{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "line-chart", "title": "Line Chart", "description": "A hand-drawn line chart with Catmull-Rom curves and animated draw-on.", "dependencies": [ "roughjs" ], "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/line-chart.tsx", "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, type HTMLAttributes } from \"react\";\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 LineChartSeries {\n color?: string;\n data: number[];\n id: string;\n label: string;\n}\n\nexport interface LineChartProps\n extends HTMLAttributes, CrumbleColorProps {\n animateOnMount?: boolean;\n formatValue?: (v: number) => string;\n formatX?: (i: number) => string;\n height?: number;\n id?: string;\n labels?: string[];\n series: LineChartSeries[];\n showDots?: boolean;\n showGrid?: boolean;\n theme?: CrumbleTheme;\n}\n\nconst PAD = { top: 20, right: 20, bottom: 40, left: 48 };\nconst GRID_LINES = 4;\n\n// Catmull-Rom to cubic bezier for smooth rough lines\nfunction catmullRomPath(points: [number, number][]): string {\n if (points.length < 2) return \"\";\n let d = `M ${points[0][0]} ${points[0][1]}`;\n for (let i = 0; i < points.length - 1; i++) {\n const p0 = points[Math.max(0, i - 1)];\n const p1 = points[i];\n const p2 = points[i + 1];\n const p3 = points[Math.min(points.length - 1, i + 2)];\n const cp1x = p1[0] + (p2[0] - p0[0]) / 6;\n const cp1y = p1[1] + (p2[1] - p0[1]) / 6;\n const cp2x = p2[0] - (p3[0] - p1[0]) / 6;\n const cp2y = p2[1] - (p3[1] - p1[1]) / 6;\n d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;\n }\n return d;\n}\n\nexport function LineChart({\n animateOnMount = true,\n className,\n fill,\n formatValue = (v) => String(v),\n formatX,\n height = 240,\n id,\n labels,\n series,\n showDots = true,\n showGrid = true,\n stroke,\n strokeMuted,\n theme: themeProp,\n ...props\n}: LineChartProps) {\n const containerRef = useRef(null);\n const chartId = id ?? \"line-chart\";\n const { drawCircle, drawLine, drawPath, svgRef, theme } = useRough({\n variant: \"chart\",\n stableId: chartId,\n theme: themeProp,\n });\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n\n const scale = (\n v: number,\n dMin: number,\n dMax: number,\n rMin: number,\n rMax: number,\n ) =>\n dMax === dMin ? rMin : rMin + ((v - dMin) / (dMax - dMin)) * (rMax - rMin);\n\n const draw = useCallback(() => {\n const container = containerRef.current;\n const svg = svgRef.current;\n if (!container || !svg || series.length === 0) return;\n\n svg.replaceChildren();\n\n const W = container.offsetWidth;\n const H = height;\n svg.setAttribute(\"width\", String(W));\n svg.setAttribute(\"height\", String(H));\n svg.setAttribute(\"viewBox\", `0 0 ${W} ${H}`);\n\n const allValues = series.flatMap((s) => s.data);\n const maxVal = Math.max(...allValues, 0);\n const minVal = Math.min(...allValues, 0);\n const niceMax = Math.ceil(maxVal / GRID_LINES) * GRID_LINES || 1;\n const niceMin =\n minVal < 0 ? Math.floor(minVal / GRID_LINES) * GRID_LINES : 0;\n\n const plotW = W - PAD.left - PAD.right;\n const plotH = H - PAD.top - PAD.bottom;\n const maxLen = Math.max(...series.map((s) => s.data.length));\n\n // Grid + Y labels\n if (showGrid) {\n for (let i = 0; i <= GRID_LINES; i++) {\n const val = niceMin + ((niceMax - niceMin) / GRID_LINES) * i;\n const y = PAD.top + plotH - scale(val, niceMin, niceMax, 0, plotH);\n const gridLine = drawLine(PAD.left, y, PAD.left + plotW, y, {\n seed: stableSeed(`${chartId}-grid-${i}`),\n stroke: \"var(--cr-stroke-muted)\",\n strokeWidth: theme === \"crayon\" ? 1.5 : 0.8,\n });\n if (gridLine) svg.appendChild(gridLine);\n const t = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"text\",\n );\n t.setAttribute(\"x\", String(PAD.left - 8));\n t.setAttribute(\"y\", String(y + 4));\n t.setAttribute(\"text-anchor\", \"end\");\n t.setAttribute(\"fill\", \"currentColor\");\n t.setAttribute(\"font-size\", \"11\");\n t.setAttribute(\"opacity\", \"0.5\");\n t.textContent = formatValue(val);\n svg.appendChild(t);\n }\n }\n\n // Axes\n const yAxis = drawLine(PAD.left, PAD.top, PAD.left, PAD.top + plotH, {\n seed: stableSeed(`${chartId}-yaxis`),\n stroke: \"var(--cr-stroke-muted)\",\n strokeWidth: theme === \"crayon\" ? 1.5 : 0.8,\n });\n const xAxis = drawLine(\n PAD.left,\n PAD.top + plotH,\n PAD.left + plotW,\n PAD.top + plotH,\n {\n seed: stableSeed(`${chartId}-xaxis`),\n stroke: \"var(--cr-stroke-muted)\",\n strokeWidth: theme === \"crayon\" ? 1.5 : 0.8,\n },\n );\n if (yAxis) svg.appendChild(yAxis);\n if (xAxis) svg.appendChild(xAxis);\n\n // X labels\n for (let i = 0; i < maxLen; i++) {\n const x = PAD.left + scale(i, 0, maxLen - 1, 0, plotW);\n const lbl = formatX ? formatX(i) : (labels?.[i] ?? String(i + 1));\n const t = document.createElementNS(\"http://www.w3.org/2000/svg\", \"text\");\n t.setAttribute(\"x\", String(x));\n t.setAttribute(\"y\", String(PAD.top + plotH + 16));\n t.setAttribute(\"text-anchor\", \"middle\");\n t.setAttribute(\"fill\", \"currentColor\");\n t.setAttribute(\"font-size\", \"11\");\n t.setAttribute(\"opacity\", \"0.5\");\n t.textContent = lbl;\n svg.appendChild(t);\n }\n\n // Series lines\n series.forEach((s, si) => {\n const color = s.color ?? \"currentColor\";\n const pts: [number, number][] = s.data.map((v, i) => [\n PAD.left + scale(i, 0, maxLen - 1, 0, plotW),\n PAD.top + plotH - scale(v, niceMin, niceMax, 0, plotH),\n ]);\n\n const pathD = catmullRomPath(pts);\n\n const lineNode = drawPath(pathD, {\n fill: \"none\",\n seed: stableSeed(`${chartId}-line-${si}`),\n stroke: color,\n strokeWidth: theme === \"crayon\" ? 2.5 : theme === \"ink\" ? 2 : 1.5,\n }) as SVGGElement | null;\n if (!lineNode) return;\n\n if (animateOnMount) {\n lineNode.querySelectorAll(\"path\").forEach((p) => {\n const len = p.getTotalLength();\n p.style.strokeDasharray = String(len);\n p.style.strokeDashoffset = String(len);\n const dur = theme === \"crayon\" ? 900 : theme === \"ink\" ? 500 : 700;\n p.style.transition = `stroke-dashoffset ${dur}ms cubic-bezier(0.16,1,0.3,1) ${si * 100}ms`;\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n p.style.strokeDashoffset = \"0\";\n }),\n );\n });\n }\n\n svg.appendChild(lineNode);\n\n // Dots at data points\n if (showDots) {\n pts.forEach((pt, i) => {\n const dotNode = drawCircle(pt[0], pt[1], 7, {\n fill: color,\n fillStyle: \"solid\",\n seed: stableSeed(`${chartId}-dot-${si}-${i}`),\n stroke: color,\n strokeWidth: 0.5,\n });\n if (dotNode) svg.appendChild(dotNode);\n });\n }\n });\n\n // Legend\n if (series.length > 1) {\n series.forEach((s, i) => {\n const lx = PAD.left + i * 100;\n const ly = H - 8;\n const color = s.color ?? \"currentColor\";\n const line = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"line\",\n );\n line.setAttribute(\"x1\", String(lx));\n line.setAttribute(\"y1\", String(ly));\n line.setAttribute(\"x2\", String(lx + 16));\n line.setAttribute(\"y2\", String(ly));\n line.setAttribute(\"stroke\", color);\n line.setAttribute(\"stroke-width\", \"2\");\n svg.appendChild(line);\n const t = document.createElementNS(\n \"http://www.w3.org/2000/svg\",\n \"text\",\n );\n t.setAttribute(\"x\", String(lx + 20));\n t.setAttribute(\"y\", String(ly + 4));\n t.setAttribute(\"fill\", \"currentColor\");\n t.setAttribute(\"font-size\", \"11\");\n t.setAttribute(\"opacity\", \"0.6\");\n t.textContent = s.label;\n svg.appendChild(t);\n });\n }\n }, [\n animateOnMount,\n chartId,\n drawCircle,\n drawLine,\n drawPath,\n formatValue,\n formatX,\n height,\n labels,\n series,\n showDots,\n showGrid,\n svgRef,\n theme,\n ]);\n\n useEffect(() => {\n const id = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(id);\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/line-chart.tsx" } ], "type": "registry:ui" }