github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/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 { 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 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 44 .scaleLinear() 45 .rangeRound([xAxisOffset, 10]) 46 .domain([0, max]); 47 }; 48 49 export default class LineChart extends Component { 50 /** Args 51 data = null; 52 xProp = null; 53 yProp = null; 54 curve = 'linear'; 55 title = 'Line Chart'; 56 description = null; 57 timeseries = false; 58 chartClass = 'is-primary'; 59 activeAnnotation = null; 60 onAnnotationClick() {} 61 xFormat; 62 yFormat; 63 xScale; 64 yScale; 65 */ 66 67 @tracked width = 0; 68 @tracked height = 0; 69 @tracked isActive = false; 70 @tracked activeDatum = null; 71 @tracked tooltipPosition = null; 72 @tracked element = null; 73 @tracked ready = false; 74 75 @uniquely('title') titleId; 76 @uniquely('desc') descriptionId; 77 78 get xProp() { 79 return this.args.xProp || 'time'; 80 } 81 get yProp() { 82 return this.args.yProp || 'value'; 83 } 84 get data() { 85 return this.args.data || []; 86 } 87 get curve() { 88 return this.args.curve || 'linear'; 89 } 90 get chartClass() { 91 return this.args.chartClass || 'is-primary'; 92 } 93 94 @action 95 xFormat(timeseries) { 96 if (this.args.xFormat) return this.args.xFormat; 97 return timeseries ? d3TimeFormat.timeFormat('%b %d, %H:%M') : 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.yAxisOffset) 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 @action 220 onInsert(element) { 221 this.element = element; 222 this.updateDimensions(); 223 224 const canvas = d3.select(this.element.querySelector('.hover-target')); 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.latestMouseX = mouseX; 231 updateActiveDatum(mouseX); 232 run.schedule('afterRender', chart, () => (chart.isActive = true)); 233 }); 234 235 canvas.on('mousemove', function() { 236 const mouseX = d3.mouse(this)[0]; 237 chart.latestMouseX = mouseX; 238 updateActiveDatum(mouseX); 239 }); 240 241 canvas.on('mouseleave', () => { 242 run.schedule('afterRender', this, () => (this.isActive = false)); 243 this.activeDatum = null; 244 }); 245 } 246 247 updateActiveDatum(mouseX) { 248 const { xScale, xProp, yScale, yProp, data } = this; 249 250 if (!data || !data.length) return; 251 252 // Map the mouse coordinate to the index in the data array 253 const bisector = d3Array.bisector(d => d[xProp]).left; 254 const x = xScale.invert(mouseX); 255 const index = bisector(data, x, 1); 256 257 // The data point on either side of the cursor 258 const dLeft = data[index - 1]; 259 const dRight = data[index]; 260 261 let datum; 262 263 // If there is only one point, it's the activeDatum 264 if (dLeft && !dRight) { 265 datum = dLeft; 266 } else { 267 // Pick the closer point 268 datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; 269 } 270 271 this.activeDatum = datum; 272 this.tooltipPosition = { 273 left: xScale(datum[xProp]), 274 top: yScale(datum[yProp]) - 10, 275 }; 276 } 277 278 // The renderChart method should only ever be responsible for runtime calculations 279 // and appending d3 created elements to the DOM (such as axes). 280 renderChart() { 281 // There is nothing to do if the element hasn't been inserted yet 282 if (!this.element) return; 283 284 // Create the axes to get the dimensions of the resulting 285 // svg elements 286 this.mountD3Elements(); 287 288 run.next(() => { 289 // Since each axis depends on the dimension of the other 290 // axis, the axes themselves are recomputed and need to 291 // be re-rendered. 292 this.mountD3Elements(); 293 this.ready = true; 294 if (this.isActive) { 295 this.updateActiveDatum(this.latestMouseX); 296 } 297 }); 298 } 299 300 mountD3Elements() { 301 if (!this.isDestroyed && !this.isDestroying) { 302 d3.select(this.element.querySelector('.x-axis')).call(this.xAxis); 303 d3.select(this.element.querySelector('.y-axis')).call(this.yAxis); 304 d3.select(this.element.querySelector('.y-gridlines')).call(this.yGridlines); 305 } 306 } 307 308 annotationClick(annotation) { 309 this.args.onAnnotationClick && this.args.onAnnotationClick(annotation); 310 } 311 312 @action 313 updateDimensions() { 314 const $svg = this.element.querySelector('svg'); 315 316 this.height = $svg.clientHeight; 317 this.width = $svg.clientWidth; 318 this.renderChart(); 319 } 320 }