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