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 { 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 buildPath(points: Array<{ x: number; y: number }>): string { return points .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) .join(" "); } export function generateLineChart(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 showGrid = input.style?.showGrid !== false; const showLegend = input.style?.showLegend !== false; // Support multi-series via values[] const isMulti = input.data.some((d) => d.values && d.values.length > 0); const seriesCount = isMulti ? Math.max(...input.data.map((d) => d.values?.length ?? 0)) : 1; const legendH = showLegend && isMulti ? 28 : 0; const padTop = input.title ? 50 : 20; const padBottom = 50 + legendH; const padLeft = 60; const padRight = 20; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; // Collect all values for axis const allVals = isMulti ? input.data.flatMap((d) => d.values ?? []) : input.data.map((d) => d.value ?? 0); const maxVal = Math.max(...allVals, 0); const minVal = Math.min(...allVals, 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 xStep = n > 1 ? chartW / (n - 1) : chartW / 2; const xPx = (i: number) => padLeft + i * xStep; const yPx = (v: number) => padTop + chartH * (1 - (v - niceMin) / niceRange); let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Grid lines if (showGrid) { for (let i = 0; i <= 5; i++) { const v = niceMin + (niceRange * i) / 5; const y = yPx(v); svg += ``; svg += `${fmt(v)}`; } // Vertical grid input.data.forEach((_, i) => { const x = xPx(i); svg += ``; }); } // Draw series if (isMulti) { for (let s = 0; s < seriesCount; s++) { const color = theme.palette[s % theme.palette.length]; const points = input.data.map((d, i) => ({ x: xPx(i), y: yPx(d.values?.[s] ?? 0), })); svg += ``; points.forEach((p, i) => { const val = input.data[i].values?.[s] ?? 0; svg += ``; if (input.style?.showValues) { svg += `${fmt(val)}`; } }); } } else { const color = theme.palette[0]; const points = input.data.map((d, i) => ({ x: xPx(i), y: yPx(d.value ?? 0), })); svg += ``; points.forEach((p, i) => { const val = input.data[i].value ?? 0; svg += ``; if (input.style?.showValues) { svg += `${fmt(val)}`; } }); } // X labels const labelStep = Math.max(1, Math.ceil(n / 10)); input.data.forEach((d, i) => { if (i % labelStep === 0 || i === n - 1) { svg += `${esc(truncate(d.label, 10))}`; } }); // Legend for multi-series if (showLegend && isMulti && (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 += ``; svg += `${esc(label)}`; lx += label.length * 7 + 40; }); } if (input.yLabel) { svg += `${esc(input.yLabel)}`; } if (input.xLabel) { svg += `${esc(input.xLabel)}`; } svg += ``; return svg; }