1// Small canvas plotting helpers: one axis per chart, recessive grid,
2// min/max decimation for long signals, crosshair + tooltip on hover.
4// a CSS custom property's value, as seen by element el (the page root by default; an
5// embedded widget passes an element inside its shadow root, where its own tokens apply)
6export const css = (name: string, el: Element = document.documentElement) => getComputedStyle(el).getPropertyValue(name).trim();
8export interface Series {
9 x: ArrayLike<number>; // increasing
10 y: ArrayLike<number>;
11 color: string;
12 label: string;
13}
15export interface Marker {
16 x: number;
17 color: string;
18 label: string;
19}
21export interface PlotOptions {
22 xLabel: string;
23 yLabel: string;
24 xRange?: [number, number];
25 yRange?: [number, number];
26 markers?: Marker[];
27 fmtX?: (v: number) => string;
28 fmtY?: (v: number) => string;
29}
31export const PAD = { l: 64, r: 12, t: 10, b: 34 };
33export function setupCanvas(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } {
34 const dpr = window.devicePixelRatio || 1;
35 const w = canvas.clientWidth;
36 const h = canvas.clientHeight;
37 canvas.width = Math.round(w * dpr);
38 canvas.height = Math.round(h * dpr);
39 const ctx = canvas.getContext('2d')!;
40 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
41 return { ctx, w, h };
42}
44export function niceTicks(lo: number, hi: number, count = 5): number[] {
45 const span = hi - lo;
46 if (!(span > 0)) return [lo];
47 const raw = span / count;
48 const mag = 10 ** Math.floor(Math.log10(raw));
49 const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw)!;
50 const out: number[] = [];
51 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);
52 return out;
53}
55export const fmtNum = (v: number) => {
56 const a = Math.abs(v);
57 if (a === 0) return '0';
58 if (a >= 1e4 || a < 1e-2) return v.toExponential(1);
59 return String(Number(v.toPrecision(4)));
60};
62export function drawAxes(
63 ctx: CanvasRenderingContext2D,
64 w: number,
65 h: number,
66 xr: [number, number],
67 yr: [number, number],
68 o: Pick<PlotOptions, 'xLabel' | 'yLabel' | 'fmtX' | 'fmtY'> & { grid?: boolean },
69) {
70 const fx = o.fmtX ?? fmtNum;
71 const fy = o.fmtY ?? fmtNum;
72 const X = (v: number) => PAD.l + ((v - xr[0]) / (xr[1] - xr[0])) * (w - PAD.l - PAD.r);
73 const Y = (v: number) => h - PAD.b - ((v - yr[0]) / (yr[1] - yr[0])) * (h - PAD.t - PAD.b);
74 ctx.font = '11px system-ui, sans-serif';
75 ctx.lineWidth = 1;
76 ctx.strokeStyle = css('--grid');
77 ctx.fillStyle = css('--text-muted');
78 ctx.textAlign = 'right';
79 ctx.textBaseline = 'middle';
80 for (const t of niceTicks(yr[0], yr[1], 4)) {
81 const y = Math.round(Y(t)) + 0.5;
82 if (o.grid !== false) {
83 ctx.beginPath();
84 ctx.moveTo(PAD.l, y);
85 ctx.lineTo(w - PAD.r, y);
86 ctx.stroke();
87 }
88 ctx.fillText(fy(t), PAD.l - 6, y);
89 }
90 ctx.textAlign = 'center';
91 ctx.textBaseline = 'top';
92 for (const t of niceTicks(xr[0], xr[1], Math.max(3, Math.floor((w - PAD.l) / 90)))) {
93 ctx.fillText(fx(t), X(t), h - PAD.b + 5);
94 }
95 ctx.strokeStyle = css('--axis');
96 ctx.beginPath();
97 ctx.moveTo(PAD.l, h - PAD.b + 0.5);
98 ctx.lineTo(w - PAD.r, h - PAD.b + 0.5);
99 ctx.stroke();
100 ctx.fillStyle = css('--text-secondary');
101 ctx.fillText(o.xLabel, PAD.l + (w - PAD.l - PAD.r) / 2, h - 14);
102 ctx.save();
103 ctx.translate(12, PAD.t + (h - PAD.t - PAD.b) / 2);
104 ctx.rotate(-Math.PI / 2);
105 ctx.textBaseline = 'middle';
106 ctx.fillText(o.yLabel, 0, 0);
107 ctx.restore();
108 return { X, Y };
109}
111function lowerBound(x: ArrayLike<number>, v: number): number {
112 let lo = 0;
113 let hi = x.length;
114 while (lo < hi) {
115 const m = (lo + hi) >> 1;
116 if (x[m] < v) lo = m + 1;
117 else hi = m;
118 }
119 return lo;
120}
122export class LinePlot {
123 private series: Series[] = [];
124 private opt!: PlotOptions;
125 private tip: HTMLDivElement;
126 private hoverX: number | null = null;
128 constructor(private canvas: HTMLCanvasElement) {
129 this.tip = document.createElement('div');
130 this.tip.className = 'tooltip';
131 this.tip.hidden = true;
132 canvas.parentElement!.append(this.tip);
133 canvas.addEventListener('mousemove', (e) => {
134 this.hoverX = e.offsetX;
135 this.draw();
136 });
137 canvas.addEventListener('mouseleave', () => {
138 this.hoverX = null;
139 this.tip.hidden = true;
140 this.draw();
141 });
142 new ResizeObserver(() => this.draw()).observe(canvas);
143 }
145 set(series: Series[], opt: PlotOptions) {
146 this.series = series;
147 this.opt = opt;
148 this.draw();
149 }
151 private ranges(): { xr: [number, number]; yr: [number, number] } {
152 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
153 for (const s of this.series) {
154 if (!s.x.length) continue;
155 x0 = Math.min(x0, s.x[0]);
156 x1 = Math.max(x1, s.x[s.x.length - 1]);
157 }
158 const xr = this.opt.xRange ?? [x0, x1];
159 for (const s of this.series) {
160 const a = lowerBound(s.x, xr[0]);
161 const b = lowerBound(s.x, xr[1]);
162 for (let i = a; i < Math.min(b + 1, s.y.length); i++) {
163 y0 = Math.min(y0, s.y[i]);
164 y1 = Math.max(y1, s.y[i]);
165 }
166 }
167 if (!(y1 > y0)) {
168 y0 -= 1;
169 y1 += 1;
170 }
171 const m = 0.05 * (y1 - y0);
172 return { xr, yr: this.opt.yRange ?? [y0 - m, y1 + m] };
173 }
175 draw() {
176 if (!this.opt) return;
177 const { ctx, w, h } = setupCanvas(this.canvas);
178 ctx.clearRect(0, 0, w, h);
179 if (!this.series.length) return;
180 const { xr, yr } = this.ranges();
181 const { X, Y } = drawAxes(ctx, w, h, xr, yr, this.opt);
182 ctx.save();
183 ctx.beginPath();
184 ctx.rect(PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
185 ctx.clip();
186 for (const m of this.opt.markers ?? []) {
187 if (m.x < xr[0] || m.x > xr[1]) continue;
188 ctx.strokeStyle = m.color;
189 ctx.setLineDash([3, 3]);
190 ctx.lineWidth = 1;
191 ctx.beginPath();
192 ctx.moveTo(Math.round(X(m.x)) + 0.5, PAD.t);
193 ctx.lineTo(Math.round(X(m.x)) + 0.5, h - PAD.b);
194 ctx.stroke();
195 }
196 ctx.setLineDash([]);
197 ctx.lineWidth = 1.5;
198 ctx.lineJoin = 'round';
199 const plotW = w - PAD.l - PAD.r;
200 for (const s of this.series) {
201 ctx.strokeStyle = s.color;
202 ctx.beginPath();
203 const a = Math.max(0, lowerBound(s.x, xr[0]) - 1);
204 const b = Math.min(s.x.length - 1, lowerBound(s.x, xr[1]));
205 if (b - a > 2 * plotW) {
206 // min/max per pixel column
207 let col = -1, lo = 0, hi = 0;
208 const flush = () => {
209 if (col < 0) return;
210 ctx.moveTo(PAD.l + col + 0.5, Y(lo));
211 ctx.lineTo(PAD.l + col + 0.5, Y(hi) - 0.5);
212 };
213 for (let i = a; i <= b; i++) {
214 const c = Math.floor(X(s.x[i]) - PAD.l);
215 if (c !== col) {
216 flush();
217 col = c;
218 lo = hi = s.y[i];
219 } else {
220 lo = Math.min(lo, s.y[i]);
221 hi = Math.max(hi, s.y[i]);
222 }
223 }
224 flush();
225 } else {
226 for (let i = a; i <= b; i++) {
227 if (i === a) ctx.moveTo(X(s.x[i]), Y(s.y[i]));
228 else ctx.lineTo(X(s.x[i]), Y(s.y[i]));
229 }
230 }
231 ctx.stroke();
232 }
233 ctx.restore();
235 if (this.hoverX !== null && this.hoverX >= PAD.l && this.hoverX <= w - PAD.r) {
236 const xv = xr[0] + ((this.hoverX - PAD.l) / plotW) * (xr[1] - xr[0]);
237 ctx.strokeStyle = css('--axis');
238 ctx.lineWidth = 1;
239 ctx.beginPath();
240 ctx.moveTo(Math.round(this.hoverX) + 0.5, PAD.t);
241 ctx.lineTo(Math.round(this.hoverX) + 0.5, h - PAD.b);
242 ctx.stroke();
243 const lines = [`${this.opt.xLabel}: ${(this.opt.fmtX ?? fmtNum)(xv)}`];
244 for (const s of this.series) {
245 const i = Math.min(s.x.length - 1, lowerBound(s.x, xv));
246 lines.push(`${s.label}: ${(this.opt.fmtY ?? fmtNum)(s.y[i])}`);
247 }
248 const near = (this.opt.markers ?? []).filter((m) => Math.abs(X(m.x) - this.hoverX!) < 4);
249 for (const m of near) lines.push(m.label);
250 this.tip.textContent = lines.join('\n');
251 this.tip.hidden = false;
252 this.tip.style.left = `${Math.min(this.hoverX + 12, w - 180)}px`;
253 this.tip.style.top = `${PAD.t + 4}px`;
254 }
255 }
256}