import { ChartInput, getTheme } from "../types.js"; 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; } // ─── GANTT ─────────────────────────────────────────────────────────────────── export function generateGanttChart(input: ChartInput): string { const theme = getTheme(input.style); const W = input.style?.width ?? 640; const font = input.style?.fontFamily ?? "Inter, system-ui, sans-serif"; const rowH = 36; const padTop = input.title ? 50 : 20; const padBottom = 40; const padLeft = 110; const padRight = 20; const n = input.data.length; const H = Math.max(input.style?.height ?? 0, padTop + padBottom + n * rowH + 10); const allStarts = input.data.map((d) => d.start ?? 0); const allEnds = input.data.map((d) => d.end ?? 0); const axisMin = Math.min(...allStarts, 0); const axisMax = Math.max(...allEnds, 100); const axisRange = axisMax - axisMin || 1; const chartW = W - padLeft - padRight; const xPx = (v: number) => padLeft + ((v - axisMin) / axisRange) * chartW; let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Axis ticks const tickCount = 5; for (let i = 0; i <= tickCount; i++) { const v = axisMin + (axisRange * i) / tickCount; const x = xPx(v); svg += ``; svg += `${v.toFixed(0)}`; } input.data.forEach((d, i) => { const y = padTop + i * rowH; const x1 = xPx(d.start ?? 0); const x2 = xPx(d.end ?? 0); const bW = Math.max(x2 - x1, 4); const color = d.color ?? theme.palette[i % theme.palette.length]; // Row bg if (i % 2 === 0) { svg += ``; } // Label svg += `${esc(truncate(d.label, 12))}`; // Bar svg += ``; // Duration label on bar const dur = (d.end ?? 0) - (d.start ?? 0); if (bW > 30) { svg += `${dur.toFixed(0)}`; } }); svg += ``; return svg; } // ─── MEKKO ─────────────────────────────────────────────────────────────────── export function generateMekkoChart(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 padTop = input.title ? 50 : 20; const padBottom = 40; const padLeft = 20; 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 grandTotal = totals.reduce((a, b) => a + b, 0); let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } let xCursor = padLeft; input.data.forEach((d, i) => { const colW = (totals[i] / grandTotal) * chartW; let yCursor = padTop + chartH; const vals = d.values ?? []; const colTotal = totals[i]; vals.forEach((val, s) => { const segH = colTotal > 0 ? (val / colTotal) * chartH : 0; const color = theme.palette[s % theme.palette.length]; yCursor -= segH; svg += ``; if (segH > 16 && colW > 30) { const pct = colTotal > 0 ? ((val / colTotal) * 100).toFixed(0) : "0"; svg += `${pct}%`; } }); // Column label svg += `${esc(truncate(d.label, 10))}`; // Width % const widthPct = grandTotal > 0 ? ((totals[i] / grandTotal) * 100).toFixed(0) : "0"; svg += `${widthPct}%`; xCursor += colW; }); svg += ``; return svg; } // ─── RADAR ─────────────────────────────────────────────────────────────────── export function generateRadarChart(input: ChartInput): string { const theme = getTheme(input.style); const W = input.style?.width ?? 520; const H = input.style?.height ?? 460; const font = input.style?.fontFamily ?? "Inter, system-ui, sans-serif"; const padTop = input.title ? 40 : 20; const padBottom = 20; const padSide = 60; const cx = W / 2; const cy = padTop + (H - padTop - padBottom) / 2; const maxR = Math.min((W - 2 * padSide) / 2, (H - padTop - padBottom) / 2); const n = input.data.length; const color = theme.palette[0]; const levels = 5; function angleFor(i: number) { return ((i / n) * 2 * Math.PI) - Math.PI / 2; } function point(i: number, r: number) { const a = angleFor(i); return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }; } let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Grid levels for (let l = 1; l <= levels; l++) { const r = (l / levels) * maxR; const pts = Array.from({ length: n }, (_, i) => point(i, r)); const pathD = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ") + " Z"; svg += ``; if (l === levels) { // percent label at top svg += `100`; } } // Spokes for (let i = 0; i < n; i++) { const outer = point(i, maxR); svg += ``; } // Data polygon const dataPoints = input.data.map((d, i) => { const v = Math.min(Math.max(d.value ?? 0, 0), 100); return point(i, (v / 100) * maxR); }); const dataPath = dataPoints.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ") + " Z"; svg += ``; // Data dots dataPoints.forEach((p) => { svg += ``; }); // Axis labels input.data.forEach((d, i) => { const labelR = maxR + 20; const p = point(i, labelR); const anchor = p.x < cx - 5 ? "end" : p.x > cx + 5 ? "start" : "middle"; svg += `${esc(d.label)}`; // Value const valP = point(i, (Math.min(Math.max(d.value ?? 0, 0), 100) / 100) * maxR); svg += `${d.value}`; }); svg += ``; return svg; } // ─── HEATMAP ───────────────────────────────────────────────────────────────── export function generateHeatmapChart(input: ChartInput): string { const theme = getTheme(input.style); const font = input.style?.fontFamily ?? "Inter, system-ui, sans-serif"; // Extract unique rows and cols const rows = [...new Set(input.data.map((d) => d.row ?? ""))]; const cols = [...new Set(input.data.map((d) => d.col ?? ""))]; const padTop = input.title ? 50 : 20; const padBottom = 40; const padLeft = 80; const padRight = 30; const cellW = Math.max(40, Math.min(80, 500 / cols.length)); const cellH = Math.max(28, Math.min(60, 400 / rows.length)); const W = input.style?.width ?? padLeft + cols.length * cellW + padRight; const H = input.style?.height ?? padTop + rows.length * cellH + padBottom; const values = input.data.map((d) => d.value ?? 0); const minVal = Math.min(...values); const maxVal = Math.max(...values); const valRange = maxVal - minVal || 1; function cellColor(v: number): string { const t = (v - minVal) / valRange; // Interpolate: low = dim blue → high = accent const accent = theme.accent; // Parse hex const r = parseInt(accent.slice(1, 3), 16); const g = parseInt(accent.slice(3, 5), 16); const b = parseInt(accent.slice(5, 7), 16); const bgR = theme.background === "#0a0a0a" ? 20 : 220; const bgG = bgR; const bgB = bgR + 30; const cr = Math.round(bgR + t * (r - bgR)); const cg = Math.round(bgG + t * (g - bgG)); const cb = Math.round(bgB + t * (b - bgB)); return `rgb(${cr},${cg},${cb})`; } function textColor(v: number): string { const t = (v - minVal) / valRange; return t > 0.5 ? "white" : theme.subtext; } let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Column headers cols.forEach((col, ci) => { const x = padLeft + ci * cellW + cellW / 2; svg += `${esc(truncate(col, 8))}`; }); // Row labels + cells rows.forEach((row, ri) => { const y = padTop + ri * cellH; svg += `${esc(truncate(row, 10))}`; cols.forEach((col, ci) => { const datum = input.data.find((d) => d.row === row && d.col === col); const val = datum?.value ?? 0; const x = padLeft + ci * cellW; const bg = cellColor(val); const tc = textColor(val); svg += ``; svg += `${val}`; }); }); svg += ``; return svg; }