General source code refactoring
This commit is contained in:
383
src/features/consumption/chart.js
Normal file
383
src/features/consumption/chart.js
Normal file
@@ -0,0 +1,383 @@
|
||||
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 = 320;
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user