open-nomad/ui/app/components/das/recommendation-chart.js
2022-01-20 09:46:45 -05:00

358 lines
8 KiB
JavaScript

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
import { htmlSafe } from '@ember/string';
import { get } from '@ember/object';
import { scaleLinear } from 'd3-scale';
import d3Format from 'd3-format';
const statsKeyToLabel = {
min: 'Min',
median: 'Median',
mean: 'Mean',
p99: '99th',
max: 'Max',
current: 'Current',
recommended: 'New',
};
const formatPercent = d3Format.format('+.0%');
export default class RecommendationChartComponent extends Component {
@tracked width;
@tracked height;
@tracked shown = false;
@tracked showLegend = false;
@tracked mouseX;
@tracked activeLegendRow;
get isIncrease() {
return this.args.currentValue < this.args.recommendedValue;
}
get directionClass() {
if (this.args.disabled) {
return 'disabled';
} else if (this.isIncrease) {
return 'increase';
} else {
return 'decrease';
}
}
get icon() {
return {
x: 0,
y: this.resourceLabel.y - this.iconHeight / 2,
width: 20,
height: this.iconHeight,
name: this.isIncrease ? 'arrow-up' : 'arrow-down',
};
}
gutterWidthLeft = 62;
gutterWidthRight = 50;
gutterWidth = this.gutterWidthLeft + this.gutterWidthRight;
iconHeight = 21;
tickTextHeight = 15;
edgeTickHeight = 23;
centerTickOffset = 6;
centerY = this.tickTextHeight + this.centerTickOffset + this.edgeTickHeight / 2;
edgeTickY1 = this.tickTextHeight + this.centerTickOffset;
edgeTickY2 = this.tickTextHeight + this.edgeTickHeight + this.centerTickOffset;
deltaTextY = this.edgeTickY2;
meanHeight = this.edgeTickHeight * 0.6;
p99Height = this.edgeTickHeight * 0.48;
maxHeight = this.edgeTickHeight * 0.4;
deltaTriangleHeight = this.edgeTickHeight / 2.5;
get statsShapes() {
if (this.width) {
const maxShapes = this.shapesFor('max');
const p99Shapes = this.shapesFor('p99');
const meanShapes = this.shapesFor('mean');
const labelProximityThreshold = 25;
if (p99Shapes.text.x + labelProximityThreshold > maxShapes.text.x) {
maxShapes.text.class = 'right';
}
if (meanShapes.text.x + labelProximityThreshold > p99Shapes.text.x) {
p99Shapes.text.class = 'right';
}
if (meanShapes.text.x + labelProximityThreshold * 2 > maxShapes.text.x) {
p99Shapes.text.class = 'hidden';
}
return [maxShapes, p99Shapes, meanShapes];
} else {
return [];
}
}
shapesFor(key) {
const stat = this.args.stats[key];
const rectWidth = this.xScale(stat);
const rectHeight = this[`${key}Height`];
const tickX = rectWidth + this.gutterWidthLeft;
const label = statsKeyToLabel[key];
return {
class: key,
text: {
label,
x: tickX,
y: this.tickTextHeight - 5,
class: '', // overridden in statsShapes to align/hide based on proximity
},
line: {
x1: tickX,
y1: this.tickTextHeight,
x2: tickX,
y2: this.centerY - 2,
},
rect: {
x: this.gutterWidthLeft,
y: (this.edgeTickHeight - rectHeight) / 2 + this.centerTickOffset + this.tickTextHeight,
width: rectWidth,
height: rectHeight,
},
};
}
get barWidth() {
return this.width - this.gutterWidth;
}
get higherValue() {
return Math.max(this.args.currentValue, this.args.recommendedValue);
}
get maximumX() {
return Math.max(this.higherValue, get(this.args.stats, 'max') || Number.MIN_SAFE_INTEGER);
}
get lowerValue() {
return Math.min(this.args.currentValue, this.args.recommendedValue);
}
get xScale() {
return scaleLinear().domain([0, this.maximumX]).rangeRound([0, this.barWidth]);
}
get lowerValueWidth() {
return this.gutterWidthLeft + this.xScale(this.lowerValue);
}
get higherValueWidth() {
return this.gutterWidthLeft + this.xScale(this.higherValue);
}
get center() {
if (this.width) {
return {
x1: this.gutterWidthLeft,
y1: this.centerY,
x2: this.width - this.gutterWidthRight,
y2: this.centerY,
};
} else {
return null;
}
}
get resourceLabel() {
const text = this.args.resource === 'CPU' ? 'CPU' : 'Mem';
return {
text,
x: this.gutterWidthLeft - 10,
y: this.centerY,
};
}
get deltaRect() {
if (this.isIncrease) {
return {
x: this.lowerValueWidth,
y: this.edgeTickY1,
width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
height: this.edgeTickHeight,
};
} else {
return {
x: this.shown ? this.lowerValueWidth : this.higherValueWidth,
y: this.edgeTickY1,
width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
height: this.edgeTickHeight,
};
}
}
get deltaTriangle() {
const directionXMultiplier = this.isIncrease ? 1 : -1;
let translateX;
if (this.shown) {
translateX = this.isIncrease ? this.higherValueWidth : this.lowerValueWidth;
} else {
translateX = this.isIncrease ? this.lowerValueWidth : this.higherValueWidth;
}
return {
style: htmlSafe(`transform: translateX(${translateX}px)`),
points: `
0,${this.center.y1}
0,${this.center.y1 - this.deltaTriangleHeight / 2}
${(directionXMultiplier * this.deltaTriangleHeight) / 2},${this.center.y1}
0,${this.center.y1 + this.deltaTriangleHeight / 2}
`,
};
}
get deltaLines() {
if (this.isIncrease) {
return {
original: {
x: this.lowerValueWidth,
},
delta: {
style: htmlSafe(
`transform: translateX(${this.shown ? this.higherValueWidth : this.lowerValueWidth}px)`
),
},
};
} else {
return {
original: {
x: this.higherValueWidth,
},
delta: {
style: htmlSafe(
`transform: translateX(${this.shown ? this.lowerValueWidth : this.higherValueWidth}px)`
),
},
};
}
}
get deltaText() {
const yOffset = 17;
const y = this.deltaTextY + yOffset;
const lowerValueText = {
anchor: 'end',
x: this.lowerValueWidth,
y,
};
const higherValueText = {
anchor: 'start',
x: this.higherValueWidth,
y,
};
const percentText = formatPercent(
(this.args.recommendedValue - this.args.currentValue) / this.args.currentValue
);
const percent = {
x: (lowerValueText.x + higherValueText.x) / 2,
y,
text: percentText,
};
if (this.isIncrease) {
return {
original: lowerValueText,
delta: higherValueText,
percent,
};
} else {
return {
original: higherValueText,
delta: lowerValueText,
percent,
};
}
}
get chartHeight() {
return this.deltaText.original.y + 1;
}
get tooltipStyle() {
if (this.showLegend) {
return htmlSafe(`left: ${this.mouseX}px`);
}
return undefined;
}
get sortedStats() {
if (this.args.stats) {
const statsWithCurrentAndRecommended = {
...this.args.stats,
current: this.args.currentValue,
recommended: this.args.recommendedValue,
};
return Object.keys(statsWithCurrentAndRecommended)
.map((key) => ({ label: statsKeyToLabel[key], value: statsWithCurrentAndRecommended[key] }))
.sortBy('value');
} else {
return [];
}
}
@action
isShown() {
next(() => {
this.shown = true;
});
}
@action
onResize() {
this.width = this.svgElement.clientWidth;
this.height = this.svgElement.clientHeight;
}
@action
storeSvgElement(element) {
this.svgElement = element;
}
@action
setLegendPosition(mouseMoveEvent) {
this.showLegend = true;
this.mouseX = mouseMoveEvent.layerX;
}
@action
setActiveLegendRow(row) {
this.activeLegendRow = row;
}
@action
unsetActiveLegendRow() {
this.activeLegendRow = undefined;
}
}