{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "core", "type": "registry:component", "title": "Dither Kit — Core", "description": "Shared engine for Dither Kit: contexts, d3 scales, the ordered-dither canvas painter, the canvas-agnostic chart shells, and the legend/tooltip/grid/axes/dot chrome. Installed automatically by every chart.", "author": "ripgrim", "categories": [ "core" ], "version": "0.1.0", "dependencies": [ "motion", "d3-scale", "d3-shape", "clsx", "tailwind-merge" ], "devDependencies": [ "@types/d3-scale", "@types/d3-shape" ], "registryDependencies": [], "files": [ { "path": "components/dither-kit/lib.ts", "type": "registry:component", "target": "components/dither-kit/lib.ts", "content": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\n/** Tailwind-aware className combiner — local copy so the chart pack is\n * self-contained and portable as a registry. */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n" }, { "path": "components/dither-kit/palette.ts", "type": "registry:component", "target": "components/dither-kit/palette.ts", "content": "// Shared seed palette for the dither chart family. Mirrors the seeds in\n// `dither-chart.tsx` so a series rendered through the composable engine reads\n// with the exact same fill / line / star hues as the legacy sparkline.\n\nexport type Rgb = [number, number, number]\n\nexport type DitherColor =\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"pink\"\n | \"orange\"\n | \"red\"\n | \"grey\"\n\nexport type Seed = { fill: Rgb; line: Rgb; star: Rgb }\n\n// Each seed: the area-fill hue, the bright series line, and the star sparkle.\nexport const PALETTE: Record = {\n green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] },\n blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] },\n purple: {\n fill: [150, 110, 255],\n line: [200, 175, 255],\n star: [225, 210, 255],\n },\n pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] },\n orange: {\n fill: [255, 150, 50],\n line: [255, 195, 130],\n star: [255, 220, 175],\n },\n red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] },\n // No-data: a muted grey so empty metrics read as \"nothing here\".\n grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] },\n}\n\nexport const rgb = ([r, g, b]: Rgb, k = 1, a = 1) =>\n `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`\n\nexport const seedOfColor = (color: DitherColor): Seed => PALETTE[color]\n\nexport const isDitherColor = (value: unknown): value is DitherColor =>\n typeof value === \"string\" && value in PALETTE\n" }, { "path": "components/dither-kit/scales.ts", "type": "registry:component", "target": "components/dither-kit/scales.ts", "content": "// Pure geometry helpers for the dither chart engine. Kept framework-free so the\n// context (and, later, bar/line/pie/radar roots) can share the same math.\n\nimport { scaleBand, scaleLinear, scalePoint } from \"d3-scale\"\nimport { stack as d3Stack, stackOffsetExpand } from \"d3-shape\"\n\nexport type StackType = \"default\" | \"stacked\" | \"percent\"\n\ntype Row = Record\n\nconst num = (v: unknown) =>\n typeof v === \"number\" && Number.isFinite(v) ? v : 0\n\n/**\n * Per-series [y0, y1] bands for every row. For `default` every series sits on\n * the zero baseline (y0 = 0), so a negative value yields `[0, v]` with `v < 0`\n * and draws below the baseline; for `stacked`/`percent` they pile on top of\n * each other via d3's stack layout (which splits negatives below zero). The\n * shape `bands[key][i] = [y0, y1]` is what both the SVG area paths and the\n * canvas overlay read from. `max`/`min` bound the value range so the y-scale\n * can span a diverging (below-zero) domain.\n */\nexport function computeBands(\n data: Row[],\n keys: string[],\n stackType: StackType\n): { bands: Record; max: number; min: number } {\n if (stackType === \"default\") {\n const bands: Record = {}\n let max = 0\n let min = 0\n for (const key of keys) {\n bands[key] = data.map((row) => {\n const v = num(row[key])\n if (v > max) max = v\n if (v < min) min = v\n return [0, v]\n })\n }\n // Only fall back to a unit span when there's no range at all (empty /\n // all-zero) — a purely negative series keeps max = 0 so the baseline\n // stays pinned to the top of the plot.\n const flat = max === 0 && min === 0\n return { bands, max: flat ? 1 : max, min }\n }\n\n const series = d3Stack()\n .keys(keys)\n .value((row, key) => num(row[key]))\n .offset(stackType === \"percent\" ? stackOffsetExpand : (undefined as never))(\n data\n )\n\n const bands: Record = {}\n let max = 0\n let min = 0\n series.forEach((layer) => {\n bands[layer.key] = layer.map((point) => {\n if (point[1] > max) max = point[1]\n if (point[0] < min) min = point[0]\n return [point[0], point[1]]\n })\n })\n const flat = max === 0 && min === 0\n return { bands, max: flat ? 1 : max, min }\n}\n\n/** x positions for each row index, evenly spread across the plot width. */\nexport function buildXScale(length: number, plotWidth: number) {\n return scalePoint()\n .domain(Array.from({ length }, (_, i) => i))\n .range([0, plotWidth])\n}\n\n/** Banded x for bar categories — each index owns a slot of `bandwidth` width. */\nexport function buildBandScale(length: number, plotWidth: number) {\n return scaleBand()\n .domain(Array.from({ length }, (_, i) => i))\n .range([0, plotWidth])\n .paddingInner(0.28)\n .paddingOuter(0.18)\n}\n\n/** Index of the category whose band a horizontal pixel offset falls in. */\nexport function indexAtBand(px: number, length: number, plotWidth: number) {\n if (length <= 0 || plotWidth <= 0) return 0\n const t = Math.max(0, Math.min(0.999, px / plotWidth))\n return Math.min(length - 1, Math.floor(t * length))\n}\n\n/**\n * value → vertical pixel. The domain always includes zero, so charts with only\n * positive values keep a floor at the plot bottom, while diverging data (values\n * below zero) draws below a zero baseline that sits somewhere inside the plot.\n */\nexport function buildYScale(min: number, max: number, plotHeight: number) {\n const lo = Math.min(0, min)\n const hi = Math.max(0, max)\n // Guard a degenerate (zero-width) domain so `nice()` and the range map stay\n // finite even when every value is exactly zero.\n return scaleLinear()\n .domain([lo, hi === lo ? lo + 1 : hi])\n .nice()\n .range([plotHeight, 0])\n}\n\n/** Index of the row nearest a horizontal pixel offset within the plot. */\nexport function nearestIndex(px: number, length: number, plotWidth: number) {\n if (length <= 1 || plotWidth <= 0) return 0\n const t = Math.max(0, Math.min(1, px / plotWidth))\n return Math.round(t * (length - 1))\n}\n" }, { "path": "components/dither-kit/polar.ts", "type": "registry:component", "target": "components/dither-kit/polar.ts", "content": "// Polar geometry for pie + radar dither charts. Angles start at the top\n// (−90°) and run clockwise, matching how the slices/axes read on screen.\n\ntype Row = Record\n\nconst TOP = -Math.PI / 2\nconst TAU = Math.PI * 2\n\nexport type PieSlice = {\n name: string\n value: number\n start: number // radians\n end: number\n mid: number\n}\n\n/** Slice angles from each data row's value under `dataKey`, named by `nameKey`. */\nexport function pieSlices(\n data: Row[],\n dataKey: string,\n nameKey: string\n): PieSlice[] {\n const vals = data.map((r) => Math.max(0, Number(r[dataKey]) || 0))\n const total = vals.reduce((a, b) => a + b, 0) || 1\n let a = TOP\n return data.map((r, i) => {\n const span = (vals[i] / total) * TAU\n const slice = {\n name: String(r[nameKey] ?? i),\n value: vals[i],\n start: a,\n end: a + span,\n mid: a + span / 2,\n }\n a += span\n return slice\n })\n}\n\n/** Which slice a pointer angle falls in (or -1). */\nexport function sliceAtAngle(slices: PieSlice[], angle: number): number {\n // Normalize so comparisons against [start, end) (which begin at TOP) work.\n let a = angle\n while (a < TOP) a += TAU\n while (a >= TOP + TAU) a -= TAU\n return slices.findIndex((s) => a >= s.start && a < s.end)\n}\n\nexport type RadarAxis = { label: string; angle: number }\n\n/** Evenly-spaced spokes, one per data row, labelled by `nameKey`. */\nexport function radarAxes(data: Row[], nameKey: string): RadarAxis[] {\n const n = Math.max(data.length, 1)\n return data.map((r, i) => ({\n label: String(r[nameKey] ?? i),\n angle: TOP + (i / n) * TAU,\n }))\n}\n\n/** Nearest radar spoke to a pointer angle. */\nexport function axisAtAngle(axes: RadarAxis[], angle: number): number {\n let best = 0\n let bestD = Infinity\n axes.forEach((ax, i) => {\n let d = Math.abs(((angle - ax.angle + Math.PI * 3) % TAU) - Math.PI)\n d = Math.abs(d)\n if (d < bestD) {\n bestD = d\n best = i\n }\n })\n return best\n}\n\nexport const polarX = (cx: number, r: number, angle: number) =>\n cx + Math.cos(angle) * r\nexport const polarY = (cy: number, r: number, angle: number) =>\n cy + Math.sin(angle) * r\n\n/** Even-odd point-in-polygon test (polygon as flat [x0,y0,x1,y1,…]). */\nexport function pointInPolygon(\n px: number,\n py: number,\n poly: number[]\n): boolean {\n let inside = false\n const n = poly.length / 2\n for (let i = 0, j = n - 1; i < n; j = i++) {\n const xi = poly[i * 2]\n const yi = poly[i * 2 + 1]\n const xj = poly[j * 2]\n const yj = poly[j * 2 + 1]\n if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {\n inside = !inside\n }\n }\n return inside\n}\n\n/** Distance from a point to the nearest edge of a polygon — drives the radial\n * dither density (dense near the edge, thinning to the centre). */\nexport function distToPolygonEdge(\n px: number,\n py: number,\n poly: number[]\n): number {\n let best = Infinity\n const n = poly.length / 2\n for (let i = 0, j = n - 1; i < n; j = i++) {\n const xi = poly[i * 2]\n const yi = poly[i * 2 + 1]\n const xj = poly[j * 2]\n const yj = poly[j * 2 + 1]\n const dx = xj - xi\n const dy = yj - yi\n const len2 = dx * dx + dy * dy || 1\n let t = ((px - xi) * dx + (py - yi) * dy) / len2\n t = Math.max(0, Math.min(1, t))\n const ex = xi + t * dx - px\n const ey = yi + t * dy - py\n const d = Math.hypot(ex, ey)\n if (d < best) best = d\n }\n return best\n}\n" }, { "path": "components/dither-kit/dither-paint.ts", "type": "registry:component", "target": "components/dither-kit/dither-paint.ts", "content": "// Shared ordered-dither painting primitives, used by every cartesian canvas\n// (area, line, bar). Keeping the Bayer threshold loop in one place means every\n// chart type reads with the exact same pixel texture.\n\nimport type { AreaVariant } from \"./chart-context\"\nimport { rgb, type Seed } from \"./palette\"\n\n// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the exact matrix\n// the legacy chart dithers with.\nexport const BAYER = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n].map((row) => row.map((v) => (v + 0.5) / 16))\n\nexport const CELL = 2 // css px per dither cell — chunky enough to read pixelated\nexport const MAX_COLS = 520\nexport const MAX_ROWS = 200\n// Opacity of the top border outline (just under solid, so it reads as a soft\n// edge rather than a hard line). See the note on colour vs opacity below.\nexport const BORDER_ALPHA = 0.72\n// Opacity of a dither \"off\" cell relative to an \"on\" cell. The scatter modulates\n// between these two tiers of the *same* colour instead of leaving holes, so the\n// background never shows through as stark white on a light theme.\nexport const OFF_TIER = 0.4\n\nexport type PaintOpts = {\n variant: AreaVariant\n intensity: number // 0–1 hover lift\n dim: number // selection dim multiplier (0.3 dimmed, 1 normal)\n stacked: boolean // denser + solid floor when layers stack\n sparse?: number // raise the dither threshold (thin out) — front layers\n}\n\n// Colour vs opacity — the guiding rule for the whole engine:\n//\n// Work with opacities instead of different shades of the same color. This will\n// make sure it looks good on both light and dark mode.\n//\n// So every pixel is the series' single `fill` colour and we vary only its alpha.\n// The old lighter `line` / near-white `star` shades were dropped: a shade that\n// pops on a dark background reads as a jarring bright speck on a light one, while\n// the same colour at a lower opacity simply blends into whatever sits behind it.\n\n/**\n * Fill one backing-canvas column `x` from row `top` down to `floor` with the\n * ordered-dither scatter — solid at the floor, dissolving upward so it *fades\n * out toward the value line* — then cap the top with a soft border outline in\n * the series colour. Density drives opacity (see the note above), so the fade\n * reads correctly against both light and dark backgrounds. The single source of\n * the dither look across area / line / bar.\n */\nexport function paintColumn(\n octx: CanvasRenderingContext2D,\n x: number,\n top: number,\n floor: number,\n seed: Seed,\n { variant, intensity, dim, stacked, sparse = 0 }: PaintOpts\n) {\n const t = Math.round(top)\n const f = Math.round(floor)\n const depth = f - t\n if (depth <= 0) {\n octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim)\n octx.fillRect(x, t, 1, 1)\n return\n }\n const bias = (variant === \"dotted\" ? 0.12 : 0) + (stacked ? 0.2 : 0) - sparse\n for (let y = t; y < f; y++) {\n // Inverted falloff: 0 at the top line, 1 at the floor — dense at the\n // bottom, thinning as it rises toward the outline.\n let density = (y - t) / depth\n if (stacked) density = 0.5 + 0.5 * density\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\n // \"dotted\" keeps real gaps for its open look; every other variant covers\n // the cell and lets the dither ride the alpha (on = full tier, off = a\n // faint tint) so nothing shows the background through as white.\n if (variant === \"dotted\" && !lit) continue\n // Density → alpha (see the colour-vs-opacity note above).\n const k = (0.3 + density * 0.7) * (1 + 0.22 * intensity)\n const alpha = clamp01((lit ? k : k * OFF_TIER) * dim)\n octx.fillStyle = rgb(seed.fill, 1, alpha)\n octx.fillRect(x, y, 1, 1)\n }\n // Top border outline — the shape's edge now that the fill fades out here.\n // Kept just under full opacity, with a faint feather row beneath, so it reads\n // as a soft edge rather than a hard line floating over the fade.\n octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim)\n octx.fillRect(x, t, 1, 1)\n if (depth > 1) {\n octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * 0.5 * dim)\n octx.fillRect(x, t + 1, 1, 1)\n }\n}\n\n/** Linear-resample a per-index fraction array to `cols` columns. */\nexport function resample(src: number[], cols: number): number[] {\n const out = new Array(cols)\n const last = Math.max(src.length - 1, 1)\n for (let c = 0; c < cols; c++) {\n const t = (c / Math.max(cols - 1, 1)) * last\n const i = Math.floor(t)\n const f = t - i\n const a = src[i] ?? 0\n const b = src[Math.min(i + 1, src.length - 1)] ?? a\n out[c] = a + (b - a) * f\n }\n return out\n}\n\n/** Backing-canvas resolution for a plot rect — low-res, scaled up `pixelated`. */\nexport function backingSize(width: number, height: number) {\n return {\n cols: Math.min(MAX_COLS, Math.max(8, Math.round(width / CELL))),\n rows: Math.min(MAX_ROWS, Math.max(8, Math.round(height / CELL))),\n }\n}\n\n// Bloom — a real \"shader\" glow that comes from the colours themselves: a blurred\n// copy of the rendered canvas, composited additively (`plus-lighter`) so each\n// hue blooms in its own colour instead of a grey wash. Lives on a second canvas\n// layered over the crisp one (which stays sharp/pixelated).\nexport type BloomLevel = \"off\" | \"low\" | \"high\" | \"aura\"\nexport type BloomBlend = \"plus-lighter\" | \"screen\" | \"lighten\"\nexport type BloomConfig = {\n blur: number // px\n brightness: number // 1 = none\n opacity: number // 0–1\n /** Saturation of the glow — >1 keeps it vividly in the dither's colour\n * instead of washing toward white. */\n saturate?: number\n blend?: BloomBlend // additive by default\n}\n/** A preset name, a full config, or \"off\". */\nexport type BloomInput = BloomLevel | BloomConfig\n\nconst PRESET: Record, BloomConfig> = {\n low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 },\n high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 },\n aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 },\n}\n\nexport type BloomStyle = {\n filter: string\n opacity: number\n mixBlendMode: BloomBlend\n imageRendering: \"auto\"\n}\n\n/** Style for the bloom *layer* canvas (a blurred, additive copy). null when off. */\nexport function bloomLayerStyle(\n input: BloomInput,\n active: boolean\n): BloomStyle | null {\n if (!active || input === \"off\") return null\n const cfg = typeof input === \"string\" ? PRESET[input] : input\n return {\n filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate ?? 1})`,\n opacity: cfg.opacity,\n mixBlendMode: cfg.blend ?? \"plus-lighter\",\n imageRendering: \"auto\",\n }\n}\n\n// Easing — gentle start + soft settle so entrances don't feel linear.\nexport const easeInOutCubic = (t: number) =>\n t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\nexport const easeOutCubic = (t: number) => 1 - (1 - t) ** 3\nexport const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)\n\n/** Whether the OS asks for reduced motion (snap + steady stars). */\nexport function prefersReducedMotion() {\n return (\n window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches ?? false\n )\n}\n" }, { "path": "components/dither-kit/use-chart-dimensions.ts", "type": "registry:component", "target": "components/dither-kit/use-chart-dimensions.ts", "content": "import { useLayoutEffect, useRef, useState } from \"react\"\n\nexport type Dimensions = { width: number; height: number }\n\n/**\n * Tracks an element's CSS pixel size via {@link ResizeObserver}. Uses\n * `clientWidth`/`clientHeight` (the layout size) rather than\n * `getBoundingClientRect()` so a parent `layoutId` morph — which scales the\n * element via a transform — can't trick the chart into measuring a scaled size\n * and locking its canvas to it.\n */\nexport function useChartDimensions() {\n const ref = useRef(null)\n const [size, setSize] = useState({ width: 0, height: 0 })\n\n useLayoutEffect(() => {\n const el = ref.current\n if (!el) return\n\n const measure = () => {\n const width = Math.max(0, el.clientWidth)\n const height = Math.max(0, el.clientHeight)\n setSize((prev) =>\n prev.width === width && prev.height === height\n ? prev // guard against repeat fires\n : { width, height }\n )\n }\n\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n measure()\n return () => ro.disconnect()\n }, [])\n\n return { ref, size }\n}\n" }, { "path": "components/dither-kit/chart-context.tsx", "type": "registry:component", "target": "components/dither-kit/chart-context.tsx", "content": "\"use client\"\n\nimport type { ScaleLinear } from \"d3-scale\"\nimport { createContext, use, useCallback, useMemo, useState } from \"react\"\nimport type { CommonChart } from \"./common-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport type { DitherColor, Seed } from \"./palette\"\nimport { seedOfColor } from \"./palette\"\nimport {\n buildBandScale,\n buildXScale,\n buildYScale,\n computeBands,\n indexAtBand,\n nearestIndex,\n type StackType,\n} from \"./scales\"\nimport type { Dimensions } from \"./use-chart-dimensions\"\n\n/** Which chart root a part is composed under — drives the boundary guards. */\nexport type ChartType = \"area\" | \"bar\" | \"line\" | \"pie\" | \"radar\"\n\nexport type ChartConfig = Record\n\nexport type Margins = {\n top: number\n right: number\n bottom: number\n left: number\n}\n\ntype Row = Record\n\nexport type AreaVariant = \"gradient\" | \"dotted\" | \"hatched\" | \"solid\"\nexport type StrokeVariant = \"solid\" | \"dashed\"\nexport type SeriesKind = \"area\" | \"line\" | \"bar\"\n\n/** What each series part (, , ) registers so the canvas\n * knows which series to paint and how. */\nexport type SeriesSpec = {\n dataKey: string\n kind: SeriesKind\n variant: AreaVariant\n strokeVariant: StrokeVariant\n}\n\nexport type ChartContextValue = {\n chartType: ChartType // which root this part is under\n config: ChartConfig\n configKeys: string[] // series order — drives stacking + legend\n data: Row[]\n dataLength: number\n stackType: StackType\n\n margins: Margins\n plot: { width: number; height: number } // inner drawing area\n ready: boolean // true once measured (width > 0)\n\n xCenter: (index: number) => number // category centre px within the plot\n bandwidth: number // category slot width (0 for point/area scales)\n indexAtX: (px: number) => number // nearest category for a pointer x\n // Bar geometry in plot px — one source of truth for the canvas + click rects.\n barSlot: (\n index: number,\n seriesIndex: number,\n seriesCount: number\n ) => { x: number; width: number }\n y: ScaleLinear // value → px within the plot\n bands: Record // per-series [y0, y1] per row\n max: number\n min: number // most-negative value (0 when nothing dips below the baseline)\n\n // Interaction state, shared by every part.\n selectedDataKey: string | null\n selectDataKey: (key: string | null) => void\n /** Legend-hover spotlight — dims every series but this one while set. */\n focusDataKey: string | null\n setFocusDataKey: (key: string | null) => void\n hoverIndex: number | null\n setHoverIndex: (index: number | null) => void\n markerIndex: number | null // controlled crosshair override (e.g. committed point)\n cursorX: number\n setCursorX: (px: number) => void\n isMouseInChart: boolean\n setMouseInChart: (over: boolean) => void\n hovered: boolean // parent-driven hover (e.g. the whole card) — lifts the fill\n bloom: BloomInput // glow on the dither canvas\n bloomOnHover: boolean // only bloom while hovered\n\n // Series register themselves so the canvas knows what (and how) to paint.\n seriesSpecs: Record\n registerSeries: (spec: SeriesSpec) => void\n unregisterSeries: (dataKey: string) => void\n\n // Entrance animation (prop-driven). `revision` bumps when the data changes or\n // the replay token advances, so the canvas can re-play its entrance.\n animate: boolean\n animationDuration: number\n revision: number\n entranceDone: boolean // true once the entrance has played — gates SVG markers\n markEntranceDone: () => void // the canvas calls this when its reveal completes\n\n // Helpers.\n seedOf: (key: string) => Seed\n common: CommonChart // shared surface for / \n}\n\nconst ChartContext = createContext(null)\n\nconst ROOT_OF: Record = {\n area: \"\",\n bar: \"\",\n line: \"\",\n pie: \"\",\n radar: \"\",\n}\n\n/** Generic accessor for internal layers (canvas/overlay) that work for any root. */\nexport function useChart() {\n const ctx = use(ChartContext)\n if (!ctx) {\n throw new Error(\n \"Chart parts must be used within a chart root (e.g. ).\"\n )\n }\n return ctx\n}\n\n/**\n * Boundary guard for a composable part. Throws a precise error when used outside\n * a root, or inside the wrong chart type — e.g. `` placed in an area\n * chart. `kind` omitted means the part works under any root (grid, axes, …).\n */\nexport function useChartPart(\n part: string,\n kind?: ChartType | ChartType[]\n): ChartContextValue {\n const ctx = use(ChartContext)\n if (!ctx) {\n const where = kind\n ? ROOT_OF[Array.isArray(kind) ? kind[0] : kind]\n : \"a chart root\"\n throw new Error(`<${part} /> must be used within ${where}.`)\n }\n if (kind) {\n const allowed = Array.isArray(kind) ? kind : [kind]\n if (!allowed.includes(ctx.chartType)) {\n throw new Error(\n `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${allowed\n .map((k) => ROOT_OF[k])\n .join(\" or \")}.`\n )\n }\n }\n return ctx\n}\n\nexport { ChartContext }\n\n/** A counter that advances whenever `data` changes identity or `token` advances\n * — drives entrance replays without remounting. Uses the adjust-state-during-\n * render pattern (https://react.dev/reference/react/useState) instead of a ref:\n * the revision is derived purely from render inputs, so it stays consistent\n * across the memoized values below rather than lagging a render behind. */\nexport function useRevision(data: unknown, token: number) {\n const [prev, setPrev] = useState({ data, token, revision: 0 })\n if (prev.data !== data || prev.token !== token) {\n const next = { data, token, revision: prev.revision + 1 }\n setPrev(next)\n return next.revision\n }\n return prev.revision\n}\n\n/**\n * Builds the shared context value: resolves the plot rect from the measured\n * size minus margins, computes the x/y scales and the per-series stack bands,\n * and owns the selection + hover state every part reads.\n */\nexport function useChartController({\n chartType,\n data,\n config,\n stackType,\n dimensions,\n margins,\n animate = true,\n animationDuration = 900,\n replayToken = 0,\n markerIndex = null,\n hovered = false,\n bloom = \"off\",\n bloomOnHover = false,\n defaultSelectedDataKey = null,\n onSelectionChange,\n}: {\n chartType: ChartType\n data: Row[]\n config: ChartConfig\n stackType: StackType\n dimensions: Dimensions\n margins: Margins\n animate?: boolean\n animationDuration?: number\n replayToken?: number\n markerIndex?: number | null\n hovered?: boolean\n bloom?: BloomInput\n bloomOnHover?: boolean\n defaultSelectedDataKey?: string | null\n onSelectionChange?: (key: string | null) => void\n}): ChartContextValue {\n // This object becomes the ChartContext value, so its identity — and the\n // identity of every function/object it carries — must stay stable across\n // renders that don't change the underlying inputs. Otherwise every consumer\n // (axes, legend, tooltip, dots) re-renders on every parent render. So the\n // expensive derivations, the exposed callbacks, and the returned value are\n // memoized below; only cheap scalars (bandwidth, ready, plot sizes) are left\n // bare, since they're just recomputed reads, not identities anyone depends on.\n\n // Memoized: configKeys is the dep that drives `bands`, `common` and the\n // canvas `targets` memo — a fresh array each render would bust all of them.\n const configKeys = useMemo(() => Object.keys(config), [config])\n const revision = useRevision(data, replayToken)\n\n const [selectedDataKey, setSelectedDataKey] = useState(\n defaultSelectedDataKey\n )\n const [focusDataKey, setFocusDataKey] = useState(null)\n const [hoverIndex, setHoverIndex] = useState(null)\n const [cursorX, setCursorX] = useState(0)\n const [isMouseInChart, setMouseInChart] = useState(false)\n const [seriesSpecs, setSeriesSpecs] = useState>({})\n\n // useCallback because the series effects in area.tsx/bar.tsx list these as\n // deps — without stable identities the unregister/register effect re-fires\n // every render and its setState pair loops (\"Maximum update depth exceeded\").\n const registerSeries = useCallback((spec: SeriesSpec) => {\n setSeriesSpecs((prev) => {\n const cur = prev[spec.dataKey]\n return cur &&\n cur.kind === spec.kind &&\n cur.variant === spec.variant &&\n cur.strokeVariant === spec.strokeVariant\n ? prev\n : { ...prev, [spec.dataKey]: spec }\n })\n }, [])\n const unregisterSeries = useCallback((dataKey: string) => {\n setSeriesSpecs((prev) => {\n if (!(dataKey in prev)) return prev\n const next = { ...prev }\n delete next[dataKey]\n return next\n })\n }, [])\n\n // Stable so the memoized value keeps its identity; only re-created when the\n // caller's selection handler does.\n const selectDataKey = useCallback(\n (key: string | null) => {\n setSelectedDataKey(key)\n onSelectionChange?.(key)\n },\n [onSelectionChange]\n )\n\n // The root spreads `{ ...DEFAULT_MARGINS, ...marginsProp }` fresh every\n // render, so `margins` never keeps its identity. Pin one off the four numbers\n // so it doesn't, on its own, invalidate the value or the plot geometry.\n const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins\n const stableMargins = useMemo(\n () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }),\n [mTop, mRight, mBottom, mLeft]\n )\n\n const plotWidth = Math.max(0, dimensions.width - mLeft - mRight)\n const plotHeight = Math.max(0, dimensions.height - mTop - mBottom)\n const ready = plotWidth > 0 && plotHeight > 0\n\n // The entrance gate flips true when the canvas reveal completes (via\n // `markEntranceDone`) so DOM markers fade in with the fill, and re-arms on\n // each replay. Adjust-state-during-render instead of an effect, so the reset\n // lands in the same render as the revision bump.\n const [entrance, setEntrance] = useState({ revision, done: !animate })\n if (entrance.revision !== revision) {\n setEntrance({ revision, done: !animate })\n }\n const entranceDone = entrance.revision === revision ? entrance.done : !animate\n // Stable across renders at the same revision; the canvas holds this in a ref.\n const markEntranceDone = useCallback(\n () => setEntrance({ revision, done: true }),\n [revision]\n )\n\n // Memoized: the priciest derivation in the render path — it walks every\n // row × series to build the stack bands. Hover/cursor state changes must not\n // recompute it, only a real data/series/stack change.\n const { bands, max, min } = useMemo(\n () => computeBands(data, configKeys, stackType),\n [data, configKeys, stackType]\n )\n\n const isBar = chartType === \"bar\"\n // The d3 scale factories are memoized so `y` keeps a stable identity: the\n // canvas `targets` memo (cartesian-canvas / bar-canvas) deps on ctx.y, and\n // xCenter/indexAtX/barSlot below close over these.\n const xPoint = useMemo(\n () => buildXScale(data.length, plotWidth),\n [data.length, plotWidth]\n )\n const xBand = useMemo(\n () => buildBandScale(data.length, plotWidth),\n [data.length, plotWidth]\n )\n const bandwidth = isBar ? xBand.bandwidth() : 0\n const xCenter = useCallback(\n (i: number) =>\n isBar ? (xBand(i) ?? 0) + xBand.bandwidth() / 2 : (xPoint(i) ?? 0),\n [isBar, xBand, xPoint]\n )\n const indexAtX = useCallback(\n (px: number) =>\n isBar\n ? indexAtBand(px, data.length, plotWidth)\n : nearestIndex(px, data.length, plotWidth),\n [isBar, data.length, plotWidth]\n )\n const stacked = stackType === \"stacked\" || stackType === \"percent\"\n const barSlot = useCallback(\n (i: number, si: number, n: number) => {\n const center = xCenter(i)\n if (stacked) {\n const w = bandwidth * 0.9\n return { x: center - w / 2, width: w }\n }\n const slot = bandwidth / Math.max(n, 1)\n return {\n x: center - bandwidth / 2 + si * slot + slot * 0.08,\n width: slot * 0.84,\n }\n },\n [xCenter, stacked, bandwidth]\n )\n const y = useMemo(\n () => buildYScale(min, max, plotHeight),\n [min, max, plotHeight]\n )\n\n // Stable so `common` and the value stay stable; re-created only on config.\n const seedOf = useCallback(\n (key: string) => seedOfColor(config[key]?.color ?? \"grey\"),\n [config]\n )\n\n // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip),\n // so it needs its own stable identity independent of the parent value.\n const common: CommonChart = useMemo(() => ({\n names: configKeys,\n labelOf: (n) => config[n]?.label ?? n,\n seedOf,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n ready,\n tooltipLeft: Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX)),\n // Follow the highest hovered node so the card rides the data path, but\n // keep enough headroom that the upward-lifted card never clips the top.\n tooltipTop: (() => {\n const floor = mTop + 44\n if (hoverIndex == null) return floor\n let minY = Number.POSITIVE_INFINITY\n for (const key of configKeys) {\n const b = bands[key]?.[hoverIndex]\n if (b) minY = Math.min(minY, y(b[1]))\n }\n if (!Number.isFinite(minY)) return floor\n return Math.max(floor, mTop + minY)\n })(),\n heading: (i, labelKey) =>\n labelKey ? String(data[i]?.[labelKey] ?? \"\") : null,\n itemsAt: (i) =>\n configKeys.map((name) => {\n const raw = data[i]?.[name]\n return {\n name,\n label: config[name]?.label ?? name,\n value: typeof raw === \"number\" ? raw : 0,\n seed: seedOf(name),\n dimmed: (() => {\n const emphasis = selectedDataKey ?? focusDataKey\n return emphasis !== null && emphasis !== name\n })(),\n }\n }),\n }), [\n configKeys,\n config,\n seedOf,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n ready,\n plotWidth,\n mLeft,\n mTop,\n cursorX,\n bands,\n y,\n data,\n ])\n\n // Memoized: this is the ChartContext value. A fresh object here would\n // re-render every consumer on every parent render — the whole reason the\n // pieces above are stabilized. Rebuilds only when a listed input changes\n // (which is exactly when a consumer needs the update). The useState setters\n // are listed but never change identity, so they never trigger a rebuild.\n return useMemo(\n () => ({\n chartType,\n config,\n configKeys,\n data,\n dataLength: data.length,\n stackType,\n margins: stableMargins,\n plot: { width: plotWidth, height: plotHeight },\n ready,\n xCenter,\n bandwidth,\n indexAtX,\n barSlot,\n y,\n bands,\n max,\n min,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n setHoverIndex,\n markerIndex,\n cursorX,\n setCursorX,\n isMouseInChart,\n setMouseInChart,\n hovered,\n bloom,\n bloomOnHover,\n seriesSpecs,\n registerSeries,\n unregisterSeries,\n animate,\n animationDuration,\n revision,\n entranceDone,\n markEntranceDone,\n seedOf,\n common,\n }),\n [\n chartType,\n config,\n configKeys,\n data,\n stackType,\n stableMargins,\n plotWidth,\n plotHeight,\n ready,\n xCenter,\n bandwidth,\n indexAtX,\n barSlot,\n y,\n bands,\n max,\n min,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n setHoverIndex,\n markerIndex,\n cursorX,\n setCursorX,\n isMouseInChart,\n setMouseInChart,\n hovered,\n bloom,\n bloomOnHover,\n seriesSpecs,\n registerSeries,\n unregisterSeries,\n animate,\n animationDuration,\n revision,\n entranceDone,\n markEntranceDone,\n seedOf,\n common,\n ]\n )\n}\n" }, { "path": "components/dither-kit/common-context.tsx", "type": "registry:component", "target": "components/dither-kit/common-context.tsx", "content": "\"use client\"\n\nimport { createContext, use } from \"react\"\nimport type { Seed } from \"./palette\"\n\n/** A single tooltip row — one series (cartesian/radar) or one slice (pie). */\nexport type TooltipItem = {\n name: string\n label: string\n value: number\n seed: Seed\n dimmed: boolean\n}\n\n/**\n * The minimal surface shared by every chart family, so `` and\n * `` work identically whether they sit in a cartesian, bar, or polar\n * root. Each root publishes one of these alongside its family-specific context.\n */\nexport type CommonChart = {\n names: string[] // legend entries — series keys (cartesian) or slice names (pie)\n labelOf: (name: string) => string\n seedOf: (name: string) => Seed\n selectedDataKey: string | null\n selectDataKey: (key: string | null) => void\n /** Transient legend-hover emphasis — spotlights one series (others dim)\n * while the pointer rests on its legend entry. Selection still wins. */\n focusDataKey: string | null\n setFocusDataKey: (key: string | null) => void\n hoverIndex: number | null\n heading: (index: number, labelKey?: string) => string | null\n itemsAt: (index: number) => TooltipItem[]\n ready: boolean\n tooltipLeft: number // clamped px for the floating tooltip\n tooltipTop: number // px — follows the hovered node (cartesian) / cursor (polar)\n}\n\nexport const CommonChartContext = createContext(null)\n\nexport function useCommonChart() {\n const ctx = use(CommonChartContext)\n if (!ctx) {\n throw new Error(\n \" / must be used within a chart root.\"\n )\n }\n return ctx\n}\n" }, { "path": "components/dither-kit/series-context.tsx", "type": "registry:component", "target": "components/dither-kit/series-context.tsx", "content": "\"use client\"\n\nimport { createContext, use } from \"react\"\nimport type { Seed } from \"./palette\"\n\nexport type SeriesContextValue = {\n dataKey: string\n seed: Seed\n dimmed: boolean\n}\n\nexport const SeriesContext = createContext(null)\n\n/** Boundary guard for series-scoped markers (``, ``). */\nexport function useSeries(part: string) {\n const ctx = use(SeriesContext)\n if (!ctx) {\n throw new Error(\n `<${part} /> must be rendered inside a series (e.g. ).`\n )\n }\n return ctx\n}\n" }, { "path": "components/dither-kit/polar-context.tsx", "type": "registry:component", "target": "components/dither-kit/polar-context.tsx", "content": "\"use client\"\n\nimport { createContext, use, useCallback, useMemo, useState } from \"react\"\nimport {\n type AreaVariant,\n type ChartConfig,\n type ChartType,\n type Margins,\n useRevision,\n} from \"./chart-context\"\nimport type { CommonChart } from \"./common-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport type { Seed } from \"./palette\"\nimport { seedOfColor } from \"./palette\"\nimport { type PieSlice, pieSlices, type RadarAxis, radarAxes } from \"./polar\"\nimport type { Dimensions } from \"./use-chart-dimensions\"\n\ntype Row = Record\n\nconst ROOT_OF: Record = {\n pie: \"\",\n radar: \"\",\n}\n\nexport type PolarChartContextValue = {\n chartType: ChartType\n config: ChartConfig\n configKeys: string[]\n data: Row[]\n dataLength: number\n ready: boolean\n plot: { width: number; height: number }\n margins: Margins\n center: { x: number; y: number }\n outerRadius: number\n innerRadius: number\n animate: boolean\n animationDuration: number\n revision: number\n bloom: BloomInput\n bloomOnHover: boolean\n\n seedOf: (key: string) => Seed\n variantOf: (key: string) => AreaVariant\n registerVariant: (key: string, variant: AreaVariant) => void\n unregisterVariant: (key: string) => void\n\n selectedDataKey: string | null\n selectDataKey: (key: string | null) => void\n /** Legend-hover spotlight — dims every series but this one while set. */\n focusDataKey: string | null\n setFocusDataKey: (key: string | null) => void\n hoverIndex: number | null\n setHoverIndex: (i: number | null) => void\n setCursor: (px: number, py: number) => void\n isMouseInChart: boolean\n setMouseInChart: (over: boolean) => void\n\n pie: PieSlice[] | null // present for pie charts\n radar: { axes: RadarAxis[]; max: number } | null // present for radar charts\n\n common: CommonChart\n}\n\nconst PolarChartContext = createContext(null)\n\nexport function usePolarChart() {\n const ctx = use(PolarChartContext)\n if (!ctx) {\n throw new Error(\"Polar chart parts must be used within a polar chart root.\")\n }\n return ctx\n}\n\n/** Boundary guard for polar parts (``, ``). */\nexport function usePolarPart(part: string, kind: \"pie\" | \"radar\") {\n const ctx = use(PolarChartContext)\n if (!ctx) {\n throw new Error(`<${part} /> must be used within ${ROOT_OF[kind]}.`)\n }\n if (ctx.chartType !== kind) {\n throw new Error(\n `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${ROOT_OF[kind]}.`\n )\n }\n return ctx\n}\n\nexport { PolarChartContext }\n\nexport function usePolarController({\n chartType,\n data,\n config,\n dataKey,\n nameKey,\n innerRadiusRatio,\n dimensions,\n margins,\n animate = true,\n animationDuration = 900,\n replayToken = 0,\n bloom = \"off\",\n bloomOnHover = false,\n defaultSelectedDataKey = null,\n onSelectionChange,\n}: {\n chartType: \"pie\" | \"radar\"\n data: Row[]\n config: ChartConfig\n dataKey: string\n nameKey: string\n innerRadiusRatio: number\n dimensions: Dimensions\n margins: Margins\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}): PolarChartContextValue {\n // This object becomes the PolarChartContext value, so its identity — and the\n // identity of every function/object it carries — must stay stable across\n // renders that don't change the inputs; otherwise every consumer (legend,\n // tooltip, slices, axes) re-renders on every parent render. The expensive\n // derivations, exposed callbacks, and returned value are memoized below;\n // cheap scalars (radii, ready) are left bare as plain recomputed reads.\n\n // Memoized: drives `pie`/`radar`/`common` — a fresh array would bust them.\n const configKeys = useMemo(() => Object.keys(config), [config])\n const revision = useRevision(data, replayToken)\n\n const [selectedDataKey, setSelectedDataKey] = useState(\n defaultSelectedDataKey\n )\n const [focusDataKey, setFocusDataKey] = useState(null)\n const [hoverIndex, setHoverIndex] = useState(null)\n const [cursorX, setCursorX] = useState(0)\n const [cursorY, setCursorY] = useState(0)\n const [isMouseInChart, setMouseInChart] = useState(false)\n // Stable (only wraps two useState setters) so the value keeps its identity.\n const setCursor = useCallback((px: number, py: number) => {\n setCursorX(px)\n setCursorY(py)\n }, [])\n const [variants, setVariants] = useState>({})\n\n // useCallback for the same reason as registerSeries in chart-context.tsx:\n // pie.tsx/radar.tsx list these as effect deps, so without stable identities\n // the unregister/register effect re-fires and its setState pair loops.\n const registerVariant = useCallback((key: string, variant: AreaVariant) => {\n setVariants((prev) =>\n prev[key] === variant ? prev : { ...prev, [key]: variant }\n )\n }, [])\n const unregisterVariant = useCallback((key: string) => {\n setVariants((prev) => {\n if (!(key in prev)) return prev\n const next = { ...prev }\n delete next[key]\n return next\n })\n }, [])\n\n // Stable so the value keeps its identity; re-created only on config change.\n const selectDataKey = useCallback(\n (key: string | null) => {\n setSelectedDataKey(key)\n onSelectionChange?.(key)\n },\n [onSelectionChange]\n )\n\n // The root spreads margins fresh every render; pin a stable object off the\n // four numbers so it doesn't, on its own, invalidate the value.\n const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins\n const stableMargins = useMemo(\n () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }),\n [mTop, mRight, mBottom, mLeft]\n )\n\n const plotWidth = Math.max(0, dimensions.width - mLeft - mRight)\n const plotHeight = Math.max(0, dimensions.height - mTop - mBottom)\n const ready = plotWidth > 0 && plotHeight > 0\n const pad = chartType === \"radar\" ? 20 : 6\n const outerRadius = Math.max(0, Math.min(plotWidth, plotHeight) / 2 - pad)\n const innerRadius = chartType === \"pie\" ? outerRadius * innerRadiusRatio : 0\n const centerX = plotWidth / 2\n const centerY = plotHeight / 2\n\n // Stable so `common` and the value stay stable; re-created only on config.\n const seedOf = useCallback(\n (key: string) => seedOfColor(config[key]?.color ?? \"grey\"),\n [config]\n )\n // \"*\" is the pie-wide variant set by ; radar registers per series key.\n const variantOf = useCallback(\n (key: string) => variants[key] ?? variants[\"*\"] ?? \"gradient\",\n [variants]\n )\n\n // Memoized: slice geometry — recomputing it on every hover/cursor tick would\n // rebuild the pie layout needlessly.\n const pie = useMemo(\n () => (chartType === \"pie\" ? pieSlices(data, dataKey, nameKey) : null),\n [chartType, data, dataKey, nameKey]\n )\n\n // Memoized: walks every row × series for the axis max, then builds the axes.\n const radar = useMemo(() => {\n if (chartType !== \"radar\") return null\n let max = 0\n for (const row of data) {\n for (const key of configKeys) {\n const v = Number(row[key]) || 0\n if (v > max) max = v\n }\n }\n return { axes: radarAxes(data, nameKey), max: max || 1 }\n }, [chartType, data, configKeys, nameKey])\n\n // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip),\n // so it needs its own stable identity independent of the parent value.\n const common: CommonChart = useMemo(() => {\n const tooltipLeft = Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX))\n const tooltipTop = Math.max(mTop + 44, cursorY)\n const emphasis = selectedDataKey ?? focusDataKey\n if (chartType === \"pie\" && pie) {\n const names = pie.map((s) => s.name)\n return {\n names,\n tooltipTop,\n labelOf: (n) => config[n]?.label ?? n,\n seedOf,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n ready,\n tooltipLeft,\n heading: (i) => pie[i]?.name ?? null,\n itemsAt: (i) => {\n const s = pie[i]\n if (!s) return []\n return [\n {\n name: s.name,\n label: config[s.name]?.label ?? s.name,\n value: s.value,\n seed: seedOf(s.name),\n dimmed: emphasis !== null && emphasis !== s.name,\n },\n ]\n },\n }\n }\n // radar\n return {\n names: configKeys,\n tooltipTop,\n labelOf: (n) => config[n]?.label ?? n,\n seedOf,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n ready,\n tooltipLeft,\n heading: (i) => radar?.axes[i]?.label ?? null,\n itemsAt: (i) =>\n configKeys.map((name) => {\n const raw = data[i]?.[name]\n return {\n name,\n label: config[name]?.label ?? name,\n value: typeof raw === \"number\" ? raw : 0,\n seed: seedOf(name),\n dimmed: emphasis !== null && emphasis !== name,\n }\n }),\n }\n }, [\n chartType,\n config,\n configKeys,\n data,\n pie,\n radar,\n seedOf,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n ready,\n plotWidth,\n mLeft,\n mTop,\n cursorX,\n cursorY,\n ])\n\n // Memoized: this is the PolarChartContext value. A fresh object here would\n // re-render every consumer on every parent render — the reason the pieces\n // above are stabilized. Rebuilds only when a listed input changes. The\n // useState setters are listed but never change identity.\n return useMemo(\n () => ({\n chartType,\n config,\n configKeys,\n data,\n dataLength: data.length,\n ready,\n plot: { width: plotWidth, height: plotHeight },\n margins: stableMargins,\n center: { x: centerX, y: centerY },\n outerRadius,\n innerRadius,\n animate,\n animationDuration,\n revision,\n bloom,\n bloomOnHover,\n seedOf,\n variantOf,\n registerVariant,\n unregisterVariant,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n setHoverIndex,\n setCursor,\n isMouseInChart,\n setMouseInChart,\n pie,\n radar,\n common,\n }),\n [\n chartType,\n config,\n configKeys,\n data,\n ready,\n plotWidth,\n plotHeight,\n stableMargins,\n centerX,\n centerY,\n outerRadius,\n innerRadius,\n animate,\n animationDuration,\n revision,\n bloom,\n bloomOnHover,\n seedOf,\n variantOf,\n registerVariant,\n unregisterVariant,\n selectedDataKey,\n selectDataKey,\n focusDataKey,\n setFocusDataKey,\n hoverIndex,\n setHoverIndex,\n setCursor,\n isMouseInChart,\n setMouseInChart,\n pie,\n radar,\n common,\n ]\n )\n}\n" }, { "path": "components/dither-kit/cartesian-root.tsx", "type": "registry:component", "target": "components/dither-kit/cartesian-root.tsx", "content": "\"use client\"\n\nimport {\n Children,\n type ComponentType,\n isValidElement,\n type ReactNode,\n} from \"react\"\nimport {\n type ChartConfig,\n ChartContext,\n type ChartType,\n type Margins,\n useChartController,\n} from \"./chart-context\"\nimport { CommonChartContext } from \"./common-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport { cn } from \"./lib\"\nimport type { StackType } from \"./scales\"\nimport { useChartDimensions } from \"./use-chart-dimensions\"\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\nconst DEFAULT_MARGINS: Margins = {\n top: 10,\n right: 12,\n bottom: 22,\n left: 36,\n}\n\nexport type CartesianChartProps = {\n data: TData[]\n config: ChartConfig\n children: ReactNode\n stackType?: StackType\n margins?: Partial\n className?: string\n animate?: boolean\n animationDuration?: number\n replayToken?: number // change to re-play the entrance without remounting\n /** Set false for a decorative sparkline: keeps the hover lift but no scrub\n * crosshair / tooltip. */\n interactive?: boolean\n /** Controlled crosshair position (e.g. a committed point) — overrides the\n * internal hover when set. */\n markerIndex?: number | null\n /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */\n hovered?: boolean\n /** Glow on the dither fill. */\n bloom?: BloomInput\n /** Only bloom while the chart is hovered. */\n bloomOnHover?: boolean\n /** Fires with the scrubbed index as the pointer moves (null on leave). */\n onHoverChange?: (index: number | null) => void\n defaultSelectedDataKey?: string | null\n onSelectionChange?: (key: string | null) => void\n}\n\n/** Which render layer a composed part targets — defaults to the front SVG. */\nfunction layerOf(node: ReactNode): \"back\" | \"dom\" | \"svg\" {\n if (!isValidElement(node) || typeof node.type === \"string\") return \"svg\"\n return (node.type as { chartLayer?: \"back\" | \"dom\" }).chartLayer ?? \"svg\"\n}\n\n/**\n * Shared root for the cartesian dither charts (area, line, bar). Owns the\n * measured size, the shared context, and pointer interaction; every visual is\n * composed as children. Back chrome (grid) sits behind the dither canvas; the\n * canvas paints the fill/line/bars + stars; front chrome (axes, dots) and DOM\n * legend/tooltip layer on top. `chartType` drives the scales/interaction and the\n * `Canvas` prop supplies the family's painter (continuous for area/line, bars for\n * bar) — so each chart ships only its own canvas.\n */\nexport function CartesianRoot({\n chartType,\n Canvas,\n data,\n config,\n children,\n stackType = \"default\",\n margins: marginsProp,\n className,\n animate = true,\n animationDuration = 900,\n replayToken = 0,\n interactive = true,\n markerIndex = null,\n hovered = false,\n bloom = \"off\",\n bloomOnHover = false,\n onHoverChange,\n defaultSelectedDataKey = null,\n onSelectionChange,\n}: CartesianChartProps & {\n chartType: ChartType\n Canvas: ComponentType\n}) {\n const { ref, size } = useChartDimensions()\n const margins = { ...DEFAULT_MARGINS, ...marginsProp }\n\n const ctx = useChartController({\n chartType,\n // Safe: the controller only reads row[key] for the configured series keys.\n data: data as Record[],\n config,\n stackType,\n dimensions: size,\n margins,\n animate,\n animationDuration,\n replayToken,\n markerIndex,\n hovered,\n bloom,\n bloomOnHover,\n defaultSelectedDataKey,\n onSelectionChange,\n })\n\n const backChildren: ReactNode[] = []\n const svgChildren: ReactNode[] = []\n const domChildren: ReactNode[] = []\n Children.forEach(children, (child) => {\n const layer = layerOf(child)\n if (layer === \"back\") backChildren.push(child)\n else if (layer === \"dom\") domChildren.push(child)\n else svgChildren.push(child)\n })\n\n const onMove = (clientX: number) => {\n const el = ref.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const px = clientX - rect.left - margins.left\n const index = ctx.indexAtX(px)\n ctx.setHoverIndex(index)\n ctx.setCursorX(clientX - rect.left)\n onHoverChange?.(index)\n }\n\n return (\n \n \n ctx.setMouseInChart(true)}\n onPointerMove={interactive ? (e) => onMove(e.clientX) : undefined}\n onPointerLeave={() => {\n ctx.setMouseInChart(false)\n ctx.setHoverIndex(null)\n onHoverChange?.(null)\n }}\n >\n {ctx.ready && backChildren.length > 0 && (\n \n \n {backChildren}\n \n \n )}\n \n {ctx.ready && (\n \n \n {svgChildren}\n \n \n )}\n {domChildren}\n \n \n \n )\n}\n\nexport type AreaChartProps = CartesianChartProps\n" }, { "path": "components/dither-kit/polar-root.tsx", "type": "registry:component", "target": "components/dither-kit/polar-root.tsx", "content": "\"use client\"\n\nimport {\n Children,\n type ComponentType,\n isValidElement,\n type ReactNode,\n} from \"react\"\nimport type { ChartConfig, Margins } from \"./chart-context\"\nimport { CommonChartContext } from \"./common-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport { cn } from \"./lib\"\nimport { axisAtAngle, sliceAtAngle } from \"./polar\"\nimport { PolarChartContext, usePolarController } from \"./polar-context\"\nimport { useChartDimensions } from \"./use-chart-dimensions\"\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\nconst DEFAULT_POLAR_MARGINS: Margins = {\n top: 22,\n right: 14,\n bottom: 14,\n left: 14,\n}\n\nfunction layerOf(node: ReactNode): \"back\" | \"dom\" | \"svg\" {\n if (!isValidElement(node) || typeof node.type === \"string\") return \"svg\"\n return (node.type as { chartLayer?: \"back\" | \"dom\" }).chartLayer ?? \"svg\"\n}\n\nexport type PolarRootProps = {\n chartType: \"pie\" | \"radar\"\n /** Family painter — `PieCanvas` or `RadarCanvas`; ships with each chart. */\n Canvas: ComponentType\n /** Extra back-layer SVG content (e.g. the radar frame). */\n backDecoration?: ReactNode\n data: TData[]\n config: ChartConfig\n children: ReactNode\n dataKey: string\n nameKey: string\n innerRadius?: number // 0–1 ratio (donut); pie only\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\nexport function PolarRoot({\n chartType,\n Canvas,\n backDecoration,\n data,\n config,\n children,\n dataKey,\n nameKey,\n innerRadius = 0,\n margins: marginsProp,\n className,\n animate = true,\n animationDuration = 900,\n replayToken = 0,\n bloom = \"off\",\n bloomOnHover = false,\n defaultSelectedDataKey = null,\n onSelectionChange,\n}: PolarRootProps) {\n const { ref, size } = useChartDimensions()\n const margins = { ...DEFAULT_POLAR_MARGINS, ...marginsProp }\n\n const ctx = usePolarController({\n chartType,\n // Safe: the controller only reads row[key] for the configured keys.\n data: data as Record[],\n config,\n dataKey,\n nameKey,\n innerRadiusRatio: innerRadius,\n dimensions: size,\n margins,\n animate,\n animationDuration,\n replayToken,\n bloom,\n bloomOnHover,\n defaultSelectedDataKey,\n onSelectionChange,\n })\n\n const backChildren: ReactNode[] = []\n const svgChildren: ReactNode[] = []\n const domChildren: ReactNode[] = []\n Children.forEach(children, (child) => {\n const layer = layerOf(child)\n if (layer === \"back\") backChildren.push(child)\n else if (layer === \"dom\") domChildren.push(child)\n else svgChildren.push(child)\n })\n\n const onMove = (clientX: number, clientY: number) => {\n const el = ref.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const dx = clientX - rect.left - margins.left - ctx.center.x\n const dy = clientY - rect.top - margins.top - ctx.center.y\n const angle = Math.atan2(dy, dx)\n const r = Math.hypot(dx, dy)\n if (chartType === \"pie\" && ctx.pie) {\n const inside = r <= ctx.outerRadius && r >= ctx.innerRadius\n const i = inside ? sliceAtAngle(ctx.pie, angle) : -1\n ctx.setHoverIndex(i >= 0 ? i : null)\n } else if (ctx.radar) {\n ctx.setHoverIndex(axisAtAngle(ctx.radar.axes, angle))\n }\n ctx.setCursor(clientX - rect.left, clientY - rect.top)\n }\n\n return (\n \n \n ctx.setMouseInChart(true)}\n onPointerMove={(e) => onMove(e.clientX, e.clientY)}\n onPointerLeave={() => {\n ctx.setMouseInChart(false)\n ctx.setHoverIndex(null)\n }}\n >\n {ctx.ready && (\n \n \n {backDecoration}\n {backChildren}\n \n \n )}\n \n {ctx.ready && (\n \n \n {svgChildren}\n \n \n )}\n {domChildren}\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/grid.tsx", "type": "registry:component", "target": "components/dither-kit/grid.tsx", "content": "\"use client\"\n\nimport { useChartPart } from \"./chart-context\"\n\nexport function Grid({\n horizontal = true,\n vertical = false,\n strokeDasharray = \"3 3\",\n}: {\n horizontal?: boolean\n vertical?: boolean\n strokeDasharray?: string\n}) {\n const ctx = useChartPart(\"Grid\")\n if (!ctx.ready) return null\n const { width } = ctx.plot\n\n return (\n \n {horizontal &&\n ctx.y\n .ticks(4)\n .map((t) => (\n \n ))}\n {vertical &&\n ctx.data.map((_, i) => (\n \n ))}\n \n )\n}\n\n// Render beneath the dither canvas so grid lines sit behind the fill.\nGrid.chartLayer = \"back\" as const\n" }, { "path": "components/dither-kit/reference-line.tsx", "type": "registry:component", "target": "components/dither-kit/reference-line.tsx", "content": "\"use client\"\n\nimport { useChartPart } from \"./chart-context\"\n\n/**\n * A horizontal marker line at a value on the y-axis — most useful as the zero\n * baseline for diverging data (``), or to mark a target\n * / threshold. Renders in the front SVG layer so it stays visible over the\n * dither fill; pass an optional `label` to annotate it at the right edge.\n */\nexport function ReferenceLine({\n y = 0,\n label,\n strokeDasharray = \"4 4\",\n className = \"stroke-muted-foreground/60\",\n}: {\n y?: number\n label?: string\n strokeDasharray?: string\n className?: string\n}) {\n const ctx = useChartPart(\"ReferenceLine\")\n if (!ctx.ready) return null\n\n const { width } = ctx.plot\n const py = ctx.y(y)\n\n return (\n \n \n {label ? (\n \n {label}\n \n ) : null}\n \n )\n}\n" }, { "path": "components/dither-kit/x-axis.tsx", "type": "registry:component", "target": "components/dither-kit/x-axis.tsx", "content": "\"use client\"\n\nimport { useChartPart } from \"./chart-context\"\n\nexport function XAxis({\n dataKey,\n tickFormatter,\n tickMargin = 8,\n maxTicks = 8,\n}: {\n dataKey?: string\n tickFormatter?: (value: unknown, index: number) => string\n tickMargin?: number\n maxTicks?: number\n}) {\n const ctx = useChartPart(\"XAxis\")\n if (!ctx.ready) return null\n\n const step = Math.max(1, Math.ceil(ctx.dataLength / maxTicks))\n const y = ctx.plot.height + tickMargin\n\n return (\n \n {ctx.data.map((row, i) => {\n if (i % step !== 0) return null\n const raw = dataKey ? row[dataKey] : i\n const label = tickFormatter ? tickFormatter(raw, i) : String(raw ?? \"\")\n return (\n \n {label}\n \n )\n })}\n \n )\n}\n" }, { "path": "components/dither-kit/y-axis.tsx", "type": "registry:component", "target": "components/dither-kit/y-axis.tsx", "content": "\"use client\"\n\nimport { useChartPart } from \"./chart-context\"\n\nexport function YAxis({\n tickFormatter,\n tickCount = 4,\n tickMargin = 8,\n}: {\n tickFormatter?: (value: number) => string\n tickCount?: number\n tickMargin?: number\n}) {\n const ctx = useChartPart(\"YAxis\")\n if (!ctx.ready) return null\n\n return (\n \n {ctx.y.ticks(tickCount).map((t) => (\n \n {tickFormatter ? tickFormatter(t) : t}\n \n ))}\n \n )\n}\n" }, { "path": "components/dither-kit/dot.tsx", "type": "registry:component", "target": "components/dither-kit/dot.tsx", "content": "\"use client\"\n\nimport { useChart } from \"./chart-context\"\nimport { rgb, type Seed } from \"./palette\"\nimport { useSeries } from \"./series-context\"\n\nexport type DotVariant = \"border\" | \"colored-border\" | \"filled\"\n\nfunction dotPaint(variant: DotVariant, seed: Seed) {\n switch (variant) {\n case \"colored-border\":\n return {\n fill: \"var(--card, #0b0b0c)\",\n stroke: rgb(seed.line),\n strokeWidth: 1.5,\n }\n case \"filled\":\n return { fill: rgb(seed.star), stroke: rgb(seed.line), strokeWidth: 1 }\n default:\n return {\n fill: \"var(--card, #0b0b0c)\",\n stroke: rgb(seed.star, 0.8),\n strokeWidth: 1,\n }\n }\n}\n\n/** A marker at every data point along the series' top line. */\nexport function Dot({\n variant = \"border\",\n r = 2,\n}: {\n variant?: DotVariant\n r?: number\n}) {\n const ctx = useChart()\n const { dataKey, seed } = useSeries(\"Dot\")\n const band = ctx.bands[dataKey]\n if (!ctx.ready || !band) return null\n const paint = dotPaint(variant, seed)\n\n return (\n // Fade in once the fill has drawn so dots don't float over the entrance.\n \n {band.map((b, i) => (\n \n ))}\n \n )\n}\n\n/** A single marker at the hovered point — keys off the shared hover index. */\nexport function ActiveDot({\n variant = \"colored-border\",\n r = 3,\n}: {\n variant?: DotVariant\n r?: number\n}) {\n const ctx = useChart()\n const { dataKey, seed } = useSeries(\"ActiveDot\")\n const band = ctx.bands[dataKey]\n if (!ctx.ready || !band || ctx.hoverIndex == null || !ctx.entranceDone)\n return null\n const b = band[ctx.hoverIndex]\n if (!b) return null\n const paint = dotPaint(variant, seed)\n const cx = ctx.xCenter(ctx.hoverIndex)\n const cy = ctx.y(b[1])\n\n return (\n \n {/* Soft halo so the active point is unmistakable over the dither. */}\n \n \n \n )\n}\n" }, { "path": "components/dither-kit/legend.tsx", "type": "registry:component", "target": "components/dither-kit/legend.tsx", "content": "\"use client\"\n\nimport { useCommonChart } from \"./common-context\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\n\n/** Series/slice legend. With `isClickable`, each entry toggles its selection.\n * Works in every chart family via the shared common context.\n *\n * Note: this is an absolute overlay pinned to the top of the plot, so it's best\n * for ≤2–3 entries. With more entries (or a narrow container) it wraps onto\n * extra rows that overlay the chart — reach for the in-flow ``\n * instead, which renders as a sibling and can't overlap at any width. */\nexport function Legend({\n isClickable = false,\n align = \"right\",\n}: {\n isClickable?: boolean\n align?: \"left\" | \"center\" | \"right\"\n}) {\n const chart = useCommonChart()\n\n return (\n \n {chart.names.map((name) => {\n const seed = chart.seedOf(name)\n const emphasis = chart.selectedDataKey ?? chart.focusDataKey\n const dimmed = emphasis !== null && emphasis !== name\n return (\n \n chart.selectDataKey(chart.selectedDataKey === name ? null : name)\n }\n // Hovering an entry spotlights its series so overlapping layers\n // (e.g. two meshed radar polygons) can be told apart at a glance.\n onPointerEnter={() => chart.setFocusDataKey(name)}\n onPointerLeave={() => chart.setFocusDataKey(null)}\n onFocus={() => chart.setFocusDataKey(name)}\n onBlur={() => chart.setFocusDataKey(null)}\n className={cn(\n \"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground transition-opacity\",\n isClickable &&\n \"pointer-events-auto cursor-pointer hover:text-foreground\",\n dimmed && \"opacity-40\"\n )}\n >\n \n {chart.labelOf(name)}\n \n )\n })}\n \n )\n}\n\nLegend.chartLayer = \"dom\" as const\n" }, { "path": "components/dither-kit/block-legend.tsx", "type": "registry:component", "target": "components/dither-kit/block-legend.tsx", "content": "\"use client\"\n\nimport type { ChartConfig } from \"./chart-context\"\nimport { cn } from \"./lib\"\nimport { rgb, seedOfColor } from \"./palette\"\n\n/**\n * An in-flow legend rendered as a sibling of the chart rather than an overlay.\n *\n * The overlay {@link Legend} is pinned absolutely to the top of the plot, so\n * with more than ~3 entries (or a narrow container) its wrapped rows sit on top\n * of the chart. `` lives in normal document flow, so it can never\n * overlap the plot at any width — use it for multi-entry charts (donuts, many\n * series) and reserve the overlay `` for ≤2–3 entries.\n *\n * It needs no chart context: feed it the same `config` you pass the chart, and\n * optionally a `values` map to show a number beside each entry (e.g. allocation\n * shares or totals).\n */\nexport function BlockLegend({\n config,\n values,\n valueFormatter = (v) => String(v),\n align = \"start\",\n className,\n}: {\n config: ChartConfig\n values?: Record\n valueFormatter?: (value: number) => string\n align?: \"start\" | \"center\" | \"end\"\n className?: string\n}) {\n return (\n \n {Object.entries(config).map(([name, entry]) => {\n const seed = seedOfColor(entry.color)\n const value = values?.[name]\n return (\n \n \n {entry.label ?? name}\n {value !== undefined ? (\n {valueFormatter(value)}\n ) : null}\n \n )\n })}\n \n )\n}\n" }, { "path": "components/dither-kit/tooltip.tsx", "type": "registry:component", "target": "components/dither-kit/tooltip.tsx", "content": "\"use client\"\n\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { useState } from \"react\"\nimport { useCommonChart } from \"./common-context\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\n\nexport type TooltipVariant = \"default\" | \"frosted-glass\"\n\nconst VARIANT: Record = {\n default: \"bg-popover\",\n \"frosted-glass\": \"bg-popover/70 backdrop-blur-sm\",\n}\n\n/**\n * Floating hover tooltip. Reads the shared common context so it works in every\n * chart family. It glides between points and fades in/out (instead of snapping),\n * and dims unselected series/slices.\n */\nexport function Tooltip({\n labelKey,\n valueFormatter,\n variant = \"default\",\n}: {\n labelKey?: string\n valueFormatter?: (value: number, name: string) => string\n variant?: TooltipVariant\n}) {\n const chart = useCommonChart()\n const show = chart.ready && chart.hoverIndex != null\n\n // Retain the last hovered index so the card keeps its content while fading\n // out — adjust-state-during-render (no refs in render).\n const [lastIndex, setLastIndex] = useState(0)\n if (chart.hoverIndex != null && chart.hoverIndex !== lastIndex) {\n setLastIndex(chart.hoverIndex)\n }\n const index = chart.hoverIndex ?? lastIndex\n\n const heading = chart.heading(index, labelKey)\n const items = chart.itemsAt(index)\n\n return (\n \n {show && items.length > 0 && (\n \n {heading && (\n
\n {heading}\n
\n )}\n
\n {items.map((item) => (\n \n \n {item.label}\n \n {valueFormatter\n ? valueFormatter(item.value, item.name)\n : item.value.toLocaleString()}\n \n
\n ))}\n \n \n )}\n
\n )\n}\n\nTooltip.chartLayer = \"dom\" as const\n" } ] }