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.