From e8c860b33042f843f70761a4ad7c0c87245304da Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:55:39 -0700 Subject: [PATCH 01/14] Bring in new d3 dependencies --- ui/package.json | 6 ++++++ ui/yarn.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) 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/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" From 5b927d36544c3e8625c00d14e50fb4915ef651bf Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:57:33 -0700 Subject: [PATCH 02/14] Add chart color swatches based on css colors --- ui/app/styles/charts/colors.scss | 8 ++++++++ 1 file changed, 8 insertions(+) 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; + } + } } From dc63be2a8fd67fa48003f90c63970e925e1f0330 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:57:54 -0700 Subject: [PATCH 03/14] Add support for non-list tooltips in charts --- ui/app/styles/charts/tooltip.scss | 57 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 580e0ab3b87..3e128134b89 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -53,41 +53,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; + } } } } From 48df4d2d29e55f0ae05610ed0adaf3f4cc0c42f2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:59:02 -0700 Subject: [PATCH 04/14] New line chart component --- ui/app/components/line-chart.js | 292 +++++++++++++++++++++ ui/app/styles/charts.scss | 1 + ui/app/styles/charts/line-chart.scss | 68 +++++ ui/app/templates/components/line-chart.hbs | 29 ++ 4 files changed, 390 insertions(+) create mode 100644 ui/app/components/line-chart.js create mode 100644 ui/app/styles/charts/line-chart.scss create mode 100644 ui/app/templates/components/line-chart.hbs diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js new file mode 100644 index 00000000000..f3aa3bbbeca --- /dev/null +++ b/ui/app/components/line-chart.js @@ -0,0 +1,292 @@ +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)}`; + }), + + 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'); + + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, d3Array.max(this.get('data'), d => d[yProp])]); + }), + + 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; + return nice(lerp(this.get('yScale').domain(), tickCount)); + }), + + 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() { + const axis = this.element.querySelector('.x-axis'); + return axis && axis.getBBox().height; + }), + + yAxisWidth: computed(function() { + 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); + + canvas.on('mouseenter', () => { + run.schedule('afterRender', this, () => this.set('isActive', true)); + }); + + const chart = this; + canvas.on('mousemove', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + }); + + canvas.on('mouseleave', () => { + 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() { + // 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/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/line-chart.scss b/ui/app/styles/charts/line-chart.scss new file mode 100644 index 00000000000..19ba5d14817 --- /dev/null +++ b/ui/app/styles/charts/line-chart.scss @@ -0,0 +1,68 @@ +.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/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs new file mode 100644 index 00000000000..caf237d4ee2 --- /dev/null +++ b/ui/app/templates/components/line-chart.hbs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + +
+
    +

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

    +
