383 lines
10 KiB
JavaScript
383 lines
10 KiB
JavaScript
import Component from '@glimmer/component';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { action } from '@ember/object';
|
|
import { schedule, next } from '@ember/runloop';
|
|
import d3 from 'd3-selection';
|
|
import d3Scale from 'd3-scale';
|
|
import d3Axis from 'd3-axis';
|
|
import d3Array from 'd3-array';
|
|
import d3Format from 'd3-format';
|
|
import d3TimeFormat from 'd3-time-format';
|
|
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
|
|
import uniquely from 'nomad-ui/utils/properties/uniquely';
|
|
|
|
// 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));
|
|
|
|
const defaultXScale = (data, yAxisOffset, xProp, timeseries) => {
|
|
const scale = timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
|
|
const domain = data.length ? d3Array.extent(data, (d) => d[xProp]) : [0, 1];
|
|
|
|
scale.rangeRound([10, yAxisOffset]).domain(domain);
|
|
|
|
return scale;
|
|
};
|
|
|
|
const defaultYScale = (data, xAxisOffset, yProp) => {
|
|
let max = d3Array.max(data, (d) => d[yProp]) || 1;
|
|
if (max > 1) {
|
|
max = nice(max);
|
|
}
|
|
|
|
return d3Scale.scaleLinear().rangeRound([xAxisOffset, 10]).domain([0, max]);
|
|
};
|
|
|
|
export default class LineChart extends Component {
|
|
/** Args
|
|
data = null;
|
|
xProp = null;
|
|
yProp = null;
|
|
curve = 'linear';
|
|
title = 'Line Chart';
|
|
description = null;
|
|
timeseries = false;
|
|
activeAnnotation = null;
|
|
onAnnotationClick() {}
|
|
xFormat;
|
|
yFormat;
|
|
xScale;
|
|
yScale;
|
|
*/
|
|
|
|
@tracked width = 0;
|
|
@tracked height = 0;
|
|
@tracked isActive = false;
|
|
@tracked activeDatum = null;
|
|
@tracked activeData = [];
|
|
@tracked tooltipPosition = null;
|
|
@tracked element = null;
|
|
@tracked ready = false;
|
|
|
|
@uniquely('title') titleId;
|
|
@uniquely('desc') descriptionId;
|
|
|
|
get xProp() {
|
|
return this.args.xProp || 'time';
|
|
}
|
|
get yProp() {
|
|
return this.args.yProp || 'value';
|
|
}
|
|
get data() {
|
|
if (!this.args.data) return [];
|
|
if (this.args.dataProp) {
|
|
return this.args.data.mapBy(this.args.dataProp).flat();
|
|
}
|
|
return this.args.data;
|
|
}
|
|
get curve() {
|
|
return this.args.curve || 'linear';
|
|
}
|
|
|
|
@action
|
|
xFormat(timeseries) {
|
|
if (this.args.xFormat) return this.args.xFormat;
|
|
return timeseries
|
|
? d3TimeFormat.timeFormat('%b %d, %H:%M')
|
|
: d3Format.format(',');
|
|
}
|
|
|
|
@action
|
|
yFormat() {
|
|
if (this.args.yFormat) return this.args.yFormat;
|
|
return d3Format.format(',.2~r');
|
|
}
|
|
|
|
get activeDatumLabel() {
|
|
const datum = this.activeDatum;
|
|
|
|
if (!datum) return undefined;
|
|
|
|
const x = datum[this.xProp];
|
|
return this.xFormat(this.args.timeseries)(x);
|
|
}
|
|
|
|
get activeDatumValue() {
|
|
const datum = this.activeDatum;
|
|
|
|
if (!datum) return undefined;
|
|
|
|
const y = datum[this.yProp];
|
|
return this.yFormat()(y);
|
|
}
|
|
|
|
@styleString
|
|
get tooltipStyle() {
|
|
return this.tooltipPosition;
|
|
}
|
|
|
|
get xScale() {
|
|
const fn = this.args.xScale || defaultXScale;
|
|
return fn(this.data, this.yAxisOffset, this.xProp, this.args.timeseries);
|
|
}
|
|
|
|
get xRange() {
|
|
const { xProp, data } = this;
|
|
const range = d3Array.extent(data, (d) => d[xProp]);
|
|
const formatter = this.xFormat(this.args.timeseries);
|
|
|
|
return range.map(formatter);
|
|
}
|
|
|
|
get yRange() {
|
|
const yProp = this.yProp;
|
|
const range = d3Array.extent(this.data, (d) => d[yProp]);
|
|
const formatter = this.yFormat();
|
|
|
|
return range.map(formatter);
|
|
}
|
|
|
|
get yScale() {
|
|
const fn = this.args.yScale || defaultYScale;
|
|
return fn(this.data, this.xAxisOffset, this.yProp);
|
|
}
|
|
|
|
get xAxis() {
|
|
const formatter = this.xFormat(this.args.timeseries);
|
|
|
|
return d3Axis
|
|
.axisBottom()
|
|
.scale(this.xScale)
|
|
.ticks(5)
|
|
.tickFormat(formatter);
|
|
}
|
|
|
|
get yTicks() {
|
|
const height = this.xAxisOffset;
|
|
const tickCount = Math.ceil(height / 120) * 2 + 1;
|
|
const domain = this.yScale.domain();
|
|
const ticks = lerp(domain, tickCount);
|
|
return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
|
|
}
|
|
|
|
get yAxis() {
|
|
const formatter = this.yFormat();
|
|
|
|
return d3Axis
|
|
.axisRight()
|
|
.scale(this.yScale)
|
|
.tickValues(this.yTicks)
|
|
.tickFormat(formatter);
|
|
}
|
|
|
|
get yGridlines() {
|
|
// The first gridline overlaps the x-axis, so remove it
|
|
const [, ...ticks] = this.yTicks;
|
|
|
|
return d3Axis
|
|
.axisRight()
|
|
.scale(this.yScale)
|
|
.tickValues(ticks)
|
|
.tickSize(-this.canvasDimensions.width)
|
|
.tickFormat('');
|
|
}
|
|
|
|
get xAxisHeight() {
|
|
// Avoid divide by zero errors by always having a height
|
|
if (!this.element) return 1;
|
|
|
|
const axis = this.element.querySelector('.x-axis');
|
|
return axis && axis.getBBox().height;
|
|
}
|
|
|
|
get yAxisWidth() {
|
|
// Avoid divide by zero errors by always having a width
|
|
if (!this.element) return 1;
|
|
|
|
const axis = this.element.querySelector('.y-axis');
|
|
return axis && axis.getBBox().width;
|
|
}
|
|
|
|
get xAxisOffset() {
|
|
return Math.max(0, this.height - this.xAxisHeight);
|
|
}
|
|
|
|
get yAxisOffset() {
|
|
return Math.max(0, this.width - this.yAxisWidth);
|
|
}
|
|
|
|
get canvasDimensions() {
|
|
const [left, right] = this.xScale.range();
|
|
const [top, bottom] = this.yScale.range();
|
|
return { left, width: right - left, top, height: bottom - top };
|
|
}
|
|
|
|
@action
|
|
onInsert(element) {
|
|
this.element = element;
|
|
this.updateDimensions();
|
|
|
|
const canvas = d3.select(this.element.querySelector('.hover-target'));
|
|
const updateActiveDatum = this.updateActiveDatum.bind(this);
|
|
|
|
const chart = this;
|
|
canvas.on('mouseenter', function (ev) {
|
|
const mouseX = d3.pointer(ev, this)[0];
|
|
chart.latestMouseX = mouseX;
|
|
updateActiveDatum(mouseX);
|
|
schedule('afterRender', chart, () => (chart.isActive = true));
|
|
});
|
|
|
|
canvas.on('mousemove', function (ev) {
|
|
const mouseX = d3.pointer(ev, this)[0];
|
|
chart.latestMouseX = mouseX;
|
|
updateActiveDatum(mouseX);
|
|
});
|
|
|
|
canvas.on('mouseleave', () => {
|
|
schedule('afterRender', this, () => (this.isActive = false));
|
|
this.activeDatum = null;
|
|
this.activeData = [];
|
|
});
|
|
}
|
|
|
|
updateActiveDatum(mouseX) {
|
|
if (!this.data || !this.data.length) return;
|
|
|
|
const { xScale, xProp, yScale, yProp } = this;
|
|
let { dataProp, data } = this.args;
|
|
|
|
if (!dataProp) {
|
|
dataProp = 'data';
|
|
data = [{ data: this.data }];
|
|
}
|
|
|
|
// Map screen coordinates to data domain
|
|
const bisector = d3Array.bisector((d) => d[xProp]).left;
|
|
const x = xScale.invert(mouseX);
|
|
|
|
// Find the closest datum to the cursor for each series
|
|
const activeData = data
|
|
.map((series, seriesIndex) => {
|
|
const dataset = series[dataProp];
|
|
|
|
// If the dataset is empty, there can't be an activeData.
|
|
// This must be done here instead of preemptively in a filter to
|
|
// preserve the seriesIndex value.
|
|
if (!dataset.length) return null;
|
|
|
|
const index = bisector(dataset, x, 1);
|
|
|
|
// The data point on either side of the cursor
|
|
const dLeft = dataset[index - 1];
|
|
const dRight = dataset[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;
|
|
}
|
|
|
|
return {
|
|
series,
|
|
datum: {
|
|
formattedX: this.xFormat(this.args.timeseries)(datum[xProp]),
|
|
formattedY: this.yFormat()(datum[yProp]),
|
|
datum,
|
|
},
|
|
index: data.length - seriesIndex - 1,
|
|
};
|
|
})
|
|
.compact();
|
|
|
|
// Of the selected data, determine which is closest
|
|
const closestDatum = activeData
|
|
.slice()
|
|
.sort(
|
|
(a, b) =>
|
|
Math.abs(a.datum.datum[xProp] - x) -
|
|
Math.abs(b.datum.datum[xProp] - x)
|
|
)[0];
|
|
|
|
// If any other selected data are beyond a distance threshold, drop them from the list
|
|
// xScale is used here to measure distance in screen-space rather than data-space.
|
|
const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX);
|
|
const filteredData = activeData.filter(
|
|
(d) => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10
|
|
);
|
|
|
|
this.activeData = filteredData;
|
|
this.activeDatum = closestDatum.datum.datum;
|
|
this.tooltipPosition = {
|
|
left: xScale(this.activeDatum[xProp]),
|
|
top: yScale(this.activeDatum[yProp]) - 10,
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Create the axes to get the dimensions of the resulting
|
|
// svg elements
|
|
this.mountD3Elements();
|
|
|
|
next(() => {
|
|
// Since each axis depends on the dimension of the other
|
|
// axis, the axes themselves are recomputed and need to
|
|
// be re-rendered.
|
|
this.mountD3Elements();
|
|
this.ready = true;
|
|
if (this.isActive) {
|
|
this.updateActiveDatum(this.latestMouseX);
|
|
}
|
|
});
|
|
}
|
|
|
|
@action
|
|
recomputeXAxis(el) {
|
|
if (!this.isDestroyed && !this.isDestroying) {
|
|
d3.select(el.querySelector('.x-axis')).call(this.xAxis);
|
|
}
|
|
}
|
|
|
|
mountD3Elements() {
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
annotationClick(annotation) {
|
|
this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
|
|
}
|
|
|
|
@action
|
|
updateDimensions() {
|
|
const $svg = this.element.querySelector('svg');
|
|
|
|
this.height = $svg.clientHeight;
|
|
this.width = $svg.clientWidth;
|
|
this.renderChart();
|
|
}
|
|
}
|