384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
function createConsumptionChart({
|
|
CONSUMPTION_TREND_PERIODS,
|
|
CONSUMPTION_TREND_SERIES,
|
|
SVG_NAMESPACE,
|
|
calculateAverageConsumption,
|
|
consumptionTrendState,
|
|
createConsumptionTrendKey,
|
|
createNiceConsumptionMaximum,
|
|
formatCompactConsumptionNumber,
|
|
formatConsumptionCurrency,
|
|
formatConsumptionPointLabel,
|
|
formatConsumptionRange,
|
|
normalizeConsumptionTrendItems,
|
|
environment
|
|
}) {
|
|
const { document, window } = environment;
|
|
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
|
|
chart.textContent = "";
|
|
chart.setAttribute("aria-busy", "false");
|
|
const points = normalizeConsumptionTrendItems(items);
|
|
|
|
if (points.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "opportunities-extension-consumption-chart-empty";
|
|
empty.textContent = "No consumption data found for this period.";
|
|
chart.append(empty);
|
|
return;
|
|
}
|
|
|
|
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
|
|
const windowSize = option ? option.windowSize : points.length;
|
|
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
|
|
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
|
|
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
|
|
const end = Math.max(0, points.length - (offset * windowSize));
|
|
const start = Math.max(0, end - windowSize);
|
|
const visiblePoints = points.slice(start, end);
|
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
|
|
|
|
const navigation = document.createElement("div");
|
|
navigation.className = "opportunities-extension-consumption-navigation";
|
|
const olderButton = createConsumptionNavigationButton("older", start === 0);
|
|
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
|
|
const range = document.createElement("strong");
|
|
range.textContent = formatConsumptionRange(visiblePoints, period);
|
|
olderButton.addEventListener("click", () => {
|
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
|
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
|
});
|
|
newerButton.addEventListener("click", () => {
|
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
|
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
|
});
|
|
navigation.append(olderButton, range, newerButton);
|
|
|
|
const figure = document.createElement("figure");
|
|
figure.className = "opportunities-extension-consumption-figure";
|
|
const visual = document.createElement("div");
|
|
visual.className = "opportunities-extension-consumption-visual";
|
|
const tooltip = document.createElement("div");
|
|
tooltip.className = "opportunities-extension-consumption-tooltip";
|
|
tooltip.hidden = true;
|
|
tooltip.setAttribute("role", "tooltip");
|
|
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
|
|
const svg = createConsumptionSvg(
|
|
visiblePoints,
|
|
period,
|
|
end === points.length,
|
|
activeSeries,
|
|
tooltip
|
|
);
|
|
const legend = createConsumptionLegend(
|
|
section,
|
|
chart,
|
|
items,
|
|
period,
|
|
subPlanNum,
|
|
activeSeries
|
|
);
|
|
visual.append(svg, legend);
|
|
figure.append(visual, tooltip);
|
|
chart.append(navigation, figure);
|
|
}
|
|
|
|
function getConsumptionTrendSeriesFilter(subPlanNum) {
|
|
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
|
|
consumptionTrendState.consumptionTrendSeriesFilters.set(
|
|
subPlanNum,
|
|
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
|
|
);
|
|
}
|
|
|
|
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
|
|
}
|
|
|
|
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
|
|
const legend = document.createElement("div");
|
|
legend.className = "opportunities-extension-consumption-legend";
|
|
legend.setAttribute("role", "group");
|
|
legend.setAttribute("aria-label", "Chart series filters");
|
|
|
|
CONSUMPTION_TREND_SERIES.forEach((series) => {
|
|
const button = document.createElement("button");
|
|
const swatch = document.createElement("span");
|
|
const label = document.createElement("span");
|
|
button.type = "button";
|
|
button.dataset.series = series.value;
|
|
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
|
|
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
|
|
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
|
|
swatch.setAttribute("aria-hidden", "true");
|
|
label.textContent = series.label;
|
|
button.append(swatch, label);
|
|
button.addEventListener("click", () => {
|
|
if (activeSeries.has(series.value)) {
|
|
activeSeries.delete(series.value);
|
|
} else {
|
|
activeSeries.add(series.value);
|
|
}
|
|
|
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
|
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
|
|
});
|
|
legend.append(button);
|
|
});
|
|
|
|
return legend;
|
|
}
|
|
|
|
function createConsumptionNavigationButton(direction, disabled) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "opportunities-extension-consumption-navigation-button";
|
|
button.disabled = disabled;
|
|
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
|
|
button.title = direction === "older" ? "Older" : "Newer";
|
|
const icon = createSvgElement("svg", {
|
|
viewBox: "0 0 24 24",
|
|
"aria-hidden": "true",
|
|
focusable: "false"
|
|
});
|
|
icon.append(createSvgElement("path", {
|
|
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
"stroke-linecap": "round",
|
|
"stroke-linejoin": "round",
|
|
"stroke-width": "2"
|
|
}));
|
|
button.append(icon);
|
|
return button;
|
|
}
|
|
|
|
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
|
|
const width = 1000;
|
|
const height = 350;
|
|
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
|
|
const plotWidth = plot.right - plot.left;
|
|
const plotHeight = plot.bottom - plot.top;
|
|
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
|
|
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
|
|
const svg = createSvgElement("svg", {
|
|
viewBox: `0 0 ${width} ${height}`,
|
|
role: "img",
|
|
"aria-label": `${period} cloud consumption chart`
|
|
});
|
|
svg.classList.add("opportunities-extension-consumption-svg");
|
|
|
|
for (let tick = 0; tick <= 5; tick += 1) {
|
|
const value = (maximum / 5) * tick;
|
|
const y = plot.bottom - ((value / maximum) * plotHeight);
|
|
svg.append(
|
|
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
|
|
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
|
|
);
|
|
}
|
|
|
|
const step = plotWidth / Math.max(points.length, 1);
|
|
const barWidth = Math.min(44, Math.max(10, step * 0.58));
|
|
|
|
points.forEach((point, index) => {
|
|
const x = plot.left + (step * index) + ((step - barWidth) / 2);
|
|
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
|
|
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
|
|
const visibleTotal = fundedAmount + overageAmount;
|
|
const fundedHeight = (fundedAmount / maximum) * plotHeight;
|
|
const overageHeight = (overageAmount / maximum) * plotHeight;
|
|
const group = createSvgElement("g", {
|
|
class: "consumption-bar-group",
|
|
tabindex: "0",
|
|
role: "img",
|
|
"aria-label": createConsumptionPointAriaLabel(point, period)
|
|
});
|
|
|
|
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
|
|
group.addEventListener(eventName, (event) => {
|
|
showConsumptionTooltip(tooltip, point, period, event);
|
|
});
|
|
});
|
|
["pointerleave", "blur"].forEach((eventName) => {
|
|
group.addEventListener(eventName, () => {
|
|
tooltip.hidden = true;
|
|
});
|
|
});
|
|
|
|
if (fundedHeight > 0) {
|
|
const fundedBar = createSvgElement("rect", {
|
|
x,
|
|
y: plot.bottom - fundedHeight,
|
|
width: barWidth,
|
|
height: fundedHeight,
|
|
class: "consumption-bar-funded"
|
|
});
|
|
appendConsumptionBarAnimation(
|
|
fundedBar,
|
|
plot.bottom,
|
|
plot.bottom - fundedHeight,
|
|
fundedHeight,
|
|
index * 34
|
|
);
|
|
group.append(fundedBar);
|
|
}
|
|
|
|
if (overageHeight > 0) {
|
|
const overageTop = plot.bottom - fundedHeight - overageHeight;
|
|
const overageBar = createSvgElement("rect", {
|
|
x,
|
|
y: overageTop,
|
|
width: barWidth,
|
|
height: overageHeight,
|
|
class: "consumption-bar-overage"
|
|
});
|
|
appendConsumptionBarAnimation(
|
|
overageBar,
|
|
plot.bottom - fundedHeight,
|
|
overageTop,
|
|
overageHeight,
|
|
(index * 34) + 90
|
|
);
|
|
group.append(overageBar);
|
|
}
|
|
|
|
const top = plot.bottom - fundedHeight - overageHeight;
|
|
|
|
if (visibleTotal > 0) {
|
|
const valueLabel = createSvgText(
|
|
x + (barWidth / 2),
|
|
Math.max(plot.top + 11, top - 7),
|
|
formatCompactConsumptionNumber(visibleTotal),
|
|
"consumption-value-label consumption-animated-label",
|
|
"middle"
|
|
);
|
|
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
|
|
group.append(valueLabel);
|
|
}
|
|
|
|
const label = createSvgText(
|
|
x + (barWidth / 2),
|
|
plot.bottom + 18,
|
|
formatConsumptionPointLabel(point, period),
|
|
"consumption-x-label",
|
|
period === "monthly" || period === "quarterly" ? "middle" : "end"
|
|
);
|
|
|
|
if (period === "weekly" || period === "daily") {
|
|
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
|
|
}
|
|
|
|
group.append(label);
|
|
svg.append(group);
|
|
});
|
|
|
|
if (activeSeries.has("average")) {
|
|
const averageY = plot.bottom - ((average / maximum) * plotHeight);
|
|
const averageLine = createSvgElement("line", {
|
|
x1: plot.left,
|
|
y1: averageY,
|
|
x2: plot.right,
|
|
y2: averageY,
|
|
class: "consumption-average-line consumption-animated-average"
|
|
});
|
|
svg.append(averageLine);
|
|
}
|
|
|
|
return svg;
|
|
}
|
|
|
|
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
|
|
if (prefersReducedMotion()) {
|
|
return;
|
|
}
|
|
|
|
element.append(
|
|
createSvgElement("animate", {
|
|
attributeName: "y",
|
|
from: fromY,
|
|
to: toY,
|
|
dur: "0.52s",
|
|
begin: `${delayMs}ms`,
|
|
fill: "freeze",
|
|
calcMode: "spline",
|
|
keyTimes: "0;1",
|
|
keySplines: "0.2 0.8 0.2 1"
|
|
}),
|
|
createSvgElement("animate", {
|
|
attributeName: "height",
|
|
from: "0",
|
|
to: height,
|
|
dur: "0.52s",
|
|
begin: `${delayMs}ms`,
|
|
fill: "freeze",
|
|
calcMode: "spline",
|
|
keyTimes: "0;1",
|
|
keySplines: "0.2 0.8 0.2 1"
|
|
})
|
|
);
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return typeof window.matchMedia === "function"
|
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}
|
|
|
|
function createConsumptionPointAriaLabel(point, period) {
|
|
return `${formatConsumptionPointLabel(point, period)}. `
|
|
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
|
|
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
|
|
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
|
|
}
|
|
|
|
function showConsumptionTooltip(tooltip, point, period, event) {
|
|
const figure = tooltip.parentElement;
|
|
|
|
if (!figure) {
|
|
return;
|
|
}
|
|
|
|
tooltip.textContent = "";
|
|
const title = document.createElement("strong");
|
|
title.textContent = formatConsumptionPointLabel(point, period);
|
|
const details = document.createElement("span");
|
|
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
|
|
const overage = document.createElement("span");
|
|
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
|
|
const total = document.createElement("span");
|
|
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
|
|
tooltip.append(title, details, overage, total);
|
|
tooltip.hidden = false;
|
|
|
|
const figureBounds = figure.getBoundingClientRect();
|
|
const targetBounds = event.currentTarget.getBoundingClientRect();
|
|
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
|
|
? event.clientX
|
|
: targetBounds.left + (targetBounds.width / 2);
|
|
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
|
|
? event.clientY
|
|
: targetBounds.top;
|
|
const left = Math.min(
|
|
Math.max(8, pointerX - figureBounds.left + 12),
|
|
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
|
|
);
|
|
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
|
|
tooltip.style.left = `${left}px`;
|
|
tooltip.style.top = `${top}px`;
|
|
}
|
|
|
|
function createSvgElement(name, attributes = {}) {
|
|
const element = document.createElementNS(SVG_NAMESPACE, name);
|
|
|
|
Object.entries(attributes).forEach(([key, value]) => {
|
|
element.setAttribute(key, String(value));
|
|
});
|
|
return element;
|
|
}
|
|
|
|
function createSvgText(x, y, value, className, anchor) {
|
|
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
|
|
text.textContent = value;
|
|
return text;
|
|
}
|
|
|
|
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
|
|
}
|