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