import { ChartInput, getTheme } from "../types.js"; function fmt(n: number): string { if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(1) + "K"; return n.toFixed(n % 1 === 0 ? 0 : 1); } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n - 1) + "…" : s; } function niceNumber(max: number, min: number): number { const absMax = Math.max(Math.abs(max), Math.abs(min), 1); const magnitude = Math.pow(10, Math.floor(Math.log10(absMax))); const fraction = absMax / magnitude; let nice: number; if (fraction <= 1) nice = 1; else if (fraction <= 2) nice = 2; else if (fraction <= 5) nice = 5; else nice = 10; return Math.ceil(absMax / (nice * magnitude)) * nice * magnitude; } export function generateWaterfallChart(input: ChartInput): string { const theme = getTheme(input.style); const W = input.style?.width ?? 640; const H = input.style?.height ?? 420; const font = input.style?.fontFamily ?? "Inter, system-ui, sans-serif"; const showValues = input.style?.showValues !== false; const showGrid = input.style?.showGrid !== false; const padTop = input.title ? 50 : 20; const padBottom = 55; const padLeft = 65; const padRight = 20; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; // Compute running totals for floating bars // For isTotal bars, bar goes from 0 to the running total value // For delta bars, bar floats from running total to running total + value const bars: Array<{ label: string; barStart: number; // the "from" value (bottom of floating bar) barEnd: number; // the "to" value (top of floating bar) isTotal: boolean; value: number; runningTotal: number; }> = []; let running = 0; input.data.forEach((d) => { const val = d.value ?? 0; if (d.isTotal) { // Total bar: always drawn from 0 running = val; bars.push({ label: d.label, barStart: 0, barEnd: val, isTotal: true, value: val, runningTotal: val, }); } else { // Delta bar: floats from current running total const from = running; const to = running + val; bars.push({ label: d.label, barStart: from, barEnd: to, isTotal: false, value: val, runningTotal: to, }); running = to; } }); // Find axis range const allValues = bars.flatMap((b) => [b.barStart, b.barEnd]); const dataMax = Math.max(...allValues, 0); const dataMin = Math.min(...allValues, 0); const axisMax = niceNumber(dataMax, 0); const axisMin = dataMin < 0 ? -niceNumber(Math.abs(dataMin), 0) : 0; const axisRange = axisMax - axisMin; const yPx = (v: number): number => Math.round((padTop + chartH * (1 - (v - axisMin) / axisRange)) * 100) / 100; const zeroY = yPx(0); const n = bars.length; const groupW = chartW / n; const barPad = Math.max(4, groupW * 0.18); const barW = groupW - barPad; // Colors const positiveColor = theme.palette[0]; // accent const negativeColor = theme.palette[4] ?? "#F43F5E"; const totalColor = theme.palette[1] ?? "#22D3EE"; let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Grid lines + y-axis labels const gridLines = 5; if (showGrid) { for (let i = 0; i <= gridLines; i++) { const v = axisMin + (axisRange * i) / gridLines; const y = yPx(v); svg += ``; svg += `${fmt(v)}`; } } // Zero axis line svg += ``; // Draw bars and connectors bars.forEach((b, i) => { const x = padLeft + i * groupW + barPad / 2; const y1 = yPx(Math.max(b.barStart, b.barEnd)); const y2 = yPx(Math.min(b.barStart, b.barEnd)); const barH = Math.max(Math.abs(y2 - y1), 2); let color: string; if (b.isTotal) { color = totalColor; } else if (b.value >= 0) { color = positiveColor; } else { color = negativeColor; } svg += ``; // Value label above/below bar if (showValues) { const sign = b.value >= 0 ? "+" : ""; const displayVal = b.isTotal ? fmt(b.value) : `${sign}${fmt(b.value)}`; const labelY = b.value >= 0 ? y1 - 5 : y2 + 13; svg += `${displayVal}`; } // Connector line to next bar (dashed, at the running total level) if (i < bars.length - 1) { const connectorY = yPx(b.runningTotal); const nextX = padLeft + (i + 1) * groupW + barPad / 2; svg += ``; } // X label svg += `${esc(truncate(b.label, 10))}`; }); // Y axis label if (input.yLabel) { svg += `${esc(input.yLabel)}`; } svg += ``; return svg; }