[ML] Indicate multi-bucket anomalies in results dashboards (#23746)

This commit is contained in:
Pete Harverson 2018-10-03 16:09:52 +01:00 committed by GitHub
parent c993ad3996
commit 557fc7a66f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 263 additions and 65 deletions

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// Thresholds for indicating the impact of multi-bucket features in an anomaly.
export const MULTI_BUCKET_IMPACT = {
HIGH: 4,
MEDIUM: 3,
LOW: 1,
NONE: -5
};

View file

@ -11,6 +11,7 @@ import {
getSeverity,
getSeverityWithLow,
getSeverityColor,
getMultiBucketImpactLabel,
getEntityFieldName,
getEntityFieldValue,
showActualForFunction,
@ -281,6 +282,35 @@ describe('ML - anomaly utils', () => {
});
describe('getMultiBucketImpactLabel', () => {
it('returns high for 4 <= score <= 5', () => {
expect(getMultiBucketImpactLabel(4)).to.be('high');
expect(getMultiBucketImpactLabel(5)).to.be('high');
});
it('returns medium for 3 <= score < 4', () => {
expect(getMultiBucketImpactLabel(3)).to.be('medium');
expect(getMultiBucketImpactLabel(3.99)).to.be('medium');
});
it('returns low for 1 <= score < 3', () => {
expect(getMultiBucketImpactLabel(1)).to.be('low');
expect(getMultiBucketImpactLabel(2.99)).to.be('low');
});
it('returns none for -5 <= score < 1', () => {
expect(getMultiBucketImpactLabel(-5)).to.be('none');
expect(getMultiBucketImpactLabel(0.99)).to.be('none');
});
it('returns expected label when impact outside normal bounds', () => {
expect(getMultiBucketImpactLabel(10)).to.be('high');
expect(getMultiBucketImpactLabel(-10)).to.be('none');
});
});
describe('getEntityFieldName', () => {
it('returns the by field name', () => {
expect(getEntityFieldName(byEntityRecord)).to.be('airline');

View file

@ -13,6 +13,7 @@
import _ from 'lodash';
import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule';
import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact';
// List of function descriptions for which actual values from record level results should be displayed.
const DISPLAY_ACTUAL_FUNCTIONS = ['count', 'distinct_count', 'lat_long', 'mean', 'max', 'min', 'sum',
@ -75,6 +76,21 @@ export function getSeverityColor(normalizedScore) {
}
}
// Returns a label to use for the multi-bucket impact of an anomaly
// according to the value of the multi_bucket_impact field of a record,
// which ranges from -5 to +5.
export function getMultiBucketImpactLabel(multiBucketImpact) {
if (multiBucketImpact >= MULTI_BUCKET_IMPACT.HIGH) {
return 'high';
} else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.MEDIUM) {
return 'medium';
} else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.LOW) {
return 'low';
} else {
return 'none';
}
}
// Returns the name of the field to use as the entity name from the source record
// obtained from Elasticsearch. The function looks first for a by_field, then over_field,
// then partition_field, returning undefined if none of these fields are present.

View file

@ -25,11 +25,13 @@ import { formatDate } from '@elastic/eui/lib/services/format';
import { EntityCell } from './entity_cell';
import {
getMultiBucketImpactLabel,
getSeverity,
showActualForFunction,
showTypicalForFunction
} from 'plugins/ml/../common/util/anomaly_utils';
import { formatValue } from 'plugins/ml/formatters/format_value';
showTypicalForFunction,
} from '../../../common/util/anomaly_utils';
import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact';
import { formatValue } from '../../formatters/format_value';
const TIME_FIELD_NAME = 'timestamp';
@ -152,6 +154,14 @@ function getDetailsItems(anomaly, examples, filter) {
description: anomaly.jobId
});
if (source.multi_bucket_impact !== undefined &&
source.multi_bucket_impact >= MULTI_BUCKET_IMPACT.LOW) {
items.push({
title: 'multi-bucket impact',
description: getMultiBucketImpactLabel(source.multi_bucket_impact)
});
}
items.push({
title: 'probability',
description: source.probability

View file

@ -22,12 +22,20 @@ import moment from 'moment';
// don't use something like plugins/ml/../common
// because it won't work with the jest tests
import { formatValue } from '../../formatters/format_value';
import { getSeverityWithLow } from '../../../common/util/anomaly_utils';
import {
getSeverityWithLow,
getMultiBucketImpactLabel,
} from '../../../common/util/anomaly_utils';
import {
LINE_CHART_ANOMALY_RADIUS,
MULTI_BUCKET_SYMBOL_SIZE,
SCHEDULED_EVENT_SYMBOL_HEIGHT,
drawLineChartDots,
getTickValues,
numTicksForDateFormat,
removeLabelOverlap
removeLabelOverlap,
showMultiBucketAnomalyMarker,
showMultiBucketAnomalyTooltip,
} from '../../util/chart_utils';
import { TimeBuckets } from 'ui/time_buckets';
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
@ -74,8 +82,6 @@ export class ExplorerChart extends React.Component {
let vizWidth = 0;
const chartHeight = 170;
const LINE_CHART_ANOMALY_RADIUS = 7;
const SCHEDULED_EVENT_MARKER_HEIGHT = 5;
// Left margin is adjusted later for longest y-axis label.
const margin = { top: 10, right: 0, bottom: 30, left: 60 };
@ -260,11 +266,11 @@ export class ExplorerChart extends React.Component {
function drawLineChartMarkers(data) {
// Render circle markers for the points.
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps)
// Don't render dots where value=null (data gaps) or for multi-bucket anomalies.
const dots = lineChartGroup.append('g')
.attr('class', 'chart-markers')
.selectAll('.metric-value')
.data(data.filter(d => d.value !== null));
.data(data.filter(d => (d.value !== null && !showMultiBucketAnomalyMarker(d))));
// Remove dots that are no longer needed i.e. if number of chart points has decreased.
dots.exit().remove();
@ -283,12 +289,28 @@ export class ExplorerChart extends React.Component {
.attr('class', function (d) {
let markerClass = 'metric-value';
if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) {
markerClass += ' anomaly-marker ';
markerClass += getSeverityWithLow(d.anomalyScore);
markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore)}`;
}
return markerClass;
});
// Render cross symbols for any multi-bucket anomalies.
const multiBucketMarkers = lineChartGroup.select('.chart-markers').selectAll('.multi-bucket')
.data(data.filter(d => (d.anomalyScore !== null && showMultiBucketAnomalyMarker(d) === true)));
// Remove multi-bucket markers that are no longer needed
multiBucketMarkers.exit().remove();
// Update markers to new positions.
multiBucketMarkers.enter().append('path')
.attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross'))
.attr('transform', d => `translate(${lineChartXScale(d.date)}, ${lineChartYScale(d.value)})`)
.attr('class', d => `metric-value anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore)}`)
.on('mouseover', function (d) {
showLineChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
// Add rectangular markers for any scheduled events.
const scheduledEventMarkers = lineChartGroup.select('.chart-markers').selectAll('.scheduled-event-marker')
.data(data.filter(d => d.scheduledEvents !== undefined));
@ -298,14 +320,14 @@ export class ExplorerChart extends React.Component {
// Create any new markers that are needed i.e. if number of chart points has increased.
scheduledEventMarkers.enter().append('rect')
.attr('width', LINE_CHART_ANOMALY_RADIUS * 2)
.attr('height', SCHEDULED_EVENT_MARKER_HEIGHT)
.attr('height', SCHEDULED_EVENT_SYMBOL_HEIGHT)
.attr('class', 'scheduled-event-marker')
.attr('rx', 1)
.attr('ry', 1);
// Update all markers to new positions.
scheduledEventMarkers.attr('x', (d) => lineChartXScale(d.date) - LINE_CHART_ANOMALY_RADIUS)
.attr('y', (d) => lineChartYScale(d.value) - (SCHEDULED_EVENT_MARKER_HEIGHT / 2));
.attr('y', (d) => lineChartYScale(d.value) - (SCHEDULED_EVENT_SYMBOL_HEIGHT / 2));
}
@ -319,6 +341,11 @@ export class ExplorerChart extends React.Component {
const score = parseInt(marker.anomalyScore);
const displayScore = (score > 0 ? score : '< 1');
contents += ('anomaly score: ' + displayScore);
if (showMultiBucketAnomalyTooltip(marker) === true) {
contents += `<br/>multi-bucket impact: ${getMultiBucketImpactLabel(marker.multiBucketImpact)}`;
}
// Show actual/typical when available except for rare detectors.
// Rare detectors always have 1 as actual and the probability as typical.
// Exposing those values in the tooltip with actual/typical labels might irritate users.

View file

@ -209,6 +209,10 @@ export function explorerChartsContainerServiceFactory(
}
}
}
if (_.has(record, 'multi_bucket_impact')) {
chartPoint.multiBucketImpact = record.multi_bucket_impact;
}
}
});

View file

@ -64,44 +64,44 @@ ml-explorer-chart,
fill: #32a7c2;
}
circle.metric-value {
.metric-value {
opacity: 1;
fill: transparent;
stroke: #32a7c2;
stroke-width: 0px;
}
circle.anomaly-marker {
.anomaly-marker {
stroke-width: 1px;
stroke: #aaaaaa;
}
circle.anomaly-marker:hover {
.anomaly-marker:hover {
stroke-width: 6px;
stroke: #32a7c2;
}
circle.metric-value.critical {
.metric-value.critical {
fill: #fe5050;
}
circle.metric-value.major {
.metric-value.major {
fill: #ff7e00;
}
circle.metric-value.minor {
.metric-value.minor {
fill: #ffdd00;
}
circle.metric-value.warning {
.metric-value.warning {
fill: #8bc8fb;
}
circle.metric-value.low {
.metric-value.low {
fill: #d2e9f7;
}
circle.metric-value:hover {
.metric-value:hover {
stroke-width: 6px;
stroke-opacity: 0.65;
}

View file

@ -151,40 +151,47 @@
fill: rgba(204, 163, 0, 0.25);
}
circle.metric-value {
.metric-value {
opacity: 1;
fill: transparent;
stroke: #32a7c2;
stroke-width: 0px;
}
circle.anomaly-marker {
.anomaly-marker {
stroke-width: 1px;
stroke: #aaaaaa;
}
circle.metric-value.critical {
.metric-value.critical {
fill: #fe5050;
}
circle.metric-value.major {
.metric-value.major {
fill: #ff7e00;
}
circle.metric-value.minor {
.metric-value.minor {
fill: #ffdd00;
}
circle.metric-value.warning {
.metric-value.warning {
fill: #8bc8fb;
}
circle.metric-value.low {
.metric-value.low {
fill: #d2e9f7;
}
circle.metric-value:hover,
circle.anomaly-marker.highlighted {
.metric-value:hover,
.anomaly-marker.highlighted {
stroke-width: 6px;
stroke-opacity: 0.65;
stroke: #32a7c2;
}
.metric-value:hover,
.anomaly-marker.highlighted {
stroke-width: 6px;
stroke-opacity: 0.65;
stroke: #32a7c2;
@ -198,8 +205,8 @@
}
.forecast {
circle.metric-value,
circle.metric-value:hover {
.metric-value,
.metric-value:hover {
stroke: #cca300;
}
}

View file

@ -20,12 +20,20 @@ import { timefilter } from 'ui/timefilter';
import { ResizeChecker } from 'ui/resize_checker';
import { getSeverityWithLow } from 'plugins/ml/../common/util/anomaly_utils';
import {
getSeverityWithLow,
getMultiBucketImpactLabel,
} from 'plugins/ml/../common/util/anomaly_utils';
import { formatValue } from 'plugins/ml/formatters/format_value';
import {
LINE_CHART_ANOMALY_RADIUS,
MULTI_BUCKET_SYMBOL_SIZE,
SCHEDULED_EVENT_SYMBOL_HEIGHT,
drawLineChartDots,
filterAxisLabels,
numTicksForDateFormat
numTicksForDateFormat,
showMultiBucketAnomalyMarker,
showMultiBucketAnomalyTooltip,
} from 'plugins/ml/util/chart_utils';
import { TimeBuckets } from 'ui/time_buckets';
import { mlAnomaliesTableService } from 'plugins/ml/components/anomalies_table/anomalies_table_service';
@ -55,9 +63,6 @@ module.directive('mlTimeseriesChart', function () {
const svgHeight = focusHeight + contextChartHeight + swimlaneHeight + chartSpacing + margin.top + margin.bottom;
let vizWidth = svgWidth - margin.left - margin.right;
const FOCUS_CHART_ANOMALY_RADIUS = 7;
const SCHEDULED_EVENT_MARKER_HEIGHT = 5;
const ZOOM_INTERVAL_OPTIONS = [
{ duration: moment.duration(1, 'h'), label: '1h' },
{ duration: moment.duration(12, 'h'), label: '12h' },
@ -459,15 +464,15 @@ module.directive('mlTimeseriesChart', function () {
// Render circle markers for the points.
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps)
// Don't render dots where value=null (data gaps) or for multi-bucket anomalies.
const dots = d3.select('.focus-chart-markers').selectAll('.metric-value')
.data(data.filter(d => d.value !== null));
.data(data.filter(d => (d.value !== null && !showMultiBucketAnomalyMarker(d))));
// Remove dots that are no longer needed i.e. if number of chart points has decreased.
dots.exit().remove();
// Create any new dots that are needed i.e. if number of chart points has increased.
dots.enter().append('circle')
.attr('r', FOCUS_CHART_ANOMALY_RADIUS)
.attr('r', LINE_CHART_ANOMALY_RADIUS)
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
@ -479,12 +484,29 @@ module.directive('mlTimeseriesChart', function () {
.attr('class', (d) => {
let markerClass = 'metric-value';
if (_.has(d, 'anomalyScore')) {
markerClass += ' anomaly-marker ';
markerClass += getSeverityWithLow(d.anomalyScore);
markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore)}`;
}
return markerClass;
});
// Render cross symbols for any multi-bucket anomalies.
const multiBucketMarkers = d3.select('.focus-chart-markers').selectAll('.multi-bucket')
.data(data.filter(d => (d.anomalyScore !== null && showMultiBucketAnomalyMarker(d) === true)));
// Remove multi-bucket markers that are no longer needed
multiBucketMarkers.exit().remove();
// Update markers to new positions.
multiBucketMarkers.enter().append('path')
.attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross'))
.attr('transform', d => `translate(${focusXScale(d.date)}, ${focusYScale(d.value)})`)
.attr('class', d => `metric-value anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore)}`)
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
// Add rectangular markers for any scheduled events.
const scheduledEventMarkers = d3.select('.focus-chart-markers').selectAll('.scheduled-event-marker')
.data(data.filter(d => d.scheduledEvents !== undefined));
@ -494,14 +516,14 @@ module.directive('mlTimeseriesChart', function () {
// Create any new markers that are needed i.e. if number of chart points has increased.
scheduledEventMarkers.enter().append('rect')
.attr('width', FOCUS_CHART_ANOMALY_RADIUS * 2)
.attr('height', SCHEDULED_EVENT_MARKER_HEIGHT)
.attr('width', LINE_CHART_ANOMALY_RADIUS * 2)
.attr('height', SCHEDULED_EVENT_SYMBOL_HEIGHT)
.attr('class', 'scheduled-event-marker')
.attr('rx', 1)
.attr('ry', 1);
// Update all markers to new positions.
scheduledEventMarkers.attr('x', (d) => focusXScale(d.date) - FOCUS_CHART_ANOMALY_RADIUS)
scheduledEventMarkers.attr('x', (d) => focusXScale(d.date) - LINE_CHART_ANOMALY_RADIUS)
.attr('y', (d) => focusYScale(d.value) - 3);
// Plot any forecast data in scope.
@ -520,7 +542,7 @@ module.directive('mlTimeseriesChart', function () {
forecastDots.exit().remove();
// Create any new dots that are needed i.e. if number of forecast points has increased.
forecastDots.enter().append('circle')
.attr('r', FOCUS_CHART_ANOMALY_RADIUS)
.attr('r', LINE_CHART_ANOMALY_RADIUS)
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
@ -959,6 +981,10 @@ module.directive('mlTimeseriesChart', function () {
const displayScore = (score > 0 ? score : '< 1');
contents += `anomaly score: ${displayScore}<br/>`;
if (showMultiBucketAnomalyTooltip(marker) === true) {
contents += `multi-bucket impact: ${getMultiBucketImpactLabel(marker.multiBucketImpact)}<br/>`;
}
if (scope.modelPlotEnabled === false) {
// Show actual/typical when available except for rare detectors.
// Rare detectors always have 1 as actual and the probability as typical.
@ -1006,7 +1032,7 @@ module.directive('mlTimeseriesChart', function () {
}
mlChartTooltipService.show(contents, circle, {
x: FOCUS_CHART_ANOMALY_RADIUS * 2,
x: LINE_CHART_ANOMALY_RADIUS * 2,
y: 0
});
}
@ -1024,17 +1050,21 @@ module.directive('mlTimeseriesChart', function () {
// TODO - plot anomaly markers for cases where there is an anomaly due
// to the absence of data and model plot is enabled.
if (markerToSelect !== undefined) {
const selectedMarker = d3.select('.focus-chart-markers').selectAll('.focus-chart-highlighted-marker')
const selectedMarker = d3.select('.focus-chart-markers')
.selectAll('.focus-chart-highlighted-marker')
.data([markerToSelect]);
selectedMarker.enter().append('circle')
.attr('r', FOCUS_CHART_ANOMALY_RADIUS);
selectedMarker.attr('cx', (d) => { return focusXScale(d.date); })
.attr('cy', (d) => { return focusYScale(d.value); })
.attr('class', (d) => {
let markerClass = 'metric-value anomaly-marker highlighted ';
markerClass += getSeverityWithLow(d.anomalyScore);
return markerClass;
});
if (showMultiBucketAnomalyMarker(markerToSelect) === true) {
selectedMarker.enter().append('path')
.attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross'))
.attr('transform', d => `translate(${focusXScale(d.date)}, ${focusYScale(d.value)})`);
} else {
selectedMarker.enter().append('circle')
.attr('r', LINE_CHART_ANOMALY_RADIUS)
.attr('cx', d => focusXScale(d.date))
.attr('cy', d => focusYScale(d.value));
}
selectedMarker.attr('class',
d => `metric-value anomaly-marker ${getSeverityWithLow(d.anomalyScore)} highlighted`);
// Display the chart tooltip for this marker.
// Note the values of the record and marker may differ depending on the levels of aggregation.

View file

@ -91,17 +91,17 @@ export function processRecordScoreResults(scoreData) {
return bucketScoreData;
}
// Uses data from the list of anomaly records to add anomalyScore properties
// to the chartData entries for anomalous buckets.
// Uses data from the list of anomaly records to add anomalyScore,
// function, actual and typical properties, plus causes and multi-bucket
// info if applicable, to the chartData entries for anomalous buckets.
export function processDataForFocusAnomalies(
chartData,
anomalyRecords,
timeFieldName) {
// Iterate through the anomaly records, adding anomalyScore, function,
// actual and typical properties, plus causes info if applicable,
// to the chartData entries for anomalous buckets.
_.each(anomalyRecords, (record) => {
// Iterate through the anomaly records adding the
// various properties required for display.
anomalyRecords.forEach((record) => {
// Look for a chart point with the same time as the record.
// If none found, find closest time in chartData set.
@ -146,6 +146,10 @@ export function processDataForFocusAnomalies(
}
}
}
if (_.has(record, 'multi_bucket_impact')) {
chartPoint.multiBucketImpact = record.multi_bucket_impact;
}
}
}

View file

@ -9,7 +9,14 @@
import $ from 'jquery';
import d3 from 'd3';
import expect from 'expect.js';
import { chartLimits, filterAxisLabels, numTicks } from '../chart_utils';
import {
chartLimits,
filterAxisLabels,
numTicks,
showMultiBucketAnomalyMarker,
showMultiBucketAnomalyTooltip,
} from '../chart_utils';
import { MULTI_BUCKET_IMPACT } from 'plugins/ml/../common/constants/multi_bucket_impact';
describe('ML - chart utils', () => {
@ -120,4 +127,34 @@ describe('ML - chart utils', () => {
});
describe('showMultiBucketAnomalyMarker', () => {
it('returns true for points with multiBucketImpact at or above medium impact', () => {
expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).to.be(true);
expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM })).to.be(true);
});
it('returns false for points with multiBucketImpact missing or below medium impact', () => {
expect(showMultiBucketAnomalyMarker({})).to.be(false);
expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).to.be(false);
expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).to.be(false);
});
});
describe('showMultiBucketAnomalyTooltip', () => {
it('returns true for points with multiBucketImpact at or above low impact', () => {
expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).to.be(true);
expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM })).to.be(true);
expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).to.be(true);
});
it('returns false for points with multiBucketImpact missing or below medium impact', () => {
expect(showMultiBucketAnomalyTooltip({})).to.be(false);
expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).to.be(false);
});
});
});

View file

@ -8,12 +8,18 @@
import d3 from 'd3';
import { calculateTextWidth } from '../util/string_utils';
import { MULTI_BUCKET_IMPACT } from '../../common/constants/multi_bucket_impact';
import moment from 'moment';
import rison from 'rison-node';
import chrome from 'ui/chrome';
import { timefilter } from 'ui/timefilter';
export const LINE_CHART_ANOMALY_RADIUS = 7;
export const MULTI_BUCKET_SYMBOL_SIZE = 144; // In square pixels for use with d3 symbol.size
export const SCHEDULED_EVENT_SYMBOL_HEIGHT = 5;
const MAX_LABEL_WIDTH = 100;
export function chartLimits(data = []) {
@ -178,6 +184,18 @@ export function getExploreSeriesLink(series) {
return `${chrome.getBasePath()}/app/ml#/timeseriesexplorer?_g=${_g}&_a=${encodeURIComponent(_a)}`;
}
export function showMultiBucketAnomalyMarker(point) {
// TODO - test threshold with real use cases
return (point.multiBucketImpact !== undefined &&
point.multiBucketImpact >= MULTI_BUCKET_IMPACT.MEDIUM);
}
export function showMultiBucketAnomalyTooltip(point) {
// TODO - test threshold with real use cases
return (point.multiBucketImpact !== undefined &&
point.multiBucketImpact >= MULTI_BUCKET_IMPACT.LOW);
}
export function numTicks(axisWidth) {
return axisWidth / MAX_LABEL_WIDTH;
}