github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/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 { 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 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
    44      .scaleLinear()
    45      .rangeRound([xAxisOffset, 10])
    46      .domain([0, max]);
    47  };
    48  
    49  export default class LineChart extends Component {
    50    /** Args
    51      data = null;
    52      xProp = null;
    53      yProp = null;
    54      curve = 'linear';
    55      title = 'Line Chart';
    56      description = null;
    57      timeseries = false;
    58      chartClass = 'is-primary';
    59      activeAnnotation = null;
    60      onAnnotationClick() {}
    61      xFormat;
    62      yFormat;
    63      xScale;
    64      yScale;
    65    */
    66  
    67    @tracked width = 0;
    68    @tracked height = 0;
    69    @tracked isActive = false;
    70    @tracked activeDatum = null;
    71    @tracked tooltipPosition = null;
    72    @tracked element = null;
    73    @tracked ready = false;
    74  
    75    @uniquely('title') titleId;
    76    @uniquely('desc') descriptionId;
    77  
    78    get xProp() {
    79      return this.args.xProp || 'time';
    80    }
    81    get yProp() {
    82      return this.args.yProp || 'value';
    83    }
    84    get data() {
    85      return this.args.data || [];
    86    }
    87    get curve() {
    88      return this.args.curve || 'linear';
    89    }
    90    get chartClass() {
    91      return this.args.chartClass || 'is-primary';
    92    }
    93  
    94    @action
    95    xFormat(timeseries) {
    96      if (this.args.xFormat) return this.args.xFormat;
    97      return timeseries ? d3TimeFormat.timeFormat('%b %d, %H:%M') : 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.yAxisOffset)
   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    @action
   220    onInsert(element) {
   221      this.element = element;
   222      this.updateDimensions();
   223  
   224      const canvas = d3.select(this.element.querySelector('.hover-target'));
   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.latestMouseX = mouseX;
   231        updateActiveDatum(mouseX);
   232        run.schedule('afterRender', chart, () => (chart.isActive = true));
   233      });
   234  
   235      canvas.on('mousemove', function() {
   236        const mouseX = d3.mouse(this)[0];
   237        chart.latestMouseX = mouseX;
   238        updateActiveDatum(mouseX);
   239      });
   240  
   241      canvas.on('mouseleave', () => {
   242        run.schedule('afterRender', this, () => (this.isActive = false));
   243        this.activeDatum = null;
   244      });
   245    }
   246  
   247    updateActiveDatum(mouseX) {
   248      const { xScale, xProp, yScale, yProp, data } = this;
   249  
   250      if (!data || !data.length) return;
   251  
   252      // Map the mouse coordinate to the index in the data array
   253      const bisector = d3Array.bisector(d => d[xProp]).left;
   254      const x = xScale.invert(mouseX);
   255      const index = bisector(data, x, 1);
   256  
   257      // The data point on either side of the cursor
   258      const dLeft = data[index - 1];
   259      const dRight = data[index];
   260  
   261      let datum;
   262  
   263      // If there is only one point, it's the activeDatum
   264      if (dLeft && !dRight) {
   265        datum = dLeft;
   266      } else {
   267        // Pick the closer point
   268        datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
   269      }
   270  
   271      this.activeDatum = datum;
   272      this.tooltipPosition = {
   273        left: xScale(datum[xProp]),
   274        top: yScale(datum[yProp]) - 10,
   275      };
   276    }
   277  
   278    // The renderChart method should only ever be responsible for runtime calculations
   279    // and appending d3 created elements to the DOM (such as axes).
   280    renderChart() {
   281      // There is nothing to do if the element hasn't been inserted yet
   282      if (!this.element) return;
   283  
   284      // Create the axes to get the dimensions of the resulting
   285      // svg elements
   286      this.mountD3Elements();
   287  
   288      run.next(() => {
   289        // Since each axis depends on the dimension of the other
   290        // axis, the axes themselves are recomputed and need to
   291        // be re-rendered.
   292        this.mountD3Elements();
   293        this.ready = true;
   294        if (this.isActive) {
   295          this.updateActiveDatum(this.latestMouseX);
   296        }
   297      });
   298    }
   299  
   300    mountD3Elements() {
   301      if (!this.isDestroyed && !this.isDestroying) {
   302        d3.select(this.element.querySelector('.x-axis')).call(this.xAxis);
   303        d3.select(this.element.querySelector('.y-axis')).call(this.yAxis);
   304        d3.select(this.element.querySelector('.y-gridlines')).call(this.yGridlines);
   305      }
   306    }
   307  
   308    annotationClick(annotation) {
   309      this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
   310    }
   311  
   312    @action
   313    updateDimensions() {
   314      const $svg = this.element.querySelector('svg');
   315  
   316      this.height = $svg.clientHeight;
   317      this.width = $svg.clientWidth;
   318      this.renderChart();
   319    }
   320  }