PageSourceSearch

https://lootcomics.com/chart/dither-chart.js

js lootcomics.com collected 2026-10-03 19:24:24 UTC 27,137 bytes, 672 lines download raw bytes

1/**
2 * DitherChart — breathing Bayer-dithered charts.
3 * Five chart types on one tiny canvas engine: area, line, bar, pie, radar.
4 *
5 * One chart per mount. Mount as many as you want on a page; all instances
6 * share a single requestAnimationFrame loop. No dependencies.
7 *
8 * Usage:
9 *   <div id="my-chart" style="height:180px"></div>
10 *   <script src="/chart/dither-chart.js"></script>
11 *   <script>
12 *     const chart = DitherChart.mount('#my-chart', { type: 'bar', color: '#1899dd', data: [3,7,5,9] });
13 *     // later: chart.destroy()
14 *   </script>
15 *
16 * Deck — a compact tabbed widget cycling through several charts in one box.
17 * Give the container a size (a small square works well) and call:
18 *   DitherChart.deck('#slot')                          // built-in demo set, all 5 types
19 *   DitherChart.deck('#slot', { charts: [              // or your own
20 *     { name: 'Sales', type: 'bar', color: '#1899dd', data: [3,7,5,9] },
21 *     { name: 'Mix',   type: 'pie', inner: 0.45, data: [...] },
22 *   ], active: 0, theme: 'light', ink: '#000' })
23 * Each charts[] entry is a mount() options object plus a `name` for its tab.
24 * `ink` colors the inactive tab text and every tooltip (active tab keeps the
25 * chart's series color). Tabs and tooltips inherit the page's font-family.
26 * Returns { el, show(i), next(), destroy() }.
27 *
28 * Options (all optional):
29 *   type    'area' | 'line' | 'bar' | 'pie' | 'radar'   default 'area'
30 *   color   hex string, the series color.               default '#1899dd'
31 *           (pie ignores it; see data/colors below)
32 *   theme   'light' | 'dark' — pixel math for a white or near-black page.
33 *                                                        default 'light'
34 *   cell    dither cell size in CSS px.                  default 3
35 *   speed   animation speed multiplier.                  default 1
36 *   seed    any number; offsets the noise field so two charts with the same
37 *           shape do not move in sync.                   default auto
38 *   tooltip false to disable hover + tooltip.            default true
39 *   ink     text color for tooltip value + label (and deck tabs when passed
40 *           to deck()). Default: theme ink/soft-indigo pair. Fonts always
41 *           inherit from the page, so the host's font-family applies.
42 *   label   tooltip label text for area/line/bar. Empty = number only.
43 *   names   array of per-point tooltip labels for area/line/bar:
44 *           names[i] labels data[i] (e.g. ['Mon','Tue',...]). With data +
45 *           names, area/line tooltips show the RAW data value, not the
46 *           0..scale reading.
47 *
48 * area / line:
49 *   shape   'plateau' | 'climb' | 'hills'               default 'hills'
50 *           or a function (u, t) => 0..1 where u is 0..1 across the width,
51 *           t is seconds, and the return is the surface height fraction.
52 *   data    array of numbers. Overrides shape: the curve traces your data
53 *           (normalized to the chart) with a gentle breathing wobble on top.
54 *   scale   number the tooltip reads at full height.     default 100
55 *
56 * bar:
57 *   data    array of numbers, one per bar. Tooltip shows the raw value.
58 *
59 * pie:
60 *   data    array of { label, value, color? } (or plain numbers).
61 *   colors  fallback palette when segments have no color.
62 *   inner   0..0.8 donut hole fraction.                  default 0
63 *
64 * radar:
65 *   data    array of numbers, one per axis (3+).
66 *   axes    array of axis label strings for the tooltip.
67 *
68 * The chart fills its container (give the container a height). It re-sizes
69 * with the container and renders a single static frame when the user prefers
70 * reduced motion.
71 */
72(function () {
73  'use strict';
74
75  // ---------- shared math ----------
76
77  const BAYER = [
78    [ 0,32, 8,40, 2,34,10,42],
79    [48,16,56,24,50,18,58,26],
80    [12,44, 4,36,14,46, 6,38],
81    [60,28,52,20,62,30,54,22],
82    [ 3,35,11,43, 1,33, 9,41],
83    [51,19,59,27,49,17,57,25],
84    [15,47, 7,39,13,45, 5,37],
85    [63,31,55,23,61,29,53,21],
86  ];
87
88  const PERM = new Uint8Array(512);
89  (() => {
90    let s = 1337;
91    const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
92    const p = [...Array(256).keys()];
93    for (let i = 255; i > 0; i--) {
94      const j = Math.floor(rnd() * (i + 1));
95      [p[i], p[j]] = [p[j], p[i]];
96    }
97    for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
98  })();
99
100  const fade = t => t * t * (3 - 2 * t);
101  function noise1(x) {
102    const xi = Math.floor(x), xf = x - xi;
103    const a = PERM[xi & 255] / 255, b = PERM[(xi + 1) & 255] / 255;
104    return a + (b - a) * fade(xf);
105  }
106  function fbm(x, oct) {
107    let v = 0, amp = 0.5, f = 1;
108    for (let i = 0; i < oct; i++) { v += amp * noise1(x * f); amp *= 0.5; f *= 2.03; }
109    return v;
110  }
111  const hash2 = (i, j) => PERM[(PERM[i & 255] + j) & 255] / 255;
112  function noise2(x, y) {
113    const xi = Math.floor(x), yi = Math.floor(y);
114    const u = fade(x - xi), v = fade(y - yi);
115    const a = hash2(xi, yi), b = hash2(xi + 1, yi);
116    const c = hash2(xi, yi + 1), e = hash2(xi + 1, yi + 1);
117    const top = a + (b - a) * u;
118    return top + ((c + (e - c) * u) - top) * v;
119  }
120
121  const hex2rgb = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
122  const lerp = (a, b, t) => a + (b - a) * t;
123  const clamp01 = v => Math.min(1, Math.max(0, v));
124  const TAU = Math.PI * 2;
125
126  const DEFAULT_COLORS = ['#f24d46', '#1899dd', '#a457f2', '#18a85c', '#f2892e'];
127  const DEFAULT_BARS = [24, 36, 30, 45, 40, 56, 50, 66, 60, 74, 68, 86];
128  const DEFAULT_PIE = [
129    { label: 'Alpha', value: 42 }, { label: 'Beta', value: 27 },
130    { label: 'Gamma', value: 19 }, { label: 'Delta', value: 12 },
131  ];
132  const DEFAULT_RADAR = [70, 85, 55, 75, 65, 60];
133
134  // ---------- shape presets (area / line) ----------
135
136  const SHAPES = {
137    plateau: (u, t, n) => 0.42 + 0.34 * fbm(u * 6.5 + t * 0.14 + n, 4)
138                        + 0.05 * Math.sin(t * 0.9 + u * 3),
139    climb:   (u, t, n) => 0.08 + 0.62 * Math.pow(u, 1.55) * (1 + 0.09 * Math.sin(t * 0.6))
140                        + u * 0.10 * fbm(u * 9 + t * 0.22 + n, 3),
141    hills:   (u, t, n) => 0.36 + 0.16 * Math.sin(u * 8.5 + t * 0.35 + n)
142                        + 0.12 * Math.sin(u * 21 - t * 0.55 + 2)
143                        + 0.14 * fbm(u * 4 + t * 0.1 + n, 3),
144  };
145
146  function makeShape(opts, seed) {
147    if (typeof opts.shape === 'function') return opts.shape;
148    if (Array.isArray(opts.data) && opts.data.length > 1) {
149      const arr = opts.data;
150      const min = Math.min(...arr), span = (Math.max(...arr) - min) || 1;
151      return (u, t) => {
152        const p = clamp01(u) * (arr.length - 1);
153        const i = Math.floor(p), f = p - i;
154        const a = (arr[i] - min) / span;
155        const b = (arr[Math.min(i + 1, arr.length - 1)] - min) / span;
156        return 0.10 + 0.72 * (a + (b - a) * f)
157             + 0.05 * fbm(u * 5 + t * 0.18 + seed, 3);
158      };
159    }
160    const fn = SHAPES[opts.shape] || SHAPES.hills;
161    return (u, t) => fn(u, t, seed);
162  }
163
164  // ---------- shared animation loop ----------
165
166  const instances = new Set();
167  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
168  const t0 = performance.now();
169  const tNow = () => (performance.now() - t0) / 1000;
170  let rafId = null;
171
172  function tick() {
173    rafId = null;
174    const t = tNow();
175    for (const inst of instances) render(inst, t);
176    if (!reduceMotion && instances.size) rafId = requestAnimationFrame(tick);
177  }
178  function wake() {
179    if (rafId === null && instances.size) rafId = requestAnimationFrame(tick);
180  }
181
182  // ---------- pixel shading ----------
183
184  // One cell. alt 0..1 is "how close to the crest/edge": high alt gets the
185  // saturated treatment (deeper on white, hotter toward white on dark).
186  function put(d, i, rgb, alt, shimmer, hot, dark) {
187    if (dark) {
188      const k = (0.35 + 0.65 * alt) * shimmer * (hot ? 1.5 : 1);
189      const white = Math.max(0, (alt - 0.72) * 1.6);
190      d[i]     = Math.min(255, lerp(rgb[0], 255, white) * k) | 0;
191      d[i + 1] = Math.min(255, lerp(rgb[1], 255, white) * k) | 0;
192      d[i + 2] = Math.min(255, lerp(rgb[2], 255, white) * k) | 0;
193    } else {
194      const k = Math.min(1, (0.62 + 0.38 * alt) * shimmer * (hot ? 1.4 : 1));
195      const deep = 1 - 0.45 * Math.max(0, (alt - 0.72) * 1.6);
196      d[i]     = lerp(255, rgb[0] * deep, k) | 0;
197      d[i + 1] = lerp(255, rgb[1] * deep, k) | 0;
198      d[i + 2] = lerp(255, rgb[2] * deep, k) | 0;
199    }
200    d[i + 3] = 255;
201  }
202
203  // ---------- renderers ----------
204
205  function renderArea(inst, d, ts) {
206    const { cols, rows, rgb, shape, dark } = inst;
207    const dissolve = rows * 0.5;
208    for (let x = 0; x < cols; x++) {
209      const u = x / cols;
210      const h = Math.min(1, Math.max(0.02, shape(u, ts)));
211      const crest = (1 - h) * rows;
212      const hot = x === inst.hoverCol;
213      for (let y = 0; y < rows; y++) {
214        let density;
215        if (y >= crest) {
216          density = 0.22 + (y - crest) / dissolve;
217        } else {
218          const gap = crest - y;
219          density = 0.30 * Math.exp(-gap / 2.2)
220                  + 0.05 * noise2(x * 0.7 + ts * 1.5, y * 0.9);
221        }
222        if (hot) density += 0.15;
223        if (density <= BAYER[y & 7][x & 7] / 64) continue;
224        const alt = 1 - y / rows;
225        const shimmer = 0.85 + 0.3 * noise2(x * 0.16 + ts * 0.5, y * 0.16 - ts * 0.3);
226        put(d, (y * cols + x) * 4, rgb, alt, shimmer, hot, dark);
227      }
228    }
229  }
230
231  function renderLine(inst, d, ts) {
232    const { cols, rows, rgb, shape, dark } = inst;
233    for (let x = 0; x < cols; x++) {
234      const u = x / cols;
235      const h = Math.min(1, Math.max(0.02, shape(u, ts)));
236      const crest = (1 - h) * rows;
237      const hot = x === inst.hoverCol;
238      for (let y = 0; y < rows; y++) {
239        const dist = y - crest; // >0 is below the stroke
240        const ad = Math.abs(dist);
241        let density = Math.max(0, 1.15 - ad * 0.6); // stroke core, ~2 cells
242        if (dist > 0) density += 0.03 + 0.16 * Math.exp(-dist / 6); // glow + faint fill
243        else density += 0.15 * Math.exp(-ad / 2.2); // sparks above
244        density += 0.04 * noise2(x * 0.7 + ts * 1.5, y * 0.9) - 0.02;
245        if (hot) density += 0.15;
246        if (density <= BAYER[y & 7][x & 7] / 64) continue;
247        const alt = 0.25 + 0.75 * clamp01(1 - ad / 7);
248        const shimmer = 0.85 + 0.3 * noise2(x * 0.16 + ts * 0.5, y * 0.16 - ts * 0.3);
249        put(d, (y * cols + x) * 4, rgb, alt, shimmer, hot, dark);
250      }
251    }
252  }
253
254  function renderBar(inst, d, ts) {
255    const { cols, rows, rgb, barH, seed, dark } = inst;
256    const n = barH.length;
257    const slot = cols / n;
258    const gap = Math.max(1, Math.round(slot * 0.2));
259    const dissolve = rows * 0.5;
260    const hs = [];
261    for (let i = 0; i < n; i++)
262      hs.push(clamp01(barH[i] * (1 + 0.05 * (noise1(i * 3.7 + ts * 0.7 + seed) * 2 - 1))));
263    for (let x = 0; x < cols; x++) {
264      const bi = Math.min(n - 1, Math.floor(x / slot));
265      if (x >= Math.round((bi + 1) * slot) - gap) continue; // gutter
266      const h = Math.max(0.02, hs[bi]);
267      const crest = (1 - h) * rows;
268      const hot = bi === inst.hoverBar;
269      for (let y = 0; y < rows; y++) {
270        let density;
271        if (y >= crest) {
272          density = 0.30 + (y - crest) / dissolve;
273        } else {
274          const g = crest - y;
275          density = 0.30 * Math.exp(-g / 2.2)
276                  + 0.05 * noise2(x * 0.7 + ts * 1.5, y * 0.9);
277        }
278        if (hot) density += 0.15;
279        if (density <= BAYER[y & 7][x & 7] / 64) continue;
280        const alt = 1 - y / rows;
281        const shimmer = 0.85 + 0.3 * noise2(x * 0.16 + ts * 0.5, y * 0.16 - ts * 0.3);
282        put(d, (y * cols + x) * 4, rgb, alt, shimmer, hot, dark);
283      }
284    }
285  }
286
287  function renderPie(inst, d, ts) {
288    const { cols, rows, segs, seed, dark } = inst;
289    const cx = cols / 2, cy = rows / 2;
290    const R = Math.min(cols, rows) * 0.5 - 4;
291    if (R < 4) return;
292    const innerR = inst.inner * R;
293    const n = segs.length;
294    const edge = [];
295    for (let s = 0; s < n; s++)
296      edge.push(R * (0.94 + 0.05 * (noise1(s * 5.3 + ts * 0.5 + seed) * 2 - 1)));
297    for (let y = 0; y < rows; y++) {
298      for (let x = 0; x < cols; x++) {
299        const dx = x - cx + 0.5, dy = y - cy + 0.5;
300        const r = Math.sqrt(dx * dx + dy * dy);
301        if (r > R + 5) continue;
302        let a = Math.atan2(dy, dx) / TAU + 0.25; // 0 at 12 o'clock, clockwise
303        a -= Math.floor(a);
304        let s = n - 1;
305        for (let k = 0; k < n; k++) if (a < segs[k].cum1) { s = k; break; }
306        const seg = segs[s];
307        const eR = edge[s];
308        let density;
309        if (r <= eR) {
310          if (r < innerR) continue;
311          if (n > 1) {
312            const dA = Math.min(a - seg.cum0, seg.cum1 - a) * TAU * r;
313            if (dA < 0.8) continue; // gutter between slices
314          }
315          density = 0.26 + (eR - r) / (R * 0.55)
316                  + 0.05 * noise2(x * 0.7 + ts * 1.5, y * 0.9);
317          if (innerR > 0) density = Math.min(density, 0.26 + (r - innerR) / (R * 0.22));
318        } else {
319          density = 0.30 * Math.exp(-(r - eR) / 2.2)
320                  + 0.04 * noise2(x * 0.8 + ts, y * 0.8);
321        }
322        const hot = s === inst.hoverSeg;
323        if (hot) density += 0.15;
324        if (density <= BAYER[y & 7][x & 7] / 64) continue;
325        const alt = clamp01(r / eR);
326        const shimmer = 0.85 + 0.3 * noise2(x * 0.16 + ts * 0.5, y * 0.16 - ts * 0.3);
327        put(d, (y * cols + x) * 4, seg.rgb, alt, shimmer, hot, dark);
328      }
329    }
330  }
331
332  function renderRadar(inst, d, ts) {
333    const { cols, rows, rgb, radarVals, seed, dark } = inst;
334    const n = radarVals.length;
335    const cx = cols / 2, cy = rows / 2;
336    const R = Math.min(cols, rows) * 0.5 - 4;
337    if (R < 4) return;
338    const vx = [], vy = [];
339    for (let i = 0; i < n; i++) {
340      const wob = 1 + 0.06 * (noise1(i * 4.1 + ts * 0.5 + seed) * 2 - 1);
341      const vr = clamp01(radarVals[i] * wob) * R;
342      const ang = -Math.PI / 2 + i * TAU / n;
343      vx.push(Math.cos(ang) * vr);
344      vy.push(Math.sin(ang) * vr);
345    }
346    for (let y = 0; y < rows; y++) {
347      for (let x = 0; x < cols; x++) {
348        const dx = x - cx + 0.5, dy = y - cy + 0.5;
349        const r = Math.sqrt(dx * dx + dy * dy);
350        if (r > R + 5) continue;
351        let a = Math.atan2(dy, dx) / TAU + 0.25;
352        a -= Math.floor(a);
353        const s = Math.min(n - 1, Math.floor(a * n));
354        const s2 = (s + 1) % n;
355        let rb; // radius of the polygon edge along this ray
356        if (r < 0.75) rb = 2;
357        else {
358          const ex = vx[s2] - vx[s], ey = vy[s2] - vy[s];
359          const den = (dx / r) * ey - (dy / r) * ex;
360          rb = Math.abs(den) < 1e-6 ? 0 : (vx[s] * ey - vy[s] * ex) / den;
361        }
362        let density = 0;
363        if (rb > 0) {
364          const dEdge = Math.abs(r - rb);
365          density = Math.max(0, 1.1 - dEdge * 0.55); // polygon stroke, ~2 cells
366          if (r <= rb) {
367            density += 0.10 + (rb - r) / (R * 2.2)
368                     + 0.04 * noise2(x * 0.7 + ts * 1.5, y * 0.9);
369          } else {
370            density += 0.22 * Math.exp(-(r - rb) / 2.2); // sparks outside
371          }
372        }
373        if (Math.abs(r - R) < 0.55) density = Math.max(density, 0.13); // guide ring
374        const spoke = Math.abs(a * n - Math.round(a * n)) * TAU / n * r;
375        if (spoke < 0.5 && r < R) density = Math.max(density, 0.09); // axis spokes
376        const hot = inst.hoverAxis >= 0 && (s === inst.hoverAxis || s2 === inst.hoverAxis);
377        if (hot) density += 0.12;
378        if (density <= BAYER[y & 7][x & 7] / 64) continue;
379        const alt = rb > 0 ? clamp01(r / rb) : 1;
380        const shimmer = 0.85 + 0.3 * noise2(x * 0.16 + ts * 0.5, y * 0.16 - ts * 0.3);
381        put(d, (y * cols + x) * 4, rgb, alt, shimmer, hot, dark);
382      }
383    }
384  }
385
386  const RENDERERS = { area: renderArea, line: renderLine, bar: renderBar, pie: renderPie, radar: renderRadar };
387
388  function render(inst, t) {
389    if (!inst.img) return;
390    const d = inst.img.data;
391    d.fill(0); // transparent cells; the page background shows through
392    RENDERERS[inst.type](inst, d, t * inst.speed);
393    inst.ctx.putImageData(inst.img, 0, 0);
394  }
395
396  function resize(inst) {
397    const r = inst.el.getBoundingClientRect();
398    if (!r.width || !r.height) return;
399    inst.cols = Math.max(1, Math.round(r.width / inst.cell));
400    inst.rows = Math.max(1, Math.round(r.height / inst.cell));
401    inst.canvas.width = inst.cols;
402    inst.canvas.height = inst.rows;
403    inst.img = inst.ctx.createImageData(inst.cols, inst.rows);
404    if (reduceMotion) render(inst, tNow());
405  }
406
407  // ---------- public API ----------
408
409  let autoSeed = 0;
410
411  function mount(target, opts = {}) {
412    const el = typeof target === 'string' ? document.querySelector(target) : target;
413    if (!el) throw new Error('DitherChart.mount: container not found');
414
415    const seed = opts.seed !== undefined ? opts.seed : (autoSeed += 37.7);
416    const type = RENDERERS[opts.type] ? opts.type : 'area';
417    const inst = {
418      el,
419      type,
420      seed,
421      cell: opts.cell || 3,
422      speed: opts.speed || 1,
423      theme: opts.theme === 'dark' ? 'dark' : 'light',
424      dark: opts.theme === 'dark',
425      scale: opts.scale || 100,
426      rgb: hex2rgb(opts.color || '#1899dd'),
427      cols: 0, rows: 0, img: null,
428      hoverCol: -1, hoverBar: -1, hoverSeg: -1, hoverAxis: -1,
429      cleanup: [],
430    };
431
432    inst.names = Array.isArray(opts.names) ? opts.names : null;
433
434    if (type === 'area' || type === 'line') {
435      inst.shape = makeShape(opts, seed);
436      if (Array.isArray(opts.data) && opts.data.length > 1) inst.dataArr = opts.data.slice();
437    } else if (type === 'bar') {
438      inst.barData = Array.isArray(opts.data) && opts.data.length ? opts.data.slice() : DEFAULT_BARS.slice();
439      const max = Math.max(...inst.barData) || 1;
440      inst.barH = inst.barData.map(v => 0.06 + 0.79 * v / max);
441    } else if (type === 'pie') {
442      const raw = Array.isArray(opts.data) && opts.data.length ? opts.data : DEFAULT_PIE;
443      const palette = Array.isArray(opts.colors) && opts.colors.length ? opts.colors : DEFAULT_COLORS;
444      const segs = raw.map((s, i) => {
445        const o = typeof s === 'number' ? { label: 'Slice ' + (i + 1), value: s } : s;
446        return { label: o.label || 'Slice ' + (i + 1), value: o.value || 0,
447                 rgb: hex2rgb(o.color || palette[i % palette.length]) };
448      });
449      const total = segs.reduce((a, s) => a + s.value, 0) || 1;
450      let cum = 0;
451      for (const s of segs) {
452        s.frac = s.value / total;
453        s.cum0 = cum;
454        cum += s.frac;
455        s.cum1 = cum;
456      }
457      segs[segs.length - 1].cum1 = 1;
458      inst.segs = segs;
459      inst.inner = Math.min(0.8, Math.max(0, opts.inner || 0));
460    } else if (type === 'radar') {
461      inst.radarData = Array.isArray(opts.data) && opts.data.length >= 3 ? opts.data.slice() : DEFAULT_RADAR.slice();
462      const max = Math.max(...inst.radarData) || 1;
463      inst.radarVals = inst.radarData.map(v => 0.18 + 0.77 * v / max);
464      inst.axes = Array.isArray(opts.axes) ? opts.axes : [];
465      while (inst.axes.length < inst.radarData.length) inst.axes.push('Axis ' + (inst.axes.length + 1));
466    }
467
468    if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
469
470    const canvas = document.createElement('canvas');
471    canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;display:block;';
472    el.appendChild(canvas);
473    inst.canvas = canvas;
474    inst.ctx = canvas.getContext('2d');
475
476    if (opts.tooltip !== false) {
477      const dark = inst.dark;
478      const ink = opts.ink || (dark ? '#f5f7ff' : '#15151c');
479      const inkLab = opts.ink || (dark ? '#93a0d9' : '#5560c4');
480      const tip = document.createElement('div');
481      tip.style.cssText =
482        'position:absolute;pointer-events:none;border-radius:8px;padding:6px 10px;' +
483        'font-family:inherit;font-size:12px;font-weight:600;line-height:1.2;' +
484        'font-variant-numeric:tabular-nums;white-space:nowrap;' +
485        'transform:translate(-50%,-130%);opacity:0;transition:opacity .12s;z-index:2;' +
486        'color:' + ink + ';' +
487        (dark ? 'background:#17171f;border:1px solid #2a2a38;'
488              : 'background:#fff;border:1px solid #dcdde8;box-shadow:0 2px 8px rgba(25,30,80,.10);');
489      const lab = document.createElement('span');
490      lab.style.cssText = 'margin-right:6px;font-weight:500;color:' + inkLab + ';';
491      lab.textContent = opts.label || '';
492      if (!opts.label) lab.style.display = 'none';
493      tip.appendChild(lab);
494      const val = document.createElement('span');
495      tip.appendChild(val);
496      el.appendChild(tip);
497      el.style.cursor = 'crosshair';
498
499      const move = e => {
500        const r = el.getBoundingClientRect();
501        const px = e.clientX - r.left;
502        const py = e.clientY - r.top;
503        let show = true;
504
505        if (type === 'pie' || type === 'radar') {
506          const dx = px / r.width * inst.cols - inst.cols / 2;
507          const dy = py / r.height * inst.rows - inst.rows / 2;
508          const rr = Math.hypot(dx, dy);
509          const R = Math.min(inst.cols, inst.rows) * 0.5 - 4;
510          let a = Math.atan2(dy, dx) / TAU + 0.25;
511          a -= Math.floor(a);
512          if (rr > R + 4) {
513            show = false;
514            inst.hoverSeg = inst.hoverAxis = -1;
515          } else if (type === 'pie') {
516            let s = inst.segs.length - 1;
517            for (let k = 0; k < inst.segs.length; k++) if (a < inst.segs[k].cum1) { s = k; break; }
518            inst.hoverSeg = s;
519            lab.textContent = inst.segs[s].label;
520            lab.style.display = '';
521            val.textContent = inst.segs[s].value + ' · ' + Math.round(inst.segs[s].frac * 100) + '%';
522          } else {
523            const n = inst.radarData.length;
524            const idx = Math.round(a * n) % n;
525            inst.hoverAxis = idx;
526            lab.textContent = inst.axes[idx];
527            lab.style.display = '';
528            val.textContent = inst.radarData[idx].toLocaleString();
529          }
530          tip.style.left = px + 'px';
531          tip.style.top = py + 'px';
532        } else if (type === 'bar') {
533          const n = inst.barData.length;
534          const bi = Math.max(0, Math.min(n - 1, Math.floor(px / r.width * n)));
535          inst.hoverBar = bi;
536          if (inst.names && inst.names[bi] != null) {
537            lab.textContent = inst.names[bi];
538            lab.style.display = '';
539          }
540          val.textContent = inst.barData[bi].toLocaleString();
541          tip.style.left = ((bi + 0.5) / n * r.width) + 'px';
542          tip.style.top = ((1 - inst.barH[bi]) * r.height) + 'px';
543        } else {
544          inst.hoverCol = Math.floor(px / r.width * inst.cols);
545          const u = clamp01(px / r.width);
546          const h = clamp01(inst.shape(u, tNow() * inst.speed));
547          let value;
548          if (inst.dataArr) {
549            const idx = Math.round(u * (inst.dataArr.length - 1));
550            value = inst.dataArr[idx];
551            if (inst.names && inst.names[idx] != null) {
552              lab.textContent = inst.names[idx];
553              lab.style.display = '';
554            }
555          } else {
556            value = Math.round(h * inst.scale);
557          }
558          val.textContent = value.toLocaleString();
559          tip.style.left = px + 'px';
560          tip.style.top = ((1 - h) * r.height) + 'px';
561        }
562        tip.style.opacity = show ? 1 : 0;
563        if (reduceMotion) render(inst, tNow());
564      };
565      const leave = () => {
566        inst.hoverCol = inst.hoverBar = inst.hoverSeg = inst.hoverAxis = -1;
567        tip.style.opacity = 0;
568        if (reduceMotion) render(inst, tNow());
569      };
570      el.addEventListener('pointermove', move);
571      el.addEventListener('pointerleave', leave);
572      inst.cleanup.push(() => {
573        el.removeEventListener('pointermove', move);
574        el.removeEventListener('pointerleave', leave);
575        tip.remove();
576      });
577    }
578
579    const ro = new ResizeObserver(() => resize(inst));
580    ro.observe(el);
581    inst.cleanup.push(() => ro.disconnect());
582
583    resize(inst);
584    instances.add(inst);
585    wake();
586
587    return {
588      el,
589      destroy() {
590        instances.delete(inst);
591        inst.cleanup.forEach(fn => fn());
592        canvas.remove();
593      },
594    };
595  }
596
597  // ---------- deck: tabbed multi-chart widget ----------
598
599  const DECK_DEFAULTS = [
600    { name: 'Area',  type: 'area',  color: '#f24d46', shape: 'plateau' },
601    { name: 'Line',  type: 'line',  color: '#a457f2' },
602    { name: 'Bar',   type: 'bar',   color: '#1899dd' },
603    { name: 'Pie',   type: 'pie',   color: '#18a85c', inner: 0.45 },
604    { name: 'Radar', type: 'radar', color: '#f2892e' },
605  ];
606
607  function deck(target, opts = {}) {
608    const el = typeof target === 'string' ? document.querySelector(target) : target;
609    if (!el) throw new Error('DitherChart.deck: container not found');
610
611    const charts = (Array.isArray(opts.charts) && opts.charts.length ? opts.charts : DECK_DEFAULTS)
612      .map(c => Object.assign({}, c));
613    const dark = opts.theme === 'dark';
614    const inkSoft = opts.ink || (dark ? '#93a0d9' : '#5560c4');
615
616    el.style.display = 'flex';
617    el.style.flexDirection = 'column';
618
619    const stage = document.createElement('div');
620    stage.style.cssText = 'position:relative;flex:1;min-height:0;';
621    el.appendChild(stage);
622
623    const tabs = document.createElement('div');
624    tabs.style.cssText = 'display:flex;gap:2px;justify-content:center;flex-wrap:wrap;padding-top:8px;flex:none;';
625    el.appendChild(tabs);
626
627    // tab accent = the series color (pie may carry a decorative `color` just for this)
628    const accent = c => c.color
629      || (Array.isArray(c.colors) && c.colors[0])
630      || (Array.isArray(c.data) && c.data[0] && c.data[0].color)
631      || DEFAULT_COLORS[1];
632
633    let chart = null, active = -1;
634    const btns = charts.map((c, i) => {
635      const b = document.createElement('button');
636      b.type = 'button';
637      b.textContent = c.name || c.type || 'Chart ' + (i + 1);
638      b.style.cssText = 'appearance:none;border:0;background:none;cursor:pointer;border-radius:6px;' +
639        'padding:5px 8px;font-family:inherit;font-size:11px;font-weight:600;line-height:1;letter-spacing:.02em;';
640      b.addEventListener('click', () => show(i));
641      tabs.appendChild(b);
642      return b;
643    });
644
645    function show(i) {
646      i = ((i % charts.length) + charts.length) % charts.length;
647      if (i === active) return;
648      active = i;
649      if (chart) chart.destroy();
650      chart = mount(stage, Object.assign({ theme: opts.theme, ink: opts.ink }, charts[i]));
651      btns.forEach((b, j) => {
652        const on = j === i;
653        b.style.color = on ? accent(charts[j]) : inkSoft;
654        b.style.background = on ? accent(charts[j]) + '1c' : 'none';
655      });
656    }
657    show(opts.active || 0);
658
659    return {
660      el,
661      show,
662      next() { show(active + 1); },
663      destroy() {
664        if (chart) chart.destroy();
665        stage.remove();
666        tabs.remove();
667      },
668    };
669  }
670
671  window.DitherChart = { mount, deck };
672})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.