github.com/iqoqo/nomad@v0.11.3-0.20200911112621-d7021c74d101/ui/app/components/line-chart.js (about)

     1  import Component from '@ember/component';
     2  import { computed, observer } from '@ember/object';
     3  import { computed as overridable } from 'ember-overridable-computed';
     4  import { guidFor } from '@ember/object/internals';
     5  import { run } from '@ember/runloop';
     6  import d3 from 'd3-selection';
     7  import d3Scale from 'd3-scale';
     8  import d3Axis from 'd3-axis';
     9  import d3Array from 'd3-array';
    10  import d3Shape from 'd3-shape';
    11  import d3Format from 'd3-format';
    12  import d3TimeFormat from 'd3-time-format';
    13  import WindowResizable from 'nomad-ui/mixins/window-resizable';
    14  import styleStringProperty from 'nomad-ui/utils/properties/style-string';
    15  
    16  // Returns a new array with the specified number of points linearly
    17  // distributed across the bounds
    18  const lerp = ([low, high], numPoints) => {
    19    const step = (high - low) / (numPoints - 1);
    20    const arr = [];
    21    for (var i = 0; i < numPoints; i++) {
    22      arr.push(low + step * i);
    23    }
    24    return arr;
    25  };
    26  
    27  // Round a number or an array of numbers
    28  const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val));
    29  
    30  export default Component.extend(WindowResizable, {
    31    classNames: ['chart', 'line-chart'],
    32  
    33    // Public API
    34  
    35    data: null,
    36    xProp: null,
    37    yProp: null,
    38    timeseries: false,
    39    chartClass: 'is-primary',
    40  
    41    title: 'Line Chart',
    42    description: null,
    43  
    44    // Private Properties
    45  
    46    width: 0,
    47    height: 0,
    48  
    49    isActive: false,
    50  
    51    fillId: computed(function() {
    52      return `line-chart-fill-${guidFor(this)}`;
    53    }),
    54  
    55    maskId: computed(function() {
    56      return `line-chart-mask-${guidFor(this)}`;
    57    }),
    58  
    59    activeDatum: null,
    60  
    61    activeDatumLabel: computed('activeDatum', function() {
    62      const datum = this.activeDatum;
    63  
    64      if (!datum) return;
    65  
    66      const x = datum[this.xProp];
    67      return this.xFormat(this.timeseries)(x);
    68    }),
    69  
    70    activeDatumValue: computed('activeDatum', function() {
    71      const datum = this.activeDatum;
    72  
    73      if (!datum) return;
    74  
    75      const y = datum[this.yProp];
    76      return this.yFormat()(y);
    77    }),
    78  
    79    // Overridable functions that retrurn formatter functions
    80    xFormat(timeseries) {
    81      return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(',');
    82    },
    83  
    84    yFormat() {
    85      return d3Format.format(',.2~r');
    86    },
    87  
    88    tooltipPosition: null,
    89    tooltipStyle: styleStringProperty('tooltipPosition'),
    90  
    91    xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() {
    92      const xProp = this.xProp;
    93      const scale = this.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
    94      const data = this.data;
    95  
    96      const domain = data.length ? d3Array.extent(this.data, d => d[xProp]) : [0, 1];
    97  
    98      scale.rangeRound([10, this.yAxisOffset]).domain(domain);
    99  
   100      return scale;
   101    }),
   102  
   103    xRange: computed('data.[]', 'xFormat', 'xProp', 'timeseries', function() {
   104      const { xProp, timeseries, data } = this;
   105      const range = d3Array.extent(data, d => d[xProp]);
   106      const formatter = this.xFormat(timeseries);
   107  
   108      return range.map(formatter);
   109    }),
   110  
   111    yRange: computed('data.[]', 'yFormat', 'yProp', function() {
   112      const yProp = this.yProp;
   113      const range = d3Array.extent(this.data, d => d[yProp]);
   114      const formatter = this.yFormat();
   115  
   116      return range.map(formatter);
   117    }),
   118  
   119    yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() {
   120      const yProp = this.yProp;
   121      let max = d3Array.max(this.data, d => d[yProp]) || 1;
   122      if (max > 1) {
   123        max = nice(max);
   124      }
   125  
   126      return d3Scale
   127        .scaleLinear()
   128        .rangeRound([this.xAxisOffset, 10])
   129        .domain([0, max]);
   130    }),
   131  
   132    xAxis: computed('xScale', function() {
   133      const formatter = this.xFormat(this.timeseries);
   134  
   135      return d3Axis
   136        .axisBottom()
   137        .scale(this.xScale)
   138        .ticks(5)
   139        .tickFormat(formatter);
   140    }),
   141  
   142    yTicks: computed('xAxisOffset', function() {
   143      const height = this.xAxisOffset;
   144      const tickCount = Math.ceil(height / 120) * 2 + 1;
   145      const domain = this.yScale.domain();
   146      const ticks = lerp(domain, tickCount);
   147      return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
   148    }),
   149  
   150    yAxis: computed('yScale', function() {
   151      const formatter = this.yFormat();
   152  
   153      return d3Axis
   154        .axisRight()
   155        .scale(this.yScale)
   156        .tickValues(this.yTicks)
   157        .tickFormat(formatter);
   158    }),
   159  
   160    yGridlines: computed('yScale', function() {
   161      // The first gridline overlaps the x-axis, so remove it
   162      const [, ...ticks] = this.yTicks;
   163  
   164      return d3Axis
   165        .axisRight()
   166        .scale(this.yScale)
   167        .tickValues(ticks)
   168        .tickSize(-this.yAxisOffset)
   169        .tickFormat('');
   170    }),
   171  
   172    xAxisHeight: computed(function() {
   173      // Avoid divide by zero errors by always having a height
   174      if (!this.element) return 1;
   175  
   176      const axis = this.element.querySelector('.x-axis');
   177      return axis && axis.getBBox().height;
   178    }),
   179  
   180    yAxisWidth: computed(function() {
   181      // Avoid divide by zero errors by always having a width
   182      if (!this.element) return 1;
   183  
   184      const axis = this.element.querySelector('.y-axis');
   185      return axis && axis.getBBox().width;
   186    }),
   187  
   188    xAxisOffset: overridable('height', 'xAxisHeight', function() {
   189      return this.height - this.xAxisHeight;
   190    }),
   191  
   192    yAxisOffset: computed('width', 'yAxisWidth', function() {
   193      return this.width - this.yAxisWidth;
   194    }),
   195  
   196    line: computed('data.[]', 'xScale', 'yScale', function() {
   197      const { xScale, yScale, xProp, yProp } = this;
   198  
   199      const line = d3Shape
   200        .line()
   201        .defined(d => d[yProp] != null)
   202        .x(d => xScale(d[xProp]))
   203        .y(d => yScale(d[yProp]));
   204  
   205      return line(this.data);
   206    }),
   207  
   208    area: computed('data.[]', 'xScale', 'yScale', function() {
   209      const { xScale, yScale, xProp, yProp } = this;
   210  
   211      const area = d3Shape
   212        .area()
   213        .defined(d => d[yProp] != null)
   214        .x(d => xScale(d[xProp]))
   215        .y0(yScale(0))
   216        .y1(d => yScale(d[yProp]));
   217  
   218      return area(this.data);
   219    }),
   220  
   221    didInsertElement() {
   222      this.updateDimensions();
   223  
   224      const canvas = d3.select(this.element.querySelector('.canvas'));
   225      const updateActiveDatum = this.updateActiveDatum.bind(this);
   226  
   227      const chart = this;
   228      canvas.on('mouseenter', function() {
   229        const mouseX = d3.mouse(this)[0];
   230        chart.set('latestMouseX', mouseX);
   231        updateActiveDatum(mouseX);
   232        run.schedule('afterRender', chart, () => chart.set('isActive', true));
   233      });
   234  
   235      canvas.on('mousemove', function() {
   236        const mouseX = d3.mouse(this)[0];
   237        chart.set('latestMouseX', mouseX);
   238        updateActiveDatum(mouseX);
   239      });
   240  
   241      canvas.on('mouseleave', () => {
   242        run.schedule('afterRender', this, () => this.set('isActive', false));
   243        this.set('activeDatum', null);
   244      });
   245    },
   246  
   247    didUpdateAttrs() {
   248      this.renderChart();
   249    },
   250  
   251    updateActiveDatum(mouseX) {
   252      const { xScale, xProp, yScale, yProp, data } = this;
   253  
   254      if (!data || !data.length) return;
   255  
   256      // Map the mouse coordinate to the index in the data array
   257      const bisector = d3Array.bisector(d => d[xProp]).left;
   258      const x = xScale.invert(mouseX);
   259      const index = bisector(data, x, 1);
   260  
   261      // The data point on either side of the cursor
   262      const dLeft = data[index - 1];
   263      const dRight = data[index];
   264  
   265      let datum;
   266  
   267      // If there is only one point, it's the activeDatum
   268      if (dLeft && !dRight) {
   269        datum = dLeft;
   270      } else {
   271        // Pick the closer point
   272        datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
   273      }
   274  
   275      this.set('activeDatum', datum);
   276      this.set('tooltipPosition', {
   277        left: xScale(datum[xProp]),
   278        top: yScale(datum[yProp]) - 10,
   279      });
   280    },
   281  
   282    updateChart: observer('data.[]', function() {
   283      this.renderChart();
   284    }),
   285  
   286    // The renderChart method should only ever be responsible for runtime calculations
   287    // and appending d3 created elements to the DOM (such as axes).
   288    renderChart() {
   289      // There is nothing to do if the element hasn't been inserted yet
   290      if (!this.element) return;
   291  
   292      // First, create the axes to get the dimensions of the resulting
   293      // svg elements
   294      this.mountD3Elements();
   295  
   296      run.next(() => {
   297        // Then, recompute anything that depends on the dimensions
   298        // on the dimensions of the axes elements
   299        this.notifyPropertyChange('xAxisHeight');
   300        this.notifyPropertyChange('yAxisWidth');
   301  
   302        // Since each axis depends on the dimension of the other
   303        // axis, the axes themselves are recomputed and need to
   304        // be re-rendered.
   305        this.mountD3Elements();
   306        if (this.isActive) {
   307          this.updateActiveDatum(this.latestMouseX);
   308        }
   309      });
   310    },
   311  
   312    mountD3Elements() {
   313      if (!this.isDestroyed && !this.isDestroying) {
   314        d3.select(this.element.querySelector('.x-axis')).call(this.xAxis);
   315        d3.select(this.element.querySelector('.y-axis')).call(this.yAxis);
   316        d3.select(this.element.querySelector('.y-gridlines')).call(this.yGridlines);
   317      }
   318    },
   319  
   320    windowResizeHandler() {
   321      run.once(this, this.updateDimensions);
   322    },
   323  
   324    updateDimensions() {
   325      const $svg = this.$('svg');
   326      const width = $svg.width();
   327      const height = $svg.height();
   328  
   329      this.setProperties({ width, height });
   330      this.renderChart();
   331    },
   332  });