/** * chart-utils — dependency-free geometry + color helpers for the sd data-viz system. * No chart library: scales, path builders and palette resolution are all hand-rolled. * See sd DATAVIZ.md. */ export type Point = { x: number; y: number }; export const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); /** Linear scale domain → range. Returns a mapping fn. */ export function linearScale( domain: [number, number], range: [number, number], ): (v: number) => number { const [d0, d1] = domain; const [r0, r1] = range; const span = d1 - d0 || 1; return (v: number) => r0 + ((v - d0) / span) * (r1 - r0); } /** "Nice" rounded tick values across [min, max]. */ export function niceTicks(min: number, max: number, count = 5): number[] { if (min === max) return [min]; const range = max - min; const raw = range / count; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / mag; const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag; const start = Math.ceil(min / step) * step; const ticks: number[] = []; for (let v = start; v <= max + step / 2; v += step) { ticks.push(Math.round(v * 1e6) / 1e6); } return ticks; } /** Polyline path through points (absolute coords). */ export function linePath(points: Point[]): string { if (!points.length) return ""; return points .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`) .join(" "); } /** Smooth (Catmull-Rom → cubic bezier) path through points. */ export function smoothPath(points: Point[], tension = 0.5): string { if (points.length < 3) return linePath(points); let d = `M${points[0].x.toFixed(2)},${points[0].y.toFixed(2)}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i - 1] ?? points[i]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] ?? p2; const c1x = p1.x + ((p2.x - p0.x) / 6) * tension * 2; const c1y = p1.y + ((p2.y - p0.y) / 6) * tension * 2; const c2x = p2.x - ((p3.x - p1.x) / 6) * tension * 2; const c2y = p2.y - ((p3.y - p1.y) / 6) * tension * 2; d += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`; } return d; } /** Closed area path: line across the top points, down to baseline, back. */ export function areaPath(points: Point[], baselineY: number, smooth = false): string { if (!points.length) return ""; const top = smooth ? smoothPath(points) : linePath(points); const first = points[0]; const last = points[points.length - 1]; return `${top} L${last.x.toFixed(2)},${baselineY.toFixed(2)} L${first.x.toFixed(2)},${baselineY.toFixed(2)} Z`; } /** Point on a circle. Angle in radians, 0 = 3 o'clock, clockwise positive. */ export function polarToXY(cx: number, cy: number, r: number, angle: number): Point { return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }; } /** * Donut/pie sector path between two angles (radians, clockwise from 12 o'clock). * rInner = 0 → full pie wedge. */ export function sectorPath( cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number, ): string { const a0 = startAngle - Math.PI / 2; const a1 = endAngle - Math.PI / 2; const large = endAngle - startAngle > Math.PI ? 1 : 0; const o0 = polarToXY(cx, cy, rOuter, a0); const o1 = polarToXY(cx, cy, rOuter, a1); if (rInner <= 0) { return `M${cx},${cy} L${o0.x.toFixed(2)},${o0.y.toFixed(2)} A${rOuter},${rOuter} 0 ${large} 1 ${o1.x.toFixed(2)},${o1.y.toFixed(2)} Z`; } const i1 = polarToXY(cx, cy, rInner, a1); const i0 = polarToXY(cx, cy, rInner, a0); return `M${o0.x.toFixed(2)},${o0.y.toFixed(2)} A${rOuter},${rOuter} 0 ${large} 1 ${o1.x.toFixed(2)},${o1.y.toFixed(2)} L${i1.x.toFixed(2)},${i1.y.toFixed(2)} A${rInner},${rInner} 0 ${large} 0 ${i0.x.toFixed(2)},${i0.y.toFixed(2)} Z`; } /** Closed polygon path (radar). */ export function polygonPath(points: Point[]): string { if (!points.length) return ""; return linePath(points) + " Z"; } const CAT_TOKENS = ["--cat-1", "--cat-2", "--cat-3", "--cat-4", "--cat-5"] as const; /** Categorical color token, cycling cat-1..5. Returns a CSS var() string. */ export function catColor(i: number): string { return `var(${CAT_TOKENS[((i % CAT_TOKENS.length) + CAT_TOKENS.length) % CAT_TOKENS.length]})`; } function hexToRgb(hex: string): [number, number, number] { const h = hex.replace("#", ""); return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ]; } function rgbToHex(r: number, g: number, b: number): string { const c = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0"); return `#${c(r)}${c(g)}${c(b)}`; } /** Interpolate between two hex colors, t in 0..1. */ export function lerpHex(a: string, b: string, t: number): string { const ca = hexToRgb(a); const cb = hexToRgb(b); return rgbToHex( ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t, ); } // Sequential ramp anchors: ink-100 → accent-400 → accent-600 (cool paper → warm accent). const SEQ_A = "#eef0f3"; const SEQ_B = "#fbd64a"; const SEQ_C = "#c7a007"; /** Sequential color ramp for ordered/intensity data (heatmap, treemap). t in 0..1. */ export function seqRamp(t: number): string { const x = clamp(t, 0, 1); return x < 0.5 ? lerpHex(SEQ_A, SEQ_B, x / 0.5) : lerpHex(SEQ_B, SEQ_C, (x - 0.5) / 0.5); }