[ML] Explorer Chart Tweaks (#22955) (#22957)

- 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.
This commit is contained in:
Walter Rafelsberger 2018-09-12 15:06:59 +02:00 committed by GitHub
parent 2b1dacccfd
commit 7a788a219d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 358 additions and 14 deletions

View file

@ -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';
@ -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
}
@ -48,6 +54,7 @@ export class ExplorerChart extends React.Component {
renderChart() {
const {
tooManyBuckets,
mlSelectSeverityService
} = this.props;
@ -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)
@ -196,7 +217,7 @@ 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);
@ -204,6 +225,10 @@ export class ExplorerChart extends React.Component {
axes.append('g')
.attr('class', 'y axis')
.call(yAxis);
if (tooManyBuckets === false) {
removeLabelOverlap(gAxis, emphasisStart, interval, vizWidth);
}
}
function drawLineChartHighlightedSpan() {
@ -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) {

View file

@ -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);

View file

@ -65,6 +65,7 @@ export function ExplorerChartsContainer({
</a>
</div>
<ExplorerChart
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>

View file

@ -1,6 +1,7 @@
ml-explorer-chart,
.ml-explorer-chart-container {
display: block;
padding-bottom: 10px;
svg {
font-size: 12px;
@ -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 {
@ -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;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -37,10 +37,18 @@ jest.mock('ui/timefilter/lib/parse_querystring',
},
}), { virtual: true });
import d3 from 'd3';
import moment from 'moment';
import { mount } from 'enzyme';
import React from 'react';
import { timefilter } from 'ui/timefilter';
import { getExploreSeriesLink } from './chart_utils';
import {
getExploreSeriesLink,
getTickValues,
removeLabelOverlap
} from './chart_utils';
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
@ -61,3 +69,175 @@ describe('getExploreSeriesLink', () => {
expect(link).toBe(expectedLink);
});
});
describe('getTickValues', () => {
test('farequote sample data', () => {
const tickValues = getTickValues(1486656000000, 14400000, 1486606500000, 1486719900000);
expect(tickValues).toEqual([
1486612800000,
1486627200000,
1486641600000,
1486656000000,
1486670400000,
1486684800000,
1486699200000,
1486713600000
]);
});
test('filebeat sample data', () => {
const tickValues = getTickValues(1486080000000, 14400000, 1485860400000, 1486314000000);
expect(tickValues).toEqual([
1485864000000,
1485878400000,
1485892800000,
1485907200000,
1485921600000,
1485936000000,
1485950400000,
1485964800000,
1485979200000,
1485993600000,
1486008000000,
1486022400000,
1486036800000,
1486051200000,
1486065600000,
1486080000000,
1486094400000,
1486108800000,
1486123200000,
1486137600000,
1486152000000,
1486166400000,
1486180800000,
1486195200000,
1486209600000,
1486224000000,
1486238400000,
1486252800000,
1486267200000,
1486281600000,
1486296000000,
1486310400000
]);
});
test('gallery sample data', () => {
const tickValues = getTickValues(1518652800000, 604800000, 1518274800000, 1519635600000);
expect(tickValues).toEqual([
1518652800000,
1519257600000
]);
});
});
describe('removeLabelOverlap', () => {
const originalGetBBox = SVGElement.prototype.getBBox;
// This resembles how ExplorerChart renders its x axis.
// We set up this boilerplate so we can then run removeLabelOverlap()
// on some "real" structure.
function axisSetup({
interval,
plotEarliest,
plotLatest,
startTimeMs,
xAxisTickFormat
}) {
const wrapper = mount(<div className="content-wrapper" />);
const node = wrapper.getDOMNode();
const chartHeight = 170;
const margin = { top: 10, right: 0, bottom: 30, left: 60 };
const svgWidth = 500;
const svgHeight = chartHeight + margin.top + margin.bottom;
const vizWidth = 500;
const chartElement = d3.select(node);
const lineChartXScale = d3.time.scale()
.range([0, vizWidth])
.domain([plotEarliest, plotLatest]);
const xAxis = d3.svg.axis().scale(lineChartXScale)
.orient('bottom')
.innerTickSize(-chartHeight)
.outerTickSize(0)
.tickPadding(10)
.tickFormat(d => moment(d).format(xAxisTickFormat));
const tickValues = getTickValues(startTimeMs, interval, plotEarliest, plotLatest);
xAxis.tickValues(tickValues);
const svg = chartElement.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight);
const axes = svg.append('g');
const gAxis = axes.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + chartHeight + ')')
.call(xAxis);
return {
gAxis,
node,
vizWidth
};
}
test('farequote sample data', () => {
const mockedGetBBox = { width: 27.21875 };
SVGElement.prototype.getBBox = () => mockedGetBBox;
const startTimeMs = 1486656000000;
const interval = 14400000;
const { gAxis, node, vizWidth } = axisSetup({
interval,
plotEarliest: 1486606500000,
plotLatest: 1486719900000,
startTimeMs,
xAxisTickFormat: 'HH:mm'
});
expect(node.getElementsByTagName('text')).toHaveLength(8);
removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth);
// at the vizWidth of 500, the most left and right tick label
// will get removed because it overflows the chart area
expect(node.getElementsByTagName('text')).toHaveLength(6);
SVGElement.prototype.getBBox = originalGetBBox;
});
test('filebeat sample data', () => {
const mockedGetBBox = { width: 85.640625 };
SVGElement.prototype.getBBox = () => mockedGetBBox;
const startTimeMs = 1486080000000;
const interval = 14400000;
const { gAxis, node, vizWidth } = axisSetup({
interval,
plotEarliest: 1485860400000,
plotLatest: 1486314000000,
startTimeMs,
xAxisTickFormat: 'YYYY-MM-DD HH:mm'
});
expect(node.getElementsByTagName('text')).toHaveLength(32);
removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth);
// In this case labels get reduced significantly because of the wider
// labels (full dates + time) and the narrow interval.
expect(node.getElementsByTagName('text')).toHaveLength(3);
SVGElement.prototype.getBBox = originalGetBBox;
});
});