mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 10:25:42 +03:00
Merge pull request #10069 from hashicorp/f-ui/line-chart-decomposed
UI: Line chart decomposed
This commit is contained in:
13
ui/app/components/chart-primitives/area.hbs
Normal file
13
ui/app/components/chart-primitives/area.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
<defs>
|
||||
<linearGradient x1="0" x2="0" y1="0" y2="1" class="{{this.colorClass}}" id="{{this.fillId}}">
|
||||
<stop class="start" offset="0%" />
|
||||
<stop class="end" offset="100%" />
|
||||
</linearGradient>
|
||||
<clipPath id="{{this.maskId}}">
|
||||
<path class="fill" d="{{this.area}}" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g class="area {{this.colorClass}}" ...attributes>
|
||||
<path class="line" d="{{this.line}}" />
|
||||
<rect class="fill" x="0" y="0" width="{{@width}}" height="{{@height}}" fill="url(#{{this.fillId}})" clip-path="url(#{{this.maskId}})" />
|
||||
</g>
|
||||
37
ui/app/components/chart-primitives/area.js
Normal file
37
ui/app/components/chart-primitives/area.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { default as d3Shape, area, line } from 'd3-shape';
|
||||
import uniquely from 'nomad-ui/utils/properties/uniquely';
|
||||
|
||||
export default class ChartPrimitiveArea extends Component {
|
||||
get colorClass() {
|
||||
return this.args.colorClass || `${this.args.colorScale}-${this.args.index}`;
|
||||
}
|
||||
|
||||
@uniquely('area-mask') maskId;
|
||||
@uniquely('area-fill') fillId;
|
||||
|
||||
get line() {
|
||||
const { xScale, yScale, xProp, yProp, curveMethod } = this.args;
|
||||
|
||||
const builder = line()
|
||||
.curve(d3Shape[curveMethod])
|
||||
.defined(d => d[yProp] != null)
|
||||
.x(d => xScale(d[xProp]))
|
||||
.y(d => yScale(d[yProp]));
|
||||
|
||||
return builder(this.args.data);
|
||||
}
|
||||
|
||||
get area() {
|
||||
const { xScale, yScale, xProp, yProp, curveMethod } = this.args;
|
||||
|
||||
const builder = area()
|
||||
.curve(d3Shape[curveMethod])
|
||||
.defined(d => d[yProp] != null)
|
||||
.x(d => xScale(d[xProp]))
|
||||
.y0(yScale(0))
|
||||
.y1(d => yScale(d[yProp]));
|
||||
|
||||
return builder(this.args.data);
|
||||
}
|
||||
}
|
||||
17
ui/app/components/chart-primitives/v-annotations.hbs
Normal file
17
ui/app/components/chart-primitives/v-annotations.hbs
Normal file
@@ -0,0 +1,17 @@
|
||||
<div data-test-annotations class="line-chart-annotations" style={{this.chartAnnotationsStyle}} ...attributes>
|
||||
{{#each this.processed key=@key as |annotation|}}
|
||||
<div data-test-annotation class="chart-annotation {{annotation.iconClass}} {{annotation.staggerClass}}" style={{annotation.style}}>
|
||||
<button
|
||||
type="button"
|
||||
title={{annotation.label}}
|
||||
class="indicator {{if (or
|
||||
(and @key (eq-by @key annotation.annotation this.activeAnnotation))
|
||||
(and (not @key) (eq annotation.annotation this.activeAnnotation))
|
||||
) "is-active"}}"
|
||||
{{on "click" (fn this.selectAnnotation annotation.annotation)}}>
|
||||
{{x-icon annotation.icon}}
|
||||
</button>
|
||||
<div class="line" />
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
62
ui/app/components/chart-primitives/v-annotations.js
Normal file
62
ui/app/components/chart-primitives/v-annotations.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import { action } from '@ember/object';
|
||||
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
|
||||
|
||||
const iconFor = {
|
||||
error: 'cancel-circle-fill',
|
||||
info: 'info-circle-fill',
|
||||
};
|
||||
|
||||
const iconClassFor = {
|
||||
error: 'is-danger',
|
||||
info: '',
|
||||
};
|
||||
|
||||
export default class ChartPrimitiveVAnnotations extends Component {
|
||||
@styleString
|
||||
get chartAnnotationsStyle() {
|
||||
return {
|
||||
height: this.args.height,
|
||||
};
|
||||
}
|
||||
|
||||
get processed() {
|
||||
const { scale, prop, annotations, timeseries, format } = this.args;
|
||||
|
||||
if (!annotations || !annotations.length) return null;
|
||||
|
||||
let sortedAnnotations = annotations.sortBy(prop);
|
||||
if (timeseries) {
|
||||
sortedAnnotations = sortedAnnotations.reverse();
|
||||
}
|
||||
|
||||
let prevX = 0;
|
||||
let prevHigh = false;
|
||||
return sortedAnnotations.map(annotation => {
|
||||
const x = scale(annotation[prop]);
|
||||
if (prevX && !prevHigh && Math.abs(x - prevX) < 30) {
|
||||
prevHigh = true;
|
||||
} else if (prevHigh) {
|
||||
prevHigh = false;
|
||||
}
|
||||
const y = prevHigh ? -15 : 0;
|
||||
const formattedX = format(timeseries)(annotation[prop]);
|
||||
|
||||
prevX = x;
|
||||
return {
|
||||
annotation,
|
||||
style: htmlSafe(`transform:translate(${x}px,${y}px)`),
|
||||
icon: iconFor[annotation.type],
|
||||
iconClass: iconClassFor[annotation.type],
|
||||
staggerClass: prevHigh ? 'is-staggered' : '',
|
||||
label: `${annotation.type} event at ${formattedX}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
selectAnnotation(annotation) {
|
||||
if (this.args.annotationClick) this.args.annotationClick(annotation);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
/* 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 { guidFor } from '@ember/object/internals';
|
||||
import { run } from '@ember/runloop';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
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, classNameBindings } from '@ember-decorators/component';
|
||||
import classic from 'ember-classic-decorator';
|
||||
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
|
||||
@@ -33,69 +26,92 @@ 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 iconFor = {
|
||||
error: 'cancel-circle-fill',
|
||||
info: 'info-circle-fill',
|
||||
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 iconClassFor = {
|
||||
error: 'is-danger',
|
||||
info: '',
|
||||
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]);
|
||||
};
|
||||
|
||||
@classic
|
||||
@classNames('chart', 'line-chart')
|
||||
@classNameBindings('annotations.length:with-annotations')
|
||||
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() {}
|
||||
xFormat;
|
||||
yFormat;
|
||||
xScale;
|
||||
yScale;
|
||||
*/
|
||||
|
||||
data = null;
|
||||
annotations = 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';
|
||||
@uniquely('title') titleId;
|
||||
@uniquely('desc') descriptionId;
|
||||
|
||||
@overridable(function() {
|
||||
return null;
|
||||
})
|
||||
description;
|
||||
|
||||
// Private Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
isActive = false;
|
||||
|
||||
@computed()
|
||||
get fillId() {
|
||||
return `line-chart-fill-${guidFor(this)}`;
|
||||
get xProp() {
|
||||
return this.args.xProp || 'time';
|
||||
}
|
||||
get yProp() {
|
||||
return this.args.yProp || 'value';
|
||||
}
|
||||
get data() {
|
||||
return this.args.data || [];
|
||||
}
|
||||
get curve() {
|
||||
return this.args.curve || 'linear';
|
||||
}
|
||||
get chartClass() {
|
||||
return this.args.chartClass || 'is-primary';
|
||||
}
|
||||
|
||||
@computed()
|
||||
get maskId() {
|
||||
return `line-chart-mask-${guidFor(this)}`;
|
||||
@action
|
||||
xFormat(timeseries) {
|
||||
if (this.args.xFormat) return this.args.xFormat;
|
||||
return timeseries ? d3TimeFormat.timeFormat('%b %d, %H:%M') : d3Format.format(',');
|
||||
}
|
||||
|
||||
activeDatum = null;
|
||||
@action
|
||||
yFormat() {
|
||||
if (this.args.yFormat) return this.args.yFormat;
|
||||
return d3Format.format(',.2~r');
|
||||
}
|
||||
|
||||
@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);
|
||||
return this.xFormat(this.args.timeseries)(x);
|
||||
}
|
||||
|
||||
@computed('activeDatum', 'yProp')
|
||||
get activeDatumValue() {
|
||||
const datum = this.activeDatum;
|
||||
|
||||
@@ -105,7 +121,6 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
||||
return this.yFormat()(y);
|
||||
}
|
||||
|
||||
@computed('curve')
|
||||
get curveMethod() {
|
||||
const mappings = {
|
||||
linear: 'curveLinear',
|
||||
@@ -115,49 +130,24 @@ export default class LineChart extends Component.extend(WindowResizable) {
|
||||
return mappings[this.curve];
|
||||
}
|
||||
|
||||
// Overridable functions that retrurn formatter functions
|
||||
xFormat(timeseries) {
|
||||
return timeseries ? d3TimeFormat.timeFormat('%b %d, %H:%M') : d3Format.format(',');
|
||||
@styleString
|
||||
get tooltipStyle() {
|
||||
return this.tooltipPosition;
|
||||
}
|
||||
|
||||
yFormat() {
|
||||
return d3Format.format(',.2~r');
|
||||
}
|
||||
|
||||
tooltipPosition = null;
|
||||
@styleStringProperty('tooltipPosition') tooltipStyle;
|
||||
|
||||
@computed('xAxisOffset')
|
||||
get chartAnnotationBounds() {
|
||||
return {
|
||||
height: this.xAxisOffset,
|
||||
};
|
||||
}
|
||||
@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 domain = data.length ? d3Array.extent(this.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);
|
||||
}
|
||||
|
||||
@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]);
|
||||
@@ -166,23 +156,13 @@ 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;
|
||||
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);
|
||||
}
|
||||
|
||||
@computed('timeseries', 'xScale')
|
||||
get xAxis() {
|
||||
const formatter = this.xFormat(this.timeseries);
|
||||
const formatter = this.xFormat(this.args.timeseries);
|
||||
|
||||
return d3Axis
|
||||
.axisBottom()
|
||||
@@ -191,7 +171,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;
|
||||
@@ -200,7 +179,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();
|
||||
|
||||
@@ -211,7 +189,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;
|
||||
@@ -224,7 +201,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;
|
||||
@@ -233,7 +209,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;
|
||||
@@ -242,110 +217,42 @@ 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;
|
||||
}
|
||||
|
||||
@computed('data.[]', 'xScale', 'yScale', 'curveMethod')
|
||||
get line() {
|
||||
const { xScale, yScale, xProp, yProp, curveMethod } = this;
|
||||
|
||||
const line = d3Shape
|
||||
.line()
|
||||
.curve(d3Shape[curveMethod])
|
||||
.defined(d => d[yProp] != null)
|
||||
.x(d => xScale(d[xProp]))
|
||||
.y(d => yScale(d[yProp]));
|
||||
|
||||
return line(this.data);
|
||||
}
|
||||
|
||||
@computed('data.[]', 'xScale', 'yScale', 'curveMethod')
|
||||
get area() {
|
||||
const { xScale, yScale, xProp, yProp, curveMethod } = this;
|
||||
|
||||
const area = d3Shape
|
||||
.area()
|
||||
.curve(d3Shape[curveMethod])
|
||||
.defined(d => d[yProp] != null)
|
||||
.x(d => xScale(d[xProp]))
|
||||
.y0(yScale(0))
|
||||
.y1(d => yScale(d[yProp]));
|
||||
|
||||
return area(this.data);
|
||||
}
|
||||
|
||||
@computed('annotations.[]', 'xScale', 'xProp', 'timeseries')
|
||||
get processedAnnotations() {
|
||||
const { xScale, xProp, annotations, timeseries } = this;
|
||||
|
||||
if (!annotations || !annotations.length) return null;
|
||||
|
||||
let sortedAnnotations = annotations.sortBy(xProp);
|
||||
if (timeseries) {
|
||||
sortedAnnotations = sortedAnnotations.reverse();
|
||||
}
|
||||
|
||||
let prevX = 0;
|
||||
let prevHigh = false;
|
||||
return sortedAnnotations.map(annotation => {
|
||||
const x = xScale(annotation[xProp]);
|
||||
if (prevX && !prevHigh && Math.abs(x - prevX) < 30) {
|
||||
prevHigh = true;
|
||||
} else if (prevHigh) {
|
||||
prevHigh = false;
|
||||
}
|
||||
const y = prevHigh ? -15 : 0;
|
||||
const formattedX = this.xFormat(timeseries)(annotation[xProp]);
|
||||
|
||||
prevX = x;
|
||||
return {
|
||||
annotation,
|
||||
style: htmlSafe(`transform:translate(${x}px,${y}px)`),
|
||||
icon: iconFor[annotation.type],
|
||||
iconClass: iconClassFor[annotation.type],
|
||||
staggerClass: prevHigh ? 'is-staggered' : '',
|
||||
label: `${annotation.type} event at ${formattedX}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
@action
|
||||
onInsert(element) {
|
||||
this.element = element;
|
||||
this.updateDimensions();
|
||||
|
||||
const canvas = d3.select(this.element.querySelector('.canvas'));
|
||||
const canvas = d3.select(this.element.querySelector('.hover-target'));
|
||||
const updateActiveDatum = this.updateActiveDatum.bind(this);
|
||||
|
||||
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;
|
||||
|
||||
@@ -370,16 +277,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
|
||||
@@ -388,16 +290,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.
|
||||
@@ -417,19 +314,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,19 +1,17 @@
|
||||
import Component from '@ember/component';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { get } from '@ember/object';
|
||||
import { copy } from 'ember-copy';
|
||||
import { computed, get } from '@ember/object';
|
||||
import { tagName } from '@ember-decorators/component';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
@tagName('')
|
||||
export default class ScaleEventsChart extends Component {
|
||||
events = [];
|
||||
/** Args
|
||||
events = []
|
||||
*/
|
||||
|
||||
activeEvent = null;
|
||||
@tracked activeEvent = null;
|
||||
|
||||
@computed('annotations', 'events.[]')
|
||||
get data() {
|
||||
const data = this.events.filterBy('hasCount').sortBy('time');
|
||||
const data = this.args.events.filterBy('hasCount').sortBy('time');
|
||||
|
||||
// Extend the domain of the chart to the current time
|
||||
data.push({
|
||||
@@ -33,9 +31,8 @@ export default class ScaleEventsChart extends Component {
|
||||
return data;
|
||||
}
|
||||
|
||||
@computed('events.[]')
|
||||
get annotations() {
|
||||
return this.events.rejectBy('hasCount').map(ev => ({
|
||||
return this.args.events.rejectBy('hasCount').map(ev => ({
|
||||
type: ev.error ? 'error' : 'info',
|
||||
time: ev.time,
|
||||
event: copy(ev),
|
||||
@@ -46,11 +43,11 @@ export default class ScaleEventsChart extends Component {
|
||||
if (this.activeEvent && get(this.activeEvent, 'event.uid') === get(ev, 'event.uid')) {
|
||||
this.closeEventDetails();
|
||||
} else {
|
||||
this.set('activeEvent', ev);
|
||||
this.activeEvent = ev;
|
||||
}
|
||||
}
|
||||
|
||||
closeEventDetails() {
|
||||
this.set('activeEvent', null);
|
||||
this.activeEvent = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,26 @@
|
||||
import { computed } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import moment from 'moment';
|
||||
import d3TimeFormat from 'd3-time-format';
|
||||
import d3Format from 'd3-format';
|
||||
import d3Scale from 'd3-scale';
|
||||
import { scaleTime, scaleLinear } from 'd3-scale';
|
||||
import d3Array from 'd3-array';
|
||||
import LineChart from 'nomad-ui/components/line-chart';
|
||||
import layout from '../templates/components/line-chart';
|
||||
import formatDuration from 'nomad-ui/utils/format-duration';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class StatsTimeSeries extends LineChart {
|
||||
layout = layout;
|
||||
|
||||
xProp = 'timestamp';
|
||||
yProp = 'percent';
|
||||
timeseries = true;
|
||||
|
||||
xFormat() {
|
||||
export default class StatsTimeSeries extends Component {
|
||||
get xFormat() {
|
||||
return d3TimeFormat.timeFormat('%H:%M:%S');
|
||||
}
|
||||
|
||||
yFormat() {
|
||||
get yFormat() {
|
||||
return d3Format.format('.1~%');
|
||||
}
|
||||
|
||||
// Specific a11y descriptors
|
||||
title = 'Stats Time Series Chart';
|
||||
|
||||
@computed('data.[]', 'xProp', 'yProp')
|
||||
get description() {
|
||||
const { xProp, yProp, data } = this;
|
||||
const yRange = d3Array.extent(data, d => d[yProp]);
|
||||
const xRange = d3Array.extent(data, d => d[xProp]);
|
||||
const yFormatter = this.yFormat();
|
||||
const data = this.args.data;
|
||||
const yRange = d3Array.extent(data, d => d.percent);
|
||||
const xRange = d3Array.extent(data, d => d.timestamp);
|
||||
const yFormatter = this.yFormat;
|
||||
|
||||
const duration = formatDuration(xRange[1] - xRange[0], 'ms', true);
|
||||
|
||||
@@ -42,36 +29,30 @@ export default class StatsTimeSeries extends LineChart {
|
||||
)} to ${yFormatter(yRange[1])}`;
|
||||
}
|
||||
|
||||
@computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset')
|
||||
get xScale() {
|
||||
const xProp = this.xProp;
|
||||
const scale = this.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
|
||||
const data = this.data;
|
||||
xScale(data, yAxisOffset) {
|
||||
const scale = scaleTime();
|
||||
|
||||
const [low, high] = d3Array.extent(data, d => d[xProp]);
|
||||
const [low, high] = d3Array.extent(data, d => d.timestamp);
|
||||
const minLow = moment(high)
|
||||
.subtract(5, 'minutes')
|
||||
.toDate();
|
||||
|
||||
const extent = data.length ? [Math.min(low, minLow), high] : [minLow, new Date()];
|
||||
scale.rangeRound([10, this.yAxisOffset]).domain(extent);
|
||||
scale.rangeRound([10, yAxisOffset]).domain(extent);
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
@computed('data.[]', 'yProp', 'xAxisOffset')
|
||||
get yScale() {
|
||||
const yProp = this.yProp;
|
||||
const yValues = (this.data || []).mapBy(yProp);
|
||||
yScale(data, xAxisOffset) {
|
||||
const yValues = (data || []).mapBy('percent');
|
||||
|
||||
let [low, high] = [0, 1];
|
||||
if (yValues.compact().length) {
|
||||
[low, high] = d3Array.extent(yValues);
|
||||
}
|
||||
|
||||
return d3Scale
|
||||
.scaleLinear()
|
||||
.rangeRound([this.xAxisOffset, 10])
|
||||
return scaleLinear()
|
||||
.rangeRound([xAxisOffset, 10])
|
||||
.domain([Math.min(0, low), Math.max(1, high)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,18 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
.line {
|
||||
fill: transparent;
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
.hover-target {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
}
|
||||
|
||||
.hover-target {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
}
|
||||
.line {
|
||||
fill: transparent;
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
|
||||
.area {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.axis {
|
||||
@@ -60,7 +62,7 @@
|
||||
@each $name, $pair in $colors {
|
||||
$color: nth($pair, 1);
|
||||
|
||||
.canvas.is-#{$name} {
|
||||
.area.is-#{$name} {
|
||||
.line {
|
||||
stroke: $color;
|
||||
}
|
||||
|
||||
@@ -1,54 +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>
|
||||
<defs>
|
||||
<linearGradient x1="0" x2="0" y1="0" y2="1" class="{{this.chartClass}}" id="{{this.fillId}}">
|
||||
<stop class="start" offset="0%" />
|
||||
<stop class="end" offset="100%" />
|
||||
</linearGradient>
|
||||
<clipPath id="{{this.maskId}}">
|
||||
<path class="fill" d="{{this.area}}" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g class="y-gridlines gridlines" transform="translate({{this.yAxisOffset}}, 0)"></g>
|
||||
<g class="canvas {{this.chartClass}}">
|
||||
<path class="line" d="{{this.line}}" />
|
||||
<rect class="area" x="0" y="0" width="{{this.yAxisOffset}}" height="{{this.xAxisOffset}}" fill="url(#{{this.fillId}})" clip-path="url(#{{this.maskId}})" />
|
||||
<div
|
||||
class="chart line-chart"
|
||||
...attributes
|
||||
{{did-insert this.onInsert}}
|
||||
{{did-update this.renderChart}}
|
||||
{{window-resize this.updateDimensions}}>
|
||||
<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}}
|
||||
</desc>
|
||||
<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}}" />
|
||||
</g>
|
||||
<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>
|
||||
</svg>
|
||||
<div data-test-annotations class="line-chart-annotations" style={{this.chartAnnotationsStyle}}>
|
||||
{{#each this.processedAnnotations key=this.annotationKey as |annotation|}}
|
||||
<div data-test-annotation class="chart-annotation {{annotation.iconClass}} {{annotation.staggerClass}}" style={{annotation.style}}>
|
||||
<button
|
||||
type="button"
|
||||
class="indicator {{if (or
|
||||
(and this.annotationKey (eq-by this.annotationKey annotation.annotation this.activeAnnotation))
|
||||
(and (not this.annotationKey) (eq annotation.annotation this.activeAnnotation))
|
||||
) "is-active"}}"
|
||||
title={{annotation.label}}
|
||||
onclick={{action this.annotationClick annotation.annotation}}>
|
||||
{{x-icon annotation.icon}}
|
||||
</button>
|
||||
<div class="line" />
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<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>
|
||||
</svg>
|
||||
<ChartPrimitives::VAnnotations
|
||||
@annotations={{@annotations}}
|
||||
@key={{@annotationKey}}
|
||||
@annotationClick={{action this.annotationClick}}
|
||||
@timeseries={{@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: 2.6 KiB After Width: | Height: | Size: 1.9 KiB |
12
ui/app/templates/components/stats-time-series.hbs
Normal file
12
ui/app/templates/components/stats-time-series.hbs
Normal file
@@ -0,0 +1,12 @@
|
||||
<LineChart
|
||||
@data={{@data}}
|
||||
@xProp="timestamp"
|
||||
@yProp="percent"
|
||||
@chartClass={{@chartClass}}
|
||||
@timeseries={{true}}
|
||||
@title="Stats Time Series Chart"
|
||||
@description={{this.description}}
|
||||
@xScale={{this.xScale}}
|
||||
@yScale={{this.yScale}}
|
||||
@xFormat={{this.xFormat}}
|
||||
@yFormat={{this.yFormat}} />
|
||||
31
ui/app/utils/properties/glimmer-style-string.js
Normal file
31
ui/app/utils/properties/glimmer-style-string.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
// A decorator for transforming an object into an html
|
||||
// compatible style attribute string.
|
||||
//
|
||||
// ex. @styleString
|
||||
// get styleStr() {
|
||||
// return { color: '#FF0', 'border-width': '1px' }
|
||||
// }
|
||||
export default function styleString(target, name, descriptor) {
|
||||
if (!descriptor.get) throw new Error('styleString only works on getters');
|
||||
const orig = descriptor.get;
|
||||
descriptor.get = function() {
|
||||
const styles = orig.apply(this);
|
||||
|
||||
let str = '';
|
||||
|
||||
if (styles) {
|
||||
str = Object.keys(styles)
|
||||
.reduce(function(arr, key) {
|
||||
const val = styles[key];
|
||||
arr.push(key + ':' + (typeof val === 'number' ? val.toFixed(2) + 'px' : val));
|
||||
return arr;
|
||||
}, [])
|
||||
.join(';');
|
||||
}
|
||||
|
||||
return htmlSafe(str);
|
||||
};
|
||||
return descriptor;
|
||||
}
|
||||
12
ui/app/utils/properties/uniquely.js
Normal file
12
ui/app/utils/properties/uniquely.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { computed } from '@ember/object';
|
||||
import { guidFor } from '@ember/object/internals';
|
||||
|
||||
// An Ember.Computed property for creating a unique string with a
|
||||
// common prefix (based on the guid of the object with the property)
|
||||
//
|
||||
// ex. @uniquely('name') // 'name-ember129383'
|
||||
export default function uniquely(prefix) {
|
||||
return computed(function() {
|
||||
return `${prefix}-${guidFor(this)}`;
|
||||
});
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export let LiveData = () => {
|
||||
clearInterval(this.timer);
|
||||
},
|
||||
|
||||
secondsFormat() {
|
||||
get secondsFormat() {
|
||||
return date => moment(date).format('HH:mm:ss');
|
||||
},
|
||||
}).create(),
|
||||
@@ -153,6 +153,7 @@ export let Annotations = () => {
|
||||
<div class="block" style="height:250px">
|
||||
{{#if (and this.data this.annotations)}}
|
||||
<LineChart
|
||||
class="with-annotations"
|
||||
@timeseries={{true}}
|
||||
@xProp="x"
|
||||
@yProp="y"
|
||||
@@ -166,6 +167,7 @@ export let Annotations = () => {
|
||||
<div class="block" style="height:150px; width:450px">
|
||||
{{#if (and this.data this.annotations)}}
|
||||
<LineChart
|
||||
class="with-annotations"
|
||||
@timeseries={{true}}
|
||||
@xProp="x"
|
||||
@yProp="y"
|
||||
|
||||
@@ -12,9 +12,11 @@ export default ArrayProxy.extend({
|
||||
init(array) {
|
||||
this.set('content', A([]));
|
||||
this._super(...arguments);
|
||||
this[Symbol.iterator] = this.content[Symbol.iterator];
|
||||
|
||||
next(this, () => {
|
||||
this.set('content', A(array));
|
||||
this[Symbol.iterator] = this.content[Symbol.iterator];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function setupGlimmerComponentFactory(hooks, componentKey) {
|
||||
// Look up the component class in the glimmer component manager and return a
|
||||
// function to construct components as if they were functions.
|
||||
function glimmerComponentInstantiator(owner, componentKey) {
|
||||
return args => {
|
||||
return (args = {}) => {
|
||||
const componentManager = owner.lookup('component-manager:glimmer');
|
||||
const componentClass = owner.factoryFor(`component:${componentKey}`).class;
|
||||
return componentManager.createComponent(componentClass, { named: args });
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ module('Integration | Component | primary metric', function(hooks) {
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-line-chart] .canvas').classList.contains('is-info'),
|
||||
find('[data-test-line-chart] .area').classList.contains('is-info'),
|
||||
'Info class for CPU metric'
|
||||
);
|
||||
});
|
||||
@@ -106,7 +106,7 @@ module('Integration | Component | primary metric', function(hooks) {
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-line-chart] .canvas').classList.contains('is-danger'),
|
||||
find('[data-test-line-chart] .area').classList.contains('is-danger'),
|
||||
'Danger class for Memory metric'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import sinon from 'sinon';
|
||||
import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
|
||||
|
||||
module('Unit | Component | scale-events-chart', function(hooks) {
|
||||
setupTest(hooks);
|
||||
setupGlimmerComponentFactory(hooks, 'scale-events-chart');
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
this.refTime = new Date();
|
||||
@@ -16,13 +18,12 @@ module('Unit | Component | scale-events-chart', function(hooks) {
|
||||
});
|
||||
|
||||
test('the current date is appended as a datum for the line chart to render', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:scale-events-chart').create();
|
||||
const events = [
|
||||
{ time: new Date('2020-08-02T04:06:00'), count: 2, hasCount: true },
|
||||
{ time: new Date('2020-08-01T04:06:00'), count: 2, hasCount: true },
|
||||
];
|
||||
|
||||
chart.set('events', events);
|
||||
const chart = this.createComponent({ events });
|
||||
|
||||
assert.equal(chart.data.length, events.length + 1);
|
||||
assert.deepEqual(chart.data.slice(0, events.length), events.sortBy('time'));
|
||||
@@ -33,7 +34,6 @@ module('Unit | Component | scale-events-chart', function(hooks) {
|
||||
});
|
||||
|
||||
test('if the earliest annotation is outside the domain of the events, the earliest annotation time is added as a datum for the line chart to render', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:scale-events-chart').create();
|
||||
const annotationOutside = [
|
||||
{ time: new Date('2020-08-01T04:06:00'), hasCount: false, error: true },
|
||||
{ time: new Date('2020-08-02T04:06:00'), count: 2, hasCount: true },
|
||||
@@ -45,7 +45,7 @@ module('Unit | Component | scale-events-chart', function(hooks) {
|
||||
{ time: new Date('2020-08-03T04:06:00'), count: 2, hasCount: true },
|
||||
];
|
||||
|
||||
chart.set('events', annotationOutside);
|
||||
const chart = this.createComponent({ events: annotationOutside });
|
||||
|
||||
assert.equal(chart.data.length, annotationOutside.length + 1);
|
||||
assert.deepEqual(
|
||||
@@ -57,7 +57,7 @@ module('Unit | Component | scale-events-chart', function(hooks) {
|
||||
assert.equal(appendedDatum.count, annotationOutside[1].count);
|
||||
assert.equal(+appendedDatum.time, +annotationOutside[0].time);
|
||||
|
||||
chart.set('events', annotationInside);
|
||||
chart.args.events = annotationInside;
|
||||
|
||||
assert.equal(chart.data.length, annotationOutside.length);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -3,9 +3,11 @@ import { setupTest } from 'ember-qunit';
|
||||
import moment from 'moment';
|
||||
import d3Format from 'd3-format';
|
||||
import d3TimeFormat from 'd3-time-format';
|
||||
import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
|
||||
|
||||
module('Unit | Component | stats-time-series', function(hooks) {
|
||||
setupTest(hooks);
|
||||
setupGlimmerComponentFactory(hooks, 'stats-time-series');
|
||||
|
||||
const ts = (offset, resolution = 'm') =>
|
||||
moment()
|
||||
@@ -46,35 +48,29 @@ module('Unit | Component | stats-time-series', function(hooks) {
|
||||
];
|
||||
|
||||
test('xFormat is time-formatted for hours, minutes, and seconds', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', wideData);
|
||||
const chart = this.createComponent({ data: wideData });
|
||||
|
||||
wideData.forEach(datum => {
|
||||
assert.equal(
|
||||
chart.xFormat()(datum.timestamp),
|
||||
chart.xFormat(datum.timestamp),
|
||||
d3TimeFormat.timeFormat('%H:%M:%S')(datum.timestamp)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('yFormat is percent-formatted', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', wideData);
|
||||
const chart = this.createComponent({ data: wideData });
|
||||
|
||||
wideData.forEach(datum => {
|
||||
assert.equal(chart.yFormat()(datum.percent), d3Format.format('.1~%')(datum.percent));
|
||||
assert.equal(chart.yFormat(datum.percent), d3Format.format('.1~%')(datum.percent));
|
||||
});
|
||||
});
|
||||
|
||||
test('x scale domain is at least five minutes', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', narrowData);
|
||||
const chart = this.createComponent({ data: narrowData });
|
||||
|
||||
assert.equal(
|
||||
+chart.get('xScale').domain()[0],
|
||||
+chart.xScale(narrowData, 0).domain()[0],
|
||||
+moment(Math.max(...narrowData.mapBy('timestamp')))
|
||||
.subtract(5, 'm')
|
||||
.toDate(),
|
||||
@@ -83,21 +79,17 @@ module('Unit | Component | stats-time-series', function(hooks) {
|
||||
});
|
||||
|
||||
test('x scale domain is greater than five minutes when the domain of the data is larger than five minutes', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', wideData);
|
||||
const chart = this.createComponent({ data: wideData });
|
||||
|
||||
assert.equal(
|
||||
+chart.get('xScale').domain()[0],
|
||||
+chart.xScale(wideData, 0).domain()[0],
|
||||
Math.min(...wideData.mapBy('timestamp')),
|
||||
'The lower bound of the xScale is the oldest timestamp in the dataset'
|
||||
);
|
||||
});
|
||||
|
||||
test('y scale domain is typically 0 to 1 (0 to 100%)', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', wideData);
|
||||
const chart = this.createComponent({ data: wideData });
|
||||
|
||||
assert.deepEqual(
|
||||
[Math.min(...wideData.mapBy('percent')), Math.max(...wideData.mapBy('percent'))],
|
||||
@@ -106,45 +98,41 @@ module('Unit | Component | stats-time-series', function(hooks) {
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
chart.get('yScale').domain(),
|
||||
chart.yScale(wideData, 0).domain(),
|
||||
[0, 1],
|
||||
'The bounds of the yScale are still 0 and 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('the extent of the y domain overrides the default 0 to 1 domain when there are values beyond these bounds', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
|
||||
chart.set('data', unboundedData);
|
||||
const chart = this.createComponent({ data: unboundedData });
|
||||
|
||||
assert.deepEqual(
|
||||
chart.get('yScale').domain(),
|
||||
chart.yScale(unboundedData, 0).domain(),
|
||||
[-0.5, 1.5],
|
||||
'The bounds of the yScale match the bounds of the unbounded data'
|
||||
);
|
||||
|
||||
chart.set('data', [unboundedData[0]]);
|
||||
chart.args.data = [unboundedData[0]];
|
||||
|
||||
assert.deepEqual(
|
||||
chart.get('yScale').domain(),
|
||||
chart.yScale(chart.args.data, 0).domain(),
|
||||
[-0.5, 1],
|
||||
'The upper bound is still the default 1, but the lower bound is overridden due to the unbounded low value'
|
||||
);
|
||||
|
||||
chart.set('data', [unboundedData[1]]);
|
||||
chart.args.data = [unboundedData[1]];
|
||||
|
||||
assert.deepEqual(
|
||||
chart.get('yScale').domain(),
|
||||
chart.yScale(chart.args.data, 0).domain(),
|
||||
[0, 1.5],
|
||||
'The lower bound is still the default 0, but the upper bound is overridden due to the unbounded high value'
|
||||
);
|
||||
});
|
||||
|
||||
test('when there are only empty frames in the data array, the default y domain is used', function(assert) {
|
||||
const chart = this.owner.factoryFor('component:stats-time-series').create();
|
||||
const chart = this.createComponent({ data: nullData });
|
||||
|
||||
chart.set('data', nullData);
|
||||
|
||||
assert.deepEqual(chart.get('yScale').domain(), [0, 1], 'The bounds are 0 and 1');
|
||||
assert.deepEqual(chart.yScale(nullData, 0).domain(), [0, 1], 'The bounds are 0 and 1');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user