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"}} +
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 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}} +
+