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 }; }