diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js
index 5932ba8786d27..8687579d9349e 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js
@@ -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) {
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js
index cf28917c47a9a..fbf4d97a89bce 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js
@@ -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);
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
index b4ec174a69907..c029d7db16aaf 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
@@ -65,6 +65,7 @@ export function ExplorerChartsContainer({
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
index aad07728dd931..de8e351685f7e 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
@@ -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;
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
index bc8256b977384..9d1f205f9b224 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
@@ -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;
diff --git a/x-pack/plugins/ml/public/util/chart_utils.js b/x-pack/plugins/ml/public/util/chart_utils.js
index 260635ef14163..158db91ebdacf 100644
--- a/x-pack/plugins/ml/public/util/chart_utils.js
+++ b/x-pack/plugins/ml/public/util/chart_utils.js
@@ -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);
+}
diff --git a/x-pack/plugins/ml/public/util/chart_utils.test.js b/x-pack/plugins/ml/public/util/chart_utils.test.js
index 5bec58b9be0c9..d4beee8484b9b 100644
--- a/x-pack/plugins/ml/public/util/chart_utils.test.js
+++ b/x-pack/plugins/ml/public/util/chart_utils.test.js
@@ -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(
);
+ 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;
+ });
+});