// Small canvas plotting helpers: one axis per chart, recessive grid, // min/max decimation for long signals, crosshair + tooltip on hover. // a CSS custom property's value, as seen by element el (the page root by default; an // embedded widget passes an element inside its shadow root, where its own tokens apply) export const css = (name: string, el: Element = document.documentElement) => getComputedStyle(el).getPropertyValue(name).trim(); export interface Series { x: ArrayLike; // increasing y: ArrayLike; color: string; label: string; } export interface Marker { x: number; color: string; label: string; } export interface PlotOptions { xLabel: string; yLabel: string; xRange?: [number, number]; yRange?: [number, number]; markers?: Marker[]; fmtX?: (v: number) => string; fmtY?: (v: number) => string; } export const PAD = { l: 64, r: 12, t: 10, b: 34 }; export function setupCanvas(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } { 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); return { ctx, w, h }; } export function niceTicks(lo: number, hi: number, count = 5): number[] { const span = hi - lo; if (!(span > 0)) return [lo]; const raw = span / count; const mag = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw)!; 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 const fmtNum = (v: number) => { const a = Math.abs(v); if (a === 0) return '0'; if (a >= 1e4 || a < 1e-2) return v.toExponential(1); return String(Number(v.toPrecision(4))); }; export function drawAxes( ctx: CanvasRenderingContext2D, w: number, h: number, xr: [number, number], yr: [number, number], o: Pick & { grid?: boolean }, ) { const fx = o.fmtX ?? fmtNum; const fy = o.fmtY ?? fmtNum; const X = (v: number) => PAD.l + ((v - xr[0]) / (xr[1] - xr[0])) * (w - PAD.l - PAD.r); const Y = (v: number) => h - PAD.b - ((v - yr[0]) / (yr[1] - yr[0])) * (h - PAD.t - PAD.b); ctx.font = '11px system-ui, sans-serif'; ctx.lineWidth = 1; ctx.strokeStyle = css('--grid'); ctx.fillStyle = css('--text-muted'); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (const t of niceTicks(yr[0], yr[1], 4)) { const y = Math.round(Y(t)) + 0.5; if (o.grid !== false) { ctx.beginPath(); ctx.moveTo(PAD.l, y); ctx.lineTo(w - PAD.r, y); ctx.stroke(); } ctx.fillText(fy(t), PAD.l - 6, y); } ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (const t of niceTicks(xr[0], xr[1], Math.max(3, Math.floor((w - PAD.l) / 90)))) { ctx.fillText(fx(t), X(t), h - PAD.b + 5); } ctx.strokeStyle = css('--axis'); ctx.beginPath(); ctx.moveTo(PAD.l, h - PAD.b + 0.5); ctx.lineTo(w - PAD.r, h - PAD.b + 0.5); ctx.stroke(); ctx.fillStyle = css('--text-secondary'); ctx.fillText(o.xLabel, PAD.l + (w - PAD.l - PAD.r) / 2, h - 14); ctx.save(); ctx.translate(12, PAD.t + (h - PAD.t - PAD.b) / 2); ctx.rotate(-Math.PI / 2); ctx.textBaseline = 'middle'; ctx.fillText(o.yLabel, 0, 0); ctx.restore(); return { X, Y }; } function lowerBound(x: ArrayLike, v: number): number { let lo = 0; let hi = x.length; while (lo < hi) { const m = (lo + hi) >> 1; if (x[m] < v) lo = m + 1; else hi = m; } return lo; } export class LinePlot { private series: Series[] = []; private opt!: PlotOptions; private tip: HTMLDivElement; private hoverX: number | null = null; constructor(private canvas: HTMLCanvasElement) { this.tip = document.createElement('div'); this.tip.className = 'tooltip'; this.tip.hidden = true; canvas.parentElement!.append(this.tip); canvas.addEventListener('mousemove', (e) => { this.hoverX = e.offsetX; this.draw(); }); canvas.addEventListener('mouseleave', () => { this.hoverX = null; this.tip.hidden = true; this.draw(); }); new ResizeObserver(() => this.draw()).observe(canvas); } set(series: Series[], opt: PlotOptions) { this.series = series; this.opt = opt; this.draw(); } private ranges(): { xr: [number, number]; yr: [number, number] } { let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; for (const s of this.series) { if (!s.x.length) continue; x0 = Math.min(x0, s.x[0]); x1 = Math.max(x1, s.x[s.x.length - 1]); } const xr = this.opt.xRange ?? [x0, x1]; for (const s of this.series) { const a = lowerBound(s.x, xr[0]); const b = lowerBound(s.x, xr[1]); for (let i = a; i < Math.min(b + 1, s.y.length); i++) { y0 = Math.min(y0, s.y[i]); y1 = Math.max(y1, s.y[i]); } } if (!(y1 > y0)) { y0 -= 1; y1 += 1; } const m = 0.05 * (y1 - y0); return { xr, yr: this.opt.yRange ?? [y0 - m, y1 + m] }; } draw() { if (!this.opt) return; const { ctx, w, h } = setupCanvas(this.canvas); ctx.clearRect(0, 0, w, h); if (!this.series.length) return; const { xr, yr } = this.ranges(); const { X, Y } = drawAxes(ctx, w, h, xr, yr, this.opt); ctx.save(); ctx.beginPath(); ctx.rect(PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b); ctx.clip(); for (const m of this.opt.markers ?? []) { if (m.x < xr[0] || m.x > xr[1]) continue; ctx.strokeStyle = m.color; ctx.setLineDash([3, 3]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.round(X(m.x)) + 0.5, PAD.t); ctx.lineTo(Math.round(X(m.x)) + 0.5, h - PAD.b); ctx.stroke(); } ctx.setLineDash([]); ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; const plotW = w - PAD.l - PAD.r; for (const s of this.series) { ctx.strokeStyle = s.color; ctx.beginPath(); const a = Math.max(0, lowerBound(s.x, xr[0]) - 1); const b = Math.min(s.x.length - 1, lowerBound(s.x, xr[1])); if (b - a > 2 * plotW) { // min/max per pixel column let col = -1, lo = 0, hi = 0; const flush = () => { if (col < 0) return; ctx.moveTo(PAD.l + col + 0.5, Y(lo)); ctx.lineTo(PAD.l + col + 0.5, Y(hi) - 0.5); }; for (let i = a; i <= b; i++) { const c = Math.floor(X(s.x[i]) - PAD.l); if (c !== col) { flush(); col = c; lo = hi = s.y[i]; } else { lo = Math.min(lo, s.y[i]); hi = Math.max(hi, s.y[i]); } } flush(); } else { for (let i = a; i <= b; i++) { if (i === a) ctx.moveTo(X(s.x[i]), Y(s.y[i])); else ctx.lineTo(X(s.x[i]), Y(s.y[i])); } } ctx.stroke(); } ctx.restore(); if (this.hoverX !== null && this.hoverX >= PAD.l && this.hoverX <= w - PAD.r) { const xv = xr[0] + ((this.hoverX - PAD.l) / plotW) * (xr[1] - xr[0]); ctx.strokeStyle = css('--axis'); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.round(this.hoverX) + 0.5, PAD.t); ctx.lineTo(Math.round(this.hoverX) + 0.5, h - PAD.b); ctx.stroke(); const lines = [`${this.opt.xLabel}: ${(this.opt.fmtX ?? fmtNum)(xv)}`]; for (const s of this.series) { const i = Math.min(s.x.length - 1, lowerBound(s.x, xv)); lines.push(`${s.label}: ${(this.opt.fmtY ?? fmtNum)(s.y[i])}`); } const near = (this.opt.markers ?? []).filter((m) => Math.abs(X(m.x) - this.hoverX!) < 4); for (const m of near) lines.push(m.label); this.tip.textContent = lines.join('\n'); this.tip.hidden = false; this.tip.style.left = `${Math.min(this.hoverX + 12, w - 180)}px`; this.tip.style.top = `${PAD.t + 4}px`; } } }