github.com/ferranbt/nomad@v0.9.3-0.20190607002617-85c449b7667c/ui/app/components/line-chart.js (about)

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