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}
58// Axis tick labels: whole numbers below 10^7 in full (20000 rather than 2.0e+4), the rest as fmtNum.
59// Ticks are multiples of the step, so round off what adding the step accumulated.
60export const fmtTick = (v: number) => {
61 const r = Math.round(v);
62 return Math.abs(r) >= 1 && Math.abs(r) < 1e7 && Math.abs(v - r) < 1e-9 * Math.abs(v) ? String(r) : fmtNum(v);
63};
65export const fmtNum = (v: number) => {
66 const a = Math.abs(v);
67 if (a === 0) return '0';
68 if (a >= 1e4 || a < 1e-2) return v.toExponential(1);
69 return String(Number(v.toPrecision(4)));
70};
72export function drawAxes(
73 ctx: CanvasRenderingContext2D,
74 w: number,
75 h: number,
76 xr: [number, number],
77 yr: [number, number],
78 o: Pick<PlotOptions, 'xLabel' | 'yLabel' | 'fmtX' | 'fmtY'> & { grid?: boolean },
79) {
80 const fx = o.fmtX ?? fmtTick;
81 const fy = o.fmtY ?? fmtTick;
82 const X = (v: number) => PAD.l + ((v - xr[0]) / (xr[1] - xr[0])) * (w - PAD.l - PAD.r);
83 const Y = (v: number) => h - PAD.b - ((v - yr[0]) / (yr[1] - yr[0])) * (h - PAD.t - PAD.b);
84 ctx.font = '11px system-ui, sans-serif';
85 ctx.lineWidth = 1;
86 ctx.strokeStyle = css('--grid', ctx.canvas);
87 ctx.fillStyle = css('--text-muted', ctx.canvas);
88 ctx.textAlign = 'right';
89 ctx.textBaseline = 'middle';
90 for (const t of niceTicks(yr[0], yr[1], 4)) {
91 const y = Math.round(Y(t)) + 0.5;
92 if (o.grid !== false) {
93 ctx.beginPath();
94 ctx.moveTo(PAD.l, y);
95 ctx.lineTo(w - PAD.r, y);
96 ctx.stroke();
97 }
98 ctx.fillText(fy(t), PAD.l - 6, y);
99 }
100 ctx.textAlign = 'center';
101 ctx.textBaseline = 'top';
102 for (const t of niceTicks(xr[0], xr[1], Math.max(3, Math.floor((w - PAD.l) / 90)))) {
103 ctx.fillText(fx(t), X(t), h - PAD.b + 5);
104 }
105 ctx.strokeStyle = css('--axis', ctx.canvas);
106 ctx.beginPath();
107 ctx.moveTo(PAD.l, h - PAD.b + 0.5);
108 ctx.lineTo(w - PAD.r, h - PAD.b + 0.5);
109 ctx.stroke();
110 ctx.fillStyle = css('--text-secondary', ctx.canvas);
111 ctx.fillText(o.xLabel, PAD.l + (w - PAD.l - PAD.r) / 2, h - 14);
112 ctx.save();
113 ctx.translate(12, PAD.t + (h - PAD.t - PAD.b) / 2);
114 ctx.rotate(-Math.PI / 2);
115 ctx.textBaseline = 'middle';
116 ctx.fillText(o.yLabel, 0, 0);
117 ctx.restore();
118 return { X, Y };
119}
121function lowerBound(x: ArrayLike<number>, v: number): number {
122 let lo = 0;
123 let hi = x.length;
124 while (lo < hi) {
125 const m = (lo + hi) >> 1;
126 if (x[m] < v) lo = m + 1;
127 else hi = m;
128 }
129 return lo;
130}
132export class LinePlot {
133 private series: Series[] = [];
134 private opt!: PlotOptions;
135 private tip: HTMLDivElement;
136 private hoverX: number | null = null;
138 constructor(private canvas: HTMLCanvasElement) {
139 this.tip = document.createElement('div');
140 this.tip.className = 'tooltip';
141 this.tip.hidden = true;
142 canvas.parentElement!.append(this.tip);
143 canvas.addEventListener('mousemove', (e) => {
144 this.hoverX = e.offsetX;
145 this.draw();
146 });
147 canvas.addEventListener('mouseleave', () => {
148 this.hoverX = null;
149 this.tip.hidden = true;
150 this.draw();
151 });
152 new ResizeObserver(() => this.draw()).observe(canvas);
153 }
155 set(series: Series[], opt: PlotOptions) {
156 this.series = series;
157 this.opt = opt;
158 this.draw();
159 }
161 private ranges(): { xr: [number, number]; yr: [number, number] } {
162 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
163 for (const s of this.series) {
164 if (!s.x.length) continue;
165 x0 = Math.min(x0, s.x[0]);
166 x1 = Math.max(x1, s.x[s.x.length - 1]);
167 }
168 const xr = this.opt.xRange ?? [x0, x1];
169 for (const s of this.series) {
170 const a = lowerBound(s.x, xr[0]);
171 const b = lowerBound(s.x, xr[1]);
172 for (let i = a; i < Math.min(b + 1, s.y.length); i++) {
173 y0 = Math.min(y0, s.y[i]);
174 y1 = Math.max(y1, s.y[i]);
175 }
176 }
177 if (!(y1 > y0)) {
178 y0 -= 1;
179 y1 += 1;
180 }
181 const m = 0.05 * (y1 - y0);
182 return { xr, yr: this.opt.yRange ?? [y0 - m, y1 + m] };
183 }
185 draw() {
186 if (!this.opt) return;
187 const { ctx, w, h } = setupCanvas(this.canvas);
188 ctx.clearRect(0, 0, w, h);
189 if (!this.series.length) return;
190 const { xr, yr } = this.ranges();
191 const { X, Y } = drawAxes(ctx, w, h, xr, yr, this.opt);
192 ctx.save();
193 ctx.beginPath();
194 ctx.rect(PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
195 ctx.clip();
196 for (const m of this.opt.markers ?? []) {
197 if (m.x < xr[0] || m.x > xr[1]) continue;
198 ctx.strokeStyle = m.color;
199 ctx.setLineDash([3, 3]);
200 ctx.lineWidth = 1;
201 ctx.beginPath();
202 ctx.moveTo(Math.round(X(m.x)) + 0.5, PAD.t);
203 ctx.lineTo(Math.round(X(m.x)) + 0.5, h - PAD.b);
204 ctx.stroke();
205 }
206 ctx.setLineDash([]);
207 ctx.lineWidth = 1.5;
208 ctx.lineJoin = 'round';
209 const plotW = w - PAD.l - PAD.r;
210 for (const s of this.series) {
211 ctx.strokeStyle = s.color;
212 ctx.beginPath();
213 const a = Math.max(0, lowerBound(s.x, xr[0]) - 1);
214 const b = Math.min(s.x.length - 1, lowerBound(s.x, xr[1]));
215 if (b - a > 2 * plotW) {
216 // min/max per pixel column
217 let col = -1, lo = 0, hi = 0;
218 const flush = () => {
219 if (col < 0) return;
220 ctx.moveTo(PAD.l + col + 0.5, Y(lo));
221 ctx.lineTo(PAD.l + col + 0.5, Y(hi) - 0.5);
222 };
223 for (let i = a; i <= b; i++) {
224 const c = Math.floor(X(s.x[i]) - PAD.l);
225 if (c !== col) {
226 flush();
227 col = c;
228 lo = hi = s.y[i];
229 } else {
230 lo = Math.min(lo, s.y[i]);
231 hi = Math.max(hi, s.y[i]);
232 }
233 }
234 flush();
235 } else {
236 for (let i = a; i <= b; i++) {
237 if (i === a) ctx.moveTo(X(s.x[i]), Y(s.y[i]));
238 else ctx.lineTo(X(s.x[i]), Y(s.y[i]));
239 }
240 }
241 ctx.stroke();
242 }
243 ctx.restore();
245 if (this.hoverX !== null && this.hoverX >= PAD.l && this.hoverX <= w - PAD.r) {
246 const xv = xr[0] + ((this.hoverX - PAD.l) / plotW) * (xr[1] - xr[0]);
247 ctx.strokeStyle = css('--axis', ctx.canvas);
248 ctx.lineWidth = 1;
249 ctx.beginPath();
250 ctx.moveTo(Math.round(this.hoverX) + 0.5, PAD.t);
251 ctx.lineTo(Math.round(this.hoverX) + 0.5, h - PAD.b);
252 ctx.stroke();
253 const lines = [`${this.opt.xLabel}: ${(this.opt.fmtX ?? fmtNum)(xv)}`];
254 for (const s of this.series) {
255 const i = Math.min(s.x.length - 1, lowerBound(s.x, xv));
256 lines.push(`${s.label}: ${(this.opt.fmtY ?? fmtNum)(s.y[i])}`);
257 }
258 const near = (this.opt.markers ?? []).filter((m) => Math.abs(X(m.x) - this.hoverX!) < 4);
259 for (const m of near) lines.push(m.label);
260 this.tip.textContent = lines.join('\n');
261 this.tip.hidden = false;
262 this.tip.style.left = `${Math.min(this.hoverX + 12, w - 180)}px`;
263 this.tip.style.top = `${PAD.t + 4}px`;
264 }
265 }
266}