github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/das/recommendation-chart.js (about) 1 import Component from '@glimmer/component'; 2 import { action } from '@ember/object'; 3 import { tracked } from '@glimmer/tracking'; 4 import { next } from '@ember/runloop'; 5 import { htmlSafe } from '@ember/string'; 6 import { get } from '@ember/object'; 7 8 import { scaleLinear } from 'd3-scale'; 9 import d3Format from 'd3-format'; 10 11 const statsKeyToLabel = { 12 min: 'Min', 13 median: 'Median', 14 mean: 'Mean', 15 p99: '99th', 16 max: 'Max', 17 current: 'Current', 18 recommended: 'New', 19 }; 20 21 const formatPercent = d3Format.format('+.0%'); 22 export default class RecommendationChartComponent extends Component { 23 @tracked width; 24 @tracked height; 25 26 @tracked shown = false; 27 28 @tracked showLegend = false; 29 @tracked mouseX; 30 @tracked activeLegendRow; 31 32 get isIncrease() { 33 return this.args.currentValue < this.args.recommendedValue; 34 } 35 36 get directionClass() { 37 if (this.args.disabled) { 38 return 'disabled'; 39 } else if (this.isIncrease) { 40 return 'increase'; 41 } else { 42 return 'decrease'; 43 } 44 } 45 46 get icon() { 47 return { 48 x: 0, 49 y: this.resourceLabel.y - this.iconHeight / 2, 50 width: 20, 51 height: this.iconHeight, 52 name: this.isIncrease ? 'arrow-up' : 'arrow-down', 53 }; 54 } 55 56 gutterWidthLeft = 62; 57 gutterWidthRight = 50; 58 gutterWidth = this.gutterWidthLeft + this.gutterWidthRight; 59 60 iconHeight = 21; 61 62 tickTextHeight = 15; 63 64 edgeTickHeight = 23; 65 centerTickOffset = 6; 66 67 centerY = 68 this.tickTextHeight + this.centerTickOffset + this.edgeTickHeight / 2; 69 70 edgeTickY1 = this.tickTextHeight + this.centerTickOffset; 71 edgeTickY2 = 72 this.tickTextHeight + this.edgeTickHeight + this.centerTickOffset; 73 74 deltaTextY = this.edgeTickY2; 75 76 meanHeight = this.edgeTickHeight * 0.6; 77 p99Height = this.edgeTickHeight * 0.48; 78 maxHeight = this.edgeTickHeight * 0.4; 79 80 deltaTriangleHeight = this.edgeTickHeight / 2.5; 81 82 get statsShapes() { 83 if (this.width) { 84 const maxShapes = this.shapesFor('max'); 85 const p99Shapes = this.shapesFor('p99'); 86 const meanShapes = this.shapesFor('mean'); 87 88 const labelProximityThreshold = 25; 89 90 if (p99Shapes.text.x + labelProximityThreshold > maxShapes.text.x) { 91 maxShapes.text.class = 'right'; 92 } 93 94 if (meanShapes.text.x + labelProximityThreshold > p99Shapes.text.x) { 95 p99Shapes.text.class = 'right'; 96 } 97 98 if (meanShapes.text.x + labelProximityThreshold * 2 > maxShapes.text.x) { 99 p99Shapes.text.class = 'hidden'; 100 } 101 102 return [maxShapes, p99Shapes, meanShapes]; 103 } else { 104 return []; 105 } 106 } 107 108 shapesFor(key) { 109 const stat = this.args.stats[key]; 110 111 const rectWidth = this.xScale(stat); 112 const rectHeight = this[`${key}Height`]; 113 114 const tickX = rectWidth + this.gutterWidthLeft; 115 116 const label = statsKeyToLabel[key]; 117 118 return { 119 class: key, 120 text: { 121 label, 122 x: tickX, 123 y: this.tickTextHeight - 5, 124 class: '', // overridden in statsShapes to align/hide based on proximity 125 }, 126 line: { 127 x1: tickX, 128 y1: this.tickTextHeight, 129 x2: tickX, 130 y2: this.centerY - 2, 131 }, 132 rect: { 133 x: this.gutterWidthLeft, 134 y: 135 (this.edgeTickHeight - rectHeight) / 2 + 136 this.centerTickOffset + 137 this.tickTextHeight, 138 width: rectWidth, 139 height: rectHeight, 140 }, 141 }; 142 } 143 144 get barWidth() { 145 return this.width - this.gutterWidth; 146 } 147 148 get higherValue() { 149 return Math.max(this.args.currentValue, this.args.recommendedValue); 150 } 151 152 get maximumX() { 153 return Math.max( 154 this.higherValue, 155 get(this.args.stats, 'max') || Number.MIN_SAFE_INTEGER 156 ); 157 } 158 159 get lowerValue() { 160 return Math.min(this.args.currentValue, this.args.recommendedValue); 161 } 162 163 get xScale() { 164 return scaleLinear() 165 .domain([0, this.maximumX]) 166 .rangeRound([0, this.barWidth]); 167 } 168 169 get lowerValueWidth() { 170 return this.gutterWidthLeft + this.xScale(this.lowerValue); 171 } 172 173 get higherValueWidth() { 174 return this.gutterWidthLeft + this.xScale(this.higherValue); 175 } 176 177 get center() { 178 if (this.width) { 179 return { 180 x1: this.gutterWidthLeft, 181 y1: this.centerY, 182 x2: this.width - this.gutterWidthRight, 183 y2: this.centerY, 184 }; 185 } else { 186 return null; 187 } 188 } 189 190 get resourceLabel() { 191 const text = this.args.resource === 'CPU' ? 'CPU' : 'Mem'; 192 193 return { 194 text, 195 x: this.gutterWidthLeft - 10, 196 y: this.centerY, 197 }; 198 } 199 200 get deltaRect() { 201 if (this.isIncrease) { 202 return { 203 x: this.lowerValueWidth, 204 y: this.edgeTickY1, 205 width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0, 206 height: this.edgeTickHeight, 207 }; 208 } else { 209 return { 210 x: this.shown ? this.lowerValueWidth : this.higherValueWidth, 211 y: this.edgeTickY1, 212 width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0, 213 height: this.edgeTickHeight, 214 }; 215 } 216 } 217 218 get deltaTriangle() { 219 const directionXMultiplier = this.isIncrease ? 1 : -1; 220 let translateX; 221 222 if (this.shown) { 223 translateX = this.isIncrease 224 ? this.higherValueWidth 225 : this.lowerValueWidth; 226 } else { 227 translateX = this.isIncrease 228 ? this.lowerValueWidth 229 : this.higherValueWidth; 230 } 231 232 return { 233 style: htmlSafe(`transform: translateX(${translateX}px)`), 234 points: ` 235 0,${this.center.y1} 236 0,${this.center.y1 - this.deltaTriangleHeight / 2} 237 ${(directionXMultiplier * this.deltaTriangleHeight) / 2},${ 238 this.center.y1 239 } 240 0,${this.center.y1 + this.deltaTriangleHeight / 2} 241 `, 242 }; 243 } 244 245 get deltaLines() { 246 if (this.isIncrease) { 247 return { 248 original: { 249 x: this.lowerValueWidth, 250 }, 251 delta: { 252 style: htmlSafe( 253 `transform: translateX(${ 254 this.shown ? this.higherValueWidth : this.lowerValueWidth 255 }px)` 256 ), 257 }, 258 }; 259 } else { 260 return { 261 original: { 262 x: this.higherValueWidth, 263 }, 264 delta: { 265 style: htmlSafe( 266 `transform: translateX(${ 267 this.shown ? this.lowerValueWidth : this.higherValueWidth 268 }px)` 269 ), 270 }, 271 }; 272 } 273 } 274 275 get deltaText() { 276 const yOffset = 17; 277 const y = this.deltaTextY + yOffset; 278 279 const lowerValueText = { 280 anchor: 'end', 281 x: this.lowerValueWidth, 282 y, 283 }; 284 285 const higherValueText = { 286 anchor: 'start', 287 x: this.higherValueWidth, 288 y, 289 }; 290 291 const percentText = formatPercent( 292 (this.args.recommendedValue - this.args.currentValue) / 293 this.args.currentValue 294 ); 295 296 const percent = { 297 x: (lowerValueText.x + higherValueText.x) / 2, 298 y, 299 text: percentText, 300 }; 301 302 if (this.isIncrease) { 303 return { 304 original: lowerValueText, 305 delta: higherValueText, 306 percent, 307 }; 308 } else { 309 return { 310 original: higherValueText, 311 delta: lowerValueText, 312 percent, 313 }; 314 } 315 } 316 317 get chartHeight() { 318 return this.deltaText.original.y + 1; 319 } 320 321 get tooltipStyle() { 322 if (this.showLegend) { 323 return htmlSafe(`left: ${this.mouseX}px`); 324 } 325 326 return undefined; 327 } 328 329 get sortedStats() { 330 if (this.args.stats) { 331 const statsWithCurrentAndRecommended = { 332 ...this.args.stats, 333 current: this.args.currentValue, 334 recommended: this.args.recommendedValue, 335 }; 336 337 return Object.keys(statsWithCurrentAndRecommended) 338 .map((key) => ({ 339 label: statsKeyToLabel[key], 340 value: statsWithCurrentAndRecommended[key], 341 })) 342 .sortBy('value'); 343 } else { 344 return []; 345 } 346 } 347 348 @action 349 isShown() { 350 next(() => { 351 this.shown = true; 352 }); 353 } 354 355 @action 356 onResize() { 357 this.width = this.svgElement.clientWidth; 358 this.height = this.svgElement.clientHeight; 359 } 360 361 @action 362 storeSvgElement(element) { 363 this.svgElement = element; 364 } 365 366 @action 367 setLegendPosition(mouseMoveEvent) { 368 this.showLegend = true; 369 this.mouseX = mouseMoveEvent.layerX; 370 } 371 372 @action 373 setActiveLegendRow(row) { 374 this.activeLegendRow = row; 375 } 376 377 @action 378 unsetActiveLegendRow() { 379 this.activeLegendRow = undefined; 380 } 381 }