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 5932ba8786d278..8687579d9349ea 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 cf28917c47a9a3..fbf4d97a89bcee 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 b4ec174a699073..c029d7db16aaf6 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 aad07728dd931b..de8e351685f7ec 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 bc8256b9773849..9d1f205f9b224b 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 260635ef14163e..158db91ebdacf3 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 5bec58b9be0c99..d4beee8484b9b2 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; + }); +});