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