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