PolyTools Journal 0.1.1 / src/journal/charts.js
| 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); |