github.com/hernad/nomad@v1.6.112/ui/app/components/line-chart.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  import Component from '@glimmer/component';
     7  import { tracked } from '@glimmer/tracking';
     8  import { action } from '@ember/object';
     9  import { schedule, next } from '@ember/runloop';
    10  import d3 from 'd3-selection';
    11  import d3Scale from 'd3-scale';
    12  import d3Axis from 'd3-axis';
    13  import d3Array from 'd3-array';
    14  import d3Format from 'd3-format';
    15  import d3TimeFormat from 'd3-time-format';
    16  import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
    17  import uniquely from 'nomad-ui/utils/properties/uniquely';
    18  
    19  // Returns a new array with the specified number of points linearly
    20  // distributed across the bounds
    21  const lerp = ([low, high], numPoints) => {
    22    const step = (high - low) / (numPoints - 1);
    23    const arr = [];
    24    for (var i = 0; i < numPoints; i++) {
    25      arr.push(low + step * i);
    26    }
    27    return arr;
    28  };
    29  
    30  // Round a number or an array of numbers
    31  const nice = (val) => (val instanceof Array ? val.map(nice) : Math.round(val));
    32  
    33  const defaultXScale = (data, yAxisOffset, xProp, timeseries) => {
    34    const scale = timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
    35    const domain = data.length ? d3Array.extent(data, (d) => d[xProp]) : [0, 1];
    36  
    37    scale.rangeRound([10, yAxisOffset]).domain(domain);
    38  
    39    return scale;
    40  };
    41  
    42  const defaultYScale = (data, xAxisOffset, yProp) => {
    43    let max = d3Array.max(data, (d) => d[yProp]) || 1;
    44    if (max > 1) {
    45      max = nice(max);
    46    }
    47  
    48    return d3Scale.scaleLinear().rangeRound([xAxisOffset, 10]).domain([0, max]);
    49  };
    50  
    51  export default class LineChart extends Component {
    52    /** Args
    53      data = null;
    54      xProp = null;
    55      yProp = null;
    56      curve = 'linear';
    57      title = 'Line Chart';
    58      description = null;
    59      timeseries = false;
    60      activeAnnotation = null;
    61      onAnnotationClick() {}
    62      xFormat;
    63      yFormat;
    64      xScale;
    65      yScale;
    66    */
    67  
    68    @tracked width = 0;
    69    @tracked height = 0;
    70    @tracked isActive = false;
    71    @tracked activeDatum = null;
    72    @tracked activeData = [];
    73    @tracked tooltipPosition = null;
    74    @tracked element = null;
    75    @tracked ready = false;
    76  
    77    @uniquely('title') titleId;
    78    @uniquely('desc') descriptionId;
    79  
    80    get xProp() {
    81      return this.args.xProp || 'time';
    82    }
    83    get yProp() {
    84      return this.args.yProp || 'value';
    85    }
    86    get data() {
    87      if (!this.args.data) return [];
    88      if (this.args.dataProp) {
    89        return this.args.data.mapBy(this.args.dataProp).flat();
    90      }
    91      return this.args.data;
    92    }
    93    get curve() {
    94      return this.args.curve || 'linear';
    95    }
    96  
    97    @action
    98    xFormat(timeseries) {
    99      if (this.args.xFormat) return this.args.xFormat;
   100      return timeseries
   101        ? d3TimeFormat.timeFormat('%b %d, %H:%M')
   102        : d3Format.format(',');
   103    }
   104  
   105    @action
   106    yFormat() {
   107      if (this.args.yFormat) return this.args.yFormat;
   108      return d3Format.format(',.2~r');
   109    }
   110  
   111    get activeDatumLabel() {
   112      const datum = this.activeDatum;
   113  
   114      if (!datum) return undefined;
   115  
   116      const x = datum[this.xProp];
   117      return this.xFormat(this.args.timeseries)(x);
   118    }
   119  
   120    get activeDatumValue() {
   121      const datum = this.activeDatum;
   122  
   123      if (!datum) return undefined;
   124  
   125      const y = datum[this.yProp];
   126      return this.yFormat()(y);
   127    }
   128  
   129    @styleString
   130    get tooltipStyle() {
   131      return this.tooltipPosition;
   132    }
   133  
   134    get xScale() {
   135      const fn = this.args.xScale || defaultXScale;
   136      return fn(this.data, this.yAxisOffset, this.xProp, this.args.timeseries);
   137    }
   138  
   139    get xRange() {
   140      const { xProp, data } = this;
   141      const range = d3Array.extent(data, (d) => d[xProp]);
   142      const formatter = this.xFormat(this.args.timeseries);
   143  
   144      return range.map(formatter);
   145    }
   146  
   147    get yRange() {
   148      const yProp = this.yProp;
   149      const range = d3Array.extent(this.data, (d) => d[yProp]);
   150      const formatter = this.yFormat();
   151  
   152      return range.map(formatter);
   153    }
   154  
   155    get yScale() {
   156      const fn = this.args.yScale || defaultYScale;
   157      return fn(this.data, this.xAxisOffset, this.yProp);
   158    }
   159  
   160    get xAxis() {
   161      const formatter = this.xFormat(this.args.timeseries);
   162  
   163      return d3Axis
   164        .axisBottom()
   165        .scale(this.xScale)
   166        .ticks(5)
   167        .tickFormat(formatter);
   168    }
   169  
   170    get yTicks() {
   171      const height = this.xAxisOffset;
   172      const tickCount = Math.ceil(height / 120) * 2 + 1;
   173      const domain = this.yScale.domain();
   174      const ticks = lerp(domain, tickCount);
   175      return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
   176    }
   177  
   178    get yAxis() {
   179      const formatter = this.yFormat();
   180  
   181      return d3Axis
   182        .axisRight()
   183        .scale(this.yScale)
   184        .tickValues(this.yTicks)
   185        .tickFormat(formatter);
   186    }
   187  
   188    get yGridlines() {
   189      // The first gridline overlaps the x-axis, so remove it
   190      const [, ...ticks] = this.yTicks;
   191  
   192      return d3Axis
   193        .axisRight()
   194        .scale(this.yScale)
   195        .tickValues(ticks)
   196        .tickSize(-this.canvasDimensions.width)
   197        .tickFormat('');
   198    }
   199  
   200    get xAxisHeight() {
   201      // Avoid divide by zero errors by always having a height
   202      if (!this.element) return 1;
   203  
   204      const axis = this.element.querySelector('.x-axis');
   205      return axis && axis.getBBox().height;
   206    }
   207  
   208    get yAxisWidth() {
   209      // Avoid divide by zero errors by always having a width
   210      if (!this.element) return 1;
   211  
   212      const axis = this.element.querySelector('.y-axis');
   213      return axis && axis.getBBox().width;
   214    }
   215  
   216    get xAxisOffset() {
   217      return Math.max(0, this.height - this.xAxisHeight);
   218    }
   219  
   220    get yAxisOffset() {
   221      return Math.max(0, this.width - this.yAxisWidth);
   222    }
   223  
   224    get canvasDimensions() {
   225      const [left, right] = this.xScale.range();
   226      const [top, bottom] = this.yScale.range();
   227      return { left, width: right - left, top, height: bottom - top };
   228    }
   229  
   230    @action
   231    onInsert(element) {
   232      this.element = element;
   233      this.updateDimensions();
   234  
   235      const canvas = d3.select(this.element.querySelector('.hover-target'));
   236      const updateActiveDatum = this.updateActiveDatum.bind(this);
   237  
   238      const chart = this;
   239      canvas.on('mouseenter', function (ev) {
   240        const mouseX = d3.pointer(ev, this)[0];
   241        chart.latestMouseX = mouseX;
   242        updateActiveDatum(mouseX);
   243        schedule('afterRender', chart, () => (chart.isActive = true));
   244      });
   245  
   246      canvas.on('mousemove', function (ev) {
   247        const mouseX = d3.pointer(ev, this)[0];
   248        chart.latestMouseX = mouseX;
   249        updateActiveDatum(mouseX);
   250      });
   251  
   252      canvas.on('mouseleave', () => {
   253        schedule('afterRender', this, () => (this.isActive = false));
   254        this.activeDatum = null;
   255        this.activeData = [];
   256      });
   257    }
   258  
   259    updateActiveDatum(mouseX) {
   260      if (!this.data || !this.data.length) return;
   261  
   262      const { xScale, xProp, yScale, yProp } = this;
   263      let { dataProp, data } = this.args;
   264  
   265      if (!dataProp) {
   266        dataProp = 'data';
   267        data = [{ data: this.data }];
   268      }
   269  
   270      // Map screen coordinates to data domain
   271      const bisector = d3Array.bisector((d) => d[xProp]).left;
   272      const x = xScale.invert(mouseX);
   273  
   274      // Find the closest datum to the cursor for each series
   275      const activeData = data
   276        .map((series, seriesIndex) => {
   277          const dataset = series[dataProp];
   278  
   279          // If the dataset is empty, there can't be an activeData.
   280          // This must be done here instead of preemptively in a filter to
   281          // preserve the seriesIndex value.
   282          if (!dataset.length) return null;
   283  
   284          const index = bisector(dataset, x, 1);
   285  
   286          // The data point on either side of the cursor
   287          const dLeft = dataset[index - 1];
   288          const dRight = dataset[index];
   289  
   290          let datum;
   291  
   292          // If there is only one point, it's the activeDatum
   293          if (dLeft && !dRight) {
   294            datum = dLeft;
   295          } else {
   296            // Pick the closer point
   297            datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
   298          }
   299  
   300          return {
   301            series,
   302            datum: {
   303              formattedX: this.xFormat(this.args.timeseries)(datum[xProp]),
   304              formattedY: this.yFormat()(datum[yProp]),
   305              datum,
   306            },
   307            index: data.length - seriesIndex - 1,
   308          };
   309        })
   310        .compact();
   311  
   312      // Of the selected data, determine which is closest
   313      const closestDatum = activeData
   314        .slice()
   315        .sort(
   316          (a, b) =>
   317            Math.abs(a.datum.datum[xProp] - x) -
   318            Math.abs(b.datum.datum[xProp] - x)
   319        )[0];
   320  
   321      // If any other selected data are beyond a distance threshold, drop them from the list
   322      // xScale is used here to measure distance in screen-space rather than data-space.
   323      const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX);
   324      const filteredData = activeData.filter(
   325        (d) => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10
   326      );
   327  
   328      this.activeData = filteredData;
   329      this.activeDatum = closestDatum.datum.datum;
   330      this.tooltipPosition = {
   331        left: xScale(this.activeDatum[xProp]),
   332        top: yScale(this.activeDatum[yProp]) - 10,
   333      };
   334    }
   335  
   336    // The renderChart method should only ever be responsible for runtime calculations
   337    // and appending d3 created elements to the DOM (such as axes).
   338    renderChart() {
   339      // There is nothing to do if the element hasn't been inserted yet
   340      if (!this.element) return;
   341  
   342      // Create the axes to get the dimensions of the resulting
   343      // svg elements
   344      this.mountD3Elements();
   345  
   346      next(() => {
   347        // Since each axis depends on the dimension of the other
   348        // axis, the axes themselves are recomputed and need to
   349        // be re-rendered.
   350        this.mountD3Elements();
   351        this.ready = true;
   352        if (this.isActive) {
   353          this.updateActiveDatum(this.latestMouseX);
   354        }
   355      });
   356    }
   357  
   358    @action
   359    recomputeXAxis(el) {
   360      if (!this.isDestroyed && !this.isDestroying) {
   361        d3.select(el.querySelector('.x-axis')).call(this.xAxis);
   362      }
   363    }
   364  
   365    @action
   366    recomputeYAxis(el) {
   367      if (!this.isDestroyed && !this.isDestroying) {
   368        d3.select(el.querySelector('.y-axis')).call(this.yAxis);
   369      }
   370    }
   371  
   372    mountD3Elements() {
   373      if (!this.isDestroyed && !this.isDestroying) {
   374        d3.select(this.element.querySelector('.x-axis')).call(this.xAxis);
   375        d3.select(this.element.querySelector('.y-axis')).call(this.yAxis);
   376        d3.select(this.element.querySelector('.y-gridlines')).call(
   377          this.yGridlines
   378        );
   379      }
   380    }
   381  
   382    annotationClick(annotation) {
   383      this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
   384    }
   385  
   386    @action
   387    updateDimensions() {
   388      const $svg = this.element.querySelector('svg');
   389  
   390      this.height = $svg.clientHeight;
   391      this.width = $svg.clientWidth;
   392      this.renderChart();
   393    }
   394  }