Skip to content

Commit

Permalink
[ML] Explorer Chart Tweaks (#22955) (#22957)
Browse files Browse the repository at this point in the history
- The aim of this is to more clearly visualize how the timerange of the cell selected in the swimlane relates to the time span shown in the charts.
- The most important change is that the vertical date axis ticks no longer are randomly positioned by d3. Instead they are aligned with the cell interval of the swimlane. This way, the date information shown in the swimlane tooltip will always align with the date tick shown left of the emphasized area in the chart.
- The highlighted area now features a gray rounded border to resemble the styling of the selected cell in the swimlane.
- The chart also fixes where to long chart headers would wrap the "View" link to a new line.
- The x/y axis labels blackness has been reduced to reduce emphasis on the labels.
  • Loading branch information
walterra authored Sep 12, 2018
1 parent 2b1dacc commit 7a788a2
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import moment from 'moment';
// because it won't work with the jest tests
import { formatValue } from '../../formatters/format_value';
import { getSeverityWithLow } from '../../../common/util/anomaly_utils';
import { drawLineChartDots, numTicksForDateFormat } from '../../util/chart_utils';
import {
drawLineChartDots,
getTickValues,
numTicksForDateFormat,
removeLabelOverlap
} from '../../util/chart_utils';
import { TimeBuckets } from 'ui/time_buckets';
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
import { mlEscape } from '../../util/string_utils';
Expand All @@ -34,6 +39,7 @@ const CONTENT_WRAPPER_HEIGHT = 215;

export class ExplorerChart extends React.Component {
static propTypes = {
tooManyBuckets: PropTypes.bool,
seriesConfig: PropTypes.object,
mlSelectSeverityService: PropTypes.object.isRequired
}
Expand All @@ -48,6 +54,7 @@ export class ExplorerChart extends React.Component {

renderChart() {
const {
tooManyBuckets,
mlSelectSeverityService
} = this.props;

Expand Down Expand Up @@ -176,14 +183,28 @@ export class ExplorerChart extends React.Component {
timeBuckets.setInterval('auto');
const xAxisTickFormat = timeBuckets.getScaledDateFormat();

const emphasisStart = Math.max(config.selectedEarliest, config.plotEarliest);
const emphasisEnd = Math.min(config.selectedLatest, config.plotLatest);
// +1 ms to account for the ms that was substracted for query aggregations.
const interval = emphasisEnd - emphasisStart + 1;
const tickValues = getTickValues(emphasisStart, interval, config.plotEarliest, config.plotLatest);

const xAxis = d3.svg.axis().scale(lineChartXScale)
.orient('bottom')
.innerTickSize(-chartHeight)
.outerTickSize(0)
.tickPadding(10)
.ticks(numTicksForDateFormat(vizWidth, xAxisTickFormat))
.tickFormat(d => moment(d).format(xAxisTickFormat));

// With tooManyBuckets the chart would end up with no x-axis labels
// because the ticks are based on the span of the emphasis section,
// and the highlighted area spans the whole chart.
if (tooManyBuckets === false) {
xAxis.tickValues(tickValues);
} else {
xAxis.ticks(numTicksForDateFormat(vizWidth, xAxisTickFormat));
}

const yAxis = d3.svg.axis().scale(lineChartYScale)
.orient('left')
.innerTickSize(0)
Expand All @@ -196,14 +217,18 @@ export class ExplorerChart extends React.Component {

const axes = lineChartGroup.append('g');

axes.append('g')
const gAxis = axes.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + chartHeight + ')')
.call(xAxis);

axes.append('g')
.attr('class', 'y axis')
.call(yAxis);

if (tooManyBuckets === false) {
removeLabelOverlap(gAxis, emphasisStart, interval, vizWidth);
}
}

function drawLineChartHighlightedSpan() {
Expand All @@ -216,10 +241,12 @@ export class ExplorerChart extends React.Component {

lineChartGroup.append('rect')
.attr('class', 'selected-interval')
.attr('x', lineChartXScale(new Date(rectStart)))
.attr('y', 1)
.attr('width', rectWidth)
.attr('height', chartHeight - 1);
.attr('x', lineChartXScale(new Date(rectStart)) + 2)
.attr('y', 2)
.attr('rx', 3)
.attr('ry', 3)
.attr('width', rectWidth - 4)
.attr('height', chartHeight - 4);
}

function drawLineChartPaths(data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ describe('ExplorerChart', () => {

const selectedInterval = rects[1];
expect(selectedInterval.getAttribute('class')).toBe('selected-interval');
expect(+selectedInterval.getAttribute('y')).toBe(1);
expect(+selectedInterval.getAttribute('height')).toBe(169);
expect(+selectedInterval.getAttribute('y')).toBe(2);
expect(+selectedInterval.getAttribute('height')).toBe(166);

const xAxisTicks = wrapper.getDOMNode().querySelector('.x').querySelectorAll('.tick');
expect([...xAxisTicks]).toHaveLength(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function ExplorerChartsContainer({
</a>
</div>
<ExplorerChart
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ml-explorer-chart,
.ml-explorer-chart-container {
display: block;
padding-bottom: 10px;

svg {
font-size: 12px;
Expand All @@ -13,7 +14,10 @@ ml-explorer-chart,
}

rect.selected-interval {
fill: rgba(200, 200, 200, 0.25);
fill: rgba(200, 200, 200, 0.1);
stroke: #6b6b6b;
stroke-width: 2px;
stroke-opacity: 0.8;
}

rect.scheduled-event-marker {
Expand All @@ -31,12 +35,16 @@ ml-explorer-chart,
shape-rendering: crispEdges;
}

.axis .tick line.ml-tick-emphasis {
stroke: rgba(0, 0, 0, 0.2);
}

.axis text {
fill: #000;
fill: #888;
}

.axis .tick line {
stroke: rgba(0, 0, 0, 0.1);
stroke: rgba(0, 0, 0, 0.05);
stroke-width: 1px;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@

.explorer-chart-label-fields {
vertical-align: top;
max-width: calc(~"100% - 15px");
/* account 80px for the "View" link */
max-width: calc(~"100% - 80px");
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
Expand Down
127 changes: 127 additions & 0 deletions x-pack/plugins/ml/public/util/chart_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,130 @@ export function numTicksForDateFormat(axisWidth, dateFormat) {
const tickWidth = calculateTextWidth(moment().format(dateFormat), false);
return axisWidth / (1.75 * tickWidth);
}

const TICK_DIRECTION = {
NEXT: 'next',
PREVIOUS: 'previous'
};

// Based on a fixed starting timestamp and an interval, get tick values within
// the bounds of earliest and latest. This is useful for the Anomaly Explorer Charts
// to align axis ticks with the gray area resembling the swimlane cell selection.
export function getTickValues(startTimeMs, tickInterval, earliest, latest) {
const tickValues = [startTimeMs];

function addTicks(ts, operator) {
let newTick;
let addAnotherTick;

switch (operator) {
case TICK_DIRECTION.PREVIOUS:
newTick = ts - tickInterval;
addAnotherTick = newTick >= earliest;
break;
case TICK_DIRECTION.NEXT:
newTick = ts + tickInterval;
addAnotherTick = newTick <= latest;
break;
}

if (addAnotherTick) {
tickValues.push(newTick);
addTicks(newTick, operator);
}
}

addTicks(startTimeMs, TICK_DIRECTION.PREVIOUS);
addTicks(startTimeMs, TICK_DIRECTION.NEXT);

tickValues.sort();

return tickValues;
}

// This removes overlapping x-axis labels by starting off from a specific label
// that is required/wanted to show up. The code then traverses to both sides along the axis
// and decides which labels to keep or remove. All vertical tick lines will be kept visible,
// but those which still have their text label will be emphasized using the ml-tick-emphasis class.
export function removeLabelOverlap(axis, startTimeMs, tickInterval, width) {
// Put emphasis on all tick lines, will again de-emphasize the
// ones where we remove the label in the next steps.
axis.selectAll('g.tick').select('line').classed('ml-tick-emphasis', true);

function getNeighborTickFactory(operator) {
return function (ts) {
switch (operator) {
case TICK_DIRECTION.PREVIOUS:
return ts - tickInterval;
case TICK_DIRECTION.NEXT:
return ts + tickInterval;
}
};
}

function getTickDataFactory(operator) {
const getNeighborTick = getNeighborTickFactory(operator);
const fn = function (ts) {
const filteredTicks = axis.selectAll('.tick').filter(d => d === ts);

if (filteredTicks[0].length === 0) {
return false;
}

const tick = d3.selectAll(filteredTicks[0]);
const textNode = tick.select('text').node();

if (textNode === null) {
return fn(getNeighborTick(ts));
}

const tickWidth = textNode.getBBox().width;
const padding = 15;
// To get xTransform it would be nicer to use d3.transform, but that doesn't play well with JSDOM.
// So this uses a regex variant because we definitely want test coverage for the label removal.
// Once JSDOM supports SVGAnimatedTransformList we can use the simpler version.
// const xTransform = d3.transform(tick.attr('transform')).translate[0];
const xTransform = +(/translate\(\s*([^\s,)]+)[ ,]([^\s,)]+)\)/.exec(tick.attr('transform'))[1]);
const xMinOffset = xTransform - (tickWidth / 2 + padding);
const xMaxOffset = xTransform + (tickWidth / 2 + padding);

return {
tick,
ts,
xMinOffset,
xMaxOffset
};
};
return fn;
}

function checkTicks(ts, operator) {
const getTickData = getTickDataFactory(operator);
const currentTickData = getTickData(ts);

if (currentTickData === false) {
return;
}

const getNeighborTick = getNeighborTickFactory(operator);
const newTickData = getTickData(getNeighborTick(ts));

if (newTickData !== false) {
if (
newTickData.xMinOffset < 0 ||
newTickData.xMaxOffset > width ||
(newTickData.xMaxOffset > currentTickData.xMinOffset && operator === TICK_DIRECTION.PREVIOUS) ||
(newTickData.xMinOffset < currentTickData.xMaxOffset && operator === TICK_DIRECTION.NEXT)
) {
newTickData.tick.select('text').remove();
newTickData.tick.select('line').classed('ml-tick-emphasis', false);
checkTicks(currentTickData.ts, operator);
} else {
checkTicks(newTickData.ts, operator);
}
}
}

checkTicks(startTimeMs, TICK_DIRECTION.PREVIOUS);
checkTicks(startTimeMs, TICK_DIRECTION.NEXT);
}
Loading

0 comments on commit 7a788a2

Please sign in to comment.