Refactor line chart scales and refactor tests

This commit is contained in:
Michael Lange
2021-02-22 17:34:24 -08:00
parent ae381d1a20
commit 12fb78c3ab
4 changed files with 100 additions and 77 deletions

View File

@@ -1,4 +1,3 @@
/* eslint-disable ember/no-observers */
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -11,6 +10,7 @@ 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
@@ -26,6 +26,27 @@ 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));
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;
@@ -47,6 +68,9 @@ export default class LineChart extends Component {
@tracked tooltipPosition = null;
@tracked element = null;
@uniquely('title') titleId;
@uniquely('desc') descriptionId;
get xProp() {
return this.args.xProp || 'time';
}
@@ -63,12 +87,15 @@ export default class LineChart extends Component {
return this.args.chartClass || 'is-primary';
}
// Overridable functions that retrurn formatter functions
@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');
}
@@ -105,14 +132,8 @@ export default class LineChart extends Component {
}
get xScale() {
const { xProp, data } = this;
const scale = this.args.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
const domain = data.length ? d3Array.extent(data, d => d[xProp]) : [0, 1];
scale.rangeRound([10, this.yAxisOffset]).domain(domain);
return scale;
const fn = this.args.xScale || defaultXScale;
return fn(this.data, this.yAxisOffset, this.xProp, this.args.timeseries);
}
get xRange() {
@@ -132,16 +153,8 @@ export default class LineChart extends Component {
}
get yScale() {
const yProp = this.yProp;
let max = d3Array.max(this.data, d => d[yProp]) || 1;
if (max > 1) {
max = nice(max);
}
return d3Scale
.scaleLinear()
.rangeRound([this.xAxisOffset, 10])
.domain([0, max]);
const fn = this.args.yScale || defaultYScale;
return fn(this.data, this.xAxisOffset, this.yProp);
}
get xAxis() {

View File

@@ -4,16 +4,16 @@
{{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}}">
<svg data-test-line-chart aria-labelledby="{{this.titleId}}" aria-describedby="{{this.descriptionId}}">
<title id="{{this.titleId}}">{{this.title}}</title>
<desc id="{{this.descriptionId}}">
{{#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>
</desc>
<g class="y-gridlines gridlines" transform="translate({{this.yAxisOffset}}, 0)"></g>
<ChartPrimitives::Area
@data={{this.data}}
@@ -33,7 +33,7 @@
@annotations={{@annotations}}
@key={{@annotationKey}}
@annotationClick={{action this.annotationClick}}
@timeseries={{this.timeseries}}
@timeseries={{@timeseries}}
@format={{this.xFormat}}
@scale={{this.xScale}}
@prop={{this.xProp}}

View File

@@ -8,14 +8,21 @@ import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
const REF_DATE = new Date();
module('Integration | Component | line chart', function(hooks) {
module('Integration | Component | line-chart', function(hooks) {
setupRenderingTest(hooks);
test('when a chart has annotations, they are rendered in order', async function(assert) {
const annotations = [{ x: 2, type: 'info' }, { x: 1, type: 'error' }, { x: 3, type: 'info' }];
const annotations = [
{ x: 2, type: 'info' },
{ x: 1, type: 'error' },
{ x: 3, type: 'info' },
];
this.setProperties({
annotations,
data: [{ x: 1, y: 1 }, { x: 10, y: 10 }],
data: [
{ x: 1, y: 1 },
{ x: 10, y: 10 },
],
});
await render(hbs`
@@ -61,7 +68,10 @@ module('Integration | Component | line chart', function(hooks) {
];
this.setProperties({
annotations,
data: [{ x: 1, y: 1 }, { x: 10, y: 10 }],
data: [
{ x: 1, y: 1 },
{ x: 10, y: 10 },
],
});
await render(hbs`
@@ -87,7 +97,10 @@ module('Integration | Component | line chart', function(hooks) {
const annotations = [{ x: 2, type: 'info', meta: { data: 'here' } }];
this.setProperties({
annotations,
data: [{ x: 1, y: 1 }, { x: 10, y: 10 }],
data: [
{ x: 1, y: 1 },
{ x: 10, y: 10 },
],
click: sinon.spy(),
});
@@ -105,10 +118,17 @@ module('Integration | Component | line chart', function(hooks) {
});
test('annotations will have staggered heights when too close to be positioned side-by-side', async function(assert) {
const annotations = [{ x: 2, type: 'info' }, { x: 2.4, type: 'error' }, { x: 9, type: 'info' }];
const annotations = [
{ x: 2, type: 'info' },
{ x: 2.4, type: 'error' },
{ x: 9, type: 'info' },
];
this.setProperties({
annotations,
data: [{ x: 1, y: 1 }, { x: 10, y: 10 }],
data: [
{ x: 1, y: 1 },
{ x: 10, y: 10 },
],
click: sinon.spy(),
});

View File

@@ -1,9 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import d3Format from 'd3-format';
import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
module('Unit | Component | line-chart', function(hooks) {
setupTest(hooks);
setupGlimmerComponentFactory(hooks, 'line-chart');
const data = [
{ foo: 1, bar: 100 },
@@ -14,14 +16,12 @@ module('Unit | Component | line-chart', function(hooks) {
];
test('x scale domain is the min and max values in data based on the xProp value', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
xProp: 'foo',
data,
});
let [xDomainLow, xDomainHigh] = chart.get('xScale').domain();
let [xDomainLow, xDomainHigh] = chart.xScale.domain();
assert.equal(
xDomainLow,
Math.min(...data.mapBy('foo')),
@@ -33,21 +33,19 @@ module('Unit | Component | line-chart', function(hooks) {
'Domain upper bound is the highest foo value'
);
chart.set('data', [...data, { foo: 12, bar: 600 }]);
chart.args.data = [...data, { foo: 12, bar: 600 }];
[, xDomainHigh] = chart.get('xScale').domain();
[, xDomainHigh] = chart.xScale.domain();
assert.equal(xDomainHigh, 12, 'When the data changes, the xScale is recalculated');
});
test('y scale domain uses the max value in the data based off of yProp, but is always zero-based', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
yProp: 'bar',
data,
});
let [yDomainLow, yDomainHigh] = chart.get('yScale').domain();
let [yDomainLow, yDomainHigh] = chart.yScale.domain();
assert.equal(yDomainLow, 0, 'Domain lower bound is always 0');
assert.equal(
yDomainHigh,
@@ -55,54 +53,47 @@ module('Unit | Component | line-chart', function(hooks) {
'Domain upper bound is the highest bar value'
);
chart.set('data', [...data, { foo: 12, bar: 600 }]);
chart.args.data = [...data, { foo: 12, bar: 600 }];
[, yDomainHigh] = chart.get('yScale').domain();
[, yDomainHigh] = chart.yScale.domain();
assert.equal(yDomainHigh, 600, 'When the data changes, the yScale is recalculated');
});
test('the number of yTicks is always odd (to always have a mid-line) and is based off the chart height', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
yProp: 'bar',
xAxisOffset: 100,
data,
});
assert.equal(chart.get('yTicks').length, 3);
chart.height = 100;
assert.equal(chart.yTicks.length, 3);
chart.set('xAxisOffset', 240);
assert.equal(chart.get('yTicks').length, 5);
chart.height = 240;
assert.equal(chart.yTicks.length, 5);
chart.set('xAxisOffset', 241);
assert.equal(chart.get('yTicks').length, 7);
chart.height = 242;
assert.equal(chart.yTicks.length, 7);
});
test('the values for yTicks are rounded to whole numbers', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
yProp: 'bar',
xAxisOffset: 100,
data,
});
assert.deepEqual(chart.get('yTicks'), [0, 250, 500]);
chart.height = 100;
assert.deepEqual(chart.yTicks, [0, 250, 500]);
chart.set('xAxisOffset', 240);
assert.deepEqual(chart.get('yTicks'), [0, 125, 250, 375, 500]);
chart.height = 240;
assert.deepEqual(chart.yTicks, [0, 125, 250, 375, 500]);
chart.set('xAxisOffset', 241);
assert.deepEqual(chart.get('yTicks'), [0, 83, 167, 250, 333, 417, 500]);
chart.height = 242;
assert.deepEqual(chart.yTicks, [0, 83, 167, 250, 333, 417, 500]);
});
test('the values for yTicks are fractions when the domain is between 0 and 1', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
yProp: 'bar',
xAxisOffset: 100,
data: [
{ foo: 1, bar: 0.1 },
{ foo: 2, bar: 0.2 },
@@ -112,38 +103,37 @@ module('Unit | Component | line-chart', function(hooks) {
],
});
assert.deepEqual(chart.get('yTicks'), [0, 0.25, 0.5]);
chart.height = 100;
assert.deepEqual(chart.yTicks, [0, 0.25, 0.5]);
});
test('activeDatumLabel is the xProp value of the activeDatum formatted with xFormat', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
xProp: 'foo',
yProp: 'bar',
data,
activeDatum: data[1],
});
chart.activeDatum = data[1];
assert.equal(
chart.get('activeDatumLabel'),
chart.activeDatumLabel,
d3Format.format(',')(data[1].foo),
'activeDatumLabel correctly formats the correct prop of the correct datum'
);
});
test('activeDatumValue is the yProp value of the activeDatum formatted with yFormat', function(assert) {
const chart = this.owner.factoryFor('component:line-chart').create();
chart.setProperties({
const chart = this.createComponent({
xProp: 'foo',
yProp: 'bar',
data,
activeDatum: data[1],
});
chart.activeDatum = data[1];
assert.equal(
chart.get('activeDatumValue'),
chart.activeDatumValue,
d3Format.format(',.2~r')(data[1].bar),
'activeDatumValue correctly formats the correct prop of the correct datum'
);