in x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js [656:1006]
renderFocusChart() {
const {
embeddableMode,
focusAggregationInterval,
focusAnnotationData: focusAnnotationDataOriginalPropValue,
focusChartData,
focusForecastData,
modelPlotEnabled,
selectedJob,
showAnnotations,
showForecast,
showModelBounds,
zoomFromFocusLoaded,
zoomToFocusLoaded,
} = this.props;
const focusAnnotationData = Array.isArray(focusAnnotationDataOriginalPropValue)
? focusAnnotationDataOriginalPropValue
: [];
if (focusChartData === undefined) {
return;
}
const data = focusChartData;
const contextYScale = this.contextYScale;
const showAnomalyPopover = this.showAnomalyPopover.bind(this);
const showFocusChartTooltip = this.showFocusChartTooltip.bind(this);
const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService);
const chartElement = d3.select(this.rootNode);
const focusChart = chartElement.select('.focus-chart');
// Update the plot interval labels.
const focusAggInt = focusAggregationInterval.expression;
const bucketSpan = selectedJob.analysis_config.bucket_span;
chartElement.select('.zoom-aggregation-interval').text(
i18n.translate('xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomAggregationIntervalLabel', {
defaultMessage: '(aggregation interval: {focusAggInt}, bucket span: {bucketSpan})',
values: { focusAggInt, bucketSpan },
})
);
// Render the axes.
// Calculate the x axis domain.
// Elasticsearch aggregation returns points at start of bucket,
// so set the x-axis min to the start of the first aggregation interval,
// and the x-axis max to the end of the last aggregation interval.
if (zoomFromFocusLoaded === undefined || zoomToFocusLoaded === undefined) {
return;
}
const bounds = {
min: moment(zoomFromFocusLoaded.getTime()),
max: moment(zoomToFocusLoaded.getTime()),
};
const aggMs = focusAggregationInterval.asMilliseconds();
const earliest = moment(Math.floor(bounds.min.valueOf() / aggMs) * aggMs);
const latest = moment(Math.ceil(bounds.max.valueOf() / aggMs) * aggMs);
this.focusXScale.domain([earliest.toDate(), latest.toDate()]);
// Calculate the y-axis domain.
if (
focusChartData.length > 0 ||
(focusForecastData !== undefined && focusForecastData.length > 0)
) {
if (this.fieldFormat !== undefined) {
this.focusYAxis.tickFormat((d) => this.fieldFormat.convert(d, 'text'));
} else {
// Use default tick formatter.
this.focusYAxis.tickFormat(null);
}
// Calculate the min/max of the metric data and the forecast data.
let yMin = 0;
let yMax = 0;
let combinedData = data;
if (showForecast && focusForecastData !== undefined && focusForecastData.length > 0) {
combinedData = data.concat(focusForecastData);
}
yMin = d3.min(combinedData, (d) => {
let metricValue = d.value;
if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
// If an anomaly coincides with a gap in the data, use the anomaly actual value.
metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
}
if (d.lower !== undefined) {
if (metricValue !== null && metricValue !== undefined) {
return Math.min(metricValue, d.lower);
} else {
// Set according to the minimum of the lower of the model plot results.
return d.lower;
}
}
// metricValue is undefined for scheduled events when there is no source data.
return metricValue || 0;
});
yMax = d3.max(combinedData, (d) => {
let metricValue = d.value;
if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
// If an anomaly coincides with a gap in the data, use the anomaly actual value.
metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
}
// metricValue is undefined for scheduled events when there is no source data.
return d.upper !== undefined ? Math.max(metricValue, d.upper) : metricValue || 0;
});
if (yMax === yMin) {
if (
this.contextYScale.domain()[0] !== contextYScale.domain()[1] &&
yMin >= contextYScale.domain()[0] &&
yMax <= contextYScale.domain()[1]
) {
// Set the focus chart limits to be the same as the context chart.
yMin = contextYScale.domain()[0];
yMax = contextYScale.domain()[1];
} else {
yMin -= yMin * 0.05;
yMax += yMax * 0.05;
}
}
// if annotations are present, we extend yMax to avoid overlap
// between annotation labels, chart lines and anomalies.
if (showAnnotations && focusAnnotationData && focusAnnotationData.length > 0) {
const levels = getAnnotationLevels(focusAnnotationData);
const maxLevel = d3.max(Object.keys(levels).map((key) => levels[key]));
// TODO needs revisiting to be a more robust normalization
yMax += Math.abs(yMax - yMin) * ((maxLevel + 1) / 5);
}
this.focusYScale.domain([yMin, yMax]);
} else {
// Display 10 unlabelled ticks.
this.focusYScale.domain([0, 10]);
this.focusYAxis.tickFormat('');
}
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = this.getTimeBuckets();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
focusChart.select('.x.axis').call(
this.focusXAxis
.ticks(numTicksForDateFormat(this.vizWidth), xAxisTickFormat)
.tickFormat((d) => {
return moment(d).format(xAxisTickFormat);
})
);
focusChart.select('.y.axis').call(this.focusYAxis);
filterAxisLabels(focusChart.select('.x.axis'), this.vizWidth);
// Render the bounds area and values line.
if (modelPlotEnabled === true) {
focusChart
.select('.area.bounds')
.attr('d', this.focusBoundedArea(data))
.classed('hidden', !showModelBounds);
}
const { focusChartHeight: focusChartIncoming, focusHeight: focusHeightIncoming } = this.props
.svgHeight
? getChartHeights(this.props.svgHeight)
: {};
renderAnnotations(
focusChart,
focusAnnotationData,
focusZoomPanelHeight,
focusChartIncoming ?? focusChartHeight,
this.focusXScale,
showAnnotations,
showFocusChartTooltip,
hideFocusChartTooltip,
this.props.annotationUpdatesService
);
// disable brushing (creation of annotations) when annotations aren't shown or when in embeddable mode
focusChart
.select('.ml-annotation__brush')
.style('display', !showAnnotations || embeddableMode ? 'none' : null);
focusChart.select('.values-line').attr('d', this.focusValuesLine(data));
drawLineChartDots(data, focusChart, this.focusValuesLine);
// Render circle markers for the points.
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps, with no anomalies)
// or for multi-bucket anomalies.
const dots = chartElement
.select('.focus-chart-markers')
.selectAll('.metric-value')
.data(
data.filter(
(d) =>
(d.value !== null || typeof d.anomalyScore === 'number') &&
!showMultiBucketAnomalyMarker(d)
)
);
const that = this;
// 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', LINE_CHART_ANOMALY_RADIUS)
.on('click', function (d) {
d3.event.preventDefault();
if (d.anomalyScore === undefined) return;
showAnomalyPopover(d, this);
})
.on('mouseover', function (d) {
// Show the tooltip only if the actions menu isn't active
if (that.state.popoverData === null) {
showFocusChartTooltip(d, this);
}
})
.on('mouseout', () => this.props.tooltipService.hide());
// Update all dots to new positions.
dots
.attr('cx', (d) => {
return this.focusXScale(d.date);
})
.attr('cy', (d) => {
return this.focusYScale(d.value);
})
.attr('data-test-subj', (d) => (d.anomalyScore !== undefined ? 'mlAnomalyMarker' : undefined))
.attr('class', (d) => {
let markerClass = 'metric-value';
if (d.anomalyScore !== undefined) {
markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore).id}`;
}
return markerClass;
});
// Render cross symbols for any multi-bucket anomalies.
const multiBucketMarkers = chartElement
.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();
// Add any new markers that are needed i.e. if number of multi-bucket points has increased.
multiBucketMarkers
.enter()
.append('path')
.attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross'))
.on('click', function (d) {
d3.event.preventDefault();
if (d.anomalyScore === undefined) return;
showAnomalyPopover(d, this);
})
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => this.props.tooltipService.hide());
// Update all markers to new positions.
multiBucketMarkers
.attr(
'transform',
(d) => `translate(${this.focusXScale(d.date)}, ${this.focusYScale(d.value)})`
)
.attr('data-test-subj', 'mlAnomalyMarker')
.attr('class', (d) => `anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore).id}`);
// Add rectangular markers for any scheduled events.
const scheduledEventMarkers = chartElement
.select('.focus-chart-markers')
.selectAll('.scheduled-event-marker')
.data(data.filter((d) => d.scheduledEvents !== undefined));
// Remove markers that are no longer needed i.e. if number of chart points has decreased.
scheduledEventMarkers.exit().remove();
// Create any new markers that are needed i.e. if number of chart points has increased.
scheduledEventMarkers
.enter()
.append('rect')
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => hideFocusChartTooltip())
.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) => this.focusXScale(d.date) - LINE_CHART_ANOMALY_RADIUS)
.attr('y', (d) => {
const focusYValue = this.focusYScale(d.value);
return isNaN(focusYValue) ? -(focusHeightIncoming ?? focusHeight) - 3 : focusYValue - 3;
});
// Plot any forecast data in scope.
if (focusForecastData !== undefined) {
focusChart
.select('.area.forecast')
.attr('d', this.focusBoundedArea(focusForecastData))
.classed('hidden', !showForecast);
focusChart
.select('.values-line.forecast')
.attr('d', this.focusValuesLine(focusForecastData))
.classed('hidden', !showForecast);
const forecastDots = chartElement
.select('.focus-chart-markers.forecast')
.selectAll('.metric-value')
.data(focusForecastData);
// Remove dots that are no longer needed i.e. if number of forecast points has decreased.
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', LINE_CHART_ANOMALY_RADIUS)
.on('mouseover', function (d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => this.props.tooltipService.hide());
// Update all dots to new positions.
forecastDots
.attr('cx', (d) => {
return this.focusXScale(d.date);
})
.attr('cy', (d) => {
return this.focusYScale(d.value);
})
.attr('class', 'metric-value')
.classed('hidden', !showForecast);
}
}