github.com/ferranbt/nomad@v0.9.3-0.20190607002617-85c449b7667c/ui/app/components/line-chart.js (about) 1 import Component from '@ember/component'; 2 import { computed, observer } from '@ember/object'; 3 import { guidFor } from '@ember/object/internals'; 4 import { run } from '@ember/runloop'; 5 import d3 from 'd3-selection'; 6 import d3Scale from 'd3-scale'; 7 import d3Axis from 'd3-axis'; 8 import d3Array from 'd3-array'; 9 import d3Shape from 'd3-shape'; 10 import d3Format from 'd3-format'; 11 import d3TimeFormat from 'd3-time-format'; 12 import WindowResizable from 'nomad-ui/mixins/window-resizable'; 13 import styleStringProperty from 'nomad-ui/utils/properties/style-string'; 14 15 // Returns a new array with the specified number of points linearly 16 // distributed across the bounds 17 const lerp = ([low, high], numPoints) => { 18 const step = (high - low) / (numPoints - 1); 19 const arr = []; 20 for (var i = 0; i < numPoints; i++) { 21 arr.push(low + step * i); 22 } 23 return arr; 24 }; 25 26 // Round a number or an array of numbers 27 const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val)); 28 29 export default Component.extend(WindowResizable, { 30 classNames: ['chart', 'line-chart'], 31 32 // Public API 33 34 data: null, 35 xProp: null, 36 yProp: null, 37 timeseries: false, 38 chartClass: 'is-primary', 39 40 title: 'Line Chart', 41 description: null, 42 43 // Private Properties 44 45 width: 0, 46 height: 0, 47 48 isActive: false, 49 50 fillId: computed(function() { 51 return `line-chart-fill-${guidFor(this)}`; 52 }), 53 54 maskId: computed(function() { 55 return `line-chart-mask-${guidFor(this)}`; 56 }), 57 58 activeDatum: null, 59 60 activeDatumLabel: computed('activeDatum', function() { 61 const datum = this.activeDatum; 62 63 if (!datum) return; 64 65 const x = datum[this.xProp]; 66 return this.xFormat(this.timeseries)(x); 67 }), 68 69 activeDatumValue: computed('activeDatum', function() { 70 const datum = this.activeDatum; 71 72 if (!datum) return; 73 74 const y = datum[this.yProp]; 75 return this.yFormat()(y); 76 }), 77 78 // Overridable functions that retrurn formatter functions 79 xFormat(timeseries) { 80 return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(','); 81 }, 82 83 yFormat() { 84 return d3Format.format(',.2~r'); 85 }, 86 87 tooltipPosition: null, 88 tooltipStyle: styleStringProperty('tooltipPosition'), 89 90 xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { 91 const xProp = this.xProp; 92 const scale = this.timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear(); 93 const data = this.data; 94 95 const domain = data.length ? d3Array.extent(this.data, d => d[xProp]) : [0, 1]; 96 97 scale.rangeRound([10, this.yAxisOffset]).domain(domain); 98 99 return scale; 100 }), 101 102 xRange: computed('data.[]', 'xFormat', 'xProp', 'timeseries', function() { 103 const { xProp, timeseries, data } = this; 104 const range = d3Array.extent(data, d => d[xProp]); 105 const formatter = this.xFormat(timeseries); 106 107 return range.map(formatter); 108 }), 109 110 yRange: computed('data.[]', 'yFormat', 'yProp', function() { 111 const yProp = this.yProp; 112 const range = d3Array.extent(this.data, d => d[yProp]); 113 const formatter = this.yFormat(); 114 115 return range.map(formatter); 116 }), 117 118 yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { 119 const yProp = this.yProp; 120 let max = d3Array.max(this.data, d => d[yProp]) || 1; 121 if (max > 1) { 122 max = nice(max); 123 } 124 125 return d3Scale 126 .scaleLinear() 127 .rangeRound([this.xAxisOffset, 10]) 128 .domain([0, max]); 129 }), 130 131 xAxis: computed('xScale', function() { 132 const formatter = this.xFormat(this.timeseries); 133 134 return d3Axis 135 .axisBottom() 136 .scale(this.xScale) 137 .ticks(5) 138 .tickFormat(formatter); 139 }), 140 141 yTicks: computed('xAxisOffset', function() { 142 const height = this.xAxisOffset; 143 const tickCount = Math.ceil(height / 120) * 2 + 1; 144 const domain = this.yScale.domain(); 145 const ticks = lerp(domain, tickCount); 146 return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; 147 }), 148 149 yAxis: computed('yScale', function() { 150 const formatter = this.yFormat(); 151 152 return d3Axis 153 .axisRight() 154 .scale(this.yScale) 155 .tickValues(this.yTicks) 156 .tickFormat(formatter); 157 }), 158 159 yGridlines: computed('yScale', function() { 160 // The first gridline overlaps the x-axis, so remove it 161 const [, ...ticks] = this.yTicks; 162 163 return d3Axis 164 .axisRight() 165 .scale(this.yScale) 166 .tickValues(ticks) 167 .tickSize(-this.yAxisOffset) 168 .tickFormat(''); 169 }), 170 171 xAxisHeight: computed(function() { 172 // Avoid divide by zero errors by always having a height 173 if (!this.element) return 1; 174 175 const axis = this.element.querySelector('.x-axis'); 176 return axis && axis.getBBox().height; 177 }), 178 179 yAxisWidth: computed(function() { 180 // Avoid divide by zero errors by always having a width 181 if (!this.element) return 1; 182 183 const axis = this.element.querySelector('.y-axis'); 184 return axis && axis.getBBox().width; 185 }), 186 187 xAxisOffset: computed('height', 'xAxisHeight', function() { 188 return this.height - this.xAxisHeight; 189 }), 190 191 yAxisOffset: computed('width', 'yAxisWidth', function() { 192 return this.width - this.yAxisWidth; 193 }), 194 195 line: computed('data.[]', 'xScale', 'yScale', function() { 196 const { xScale, yScale, xProp, yProp } = this; 197 198 const line = d3Shape 199 .line() 200 .defined(d => d[yProp] != null) 201 .x(d => xScale(d[xProp])) 202 .y(d => yScale(d[yProp])); 203 204 return line(this.data); 205 }), 206 207 area: computed('data.[]', 'xScale', 'yScale', function() { 208 const { xScale, yScale, xProp, yProp } = this; 209 210 const area = d3Shape 211 .area() 212 .defined(d => d[yProp] != null) 213 .x(d => xScale(d[xProp])) 214 .y0(yScale(0)) 215 .y1(d => yScale(d[yProp])); 216 217 return area(this.data); 218 }), 219 220 didInsertElement() { 221 this.updateDimensions(); 222 223 const canvas = d3.select(this.element.querySelector('.canvas')); 224 const updateActiveDatum = this.updateActiveDatum.bind(this); 225 226 const chart = this; 227 canvas.on('mouseenter', function() { 228 const mouseX = d3.mouse(this)[0]; 229 chart.set('latestMouseX', mouseX); 230 updateActiveDatum(mouseX); 231 run.schedule('afterRender', chart, () => chart.set('isActive', true)); 232 }); 233 234 canvas.on('mousemove', function() { 235 const mouseX = d3.mouse(this)[0]; 236 chart.set('latestMouseX', mouseX); 237 updateActiveDatum(mouseX); 238 }); 239 240 canvas.on('mouseleave', () => { 241 run.schedule('afterRender', this, () => this.set('isActive', false)); 242 this.set('activeDatum', null); 243 }); 244 }, 245 246 didUpdateAttrs() { 247 this.renderChart(); 248 }, 249 250 updateActiveDatum(mouseX) { 251 const { xScale, xProp, yScale, yProp, data } = this; 252 253 if (!data || !data.length) return; 254 255 // Map the mouse coordinate to the index in the data array 256 const bisector = d3Array.bisector(d => d[xProp]).left; 257 const x = xScale.invert(mouseX); 258 const index = bisector(data, x, 1); 259 260 // The data point on either side of the cursor 261 const dLeft = data[index - 1]; 262 const dRight = data[index]; 263 264 let datum; 265 266 // If there is only one point, it's the activeDatum 267 if (dLeft && !dRight) { 268 datum = dLeft; 269 } else { 270 // Pick the closer point 271 datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; 272 } 273 274 this.set('activeDatum', datum); 275 this.set('tooltipPosition', { 276 left: xScale(datum[xProp]), 277 top: yScale(datum[yProp]) - 10, 278 }); 279 }, 280 281 updateChart: observer('data.[]', function() { 282 this.renderChart(); 283 }), 284 285 // The renderChart method should only ever be responsible for runtime calculations 286 // and appending d3 created elements to the DOM (such as axes). 287 renderChart() { 288 // There is nothing to do if the element hasn't been inserted yet 289 if (!this.element) return; 290 291 // First, create the axes to get the dimensions of the resulting 292 // svg elements 293 this.mountD3Elements(); 294 295 run.next(() => { 296 // Then, recompute anything that depends on the dimensions 297 // on the dimensions of the axes elements 298 this.notifyPropertyChange('xAxisHeight'); 299 this.notifyPropertyChange('yAxisWidth'); 300 301 // Since each axis depends on the dimension of the other 302 // axis, the axes themselves are recomputed and need to 303 // be re-rendered. 304 this.mountD3Elements(); 305 if (this.isActive) { 306 this.updateActiveDatum(this.latestMouseX); 307 } 308 }); 309 }, 310 311 mountD3Elements() { 312 if (!this.isDestroyed && !this.isDestroying) { 313 d3.select(this.element.querySelector('.x-axis')).call(this.xAxis); 314 d3.select(this.element.querySelector('.y-axis')).call(this.yAxis); 315 d3.select(this.element.querySelector('.y-gridlines')).call(this.yGridlines); 316 } 317 }, 318 319 windowResizeHandler() { 320 run.once(this, this.updateDimensions); 321 }, 322 323 updateDimensions() { 324 const $svg = this.$('svg'); 325 const width = $svg.width(); 326 const height = $svg.height(); 327 328 this.setProperties({ width, height }); 329 this.renderChart(); 330 }, 331 });