From a2d06f3707fd4adfa3ceeeed915b58ebe3e4e0f3 Mon Sep 17 00:00:00 2001 From: Kyle Kreuter Date: Thu, 16 Jul 2026 16:08:32 +0200 Subject: [PATCH] pause off-screen chart render loops via IntersectionObserver, rebuild registry --- r/area-chart.json | 2 +- r/bar-chart.json | 2 +- r/pie-chart.json | 2 +- r/radar-chart.json | 2 +- registry/dither-kit/bar-canvas.tsx | 26 +++++- registry/dither-kit/cartesian-canvas.tsx | 26 +++++- registry/dither-kit/pie-canvas.tsx | 26 +++++- registry/dither-kit/radar-canvas.tsx | 26 +++++- tests/offscreen.test.tsx | 100 +++++++++++++++++++++++ 9 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 tests/offscreen.test.tsx diff --git a/r/area-chart.json b/r/area-chart.json index e82331f..a1a04a3 100644 --- a/r/area-chart.json +++ b/r/area-chart.json @@ -25,7 +25,7 @@ "path": "components/dither-kit/cartesian-canvas.tsx", "type": "registry:component", "target": "components/dither-kit/cartesian-canvas.tsx", - "content": "\"use client\"\n\nimport { type RefObject, useEffect, useMemo, useRef } from \"react\"\nimport { type ChartContextValue, useChart } from \"./chart-context\"\nimport {\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n paintColumn,\n prefersReducedMotion,\n resample,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\n\ntype Star = { key: string; xi: number; depth: number; phase: number }\ntype Surface = { top: number[]; floor: number[] }\n\ntype LoopArgs = {\n canvas: HTMLCanvasElement\n bloomCanvas: HTMLCanvasElement | null\n cols: number\n rows: number\n state: RefObject\n targets: RefObject>\n stars: RefObject\n}\n\n/**\n * The requestAnimationFrame paint loop — eases each series toward its target\n * surface, paints the dither fill (with the entrance reveal), then layers the\n * crosshair marker and winking stars on top. Lives outside the component so the\n * component stays small and this hot closure isn't re-created on every render.\n * Returns a cleanup that cancels the loop.\n */\nfunction startCartesianLoop({\n canvas,\n bloomCanvas,\n cols,\n rows,\n state,\n targets,\n stars,\n}: LoopArgs): (() => void) | undefined {\n const c = canvas.getContext(\"2d\")\n if (!c || cols <= 0 || rows <= 0) return undefined\n canvas.width = cols\n canvas.height = rows\n\n const off = document.createElement(\"canvas\")\n off.width = cols\n off.height = rows\n const octx = off.getContext(\"2d\")\n if (!octx) return undefined\n\n // Bloom layer: a blurred, additive copy of the crisp canvas.\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const EASE = reduce ? 1 : 0.18\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n const current: Record = {}\n\n // `reveal` (0–1) sweeps the fill in left-to-right on first paint.\n const paintFill = (intensity: number, reveal: number) => {\n octx.clearRect(0, 0, cols, rows)\n const s = state.current\n const stacked = s.stackType === \"stacked\" || s.stackType === \"percent\"\n const revealCols = Math.ceil(reveal * cols)\n s.configKeys.forEach((key, si) => {\n const cur = current[key]\n if (!cur) return\n const seed = s.seedOf(key)\n const variant = s.seriesSpecs[key]?.variant ?? \"gradient\"\n const isLine =\n (s.seriesSpecs[key]?.kind ??\n (s.chartType === \"line\" ? \"line\" : \"area\")) === \"line\"\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const dim = emphasis !== null && emphasis !== key ? 0.3 : 1\n // Overlapping (non-stacked) layers thin out front-to-back so they\n // read as distinct layers instead of a muddy blend.\n const sparse = stacked ? 0 : si * 0.14\n for (let x = 0; x < cols; x++) {\n if (x > revealCols) break\n // For a value that dips below the zero baseline the value line ends up\n // *below* the floor in pixels; paintColumn needs the higher edge first,\n // so order the pair (a no-op for the common positive case).\n const a = cur.top[x] ?? 0\n const b = cur.floor[x] ?? 0\n paintColumn(octx, x, Math.min(a, b), Math.max(a, b), seed, {\n variant,\n intensity,\n dim,\n stacked: stacked && !isLine,\n sparse,\n })\n }\n })\n }\n\n let raf = 0\n let tick = 0\n let last = 0\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let entranceReported = !animate\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n\n const draw = (now: number) => {\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready) return\n // Keep the bloom layer in sync with the crisp canvas while it's active.\n if (bloomCtx) {\n const on =\n s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart || s.hovered)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n const tgt = targets.current\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0 // re-play the entrance on data change / replay\n lastProg = -1\n entranceReported = false\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n const progChanged = prog !== lastProg\n // Tell the context the reveal is done so DOM markers fade in in sync.\n if (prog >= 1 && !entranceReported) {\n entranceReported = true\n s.markEntranceDone()\n }\n\n let moving = false\n for (const key of s.configKeys) {\n const t = tgt[key]\n if (!t) continue\n const cur = current[key]\n if (!cur || cur.top.length !== cols) {\n current[key] = { top: t.top.slice(), floor: t.floor.slice() }\n needsFill = true\n continue\n }\n for (let x = 0; x < cols; x++) {\n const dt = t.top[x] - cur.top[x]\n const df = t.floor[x] - cur.floor[x]\n if (Math.abs(dt) > 0.01 || Math.abs(df) > 0.01) {\n cur.top[x] += dt * EASE\n cur.floor[x] += df * EASE\n moving = true\n } else {\n cur.top[x] = t.top[x]\n cur.floor[x] = t.floor[x]\n }\n }\n }\n for (const key of Object.keys(current)) {\n if (!tgt[key]) {\n delete current[key]\n needsFill = true\n }\n }\n if (moving) needsFill = true\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n\n const itTarget = s.isMouseInChart || s.hovered ? 1 : 0\n let settling = false\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * 0.16\n settling = true\n needsFill = true\n } else intensity = itTarget\n\n // Live hover wins; the controlled markerIndex (e.g. a committed point)\n // is the fallback shown when nothing is hovered.\n const marker = s.hoverIndex != null ? s.hoverIndex : s.markerIndex\n const winkDue = !reduce && now - last >= 100\n // Repaint when a tweak-driven paint input changes (variant, stacking) so\n // the panel updates the fill live — without resetting the entrance reveal.\n const paintSig = `${s.stackType}|${s.configKeys\n .map((k) => s.seriesSpecs[k]?.variant ?? \"\")\n .join(\",\")}`\n const sigChanged = paintSig !== lastPaintSig\n if (sigChanged) {\n lastPaintSig = paintSig\n needsFill = true\n }\n if (\n !(\n moving ||\n settling ||\n winkDue ||\n marker != null ||\n progChanged ||\n sigChanged\n )\n )\n return\n if (progChanged) {\n lastProg = prog\n needsFill = true\n }\n if (winkDue) {\n last = now\n tick += 1\n }\n\n // Reveal front (left-to-right) — stars + crosshair stay behind it so\n // they don't float over the not-yet-drawn area during the entrance.\n const reveal = animate ? easeInOutCubic(prog) : 1\n const revealCols = reveal * cols\n\n if (needsFill) {\n paintFill(intensity, reveal)\n needsFill = false\n }\n c.clearRect(0, 0, cols, rows)\n c.drawImage(off, 0, 0)\n\n const mx =\n marker != null && s.dataLength > 1\n ? Math.round((marker / (s.dataLength - 1)) * (cols - 1))\n : -1\n if (mx >= 0 && mx <= revealCols) {\n for (const key of s.configKeys) {\n const cur = current[key]\n if (!cur) continue\n const seed = s.seedOf(key)\n const my = Math.round(cur.top[mx] ?? 0)\n // Full-height column + a chunky marker block at the point — the\n // series colour at higher opacity, so it reads on either theme.\n c.fillStyle = rgb(seed.fill, 1, 0.55)\n for (let y = my; y < rows; y++) c.fillRect(mx, y, 1, 1)\n c.fillStyle = rgb(seed.fill)\n c.fillRect(mx - 1, my - 1, 3, 3)\n }\n }\n\n for (const star of stars.current) {\n const cur = current[star.key]\n if (!cur) continue\n const sx = Math.round(\n (star.xi / Math.max(s.dataLength - 1, 1)) * (cols - 1)\n )\n if (sx > revealCols) continue // behind the reveal front\n const top = cur.top[sx] ?? 0\n const floor = cur.floor[sx] ?? rows - 1\n const sy = Math.round(top + star.depth * (floor - top))\n const tw = reduce ? 0.85 : (Math.sin((tick + star.phase) * 0.35) + 1) / 2\n const lift = tw * (0.7 + 0.3 * intensity)\n if (lift < 0.55 || sy < 0 || sy >= rows) continue\n // Sparkles glint in the series colour via opacity (the `lift` wink)\n // rather than a lighter shade — so they never read as stray white\n // pixels on a light background.\n const starColor = s.seedOf(star.key).fill\n c.fillStyle = rgb(starColor, 1, lift)\n c.fillRect(sx, sy, 1, 1)\n // At the peak of a wink the star flares into a 4-point glint.\n if (tw > 0.9) {\n c.fillStyle = rgb(starColor, 1, lift * 0.6 * (tw - 0.9) * 10)\n c.fillRect(sx - 1, sy, 1, 1)\n c.fillRect(sx + 1, sy, 1, 1)\n c.fillRect(sx, sy - 1, 1, 1)\n c.fillRect(sx, sy + 1, 1, 1)\n }\n }\n }\n\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n}\n\n/**\n * Continuous dither canvas for area and line charts. Each series is reduced to a\n * `[top, floor]` band per backing column: areas fill from their value line down\n * to their floor; lines fill only a thin glow band hugging the line. The shared\n * {@link paintColumn} renders the ordered-dither scatter, capped by the bright\n * series line, with winking stars + scrub crosshair on top.\n */\nexport function CartesianCanvas() {\n const ctx = useChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n const { ready, chartType, configKeys, bands, seriesSpecs, y, dataLength } = ctx\n\n // Memoized: the pricey bit in the render path — a `resample` per series to\n // the backing column count. The canvas re-renders on every hover/cursor tick\n // (it consumes ctx), so without this the whole surface is rebuilt each time.\n // Pinned to the exact ctx fields it reads, plus the backing geometry.\n const targets = useMemo(() => {\n const out: Record = {}\n if (!ready) return out\n const h = height || 1\n const glow = Math.max(6, Math.round(rows * 0.16))\n const defaultKind = chartType === \"line\" ? \"line\" : \"area\"\n for (const key of configKeys) {\n const band = bands[key]\n if (!band) continue\n const line = (seriesSpecs[key]?.kind ?? defaultKind) === \"line\"\n const top = band.map((b) => (y(b[1]) / h) * (rows - 1))\n const floor = band.map((b, i) =>\n line ? Math.min(rows - 1, top[i] + glow) : (y(b[0]) / h) * (rows - 1)\n )\n out[key] = { top: resample(top, cols), floor: resample(floor, cols) }\n }\n return out\n }, [ready, chartType, configKeys, bands, seriesSpecs, y, height, rows, cols])\n\n // Memoized: the star field is deterministic — only its shape (series ×\n // column count) matters, so it need not be rebuilt on unrelated re-renders.\n const stars = useMemo(() => {\n const out: Star[] = []\n const per = Math.max(4, Math.round(cols / 14))\n configKeys.forEach((key, k) => {\n for (let i = 0; i < per; i++) {\n const seed = i * 67 + 13 + k * 131\n out.push({\n key,\n xi: seed % Math.max(dataLength, 1),\n depth: ((seed * 53 + 7) % 100) / 100,\n phase: (seed * 41) % 360,\n })\n }\n })\n return out\n }, [configKeys, dataLength, cols])\n\n // The RAF loop reads these through refs so it always sees the latest values\n // without re-subscribing. Refs are written in an effect (never during\n // render) — mutating a ref mid-render is a React anti-pattern that tears\n // under Strict Mode / concurrent rendering.\n const stateRef = useRef(ctx)\n const targetsRef = useRef(targets)\n const starsRef = useRef(stars)\n useEffect(() => {\n stateRef.current = ctx\n targetsRef.current = targets\n starsRef.current = stars\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n return startCartesianLoop({\n canvas,\n bloomCanvas: bloomRef.current,\n cols,\n rows,\n state: stateRef,\n targets: targetsRef,\n stars: starsRef,\n })\n }, [cols, rows])\n\n const bloomActive = ctx.bloomOnHover\n ? ctx.isMouseInChart || ctx.hovered\n : true\n const bloom = bloomLayerStyle(ctx.bloom, bloomActive)\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" + "content": "\"use client\"\n\nimport { type RefObject, useEffect, useMemo, useRef } from \"react\"\nimport { type ChartContextValue, useChart } from \"./chart-context\"\nimport {\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n paintColumn,\n prefersReducedMotion,\n resample,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\n\ntype Star = { key: string; xi: number; depth: number; phase: number }\ntype Surface = { top: number[]; floor: number[] }\n\ntype LoopArgs = {\n canvas: HTMLCanvasElement\n bloomCanvas: HTMLCanvasElement | null\n cols: number\n rows: number\n state: RefObject\n targets: RefObject>\n stars: RefObject\n}\n\n/**\n * The requestAnimationFrame paint loop — eases each series toward its target\n * surface, paints the dither fill (with the entrance reveal), then layers the\n * crosshair marker and winking stars on top. Lives outside the component so the\n * component stays small and this hot closure isn't re-created on every render.\n * Returns a cleanup that cancels the loop.\n */\nfunction startCartesianLoop({\n canvas,\n bloomCanvas,\n cols,\n rows,\n state,\n targets,\n stars,\n}: LoopArgs): (() => void) | undefined {\n const c = canvas.getContext(\"2d\")\n if (!c || cols <= 0 || rows <= 0) return undefined\n canvas.width = cols\n canvas.height = rows\n\n const off = document.createElement(\"canvas\")\n off.width = cols\n off.height = rows\n const octx = off.getContext(\"2d\")\n if (!octx) return undefined\n\n // Bloom layer: a blurred, additive copy of the crisp canvas.\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const EASE = reduce ? 1 : 0.18\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n const current: Record = {}\n\n // `reveal` (0–1) sweeps the fill in left-to-right on first paint.\n const paintFill = (intensity: number, reveal: number) => {\n octx.clearRect(0, 0, cols, rows)\n const s = state.current\n const stacked = s.stackType === \"stacked\" || s.stackType === \"percent\"\n const revealCols = Math.ceil(reveal * cols)\n s.configKeys.forEach((key, si) => {\n const cur = current[key]\n if (!cur) return\n const seed = s.seedOf(key)\n const variant = s.seriesSpecs[key]?.variant ?? \"gradient\"\n const isLine =\n (s.seriesSpecs[key]?.kind ??\n (s.chartType === \"line\" ? \"line\" : \"area\")) === \"line\"\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const dim = emphasis !== null && emphasis !== key ? 0.3 : 1\n // Overlapping (non-stacked) layers thin out front-to-back so they\n // read as distinct layers instead of a muddy blend.\n const sparse = stacked ? 0 : si * 0.14\n for (let x = 0; x < cols; x++) {\n if (x > revealCols) break\n // For a value that dips below the zero baseline the value line ends up\n // *below* the floor in pixels; paintColumn needs the higher edge first,\n // so order the pair (a no-op for the common positive case).\n const a = cur.top[x] ?? 0\n const b = cur.floor[x] ?? 0\n paintColumn(octx, x, Math.min(a, b), Math.max(a, b), seed, {\n variant,\n intensity,\n dim,\n stacked: stacked && !isLine,\n sparse,\n })\n }\n })\n }\n\n let raf = 0\n let visible = false\n let tick = 0\n let last = 0\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let entranceReported = !animate\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n\n const draw = (now: number) => {\n if (!visible) {\n raf = 0\n return\n }\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready) return\n // Keep the bloom layer in sync with the crisp canvas while it's active.\n if (bloomCtx) {\n const on =\n s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart || s.hovered)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n const tgt = targets.current\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0 // re-play the entrance on data change / replay\n lastProg = -1\n entranceReported = false\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n const progChanged = prog !== lastProg\n // Tell the context the reveal is done so DOM markers fade in in sync.\n if (prog >= 1 && !entranceReported) {\n entranceReported = true\n s.markEntranceDone()\n }\n\n let moving = false\n for (const key of s.configKeys) {\n const t = tgt[key]\n if (!t) continue\n const cur = current[key]\n if (!cur || cur.top.length !== cols) {\n current[key] = { top: t.top.slice(), floor: t.floor.slice() }\n needsFill = true\n continue\n }\n for (let x = 0; x < cols; x++) {\n const dt = t.top[x] - cur.top[x]\n const df = t.floor[x] - cur.floor[x]\n if (Math.abs(dt) > 0.01 || Math.abs(df) > 0.01) {\n cur.top[x] += dt * EASE\n cur.floor[x] += df * EASE\n moving = true\n } else {\n cur.top[x] = t.top[x]\n cur.floor[x] = t.floor[x]\n }\n }\n }\n for (const key of Object.keys(current)) {\n if (!tgt[key]) {\n delete current[key]\n needsFill = true\n }\n }\n if (moving) needsFill = true\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n\n const itTarget = s.isMouseInChart || s.hovered ? 1 : 0\n let settling = false\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * 0.16\n settling = true\n needsFill = true\n } else intensity = itTarget\n\n // Live hover wins; the controlled markerIndex (e.g. a committed point)\n // is the fallback shown when nothing is hovered.\n const marker = s.hoverIndex != null ? s.hoverIndex : s.markerIndex\n const winkDue = !reduce && now - last >= 100\n // Repaint when a tweak-driven paint input changes (variant, stacking) so\n // the panel updates the fill live — without resetting the entrance reveal.\n const paintSig = `${s.stackType}|${s.configKeys\n .map((k) => s.seriesSpecs[k]?.variant ?? \"\")\n .join(\",\")}`\n const sigChanged = paintSig !== lastPaintSig\n if (sigChanged) {\n lastPaintSig = paintSig\n needsFill = true\n }\n if (\n !(\n moving ||\n settling ||\n winkDue ||\n marker != null ||\n progChanged ||\n sigChanged\n )\n )\n return\n if (progChanged) {\n lastProg = prog\n needsFill = true\n }\n if (winkDue) {\n last = now\n tick += 1\n }\n\n // Reveal front (left-to-right) — stars + crosshair stay behind it so\n // they don't float over the not-yet-drawn area during the entrance.\n const reveal = animate ? easeInOutCubic(prog) : 1\n const revealCols = reveal * cols\n\n if (needsFill) {\n paintFill(intensity, reveal)\n needsFill = false\n }\n c.clearRect(0, 0, cols, rows)\n c.drawImage(off, 0, 0)\n\n const mx =\n marker != null && s.dataLength > 1\n ? Math.round((marker / (s.dataLength - 1)) * (cols - 1))\n : -1\n if (mx >= 0 && mx <= revealCols) {\n for (const key of s.configKeys) {\n const cur = current[key]\n if (!cur) continue\n const seed = s.seedOf(key)\n const my = Math.round(cur.top[mx] ?? 0)\n // Full-height column + a chunky marker block at the point — the\n // series colour at higher opacity, so it reads on either theme.\n c.fillStyle = rgb(seed.fill, 1, 0.55)\n for (let y = my; y < rows; y++) c.fillRect(mx, y, 1, 1)\n c.fillStyle = rgb(seed.fill)\n c.fillRect(mx - 1, my - 1, 3, 3)\n }\n }\n\n for (const star of stars.current) {\n const cur = current[star.key]\n if (!cur) continue\n const sx = Math.round(\n (star.xi / Math.max(s.dataLength - 1, 1)) * (cols - 1)\n )\n if (sx > revealCols) continue // behind the reveal front\n const top = cur.top[sx] ?? 0\n const floor = cur.floor[sx] ?? rows - 1\n const sy = Math.round(top + star.depth * (floor - top))\n const tw = reduce ? 0.85 : (Math.sin((tick + star.phase) * 0.35) + 1) / 2\n const lift = tw * (0.7 + 0.3 * intensity)\n if (lift < 0.55 || sy < 0 || sy >= rows) continue\n // Sparkles glint in the series colour via opacity (the `lift` wink)\n // rather than a lighter shade — so they never read as stray white\n // pixels on a light background.\n const starColor = s.seedOf(star.key).fill\n c.fillStyle = rgb(starColor, 1, lift)\n c.fillRect(sx, sy, 1, 1)\n // At the peak of a wink the star flares into a 4-point glint.\n if (tw > 0.9) {\n c.fillStyle = rgb(starColor, 1, lift * 0.6 * (tw - 0.9) * 10)\n c.fillRect(sx - 1, sy, 1, 1)\n c.fillRect(sx + 1, sy, 1, 1)\n c.fillRect(sx, sy - 1, 1, 1)\n c.fillRect(sx, sy + 1, 1, 1)\n }\n }\n }\n\n if (typeof IntersectionObserver === \"undefined\") {\n visible = true\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }\n const io = new IntersectionObserver(([entry]) => {\n visible = entry.isIntersecting\n if (visible) {\n if (!raf) raf = requestAnimationFrame(draw)\n } else if (raf) {\n cancelAnimationFrame(raf)\n raf = 0\n }\n })\n io.observe(canvas)\n return () => {\n io.disconnect()\n cancelAnimationFrame(raf)\n }\n}\n\n/**\n * Continuous dither canvas for area and line charts. Each series is reduced to a\n * `[top, floor]` band per backing column: areas fill from their value line down\n * to their floor; lines fill only a thin glow band hugging the line. The shared\n * {@link paintColumn} renders the ordered-dither scatter, capped by the bright\n * series line, with winking stars + scrub crosshair on top.\n */\nexport function CartesianCanvas() {\n const ctx = useChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n const { ready, chartType, configKeys, bands, seriesSpecs, y, dataLength } = ctx\n\n // Memoized: the pricey bit in the render path — a `resample` per series to\n // the backing column count. The canvas re-renders on every hover/cursor tick\n // (it consumes ctx), so without this the whole surface is rebuilt each time.\n // Pinned to the exact ctx fields it reads, plus the backing geometry.\n const targets = useMemo(() => {\n const out: Record = {}\n if (!ready) return out\n const h = height || 1\n const glow = Math.max(6, Math.round(rows * 0.16))\n const defaultKind = chartType === \"line\" ? \"line\" : \"area\"\n for (const key of configKeys) {\n const band = bands[key]\n if (!band) continue\n const line = (seriesSpecs[key]?.kind ?? defaultKind) === \"line\"\n const top = band.map((b) => (y(b[1]) / h) * (rows - 1))\n const floor = band.map((b, i) =>\n line ? Math.min(rows - 1, top[i] + glow) : (y(b[0]) / h) * (rows - 1)\n )\n out[key] = { top: resample(top, cols), floor: resample(floor, cols) }\n }\n return out\n }, [ready, chartType, configKeys, bands, seriesSpecs, y, height, rows, cols])\n\n // Memoized: the star field is deterministic — only its shape (series ×\n // column count) matters, so it need not be rebuilt on unrelated re-renders.\n const stars = useMemo(() => {\n const out: Star[] = []\n const per = Math.max(4, Math.round(cols / 14))\n configKeys.forEach((key, k) => {\n for (let i = 0; i < per; i++) {\n const seed = i * 67 + 13 + k * 131\n out.push({\n key,\n xi: seed % Math.max(dataLength, 1),\n depth: ((seed * 53 + 7) % 100) / 100,\n phase: (seed * 41) % 360,\n })\n }\n })\n return out\n }, [configKeys, dataLength, cols])\n\n // The RAF loop reads these through refs so it always sees the latest values\n // without re-subscribing. Refs are written in an effect (never during\n // render) — mutating a ref mid-render is a React anti-pattern that tears\n // under Strict Mode / concurrent rendering.\n const stateRef = useRef(ctx)\n const targetsRef = useRef(targets)\n const starsRef = useRef(stars)\n useEffect(() => {\n stateRef.current = ctx\n targetsRef.current = targets\n starsRef.current = stars\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n return startCartesianLoop({\n canvas,\n bloomCanvas: bloomRef.current,\n cols,\n rows,\n state: stateRef,\n targets: targetsRef,\n stars: starsRef,\n })\n }, [cols, rows])\n\n const bloomActive = ctx.bloomOnHover\n ? ctx.isMouseInChart || ctx.hovered\n : true\n const bloom = bloomLayerStyle(ctx.bloom, bloomActive)\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/area.tsx", diff --git a/r/bar-chart.json b/r/bar-chart.json index 73a5c42..95d237f 100644 --- a/r/bar-chart.json +++ b/r/bar-chart.json @@ -25,7 +25,7 @@ "path": "components/dither-kit/bar-canvas.tsx", "type": "registry:component", "target": "components/dither-kit/bar-canvas.tsx", - "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { useChart } from \"./chart-context\"\nimport {\n backingSize,\n bloomLayerStyle,\n clamp01,\n easeOutCubic,\n paintColumn,\n prefersReducedMotion,\n} from \"./dither-paint\"\n\ntype Bars = { top: number[]; base: number[] } // per data index, in backing rows\n\n// Fraction of the timeline spent staggering bar starts — the rest is each bar's\n// own grow window, so the rise sweeps across the chart as a wave.\nconst STAGGER = 0.55\n\n/**\n * Dither canvas for bar charts. Each category owns a band; grouped series split\n * it into side-by-side bars, stacked series share its full width and pile in y.\n * Every bar is filled with the shared {@link paintColumn} ordered dither. Bars\n * grow up from their base in a staggered left-to-right wave (eased), and the\n * hovered category lifts while the rest dim.\n */\nexport function BarCanvas() {\n const ctx = useChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n const { ready, configKeys, bands, y } = ctx\n\n // Memoized: per-series bar tops/bases (backing rows) over the data indices.\n // The canvas re-renders on every hover/cursor tick, so pin this map to the\n // exact ctx fields it reads plus the backing geometry — a bar hover must not\n // rebuild every band's geometry.\n const targets = useMemo(() => {\n const out: Record = {}\n if (!ready) return out\n const h = height || 1\n for (const key of configKeys) {\n const band = bands[key]\n if (!band) continue\n out[key] = {\n top: band.map((b) => (y(b[1]) / h) * (rows - 1)),\n base: band.map((b) => (y(b[0]) / h) * (rows - 1)),\n }\n }\n return out\n }, [ready, configKeys, bands, y, height, rows])\n\n // The RAF loop reads these through refs so it always sees the latest values;\n // refs are written in an effect (never during render) — mutating a ref\n // mid-render tears under Strict Mode / concurrent rendering.\n const state = useRef(ctx)\n const targetsRef = useRef(targets)\n useEffect(() => {\n state.current = ctx\n targetsRef.current = targets\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n const fx = cols / Math.max(width, 1)\n\n // Eased grow factor for bar `i` at global progress `prog`.\n const barProgress = (i: number, len: number, prog: number) => {\n if (!animate) return 1\n const start = len > 1 ? (i / (len - 1)) * STAGGER : 0\n return easeOutCubic(clamp01((prog - start) / (1 - STAGGER)))\n }\n\n const paint = (prog: number) => {\n const s = state.current\n c.clearRect(0, 0, cols, rows)\n const stacked = s.stackType === \"stacked\" || s.stackType === \"percent\"\n const keys = s.configKeys\n keys.forEach((key, si) => {\n const t = targetsRef.current[key]\n if (!t) return\n const seed = s.seedOf(key)\n const variant = s.seriesSpecs[key]?.variant ?? \"gradient\"\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n for (let i = 0; i < s.dataLength; i++) {\n const bp = barProgress(i, s.dataLength, prog)\n const base = t.base[i] ?? rows - 1\n const grown = base + ((t.top[i] ?? base) - base) * bp\n // Bars grow from the zero baseline toward the value. Positive values\n // sit above the baseline (smaller pixel), negative ones below it —\n // paintColumn wants the higher edge first, so order the pair.\n const top = Math.min(grown, base)\n const bottom = Math.max(grown, base)\n const active = s.hoverIndex === i\n const hoverDim =\n s.hoverIndex != null && !active && s.isMouseInChart ? 0.5 : 1\n const slot = s.barSlot(i, si, keys.length)\n const c0 = Math.round(slot.x * fx)\n const c1 = Math.round((slot.x + slot.width) * fx)\n for (let x = c0; x < c1; x++) {\n paintColumn(c, x, top, bottom, seed, {\n variant,\n intensity: intensity + (active ? 0.4 : 0),\n dim: selDim * hoverDim,\n stacked,\n })\n }\n }\n })\n }\n\n let raf = 0\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const draw = (now: number) => {\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready) return\n if (bloomCtx) {\n const on =\n s.bloom !== \"off\" &&\n (!s.bloomOnHover || s.isMouseInChart || s.hovered)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0 // re-play the wave on data change / replay\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n needsFill = true\n }\n const itTarget = s.isMouseInChart || s.hovered ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n\n // Live tweak repaint (variant, stacking) without replaying the wave.\n const paintSig = `${s.stackType}|${s.configKeys\n .map((k) => s.seriesSpecs[k]?.variant ?? \"\")\n .join(\",\")}`\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }, [cols, rows, width])\n\n const bloomActive = ctx.bloomOnHover\n ? ctx.isMouseInChart || ctx.hovered\n : true\n const bloom = bloomLayerStyle(ctx.bloom, bloomActive)\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" + "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { useChart } from \"./chart-context\"\nimport {\n backingSize,\n bloomLayerStyle,\n clamp01,\n easeOutCubic,\n paintColumn,\n prefersReducedMotion,\n} from \"./dither-paint\"\n\ntype Bars = { top: number[]; base: number[] } // per data index, in backing rows\n\n// Fraction of the timeline spent staggering bar starts — the rest is each bar's\n// own grow window, so the rise sweeps across the chart as a wave.\nconst STAGGER = 0.55\n\n/**\n * Dither canvas for bar charts. Each category owns a band; grouped series split\n * it into side-by-side bars, stacked series share its full width and pile in y.\n * Every bar is filled with the shared {@link paintColumn} ordered dither. Bars\n * grow up from their base in a staggered left-to-right wave (eased), and the\n * hovered category lifts while the rest dim.\n */\nexport function BarCanvas() {\n const ctx = useChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n const { ready, configKeys, bands, y } = ctx\n\n // Memoized: per-series bar tops/bases (backing rows) over the data indices.\n // The canvas re-renders on every hover/cursor tick, so pin this map to the\n // exact ctx fields it reads plus the backing geometry — a bar hover must not\n // rebuild every band's geometry.\n const targets = useMemo(() => {\n const out: Record = {}\n if (!ready) return out\n const h = height || 1\n for (const key of configKeys) {\n const band = bands[key]\n if (!band) continue\n out[key] = {\n top: band.map((b) => (y(b[1]) / h) * (rows - 1)),\n base: band.map((b) => (y(b[0]) / h) * (rows - 1)),\n }\n }\n return out\n }, [ready, configKeys, bands, y, height, rows])\n\n // The RAF loop reads these through refs so it always sees the latest values;\n // refs are written in an effect (never during render) — mutating a ref\n // mid-render tears under Strict Mode / concurrent rendering.\n const state = useRef(ctx)\n const targetsRef = useRef(targets)\n useEffect(() => {\n state.current = ctx\n targetsRef.current = targets\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n const fx = cols / Math.max(width, 1)\n\n // Eased grow factor for bar `i` at global progress `prog`.\n const barProgress = (i: number, len: number, prog: number) => {\n if (!animate) return 1\n const start = len > 1 ? (i / (len - 1)) * STAGGER : 0\n return easeOutCubic(clamp01((prog - start) / (1 - STAGGER)))\n }\n\n const paint = (prog: number) => {\n const s = state.current\n c.clearRect(0, 0, cols, rows)\n const stacked = s.stackType === \"stacked\" || s.stackType === \"percent\"\n const keys = s.configKeys\n keys.forEach((key, si) => {\n const t = targetsRef.current[key]\n if (!t) return\n const seed = s.seedOf(key)\n const variant = s.seriesSpecs[key]?.variant ?? \"gradient\"\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n for (let i = 0; i < s.dataLength; i++) {\n const bp = barProgress(i, s.dataLength, prog)\n const base = t.base[i] ?? rows - 1\n const grown = base + ((t.top[i] ?? base) - base) * bp\n // Bars grow from the zero baseline toward the value. Positive values\n // sit above the baseline (smaller pixel), negative ones below it —\n // paintColumn wants the higher edge first, so order the pair.\n const top = Math.min(grown, base)\n const bottom = Math.max(grown, base)\n const active = s.hoverIndex === i\n const hoverDim =\n s.hoverIndex != null && !active && s.isMouseInChart ? 0.5 : 1\n const slot = s.barSlot(i, si, keys.length)\n const c0 = Math.round(slot.x * fx)\n const c1 = Math.round((slot.x + slot.width) * fx)\n for (let x = c0; x < c1; x++) {\n paintColumn(c, x, top, bottom, seed, {\n variant,\n intensity: intensity + (active ? 0.4 : 0),\n dim: selDim * hoverDim,\n stacked,\n })\n }\n }\n })\n }\n\n let raf = 0\n let visible = false\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const draw = (now: number) => {\n if (!visible) {\n raf = 0\n return\n }\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready) return\n if (bloomCtx) {\n const on =\n s.bloom !== \"off\" &&\n (!s.bloomOnHover || s.isMouseInChart || s.hovered)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0 // re-play the wave on data change / replay\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n needsFill = true\n }\n const itTarget = s.isMouseInChart || s.hovered ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n\n // Live tweak repaint (variant, stacking) without replaying the wave.\n const paintSig = `${s.stackType}|${s.configKeys\n .map((k) => s.seriesSpecs[k]?.variant ?? \"\")\n .join(\",\")}`\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n if (typeof IntersectionObserver === \"undefined\") {\n visible = true\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }\n const io = new IntersectionObserver(([entry]) => {\n visible = entry.isIntersecting\n if (visible) {\n if (!raf) raf = requestAnimationFrame(draw)\n } else if (raf) {\n cancelAnimationFrame(raf)\n raf = 0\n }\n })\n io.observe(canvas)\n return () => {\n io.disconnect()\n cancelAnimationFrame(raf)\n }\n }, [cols, rows, width])\n\n const bloomActive = ctx.bloomOnHover\n ? ctx.isMouseInChart || ctx.hovered\n : true\n const bloom = bloomLayerStyle(ctx.bloom, bloomActive)\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/bar.tsx", diff --git a/r/pie-chart.json b/r/pie-chart.json index def45b2..da9110b 100644 --- a/r/pie-chart.json +++ b/r/pie-chart.json @@ -25,7 +25,7 @@ "path": "components/dither-kit/pie-canvas.tsx", "type": "registry:component", "target": "components/dither-kit/pie-canvas.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n BAYER,\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n OFF_TIER,\n prefersReducedMotion,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\nimport { sliceAtAngle } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\nconst TOP = -Math.PI / 2\nconst TAU = Math.PI * 2\nconst POP = 6 // px the hovered slice bulges outward\n\n/**\n * Dither canvas for pie / donut charts. Each backing pixel is mapped back to\n * plot space, tested for its slice by angle, and filled with the ordered-dither\n * scatter — dense at the outer edge, thinning toward the centre — capped by a\n * bright arc on the rim. The pie sweeps in clockwise on mount; the hovered slice\n * bulges outward with a brighter rim while the rest dim.\n */\nexport function PieCanvas() {\n const ctx = usePolarChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n\n // The RAF loop reads the latest ctx through a ref; written in an effect\n // (never during render) — mutating a ref mid-render tears under Strict Mode /\n // concurrent rendering.\n const state = useRef(ctx)\n useEffect(() => {\n state.current = ctx\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n let raf = 0\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let popEase = 0 // eases the hovered slice's outward bulge\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const paint = (prog: number) => {\n const s = state.current\n const slices = s.pie\n if (!slices) return\n c.clearRect(0, 0, cols, rows)\n const cx = s.center.x\n const cy = s.center.y\n const outerR = s.outerRadius\n const innerR = s.innerRadius\n const revealAngle = TOP + easeInOutCubic(prog) * TAU\n\n for (let y = 0; y < rows; y++) {\n const py = ((y + 0.5) * height) / rows\n for (let x = 0; x < cols; x++) {\n const px = ((x + 0.5) * width) / cols\n const dx = px - cx\n const dy = py - cy\n const r = Math.hypot(dx, dy)\n if (r < innerR) continue\n const angle = Math.atan2(dy, dx)\n let na = angle\n while (na < TOP) na += TAU\n while (na >= TOP + TAU) na -= TAU\n if (na > revealAngle) continue // clockwise sweep-in\n const si = sliceAtAngle(slices, angle)\n if (si < 0) continue\n const slice = slices[si]\n const active = s.hoverIndex === si\n const localOuter = active ? outerR + POP * popEase : outerR\n if (r > localOuter) continue\n\n const seed = s.seedOf(slice.name)\n const variant = s.variantOf(slice.name)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== slice.name ? 0.3 : 1\n const it = intensity + (active ? 0.4 * popEase : 0)\n\n // Bright rim on the outer edge — thicker on the hovered slice.\n if (localOuter - r < (active ? 1.4 + popEase : 1.4)) {\n c.fillStyle = rgb(seed.fill, 1, selDim)\n c.fillRect(x, y, 1, 1)\n continue\n }\n const density = (r - innerR) / Math.max(localOuter - innerR, 1)\n const bias = variant === \"dotted\" ? 0.12 : 0\n if (variant === \"hatched\" && ((x + y) & 3) >= 2) continue\n const lit =\n variant === \"solid\" ||\n density > BAYER[y & 3][x & 3] - 0.1 * it - bias\n if (variant === \"dotted\" && !lit) continue\n // Density → opacity (see the colour-vs-opacity note in dither-paint);\n // off cells drop to a faint tier, never a hole to the background.\n const k = (0.35 + density * 0.65) * (1 + 0.22 * it)\n const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim)\n c.fillStyle = rgb(seed.fill, 1, alpha)\n c.fillRect(x, y, 1, 1)\n }\n }\n }\n\n const draw = (now: number) => {\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready || !s.pie) return\n if (bloomCtx) {\n const on = s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n popEase = 0 // a freshly-hovered slice bulges out from rest\n needsFill = true\n }\n const itTarget = s.isMouseInChart ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n // Ease the hovered slice's bulge in (and back out when nothing's hovered).\n const popTarget = s.hoverIndex != null ? 1 : 0\n if (Math.abs(popEase - popTarget) > 0.001) {\n popEase += (popTarget - popEase) * (reduce ? 1 : 0.22)\n needsFill = true\n } else popEase = popTarget\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n\n // Live tweak repaint (variant, donut inner radius) without re-sweeping.\n const paintSig = `${s.innerRadius}|${s.pie\n .map((sl) => s.variantOf(sl.name))\n .join(\",\")}`\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }, [cols, rows, width, height])\n\n const bloom = bloomLayerStyle(\n ctx.bloom,\n ctx.bloomOnHover ? ctx.isMouseInChart : true\n )\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n BAYER,\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n OFF_TIER,\n prefersReducedMotion,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\nimport { sliceAtAngle } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\nconst TOP = -Math.PI / 2\nconst TAU = Math.PI * 2\nconst POP = 6 // px the hovered slice bulges outward\n\n/**\n * Dither canvas for pie / donut charts. Each backing pixel is mapped back to\n * plot space, tested for its slice by angle, and filled with the ordered-dither\n * scatter — dense at the outer edge, thinning toward the centre — capped by a\n * bright arc on the rim. The pie sweeps in clockwise on mount; the hovered slice\n * bulges outward with a brighter rim while the rest dim.\n */\nexport function PieCanvas() {\n const ctx = usePolarChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n\n // The RAF loop reads the latest ctx through a ref; written in an effect\n // (never during render) — mutating a ref mid-render tears under Strict Mode /\n // concurrent rendering.\n const state = useRef(ctx)\n useEffect(() => {\n state.current = ctx\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n let raf = 0\n let visible = false\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let popEase = 0 // eases the hovered slice's outward bulge\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const paint = (prog: number) => {\n const s = state.current\n const slices = s.pie\n if (!slices) return\n c.clearRect(0, 0, cols, rows)\n const cx = s.center.x\n const cy = s.center.y\n const outerR = s.outerRadius\n const innerR = s.innerRadius\n const revealAngle = TOP + easeInOutCubic(prog) * TAU\n\n for (let y = 0; y < rows; y++) {\n const py = ((y + 0.5) * height) / rows\n for (let x = 0; x < cols; x++) {\n const px = ((x + 0.5) * width) / cols\n const dx = px - cx\n const dy = py - cy\n const r = Math.hypot(dx, dy)\n if (r < innerR) continue\n const angle = Math.atan2(dy, dx)\n let na = angle\n while (na < TOP) na += TAU\n while (na >= TOP + TAU) na -= TAU\n if (na > revealAngle) continue // clockwise sweep-in\n const si = sliceAtAngle(slices, angle)\n if (si < 0) continue\n const slice = slices[si]\n const active = s.hoverIndex === si\n const localOuter = active ? outerR + POP * popEase : outerR\n if (r > localOuter) continue\n\n const seed = s.seedOf(slice.name)\n const variant = s.variantOf(slice.name)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== slice.name ? 0.3 : 1\n const it = intensity + (active ? 0.4 * popEase : 0)\n\n // Bright rim on the outer edge — thicker on the hovered slice.\n if (localOuter - r < (active ? 1.4 + popEase : 1.4)) {\n c.fillStyle = rgb(seed.fill, 1, selDim)\n c.fillRect(x, y, 1, 1)\n continue\n }\n const density = (r - innerR) / Math.max(localOuter - innerR, 1)\n const bias = variant === \"dotted\" ? 0.12 : 0\n if (variant === \"hatched\" && ((x + y) & 3) >= 2) continue\n const lit =\n variant === \"solid\" ||\n density > BAYER[y & 3][x & 3] - 0.1 * it - bias\n if (variant === \"dotted\" && !lit) continue\n // Density → opacity (see the colour-vs-opacity note in dither-paint);\n // off cells drop to a faint tier, never a hole to the background.\n const k = (0.35 + density * 0.65) * (1 + 0.22 * it)\n const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim)\n c.fillStyle = rgb(seed.fill, 1, alpha)\n c.fillRect(x, y, 1, 1)\n }\n }\n }\n\n const draw = (now: number) => {\n if (!visible) {\n raf = 0\n return\n }\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready || !s.pie) return\n if (bloomCtx) {\n const on = s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n popEase = 0 // a freshly-hovered slice bulges out from rest\n needsFill = true\n }\n const itTarget = s.isMouseInChart ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n // Ease the hovered slice's bulge in (and back out when nothing's hovered).\n const popTarget = s.hoverIndex != null ? 1 : 0\n if (Math.abs(popEase - popTarget) > 0.001) {\n popEase += (popTarget - popEase) * (reduce ? 1 : 0.22)\n needsFill = true\n } else popEase = popTarget\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n\n // Live tweak repaint (variant, donut inner radius) without re-sweeping.\n const paintSig = `${s.innerRadius}|${s.pie\n .map((sl) => s.variantOf(sl.name))\n .join(\",\")}`\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n if (typeof IntersectionObserver === \"undefined\") {\n visible = true\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }\n const io = new IntersectionObserver(([entry]) => {\n visible = entry.isIntersecting\n if (visible) {\n if (!raf) raf = requestAnimationFrame(draw)\n } else if (raf) {\n cancelAnimationFrame(raf)\n raf = 0\n }\n })\n io.observe(canvas)\n return () => {\n io.disconnect()\n cancelAnimationFrame(raf)\n }\n }, [cols, rows, width, height])\n\n const bloom = bloomLayerStyle(\n ctx.bloom,\n ctx.bloomOnHover ? ctx.isMouseInChart : true\n )\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/pie.tsx", diff --git a/r/radar-chart.json b/r/radar-chart.json index 573b3b9..b06772c 100644 --- a/r/radar-chart.json +++ b/r/radar-chart.json @@ -25,7 +25,7 @@ "path": "components/dither-kit/radar-canvas.tsx", "type": "registry:component", "target": "components/dither-kit/radar-canvas.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n BAYER,\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n OFF_TIER,\n prefersReducedMotion,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\nimport { distToPolygonEdge, pointInPolygon, polarX, polarY } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\n/**\n * Dither canvas for radar charts. Each series is a closed polygon over the\n * spokes (value → radius per axis). Backing pixels inside a polygon are filled\n * with the ordered-dither scatter — dense near the polygon edge, thinning toward\n * the centre — and each vertex is marked with a bright dot (larger on the hovered\n * axis). The polygons scale in from the centre on mount.\n */\nexport function RadarCanvas() {\n const ctx = usePolarChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n\n // The RAF loop reads the latest ctx through a ref; written in an effect\n // (never during render) — mutating a ref mid-render tears under Strict Mode /\n // concurrent rendering.\n const state = useRef(ctx)\n useEffect(() => {\n state.current = ctx\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n let raf = 0\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const fx = cols / Math.max(width, 1)\n const fy = rows / Math.max(height, 1)\n\n // Build each series polygon in plot coords, scaled by `prog`.\n const buildPolys = (prog: number) => {\n const s = state.current\n const radar = s.radar\n if (!radar) return []\n return s.configKeys.map((key) => {\n const poly: number[] = []\n const pts: { x: number; y: number }[] = []\n radar.axes.forEach((ax, i) => {\n const v = Number(s.data[i]?.[key]) || 0\n const r = (v / radar.max) * s.outerRadius * prog\n const x = polarX(s.center.x, r, ax.angle)\n const y = polarY(s.center.y, r, ax.angle)\n poly.push(x, y)\n pts.push({ x, y })\n })\n return { key, poly, pts }\n })\n }\n\n const paint = (prog: number) => {\n const s = state.current\n if (!s.radar) return\n c.clearRect(0, 0, cols, rows)\n const polys = buildPolys(easeInOutCubic(prog))\n const band = Math.max(s.outerRadius * 0.45, 1)\n\n for (let y = 0; y < rows; y++) {\n const py = ((y + 0.5) * height) / rows\n for (let x = 0; x < cols; x++) {\n const px = ((x + 0.5) * width) / cols\n // Whether a layer behind already coloured this pixel — front layers\n // then keep true gaps in their dither so the back layer shows\n // through, instead of tinting the overlap into a muddy blend.\n let covered = false\n for (let pi = 0; pi < polys.length; pi++) {\n const { key, poly } = polys[pi]\n if (!pointInPolygon(px, py, poly)) continue\n const seed = s.seedOf(key)\n const variant = s.variantOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n const dist = distToPolygonEdge(px, py, poly)\n if (dist < 1.4) {\n c.fillStyle = rgb(seed.fill, 1, selDim)\n c.fillRect(x, y, 1, 1)\n covered = true\n continue\n }\n const density = 1 - Math.min(1, dist / band)\n const bias = variant === \"dotted\" ? 0.12 : 0\n // Thin each successive (front) layer so overlapping polygons\n // read as distinct layers, not a muddy blend.\n const sparse = pi * 0.2\n if (variant === \"hatched\" && ((x + y) & 3) >= 2) continue\n const lit =\n variant === \"solid\" ||\n density > BAYER[y & 3][x & 3] - 0.1 * intensity - bias + sparse\n // Unlit cells: over another layer, stay a real gap (back layer\n // shows through); over bare background, paint the faint tier so\n // the page never bleeds in.\n if (!lit && (variant === \"dotted\" || covered)) continue\n const k = (0.32 + density * 0.68) * (1 + 0.22 * intensity)\n const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim)\n c.fillStyle = rgb(seed.fill, 1, alpha)\n c.fillRect(x, y, 1, 1)\n covered = true\n }\n }\n }\n\n // Vertex markers — larger on the hovered axis.\n for (const { key, pts } of polys) {\n const seed = s.seedOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n pts.forEach((p, i) => {\n const bx = Math.round(p.x * fx)\n const by = Math.round(p.y * fy)\n const big = s.hoverIndex === i\n c.fillStyle = rgb(seed.fill, 1, selDim)\n const sz = big ? 2 : 1\n c.fillRect(bx - (sz - 1), by - (sz - 1), sz * 2 - 1, sz * 2 - 1)\n })\n }\n }\n\n const draw = (now: number) => {\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready || !s.radar) return\n if (bloomCtx) {\n const on = s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n needsFill = true\n }\n const itTarget = s.isMouseInChart ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n\n // Live tweak repaint (variant) without replaying the scale-in.\n const paintSig = s.configKeys.map((k) => s.variantOf(k)).join(\",\")\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }, [cols, rows, width, height])\n\n const bloom = bloomLayerStyle(\n ctx.bloom,\n ctx.bloomOnHover ? ctx.isMouseInChart : true\n )\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n BAYER,\n backingSize,\n bloomLayerStyle,\n easeInOutCubic,\n OFF_TIER,\n prefersReducedMotion,\n} from \"./dither-paint\"\nimport { rgb } from \"./palette\"\nimport { distToPolygonEdge, pointInPolygon, polarX, polarY } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\n/**\n * Dither canvas for radar charts. Each series is a closed polygon over the\n * spokes (value → radius per axis). Backing pixels inside a polygon are filled\n * with the ordered-dither scatter — dense near the polygon edge, thinning toward\n * the centre — and each vertex is marked with a bright dot (larger on the hovered\n * axis). The polygons scale in from the centre on mount.\n */\nexport function RadarCanvas() {\n const ctx = usePolarChart()\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n const { width, height } = ctx.plot\n const { cols, rows } = backingSize(width, height)\n\n // The RAF loop reads the latest ctx through a ref; written in an effect\n // (never during render) — mutating a ref mid-render tears under Strict Mode /\n // concurrent rendering.\n const state = useRef(ctx)\n useEffect(() => {\n state.current = ctx\n })\n\n useEffect(() => {\n const canvas = canvasRef.current\n const c = canvas?.getContext(\"2d\")\n if (!(canvas && c) || cols <= 0 || rows <= 0) return\n canvas.width = cols\n canvas.height = rows\n\n const bloomCanvas = bloomRef.current\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n }\n\n const reduce = prefersReducedMotion()\n const animate = state.current.animate && !reduce\n const duration = state.current.animationDuration\n let raf = 0\n let visible = false\n let animStart = 0\n let lastProg = -1\n let lastRevision = state.current.revision\n let intensity = 0\n let needsFill = true\n let lastPaintSig = \"\"\n let lastSelected: string | null | undefined = Symbol() as never\n let lastHover: number | null | undefined = Symbol() as never\n\n const fx = cols / Math.max(width, 1)\n const fy = rows / Math.max(height, 1)\n\n // Build each series polygon in plot coords, scaled by `prog`.\n const buildPolys = (prog: number) => {\n const s = state.current\n const radar = s.radar\n if (!radar) return []\n return s.configKeys.map((key) => {\n const poly: number[] = []\n const pts: { x: number; y: number }[] = []\n radar.axes.forEach((ax, i) => {\n const v = Number(s.data[i]?.[key]) || 0\n const r = (v / radar.max) * s.outerRadius * prog\n const x = polarX(s.center.x, r, ax.angle)\n const y = polarY(s.center.y, r, ax.angle)\n poly.push(x, y)\n pts.push({ x, y })\n })\n return { key, poly, pts }\n })\n }\n\n const paint = (prog: number) => {\n const s = state.current\n if (!s.radar) return\n c.clearRect(0, 0, cols, rows)\n const polys = buildPolys(easeInOutCubic(prog))\n const band = Math.max(s.outerRadius * 0.45, 1)\n\n for (let y = 0; y < rows; y++) {\n const py = ((y + 0.5) * height) / rows\n for (let x = 0; x < cols; x++) {\n const px = ((x + 0.5) * width) / cols\n // Whether a layer behind already coloured this pixel — front layers\n // then keep true gaps in their dither so the back layer shows\n // through, instead of tinting the overlap into a muddy blend.\n let covered = false\n for (let pi = 0; pi < polys.length; pi++) {\n const { key, poly } = polys[pi]\n if (!pointInPolygon(px, py, poly)) continue\n const seed = s.seedOf(key)\n const variant = s.variantOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n const dist = distToPolygonEdge(px, py, poly)\n if (dist < 1.4) {\n c.fillStyle = rgb(seed.fill, 1, selDim)\n c.fillRect(x, y, 1, 1)\n covered = true\n continue\n }\n const density = 1 - Math.min(1, dist / band)\n const bias = variant === \"dotted\" ? 0.12 : 0\n // Thin each successive (front) layer so overlapping polygons\n // read as distinct layers, not a muddy blend.\n const sparse = pi * 0.2\n if (variant === \"hatched\" && ((x + y) & 3) >= 2) continue\n const lit =\n variant === \"solid\" ||\n density > BAYER[y & 3][x & 3] - 0.1 * intensity - bias + sparse\n // Unlit cells: over another layer, stay a real gap (back layer\n // shows through); over bare background, paint the faint tier so\n // the page never bleeds in.\n if (!lit && (variant === \"dotted\" || covered)) continue\n const k = (0.32 + density * 0.68) * (1 + 0.22 * intensity)\n const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim)\n c.fillStyle = rgb(seed.fill, 1, alpha)\n c.fillRect(x, y, 1, 1)\n covered = true\n }\n }\n }\n\n // Vertex markers — larger on the hovered axis.\n for (const { key, pts } of polys) {\n const seed = s.seedOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n pts.forEach((p, i) => {\n const bx = Math.round(p.x * fx)\n const by = Math.round(p.y * fy)\n const big = s.hoverIndex === i\n c.fillStyle = rgb(seed.fill, 1, selDim)\n const sz = big ? 2 : 1\n c.fillRect(bx - (sz - 1), by - (sz - 1), sz * 2 - 1, sz * 2 - 1)\n })\n }\n }\n\n const draw = (now: number) => {\n if (!visible) {\n raf = 0\n return\n }\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready || !s.radar) return\n if (bloomCtx) {\n const on = s.bloom !== \"off\" && (!s.bloomOnHover || s.isMouseInChart)\n if (on) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n if (s.revision !== lastRevision) {\n lastRevision = s.revision\n animStart = 0\n lastProg = -1\n }\n if (!animStart) animStart = now\n const prog = animate ? Math.min(1, (now - animStart) / duration) : 1\n\n const emphasisNow = s.selectedDataKey ?? s.focusDataKey\n if (emphasisNow !== lastSelected) {\n lastSelected = emphasisNow\n needsFill = true\n }\n if (s.hoverIndex !== lastHover) {\n lastHover = s.hoverIndex\n needsFill = true\n }\n const itTarget = s.isMouseInChart ? 1 : 0\n if (Math.abs(intensity - itTarget) > 0.001) {\n intensity += (itTarget - intensity) * (reduce ? 1 : 0.16)\n needsFill = true\n } else intensity = itTarget\n if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n\n // Live tweak repaint (variant) without replaying the scale-in.\n const paintSig = s.configKeys.map((k) => s.variantOf(k)).join(\",\")\n if (paintSig !== lastPaintSig) {\n lastPaintSig = paintSig\n needsFill = true\n }\n\n if (!needsFill) return\n paint(prog)\n needsFill = false\n }\n\n if (typeof IntersectionObserver === \"undefined\") {\n visible = true\n raf = requestAnimationFrame(draw)\n return () => cancelAnimationFrame(raf)\n }\n const io = new IntersectionObserver(([entry]) => {\n visible = entry.isIntersecting\n if (visible) {\n if (!raf) raf = requestAnimationFrame(draw)\n } else if (raf) {\n cancelAnimationFrame(raf)\n raf = 0\n }\n })\n io.observe(canvas)\n return () => {\n io.disconnect()\n cancelAnimationFrame(raf)\n }\n }, [cols, rows, width, height])\n\n const bloom = bloomLayerStyle(\n ctx.bloom,\n ctx.bloomOnHover ? ctx.isMouseInChart : true\n )\n const pos = {\n left: ctx.margins.left,\n top: ctx.margins.top,\n width,\n height,\n } as const\n\n return (\n <>\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/radar.tsx", diff --git a/registry/dither-kit/bar-canvas.tsx b/registry/dither-kit/bar-canvas.tsx index 145175c..f21cd3e 100644 --- a/registry/dither-kit/bar-canvas.tsx +++ b/registry/dither-kit/bar-canvas.tsx @@ -128,6 +128,7 @@ export function BarCanvas() { } let raf = 0 + let visible = false let animStart = 0 let lastProg = -1 let lastRevision = state.current.revision @@ -138,6 +139,10 @@ export function BarCanvas() { let lastHover: number | null | undefined = Symbol() as never const draw = (now: number) => { + if (!visible) { + raf = 0 + return + } raf = requestAnimationFrame(draw) const s = state.current if (!s.ready) return @@ -191,8 +196,25 @@ export function BarCanvas() { needsFill = false } - raf = requestAnimationFrame(draw) - return () => cancelAnimationFrame(raf) + if (typeof IntersectionObserver === "undefined") { + visible = true + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + } + const io = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting + if (visible) { + if (!raf) raf = requestAnimationFrame(draw) + } else if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + }) + io.observe(canvas) + return () => { + io.disconnect() + cancelAnimationFrame(raf) + } }, [cols, rows, width]) const bloomActive = ctx.bloomOnHover diff --git a/registry/dither-kit/cartesian-canvas.tsx b/registry/dither-kit/cartesian-canvas.tsx index eca31ef..0be5e49 100644 --- a/registry/dither-kit/cartesian-canvas.tsx +++ b/registry/dither-kit/cartesian-canvas.tsx @@ -103,6 +103,7 @@ function startCartesianLoop({ } let raf = 0 + let visible = false let tick = 0 let last = 0 let animStart = 0 @@ -115,6 +116,10 @@ function startCartesianLoop({ let lastSelected: string | null | undefined = Symbol() as never const draw = (now: number) => { + if (!visible) { + raf = 0 + return + } raf = requestAnimationFrame(draw) const s = state.current if (!s.ready) return @@ -282,8 +287,25 @@ function startCartesianLoop({ } } - raf = requestAnimationFrame(draw) - return () => cancelAnimationFrame(raf) + if (typeof IntersectionObserver === "undefined") { + visible = true + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + } + const io = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting + if (visible) { + if (!raf) raf = requestAnimationFrame(draw) + } else if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + }) + io.observe(canvas) + return () => { + io.disconnect() + cancelAnimationFrame(raf) + } } /** diff --git a/registry/dither-kit/pie-canvas.tsx b/registry/dither-kit/pie-canvas.tsx index 10e0549..7f8eb63 100644 --- a/registry/dither-kit/pie-canvas.tsx +++ b/registry/dither-kit/pie-canvas.tsx @@ -58,6 +58,7 @@ export function PieCanvas() { const animate = state.current.animate && !reduce const duration = state.current.animationDuration let raf = 0 + let visible = false let animStart = 0 let lastProg = -1 let lastRevision = state.current.revision @@ -129,6 +130,10 @@ export function PieCanvas() { } const draw = (now: number) => { + if (!visible) { + raf = 0 + return + } raf = requestAnimationFrame(draw) const s = state.current if (!s.ready || !s.pie) return @@ -187,8 +192,25 @@ export function PieCanvas() { needsFill = false } - raf = requestAnimationFrame(draw) - return () => cancelAnimationFrame(raf) + if (typeof IntersectionObserver === "undefined") { + visible = true + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + } + const io = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting + if (visible) { + if (!raf) raf = requestAnimationFrame(draw) + } else if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + }) + io.observe(canvas) + return () => { + io.disconnect() + cancelAnimationFrame(raf) + } }, [cols, rows, width, height]) const bloom = bloomLayerStyle( diff --git a/registry/dither-kit/radar-canvas.tsx b/registry/dither-kit/radar-canvas.tsx index 58d40b5..3eba410 100644 --- a/registry/dither-kit/radar-canvas.tsx +++ b/registry/dither-kit/radar-canvas.tsx @@ -54,6 +54,7 @@ export function RadarCanvas() { const animate = state.current.animate && !reduce const duration = state.current.animationDuration let raf = 0 + let visible = false let animStart = 0 let lastProg = -1 let lastRevision = state.current.revision @@ -154,6 +155,10 @@ export function RadarCanvas() { } const draw = (now: number) => { + if (!visible) { + raf = 0 + return + } raf = requestAnimationFrame(draw) const s = state.current if (!s.ready || !s.radar) return @@ -203,8 +208,25 @@ export function RadarCanvas() { needsFill = false } - raf = requestAnimationFrame(draw) - return () => cancelAnimationFrame(raf) + if (typeof IntersectionObserver === "undefined") { + visible = true + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + } + const io = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting + if (visible) { + if (!raf) raf = requestAnimationFrame(draw) + } else if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + }) + io.observe(canvas) + return () => { + io.disconnect() + cancelAnimationFrame(raf) + } }, [cols, rows, width, height]) const bloom = bloomLayerStyle( diff --git a/tests/offscreen.test.tsx b/tests/offscreen.test.tsx new file mode 100644 index 0000000..632de84 --- /dev/null +++ b/tests/offscreen.test.tsx @@ -0,0 +1,100 @@ +import { render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("../registry/dither-kit/use-chart-dimensions", () => ({ + useChartDimensions: () => ({ + ref: { current: null }, + size: { width: 600, height: 240 }, + }), +})) + +import { Area } from "../registry/dither-kit/area" +import { AreaChart } from "../registry/dither-kit/area-chart" + +const data = [ + { m: "Jan", v: 1 }, + { m: "Feb", v: 2 }, +] +const config = { v: { label: "V", color: "blue" as const } } + +type IOInstance = { + cb: (entries: Array<{ isIntersecting: boolean }>) => void + targets: Element[] +} +let observers: IOInstance[] = [] + +describe("offscreen render pause", () => { + const orig: Record = {} + let rafSpy: ReturnType + let cafSpy: ReturnType + + beforeEach(() => { + observers = [] + orig.IO = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver + orig.RO = (globalThis as { ResizeObserver?: unknown }).ResizeObserver + orig.getContext = HTMLCanvasElement.prototype.getContext + ;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + class { + cb: IOInstance["cb"] + targets: Element[] = [] + constructor(cb: IOInstance["cb"]) { + this.cb = cb + observers.push(this) + } + observe(el: Element) { + this.targets.push(el) + } + unobserve() {} + disconnect() {} + } + ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + HTMLCanvasElement.prototype.getContext = (() => ({})) as unknown as typeof HTMLCanvasElement.prototype.getContext + if (typeof globalThis.requestAnimationFrame === "undefined") { + globalThis.requestAnimationFrame = (() => 1) as typeof requestAnimationFrame + } + if (typeof globalThis.cancelAnimationFrame === "undefined") { + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + } + rafSpy = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockReturnValue(1 as unknown as number) + cafSpy = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {}) + }) + + afterEach(() => { + rafSpy.mockRestore() + cafSpy.mockRestore() + ;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = orig.IO + ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = orig.RO + HTMLCanvasElement.prototype.getContext = + orig.getContext as typeof HTMLCanvasElement.prototype.getContext + }) + + it("watches the canvas but doesn't start the loop until it is visible", () => { + render( + + + + ) + expect(observers.length).toBeGreaterThan(0) + expect(observers[0].targets.length).toBeGreaterThan(0) + expect(rafSpy).not.toHaveBeenCalled() + }) + + it("starts on intersect and stops when it leaves the viewport", () => { + render( + + + + ) + const io = observers[0] + io.cb([{ isIntersecting: true }]) + expect(rafSpy).toHaveBeenCalledTimes(1) + io.cb([{ isIntersecting: false }]) + expect(cafSpy).toHaveBeenCalled() + }) +})