From 36fcd21089d5c16d765d402c8fd43dbcc8b1300e Mon Sep 17 00:00:00 2001 From: Dloom Date: Sun, 12 Jul 2026 20:11:20 -0400 Subject: [PATCH] feat: accept a custom Seed in ChartConfig color The palette ships seven named colors; a brand adopting the kit has no way to plot in its own hue without editing palette.ts by hand. Let config entries pass a full Seed instead: const config = { revenue: { label: "Revenue", color: { fill: [61, 216, 201], line: [160, 240, 231], star: [208, 250, 245] } }, costs: { label: "Costs", color: "grey" }, // named colors unchanged } Both controllers resolve through the existing isDitherColor guard, so every consumer of seedOf (canvas painters, legend, tooltip, dots) picks custom seeds up with no further changes. Type-level default stays the named union. Registry JSON regenerated via npm run build. --- r/core.json | 4 ++-- registry/dither-kit/chart-context.tsx | 16 +++++++++++++--- registry/dither-kit/polar-context.tsx | 8 ++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/r/core.json b/r/core.json index 75ed89f..3d16f39 100644 --- a/r/core.json +++ b/r/core.json @@ -62,7 +62,7 @@ "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\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 } = 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(() => buildYScale(max, plotHeight), [max, plotHeight])\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 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 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" + "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 { isDitherColor, 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\n// `color` accepts a named palette color OR a custom Seed, so brands can\n// supply their own hues (fill/line/star triplets) without forking the\n// palette. Named colors stay the ergonomic default.\nexport type ChartConfig = Record<\n string,\n { label?: string; color: DitherColor | Seed }\n>\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\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 } = 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(() => buildYScale(max, plotHeight), [max, plotHeight])\n\n // Stable so `common` and the value stay stable; re-created only on config.\n const seedOf = useCallback(\n (key: string) => {\n const color = config[key]?.color\n if (color == null) return seedOfColor(\"grey\")\n return isDitherColor(color) ? seedOfColor(color) : color\n },\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 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 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", @@ -80,7 +80,7 @@ "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" + "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 { isDitherColor, 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) => {\n const color = config[key]?.color\n if (color == null) return seedOfColor(\"grey\")\n return isDitherColor(color) ? seedOfColor(color) : color\n },\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", diff --git a/registry/dither-kit/chart-context.tsx b/registry/dither-kit/chart-context.tsx index 5a38824..45e525b 100644 --- a/registry/dither-kit/chart-context.tsx +++ b/registry/dither-kit/chart-context.tsx @@ -5,7 +5,7 @@ import { createContext, use, useCallback, useMemo, useState } from "react" import type { CommonChart } from "./common-context" import type { BloomInput } from "./dither-paint" import type { DitherColor, Seed } from "./palette" -import { seedOfColor } from "./palette" +import { isDitherColor, seedOfColor } from "./palette" import { buildBandScale, buildXScale, @@ -20,7 +20,13 @@ import type { Dimensions } from "./use-chart-dimensions" /** Which chart root a part is composed under — drives the boundary guards. */ export type ChartType = "area" | "bar" | "line" | "pie" | "radar" -export type ChartConfig = Record +// `color` accepts a named palette color OR a custom Seed, so brands can +// supply their own hues (fill/line/star triplets) without forking the +// palette. Named colors stay the ergonomic default. +export type ChartConfig = Record< + string, + { label?: string; color: DitherColor | Seed } +> export type Margins = { top: number @@ -345,7 +351,11 @@ export function useChartController({ // Stable so `common` and the value stay stable; re-created only on config. const seedOf = useCallback( - (key: string) => seedOfColor(config[key]?.color ?? "grey"), + (key: string) => { + const color = config[key]?.color + if (color == null) return seedOfColor("grey") + return isDitherColor(color) ? seedOfColor(color) : color + }, [config] ) diff --git a/registry/dither-kit/polar-context.tsx b/registry/dither-kit/polar-context.tsx index 0fd06b3..c65c6b2 100644 --- a/registry/dither-kit/polar-context.tsx +++ b/registry/dither-kit/polar-context.tsx @@ -11,7 +11,7 @@ import { import type { CommonChart } from "./common-context" import type { BloomInput } from "./dither-paint" import type { Seed } from "./palette" -import { seedOfColor } from "./palette" +import { isDitherColor, seedOfColor } from "./palette" import { type PieSlice, pieSlices, type RadarAxis, radarAxes } from "./polar" import type { Dimensions } from "./use-chart-dimensions" @@ -192,7 +192,11 @@ export function usePolarController({ // Stable so `common` and the value stay stable; re-created only on config. const seedOf = useCallback( - (key: string) => seedOfColor(config[key]?.color ?? "grey"), + (key: string) => { + const color = config[key]?.color + if (color == null) return seedOfColor("grey") + return isDitherColor(color) ? seedOfColor(color) : color + }, [config] ) // "*" is the pie-wide variant set by ; radar registers per series key.