Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Explorer Chart Tweaks #22955

Merged
merged 14 commits into from
Sep 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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