mirror of
https://github.com/kemko/nomad.git
synced 2026-01-05 09:55:44 +03:00
Refactor line chart scales and refactor tests
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user