renderFocusChart()

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);
    }
  }