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