{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "pie-chart", "type": "registry:component", "title": "Dither Pie / Donut Chart", "description": "Composable dithered pie / donut chart — per-pixel radial dither, clockwise sweep-in, slice hover-pop, 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/pie-chart.tsx", "type": "registry:component", "target": "components/dither-kit/pie-chart.tsx", "content": "\"use client\"\n\nimport type { ReactNode } from \"react\"\nimport type { ChartConfig, Margins } from \"./chart-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport { PieCanvas } from \"./pie-canvas\"\nimport { PolarRoot } from \"./polar-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\nexport type PieChartProps = {\n data: TData[]\n config: ChartConfig\n children: ReactNode\n dataKey: string // value field\n nameKey: string // slice-name field (looked up in config for colour)\n innerRadius?: number // 0–1 ratio for a donut\n margins?: Partial\n className?: string\n animate?: boolean\n animationDuration?: number\n replayToken?: number\n bloom?: BloomInput\n bloomOnHover?: boolean\n defaultSelectedDataKey?: string | null\n onSelectionChange?: (key: string | null) => void\n}\n\n/** Composable dither **pie / donut** chart. Compose ``, ``, … inside. */\nexport function PieChart(props: PieChartProps) {\n return \n}\n" }, { "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" }, { "path": "components/dither-kit/pie.tsx", "type": "registry:component", "target": "components/dither-kit/pie.tsx", "content": "\"use client\"\n\nimport { useEffect } from \"react\"\nimport type { AreaVariant } from \"./chart-context\"\nimport { usePolarPart } from \"./polar-context\"\n\nexport type PieProps = {\n /** Fill texture applied to every slice. */\n variant?: AreaVariant\n}\n\n/**\n * The pie/donut ring. Slices come from the chart `data` (one per row); this part\n * sets the shared fill variant. The dithered wedges are painted on the canvas.\n */\nexport function Pie({ variant = \"gradient\" }: PieProps) {\n const ctx = usePolarPart(\"Pie\", \"pie\")\n const { registerVariant, unregisterVariant } = ctx\n\n useEffect(() => {\n registerVariant(\"*\", variant)\n return () => unregisterVariant(\"*\")\n }, [variant, registerVariant, unregisterVariant])\n\n return null\n}\n" } ] }