import { ChartInput, ThemeColors, 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); } export function generateBarChart(input: ChartInput): string { const theme = getTheme(input.style); const W = input.style?.width ?? 600; const H = input.style?.height ?? 400; 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 = 60; const padRight = 20; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; const values = input.data.map((d) => d.value ?? 0); const maxVal = Math.max(...values, 0); const minVal = Math.min(...values, 0); const range = maxVal - minVal || 1; // Nice axis const niceMax = niceNumber(maxVal, minVal); const niceMin = minVal < 0 ? -niceNumber(Math.abs(minVal), 0) : 0; const niceRange = niceMax - niceMin; const n = input.data.length; const groupW = chartW / n; const barPad = Math.max(4, groupW * 0.2); const barW = groupW - barPad; const yScale = (v: number) => chartH - ((v - niceMin) / niceRange) * chartH; const zeroY = yScale(0); const gridLines = 5; let svg = ``; svg += ``; // Title if (input.title) { svg += `${esc(input.title)}`; } // Grid lines if (showGrid) { for (let i = 0; i <= gridLines; i++) { const v = niceMin + (niceRange * i) / gridLines; const y = padTop + yScale(v); svg += ``; svg += `${fmt(v)}`; } } // Zero line if (niceMin < 0) { const y = padTop + zeroY; svg += ``; } // Bars input.data.forEach((d, i) => { const val = d.value ?? 0; const barColor = d.color ?? theme.palette[i % theme.palette.length]; const x = padLeft + i * groupW + barPad / 2; const barH = Math.abs(((val - 0) / niceRange) * chartH); const y = padTop + (val >= 0 ? yScale(val) : zeroY); svg += ``; // Value label if (showValues) { const labelY = val >= 0 ? y - 5 : y + barH + 13; svg += `${fmt(val)}`; } // X label const labelText = truncate(d.label, 10); svg += `${esc(labelText)}`; }); // Y axis label if (input.yLabel) { svg += `${esc(input.yLabel)}`; } // X axis label if (input.xLabel) { svg += `${esc(input.xLabel)}`; } svg += ``; return svg; } export function generateGroupedBarChart(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 showLegend = input.style?.showLegend !== false; const legendH = showLegend ? 30 : 0; const padTop = input.title ? 50 : 20; const padBottom = 55 + legendH; const padLeft = 60; const padRight = 20; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; // Determine number of series const seriesCount = Math.max(...input.data.map((d) => d.values?.length ?? 0)); const allValues = input.data.flatMap((d) => d.values ?? []); const maxVal = Math.max(...allValues, 0); const minVal = Math.min(...allValues, 0); const niceMax = niceNumber(maxVal, minVal); const niceMin = minVal < 0 ? -niceNumber(Math.abs(minVal), 0) : 0; const niceRange = niceMax - niceMin; const n = input.data.length; const groupW = chartW / n; const groupPad = Math.max(4, groupW * 0.15); const innerW = groupW - groupPad; const barW = innerW / seriesCount - 2; const yScale = (v: number) => chartH - ((v - niceMin) / niceRange) * chartH; const zeroY = yScale(0); let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } if (showGrid) { for (let i = 0; i <= 5; i++) { const v = niceMin + (niceRange * i) / 5; const y = padTop + yScale(v); svg += ``; svg += `${fmt(v)}`; } } input.data.forEach((d, i) => { const groupX = padLeft + i * groupW + groupPad / 2; (d.values ?? []).forEach((val, s) => { const barColor = theme.palette[s % theme.palette.length]; const x = groupX + s * (barW + 2); const barH = Math.abs(((val - 0) / niceRange) * chartH); const y = padTop + (val >= 0 ? yScale(val) : zeroY); svg += ``; if (showValues) { const lY = val >= 0 ? y - 4 : y + barH + 12; svg += `${fmt(val)}`; } }); const labelText = truncate(d.label, 10); svg += `${esc(labelText)}`; }); // Legend if (showLegend && (input as any).seriesLabels) { const labels: string[] = (input as any).seriesLabels; const legendY = H - legendH + 8; let lx = padLeft; labels.forEach((label, s) => { const c = theme.palette[s % theme.palette.length]; svg += ``; svg += `${esc(label)}`; lx += label.length * 7 + 30; }); } svg += ``; return svg; } export function generateStackedBarChart(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 showGrid = input.style?.showGrid !== false; const showLegend = input.style?.showLegend !== false; const legendH = showLegend ? 30 : 0; const padTop = input.title ? 50 : 20; const padBottom = 55 + legendH; const padLeft = 60; const padRight = 20; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; const totals = input.data.map((d) => (d.values ?? []).reduce((a, b) => a + b, 0)); const maxVal = Math.max(...totals, 0); const niceMax = niceNumber(maxVal, 0); const n = input.data.length; const groupW = chartW / n; const barPad = Math.max(4, groupW * 0.2); const barW = groupW - barPad; const yScale = (v: number) => chartH * (1 - v / niceMax); let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } if (showGrid) { for (let i = 0; i <= 5; i++) { const v = (niceMax * i) / 5; const y = padTop + yScale(v); svg += ``; svg += `${fmt(v)}`; } } input.data.forEach((d, i) => { const x = padLeft + i * groupW + barPad / 2; let cumulative = 0; (d.values ?? []).forEach((val, s) => { const barColor = theme.palette[s % theme.palette.length]; const segH = (val / niceMax) * chartH; const y = padTop + yScale(cumulative + val); svg += ``; cumulative += val; }); const labelText = truncate(d.label, 10); svg += `${esc(labelText)}`; }); if (showLegend && (input as any).seriesLabels) { const labels: string[] = (input as any).seriesLabels; const legendY = H - legendH + 8; let lx = padLeft; labels.forEach((label, s) => { const c = theme.palette[s % theme.palette.length]; svg += ``; svg += `${esc(label)}`; lx += label.length * 7 + 30; }); } svg += ``; return svg; } function niceNumber(max: number, min: number): number { if (max <= 0) return 10; const range = max - Math.min(min, 0); const magnitude = Math.pow(10, Math.floor(Math.log10(range))); const fraction = range / 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(max / (nice * magnitude)) * nice * magnitude; } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n - 1) + "…" : s; }