{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "pie-chart", "title": "Pie Chart", "description": "A hand-drawn pie or donut chart with hachure-filled slices and d3-shape arc math.", "dependencies": [ "roughjs", "d3-shape" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/pie-chart.tsx", "content": "\"use client\";\n\n/**\n * Dependencies: roughjs (already installed), d3-shape, d3-scale\n * npm install d3-shape d3-scale\n */\n\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n} from \"react\";\nimport { arc as d3Arc, pie as d3Pie, type PieArcDatum } from \"d3-shape\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\nexport interface PieChartSlice {\n color?: string;\n label: string;\n value: number;\n}\n\nexport interface PieChartProps\n extends HTMLAttributes, CrumbleColorProps {\n animateOnMount?: boolean;\n data: PieChartSlice[];\n donut?: boolean;\n formatValue?: (v: number, total: number) => string;\n height?: number;\n id?: string;\n showLabels?: boolean;\n showLegend?: boolean;\n theme?: CrumbleTheme;\n}\n\n// Default sketch-friendly palette — hand-drawn feel, not too saturated\nconst DEFAULT_COLORS = [\n \"oklch(0.65 0.18 260)\", // blue\n \"oklch(0.62 0.16 145)\", // green\n \"oklch(0.68 0.18 55)\", // amber\n \"oklch(0.63 0.22 25)\", // coral\n \"oklch(0.60 0.16 300)\", // purple\n \"oklch(0.64 0.14 185)\", // teal\n];\n\nexport function PieChart({\n animateOnMount = true,\n className,\n data,\n donut = false,\n fill,\n formatValue = (v, t) => `${Math.round((v / t) * 100)}%`,\n height = 260,\n id,\n showLabels = true,\n showLegend = true,\n stroke,\n strokeMuted,\n theme: themeProp,\n ...props\n}: PieChartProps) {\n const containerRef = useRef(null);\n const [hoveredIndex, setHoveredIndex] = useState(null);\n const chartId = id ?? \"pie-chart\";\n const { drawPath, svgRef, theme } = useRough({\n variant: \"fill\",\n stableId: chartId,\n theme: themeProp,\n });\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n\n const total = data.reduce((s, d) => s + d.value, 0);\n\n const draw = useCallback(() => {\n const container = containerRef.current;\n const svg = svgRef.current;\n if (!container || !svg || data.length === 0 || total === 0) return;\n\n svg.replaceChildren();\n\n const W = container.offsetWidth;\n const legendH = showLegend ? Math.ceil(data.length / 3) * 24 + 8 : 0;\n const chartH = height - legendH;\n const cx = W / 2;\n const cy = chartH / 2;\n const outerR = Math.min(cx, cy) - 16;\n const innerR = donut ? outerR * 0.5 : 0;\n\n svg.setAttribute(\"width\", String(W));\n svg.setAttribute(\"height\", String(height));\n svg.setAttribute(\"viewBox\", `0 0 ${W} ${height}`);\n\n // d3 pie layout — computes start/end angles from values\n const pieLayout = d3Pie()\n .value((d) => d.value)\n .sort(null)\n .padAngle(theme === \"crayon\" ? 0.04 : theme === \"ink\" ? 0.02 : 0.03);\n\n const arcs = pieLayout(data);\n\n // d3 arc path generator\n const arcGen = d3Arc>()\n .innerRadius(innerR)\n .outerRadius(outerR);\n\n // Label arc — slightly outside the pie\n const labelArc = d3Arc>()\n .innerRadius(outerR * 0.75)\n .outerRadius(outerR * 0.75);\n\n arcs.forEach((arcDatum, i) => {\n const color =\n arcDatum.data.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length];\n const isHovered = hoveredIndex === i;\n\n // Slightly explode hovered slice\n const offset = isHovered ? 6 : 0;\n const midAngle = (arcDatum.startAngle + arcDatum.endAngle) / 2;\n const dx = Math.sin(midAngle) * offset;\n const dy = -Math.cos(midAngle) * offset;\n\n // d3 generates the path string — roughjs wobbles it\n const pathD = arcGen(arcDatum);\n if (!pathD) return;\n\n const sliceNode = drawPath(pathD, {\n fill: color,\n fillStyle: theme === \"ink\" ? \"solid\" : \"hachure\",\n fillWeight: theme === \"pencil\" ? 0.9 : 1.2,\n hachureAngle: -41 + i * 15, // different hachure angle per slice\n hachureGap: theme === \"crayon\" ? 4 : 6,\n seed: stableSeed(`${chartId}-slice-${i}`),\n stroke: color,\n strokeWidth: theme === \"crayon\" ? 2 : theme === \"ink\" ? 1.5 : 1,\n }) as SVGGElement | null;\n if (!sliceNode) return;\n\n const group = document.createElementNS(\"http://www.w3.org/2000/svg\", \"g\");\n group.setAttribute(\"transform\", `translate(${cx + dx}, ${cy + dy})`);\n\n // Animate on mount: fade + scale in staggered\n if (animateOnMount) {\n group.style.opacity = \"0\";\n group.style.transform = `translate(${cx + dx}px, ${cy + dy}px) scale(0.85)`;\n group.style.transformOrigin = `${cx + dx}px ${cy + dy}px`;\n group.style.transition = `opacity 350ms ease ${i * 60}ms, transform 350ms cubic-bezier(0.16,1,0.3,1) ${i * 60}ms`;\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n group.style.opacity = \"1\";\n group.style.transform = `translate(${cx + dx}px, ${cy + dy}px) scale(1)`;\n }),\n );\n }\n\n group.appendChild(sliceNode);\n\n // Hit area for hover\n const hitPath = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"path\",\n );\n hitPath.setAttribute(\"d\", pathD);\n hitPath.setAttribute(\"fill\", \"transparent\");\n hitPath.setAttribute(\"stroke\", \"none\");\n hitPath.style.cursor = \"pointer\";\n hitPath.addEventListener(\"mouseenter\", () => setHoveredIndex(i));\n hitPath.addEventListener(\"mouseleave\", () => setHoveredIndex(null));\n group.appendChild(hitPath);\n\n svg.appendChild(group);\n\n // Slice label\n if (showLabels && arcDatum.endAngle - arcDatum.startAngle > 0.4) {\n const [lx, ly] = labelArc.centroid(arcDatum);\n const t = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"text\",\n );\n t.setAttribute(\"x\", String(cx + lx));\n t.setAttribute(\"y\", String(cy + ly));\n t.setAttribute(\"text-anchor\", \"middle\");\n t.setAttribute(\"dominant-baseline\", \"central\");\n t.setAttribute(\"fill\", \"white\");\n t.setAttribute(\"font-size\", \"11\");\n t.setAttribute(\"font-weight\", \"500\");\n t.setAttribute(\"pointer-events\", \"none\");\n t.textContent = formatValue(arcDatum.data.value, total);\n svg.appendChild(t);\n }\n });\n\n // Center label for donut\n if (donut) {\n const ct = document.createElementNS(\"http://www.w3.org/2000/svg\", \"text\");\n ct.setAttribute(\"x\", String(cx));\n ct.setAttribute(\"y\", String(cy - 6));\n ct.setAttribute(\"text-anchor\", \"middle\");\n ct.setAttribute(\"fill\", \"currentColor\");\n ct.setAttribute(\"font-size\", \"22\");\n ct.setAttribute(\"font-weight\", \"600\");\n ct.textContent = String(total);\n svg.appendChild(ct);\n\n const cl = document.createElementNS(\"http://www.w3.org/2000/svg\", \"text\");\n cl.setAttribute(\"x\", String(cx));\n cl.setAttribute(\"y\", String(cy + 14));\n cl.setAttribute(\"text-anchor\", \"middle\");\n cl.setAttribute(\"fill\", \"currentColor\");\n cl.setAttribute(\"font-size\", \"11\");\n cl.setAttribute(\"opacity\", \"0.5\");\n cl.textContent = \"total\";\n svg.appendChild(cl);\n }\n\n // Legend\n if (showLegend) {\n const perRow = 3;\n const cellW = W / perRow;\n data.forEach((d, i) => {\n const col = i % perRow;\n const row = Math.floor(i / perRow);\n const lx = col * cellW + 12;\n const ly = chartH + row * 24 + 12;\n const color = d.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length];\n\n // Color dot\n const dot = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"circle\",\n );\n dot.setAttribute(\"cx\", String(lx + 5));\n dot.setAttribute(\"cy\", String(ly + 5));\n dot.setAttribute(\"r\", \"5\");\n dot.setAttribute(\"fill\", color);\n dot.setAttribute(\"opacity\", \"0.85\");\n svg.appendChild(dot);\n\n const lt = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"text\",\n );\n lt.setAttribute(\"x\", String(lx + 14));\n lt.setAttribute(\"y\", String(ly + 9));\n lt.setAttribute(\"fill\", \"currentColor\");\n lt.setAttribute(\"font-size\", \"11\");\n lt.setAttribute(\"opacity\", \"0.65\");\n lt.textContent = d.label;\n svg.appendChild(lt);\n });\n }\n }, [\n animateOnMount,\n chartId,\n data,\n donut,\n drawPath,\n formatValue,\n height,\n hoveredIndex,\n showLabels,\n showLegend,\n svgRef,\n theme,\n total,\n ]);\n\n useEffect(() => {\n const rid = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(rid);\n }, [draw]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n const ro = new ResizeObserver(() => draw());\n ro.observe(container);\n return () => ro.disconnect();\n }, [draw]);\n\n return (\n \n \n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/pie-chart.tsx" } ], "type": "registry:ui" }