github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/line-chart.js (about) 1 import Component from '@glimmer/component'; 2 import { tracked } from '@glimmer/tracking'; 3 import { action } from '@ember/object'; 4 import { schedule, next } 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 d3Format from 'd3-format'; 10 import d3TimeFormat from 'd3-time-format'; 11 import styleString from 'nomad-ui/utils/properties/glimmer-style-string'; 12 import uniquely from 'nomad-ui/utils/properties/uniquely'; 13 14 // Returns a new array with the specified number of points linearly 15 // distributed across the bounds 16 const lerp = ([low, high], numPoints) => { 17 const step = (high - low) / (numPoints - 1); 18 const arr = []; 19 for (var i = 0; i < numPoints; i++) { 20 arr.push(low + step * i); 21 } 22 return arr; 23 }; 24 25 // Round a number or an array of numbers 26 const nice = (val) => (val instanceof Array ? val.map(nice) : Math.round(val)); 27 28 const defaultXScale = (data, yAxisOffset, xProp, timeseries) => { 29 const scale = timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear(); 30 const domain = data.length ? d3Array.extent(data, (d) => d[xProp]) : [0, 1]; 31 32 scale.rangeRound([10, yAxisOffset]).domain(domain); 33 34 return scale; 35 }; 36 37 const defaultYScale = (data, xAxisOffset, yProp) => { 38 let max = d3Array.max(data, (d) => d[yProp]) || 1; 39 if (max > 1) { 40 max = nice(max); 41 } 42 43 return d3Scale.scaleLinear().rangeRound([xAxisOffset, 10]).domain([0, max]); 44 }; 45 46 export default class LineChart extends Component { 47 /** Args 48 data = null; 49 xProp = null; 50 yProp = null; 51 curve = 'linear'; 52 title = 'Line Chart'; 53 description = null; 54 timeseries = false; 55 activeAnnotation = null; 56 onAnnotationClick() {} 57 xFormat; 58 yFormat; 59 xScale; 60 yScale; 61 */ 62 63 @tracked width = 0; 64 @tracked height = 0; 65 @tracked isActive = false; 66 @tracked activeDatum = null; 67 @tracked activeData = []; 68 @tracked tooltipPosition = null; 69 @tracked element = null; 70 @tracked ready = false; 71 72 @uniquely('title') titleId; 73 @uniquely('desc') descriptionId; 74 75 get xProp() { 76 return this.args.xProp || 'time'; 77 } 78 get yProp() { 79 return this.args.yProp || 'value'; 80 } 81 get data() { 82 if (!this.args.data) return []; 83 if (this.args.dataProp) { 84 return this.args.data.mapBy(this.args.dataProp).flat(); 85 } 86 return this.args.data; 87 } 88 get curve() { 89 return this.args.curve || 'linear'; 90 } 91 92 @action 93 xFormat(timeseries) { 94 if (this.args.xFormat) return this.args.xFormat; 95 return timeseries 96 ? d3TimeFormat.timeFormat('%b %d, %H:%M') 97 : d3Format.format(','); 98 } 99 100 @action 101 yFormat() { 102 if (this.args.yFormat) return this.args.yFormat; 103 return d3Format.format(',.2~r'); 104 } 105 106 get activeDatumLabel() { 107 const datum = this.activeDatum; 108 109 if (!datum) return undefined; 110 111 const x = datum[this.xProp]; 112 return this.xFormat(this.args.timeseries)(x); 113 } 114 115 get activeDatumValue() { 116 const datum = this.activeDatum; 117 118 if (!datum) return undefined; 119 120 const y = datum[this.yProp]; 121 return this.yFormat()(y); 122 } 123 124 @styleString 125 get tooltipStyle() { 126 return this.tooltipPosition; 127 } 128 129 get xScale() { 130 const fn = this.args.xScale || defaultXScale; 131 return fn(this.data, this.yAxisOffset, this.xProp, this.args.timeseries); 132 } 133 134 get xRange() { 135 const { xProp, data } = this; 136 const range = d3Array.extent(data, (d) => d[xProp]); 137 const formatter = this.xFormat(this.args.timeseries); 138 139 return range.map(formatter); 140 } 141 142 get yRange() { 143 const yProp = this.yProp; 144 const range = d3Array.extent(this.data, (d) => d[yProp]); 145 const formatter = this.yFormat(); 146 147 return range.map(formatter); 148 } 149 150 get yScale() { 151 const fn = this.args.yScale || defaultYScale; 152 return fn(this.data, this.xAxisOffset, this.yProp); 153 } 154 155 get xAxis() { 156 const formatter = this.xFormat(this.args.timeseries); 157 158 return d3Axis 159 .axisBottom() 160 .scale(this.xScale) 161 .ticks(5) 162 .tickFormat(formatter); 163 } 164 165 get yTicks() { 166 const height = this.xAxisOffset; 167 const tickCount = Math.ceil(height / 120) * 2 + 1; 168 const domain = this.yScale.domain(); 169 const ticks = lerp(domain, tickCount); 170 return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; 171 } 172 173 get yAxis() { 174 const formatter = this.yFormat(); 175 176 return d3Axis 177 .axisRight() 178 .scale(this.yScale) 179 .tickValues(this.yTicks) 180 .tickFormat(formatter); 181 } 182 183 get yGridlines() { 184 // The first gridline overlaps the x-axis, so remove it 185 const [, ...ticks] = this.yTicks; 186 187 return d3Axis 188 .axisRight() 189 .scale(this.yScale) 190 .tickValues(ticks) 191 .tickSize(-this.canvasDimensions.width) 192 .tickFormat(''); 193 } 194 195 get xAxisHeight() { 196 // Avoid divide by zero errors by always having a height 197 if (!this.element) return 1; 198 199 const axis = this.element.querySelector('.x-axis'); 200 return axis && axis.getBBox().height; 201 } 202 203 get yAxisWidth() { 204 // Avoid divide by zero errors by always having a width 205 if (!this.element) return 1; 206 207 const axis = this.element.querySelector('.y-axis'); 208 return axis && axis.getBBox().width; 209 } 210 211 get xAxisOffset() { 212 return Math.max(0, this.height - this.xAxisHeight); 213 } 214 215 get yAxisOffset() { 216 return Math.max(0, this.width - this.yAxisWidth); 217 } 218 219 get canvasDimensions() { 220 const [left, right] = this.xScale.range(); 221 const [top, bottom] = this.yScale.range(); 222 return { left, width: right - left, top, height: bottom - top }; 223 } 224 225 @action 226 onInsert(element) { 227 this.element = element; 228 this.updateDimensions(); 229 230 const canvas = d3.select(this.element.querySelector('.hover-target')); 231 const updateActiveDatum = this.updateActiveDatum.bind(this); 232 233 const chart = this; 234 canvas.on('mouseenter', function (ev) { 235 const mouseX = d3.pointer(ev, this)[0]; 236 chart.latestMouseX = mouseX; 237 updateActiveDatum(mouseX); 238 schedule('afterRender', chart, () => (chart.isActive = true)); 239 }); 240 241 canvas.on('mousemove', function (ev) { 242 const mouseX = d3.pointer(ev, this)[0]; 243 chart.latestMouseX = mouseX; 244 updateActiveDatum(mouseX); 245 }); 246 247 canvas.on('mouseleave', () => { 248 schedule('afterRender', this, () => (this.isActive = false)); 249 this.activeDatum = null; 250 this.activeData = []; 251 }); 252 } 253 254 updateActiveDatum(mouseX) { 255 if (!this.data || !this.data.length) return; 256 257 const { xScale, xProp, yScale, yProp } = this; 258 let { dataProp, data } = this.args; 259 260 if (!dataProp) { 261 dataProp = 'data'; 262 data = [{ data: this.data }]; 263 } 264 265 // Map screen coordinates to data domain 266 const bisector = d3Array.bisector((d) => d[xProp]).left; 267 const x = xScale.invert(mouseX); 268 269 // Find the closest datum to the cursor for each series 270 const activeData = data 271 .map((series, seriesIndex) => { 272 const dataset = series[dataProp]; 273 274 // If the dataset is empty, there can't be an activeData. 275 // This must be done here instead of preemptively in a filter to 276 // preserve the seriesIndex value. 277 if (!dataset.length) return null; 278 279 const index = bisector(dataset, x, 1); 280 281 // The data point on either side of the cursor 282 const dLeft = dataset[index - 1]; 283 const dRight = dataset[index]; 284 285 let datum; 286 287 // If there is only one point, it's the activeDatum 288 if (dLeft && !dRight) { 289 datum = dLeft; 290 } else { 291 // Pick the closer point 292 datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; 293 } 294 295 return { 296 series, 297 datum: { 298 formattedX: this.xFormat(this.args.timeseries)(datum[xProp]), 299 formattedY: this.yFormat()(datum[yProp]), 300 datum, 301 }, 302 index: data.length - seriesIndex - 1, 303 }; 304 }) 305 .compact(); 306 307 // Of the selected data, determine which is closest 308 const closestDatum = activeData 309 .slice() 310 .sort( 311 (a, b) => 312 Math.abs(a.datum.datum[xProp] - x) - 313 Math.abs(b.datum.datum[xProp] - x) 314 )[0]; 315 316 // If any other selected data are beyond a distance threshold, drop them from the list 317 // xScale is used here to measure distance in screen-space rather than data-space. 318 const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX); 319 const filteredData = activeData.filter( 320 (d) => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10 321 ); 322 323 this.activeData = filteredData; 324 this.activeDatum = closestDatum.datum.datum; 325 this.tooltipPosition = { 326 left: xScale(this.activeDatum[xProp]), 327 top: yScale(this.activeDatum[yProp]) - 10, 328 }; 329 } 330 331 // The renderChart method should only ever be responsible for runtime calculations 332 // and appending d3 created elements to the DOM (such as axes). 333 renderChart() { 334 // There is nothing to do if the element hasn't been inserted yet 335 if (!this.element) return; 336 337 // Create the axes to get the dimensions of the resulting 338 // svg elements 339 this.mountD3Elements(); 340 341 next(() => { 342 // Since each axis depends on the dimension of the other 343 // axis, the axes themselves are recomputed and need to 344 // be re-rendered. 345 this.mountD3Elements(); 346 this.ready = true; 347 if (this.isActive) { 348 this.updateActiveDatum(this.latestMouseX); 349 } 350 }); 351 } 352 353 @action 354 recomputeXAxis(el) { 355 if (!this.isDestroyed && !this.isDestroying) { 356 d3.select(el.querySelector('.x-axis')).call(this.xAxis); 357 } 358 } 359 360 @action 361 recomputeYAxis(el) { 362 if (!this.isDestroyed && !this.isDestroying) { 363 d3.select(el.querySelector('.y-axis')).call(this.yAxis); 364 } 365 } 366 367 mountD3Elements() { 368 if (!this.isDestroyed && !this.isDestroying) { 369 d3.select(this.element.querySelector('.x-axis')).call(this.xAxis); 370 d3.select(this.element.querySelector('.y-axis')).call(this.yAxis); 371 d3.select(this.element.querySelector('.y-gridlines')).call( 372 this.yGridlines 373 ); 374 } 375 } 376 377 annotationClick(annotation) { 378 this.args.onAnnotationClick && this.args.onAnnotationClick(annotation); 379 } 380 381 @action 382 updateDimensions() { 383 const $svg = this.element.querySelector('svg'); 384 385 this.height = $svg.clientHeight; 386 this.width = $svg.clientWidth; 387 this.renderChart(); 388 } 389 }