open-nomad/ui/app/components/line-chart.js

360 lines
9 KiB
JavaScript
Raw Normal View History

/* eslint-disable ember/no-observers */
2018-09-07 16:59:02 +00:00
import Component from '@ember/component';
import { computed } from '@ember/object';
import { observes } from '@ember-decorators/object';
import { computed as overridable } from 'ember-overridable-computed';
2018-09-07 16:59:02 +00:00
import { guidFor } from '@ember/object/internals';
import { run } from '@ember/runloop';
import d3 from 'd3-selection';
import d3Scale from 'd3-scale';
import d3Axis from 'd3-axis';
import d3Array from 'd3-array';
import d3Shape from 'd3-shape';
import d3Format from 'd3-format';
import d3TimeFormat from 'd3-time-format';
import WindowResizable from 'nomad-ui/mixins/window-resizable';
import styleStringProperty from 'nomad-ui/utils/properties/style-string';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
2018-09-07 16:59:02 +00:00
// Returns a new array with the specified number of points linearly
// distributed across the bounds
const lerp = ([low, high], numPoints) => {
const step = (high - low) / (numPoints - 1);
const arr = [];
for (var i = 0; i < numPoints; i++) {
arr.push(low + step * i);
}
return arr;
};
// Round a number or an array of numbers
const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val));
@classic
@classNames('chart', 'line-chart')
export default class LineChart extends Component.extend(WindowResizable) {
2018-09-07 16:59:02 +00:00
// Public API
data = null;
xProp = null;
yProp = null;
timeseries = false;
chartClass = 'is-primary';
2018-09-07 16:59:02 +00:00
title = 'Line Chart';
@overridable(function() {
return null;
})
description;
2018-09-07 16:59:02 +00:00
// Private Properties
width = 0;
height = 0;
2018-09-07 16:59:02 +00:00
isActive = false;
2018-09-07 16:59:02 +00:00
@computed()
get fillId() {
2018-09-07 16:59:02 +00:00
return `line-chart-fill-${guidFor(this)}`;
}
2018-09-07 16:59:02 +00:00
@computed()
get maskId() {
return `line-chart-mask-${guidFor(this)}`;
}
activeDatum = null;
2018-09-07 16:59:02 +00:00
@computed('activeDatum')
get activeDatumLabel() {
2019-03-26 07:46:44 +00:00
const datum = this.activeDatum;
2018-09-07 16:59:02 +00:00
if (!datum) return undefined;
2018-09-07 16:59:02 +00:00
2019-03-26 07:46:44 +00:00
const x = datum[this.xProp];
return this.xFormat(this.timeseries)(x);
}
2018-09-07 16:59:02 +00:00
@computed('activeDatum')
get activeDatumValue() {
2019-03-26 07:46:44 +00:00
const datum = this.activeDatum;
2018-09-07 16:59:02 +00:00
if (!datum) return undefined;
2018-09-07 16:59:02 +00:00
2019-03-26 07:46:44 +00:00
const y = datum[this.yProp];
2018-09-07 16:59:02 +00:00
return this.yFormat()(y);
}
2018-09-07 16:59:02 +00:00
// Overridable functions that retrurn formatter functions
xFormat(timeseries) {
return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(',');
}
2018-09-07 16:59:02 +00:00
yFormat() {
return d3Format.format(',.2~r');
}
2018-09-07 16:59:02 +00:00
tooltipPosition = null;
@styleStringProperty('tooltipPosition') tooltipStyle;
2018-09-07 16:59:02 +00:00
@computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset')
get xScale() {
2019-03-26 07:46:44 +00:00
const xProp = this.xProp;
const scale = this.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
const data = this.data;
2018-09-07 16:59:02 +00:00
2019-03-26 07:46:44 +00:00
const domain = data.length ? d3Array.extent(this.data, d => d[xProp]) : [0, 1];
2018-09-19 23:32:53 +00:00
2019-03-26 07:46:44 +00:00
scale.rangeRound([10, this.yAxisOffset]).domain(domain);
2018-09-07 16:59:02 +00:00
return scale;
}
2018-09-07 16:59:02 +00:00
@computed('data.[]', 'xFormat', 'xProp', 'timeseries')
get xRange() {
2019-03-26 07:46:44 +00:00
const { xProp, timeseries, data } = this;
const range = d3Array.extent(data, d => d[xProp]);
const formatter = this.xFormat(timeseries);
return range.map(formatter);
}
@computed('data.[]', 'yFormat', 'yProp')
get yRange() {
2019-03-26 07:46:44 +00:00
const yProp = this.yProp;
const range = d3Array.extent(this.data, d => d[yProp]);
const formatter = this.yFormat();
return range.map(formatter);
}
@computed('data.[]', 'yProp', 'xAxisOffset')
get yScale() {
2019-03-26 07:46:44 +00:00
const yProp = this.yProp;
let max = d3Array.max(this.data, d => d[yProp]) || 1;
2018-09-10 21:38:20 +00:00
if (max > 1) {
max = nice(max);
}
2018-09-07 16:59:02 +00:00
return d3Scale
.scaleLinear()
2019-03-26 07:46:44 +00:00
.rangeRound([this.xAxisOffset, 10])
2018-09-10 21:38:20 +00:00
.domain([0, max]);
}
2018-09-07 16:59:02 +00:00
@computed('xScale')
get xAxis() {
2019-03-26 07:46:44 +00:00
const formatter = this.xFormat(this.timeseries);
2018-09-07 16:59:02 +00:00
return d3Axis
.axisBottom()
2019-03-26 07:46:44 +00:00
.scale(this.xScale)
2018-09-07 16:59:02 +00:00
.ticks(5)
.tickFormat(formatter);
}
2018-09-07 16:59:02 +00:00
@computed('xAxisOffset')
get yTicks() {
2019-03-26 07:46:44 +00:00
const height = this.xAxisOffset;
2018-09-07 16:59:02 +00:00
const tickCount = Math.ceil(height / 120) * 2 + 1;
2019-03-26 07:46:44 +00:00
const domain = this.yScale.domain();
const ticks = lerp(domain, tickCount);
return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
}
2018-09-07 16:59:02 +00:00
@computed('yScale')
get yAxis() {
2018-09-07 16:59:02 +00:00
const formatter = this.yFormat();
return d3Axis
.axisRight()
2019-03-26 07:46:44 +00:00
.scale(this.yScale)
.tickValues(this.yTicks)
2018-09-07 16:59:02 +00:00
.tickFormat(formatter);
}
2018-09-07 16:59:02 +00:00
@computed('yScale')
get yGridlines() {
2018-09-07 16:59:02 +00:00
// The first gridline overlaps the x-axis, so remove it
2019-03-26 07:46:44 +00:00
const [, ...ticks] = this.yTicks;
2018-09-07 16:59:02 +00:00
return d3Axis
.axisRight()
2019-03-26 07:46:44 +00:00
.scale(this.yScale)
2018-09-07 16:59:02 +00:00
.tickValues(ticks)
2019-03-26 07:46:44 +00:00
.tickSize(-this.yAxisOffset)
2018-09-07 16:59:02 +00:00
.tickFormat('');
}
2018-09-07 16:59:02 +00:00
@computed()
get xAxisHeight() {
// Avoid divide by zero errors by always having a height
if (!this.element) return 1;
2018-09-07 16:59:02 +00:00
const axis = this.element.querySelector('.x-axis');
return axis && axis.getBBox().height;
}
2018-09-07 16:59:02 +00:00
@computed()
get yAxisWidth() {
// Avoid divide by zero errors by always having a width
if (!this.element) return 1;
2018-09-07 16:59:02 +00:00
const axis = this.element.querySelector('.y-axis');
return axis && axis.getBBox().width;
}
2018-09-07 16:59:02 +00:00
@overridable('height', 'xAxisHeight', function() {
2019-03-26 07:46:44 +00:00
return this.height - this.xAxisHeight;
})
xAxisOffset;
2018-09-07 16:59:02 +00:00
@computed('width', 'yAxisWidth')
get yAxisOffset() {
2019-03-26 07:46:44 +00:00
return this.width - this.yAxisWidth;
}
2018-09-07 16:59:02 +00:00
@computed('data.[]', 'xScale', 'yScale')
get line() {
2019-03-26 07:46:44 +00:00
const { xScale, yScale, xProp, yProp } = this;
2018-09-07 16:59:02 +00:00
const line = d3Shape
.line()
2018-09-17 23:58:26 +00:00
.defined(d => d[yProp] != null)
2018-09-07 16:59:02 +00:00
.x(d => xScale(d[xProp]))
.y(d => yScale(d[yProp]));
2019-03-26 07:46:44 +00:00
return line(this.data);
}
2018-09-07 16:59:02 +00:00
@computed('data.[]', 'xScale', 'yScale')
get area() {
2019-03-26 07:46:44 +00:00
const { xScale, yScale, xProp, yProp } = this;
2018-09-07 16:59:02 +00:00
const area = d3Shape
.area()
2018-09-17 23:58:26 +00:00
.defined(d => d[yProp] != null)
2018-09-07 16:59:02 +00:00
.x(d => xScale(d[xProp]))
.y0(yScale(0))
.y1(d => yScale(d[yProp]));
2019-03-26 07:46:44 +00:00
return area(this.data);
}
2018-09-07 16:59:02 +00:00
didInsertElement() {
this.updateDimensions();
const canvas = d3.select(this.element.querySelector('.canvas'));
const updateActiveDatum = this.updateActiveDatum.bind(this);
const chart = this;
canvas.on('mouseenter', function() {
const mouseX = d3.mouse(this)[0];
chart.set('latestMouseX', mouseX);
updateActiveDatum(mouseX);
run.schedule('afterRender', chart, () => chart.set('isActive', true));
2018-09-07 16:59:02 +00:00
});
canvas.on('mousemove', function() {
const mouseX = d3.mouse(this)[0];
chart.set('latestMouseX', mouseX);
updateActiveDatum(mouseX);
});
canvas.on('mouseleave', () => {
run.schedule('afterRender', this, () => this.set('isActive', false));
2018-09-07 16:59:02 +00:00
this.set('activeDatum', null);
});
}
2018-09-07 16:59:02 +00:00
didUpdateAttrs() {
this.renderChart();
}
2018-09-07 16:59:02 +00:00
updateActiveDatum(mouseX) {
2019-03-26 07:46:44 +00:00
const { xScale, xProp, yScale, yProp, data } = this;
2018-09-07 16:59:02 +00:00
if (!data || !data.length) return;
2018-09-07 16:59:02 +00:00
// Map the mouse coordinate to the index in the data array
const bisector = d3Array.bisector(d => d[xProp]).left;
const x = xScale.invert(mouseX);
const index = bisector(data, x, 1);
// The data point on either side of the cursor
const dLeft = data[index - 1];
const dRight = data[index];
let datum;
// If there is only one point, it's the activeDatum
if (dLeft && !dRight) {
datum = dLeft;
} else {
// Pick the closer point
datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
}
2018-09-07 16:59:02 +00:00
this.set('activeDatum', datum);
this.set('tooltipPosition', {
left: xScale(datum[xProp]),
top: yScale(datum[yProp]) - 10,
});
}
2018-09-07 16:59:02 +00:00
@observes('data.[]')
updateChart() {
2018-09-07 16:59:02 +00:00
this.renderChart();
}
2018-09-07 16:59:02 +00:00
// The renderChart method should only ever be responsible for runtime calculations
// and appending d3 created elements to the DOM (such as axes).
renderChart() {
// There is nothing to do if the element hasn't been inserted yet
if (!this.element) return;
2018-09-07 16:59:02 +00:00
// First, create the axes to get the dimensions of the resulting
// svg elements
this.mountD3Elements();
run.next(() => {
// Then, recompute anything that depends on the dimensions
// on the dimensions of the axes elements
this.notifyPropertyChange('xAxisHeight');
this.notifyPropertyChange('yAxisWidth');
// Since each axis depends on the dimension of the other
// axis, the axes themselves are recomputed and need to
// be re-rendered.
this.mountD3Elements();
2019-03-26 07:46:44 +00:00
if (this.isActive) {
this.updateActiveDatum(this.latestMouseX);
2018-09-07 16:59:02 +00:00
}
});
}
2018-09-07 16:59:02 +00:00
mountD3Elements() {
2019-03-26 07:46:44 +00:00
if (!this.isDestroyed && !this.isDestroying) {
d3.select(this.element.querySelector('.x-axis')).call(this.xAxis);
d3.select(this.element.querySelector('.y-axis')).call(this.yAxis);
d3.select(this.element.querySelector('.y-gridlines')).call(this.yGridlines);
}
}
2018-09-07 16:59:02 +00:00
windowResizeHandler() {
run.once(this, this.updateDimensions);
}
2018-09-07 16:59:02 +00:00
updateDimensions() {
2020-05-26 21:05:45 +00:00
const $svg = this.element.querySelector('svg');
const width = $svg.clientWidth;
const height = $svg.clientHeight;
2018-09-07 16:59:02 +00:00
this.setProperties({ width, height });
this.renderChart();
}
}