github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/views/cluster/util/graphs.ts (about)

     1  // Copyright 2018 The Cockroach Authors.
     2  //
     3  // Use of this software is governed by the Business Source License
     4  // included in the file licenses/BSL.txt.
     5  //
     6  // As of the Change Date specified in that file, in accordance with
     7  // the Business Source License, use of this software will be governed
     8  // by the Apache License, Version 2.0, included in the file
     9  // licenses/APL.txt.
    10  
    11  import React from "react";
    12  import _ from "lodash";
    13  import * as nvd3 from "nvd3";
    14  import * as d3 from "d3";
    15  import moment from "moment";
    16  
    17  import * as protos from "src/js/protos";
    18  import { NanoToMilli } from "src/util/convert";
    19  import {  DurationFitScale, BytesFitScale, ComputeByteScale, ComputeDurationScale} from "src/util/format";
    20  
    21  import {
    22    MetricProps, AxisProps, AxisUnits, QueryTimeInfo,
    23  } from "src/views/shared/components/metricQuery";
    24  
    25  type TSResponse = protos.cockroach.ts.tspb.TimeSeriesQueryResponse;
    26  
    27  // Global set of colors for graph series.
    28  const seriesPalette = [
    29    "#475872", "#FFCD02", "#F16969", "#4E9FD1", "#49D990", "#D77FBF", "#87326D", "#A3415B",
    30    "#B59153", "#C9DB6D", "#203D9B", "#748BF2", "#91C8F2", "#FF9696", "#EF843C", "#DCCD4B",
    31  ];
    32  
    33  // Chart margins to match design.
    34  export const CHART_MARGINS: nvd3.Margin = {top: 30, right: 20, bottom: 20, left: 55};
    35  
    36  // Maximum number of series we will show in the legend. If there are more we hide the legend.
    37  const MAX_LEGEND_SERIES: number = 4;
    38  
    39  // The number of ticks to display on a Y axis.
    40  const Y_AXIS_TICK_COUNT: number = 3;
    41  
    42  // The number of ticks to display on an X axis.
    43  const X_AXIS_TICK_COUNT: number = 10;
    44  
    45  // A tuple of numbers for the minimum and maximum values of an axis.
    46  type Extent = [number, number];
    47  
    48  /**
    49   * AxisDomain is a class that describes the domain of a graph axis; this
    50   * includes the minimum/maximum extend, tick values, and formatting information
    51   * for axis values as displayed in various contexts.
    52   */
    53  class AxisDomain {
    54    // the values at the ends of the axis.
    55    extent: Extent;
    56    // numbers at which an intermediate tick should be displayed on the axis.
    57    ticks: number[] = [0, 1];
    58    // label returns the label for the axis.
    59    label: string = "";
    60    // tickFormat returns a function used to format the tick values for display.
    61    tickFormat: (n: number) => string = _.identity;
    62    // guideFormat returns a function used to format the axis values in the
    63    // chart's interactive guideline.
    64    guideFormat: (n: number) => string = _.identity;
    65  
    66    // constructs a new AxisDomain with the given minimum and maximum value, with
    67    // ticks placed at intervals of the given increment in between the min and
    68    // max. Ticks are always "aligned" to values that are even multiples of
    69    // increment. Min and max are also aligned by default - the aligned min will
    70    // be <= the provided min, while the aligned max will be >= the provided max.
    71    constructor(extent: Extent, increment: number, alignMinMax: boolean = true) {
    72      const min = extent[0];
    73      const max = extent[1];
    74      if (alignMinMax) {
    75        const alignedMin = min - min % increment;
    76        let alignedMax = max;
    77        if (max % increment !== 0) {
    78          alignedMax = max - max % increment + increment;
    79        }
    80        this.extent = [alignedMin, alignedMax];
    81      } else {
    82        this.extent = extent;
    83      }
    84  
    85      this.ticks = [];
    86      for (let nextTick = min - min % increment + increment;
    87           nextTick < this.extent[1];
    88           nextTick += increment) {
    89        this.ticks.push(nextTick);
    90      }
    91    }
    92  }
    93  
    94  const countIncrementTable = [0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 1.0];
    95  
    96  // computeNormalizedIncrement computes a human-friendly increment between tick
    97  // values on an axis with a range of the given size. The provided size is taken
    98  // to be the minimum range needed to display all values present on the axis.
    99  // The increment is computed by dividing this minimum range into the correct
   100  // number of segments for the supplied tick count, and then increasing this
   101  // increment to the nearest human-friendly increment.
   102  //
   103  // "Human-friendly" increments are taken from the supplied countIncrementTable,
   104  // which should include decimal values between 0 and 1.
   105  function computeNormalizedIncrement(
   106    range: number, incrementTbl: number[] = countIncrementTable,
   107  ) {
   108    if (range === 0) {
   109      throw new Error("cannot compute tick increment with zero range");
   110    }
   111  
   112    let rawIncrement = range / (Y_AXIS_TICK_COUNT + 1);
   113    // Compute X such that 0 <= rawIncrement/10^x <= 1
   114    let x = 0;
   115    while (rawIncrement > 1) {
   116      x++;
   117      rawIncrement = rawIncrement / 10;
   118    }
   119    const normalizedIncrementIdx = _.sortedIndex(incrementTbl, rawIncrement);
   120    return incrementTbl[normalizedIncrementIdx] * Math.pow(10, x);
   121  }
   122  
   123  function computeAxisDomain(extent: Extent, factor: number = 1): AxisDomain {
   124    const range = extent[1] - extent[0];
   125  
   126    // Compute increment on min/max after conversion to the appropriate prefix unit.
   127    const increment = computeNormalizedIncrement(range / factor);
   128  
   129    // Create axis domain by multiplying computed increment by prefix factor.
   130    const axisDomain = new AxisDomain(extent, increment * factor);
   131  
   132    // If the tick increment is fractional (e.g. 0.2), we display a decimal
   133    // point. For non-fractional increments, we display with no decimal points
   134    // but with a metric prefix for large numbers (i.e. 1000 will display as "1k")
   135    let unitFormat: (v: number) => string;
   136    if (Math.floor(increment) !== increment) {
   137      unitFormat = d3.format(".1f");
   138    } else {
   139      unitFormat = d3.format("s");
   140    }
   141    axisDomain.tickFormat = (v: number) => unitFormat(v / factor);
   142  
   143    return axisDomain;
   144  }
   145  
   146  function ComputeCountAxisDomain(extent: Extent): AxisDomain {
   147    const axisDomain = computeAxisDomain(extent);
   148  
   149    // For numbers larger than 1, the tooltip displays fractional values with
   150    // metric multiplicative prefixes (e.g. kilo, mega, giga). For numbers smaller
   151    // than 1, we simply display the fractional value without converting to a
   152    // fractional metric prefix; this is because the use of fractional metric
   153    // prefixes (i.e. milli, micro, nano) have proved confusing to users.
   154    const metricFormat = d3.format(".4s");
   155    const decimalFormat = d3.format(".4f");
   156    axisDomain.guideFormat = (n: number) => {
   157      if (n < 1) {
   158        return decimalFormat(n);
   159      }
   160      return metricFormat(n);
   161    };
   162  
   163    return axisDomain;
   164  }
   165  
   166  function ComputeByteAxisDomain(extent: Extent): AxisDomain {
   167    // Compute an appropriate unit for the maximum value to be displayed.
   168    const scale = ComputeByteScale(extent[1]);
   169    const prefixFactor = scale.value;
   170  
   171    const axisDomain = computeAxisDomain(extent, prefixFactor);
   172  
   173    axisDomain.label = scale.units;
   174  
   175    axisDomain.guideFormat = BytesFitScale(scale.units);
   176    return axisDomain;
   177  }
   178  
   179  function ComputeDurationAxisDomain(extent: Extent): AxisDomain {
   180    const scale = ComputeDurationScale(extent[1]);
   181    const prefixFactor = scale.value;
   182  
   183    const axisDomain = computeAxisDomain(extent, prefixFactor);
   184  
   185    axisDomain.label = scale.units;
   186  
   187    axisDomain.guideFormat = DurationFitScale(scale.units);
   188    return axisDomain;
   189  }
   190  
   191  const percentIncrementTable = [0.25, 0.5, 0.75, 1.0];
   192  
   193  function ComputePercentageAxisDomain(
   194    min: number, max: number,
   195  ) {
   196    const range = max - min;
   197    const increment = computeNormalizedIncrement(range, percentIncrementTable);
   198    const axisDomain = new AxisDomain([min, max], increment);
   199    axisDomain.label = "percentage";
   200    axisDomain.tickFormat = d3.format(".0%");
   201    axisDomain.guideFormat = d3.format(".2%");
   202    return axisDomain;
   203  }
   204  
   205  const timeIncrementDurations = [
   206    moment.duration(1, "m"),
   207    moment.duration(5, "m"),
   208    moment.duration(10, "m"),
   209    moment.duration(15, "m"),
   210    moment.duration(30, "m"),
   211    moment.duration(1, "h"),
   212    moment.duration(2, "h"),
   213    moment.duration(3, "h"),
   214    moment.duration(6, "h"),
   215    moment.duration(12, "h"),
   216    moment.duration(24, "h"),
   217    moment.duration(1, "week"),
   218  ];
   219  const timeIncrements = _.map(timeIncrementDurations, (inc) => inc.asMilliseconds());
   220  
   221  function ComputeTimeAxisDomain(extent: Extent): AxisDomain {
   222    // Compute increment; for time scales, this is taken from a table of allowed
   223    // values.
   224    let increment = 0;
   225    {
   226      const rawIncrement = (extent[1] - extent[0]) / (X_AXIS_TICK_COUNT + 1);
   227      // Compute X such that 0 <= rawIncrement/10^x <= 1
   228      const tbl = timeIncrements;
   229      let normalizedIncrementIdx = _.sortedIndex(tbl, rawIncrement);
   230      if (normalizedIncrementIdx === tbl.length) {
   231        normalizedIncrementIdx--;
   232      }
   233      increment = tbl[normalizedIncrementIdx];
   234    }
   235  
   236    // Do not normalize min/max for time axis.
   237    const axisDomain = new AxisDomain(extent, increment, false);
   238  
   239    axisDomain.label = "time";
   240  
   241    let tickDateFormatter: (d: Date) => string;
   242    if (increment < moment.duration(24, "hours").asMilliseconds()) {
   243      tickDateFormatter = d3.time.format.utc("%H:%M");
   244    } else {
   245      tickDateFormatter = d3.time.format.utc("%m/%d %H:%M");
   246    }
   247    axisDomain.tickFormat = (n: number) => {
   248      return tickDateFormatter(new Date(n));
   249    };
   250  
   251    axisDomain.guideFormat = (num) => {
   252      return moment(num).utc().format("HH:mm:ss [<span class=\"legend-subtext\">on</span>] MMM Do, YYYY");
   253    };
   254    return axisDomain;
   255  }
   256  
   257  function calculateYAxisDomain(axisUnits: AxisUnits, data: TSResponse): AxisDomain {
   258    const resultDatapoints = _.flatMap(data.results, (result) => _.map(result.datapoints, (dp) => dp.value));
   259    // TODO(couchand): Remove these random datapoints when NVD3 is gone.
   260    const allDatapoints = resultDatapoints.concat([0, 1]);
   261    const yExtent = d3.extent(allDatapoints);
   262  
   263    switch (axisUnits) {
   264      case AxisUnits.Bytes:
   265        return ComputeByteAxisDomain(yExtent);
   266      case AxisUnits.Duration:
   267        return ComputeDurationAxisDomain(yExtent);
   268      case AxisUnits.Percentage:
   269        return ComputePercentageAxisDomain(yExtent[0], yExtent[1]);
   270      default:
   271        return ComputeCountAxisDomain(yExtent);
   272    }
   273  }
   274  
   275  function calculateXAxisDomain(timeInfo: QueryTimeInfo): AxisDomain {
   276    const xExtent: Extent = [NanoToMilli(timeInfo.start.toNumber()), NanoToMilli(timeInfo.end.toNumber())];
   277    return ComputeTimeAxisDomain(xExtent);
   278  }
   279  
   280  type formattedSeries = {
   281    values: protos.cockroach.ts.tspb.ITimeSeriesDatapoint[],
   282    key: string,
   283    area: boolean,
   284    fillOpacity: number,
   285  };
   286  
   287  function formatMetricData(
   288    metrics: React.ReactElement<MetricProps>[],
   289    data: TSResponse,
   290  ): formattedSeries[] {
   291    const formattedData: formattedSeries[] = [];
   292  
   293    _.each(metrics, (s, idx) => {
   294      const result = data.results[idx];
   295      if (result && !_.isEmpty(result.datapoints)) {
   296        formattedData.push({
   297          values: result.datapoints,
   298          key: s.props.title || s.props.name,
   299          area: true,
   300          fillOpacity: 0.1,
   301        });
   302      }
   303    });
   304  
   305    return formattedData;
   306  }
   307  
   308  function filterInvalidDatapoints(
   309    formattedData: formattedSeries[],
   310    timeInfo: QueryTimeInfo,
   311  ): formattedSeries[] {
   312    return _.map(formattedData, (datum) => {
   313      // Drop any returned points at the beginning that have a lower timestamp
   314      // than the explicitly queried domain. This works around a bug in NVD3
   315      // which causes the interactive guideline to highlight the wrong points.
   316      // https://github.com/novus/nvd3/issues/1913
   317      const filteredValues = _.dropWhile(datum.values, (dp) => {
   318        return dp.timestamp_nanos.toNumber() < timeInfo.start.toNumber();
   319      });
   320  
   321      return {
   322        ...datum,
   323        values: filteredValues,
   324      };
   325    });
   326  }
   327  
   328  export function InitLineChart(chart: nvd3.LineChart) {
   329      chart
   330        .x((d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => new Date(NanoToMilli(d && d.timestamp_nanos.toNumber())))
   331        .y((d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => d && d.value)
   332        .useInteractiveGuideline(true)
   333        .showLegend(true)
   334        .showYAxis(true)
   335        .color(seriesPalette)
   336        .margin(CHART_MARGINS);
   337      chart.xAxis
   338        .showMaxMin(false);
   339      chart.yAxis
   340        .showMaxMin(true)
   341        .axisLabelDistance(-10);
   342  }
   343  
   344  /**
   345   * ConfigureLineChart renders the given NVD3 chart with the updated data.
   346   */
   347  export function ConfigureLineChart(
   348    chart: nvd3.LineChart,
   349    svgEl: SVGElement,
   350    metrics: React.ReactElement<MetricProps>[],
   351    axis: React.ReactElement<AxisProps>,
   352    data: TSResponse,
   353    timeInfo: QueryTimeInfo,
   354  ) {
   355    chart.showLegend(metrics.length > 1 && metrics.length <= MAX_LEGEND_SERIES);
   356    let formattedData: formattedSeries[];
   357    let xAxisDomain, yAxisDomain: AxisDomain;
   358  
   359    if (data) {
   360      const formattedRaw = formatMetricData(metrics, data);
   361      formattedData = filterInvalidDatapoints(formattedRaw, timeInfo);
   362  
   363      xAxisDomain = calculateXAxisDomain(timeInfo);
   364      yAxisDomain = calculateYAxisDomain(axis.props.units, data);
   365  
   366      chart.yDomain(yAxisDomain.extent);
   367      if (axis.props.label && yAxisDomain.label) {
   368        chart.yAxis.axisLabel(`${axis.props.label} (${yAxisDomain.label})`);
   369      } else if (axis.props.label) {
   370        chart.yAxis.axisLabel(axis.props.label);
   371      } else {
   372        chart.yAxis.axisLabel(yAxisDomain.label);
   373      }
   374      chart.xDomain(xAxisDomain.extent);
   375  
   376      chart.yAxis.tickFormat(yAxisDomain.tickFormat);
   377      chart.interactiveLayer.tooltip.valueFormatter(yAxisDomain.guideFormat);
   378      chart.xAxis.tickFormat(xAxisDomain.tickFormat);
   379      chart.interactiveLayer.tooltip.headerFormatter(xAxisDomain.guideFormat);
   380  
   381      // always set the tick values to the lowest axis value, the highest axis
   382      // value, and one value in between
   383      chart.yAxis.tickValues(yAxisDomain.ticks);
   384      chart.xAxis.tickValues(xAxisDomain.ticks);
   385    }
   386    try {
   387      d3.select(svgEl)
   388        .datum(formattedData)
   389        .transition().duration(500)
   390        .call(chart);
   391  
   392      // Reduce radius of circles in the legend, if present. This is done through
   393      // d3 because it is not exposed as an option by NVD3.
   394      d3.select(svgEl).selectAll("circle").attr("r", 3);
   395    } catch (e) {
   396      console.log("Error rendering graph: ", e);
   397    }
   398  }
   399  
   400  /**
   401   * ConfigureLinkedGuide renders the linked guideline for a chart.
   402   */
   403  export function ConfigureLinkedGuideline(
   404    chart: nvd3.LineChart,
   405    svgEl: SVGElement,
   406    axis: React.ReactElement<AxisProps>,
   407    data: TSResponse,
   408    hoverTime: moment.Moment,
   409  ) {
   410    if (data) {
   411      const xScale = chart.xAxis.scale();
   412      const yScale = chart.yAxis.scale();
   413      const yAxisDomain = calculateYAxisDomain(axis.props.units, data);
   414      const yExtent: Extent = data ? [yScale(yAxisDomain.extent[0]), yScale(yAxisDomain.extent[1])] : [0, 1];
   415      updateLinkedGuideline(svgEl, xScale, yExtent, hoverTime);
   416    }
   417  }
   418  
   419  // updateLinkedGuideline is responsible for maintaining "linked" guidelines on
   420  // all other graphs on the page; a "linked" guideline highlights the same X-axis
   421  // coordinate on different graphs currently visible on the same page. This
   422  // allows the user to visually correlate a single X-axis coordinate across
   423  // multiple visible graphs.
   424  function updateLinkedGuideline(svgEl: SVGElement, x: d3.scale.Linear<number, number>, yExtent: Extent, hoverTime?: moment.Moment) {
   425    // Construct a data array for use by d3; this allows us to use d3's
   426    // "enter()/exit()" functions to cleanly add and remove the guideline.
   427    const data = !_.isNil(hoverTime) ? [x(hoverTime.valueOf())] : [];
   428  
   429    // Linked guideline will be inserted inside of the "nv-wrap" element of the
   430    // nvd3 graph. This element has several translations applied to it by nvd3
   431    // which allow us to easily display the linked guideline at the correct
   432    // position.
   433    const wrapper = d3.select(svgEl).select(".nv-wrap");
   434    if (wrapper.empty()) {
   435      // In cases where no data is available for a chart, it will not have
   436      // an "nv-wrap" element and thus should not get a linked guideline.
   437      return;
   438    }
   439  
   440    const container = wrapper.selectAll("g.linked-guideline__container")
   441      .data(data);
   442  
   443    // If there is no guideline on the currently hovered graph, data is empty
   444    // and this exit statement will remove the linked guideline from this graph
   445    // if it is already present. This occurs, for example, when the user moves
   446    // the mouse off of a graph.
   447    container.exit().remove();
   448  
   449    // If there is a guideline on the currently hovered graph, this enter
   450    // statement will add a linked guideline element to the current graph (if it
   451    // does not already exist).
   452    container.enter()
   453      .append("g")
   454        .attr("class", "linked-guideline__container")
   455        .append("line")
   456          .attr("class", "linked-guideline__line");
   457  
   458    // Update linked guideline (if present) to match the necessary attributes of
   459    // the current guideline.
   460    container.select(".linked-guideline__line")
   461      .attr("x1", (d) => d)
   462      .attr("x2", (d) => d)
   463      .attr("y1", () => yExtent[0])
   464      .attr("y2", () => yExtent[1]);
   465  }