PolyTools Journal 0.1.1 / src/journal/charts.js

244 lines · 13,004 bytes · SHA-256 c4e3c4d67935c781a226d24623463a60ddac1c72667231f1c7174bc41440e9ac

1/*
2 * PolyTools — tiny SVG charts for the journal (no dependencies).
3 * Palette (validated with the dataviz validator, light #fcfcfb / dark #1a1a19 surfaces):
4 * series / positive pole #2a78d6 (dark #3987e5) · negative pole #e34948 (dark #e66767)
5 * Marks: 2px lines, ≥8px markers with a 2px surface ring, bars ≤24px with 4px rounded data-ends,
6 * solid hairline grid, crosshair + tooltip, keyboard focus mirrors hover. Labels via textContent only.
7 */
8(function (root) {
9 'use strict';
10 const NS = 'http://www.w3.org/2000/svg';
11
12 function s(tag, attrs, parent) {
13 const el = document.createElementNS(NS, tag);
14 if (attrs) for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
15 if (parent) parent.append(el);
16 return el;
17 }
18
19 function niceStep(span, count) {
20 const raw = span / Math.max(1, count);
21 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
22 const n = raw / mag;
23 return (n >= 7.5 ? 10 : n >= 3.5 ? 5 : n >= 1.5 ? 2 : 1) * mag;
24 }
25
26 function niceTicks(min, max, count) {
27 if (min === max) { const d = Math.abs(min) || 1; min -= d; max += d; }
28 const step = niceStep(max - min, count || 4);
29 const lo = Math.floor(min / step) * step;
30 const hi = Math.ceil(max / step) * step;
31 const ticks = [];
32 for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.abs(v) < step / 1e6 ? 0 : v);
33 return { lo, hi, ticks };
34 }
35
36 /** Shared tooltip: values lead, labels follow. rows: [{value, label, key: 'line'|'pos'|'neg'|null}] */
37 function makeTooltip(container) {
38 const tip = document.createElement('div');
39 tip.className = 'viz-tip';
40 tip.setAttribute('role', 'status');
41 tip.hidden = true;
42 container.append(tip);
43 return {
44 show(x, y, title, rows) {
45 tip.replaceChildren();
46 if (title) { const t = document.createElement('div'); t.className = 'viz-tip-title'; t.textContent = title; tip.append(t); }
47 for (const r of rows) {
48 const line = document.createElement('div'); line.className = 'viz-tip-row';
49 if (r.key) { const k = document.createElement('span'); k.className = 'viz-key ' + r.key; line.append(k); }
50 const v = document.createElement('strong'); v.textContent = r.value; line.append(v);
51 if (r.label) { const l = document.createElement('span'); l.className = 'viz-tip-label'; l.textContent = r.label; line.append(l); }
52 tip.append(line);
53 }
54 tip.hidden = false;
55 const cw = container.clientWidth;
56 const tw = tip.offsetWidth;
57 tip.style.left = Math.max(4, Math.min(cw - tw - 4, x + 12)) + 'px';
58 // near the top edge the tooltip goes below the point instead of covering it
59 const above = y - tip.offsetHeight - 10;
60 tip.style.top = (above >= 4 ? above : y + 14) + 'px';
61 },
62 hide() { tip.hidden = true; },
63 };
64 }
65
66 function observe(container, draw) {
67 let w = 0;
68 const ro = new ResizeObserver(() => {
69 const nw = Math.round(container.clientWidth);
70 if (nw && nw !== w) { w = nw; draw(nw); }
71 });
72 ro.observe(container);
73 return ro;
74 }
75
76 /**
77 * Line chart over time. points: [{x: epochSec, y: number, extra?: [{label, value}]}]
78 * opts: {height, yFmt, xFmt, ariaLabel}
79 */
80 function line(container, points, opts) {
81 const o = Object.assign({ height: 240 }, opts);
82 container.classList.add('viz');
83 const tip = makeTooltip(container);
84 const svgWrap = document.createElement('div');
85 container.prepend(svgWrap);
86 const draw = (W) => {
87 svgWrap.replaceChildren();
88 const H = o.height, m = { t: 12, r: 96, b: 28, l: 64 };
89 const iw = W - m.l - m.r, ih = H - m.t - m.b;
90 const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
91 const x0 = Math.min(...xs), x1 = Math.max(...xs);
92 const yt = niceTicks(Math.min(0, ...ys), Math.max(0, ...ys), 4);
93 const X = (v) => m.l + (x1 === x0 ? iw / 2 : ((v - x0) / (x1 - x0)) * iw);
94 const Y = (v) => m.t + ih - ((v - yt.lo) / (yt.hi - yt.lo)) * ih;
95 const svg = s('svg', { width: W, height: H, viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': o.ariaLabel || '', tabindex: '0', class: 'viz-svg' }, svgWrap);
96 for (const v of yt.ticks) {
97 s('line', { x1: m.l, x2: W - m.r, y1: Y(v), y2: Y(v), class: v === 0 ? 'viz-base' : 'viz-grid' }, svg);
98 const tl = s('text', { x: m.l - 8, y: Y(v) + 4, 'text-anchor': 'end', class: 'viz-axis' }, svg);
99 tl.textContent = o.yFmt(v, true);
100 }
101 const nX = Math.max(2, Math.min(6, Math.floor(iw / 110)));
102 for (let i = 0; i < nX; i++) {
103 const v = x0 + ((x1 - x0) * i) / (nX - 1);
104 const tl = s('text', { x: X(v), y: H - 8, 'text-anchor': i === 0 ? 'start' : i === nX - 1 ? 'end' : 'middle', class: 'viz-axis' }, svg);
105 tl.textContent = o.xFmt(v);
106 }
107 const d = points.map((p, i) => (i ? 'L' : 'M') + X(p.x).toFixed(1) + ',' + Y(p.y).toFixed(1)).join('');
108 s('path', { d: d + `L${X(x1).toFixed(1)},${Y(0).toFixed(1)}L${X(x0).toFixed(1)},${Y(0).toFixed(1)}Z`, class: 'viz-area' }, svg);
109 s('path', { d, class: 'viz-line' }, svg);
110 const last = points[points.length - 1];
111 s('circle', { cx: X(last.x), cy: Y(last.y), r: 4, class: 'viz-dot' }, svg);
112 const el = s('text', { x: X(last.x) + 8, y: Y(last.y) + 4, class: 'viz-label' }, svg);
113 el.textContent = o.yFmt(last.y);
114
115 const cross = s('line', { y1: m.t, y2: m.t + ih, class: 'viz-cross', visibility: 'hidden' }, svg);
116 const hot = s('circle', { r: 4, class: 'viz-dot', visibility: 'hidden' }, svg);
117 let cur = -1;
118 const showAt = (i) => {
119 cur = Math.max(0, Math.min(points.length - 1, i));
120 const p = points[cur];
121 cross.setAttribute('x1', X(p.x)); cross.setAttribute('x2', X(p.x)); cross.setAttribute('visibility', 'visible');
122 hot.setAttribute('cx', X(p.x)); hot.setAttribute('cy', Y(p.y)); hot.setAttribute('visibility', 'visible');
123 tip.show(X(p.x), Y(p.y), o.xFmt(p.x, true), [{ value: o.yFmt(p.y), label: o.seriesLabel || '', key: 'line' }].concat(p.extra || []));
124 };
125 const hide = () => { cross.setAttribute('visibility', 'hidden'); hot.setAttribute('visibility', 'hidden'); tip.hide(); };
126 svg.addEventListener('pointermove', (e) => {
127 const r = svg.getBoundingClientRect();
128 const px = e.clientX - r.left;
129 let best = 0, bd = Infinity;
130 points.forEach((p, i) => { const dd = Math.abs(X(p.x) - px); if (dd < bd) { bd = dd; best = i; } });
131 showAt(best);
132 });
133 svg.addEventListener('pointerleave', hide);
134 svg.addEventListener('blur', hide);
135 svg.addEventListener('focus', () => showAt(points.length - 1));
136 svg.addEventListener('keydown', (e) => {
137 if (e.key === 'ArrowLeft') { showAt(cur - 1); e.preventDefault(); }
138 if (e.key === 'ArrowRight') { showAt(cur + 1); e.preventDefault(); }
139 if (e.key === 'Escape') hide();
140 });
141 };
142 return observe(container, draw);
143 }
144
145 /**
146 * Calibration scatter: buckets [{avgPrice, winRate, ciLo, ciHi, n, ...}] on a unit square.
147 * opts: {height, tipRows(b) → [{value,label}], title(b), axisX, axisY, few}
148 */
149 function calibration(container, buckets, opts) {
150 const o = Object.assign({ height: 300, few: 10 }, opts);
151 container.classList.add('viz');
152 const tip = makeTooltip(container);
153 const svgWrap = document.createElement('div');
154 container.prepend(svgWrap);
155 const pts = buckets.filter((b) => b.n > 0);
156 const draw = (W) => {
157 svgWrap.replaceChildren();
158 const H = o.height, m = { t: 12, r: 16, b: 42, l: 52 };
159 const side = Math.min(W - m.l - m.r, H - m.t - m.b);
160 const ox = m.l + Math.max(0, (W - m.l - m.r - side) / 2);
161 const X = (v) => ox + v * side, Y = (v) => m.t + side - v * side;
162 const svg = s('svg', { width: W, height: H, viewBox: `0 0 ${W} ${H}`, role: 'group', 'aria-label': o.ariaLabel || '', class: 'viz-svg' }, svgWrap);
163 for (const v of [0, 0.25, 0.5, 0.75, 1]) {
164 s('line', { x1: X(0), x2: X(1), y1: Y(v), y2: Y(v), class: v === 0 ? 'viz-base' : 'viz-grid' }, svg);
165 s('line', { x1: X(v), x2: X(v), y1: Y(0), y2: Y(1), class: v === 0 ? 'viz-base' : 'viz-grid' }, svg);
166 const ty = s('text', { x: X(0) - 8, y: Y(v) + 4, 'text-anchor': 'end', class: 'viz-axis' }, svg); ty.textContent = Math.round(v * 100) + '%';
167 const tx = s('text', { x: X(v), y: Y(0) + 16, 'text-anchor': 'middle', class: 'viz-axis' }, svg); tx.textContent = Math.round(v * 100) + '¢';
168 }
169 const ax = s('text', { x: X(0.5), y: H - 6, 'text-anchor': 'middle', class: 'viz-axis' }, svg); ax.textContent = o.axisX || '';
170 const ay = s('text', { x: 12, y: Y(0.5), 'text-anchor': 'middle', class: 'viz-axis', transform: `rotate(-90 12 ${Y(0.5)})` }, svg); ay.textContent = o.axisY || '';
171 s('line', { x1: X(0), y1: Y(0), x2: X(1), y2: Y(1), class: 'viz-diag' }, svg);
172 for (const b of pts) {
173 if (b.ciLo != null) s('line', { x1: X(b.avgPrice), x2: X(b.avgPrice), y1: Y(b.ciLo), y2: Y(b.ciHi), class: 'viz-whisker' }, svg);
174 }
175 for (const b of pts) {
176 const g = s('g', { tabindex: '0', role: 'button', 'aria-label': o.title(b) + ': ' + o.tipRows(b).map((r) => r.label + ' ' + r.value).join(', '), class: 'viz-hit' }, svg);
177 s('circle', { cx: X(b.avgPrice), cy: Y(b.winRate), r: 14, class: 'viz-hitarea' }, g);
178 s('circle', { cx: X(b.avgPrice), cy: Y(b.winRate), r: 5, class: b.n < o.few ? 'viz-dot few' : 'viz-dot' }, g);
179 const show = () => tip.show(X(b.avgPrice), Y(b.winRate), o.title(b), o.tipRows(b));
180 g.addEventListener('pointerenter', show);
181 g.addEventListener('focus', show);
182 g.addEventListener('pointerleave', () => tip.hide());
183 g.addEventListener('blur', () => tip.hide());
184 }
185 };
186 return observe(container, draw);
187 }
188
189 /**
190 * Diverging columns (positive = blue, negative = red) with a zero baseline.
191 * items: [{label, value, title, rows}] ; opts: {height, yFmt}
192 */
193 function columns(container, items, opts) {
194 const o = Object.assign({ height: 220 }, opts);
195 container.classList.add('viz');
196 const tip = makeTooltip(container);
197 const svgWrap = document.createElement('div');
198 container.prepend(svgWrap);
199 const draw = (W) => {
200 svgWrap.replaceChildren();
201 const H = o.height, m = { t: 12, r: 12, b: 28, l: 64 };
202 const iw = W - m.l - m.r, ih = H - m.t - m.b;
203 const vals = items.map((i) => i.value);
204 const yt = niceTicks(Math.min(0, ...vals), Math.max(0, ...vals), 4);
205 const Y = (v) => m.t + ih - ((v - yt.lo) / (yt.hi - yt.lo)) * ih;
206 const band = iw / items.length;
207 const bw = Math.max(2, Math.min(24, band - 2));
208 const svg = s('svg', { width: W, height: H, viewBox: `0 0 ${W} ${H}`, role: 'group', 'aria-label': o.ariaLabel || '', class: 'viz-svg' }, svgWrap);
209 for (const v of yt.ticks) {
210 s('line', { x1: m.l, x2: W - m.r, y1: Y(v), y2: Y(v), class: v === 0 ? 'viz-base' : 'viz-grid' }, svg);
211 const tl = s('text', { x: m.l - 8, y: Y(v) + 4, 'text-anchor': 'end', class: 'viz-axis' }, svg);
212 tl.textContent = o.yFmt(v, true);
213 }
214 const every = Math.max(1, Math.ceil(items.length / Math.max(1, Math.floor(iw / 64))));
215 items.forEach((it, i) => {
216 const cx = m.l + band * i + band / 2;
217 const y0 = Y(0), y1 = Y(it.value);
218 const top = Math.min(y0, y1), hgt = Math.max(1, Math.abs(y1 - y0));
219 const r = Math.min(4, hgt, bw / 2);
220 const x = cx - bw / 2;
221 // rounded only at the data end, square at the baseline
222 const d = it.value >= 0
223 ? `M${x},${top + hgt}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${top + hgt}Z`
224 : `M${x},${top}V${top + hgt - r}Q${x},${top + hgt} ${x + r},${top + hgt}H${x + bw - r}Q${x + bw},${top + hgt} ${x + bw},${top + hgt - r}V${top}Z`;
225 const g = s('g', { tabindex: '0', role: 'button', 'aria-label': it.title + ': ' + o.yFmt(it.value), class: 'viz-hit' }, svg);
226 s('rect', { x: cx - band / 2, y: m.t, width: band, height: ih, class: 'viz-hitarea' }, g);
227 s('path', { d, class: it.value >= 0 ? 'viz-bar pos' : 'viz-bar neg' }, g);
228 if (i % every === 0) {
229 const tl = s('text', { x: cx, y: H - 8, 'text-anchor': 'middle', class: 'viz-axis' }, svg);
230 tl.textContent = it.label;
231 }
232 const show = () => tip.show(cx, top, it.title, it.rows);
233 g.addEventListener('pointerenter', show);
234 g.addEventListener('focus', show);
235 g.addEventListener('pointerleave', () => tip.hide());
236 g.addEventListener('blur', () => tip.hide());
237 });
238 };
239 return observe(container, draw);
240 }
241
242 root.PWL = root.PWL || {};
243 root.PWL.charts = { line, calibration, columns, niceTicks };
244})(typeof globalThis !== 'undefined' ? globalThis : this);