github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/line-chart.js (about)

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