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 Number.isInteger(n) ? n.toString() : n.toFixed(1); } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function niceNumber(max: number): number { if (max <= 0) return 10; const magnitude = Math.pow(10, Math.floor(Math.log10(max))); const fraction = max / 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; } export function generateScatterChart(input: ChartInput): string { const theme = getTheme(input.style); const W = input.style?.width ?? 600; const H = input.style?.height ?? 420; const font = input.style?.fontFamily ?? "Inter, system-ui, sans-serif"; const showGrid = input.style?.showGrid !== false; const isBubble = input.type === "bubble"; const padTop = input.title ? 50 : 20; const padBottom = 55; const padLeft = 65; const padRight = 30; const chartW = W - padLeft - padRight; const chartH = H - padTop - padBottom; const xs = input.data.map((d) => d.x ?? 0); const ys = input.data.map((d) => d.y ?? 0); const sizes = isBubble ? input.data.map((d) => d.size ?? 20) : []; const xMin = Math.min(...xs); const xMax = Math.max(...xs); const yMin = Math.min(...ys, 0); const yMax = Math.max(...ys, 0); const xRange = xMax - xMin || 1; const xPad = xRange * 0.1; const xAxisMin = xMin - xPad; const xAxisMax = xMax + xPad; const niceYMax = niceNumber(yMax); const niceYMin = yMin < 0 ? -niceNumber(Math.abs(yMin)) : 0; const yRange = niceYMax - niceYMin; const maxSize = isBubble ? Math.max(...sizes) : 1; const minBubbleR = 6; const maxBubbleR = Math.min(chartW, chartH) * 0.1; const xPx = (v: number) => padLeft + ((v - xAxisMin) / (xAxisMax - xAxisMin)) * chartW; const yPx = (v: number) => padTop + chartH * (1 - (v - niceYMin) / yRange); let svg = ``; svg += ``; if (input.title) { svg += `${esc(input.title)}`; } // Grid if (showGrid) { for (let i = 0; i <= 5; i++) { const v = niceYMin + (yRange * i) / 5; const y = yPx(v); svg += ``; svg += `${fmt(v)}`; } for (let i = 0; i <= 5; i++) { const v = xAxisMin + ((xAxisMax - xAxisMin) * i) / 5; const x = xPx(v); svg += ``; svg += `${fmt(v)}`; } } // Zero lines if (niceYMin < 0) { const y = yPx(0); svg += ``; } // Points input.data.forEach((d, i) => { const x = xPx(d.x ?? 0); const y = yPx(d.y ?? 0); const color = theme.palette[i % theme.palette.length]; let pointR: number; if (isBubble) { const sz = d.size ?? 20; pointR = minBubbleR + ((sz / maxSize) * (maxBubbleR - minBubbleR)); svg += ``; } else { pointR = 5; svg += ``; } // Label if (d.label && input.data.length <= 20) { svg += `${esc(d.label)}`; } }); if (input.yLabel) { svg += `${esc(input.yLabel)}`; } if (input.xLabel) { svg += `${esc(input.xLabel)}`; } svg += ``; return svg; }