diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js new file mode 100644 index 00000000000..145bcbabeb0 --- /dev/null +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -0,0 +1,62 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + this.incrementProperty('timerTicks'); + + const ref = this.get('lineChartLive'); + ref.addObject({ ts: Date.now(), val: Math.random() * 30 + 20 }); + if (ref.length > 60) { + ref.splice(0, ref.length - 60); + } + }, 500) + ); + }.on('init'), + + willDestroy() { + clearInterval(this.get('timer')); + }, + + lineChartData: computed(() => { + return [ + { year: 2010, value: 10 }, + { year: 2011, value: 10 }, + { year: 2012, value: 20 }, + { year: 2013, value: 30 }, + { year: 2014, value: 50 }, + { year: 2015, value: 80 }, + { year: 2016, value: 130 }, + { year: 2017, value: 210 }, + { year: 2018, value: 340 }, + ]; + }), + + lineChartMild: computed(() => { + return [ + { year: 2010, value: 100 }, + { year: 2011, value: 90 }, + { year: 2012, value: 120 }, + { year: 2013, value: 130 }, + { year: 2014, value: 115 }, + { year: 2015, value: 105 }, + { year: 2016, value: 90 }, + { year: 2017, value: 85 }, + { year: 2018, value: 90 }, + ]; + }), + + lineChartLive: computed(() => { + return []; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/components/freestyle/sg-stats-time-series.js b/ui/app/components/freestyle/sg-stats-time-series.js new file mode 100644 index 00000000000..ac2a445979f --- /dev/null +++ b/ui/app/components/freestyle/sg-stats-time-series.js @@ -0,0 +1,76 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; +import moment from 'moment'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + const metricsHigh = this.get('metricsHigh'); + const prev = metricsHigh.length ? metricsHigh[metricsHigh.length - 1].value : 0.9; + this.appendTSValue( + metricsHigh, + Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0.5), 1) + ); + + const metricsLow = this.get('metricsLow'); + const prev2 = metricsLow.length ? metricsLow[metricsLow.length - 1].value : 0.1; + this.appendTSValue( + metricsLow, + Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 0.5) + ); + }, 1000) + ); + }.on('init'), + + appendTSValue(array, value, maxLength = 300) { + array.addObject({ + timestamp: Date.now(), + value, + }); + + if (array.length > maxLength) { + array.splice(0, array.length - maxLength); + } + }, + + willDestroy() { + clearInterval(this.get('timer')); + }, + + metricsHigh: computed(() => { + return []; + }), + + metricsLow: computed(() => { + return []; + }), + + staticMetrics: computed(() => { + const ts = offset => + moment() + .subtract(offset, 'm') + .toDate(); + return [ + { timestamp: ts(20), value: 0.5 }, + { timestamp: ts(18), value: 0.5 }, + { timestamp: ts(16), value: 0.4 }, + { timestamp: ts(14), value: 0.3 }, + { timestamp: ts(12), value: 0.9 }, + { timestamp: ts(10), value: 0.3 }, + { timestamp: ts(8), value: 0.3 }, + { timestamp: ts(6), value: 0.4 }, + { timestamp: ts(4), value: 0.5 }, + { timestamp: ts(2), value: 0.6 }, + { timestamp: ts(0), value: 0.6 }, + ]; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js new file mode 100644 index 00000000000..3ef5b1908af --- /dev/null +++ b/ui/app/components/line-chart.js @@ -0,0 +1,314 @@ +import Component from '@ember/component'; +import { computed, observer } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { run } from '@ember/runloop'; +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'; + +// Returns a new array with the specified number of points linearly +// distributed across the bounds +const lerp = ([low, high], numPoints) => { + const step = (high - low) / (numPoints - 1); + const arr = []; + for (var i = 0; i < numPoints; i++) { + arr.push(low + step * i); + } + return arr; +}; + +// Round a number or an array of numbers +const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val)); + +export default Component.extend(WindowResizable, { + classNames: ['chart', 'line-chart'], + + // Public API + + data: null, + xProp: null, + yProp: null, + timeseries: false, + chartClass: 'is-primary', + + // Private Properties + + width: 0, + height: 0, + + isActive: false, + + fillId: computed(function() { + return `line-chart-fill-${guidFor(this)}`; + }), + + maskId: computed(function() { + return `line-chart-mask-${guidFor(this)}`; + }), + + activeDatum: null, + + activeDatumLabel: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const x = datum[this.get('xProp')]; + return this.xFormat(this.get('timeseries'))(x); + }), + + activeDatumValue: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const y = datum[this.get('yProp')]; + return this.yFormat()(y); + }), + + // Overridable functions that retrurn formatter functions + xFormat(timeseries) { + return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(','); + }, + + yFormat() { + return d3Format.format(',.2~r'); + }, + + tooltipPosition: null, + tooltipStyle: styleStringProperty('tooltipPosition'), + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + + scale + .rangeRound([10, this.get('yAxisOffset')]) + .domain(d3Array.extent(this.get('data'), d => d[xProp])); + + return scale; + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + const yProp = this.get('yProp'); + let max = d3Array.max(this.get('data'), d => d[yProp]); + if (max > 1) { + max = nice(max); + } + + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, max]); + }), + + xAxis: computed('xScale', function() { + const formatter = this.xFormat(this.get('timeseries')); + + return d3Axis + .axisBottom() + .scale(this.get('xScale')) + .ticks(5) + .tickFormat(formatter); + }), + + yTicks: computed('xAxisOffset', function() { + const height = this.get('xAxisOffset'); + const tickCount = Math.ceil(height / 120) * 2 + 1; + const domain = this.get('yScale').domain(); + const ticks = lerp(domain, tickCount); + return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; + }), + + yAxis: computed('yScale', function() { + const formatter = this.yFormat(); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(this.get('yTicks')) + .tickFormat(formatter); + }), + + yGridlines: computed('yScale', function() { + // The first gridline overlaps the x-axis, so remove it + const [, ...ticks] = this.get('yTicks'); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(ticks) + .tickSize(-this.get('yAxisOffset')) + .tickFormat(''); + }), + + xAxisHeight: computed(function() { + // Avoid divide by zero errors by always having a height + if (!this.element) return 1; + + const axis = this.element.querySelector('.x-axis'); + return axis && axis.getBBox().height; + }), + + yAxisWidth: computed(function() { + // Avoid divide by zero errors by always having a width + if (!this.element) return 1; + + const axis = this.element.querySelector('.y-axis'); + return axis && axis.getBBox().width; + }), + + xAxisOffset: computed('height', 'xAxisHeight', function() { + return this.get('height') - this.get('xAxisHeight'); + }), + + yAxisOffset: computed('width', 'yAxisWidth', function() { + return this.get('width') - this.get('yAxisWidth'); + }), + + line: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const line = d3Shape + .line() + .x(d => xScale(d[xProp])) + .y(d => yScale(d[yProp])); + + return line(this.get('data')); + }), + + area: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const area = d3Shape + .area() + .x(d => xScale(d[xProp])) + .y0(yScale(0)) + .y1(d => yScale(d[yProp])); + + return area(this.get('data')); + }), + + didInsertElement() { + this.updateDimensions(); + + const canvas = d3.select(this.element.querySelector('.canvas')); + const updateActiveDatum = this.updateActiveDatum.bind(this); + + const chart = this; + canvas.on('mouseenter', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + run.schedule('afterRender', chart, () => chart.set('isActive', true)); + }); + + canvas.on('mousemove', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + }); + + canvas.on('mouseleave', () => { + run.schedule('afterRender', this, () => this.set('isActive', false)); + this.set('activeDatum', null); + }); + }, + + didUpdateAttrs() { + this.renderChart(); + }, + + updateActiveDatum(mouseX) { + const { xScale, xProp, yScale, yProp, data } = this.getProperties( + 'xScale', + 'xProp', + 'yScale', + 'yProp', + 'data' + ); + + // Map the mouse coordinate to the index in the data array + const bisector = d3Array.bisector(d => d[xProp]).left; + const x = xScale.invert(mouseX); + const index = bisector(data, x, 1); + + // The data point on either side of the cursor + const dLeft = data[index - 1]; + const dRight = data[index]; + + // Pick the closer point + const datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + + this.set('activeDatum', datum); + this.set('tooltipPosition', { + left: xScale(datum[xProp]), + top: yScale(datum[yProp]) - 10, + }); + }, + + updateChart: observer('data.[]', function() { + this.renderChart(); + }), + + // The renderChart method should only ever be responsible for runtime calculations + // and appending d3 created elements to the DOM (such as axes). + renderChart() { + // There is nothing to do if the element hasn't been inserted yet + if (!this.element) return; + + // First, 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. + this.mountD3Elements(); + if (this.get('isActive')) { + this.updateActiveDatum(this.get('latestMouseX')); + } + }); + }, + + mountD3Elements() { + d3.select(this.element.querySelector('.x-axis')).call(this.get('xAxis')); + d3.select(this.element.querySelector('.y-axis')).call(this.get('yAxis')); + d3.select(this.element.querySelector('.y-gridlines')).call(this.get('yGridlines')); + }, + + windowResizeHandler() { + run.once(this, this.updateDimensions); + }, + + updateDimensions() { + const $svg = this.$('svg'); + const width = $svg.width(); + const height = $svg.height(); + + this.setProperties({ width, height }); + this.renderChart(); + }, +}); diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js new file mode 100644 index 00000000000..90a229e6390 --- /dev/null +++ b/ui/app/components/stats-time-series.js @@ -0,0 +1,41 @@ +import { computed } from '@ember/object'; +import moment from 'moment'; +import d3TimeFormat from 'd3-time-format'; +import d3Format from 'd3-format'; +import d3Scale from 'd3-scale'; +import d3Array from 'd3-array'; +import LineChart from 'nomad-ui/components/line-chart'; + +export default LineChart.extend({ + xProp: 'timestamp', + yProp: 'value', + timeseries: true, + + xFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, + + yFormat() { + return d3Format.format('.1~%'); + }, + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + + const [low, high] = d3Array.extent(this.get('data'), d => d[xProp]); + const minLow = moment(high) + .subtract(5, 'minutes') + .toDate(); + scale.rangeRound([10, this.get('yAxisOffset')]).domain([Math.min(low, minLow), high]); + + return scale; + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, 1]); + }), +}); diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index 3db77a93b90..571535ca5ea 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,4 +1,5 @@ @import './charts/distribution-bar'; +@import './charts/line-chart'; @import './charts/tooltip'; @import './charts/colors'; diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index e204c723f2b..6eedeeaecf4 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -85,4 +85,12 @@ $lost: $dark; &.lost { background: $lost; } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + &.is-#{$name} { + background: $color; + } + } } diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss new file mode 100644 index 00000000000..eff9ec53998 --- /dev/null +++ b/ui/app/styles/charts/line-chart.scss @@ -0,0 +1,69 @@ +.chart.line-chart { + display: block; + height: 100%; + + svg { + display: block; + height: 100%; + width: 100%; + overflow: visible; + } + + .canvas { + .line { + fill: transparent; + stroke-width: 1.25; + } + + .hover-target { + fill: transparent; + stroke: transparent; + } + } + + .axis { + line, + path { + stroke: $grey-blue; + } + + text { + fill: darken($grey-blue, 20%); + } + } + + .gridlines { + path { + stroke-width: 0; + } + + line { + stroke: lighten($grey-blue, 10%); + stroke-dasharray: 5 10; + } + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + .canvas.is-#{$name} { + .line { + stroke: $color; + } + } + + linearGradient { + &.is-#{$name} { + > .start { + stop-color: $color; + stop-opacity: 0.6; + } + + > .end { + stop-color: $color; + stop-opacity: 0.05; + } + } + } + } +} diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 580e0ab3b87..028b7b9442c 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -15,6 +15,10 @@ pointer-events: none; z-index: $z-tooltip; + &.is-snappy { + transition: 0.2s top ease-out, 0.05s left ease-out; + } + &::before { pointer-events: none; display: inline-block; @@ -53,41 +57,48 @@ ol { list-style: none; + } - li { - display: flex; - flex-flow: row; - flex-wrap: nowrap; - justify-content: space-between; - padding: 0.25rem 0.5rem; + ol > li, + p { + display: flex; + flex-flow: row; + flex-wrap: nowrap; + justify-content: space-between; + padding: 0.25rem 0.5rem; - span { - display: inline-block; - } + span { + display: inline-block; + } - .label { - font-weight: $weight-bold; - color: rgba($black, 0.6); - margin: 0; + .label { + font-weight: $weight-bold; + color: $black; + margin: 0; - &.is-empty { - color: rgba($grey, 0.6); - } + &.is-empty { + color: rgba($grey, 0.6); } + } + } - &.active { - color: $black; - background: $white-ter; + ol > li { + .label { + color: rgba($black, 0.6); + } - .label { - color: $black; - } - } + &.active { + color: $black; + background: $white-ter; - + li { - border-top: 1px solid $grey-light; + .label { + color: $black; } } + + + li { + border-top: 1px solid $grey-light; + } } } } diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs new file mode 100644 index 00000000000..67dd939b01d --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -0,0 +1,32 @@ +{{#freestyle-usage "line-chart-standard" title="Standard"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-primary"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-info"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "line-chart-fill-width" title="Fluid width"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-danger"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-warning"}} +
+{{/freestyle-usage}} +{{#freestyle-annotation}} +

A line chart will assume the width of its container. This includes the dimensions of the axes, which are calculated based on real DOM measurements. This requires a two-pass render: first the axes are placed with their real domains (in order to capture width and height of tick labels), second the axes are adjusted to make sure both the x and y axes are within the height and width bounds of the container.

+{{/freestyle-annotation}} + +{{#freestyle-usage "line-chart-live-data" title="Live data"}} +
+ {{line-chart + data=lineChartLive + xProp="ts" + yProp="val" + timeseries=true + chartClass="is-primary" + xFormat=secondsFormat}} +
+{{/freestyle-usage}} diff --git a/ui/app/templates/components/freestyle/sg-stats-time-series.hbs b/ui/app/templates/components/freestyle/sg-stats-time-series.hbs new file mode 100644 index 00000000000..3b077e1d552 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-stats-time-series.hbs @@ -0,0 +1,20 @@ +{{#freestyle-usage "stats-time-series-standard" title="Stats Time Series"}} +
+ {{stats-time-series data=staticMetrics chartClass="is-primary"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "stats-time-series-comparison" title="Stats Time Series High/Low Comparison"}} +
+
+ {{stats-time-series data=metricsHigh chartClass="is-info"}} +
+
+ {{stats-time-series data=metricsLow chartClass="is-info"}} +
+
+{{/freestyle-usage}} +{{#freestyle-annotation}} +

Line charts, and therefore stats time series charts, use a constant linear gradient with a height equal to the canvas. This makes the color intensity of the gradient at values consistent across charts as long as those charts have the same y-axis domain.

+

This is used to great effect with stats charts since they all have a y-axis domain of 0-100%.

+{{/freestyle-annotation}} diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs new file mode 100644 index 00000000000..33e8dc19f14 --- /dev/null +++ b/ui/app/templates/components/line-chart.hbs @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + +
+

+ + + {{activeDatumLabel}} + + {{activeDatumValue}} +

+
diff --git a/ui/app/templates/components/stats-time-series.hbs b/ui/app/templates/components/stats-time-series.hbs new file mode 100644 index 00000000000..494fc7ebf00 --- /dev/null +++ b/ui/app/templates/components/stats-time-series.hbs @@ -0,0 +1 @@ +{{partial "components/line-chart"}} diff --git a/ui/app/templates/freestyle.hbs b/ui/app/templates/freestyle.hbs index e33756a9000..87bdb7c7685 100644 --- a/ui/app/templates/freestyle.hbs +++ b/ui/app/templates/freestyle.hbs @@ -108,9 +108,17 @@ {{freestyle/sg-distribution-bar-jumbo}} {{/section.subsection}} + {{#section.subsection name="Line Chart"}} + {{freestyle/sg-line-chart}} + {{/section.subsection}} + {{#section.subsection name="Progress Bar"}} {{freestyle/sg-progress-bar}} {{/section.subsection}} + + {{#section.subsection name="Stats Time Series"}} + {{freestyle/sg-stats-time-series}} + {{/section.subsection}} {{/freestyle-section}} {{/freestyle-guide}} diff --git a/ui/package.json b/ui/package.json index b7579331c1b..483a73fef3f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,7 +28,13 @@ "broccoli-asset-rev": "^2.4.5", "bulma": "0.6.1", "core-js": "^2.4.1", + "d3-array": "^1.2.0", + "d3-axis": "^1.0.0", + "d3-format": "^1.3.0", "d3-selection": "^1.1.0", + "d3-scale": "^1.0.0", + "d3-shape": "^1.2.0", + "d3-time-format": "^2.1.0", "d3-transition": "^1.1.0", "ember-ajax": "^3.0.0", "ember-auto-import": "^1.0.1", diff --git a/ui/tests/unit/components/line-chart-test.js b/ui/tests/unit/components/line-chart-test.js new file mode 100644 index 00000000000..fda7021f162 --- /dev/null +++ b/ui/tests/unit/components/line-chart-test.js @@ -0,0 +1,148 @@ +import { test, moduleForComponent } from 'ember-qunit'; +import d3Format from 'd3-format'; + +moduleForComponent('line-chart', 'Unit | Component | line-chart'); + +const data = [ + { foo: 1, bar: 100 }, + { foo: 2, bar: 200 }, + { foo: 3, bar: 300 }, + { foo: 8, bar: 400 }, + { foo: 4, bar: 500 }, +]; + +test('x scale domain is the min and max values in data based on the xProp value', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + xProp: 'foo', + data, + }); + + let [xDomainLow, xDomainHigh] = chart.get('xScale').domain(); + assert.equal( + xDomainLow, + Math.min(...data.mapBy('foo')), + 'Domain lower bound is the lowest foo value' + ); + assert.equal( + xDomainHigh, + Math.max(...data.mapBy('foo')), + 'Domain upper bound is the highest foo value' + ); + + chart.set('data', [...data, { foo: 12, bar: 600 }]); + + [, xDomainHigh] = chart.get('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.subject(); + + chart.setProperties({ + yProp: 'bar', + data, + }); + + let [yDomainLow, yDomainHigh] = chart.get('yScale').domain(); + assert.equal(yDomainLow, 0, 'Domain lower bound is always 0'); + assert.equal( + yDomainHigh, + Math.max(...data.mapBy('bar')), + 'Domain upper bound is the highest bar value' + ); + + chart.set('data', [...data, { foo: 12, bar: 600 }]); + + [, yDomainHigh] = chart.get('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.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data, + }); + + assert.equal(chart.get('yTicks').length, 3); + + chart.set('xAxisOffset', 240); + assert.equal(chart.get('yTicks').length, 5); + + chart.set('xAxisOffset', 241); + assert.equal(chart.get('yTicks').length, 7); +}); + +test('the values for yTicks are rounded to whole numbers', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data, + }); + + assert.deepEqual(chart.get('yTicks'), [0, 250, 500]); + + chart.set('xAxisOffset', 240); + assert.deepEqual(chart.get('yTicks'), [0, 125, 250, 375, 500]); + + chart.set('xAxisOffset', 241); + assert.deepEqual(chart.get('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.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data: [ + { foo: 1, bar: 0.1 }, + { foo: 2, bar: 0.2 }, + { foo: 3, bar: 0.3 }, + { foo: 8, bar: 0.4 }, + { foo: 4, bar: 0.5 }, + ], + }); + + assert.deepEqual(chart.get('yTicks'), [0, 0.25, 0.5]); +}); + +test('activeDatumLabel is the xProp value of the activeDatum formatted with xFormat', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + xProp: 'foo', + yProp: 'bar', + data, + activeDatum: data[1], + }); + + assert.equal( + chart.get('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.subject(); + + chart.setProperties({ + xProp: 'foo', + yProp: 'bar', + data, + activeDatum: data[1], + }); + + assert.equal( + chart.get('activeDatumValue'), + d3Format.format(',.2~r')(data[1].bar), + 'activeDatumValue correctly formats the correct prop of the correct datum' + ); +}); diff --git a/ui/tests/unit/components/stats-time-series-test.js b/ui/tests/unit/components/stats-time-series-test.js new file mode 100644 index 00000000000..401a8a5b153 --- /dev/null +++ b/ui/tests/unit/components/stats-time-series-test.js @@ -0,0 +1,101 @@ +import { test, moduleForComponent } from 'ember-qunit'; +import moment from 'moment'; +import d3Format from 'd3-format'; +import d3TimeFormat from 'd3-time-format'; + +moduleForComponent('stats-time-series', 'Unit | Component | stats-time-series'); + +const ts = (offset, resolution = 'm') => + moment() + .subtract(offset, resolution) + .toDate(); + +const wideData = [ + { timestamp: ts(20), value: 0.5 }, + { timestamp: ts(18), value: 0.5 }, + { timestamp: ts(16), value: 0.4 }, + { timestamp: ts(14), value: 0.3 }, + { timestamp: ts(12), value: 0.9 }, + { timestamp: ts(10), value: 0.3 }, + { timestamp: ts(8), value: 0.3 }, + { timestamp: ts(6), value: 0.4 }, + { timestamp: ts(4), value: 0.5 }, + { timestamp: ts(2), value: 0.6 }, + { timestamp: ts(0), value: 0.6 }, +]; + +const narrowData = [ + { timestamp: ts(20, 's'), value: 0.5 }, + { timestamp: ts(18, 's'), value: 0.5 }, + { timestamp: ts(16, 's'), value: 0.4 }, + { timestamp: ts(14, 's'), value: 0.3 }, + { timestamp: ts(12, 's'), value: 0.9 }, + { timestamp: ts(10, 's'), value: 0.3 }, +]; + +test('xFormat is time-formatted for hours, minutes, and seconds', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + wideData.forEach(datum => { + assert.equal( + chart.xFormat()(datum.timestamp), + d3TimeFormat.timeFormat('%H:%M:%S')(datum.timestamp) + ); + }); +}); + +test('yFormat is percent-formatted', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + wideData.forEach(datum => { + assert.equal(chart.yFormat()(datum.value), d3Format.format('.1~%')(datum.value)); + }); +}); + +test('x scale domain is at least five minutes', function(assert) { + const chart = this.subject(); + + chart.set('data', narrowData); + + assert.equal( + +chart.get('xScale').domain()[0], + +moment(Math.max(...narrowData.mapBy('timestamp'))) + .subtract(5, 'm') + .toDate(), + 'The lower bound of the xScale is 5 minutes ago' + ); +}); + +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.subject(); + + chart.set('data', wideData); + + assert.equal( + +chart.get('xScale').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 always 0 to 1 (0 to 100%)', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + assert.deepEqual( + [Math.min(...wideData.mapBy('value')), Math.max(...wideData.mapBy('value'))], + [0.3, 0.9], + 'The bounds of the value prop of the dataset is narrower than 0 - 1' + ); + + assert.deepEqual( + chart.get('yScale').domain(), + [0, 1], + 'The bounds of the yScale are still 0 and 1' + ); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 5414f4bd4a3..943460d2014 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2611,6 +2611,18 @@ cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + +d3-axis@^1.0.0: + version "1.0.12" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9" + +d3-collection@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + d3-color@1: version "1.0.3" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.0.3.tgz#bc7643fca8e53a8347e2fbdaffa236796b58509b" @@ -2623,16 +2635,52 @@ d3-ease@1: version "1.0.3" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.3.tgz#68bfbc349338a380c44d8acc4fbc3304aa2d8c0e" +d3-format@1, d3-format@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562" + d3-interpolate@1: version "1.1.5" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.1.5.tgz#69e099ff39214716e563c9aec3ea9d1ea4b8a79f" dependencies: d3-color "1" +d3-path@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.7.tgz#8de7cd693a75ac0b5480d3abaccd94793e58aae8" + +d3-scale@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + d3-selection@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.2.0.tgz#1b8ec1c7cedadfb691f2ba20a4a3cfbeb71bbc88" +d3-shape@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.2.2.tgz#f9dba3777a5825f9a8ce8bc928da08c17679e9a7" + dependencies: + d3-path "1" + +d3-time-format@2, d3-time-format@^2.1.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b" + dependencies: + d3-time "1" + +d3-time@1: + version "1.0.10" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.10.tgz#8259dd71288d72eeacfd8de281c4bf5c7393053c" + d3-timer@1: version "1.0.6" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.6.tgz#4044bf15d7025c06ce7d1149f73cd07b54dbd784"