+
From 5e09491bab11b33176bee401b28bbb36dedcff0c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:59:28 -0700 Subject: [PATCH 05/14] Styleguide entry for the line chart component --- ui/app/components/freestyle/sg-line-chart.js | 61 +++++++++++++++++++ .../components/freestyle/sg-line-chart.hbs | 29 +++++++++ ui/app/templates/freestyle.hbs | 4 ++ 3 files changed, 94 insertions(+) create mode 100644 ui/app/components/freestyle/sg-line-chart.js create mode 100644 ui/app/templates/components/freestyle/sg-line-chart.hbs 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..94f9e110540 --- /dev/null +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -0,0 +1,61 @@ +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/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs new file mode 100644 index 00000000000..bebc7214f93 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -0,0 +1,29 @@ +{{#freestyle-usage "line-chart-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"}} +
+ {{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-usage "line-chart-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/freestyle.hbs b/ui/app/templates/freestyle.hbs index e33756a9000..5f9c9489c87 100644 --- a/ui/app/templates/freestyle.hbs +++ b/ui/app/templates/freestyle.hbs @@ -108,6 +108,10 @@ {{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}} From bb40cb029bffc97f9785b07609b494e98208776d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:15:48 -0700 Subject: [PATCH 06/14] Don't round numbers when the domain is between 0 and 1 --- ui/app/components/line-chart.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index f3aa3bbbeca..c973ae47c86 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -113,7 +113,9 @@ export default Component.extend(WindowResizable, { yTicks: computed('xAxisOffset', function() { const height = this.get('xAxisOffset'); const tickCount = Math.ceil(height / 120) * 2 + 1; - return nice(lerp(this.get('yScale').domain(), tickCount)); + const domain = this.get('yScale').domain(); + const ticks = lerp(domain, tickCount); + return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; }), yAxis: computed('yScale', function() { From db6ad98eb4ecbf8267d4338063bb412708624713 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:16:20 -0700 Subject: [PATCH 07/14] Make the tooltip animation snappier for line charts When data is coming in live, the tooltip can get bogged down by updates causing the tooltip to never make it under the mouse, which looks like either lag or a bug. --- ui/app/styles/charts/tooltip.scss | 4 ++++ ui/app/templates/components/line-chart.hbs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 3e128134b89..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; diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index caf237d4ee2..a5add6a2492 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -16,7 +16,7 @@ -
+

    From 0d7c22ff0884a9486c8e266f44a12ecb9763cf92 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:17:21 -0700 Subject: [PATCH 08/14] Stats time series A use-case specific line-chart for showing utilization metrics by percent --- ui/app/components/stats-time-series.js | 41 +++++++++++++++++++ .../components/stats-time-series.hbs | 1 + 2 files changed, 42 insertions(+) create mode 100644 ui/app/components/stats-time-series.js create mode 100644 ui/app/templates/components/stats-time-series.hbs 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/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"}} From b84d75597dece91255321cbf25dcb35d1beebba6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:59:37 -0700 Subject: [PATCH 09/14] Avoid race conditions around showing and hiding the line chart tooltip --- ui/app/components/line-chart.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index c973ae47c86..633c33945a5 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -197,11 +197,14 @@ export default Component.extend(WindowResizable, { const canvas = d3.select(this.element.querySelector('.canvas')); const updateActiveDatum = this.updateActiveDatum.bind(this); - canvas.on('mouseenter', () => { - run.schedule('afterRender', this, () => this.set('isActive', true)); + 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)); }); - const chart = this; canvas.on('mousemove', function() { const mouseX = d3.mouse(this)[0]; chart.set('latestMouseX', mouseX); @@ -209,7 +212,7 @@ export default Component.extend(WindowResizable, { }); canvas.on('mouseleave', () => { - this.set('isActive', false); + run.schedule('afterRender', this, () => this.set('isActive', false)); this.set('activeDatum', null); }); }, From 4b67b7668c10db84287541955b2136c4c0be41ca Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 18:00:25 -0700 Subject: [PATCH 10/14] Use "global" gradients via a clipping mask and a rect w/100% height --- ui/app/components/line-chart.js | 4 +++ ui/app/templates/components/line-chart.hbs | 29 +++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 633c33945a5..e268c49fa41 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -48,6 +48,10 @@ export default Component.extend(WindowResizable, { return `line-chart-fill-${guidFor(this)}`; }), + maskId: computed(function() { + return `line-chart-mask-${guidFor(this)}`; + }), + activeDatum: null, activeDatumLabel: computed('activeDatum', function() { diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index a5add6a2492..33e8dc19f14 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -1,29 +1,28 @@ - - - - - + + + + + + - +

    -
      -

      - - - {{activeDatumLabel}} - - {{activeDatumValue}} -

      -
    +

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

    From a3e858e27e9bacab0af53396c7cb5a9a2a33b46f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 14:38:20 -0700 Subject: [PATCH 11/14] Updates to the styleguide --- ui/app/components/freestyle/sg-line-chart.js | 31 +++++++++++++++++++ ui/app/components/line-chart.js | 6 +++- ui/app/styles/charts/line-chart.scss | 1 + .../components/freestyle/sg-line-chart.hbs | 20 ++++++++++-- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index 94f9e110540..a149830e0c1 100644 --- a/ui/app/components/freestyle/sg-line-chart.js +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -10,11 +10,34 @@ export default Component.extend({ '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); } + + if (this.get('timerTicks') % 2 === 0) { + const ref2 = this.get('metrics'); + const prev = ref2.length ? ref2[ref2.length - 1].value : 0.9; + ref2.addObject({ + timestamp: Date.now(), + value: Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0), 1), + }); + if (ref2.length > 300) { + ref2.splice(0, ref2.length - 300); + } + + const ref3 = this.get('metrics2'); + const prev2 = ref3.length ? ref3[ref3.length - 1].value : 0.1; + ref3.addObject({ + timestamp: Date.now(), + value: Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 1), + }); + if (ref3.length > 300) { + ref3.splice(0, ref3.length - 300); + } + } }, 500) ); }.on('init'), @@ -55,6 +78,14 @@ export default Component.extend({ return []; }), + metrics: computed(() => { + return []; + }), + + metrics2: computed(() => { + return []; + }), + secondsFormat() { return d3TimeFormat.timeFormat('%H:%M:%S'); }, diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index e268c49fa41..683afc36f7e 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -97,11 +97,15 @@ export default Component.extend(WindowResizable, { 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, d3Array.max(this.get('data'), d => d[yProp])]); + .domain([0, max]); }), xAxis: computed('xScale', function() { diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss index 19ba5d14817..eff9ec53998 100644 --- a/ui/app/styles/charts/line-chart.scss +++ b/ui/app/styles/charts/line-chart.scss @@ -26,6 +26,7 @@ path { stroke: $grey-blue; } + text { fill: darken($grey-blue, 20%); } diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index bebc7214f93..cdb7d081a58 100644 --- a/ui/app/templates/components/freestyle/sg-line-chart.hbs +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -1,4 +1,4 @@ -{{#freestyle-usage "line-chart-standard"}} +{{#freestyle-usage "line-chart-standard" title="Standard"}}
    {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-primary"}}
    @@ -7,7 +7,7 @@
{{/freestyle-usage}} -{{#freestyle-usage "line-chart-fill-width"}} +{{#freestyle-usage "line-chart-fill-width" title="Fluid width"}}
{{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-danger"}}
@@ -15,8 +15,11 @@ {{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"}} +{{#freestyle-usage "line-chart-live-data" title="Live data"}}
{{line-chart data=lineChartLive @@ -27,3 +30,14 @@ xFormat=secondsFormat}}
{{/freestyle-usage}} + +{{#freestyle-usage "stats-chart"}} +
+
+ {{stats-time-series data=metrics chartClass="is-info"}} +
+
+ {{stats-time-series data=metrics2 chartClass="is-info"}} +
+
+{{/freestyle-usage}} From fd80df66a50218080bb0f6394fb72b7d037f9710 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 16:06:02 -0700 Subject: [PATCH 12/14] Split the line-chart and stats-time-series freestyle entries --- ui/app/components/freestyle/sg-line-chart.js | 30 -------- .../freestyle/sg-stats-time-series.js | 76 +++++++++++++++++++ .../components/freestyle/sg-line-chart.hbs | 11 --- .../freestyle/sg-stats-time-series.hbs | 20 +++++ ui/app/templates/freestyle.hbs | 4 + 5 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 ui/app/components/freestyle/sg-stats-time-series.js create mode 100644 ui/app/templates/components/freestyle/sg-stats-time-series.hbs diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index a149830e0c1..145bcbabeb0 100644 --- a/ui/app/components/freestyle/sg-line-chart.js +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -16,28 +16,6 @@ export default Component.extend({ if (ref.length > 60) { ref.splice(0, ref.length - 60); } - - if (this.get('timerTicks') % 2 === 0) { - const ref2 = this.get('metrics'); - const prev = ref2.length ? ref2[ref2.length - 1].value : 0.9; - ref2.addObject({ - timestamp: Date.now(), - value: Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0), 1), - }); - if (ref2.length > 300) { - ref2.splice(0, ref2.length - 300); - } - - const ref3 = this.get('metrics2'); - const prev2 = ref3.length ? ref3[ref3.length - 1].value : 0.1; - ref3.addObject({ - timestamp: Date.now(), - value: Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 1), - }); - if (ref3.length > 300) { - ref3.splice(0, ref3.length - 300); - } - } }, 500) ); }.on('init'), @@ -78,14 +56,6 @@ export default Component.extend({ return []; }), - metrics: computed(() => { - return []; - }), - - metrics2: 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/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index cdb7d081a58..67dd939b01d 100644 --- a/ui/app/templates/components/freestyle/sg-line-chart.hbs +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -30,14 +30,3 @@ xFormat=secondsFormat}} {{/freestyle-usage}} - -{{#freestyle-usage "stats-chart"}} -
-
- {{stats-time-series data=metrics chartClass="is-info"}} -
-
- {{stats-time-series data=metrics2 chartClass="is-info"}} -
-
-{{/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/freestyle.hbs b/ui/app/templates/freestyle.hbs index 5f9c9489c87..87bdb7c7685 100644 --- a/ui/app/templates/freestyle.hbs +++ b/ui/app/templates/freestyle.hbs @@ -115,6 +115,10 @@ {{#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}} From 40861aaae33c76d2bb79e295ce063b4b49299456 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 20:47:55 -0700 Subject: [PATCH 13/14] Unit test coverage for the line chart component --- ui/app/components/line-chart.js | 9 ++ ui/tests/unit/components/line-chart-test.js | 148 ++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 ui/tests/unit/components/line-chart-test.js diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 683afc36f7e..3ef5b1908af 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -149,11 +149,17 @@ export default Component.extend(WindowResizable, { }), 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; }), @@ -264,6 +270,9 @@ export default Component.extend(WindowResizable, { // 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(); 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' + ); +}); From efb130163874ea6a374f14e541470ef7dd4eee20 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 21:10:09 -0700 Subject: [PATCH 14/14] Unit test coverage for the stats-time-series chart --- .../unit/components/stats-time-series-test.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 ui/tests/unit/components/stats-time-series-test.js 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' + ); +});