{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "area-chart", "type": "registry:component", "title": "Dither Area & Line Chart", "description": "Composable dithered area + line charts — children-as-config API with the ordered-dither fill, winking sparkles, a gliding scrub tooltip, selection, and colour bloom. Includes Sparkline. Inspired by Evil Charts (evilcharts.com).", "author": "ripgrim", "categories": [ "charts" ], "version": "0.1.0", "dependencies": [], "devDependencies": [], "registryDependencies": [ "https://tripwire.sh/r/core.json" ], "files": [ { "path": "components/dither-kit/area-chart.tsx", "type": "registry:component", "target": "components/dither-kit/area-chart.tsx", "content": "\"use client\"\n\nimport { CartesianCanvas } from \"./cartesian-canvas\"\nimport { type CartesianChartProps, CartesianRoot } from \"./cartesian-root\"\n\n// `object` rather than `Record`: interfaces don't get an\n// implicit index signature, so interface-typed rows failed to satisfy the\n// generic. Internal layers still index rows through their own Row type.\ntype Row = object\n\n/** Composable dither **area** chart. Compose ``, ``, axes, … inside. */\nexport function AreaChart(\n props: CartesianChartProps\n) {\n return \n}\n\n/** Composable dither **line** chart — `` series with a glow under the line. */\nexport function LineChart(\n props: CartesianChartProps\n) {\n return \n}\n\nexport type AreaChartProps = CartesianChartProps\n" }, { "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" }, { "path": "components/dither-kit/area.tsx", "type": "registry:component", "target": "components/dither-kit/area.tsx", "content": "\"use client\"\n\nimport { type ReactNode, useEffect } from \"react\"\nimport {\n type AreaVariant,\n type SeriesKind,\n type StrokeVariant,\n useChartPart,\n} from \"./chart-context\"\nimport { SeriesContext } from \"./series-context\"\n\nexport type SeriesProps = {\n dataKey: string\n variant?: AreaVariant\n strokeVariant?: StrokeVariant\n isClickable?: boolean\n children?: ReactNode\n}\n\n/**\n * Shared implementation for the continuous series (``, ``). The\n * dithered fill/line is painted on the canvas; this registers the series so the\n * canvas knows how to draw it, wires click-to-select via a transparent band\n * polygon, and exposes the series to child ``/`` markers.\n */\nfunction CartesianSeries({\n part,\n kind,\n dataKey,\n variant = \"gradient\",\n strokeVariant = \"solid\",\n isClickable = false,\n children,\n}: SeriesProps & { part: string; kind: SeriesKind }) {\n const ctx = useChartPart(part, kind === \"line\" ? \"line\" : \"area\")\n const { registerSeries, unregisterSeries } = ctx\n\n if (process.env.NODE_ENV !== \"production\" && !ctx.config[dataKey]) {\n console.warn(\n `<${part} dataKey=\"${dataKey}\" />: \"${dataKey}\" is not in the chart \\`config\\`. Add it so the series has a colour and label.`\n )\n }\n\n useEffect(() => {\n registerSeries({ dataKey, kind, variant, strokeVariant })\n return () => unregisterSeries(dataKey)\n }, [dataKey, kind, variant, strokeVariant, registerSeries, unregisterSeries])\n\n const band = ctx.bands[dataKey]\n if (!ctx.ready || !band) return null\n\n const seed = ctx.seedOf(dataKey)\n const emphasis = ctx.selectedDataKey ?? ctx.focusDataKey\n const dimmed = emphasis !== null && emphasis !== dataKey\n const onClick = isClickable\n ? () => ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey)\n : undefined\n\n // Transparent hit polygon tracing the series' own band, so clicking a series\n // selects *that* series. The Legend offers the same toggle accessibly.\n // One pass out along the top edge, one pass back along the floor.\n let hitPath: string | null = null\n if (isClickable) {\n const parts: string[] = []\n band.forEach((b, i) => {\n parts.push(`${i === 0 ? \"M\" : \"L\"}${ctx.xCenter(i)},${ctx.y(b[1])}`)\n })\n for (let i = band.length - 1; i >= 0; i -= 1) {\n parts.push(`L${ctx.xCenter(i)},${ctx.y(band[i][0])}`)\n }\n hitPath = `${parts.join(\" \")} Z`\n }\n\n return (\n <>\n {hitPath && (\n // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly\n \n )}\n \n {children}\n \n \n )\n}\n\nexport type AreaProps = SeriesProps\n\n/** One area series — dithered fill from the value line down to its floor. */\nexport function Area(props: AreaProps) {\n return \n}\n\n/** One line series — bright line with a thin dither glow hugging it. */\nexport function Line(props: AreaProps) {\n return \n}\n" }, { "path": "components/dither-kit/sparkline.tsx", "type": "registry:component", "target": "components/dither-kit/sparkline.tsx", "content": "\"use client\"\n\nimport { useMemo } from \"react\"\nimport { Area } from \"./area\"\nimport { AreaChart } from \"./area-chart\"\nimport type { AreaVariant } from \"./chart-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport type { DitherColor } from \"./palette\"\n\nexport type SparklineProps = {\n /** Plain numeric series — the common sparkline case. */\n data: number[]\n color: DitherColor\n variant?: AreaVariant\n /** Controlled crosshair position (e.g. a committed point). */\n markerIndex?: number | null\n /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */\n hovered?: boolean\n /** Glow on the dither fill. */\n bloom?: BloomInput\n /** Only bloom while hovered. */\n bloomOnHover?: boolean\n /** Play the entrance sweep — off by default for a calm spark. */\n animate?: boolean\n className?: string\n}\n\n/**\n * Thin wrapper over {@link AreaChart} for the decorative-sparkline case: a\n * single `number[]` series, no axes/grid/tooltip, no scrub crosshair (unless a\n * `markerIndex` is supplied). Keeps the hover brightness lift.\n */\nexport function Sparkline({\n data,\n color,\n variant = \"gradient\",\n markerIndex = null,\n hovered = false,\n bloom = \"off\",\n bloomOnHover = false,\n animate = false,\n className,\n}: SparklineProps) {\n // Memoized explicitly so the chart works without React Compiler: `rows`\n // identity drives the entrance-replay revision, so a fresh array every\n // render would re-trigger the revision's state adjustment each pass.\n const rows = useMemo(() => data.map((v) => ({ v })), [data])\n const config = useMemo(() => ({ v: { color } }), [color])\n\n return (\n \n \n \n )\n}\n" } ] }