{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "radar-chart", "type": "registry:component", "title": "Dither Radar Chart", "description": "Composable dithered radar chart — polygon-membership dither, scale-in entrance, vertex markers, the dither frame, 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/radar-chart.tsx", "type": "registry:component", "target": "components/dither-kit/radar-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 { PolarRoot } from \"./polar-root\"\nimport { RadarCanvas } from \"./radar-canvas\"\nimport { RadarFrame } from \"./radar-frame\"\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 RadarChartProps = {\n data: TData[]\n config: ChartConfig\n children: ReactNode\n nameKey: string // axis-label field\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 **radar** chart. Compose `` series, ``, … inside. */\nexport function RadarChart(props: RadarChartProps) {\n return (\n }\n dataKey=\"\"\n {...props}\n />\n )\n}\n" }, { "path": "components/dither-kit/radar-canvas.tsx", "type": "registry:component", "target": "components/dither-kit/radar-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 { distToPolygonEdge, pointInPolygon, polarX, polarY } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\n/**\n * Dither canvas for radar charts. Each series is a closed polygon over the\n * spokes (value → radius per axis). Backing pixels inside a polygon are filled\n * with the ordered-dither scatter — dense near the polygon edge, thinning toward\n * the centre — and each vertex is marked with a bright dot (larger on the hovered\n * axis). The polygons scale in from the centre on mount.\n */\nexport function RadarCanvas() {\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 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 fx = cols / Math.max(width, 1)\n const fy = rows / Math.max(height, 1)\n\n // Build each series polygon in plot coords, scaled by `prog`.\n const buildPolys = (prog: number) => {\n const s = state.current\n const radar = s.radar\n if (!radar) return []\n return s.configKeys.map((key) => {\n const poly: number[] = []\n const pts: { x: number; y: number }[] = []\n radar.axes.forEach((ax, i) => {\n const v = Number(s.data[i]?.[key]) || 0\n const r = (v / radar.max) * s.outerRadius * prog\n const x = polarX(s.center.x, r, ax.angle)\n const y = polarY(s.center.y, r, ax.angle)\n poly.push(x, y)\n pts.push({ x, y })\n })\n return { key, poly, pts }\n })\n }\n\n const paint = (prog: number) => {\n const s = state.current\n if (!s.radar) return\n c.clearRect(0, 0, cols, rows)\n const polys = buildPolys(easeInOutCubic(prog))\n const band = Math.max(s.outerRadius * 0.45, 1)\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 // Whether a layer behind already coloured this pixel — front layers\n // then keep true gaps in their dither so the back layer shows\n // through, instead of tinting the overlap into a muddy blend.\n let covered = false\n for (let pi = 0; pi < polys.length; pi++) {\n const { key, poly } = polys[pi]\n if (!pointInPolygon(px, py, poly)) continue\n const seed = s.seedOf(key)\n const variant = s.variantOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n const dist = distToPolygonEdge(px, py, poly)\n if (dist < 1.4) {\n c.fillStyle = rgb(seed.fill, 1, selDim)\n c.fillRect(x, y, 1, 1)\n covered = true\n continue\n }\n const density = 1 - Math.min(1, dist / band)\n const bias = variant === \"dotted\" ? 0.12 : 0\n // Thin each successive (front) layer so overlapping polygons\n // read as distinct layers, not a muddy blend.\n const sparse = pi * 0.2\n if (variant === \"hatched\" && ((x + y) & 3) >= 2) continue\n const lit =\n variant === \"solid\" ||\n density > BAYER[y & 3][x & 3] - 0.1 * intensity - bias + sparse\n // Unlit cells: over another layer, stay a real gap (back layer\n // shows through); over bare background, paint the faint tier so\n // the page never bleeds in.\n if (!lit && (variant === \"dotted\" || covered)) continue\n const k = (0.32 + density * 0.68) * (1 + 0.22 * intensity)\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 covered = true\n }\n }\n }\n\n // Vertex markers — larger on the hovered axis.\n for (const { key, pts } of polys) {\n const seed = s.seedOf(key)\n const emphasis = s.selectedDataKey ?? s.focusDataKey\n const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1\n pts.forEach((p, i) => {\n const bx = Math.round(p.x * fx)\n const by = Math.round(p.y * fy)\n const big = s.hoverIndex === i\n c.fillStyle = rgb(seed.fill, 1, selDim)\n const sz = big ? 2 : 1\n c.fillRect(bx - (sz - 1), by - (sz - 1), sz * 2 - 1, sz * 2 - 1)\n })\n }\n }\n\n const draw = (now: number) => {\n raf = requestAnimationFrame(draw)\n const s = state.current\n if (!s.ready || !s.radar) 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 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 if (prog !== lastProg) {\n lastProg = prog\n needsFill = true\n }\n\n // Live tweak repaint (variant) without replaying the scale-in.\n const paintSig = s.configKeys.map((k) => s.variantOf(k)).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/radar.tsx", "type": "registry:component", "target": "components/dither-kit/radar.tsx", "content": "\"use client\"\n\nimport { useEffect } from \"react\"\nimport type { AreaVariant } from \"./chart-context\"\nimport { usePolarPart } from \"./polar-context\"\n\nexport type RadarProps = {\n dataKey: string\n variant?: AreaVariant\n}\n\n/**\n * One radar series — a closed polygon over the spokes. Sets this series' fill\n * variant; the dithered polygon is painted on the canvas.\n */\nexport function Radar({ dataKey, variant = \"gradient\" }: RadarProps) {\n const ctx = usePolarPart(\"Radar\", \"radar\")\n const { registerVariant, unregisterVariant } = 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 registerVariant(dataKey, variant)\n return () => unregisterVariant(dataKey)\n }, [dataKey, variant, registerVariant, unregisterVariant])\n\n return null\n}\n" }, { "path": "components/dither-kit/radar-frame.tsx", "type": "registry:component", "target": "components/dither-kit/radar-frame.tsx", "content": "\"use client\"\n\nimport { polarX, polarY } from \"./polar\"\nimport { usePolarChart } from \"./polar-context\"\n\nconst LEVELS = 4\n\n/** Built-in radar chrome: concentric polygon rings, spokes, and axis labels.\n * Rendered behind the dither canvas by the radar root. */\nexport function RadarFrame() {\n const ctx = usePolarChart()\n if (!ctx.ready || !ctx.radar) return null\n const { axes } = ctx.radar\n const { x: cx, y: cy } = ctx.center\n const R = ctx.outerRadius\n\n const ring = (radius: number) =>\n `${axes\n .map(\n (ax, i) =>\n `${i === 0 ? \"M\" : \"L\"}${polarX(cx, radius, ax.angle).toFixed(1)},${polarY(cy, radius, ax.angle).toFixed(1)}`\n )\n .join(\" \")} Z`\n\n return (\n \n \n {Array.from({ length: LEVELS }, (_, l) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: fixed ring levels\n \n ))}\n {axes.map((ax, i) => (\n \n ))}\n \n \n {axes.map((ax, i) => {\n const lx = polarX(cx, R + 10, ax.angle)\n const ly = polarY(cy, R + 10, ax.angle)\n const anchor =\n Math.abs(Math.cos(ax.angle)) < 0.3\n ? \"middle\"\n : Math.cos(ax.angle) > 0\n ? \"start\"\n : \"end\"\n const hot = ctx.hoverIndex === i\n return (\n \n {ax.label}\n \n )\n })}\n \n \n )\n}\n\nRadarFrame.chartLayer = \"back\" as const\n" } ] }