// Minimal canvas plotting helpers. export interface Frame { ctx: CanvasRenderingContext2D; w: number; h: number; // plot area x0: number; y0: number; pw: number; ph: number; css: (name: string) => string; } export function setupCanvas(canvas: HTMLCanvasElement, margin = { l: 56, r: 12, t: 10, b: 32 }): Frame { const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const ctx = canvas.getContext("2d")!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const style = getComputedStyle(canvas); return { ctx, w, h, x0: margin.l, y0: margin.t, pw: Math.max(10, w - margin.l - margin.r), ph: Math.max(10, h - margin.t - margin.b), css: name => style.getPropertyValue(name).trim(), }; } export function niceTicks(lo: number, hi: number, count = 5): number[] { if (!(hi > lo)) return [lo]; const step0 = (hi - lo) / count; const mag = Math.pow(10, Math.floor(Math.log10(step0))); const step = [1, 2, 5, 10].map(m => m * mag).find(s => s >= step0) ?? 10 * mag; const out: number[] = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9 * step; v += step) out.push(Math.abs(v) < 1e-12 * step ? 0 : v); return out; } export function fmt(v: number): string { if (v === 0) return "0"; const a = Math.abs(v); if (a >= 1e4 || a < 1e-3) return v.toExponential(0); return String(+v.toPrecision(3)); } export function drawAxes( f: Frame, xr: [number, number], yr: [number, number], opts: { grid?: boolean; xlabel?: string; ylabel?: string; xticks?: number[]; yticks?: number[]; yfmt?: (v: number) => string; xfmt?: (v: number) => string; ylog?: boolean } = {} ) { const { ctx, x0, y0, pw, ph } = f; ctx.font = "11px system-ui, sans-serif"; ctx.strokeStyle = f.css("--grid"); ctx.fillStyle = f.css("--muted"); ctx.lineWidth = 1; const xt = opts.xticks ?? niceTicks(xr[0], xr[1]); const yt = opts.yticks ?? niceTicks(yr[0], yr[1], 4); ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const v of xt) { const px = x0 + ((v - xr[0]) / (xr[1] - xr[0])) * pw; if (px < x0 - 0.5 || px > x0 + pw + 0.5) continue; if (opts.grid !== false) { ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y0 + ph); ctx.stroke(); } ctx.fillText((opts.xfmt ?? fmt)(v), px, y0 + ph + 4); } ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const v of yt) { const py = y0 + ph - ((v - yr[0]) / (yr[1] - yr[0])) * ph; if (py < y0 - 0.5 || py > y0 + ph + 0.5) continue; if (opts.grid !== false) { ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x0 + pw, py); ctx.stroke(); } ctx.fillText((opts.yfmt ?? fmt)(v), x0 - 5, py); } ctx.strokeStyle = f.css("--axis"); ctx.strokeRect(x0 + 0.5, y0 + 0.5, pw - 1, ph - 1); if (opts.xlabel) { ctx.textAlign = "right"; ctx.textBaseline = "bottom"; ctx.fillText(opts.xlabel, x0 + pw, f.h - 1); } if (opts.ylabel) { ctx.save(); ctx.translate(11, y0 + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(opts.ylabel, 0, 0); ctx.restore(); } } // Sequential color map (dark to bright), t in [0, 1]. export function heat(t: number): [number, number, number] { t = Math.max(0, Math.min(1, t)); const stops: [number, number, number][] = [ [12, 7, 35], [72, 18, 110], [150, 40, 110], [218, 85, 60], [250, 170, 50], [252, 250, 190], ]; const s = t * (stops.length - 1); const i = Math.min(stops.length - 2, Math.floor(s)); const u = s - i; return [0, 1, 2].map(c => Math.round(stops[i][c] + u * (stops[i + 1][c] - stops[i][c]))) as [number, number, number]; }