{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "bar-chart", "type": "registry:component", "title": "Dither Bar Chart", "description": "Composable dithered bar chart — grouped or stacked, with a staggered grow-in wave, the ordered-dither fill, scrub tooltip, selection, and colour bloom. 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/bar-chart.tsx", "type": "registry:component", "target": "components/dither-kit/bar-chart.tsx", "content": "\"use client\"\n\nimport { BarCanvas } from \"./bar-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 **bar** chart — `` series, grouped or stacked. */\nexport function BarChart(props: CartesianChartProps) {\n return \n}\n" }, { "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" }, { "path": "components/dither-kit/bar.tsx", "type": "registry:component", "target": "components/dither-kit/bar.tsx", "content": "\"use client\"\n\nimport { type ReactNode, useEffect } from \"react\"\nimport {\n type AreaVariant,\n type StrokeVariant,\n useChartPart,\n} from \"./chart-context\"\nimport { SeriesContext } from \"./series-context\"\n\nexport type BarProps = {\n dataKey: string\n variant?: AreaVariant\n strokeVariant?: StrokeVariant\n isClickable?: boolean\n children?: ReactNode\n}\n\n/**\n * One bar series. The dithered bars are painted on the canvas; this registers\n * the series and (when `isClickable`) lays transparent hit rects over each bar\n * — using the shared `barSlot` geometry so clicks line up with the pixels — to\n * select the series. The Legend offers the same toggle accessibly.\n */\nexport function Bar({\n dataKey,\n variant = \"gradient\",\n strokeVariant = \"solid\",\n isClickable = false,\n children,\n}: BarProps) {\n const ctx = useChartPart(\"Bar\", \"bar\")\n const { registerSeries, unregisterSeries } = ctx\n\n if (process.env.NODE_ENV !== \"production\" && !ctx.config[dataKey]) {\n console.warn(\n `: \"${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: \"bar\", variant, strokeVariant })\n return () => unregisterSeries(dataKey)\n }, [dataKey, 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 dimmed = ctx.selectedDataKey !== null && ctx.selectedDataKey !== dataKey\n const si = ctx.configKeys.indexOf(dataKey)\n const n = ctx.configKeys.length\n const onClick = () =>\n ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey)\n\n return (\n <>\n {isClickable &&\n band.map((b, i) => {\n const slot = ctx.barSlot(i, si, n)\n const top = ctx.y(b[1])\n const base = ctx.y(b[0])\n return (\n // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly\n \n )\n })}\n \n {children}\n \n \n )\n}\n" } ] }