Convert LineChart into a glimmer component
This commit is contained in:
parent
9a430651c4
commit
51092a886c
|
@ -1,9 +1,8 @@
|
|||
/* eslint-disable ember/no-observers */
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { assert } from '@ember/debug';
|
||||
import { observes } from '@ember-decorators/object';
|
||||
import { computed as overridable } from 'ember-overridable-computed';
|
||||
import { run } from '@ember/runloop';
|
||||
import d3 from 'd3-selection';
|
||||
import d3Scale from 'd3-scale';
|
||||
|
@ -11,10 +10,7 @@ import d3Axis from 'd3-axis';
|
|||
import d3Array from 'd3-array';
|
||||
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';
|
||||
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
|
||||
|
||||
// Returns a new array with the specified number of points linearly
|
||||
// distributed across the bounds
|
||||
|
@ -30,64 +26,41 @@ const lerp = ([low, high], numPoints) => {
|
|||
// 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) {
|
||||
// Public API
|
||||
export default class LineChart extends Component {
|
||||
/** Args
|
||||
data = null;
|
||||
xProp = null;
|
||||
yProp = null;
|
||||
curve = 'linear';
|
||||
title = 'Line Chart';
|
||||
description = null;
|
||||
timeseries = false;
|
||||
chartClass = 'is-primary';
|
||||
activeAnnotation = null;
|
||||
onAnnotationClick() {}
|
||||
*/
|
||||
|
||||
data = null;
|
||||
activeAnnotation = null;
|
||||
onAnnotationClick() {}
|
||||
xProp = null;
|
||||
yProp = null;
|
||||
curve = 'linear';
|
||||
timeseries = false;
|
||||
chartClass = 'is-primary';
|
||||
@tracked width = 0;
|
||||
@tracked height = 0;
|
||||
@tracked isActive = false;
|
||||
@tracked activeDatum = null;
|
||||
@tracked tooltipPosition = null;
|
||||
@tracked element = null;
|
||||
|
||||
title = 'Line Chart';
|
||||
|
||||
@overridable(function() {
|
||||
return null;
|
||||
})
|
||||
description;
|
||||
|
||||
// Private Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
isActive = false;
|
||||
|
||||
activeDatum = null;
|
||||
|
||||
@computed('activeDatum', 'timeseries', 'xProp')
|
||||
get activeDatumLabel() {
|
||||
const datum = this.activeDatum;
|
||||
|
||||
if (!datum) return undefined;
|
||||
|
||||
const x = datum[this.xProp];
|
||||
return this.xFormat(this.timeseries)(x);
|
||||
get xProp() {
|
||||
return this.args.xProp || 'time';
|
||||
}
|
||||
|
||||
@computed('activeDatum', 'yProp')
|
||||
get activeDatumValue() {
|
||||
const datum = this.activeDatum;
|
||||
|
||||
if (!datum) return undefined;
|
||||
|
||||
const y = datum[this.yProp];
|
||||
return this.yFormat()(y);
|
||||
get yProp() {
|
||||
return this.args.yProp || 'value';
|
||||
}
|
||||
|
||||
@computed('curve')
|
||||
get curveMethod() {
|
||||
const mappings = {
|
||||
linear: 'curveLinear',
|
||||
stepAfter: 'curveStepAfter',
|
||||
};
|
||||
assert(`Provided curve "${this.curve}" is not an allowed curve type`, mappings[this.curve]);
|
||||
return mappings[this.curve];
|
||||
get data() {
|
||||
return this.args.data || [];
|
||||
}
|
||||
get curve() {
|
||||
return this.args.curve || 'linear';
|
||||
}
|
||||
get chartClass() {
|
||||
return this.args.chartClass || 'is-primary';
|
||||
}
|
||||
|
||||
// Overridable functions that retrurn formatter functions
|
||||
|
@ -99,40 +72,57 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
return d3Format.format(',.2~r');
|
||||
}
|
||||
|
||||
tooltipPosition = null;
|
||||
@styleStringProperty('tooltipPosition') tooltipStyle;
|
||||
get activeDatumLabel() {
|
||||
const datum = this.activeDatum;
|
||||
|
||||
@computed('xAxisOffset')
|
||||
get chartAnnotationBounds() {
|
||||
return {
|
||||
height: this.xAxisOffset,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
get curveMethod() {
|
||||
const mappings = {
|
||||
linear: 'curveLinear',
|
||||
stepAfter: 'curveStepAfter',
|
||||
};
|
||||
assert(`Provided curve "${this.curve}" is not an allowed curve type`, mappings[this.curve]);
|
||||
return mappings[this.curve];
|
||||
}
|
||||
|
||||
@styleString
|
||||
get tooltipStyle() {
|
||||
return this.tooltipPosition;
|
||||
}
|
||||
@styleStringProperty('chartAnnotationBounds') chartAnnotationsStyle;
|
||||
|
||||
@computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset')
|
||||
get xScale() {
|
||||
const xProp = this.xProp;
|
||||
const scale = this.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
|
||||
const data = this.data;
|
||||
const { xProp, data } = this;
|
||||
const scale = this.args.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
|
||||
|
||||
const domain = data.length ? d3Array.extent(this.data, d => d[xProp]) : [0, 1];
|
||||
const domain = data.length ? d3Array.extent(data, d => d[xProp]) : [0, 1];
|
||||
|
||||
scale.rangeRound([10, this.yAxisOffset]).domain(domain);
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
@computed('data.[]', 'xFormat', 'xProp', 'timeseries')
|
||||
get xRange() {
|
||||
const { xProp, timeseries, data } = this;
|
||||
const { xProp, data } = this;
|
||||
const range = d3Array.extent(data, d => d[xProp]);
|
||||
const formatter = this.xFormat(timeseries);
|
||||
const formatter = this.xFormat(this.args.timeseries);
|
||||
|
||||
return range.map(formatter);
|
||||
}
|
||||
|
||||
@computed('data.[]', 'yFormat', 'yProp')
|
||||
get yRange() {
|
||||
const yProp = this.yProp;
|
||||
const range = d3Array.extent(this.data, d => d[yProp]);
|
||||
|
@ -141,7 +131,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
return range.map(formatter);
|
||||
}
|
||||
|
||||
@computed('data.[]', 'yProp', 'xAxisOffset')
|
||||
get yScale() {
|
||||
const yProp = this.yProp;
|
||||
let max = d3Array.max(this.data, d => d[yProp]) || 1;
|
||||
|
@ -155,9 +144,8 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
.domain([0, max]);
|
||||
}
|
||||
|
||||
@computed('timeseries', 'xScale')
|
||||
get xAxis() {
|
||||
const formatter = this.xFormat(this.timeseries);
|
||||
const formatter = this.xFormat(this.args.timeseries);
|
||||
|
||||
return d3Axis
|
||||
.axisBottom()
|
||||
|
@ -166,7 +154,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
.tickFormat(formatter);
|
||||
}
|
||||
|
||||
@computed('xAxisOffset', 'yScale')
|
||||
get yTicks() {
|
||||
const height = this.xAxisOffset;
|
||||
const tickCount = Math.ceil(height / 120) * 2 + 1;
|
||||
|
@ -175,7 +162,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
|
||||
}
|
||||
|
||||
@computed('yScale', 'yTicks')
|
||||
get yAxis() {
|
||||
const formatter = this.yFormat();
|
||||
|
||||
|
@ -186,7 +172,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
.tickFormat(formatter);
|
||||
}
|
||||
|
||||
@computed('yAxisOffset', 'yScale', 'yTicks')
|
||||
get yGridlines() {
|
||||
// The first gridline overlaps the x-axis, so remove it
|
||||
const [, ...ticks] = this.yTicks;
|
||||
|
@ -199,7 +184,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
.tickFormat('');
|
||||
}
|
||||
|
||||
@computed('element')
|
||||
get xAxisHeight() {
|
||||
// Avoid divide by zero errors by always having a height
|
||||
if (!this.element) return 1;
|
||||
|
@ -208,7 +192,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
return axis && axis.getBBox().height;
|
||||
}
|
||||
|
||||
@computed('element')
|
||||
get yAxisWidth() {
|
||||
// Avoid divide by zero errors by always having a width
|
||||
if (!this.element) return 1;
|
||||
|
@ -217,17 +200,17 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
return axis && axis.getBBox().width;
|
||||
}
|
||||
|
||||
@overridable('height', 'xAxisHeight', function() {
|
||||
get xAxisOffset() {
|
||||
return this.height - this.xAxisHeight;
|
||||
})
|
||||
xAxisOffset;
|
||||
}
|
||||
|
||||
@computed('width', 'yAxisWidth')
|
||||
get yAxisOffset() {
|
||||
return this.width - this.yAxisWidth;
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
@action
|
||||
onInsert(element) {
|
||||
this.element = element;
|
||||
this.updateDimensions();
|
||||
|
||||
const canvas = d3.select(this.element.querySelector('.hover-target'));
|
||||
|
@ -236,27 +219,23 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
const chart = this;
|
||||
canvas.on('mouseenter', function() {
|
||||
const mouseX = d3.mouse(this)[0];
|
||||
chart.set('latestMouseX', mouseX);
|
||||
chart.latestMouseX = mouseX;
|
||||
updateActiveDatum(mouseX);
|
||||
run.schedule('afterRender', chart, () => chart.set('isActive', true));
|
||||
run.schedule('afterRender', chart, () => (chart.isActive = true));
|
||||
});
|
||||
|
||||
canvas.on('mousemove', function() {
|
||||
const mouseX = d3.mouse(this)[0];
|
||||
chart.set('latestMouseX', mouseX);
|
||||
chart.latestMouseX = mouseX;
|
||||
updateActiveDatum(mouseX);
|
||||
});
|
||||
|
||||
canvas.on('mouseleave', () => {
|
||||
run.schedule('afterRender', this, () => this.set('isActive', false));
|
||||
this.set('activeDatum', null);
|
||||
run.schedule('afterRender', this, () => (this.isActive = false));
|
||||
this.activeDatum = null;
|
||||
});
|
||||
}
|
||||
|
||||
didUpdateAttrs() {
|
||||
this.renderChart();
|
||||
}
|
||||
|
||||
updateActiveDatum(mouseX) {
|
||||
const { xScale, xProp, yScale, yProp, data } = this;
|
||||
|
||||
|
@ -281,16 +260,11 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
|
||||
}
|
||||
|
||||
this.set('activeDatum', datum);
|
||||
this.set('tooltipPosition', {
|
||||
this.activeDatum = datum;
|
||||
this.tooltipPosition = {
|
||||
left: xScale(datum[xProp]),
|
||||
top: yScale(datum[yProp]) - 10,
|
||||
});
|
||||
}
|
||||
|
||||
@observes('data.[]')
|
||||
updateChart() {
|
||||
this.renderChart();
|
||||
};
|
||||
}
|
||||
|
||||
// The renderChart method should only ever be responsible for runtime calculations
|
||||
|
@ -299,16 +273,11 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
// There is nothing to do if the element hasn't been inserted yet
|
||||
if (!this.element) return;
|
||||
|
||||
// First, create the axes to get the dimensions of the resulting
|
||||
// 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.
|
||||
|
@ -328,19 +297,15 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
|||
}
|
||||
|
||||
annotationClick(annotation) {
|
||||
this.onAnnotationClick(annotation);
|
||||
}
|
||||
|
||||
windowResizeHandler() {
|
||||
run.once(this, this.updateDimensions);
|
||||
this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
|
||||
}
|
||||
|
||||
@action
|
||||
updateDimensions() {
|
||||
const $svg = this.element.querySelector('svg');
|
||||
const width = $svg.clientWidth;
|
||||
const height = $svg.clientHeight;
|
||||
|
||||
this.setProperties({ width, height });
|
||||
this.height = $svg.clientHeight;
|
||||
this.width = $svg.clientWidth;
|
||||
this.renderChart();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,43 +1,50 @@
|
|||
<svg data-test-line-chart role="img" aria-labelledby="{{concat "title-" this.elementId}} {{concat "desc-" this.elementId}}">
|
||||
<title id="{{concat "title-" this.elementId}}">{{this.title}}</title>
|
||||
<description id="{{concat "desc-" this.elementId}}">
|
||||
{{#if this.description}}
|
||||
{{this.description}}
|
||||
{{else}}
|
||||
X-axis values range from {{this.xRange.firstObject}} to {{this.xRange.lastObject}},
|
||||
and Y-axis values range from {{this.yRange.firstObject}} to {{this.yRange.lastObject}}.
|
||||
{{/if}}
|
||||
</description>
|
||||
<g class="y-gridlines gridlines" transform="translate({{this.yAxisOffset}}, 0)"></g>
|
||||
<ChartPrimitives::Area
|
||||
@data={{this.data}}
|
||||
@colorClass={{this.chartClass}}
|
||||
@xScale={{this.xScale}}
|
||||
@yScale={{this.yScale}}
|
||||
@xProp={{this.xProp}}
|
||||
@yProp={{this.yProp}}
|
||||
@width={{this.yAxisOffset}}
|
||||
@height={{this.xAxisOffset}}
|
||||
@curveMethod={{this.curveMethod}} />
|
||||
<g aria-hidden="true" class="x-axis axis" transform="translate(0, {{this.xAxisOffset}})"></g>
|
||||
<g aria-hidden="true" class="y-axis axis" transform="translate({{this.yAxisOffset}}, 0)"></g>
|
||||
<rect class="hover-target" x="0" y="0" width="{{this.yAxisOffset}}" height="{{this.xAxisOffset}}" />
|
||||
</svg>
|
||||
<ChartPrimitives::VAnnotations
|
||||
@annotations={{this.annotations}}
|
||||
@key={{this.annotationKey}}
|
||||
@annotationClick={{action this.annotationClick}}
|
||||
@timeseries={{this.timeseries}}
|
||||
@format={{this.xFormat}}
|
||||
@scale={{this.xScale}}
|
||||
@prop={{this.xProp}}
|
||||
@height={{this.xAxisOffset}} />
|
||||
<div class="chart-tooltip is-snappy {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
|
||||
<p>
|
||||
<span class="label">
|
||||
<span class="color-swatch {{this.chartClass}}" />
|
||||
{{this.activeDatumLabel}}
|
||||
</span>
|
||||
<span class="value">{{this.activeDatumValue}}</span>
|
||||
</p>
|
||||
<div
|
||||
class="chart line-chart"
|
||||
...attributes
|
||||
{{did-insert this.onInsert}}
|
||||
{{did-update this.renderChart}}
|
||||
{{window-resize this.updateDimensions}}>
|
||||
<svg data-test-line-chart role="img" aria-labelledby="{{concat "title-" this.elementId}} {{concat "desc-" this.elementId}}">
|
||||
<title id="{{concat "title-" this.elementId}}">{{this.title}}</title>
|
||||
<description id="{{concat "desc-" this.elementId}}">
|
||||
{{#if this.description}}
|
||||
{{this.description}}
|
||||
{{else}}
|
||||
X-axis values range from {{this.xRange.firstObject}} to {{this.xRange.lastObject}},
|
||||
and Y-axis values range from {{this.yRange.firstObject}} to {{this.yRange.lastObject}}.
|
||||
{{/if}}
|
||||
</description>
|
||||
<g class="y-gridlines gridlines" transform="translate({{this.yAxisOffset}}, 0)"></g>
|
||||
<ChartPrimitives::Area
|
||||
@data={{this.data}}
|
||||
@colorClass={{this.chartClass}}
|
||||
@xScale={{this.xScale}}
|
||||
@yScale={{this.yScale}}
|
||||
@xProp={{this.xProp}}
|
||||
@yProp={{this.yProp}}
|
||||
@width={{this.yAxisOffset}}
|
||||
@height={{this.xAxisOffset}}
|
||||
@curveMethod={{this.curveMethod}} />
|
||||
<g aria-hidden="true" class="x-axis axis" transform="translate(0, {{this.xAxisOffset}})"></g>
|
||||
<g aria-hidden="true" class="y-axis axis" transform="translate({{this.yAxisOffset}}, 0)"></g>
|
||||
<rect class="hover-target" x="0" y="0" width="{{this.yAxisOffset}}" height="{{this.xAxisOffset}}" />
|
||||
</svg>
|
||||
<ChartPrimitives::VAnnotations
|
||||
@annotations={{@annotations}}
|
||||
@key={{@annotationKey}}
|
||||
@annotationClick={{action this.annotationClick}}
|
||||
@timeseries={{this.timeseries}}
|
||||
@format={{this.xFormat}}
|
||||
@scale={{this.xScale}}
|
||||
@prop={{this.xProp}}
|
||||
@height={{this.xAxisOffset}} />
|
||||
<div class="chart-tooltip is-snappy {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
|
||||
<p>
|
||||
<span class="label">
|
||||
<span class="color-swatch {{this.chartClass}}" />
|
||||
{{this.activeDatumLabel}}
|
||||
</span>
|
||||
<span class="value">{{this.activeDatumValue}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2 KiB |
Loading…
Reference in a new issue