1/**2 * Canvas chart primitives for the stats dashboard. No chart libraries.3 *4 * Dataviz-skill conventions applied throughout:5 * - 2px lines (round join), hairline SOLID gridlines one step off the surface,6 * recessive axes, thin bars (≤18px) with a 4px rounded data-end and square7 * baseline, 2px surface gaps between stacked segments, 2px surface rings on8 * end-dots, selective direct labels only, text in ink/muted tokens (never the9 * series color).10 * - Hover layer by default: crosshair + all-series tooltip on line/area charts,11 * per-segment tooltip on bars. Tooltip content is inserted via textContent12 * only (series names can be user-provided script names).13 * - Time axis downsampling: when there are more points than pixels, each14 * pixel-bucket plots its average (lines/areas) so zoomed-out charts stay15 * faithful without aliasing.16 */17import { CHROME, PIXEL_FONT } from './palette';1819/** Canvas text styles — pixel/terminal font, matching the HUD chrome. */20const AXIS_FONT = `13px ${PIXEL_FONT}`;21const LABEL_FONT = `13px ${PIXEL_FONT}`;2223export interface Series {24 name: string;25 color: string;26 data: number[];27}2829// ---------------------------------------------------------------------------30// formatting3132export function fmtCompact(v: number): string {33 const a = Math.abs(v);34 const trim = (s: string) => s.replace(/\.0$/, '');35 if (a >= 1_000_000) return `${trim((v / 1_000_000).toFixed(a >= 10_000_000 ? 0 : 1))}M`;36 if (a >= 1000) return `${trim((v / 1000).toFixed(a >= 10_000 ? 0 : 1))}K`;37 return String(Math.round(v));38}3940export const fmtPermille = (v: number): string => `${Math.round(v / 10)}%`;4142/** Round up to a clean 1/2/5×10^k number. */43function niceCeil(v: number): number {44 if (v <= 0) return 1;45 const p = Math.pow(10, Math.floor(Math.log10(v)));46 const m = v / p;47 const nice = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10;48 return nice * p;49}5051/** Clean step so that range/step gives ~target intervals. */52function niceStep(range: number, target: number): number {53 const raw = range / Math.max(1, target);54 const p = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-9))));55 const m = raw / p;56 return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;57}5859const dpr = () => Math.min(2, window.devicePixelRatio || 1);6061// ---------------------------------------------------------------------------62// shared tooltip (one element for the whole page; textContent only)6364let tipEl: HTMLDivElement | null = null;65function tip(): HTMLDivElement {66 if (!tipEl) {67 tipEl = document.createElement('div');68 tipEl.className = 'chart-tip';69 tipEl.style.display = 'none';70 document.body.append(tipEl);71 }72 return tipEl;73}7475export interface TipRow {76 name: string;77 color: string | null;78 value: string;79}8081function showTip(clientX: number, clientY: number, title: string, rows: TipRow[]) {82 const t = tip();83 t.textContent = '';84 const head = document.createElement('div');85 head.className = 'ct-title';86 head.textContent = title;87 t.append(head);88 for (const r of rows) {89 const row = document.createElement('div');90 row.className = 'ct-row';91 if (r.color) {92 const key = document.createElement('span');93 key.className = 'ct-key';94 key.style.background = r.color;95 row.append(key);96 }97 const val = document.createElement('span');98 val.className = 'ct-val';99 val.textContent = r.value;100 const name = document.createElement('span');101 name.className = 'ct-name';102 name.textContent = r.name;103 row.append(val, name);104 t.append(row);105 }106 t.style.display = 'block';107 const pad = 6;108 let x = clientX + 14;109 let y = clientY + 14;110 if (x + t.offsetWidth + pad > window.innerWidth) x = clientX - t.offsetWidth - 10;111 if (y + t.offsetHeight + pad > window.innerHeight) y = clientY - t.offsetHeight - 10;112 t.style.left = `${Math.max(pad, x)}px`;113 t.style.top = `${Math.max(pad, y)}px`;114}115116function hideTip() {117 if (tipEl) tipEl.style.display = 'none';118}119120// ---------------------------------------------------------------------------121// legend (DOM, ink text + colored key beside it)122123export function legendRow(items: Array<{ name: string; color: string; line?: boolean }>): HTMLElement {124 const el = document.createElement('div');125 el.className = 'chart-legend';126 for (const it of items) {127 const item = document.createElement('span');128 item.className = 'legend-item';129 const key = document.createElement('span');130 key.className = it.line ? 'legend-key line' : 'legend-key';131 key.style.background = it.color;132 const name = document.createElement('span');133 name.textContent = it.name; // textContent — names may be user-provided134 item.append(key, name);135 el.append(item);136 }137 return el;138}139140// ---------------------------------------------------------------------------141// canvas scaffold142143interface Scaffold {144 el: HTMLDivElement;145 canvas: HTMLCanvasElement;146 ctx: CanvasRenderingContext2D;147 /** CSS-pixel size; 0 while hidden. */148 size(): { w: number; h: number };149 scheduleDraw(): void;150 dispose(): void;151}152153function scaffold(heightCss: number, draw: () => void): Scaffold {154 const el = document.createElement('div');155 el.className = 'chart-plot';156 const canvas = document.createElement('canvas');157 canvas.className = 'chart';158 canvas.style.height = `${heightCss}px`;159 el.append(canvas);160 const ctx = canvas.getContext('2d')!;161 let raf = 0;162 const scheduleDraw = () => {163 if (raf) return;164 raf = requestAnimationFrame(() => {165 raf = 0;166 const w = el.clientWidth;167 const h = canvas.clientHeight;168 if (w === 0 || h === 0) return; // hidden tab — RO will refire when shown169 const d = dpr();170 if (canvas.width !== Math.round(w * d) || canvas.height !== Math.round(h * d)) {171 canvas.width = Math.round(w * d);172 canvas.height = Math.round(h * d);173 }174 ctx.setTransform(d, 0, 0, d, 0, 0);175 ctx.clearRect(0, 0, w, h);176 draw();177 });178 };179 const ro = new ResizeObserver(scheduleDraw);180 ro.observe(el);181 return {182 el,183 canvas,184 ctx,185 size: () => ({ w: el.clientWidth, h: canvas.clientHeight }),186 scheduleDraw,187 dispose() {188 ro.disconnect();189 cancelAnimationFrame(raf);190 hideTip();191 },192 };193}194195/** Downsample values to at most `maxPts` points by bucket-averaging. */196function resample(values: number[], maxPts: number): number[] {197 const n = values.length;198 if (n <= maxPts || maxPts < 2) return values;199 const out = new Array<number>(maxPts);200 for (let i = 0; i < maxPts; i++) {201 const a = Math.floor((i * n) / maxPts);202 const b = Math.max(a + 1, Math.floor(((i + 1) * n) / maxPts));203 let sum = 0;204 for (let j = a; j < b; j++) sum += values[j]!;205 out[i] = sum / (b - a);206 }207 return out;208}209210const PAD = { top: 10, right: 14, bottom: 20, left: 44 };211212interface AxisFrame {213 x0: number;214 y0: number; // baseline (bottom)215 pw: number; // plot width216 ph: number; // plot height217 yMax: number;218 xTicks: number[]; // tick values (sim ticks)219 tickToX(t: number): number;220 valToY(v: number): number;221}222223/** Draw grid + axes + labels; returns the plot frame. */224function drawAxes(225 ctx: CanvasRenderingContext2D,226 w: number,227 h: number,228 t0: number,229 t1: number,230 yMaxRaw: number,231 yFormat: (v: number) => string,232 padRight = PAD.right,233): AxisFrame {234 const x0 = PAD.left;235 const pw = Math.max(10, w - x0 - padRight);236 const ph = Math.max(10, h - PAD.top - PAD.bottom);237 const y0 = PAD.top + ph;238 const yMax = niceCeil(Math.max(yMaxRaw, 1));239 const tickToX = (t: number) => x0 + (t1 === t0 ? 0 : ((t - t0) / (t1 - t0)) * pw);240 const valToY = (v: number) => y0 - (v / yMax) * ph;241242 ctx.font = AXIS_FONT;243 ctx.textBaseline = 'middle';244245 // horizontal gridlines + y labels (solid hairlines, one step off surface).246 // All plotted metrics are integers — never let ticks repeat a rounded label.247 const yStep = Math.max(1, niceStep(yMax, 4));248 ctx.strokeStyle = CHROME.grid;249 ctx.fillStyle = CHROME.muted;250 ctx.lineWidth = 1;251 ctx.textAlign = 'right';252 for (let v = 0; v <= yMax + 1e-9; v += yStep) {253 const y = Math.round(valToY(v)) + 0.5;254 ctx.beginPath();255 ctx.moveTo(x0, y);256 ctx.lineTo(x0 + pw, y);257 ctx.stroke();258 ctx.fillText(yFormat(v), x0 - 6, y);259 }260 // baseline261 ctx.strokeStyle = CHROME.axis;262 ctx.beginPath();263 ctx.moveTo(x0, Math.round(y0) + 0.5);264 ctx.lineTo(x0 + pw, Math.round(y0) + 0.5);265 ctx.stroke();266267 // x ticks (sim tick numbers)268 const xTicks: number[] = [];269 ctx.textAlign = 'center';270 ctx.textBaseline = 'top';271 if (t1 > t0) {272 const xStep = niceStep(t1 - t0, Math.max(2, Math.floor(pw / 90)));273 for (let t = Math.ceil(t0 / xStep) * xStep; t <= t1 + 1e-9; t += xStep) {274 xTicks.push(t);275 ctx.fillText(fmtCompact(t), tickToX(t), y0 + 6);276 }277 }278 return { x0, y0, pw, ph, yMax, xTicks, tickToX, valToY };279}280281// ---------------------------------------------------------------------------282// line chart (multi-series, optional step interpolation)283284export interface LineChartOpts {285 height?: number;286 /** Draw as step lines (e.g. techs unlocked). */287 step?: boolean;288 /** Fixed y max (e.g. 1000 for per-mille scales). */289 yMax?: number;290 yFormat?: (v: number) => string;291 /** Direct-label the leading series at its endpoint (selective labeling). */292 labelEndMax?: boolean;293}294295export interface LineChart {296 el: HTMLElement;297 update(ticks: number[], series: Series[]): void;298 dispose(): void;299}300301export function lineChart(opts: LineChartOpts = {}): LineChart {302 const yFormat = opts.yFormat ?? fmtCompact;303 let ticks: number[] = [];304 let series: Series[] = [];305 let hover: number | null = null; // index into ticks306 let hoverXY: { x: number; y: number } | null = null;307308 const sc = scaffold(opts.height ?? 170, () => {309 const { w, h } = sc.size();310 if (ticks.length === 0 || series.length === 0) return;311 const ctx = sc.ctx;312 const t0 = ticks[0]!;313 const t1 = ticks[ticks.length - 1]!;314 let max = opts.yMax ?? 0;315 if (opts.yMax == null) for (const s of series) for (const v of s.data) if (v > max) max = v;316 const padRight = opts.labelEndMax ? 60 : PAD.right;317 const fr = drawAxes(ctx, w, h, t0, t1, opts.yMax ?? max, yFormat, padRight);318319 const maxPts = Math.max(2, Math.floor(fr.pw));320 ctx.lineWidth = 2;321 ctx.lineJoin = 'miter'; // pixel-weight corners, not rounded strokes322 ctx.lineCap = 'butt';323 let leadIdx = 0;324 for (let i = 0; i < series.length; i++) {325 const cur = series[i]!.data[series[i]!.data.length - 1] ?? 0;326 const lead = series[leadIdx]!.data[series[leadIdx]!.data.length - 1] ?? 0;327 if (cur > lead) leadIdx = i;328 }329 for (const s of series) {330 const ys = resample(s.data, maxPts);331 const n = ys.length;332 if (n === 0) continue;333 ctx.strokeStyle = s.color;334 ctx.beginPath();335 for (let i = 0; i < n; i++) {336 const x = fr.x0 + (n === 1 ? 0 : (i / (n - 1)) * fr.pw);337 const y = fr.valToY(ys[i]!);338 if (i === 0) ctx.moveTo(x, y);339 else if (opts.step) {340 ctx.lineTo(x, fr.valToY(ys[i - 1]!));341 ctx.lineTo(x, y);342 } else ctx.lineTo(x, y);343 }344 ctx.stroke();345 // square end marker with a 2px surface ring (1-bit pixel accent)346 const ex = fr.x0 + fr.pw;347 const ey = fr.valToY(ys[n - 1]!);348 ctx.fillStyle = CHROME.surface;349 ctx.fillRect(ex - 5, ey - 5, 10, 10);350 ctx.fillStyle = s.color;351 ctx.fillRect(ex - 3, ey - 3, 6, 6);352 }353 // selective direct label: leader endpoint only, ink token (never series color)354 if (opts.labelEndMax && series[leadIdx]) {355 const s = series[leadIdx]!;356 const last = s.data[s.data.length - 1] ?? 0;357 ctx.fillStyle = CHROME.ink;358 ctx.font = LABEL_FONT;359 ctx.textAlign = 'left';360 ctx.textBaseline = 'middle';361 ctx.fillText(s.name.slice(0, 9), fr.x0 + fr.pw + 7, fr.valToY(last));362 }363 // crosshair364 if (hover != null && hoverXY && ticks.length > 1) {365 const x = Math.round(fr.tickToX(ticks[hover]!)) + 0.5;366 ctx.strokeStyle = CHROME.muted;367 ctx.lineWidth = 1;368 ctx.beginPath();369 ctx.moveTo(x, PAD.top);370 ctx.lineTo(x, fr.y0);371 ctx.stroke();372 }373 });374375 const onMove = (e: PointerEvent) => {376 if (ticks.length === 0) return;377 const rect = sc.canvas.getBoundingClientRect();378 const t0 = ticks[0]!;379 const t1 = ticks[ticks.length - 1]!;380 const x0 = PAD.left;381 const pw = Math.max(10, rect.width - x0 - (opts.labelEndMax ? 60 : PAD.right));382 const frac = Math.min(1, Math.max(0, (e.clientX - rect.left - x0) / pw));383 const target = t0 + frac * (t1 - t0);384 // nearest tick index385 let best = 0;386 let bd = Infinity;387 for (let i = 0; i < ticks.length; i++) {388 const d = Math.abs(ticks[i]! - target);389 if (d < bd) {390 bd = d;391 best = i;392 }393 }394 hover = best;395 hoverXY = { x: e.clientX, y: e.clientY };396 const rows: TipRow[] = series.map((s) => ({397 name: s.name,398 color: s.color,399 value: yFormat(s.data[best] ?? 0),400 }));401 rows.sort((a, b) => parseFloat(b.value.replace(/,/g, '')) - parseFloat(a.value.replace(/,/g, '')) || 0);402 showTip(e.clientX, e.clientY, `tick ${ticks[best]}`, rows);403 sc.scheduleDraw();404 };405 const onLeave = () => {406 hover = null;407 hoverXY = null;408 hideTip();409 sc.scheduleDraw();410 };411 sc.canvas.addEventListener('pointermove', onMove);412 sc.canvas.addEventListener('pointerleave', onLeave);413414 return {415 el: sc.el,416 update(t, s) {417 ticks = t;418 series = s;419 sc.scheduleDraw();420 },421 dispose() {422 sc.canvas.removeEventListener('pointermove', onMove);423 sc.canvas.removeEventListener('pointerleave', onLeave);424 sc.dispose();425 },426 };427}428429// ---------------------------------------------------------------------------430// stacked area (part-to-whole over time; bands separated by surface hairlines)431432export function stackedArea(opts: { height?: number; yFormat?: (v: number) => string } = {}): LineChart {433 const yFormat = opts.yFormat ?? fmtCompact;434 let ticks: number[] = [];435 let series: Series[] = [];436 let hover: number | null = null;437438 const sc = scaffold(opts.height ?? 150, () => {439 const { w, h } = sc.size();440 if (ticks.length === 0 || series.length === 0) return;441 const ctx = sc.ctx;442 const t0 = ticks[0]!;443 const t1 = ticks[ticks.length - 1]!;444 const n0 = series[0]!.data.length;445 let max = 0;446 for (let i = 0; i < n0; i++) {447 let sum = 0;448 for (const s of series) sum += s.data[i] ?? 0;449 if (sum > max) max = sum;450 }451 const fr = drawAxes(ctx, w, h, t0, t1, max, yFormat);452 const maxPts = Math.max(2, Math.floor(fr.pw));453 const bands = series.map((s) => resample(s.data, maxPts));454 const n = bands[0]?.length ?? 0;455 if (n === 0) return;456 // cumulative tops457 const cum: number[][] = [];458 let prev = new Array<number>(n).fill(0);459 for (const b of bands) {460 const c = prev.map((v, i) => v + (b[i] ?? 0));461 cum.push(c);462 prev = c;463 }464 const xAt = (i: number) => fr.x0 + (n === 1 ? 0 : (i / (n - 1)) * fr.pw);465 for (let bi = 0; bi < bands.length; bi++) {466 const topC = cum[bi]!;467 const botC = bi === 0 ? null : cum[bi - 1]!;468 ctx.beginPath();469 for (let i = 0; i < n; i++) ctx.lineTo(xAt(i), fr.valToY(topC[i]!));470 if (botC) for (let i = n - 1; i >= 0; i--) ctx.lineTo(xAt(i), fr.valToY(botC[i]!));471 else {472 ctx.lineTo(fr.x0 + fr.pw, fr.y0);473 ctx.lineTo(fr.x0, fr.y0);474 }475 ctx.closePath();476 ctx.fillStyle = series[bi]!.color;477 ctx.globalAlpha = 0.9;478 ctx.fill();479 ctx.globalAlpha = 1;480 // surface-color separator on the band's top edge (the "2px gap" for areas)481 ctx.beginPath();482 for (let i = 0; i < n; i++) ctx.lineTo(xAt(i), fr.valToY(topC[i]!));483 ctx.strokeStyle = CHROME.surface;484 ctx.lineWidth = 1.5;485 ctx.stroke();486 }487 if (hover != null && ticks.length > 1) {488 const x = Math.round(fr.tickToX(ticks[hover]!)) + 0.5;489 ctx.strokeStyle = CHROME.muted;490 ctx.lineWidth = 1;491 ctx.beginPath();492 ctx.moveTo(x, PAD.top);493 ctx.lineTo(x, fr.y0);494 ctx.stroke();495 }496 });497498 const onMove = (e: PointerEvent) => {499 if (ticks.length === 0) return;500 const rect = sc.canvas.getBoundingClientRect();501 const frac = Math.min(1, Math.max(0, (e.clientX - rect.left - PAD.left) / Math.max(10, rect.width - PAD.left - PAD.right)));502 const target = ticks[0]! + frac * (ticks[ticks.length - 1]! - ticks[0]!);503 let best = 0;504 let bd = Infinity;505 for (let i = 0; i < ticks.length; i++) {506 const d = Math.abs(ticks[i]! - target);507 if (d < bd) {508 bd = d;509 best = i;510 }511 }512 hover = best;513 // top band first so the tooltip reads like the chart514 const rows: TipRow[] = [...series]515 .map((s) => ({ name: s.name, color: s.color, value: fmtCompact(s.data[best] ?? 0) }))516 .reverse();517 showTip(e.clientX, e.clientY, `tick ${ticks[best]}`, rows);518 sc.scheduleDraw();519 };520 const onLeave = () => {521 hover = null;522 hideTip();523 sc.scheduleDraw();524 };525 sc.canvas.addEventListener('pointermove', onMove);526 sc.canvas.addEventListener('pointerleave', onLeave);527528 return {529 el: sc.el,530 update(t, s) {531 ticks = t;532 series = s;533 sc.scheduleDraw();534 },535 dispose() {536 sc.canvas.removeEventListener('pointermove', onMove);537 sc.canvas.removeEventListener('pointerleave', onLeave);538 sc.dispose();539 },540 };541}542543// ---------------------------------------------------------------------------544// horizontal stacked bars (one row per tribe; e.g. score breakdown, tiers)545546export interface BarRow {547 label: string;548 segments: Array<{ name: string; color: string; value: number }>;549}550551export interface BarChart {552 el: HTMLElement;553 update(rows: BarRow[]): void;554 dispose(): void;555}556557const BAR_H = 16;558const ROW_H = 26;559const LABEL_W = 84;560561export function stackedBarsH(opts: { valueFormat?: (v: number) => string } = {}): BarChart {562 const fmt = opts.valueFormat ?? fmtCompact;563 let rows: BarRow[] = [];564 let hover: { row: number; seg: number } | null = null;565566 const sc = scaffold(120, () => {567 const { w } = sc.size();568 const ctx = sc.ctx;569 if (rows.length === 0) return;570 const x0 = LABEL_W;571 const pw = Math.max(20, w - x0 - 52);572 let max = 0;573 for (const r of rows) {574 let sum = 0;575 for (const s of r.segments) sum += s.value;576 if (sum > max) max = sum;577 }578 const xMax = niceCeil(Math.max(max, 1));579 const scaleX = (v: number) => (v / xMax) * pw;580581 // vertical gridlines582 ctx.font = AXIS_FONT;583 const step = niceStep(xMax, 4);584 ctx.strokeStyle = CHROME.grid;585 ctx.fillStyle = CHROME.muted;586 ctx.lineWidth = 1;587 ctx.textAlign = 'center';588 ctx.textBaseline = 'top';589 const chartBottom = rows.length * ROW_H;590 for (let v = 0; v <= xMax + 1e-9; v += step) {591 const x = Math.round(x0 + scaleX(v)) + 0.5;592 ctx.beginPath();593 ctx.moveTo(x, 2);594 ctx.lineTo(x, chartBottom);595 ctx.stroke();596 ctx.fillText(fmtCompact(v), x, chartBottom + 6);597 }598599 for (let ri = 0; ri < rows.length; ri++) {600 const r = rows[ri]!;601 const y = ri * ROW_H + (ROW_H - BAR_H) / 2;602 // row label — muted ink, right-aligned against the bar start603 ctx.fillStyle = CHROME.muted;604 ctx.font = LABEL_FONT;605 ctx.textAlign = 'right';606 ctx.textBaseline = 'middle';607 ctx.fillText(r.label.slice(0, 11), x0 - 8, y + BAR_H / 2);608 // segments with 2px surface gaps; square ends throughout609 let acc = 0;610 for (let si = 0; si < r.segments.length; si++) {611 const seg = r.segments[si]!;612 if (seg.value <= 0) continue;613 const sx = x0 + scaleX(acc) + (acc > 0 ? 2 : 0);614 const exRaw = x0 + scaleX(acc + seg.value);615 const wSeg = Math.max(0.5, exRaw - sx);616 const isHover = hover && hover.row === ri && hover.seg === si;617 ctx.fillStyle = seg.color;618 ctx.globalAlpha = isHover ? 1 : 0.92;619 // square ends everywhere — 1-bit HUD bars have no rounded corners620 ctx.fillRect(sx, y, wSeg, BAR_H);621 ctx.globalAlpha = 1;622 acc += seg.value;623 }624 // total at the tip — ink token625 ctx.fillStyle = CHROME.ink;626 ctx.font = AXIS_FONT;627 ctx.textAlign = 'left';628 ctx.fillText(fmt(acc), x0 + scaleX(acc) + 6, y + BAR_H / 2);629 }630 });631632 const hitTest = (e: PointerEvent): { row: number; seg: number; clientX: number; clientY: number } | null => {633 const rect = sc.canvas.getBoundingClientRect();634 const x = e.clientX - rect.left;635 const y = e.clientY - rect.top;636 const ri = Math.floor(y / ROW_H);637 if (ri < 0 || ri >= rows.length) return null;638 const r = rows[ri]!;639 const pw = Math.max(20, rect.width - LABEL_W - 52);640 let max = 0;641 for (const rr of rows) {642 let sum = 0;643 for (const s of rr.segments) sum += s.value;644 if (sum > max) max = sum;645 }646 const xMax = niceCeil(Math.max(max, 1));647 const v = ((x - LABEL_W) / pw) * xMax;648 if (v < 0) return null;649 let acc = 0;650 for (let si = 0; si < r.segments.length; si++) {651 acc += r.segments[si]!.value;652 if (v <= acc && r.segments[si]!.value > 0) return { row: ri, seg: si, clientX: e.clientX, clientY: e.clientY };653 }654 return null;655 };656657 const onMove = (e: PointerEvent) => {658 const hit = hitTest(e);659 hover = hit ? { row: hit.row, seg: hit.seg } : null;660 if (hit) {661 const r = rows[hit.row]!;662 const seg = r.segments[hit.seg]!;663 const total = r.segments.reduce((a, s) => a + s.value, 0);664 showTip(e.clientX, e.clientY, r.label, [665 { name: seg.name, color: seg.color, value: fmt(seg.value) },666 { name: 'total', color: null, value: fmt(total) },667 ]);668 } else hideTip();669 sc.scheduleDraw();670 };671 const onLeave = () => {672 hover = null;673 hideTip();674 sc.scheduleDraw();675 };676 sc.canvas.addEventListener('pointermove', onMove);677 sc.canvas.addEventListener('pointerleave', onLeave);678679 return {680 el: sc.el,681 update(r) {682 rows = r;683 sc.canvas.style.height = `${rows.length * ROW_H + 22}px`;684 sc.scheduleDraw();685 },686 dispose() {687 sc.canvas.removeEventListener('pointermove', onMove);688 sc.canvas.removeEventListener('pointerleave', onLeave);689 sc.dispose();690 },691 };692}693694// ---------------------------------------------------------------------------695// sparkline (stat-row scale; 1–2 series, no axes — the row label + current696// value carry the reading, so no hover layer is required here)697698export interface Spark {699 el: HTMLElement;700 update(seriesValues: number[][]): void;701 dispose(): void;702}703704export function sparkline(colors: string[], height = 26): Spark {705 let data: number[][] = [];706 const sc = scaffold(height, () => {707 const { w, h } = sc.size();708 const ctx = sc.ctx;709 let max = 1;710 for (const vs of data) for (const v of vs) if (v > max) max = v;711 ctx.lineWidth = 1.5;712 ctx.lineJoin = 'miter';713 ctx.lineCap = 'butt';714 for (let si = 0; si < data.length; si++) {715 const ys = resample(data[si]!, Math.max(2, Math.floor(w)));716 const n = ys.length;717 if (n < 2) continue;718 ctx.strokeStyle = colors[si] ?? CHROME.muted;719 ctx.beginPath();720 for (let i = 0; i < n; i++) {721 ctx.lineTo((i / (n - 1)) * (w - 4) + 1, 2 + (1 - ys[i]! / max) * (h - 5));722 }723 ctx.stroke();724 const ey = 2 + (1 - ys[n - 1]! / max) * (h - 5);725 ctx.fillStyle = colors[si] ?? CHROME.muted;726 ctx.fillRect(w - 5, ey - 2, 4, 4);727 }728 });729 sc.el.classList.add('spark');730 return {731 el: sc.el,732 update(vs) {733 data = vs;734 sc.scheduleDraw();735 },736 dispose: () => sc.dispose(),737 };738}