/ proof-of-concept / bead-percussion
Sign in
proof-of-concept / bead-percussion
bead-percussion / src / ui / plot.ts
126 lines · 3.8 KBCodeBlameHistory
3export interface Frame {
4 ctx: CanvasRenderingContext2D;
5 w: number;
6 h: number;
7 // plot area
8 x0: number;
9 y0: number;
10 pw: number;
11 ph: number;
12 css: (name: string) => string;
15export function setupCanvas(canvas: HTMLCanvasElement, margin = { l: 56, r: 12, t: 10, b: 32 }): Frame {
16 const dpr = window.devicePixelRatio || 1;
17 const w = canvas.clientWidth;
18 const h = canvas.clientHeight;
19 canvas.width = Math.round(w * dpr);
20 canvas.height = Math.round(h * dpr);
21 const ctx = canvas.getContext("2d")!;
22 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
23 ctx.clearRect(0, 0, w, h);
24 const style = getComputedStyle(canvas);
25 return {
26 ctx,
27 w,
28 h,
29 x0: margin.l,
30 y0: margin.t,
31 pw: Math.max(10, w - margin.l - margin.r),
32 ph: Math.max(10, h - margin.t - margin.b),
33 css: name => style.getPropertyValue(name).trim(),
34 };
37export function niceTicks(lo: number, hi: number, count = 5): number[] {
38 if (!(hi > lo)) return [lo];
39 const step0 = (hi - lo) / count;
40 const mag = Math.pow(10, Math.floor(Math.log10(step0)));
41 const step = [1, 2, 5, 10].map(m => m * mag).find(s => s >= step0) ?? 10 * mag;
42 const out: number[] = [];
43 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);
44 return out;
47export function fmt(v: number): string {
48 if (v === 0) return "0";
49 const a = Math.abs(v);
50 if (a >= 1e4 || a < 1e-3) return v.toExponential(0);
51 return String(+v.toPrecision(3));
54export function drawAxes(
55 f: Frame,
56 xr: [number, number],
57 yr: [number, number],
58 opts: { grid?: boolean; xlabel?: string; ylabel?: string; xticks?: number[]; yticks?: number[]; yfmt?: (v: number) => string; xfmt?: (v: number) => string; ylog?: boolean } = {}
59) {
60 const { ctx, x0, y0, pw, ph } = f;
61 ctx.font = "11px system-ui, sans-serif";
62 ctx.strokeStyle = f.css("--grid");
63 ctx.fillStyle = f.css("--muted");
64 ctx.lineWidth = 1;
65 const xt = opts.xticks ?? niceTicks(xr[0], xr[1]);
66 const yt = opts.yticks ?? niceTicks(yr[0], yr[1], 4);
67 ctx.textAlign = "center";
68 ctx.textBaseline = "top";
69 for (const v of xt) {
70 const px = x0 + ((v - xr[0]) / (xr[1] - xr[0])) * pw;
71 if (px < x0 - 0.5 || px > x0 + pw + 0.5) continue;
72 if (opts.grid !== false) {
73 ctx.beginPath();
74 ctx.moveTo(px, y0);
75 ctx.lineTo(px, y0 + ph);
76 ctx.stroke();
77 }
78 ctx.fillText((opts.xfmt ?? fmt)(v), px, y0 + ph + 4);
79 }
80 ctx.textAlign = "right";
81 ctx.textBaseline = "middle";
82 for (const v of yt) {
83 const py = y0 + ph - ((v - yr[0]) / (yr[1] - yr[0])) * ph;
84 if (py < y0 - 0.5 || py > y0 + ph + 0.5) continue;
85 if (opts.grid !== false) {
86 ctx.beginPath();
87 ctx.moveTo(x0, py);
88 ctx.lineTo(x0 + pw, py);
89 ctx.stroke();
90 }
91 ctx.fillText((opts.yfmt ?? fmt)(v), x0 - 5, py);
92 }
93 ctx.strokeStyle = f.css("--axis");
94 ctx.strokeRect(x0 + 0.5, y0 + 0.5, pw - 1, ph - 1);
95 if (opts.xlabel) {
96 ctx.textAlign = "right";
97 ctx.textBaseline = "bottom";
98 ctx.fillText(opts.xlabel, x0 + pw, f.h - 1);
99 }
100 if (opts.ylabel) {
101 ctx.save();
102 ctx.translate(11, y0 + ph / 2);
103 ctx.rotate(-Math.PI / 2);
104 ctx.textAlign = "center";
105 ctx.textBaseline = "middle";
106 ctx.fillText(opts.ylabel, 0, 0);
107 ctx.restore();
108 }
111// Sequential color map (dark to bright), t in [0, 1].
112export function heat(t: number): [number, number, number] {
113 t = Math.max(0, Math.min(1, t));
114 const stops: [number, number, number][] = [
115 [12, 7, 35],
116 [72, 18, 110],
117 [150, 40, 110],
118 [218, 85, 60],
119 [250, 170, 50],
120 [252, 250, 190],
121 ];
122 const s = t * (stops.length - 1);
123 const i = Math.min(stops.length - 2, Math.floor(s));
124 const u = s - i;
125 return [0, 1, 2].map(c => Math.round(stops[i][c] + u * (stops[i + 1][c] - stops[i][c]))) as [number, number, number];
↑↓ move↵openescclose