From ed981a1a11a098758e389a92c3e7a18907bd7a79 Mon Sep 17 00:00:00 2001 From: Dloom Date: Sun, 12 Jul 2026 16:42:55 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20yDomain=20=E2=80=94=20auto-fitted=20and?= =?UTF-8?q?=20explicit=20y-domains=20for=20cartesian=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a yDomain prop to the cartesian roots (and a passthrough on Sparkline), alongside the existing zero-anchored default: - "auto": domain [min − pad, max + pad] fitted to the data. A high-magnitude series with small relative variance (a $4M portfolio moving ±$50k) keeps its shape instead of flattening against the 0 baseline, and negative values render instead of clamping. - [lo, hi]: explicit domain used verbatim (no padding, no nice()), so a host can reproduce the exact value↔pixel mapping outside the chart — e.g. a draggable range-slider overlay. Default stays "zero" everywhere, so existing charts are unchanged. Stacked/percent stacks stay zero-anchored. Registry JSON regenerated via npm run build. --- r/area-chart.json | 2 +- r/core.json | 6 +-- r/dither-kit.json | 2 +- registry/dither-kit/cartesian-root.tsx | 7 ++- registry/dither-kit/chart-context.tsx | 26 ++++++++-- registry/dither-kit/index.ts | 2 +- registry/dither-kit/scales.ts | 71 +++++++++++++++++++++++--- registry/dither-kit/sparkline.tsx | 6 +++ 8 files changed, 103 insertions(+), 19 deletions(-) diff --git a/r/area-chart.json b/r/area-chart.json index a2fc43e..7d8ff6f 100644 --- a/r/area-chart.json +++ b/r/area-chart.json @@ -37,7 +37,7 @@ "path": "components/dither-kit/sparkline.tsx", "type": "registry:component", "target": "components/dither-kit/sparkline.tsx", - "content": "\"use client\"\n\nimport { useMemo } from \"react\"\nimport { Area } from \"./area\"\nimport { AreaChart } from \"./area-chart\"\nimport type { AreaVariant } from \"./chart-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport type { DitherColor } from \"./palette\"\n\nexport type SparklineProps = {\n /** Plain numeric series — the common sparkline case. */\n data: number[]\n color: DitherColor\n variant?: AreaVariant\n /** Controlled crosshair position (e.g. a committed point). */\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 hovered. */\n bloomOnHover?: boolean\n /** Play the entrance sweep — off by default for a calm spark. */\n animate?: boolean\n className?: string\n}\n\n/**\n * Thin wrapper over {@link AreaChart} for the decorative-sparkline case: a\n * single `number[]` series, no axes/grid/tooltip, no scrub crosshair (unless a\n * `markerIndex` is supplied). Keeps the hover brightness lift.\n */\nexport function Sparkline({\n data,\n color,\n variant = \"gradient\",\n markerIndex = null,\n hovered = false,\n bloom = \"off\",\n bloomOnHover = false,\n animate = false,\n className,\n}: SparklineProps) {\n // Memoized explicitly so the chart works without React Compiler: `rows`\n // identity drives the entrance-replay revision, so a fresh array every\n // render would re-trigger the revision's state adjustment each pass.\n const rows = useMemo(() => data.map((v) => ({ v })), [data])\n const config = useMemo(() => ({ v: { color } }), [color])\n\n return (\n \n \n \n )\n}\n" + "content": "\"use client\"\n\nimport { useMemo } from \"react\"\nimport { Area } from \"./area\"\nimport { AreaChart } from \"./area-chart\"\nimport type { AreaVariant } from \"./chart-context\"\nimport type { BloomInput } from \"./dither-paint\"\nimport type { DitherColor } from \"./palette\"\nimport type { YDomain } from \"./scales\"\n\nexport type SparklineProps = {\n /** Plain numeric series — the common sparkline case. */\n data: number[]\n color: DitherColor\n variant?: AreaVariant\n /** `\"auto\"` fits the domain to the data — often what a spark wants,\n * since it exists to show shape. Defaults to the zero-anchored domain. */\n yDomain?: YDomain\n /** Controlled crosshair position (e.g. a committed point). */\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 hovered. */\n bloomOnHover?: boolean\n /** Play the entrance sweep — off by default for a calm spark. */\n animate?: boolean\n className?: string\n}\n\n/**\n * Thin wrapper over {@link AreaChart} for the decorative-sparkline case: a\n * single `number[]` series, no axes/grid/tooltip, no scrub crosshair (unless a\n * `markerIndex` is supplied). Keeps the hover brightness lift.\n */\nexport function Sparkline({\n data,\n color,\n variant = \"gradient\",\n yDomain = \"zero\",\n markerIndex = null,\n hovered = false,\n bloom = \"off\",\n bloomOnHover = false,\n animate = false,\n className,\n}: SparklineProps) {\n // Memoized explicitly so the chart works without React Compiler: `rows`\n // identity drives the entrance-replay revision, so a fresh array every\n // render would re-trigger the revision's state adjustment each pass.\n const rows = useMemo(() => data.map((v) => ({ v })), [data])\n const config = useMemo(() => ({ v: { color } }), [color])\n\n return (\n \n \n \n )\n}\n" } ] } diff --git a/r/core.json b/r/core.json index 75ed89f..4be2380 100644 --- a/r/core.json +++ b/r/core.json @@ -38,7 +38,7 @@ "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 floor (y0 = 0); for `stacked`/`percent` they pile on top of each other\n * via d3's stack layout. The shape `bands[key][i] = [y0, y1]` is what both the\n * SVG area paths and the canvas overlay read from.\n */\nexport function computeBands(\n data: Row[],\n keys: string[],\n stackType: StackType\n): { bands: Record; max: number } {\n if (stackType === \"default\") {\n const bands: Record = {}\n let max = 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 return [0, v]\n })\n }\n return { bands: bands, max: max || 1 }\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 series.forEach((layer) => {\n bands[layer.key] = layer.map((point) => {\n if (point[1] > max) max = point[1]\n return [point[0], point[1]]\n })\n })\n return { bands, max: max || 1 }\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/** value → vertical pixel, with the floor at the bottom of the plot. */\nexport function buildYScale(max: number, plotHeight: number) {\n return scaleLinear().domain([0, max]).nice().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" + "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\n/**\n * Y-domain strategy:\n * - `\"zero\"` — current behavior, domain anchored at 0 (default).\n * - `\"auto\"` — domain `[min − pad, max + pad]` fitted to the data, so a\n * high-magnitude series with small relative variance (a $4M portfolio\n * moving ±$50k) keeps its shape, and negative values render instead of\n * clamping to the floor.\n * - `[lo, hi]` — explicit domain, used verbatim (no padding, no nice()) so\n * a host can share the exact value↔pixel mapping (e.g. slider overlays).\n * Only meaningful for `stackType=\"default\"`; stacks stay zero-anchored.\n */\nexport type YDomain = \"zero\" | \"auto\" | readonly [number, number]\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 floor (y0 = 0); for `stacked`/`percent` they pile on top of each other\n * via d3's stack layout. The shape `bands[key][i] = [y0, y1]` is what both the\n * SVG area paths and the canvas overlay read from.\n */\nexport function computeBands(\n data: Row[],\n keys: string[],\n stackType: StackType,\n yDomain: YDomain = \"zero\"\n): { bands: Record; max: number; min: number } {\n if (stackType === \"default\") {\n if (Array.isArray(yDomain)) {\n const [lo, hi] = yDomain as readonly [number, number]\n const domainMin = Number.isFinite(lo) ? lo : 0\n const domainMax = hi > domainMin ? hi : domainMin + 1\n const bands: Record = {}\n for (const key of keys) {\n bands[key] = data.map((row) => [domainMin, num(row[key])])\n }\n return { bands, max: domainMax, min: domainMin }\n }\n if (yDomain === \"auto\") {\n let lo = Number.POSITIVE_INFINITY\n let hi = Number.NEGATIVE_INFINITY\n for (const key of keys) {\n for (const row of data) {\n const v = num(row[key])\n if (v < lo) lo = v\n if (v > hi) hi = v\n }\n }\n if (!Number.isFinite(lo) || !Number.isFinite(hi)) {\n lo = 0\n hi = 1\n }\n const spread = hi - lo\n const pad = spread > 0 ? spread * 0.08 : Math.max(Math.abs(hi) * 0.001, 1)\n const domainMin = lo - pad\n const domainMax = hi + pad\n const bands: Record = {}\n for (const key of keys) {\n bands[key] = data.map((row) => [domainMin, num(row[key])])\n }\n return { bands, max: domainMax, min: domainMin }\n }\n const bands: Record = {}\n let max = 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 return [0, v]\n })\n }\n return { bands: bands, max: max || 1, min: 0 }\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 series.forEach((layer) => {\n bands[layer.key] = layer.map((point) => {\n if (point[1] > max) max = point[1]\n return [point[0], point[1]]\n })\n })\n return { bands, max: max || 1, min: 0 }\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/** value → vertical pixel, with the domain floor at the bottom of the plot.\n * `nice` rounds the domain to friendly ticks — kept for the zero-anchored\n * domain only; \"auto\"/explicit domains pass min/max verbatim so the\n * padding/mapping intent survives. */\nexport function buildYScale(\n max: number,\n plotHeight: number,\n min = 0,\n nice = min === 0\n) {\n const scale = scaleLinear().domain([min, max]).range([plotHeight, 0])\n return nice ? scale.nice() : scale\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", @@ -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 { seedOfColor } from \"./palette\"\nimport {\n buildBandScale,\n buildXScale,\n buildYScale,\n computeBands,\n indexAtBand,\n nearestIndex,\n type StackType,\n type YDomain,\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 yDomain = \"zero\",\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 yDomain?: YDomain\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 // Normalize the domain to primitive deps so a host passing a fresh tuple\n // literal each render can't bust the band/scale memos below.\n const domainMode = Array.isArray(yDomain) ? null : (yDomain as \"zero\" | \"auto\")\n const domainLo = Array.isArray(yDomain) ? yDomain[0] : null\n const domainHi = Array.isArray(yDomain) ? yDomain[1] : null\n const domain = useMemo(\n () => domainMode ?? ([domainLo as number, domainHi as number] as const),\n [domainMode, domainLo, domainHi]\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/domain change.\n const { bands, max, min } = useMemo(\n () => computeBands(data, configKeys, stackType, domain),\n [data, configKeys, stackType, domain]\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(max, plotHeight, min, domain === \"zero\"),\n [max, plotHeight, min, domain]\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 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", @@ -86,7 +86,7 @@ "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" + "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, YDomain } 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 /** `\"auto\"` fits the domain to the data (padded); `[lo, hi]` uses an\n * explicit domain verbatim. Defaults to the zero-anchored domain. */\n yDomain?: YDomain\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 yDomain = \"zero\",\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 yDomain,\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", diff --git a/r/dither-kit.json b/r/dither-kit.json index 4dacf2e..b9e91b9 100644 --- a/r/dither-kit.json +++ b/r/dither-kit.json @@ -25,7 +25,7 @@ "path": "components/dither-kit/index.ts", "type": "registry:component", "target": "components/dither-kit/index.ts", - "content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n" + "content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType, YDomain } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n" } ] } diff --git a/registry/dither-kit/cartesian-root.tsx b/registry/dither-kit/cartesian-root.tsx index 590030a..39bcd85 100644 --- a/registry/dither-kit/cartesian-root.tsx +++ b/registry/dither-kit/cartesian-root.tsx @@ -16,7 +16,7 @@ import { import { CommonChartContext } from "./common-context" import type { BloomInput } from "./dither-paint" import { cn } from "./lib" -import type { StackType } from "./scales" +import type { StackType, YDomain } from "./scales" import { useChartDimensions } from "./use-chart-dimensions" // `object` rather than `Record`: interfaces don't get an @@ -36,6 +36,9 @@ export type CartesianChartProps = { config: ChartConfig children: ReactNode stackType?: StackType + /** `"auto"` fits the domain to the data (padded); `[lo, hi]` uses an + * explicit domain verbatim. Defaults to the zero-anchored domain. */ + yDomain?: YDomain margins?: Partial className?: string animate?: boolean @@ -81,6 +84,7 @@ export function CartesianRoot({ config, children, stackType = "default", + yDomain = "zero", margins: marginsProp, className, animate = true, @@ -107,6 +111,7 @@ export function CartesianRoot({ data: data as Record[], config, stackType, + yDomain, dimensions: size, margins, animate, diff --git a/registry/dither-kit/chart-context.tsx b/registry/dither-kit/chart-context.tsx index 5a38824..fbd996d 100644 --- a/registry/dither-kit/chart-context.tsx +++ b/registry/dither-kit/chart-context.tsx @@ -14,6 +14,7 @@ import { indexAtBand, nearestIndex, type StackType, + type YDomain, } from "./scales" import type { Dimensions } from "./use-chart-dimensions" @@ -181,6 +182,7 @@ export function useChartController({ data, config, stackType, + yDomain = "zero", dimensions, margins, animate = true, @@ -197,6 +199,7 @@ export function useChartController({ data: Row[] config: ChartConfig stackType: StackType + yDomain?: YDomain dimensions: Dimensions margins: Margins animate?: boolean @@ -292,12 +295,22 @@ export function useChartController({ [revision] ) + // Normalize the domain to primitive deps so a host passing a fresh tuple + // literal each render can't bust the band/scale memos below. + const domainMode = Array.isArray(yDomain) ? null : (yDomain as "zero" | "auto") + const domainLo = Array.isArray(yDomain) ? yDomain[0] : null + const domainHi = Array.isArray(yDomain) ? yDomain[1] : null + const domain = useMemo( + () => domainMode ?? ([domainLo as number, domainHi as number] as const), + [domainMode, domainLo, domainHi] + ) + // Memoized: the priciest derivation in the render path — it walks every // row × series to build the stack bands. Hover/cursor state changes must not - // recompute it, only a real data/series/stack change. - const { bands, max } = useMemo( - () => computeBands(data, configKeys, stackType), - [data, configKeys, stackType] + // recompute it, only a real data/series/stack/domain change. + const { bands, max, min } = useMemo( + () => computeBands(data, configKeys, stackType, domain), + [data, configKeys, stackType, domain] ) const isBar = chartType === "bar" @@ -341,7 +354,10 @@ export function useChartController({ }, [xCenter, stacked, bandwidth] ) - const y = useMemo(() => buildYScale(max, plotHeight), [max, plotHeight]) + const y = useMemo( + () => buildYScale(max, plotHeight, min, domain === "zero"), + [max, plotHeight, min, domain] + ) // Stable so `common` and the value stay stable; re-created only on config. const seedOf = useCallback( diff --git a/registry/dither-kit/index.ts b/registry/dither-kit/index.ts index c7b3548..b7544c3 100644 --- a/registry/dither-kit/index.ts +++ b/registry/dither-kit/index.ts @@ -41,7 +41,7 @@ export { Pie, type PieProps } from "./pie" export { PieChart, type PieChartProps } from "./pie-chart" export { Radar, type RadarProps } from "./radar" export { RadarChart, type RadarChartProps } from "./radar-chart" -export type { StackType } from "./scales" +export type { StackType, YDomain } from "./scales" export { Sparkline, type SparklineProps } from "./sparkline" export { Tooltip, type TooltipVariant } from "./tooltip" export { XAxis } from "./x-axis" diff --git a/registry/dither-kit/scales.ts b/registry/dither-kit/scales.ts index 2ddfdcf..0ce1cd3 100644 --- a/registry/dither-kit/scales.ts +++ b/registry/dither-kit/scales.ts @@ -6,6 +6,19 @@ import { stack as d3Stack, stackOffsetExpand } from "d3-shape" export type StackType = "default" | "stacked" | "percent" +/** + * Y-domain strategy: + * - `"zero"` — current behavior, domain anchored at 0 (default). + * - `"auto"` — domain `[min − pad, max + pad]` fitted to the data, so a + * high-magnitude series with small relative variance (a $4M portfolio + * moving ±$50k) keeps its shape, and negative values render instead of + * clamping to the floor. + * - `[lo, hi]` — explicit domain, used verbatim (no padding, no nice()) so + * a host can share the exact value↔pixel mapping (e.g. slider overlays). + * Only meaningful for `stackType="default"`; stacks stay zero-anchored. + */ +export type YDomain = "zero" | "auto" | readonly [number, number] + type Row = Record const num = (v: unknown) => @@ -20,9 +33,44 @@ const num = (v: unknown) => export function computeBands( data: Row[], keys: string[], - stackType: StackType -): { bands: Record; max: number } { + stackType: StackType, + yDomain: YDomain = "zero" +): { bands: Record; max: number; min: number } { if (stackType === "default") { + if (Array.isArray(yDomain)) { + const [lo, hi] = yDomain as readonly [number, number] + const domainMin = Number.isFinite(lo) ? lo : 0 + const domainMax = hi > domainMin ? hi : domainMin + 1 + const bands: Record = {} + for (const key of keys) { + bands[key] = data.map((row) => [domainMin, num(row[key])]) + } + return { bands, max: domainMax, min: domainMin } + } + if (yDomain === "auto") { + let lo = Number.POSITIVE_INFINITY + let hi = Number.NEGATIVE_INFINITY + for (const key of keys) { + for (const row of data) { + const v = num(row[key]) + if (v < lo) lo = v + if (v > hi) hi = v + } + } + if (!Number.isFinite(lo) || !Number.isFinite(hi)) { + lo = 0 + hi = 1 + } + const spread = hi - lo + const pad = spread > 0 ? spread * 0.08 : Math.max(Math.abs(hi) * 0.001, 1) + const domainMin = lo - pad + const domainMax = hi + pad + const bands: Record = {} + for (const key of keys) { + bands[key] = data.map((row) => [domainMin, num(row[key])]) + } + return { bands, max: domainMax, min: domainMin } + } const bands: Record = {} let max = 0 for (const key of keys) { @@ -32,7 +80,7 @@ export function computeBands( return [0, v] }) } - return { bands: bands, max: max || 1 } + return { bands: bands, max: max || 1, min: 0 } } const series = d3Stack() @@ -50,7 +98,7 @@ export function computeBands( return [point[0], point[1]] }) }) - return { bands, max: max || 1 } + return { bands, max: max || 1, min: 0 } } /** x positions for each row index, evenly spread across the plot width. */ @@ -76,9 +124,18 @@ export function indexAtBand(px: number, length: number, plotWidth: number) { return Math.min(length - 1, Math.floor(t * length)) } -/** value → vertical pixel, with the floor at the bottom of the plot. */ -export function buildYScale(max: number, plotHeight: number) { - return scaleLinear().domain([0, max]).nice().range([plotHeight, 0]) +/** value → vertical pixel, with the domain floor at the bottom of the plot. + * `nice` rounds the domain to friendly ticks — kept for the zero-anchored + * domain only; "auto"/explicit domains pass min/max verbatim so the + * padding/mapping intent survives. */ +export function buildYScale( + max: number, + plotHeight: number, + min = 0, + nice = min === 0 +) { + const scale = scaleLinear().domain([min, max]).range([plotHeight, 0]) + return nice ? scale.nice() : scale } /** Index of the row nearest a horizontal pixel offset within the plot. */ diff --git a/registry/dither-kit/sparkline.tsx b/registry/dither-kit/sparkline.tsx index 5afe3ff..a6cab15 100644 --- a/registry/dither-kit/sparkline.tsx +++ b/registry/dither-kit/sparkline.tsx @@ -6,12 +6,16 @@ import { AreaChart } from "./area-chart" import type { AreaVariant } from "./chart-context" import type { BloomInput } from "./dither-paint" import type { DitherColor } from "./palette" +import type { YDomain } from "./scales" export type SparklineProps = { /** Plain numeric series — the common sparkline case. */ data: number[] color: DitherColor variant?: AreaVariant + /** `"auto"` fits the domain to the data — often what a spark wants, + * since it exists to show shape. Defaults to the zero-anchored domain. */ + yDomain?: YDomain /** Controlled crosshair position (e.g. a committed point). */ markerIndex?: number | null /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */ @@ -34,6 +38,7 @@ export function Sparkline({ data, color, variant = "gradient", + yDomain = "zero", markerIndex = null, hovered = false, bloom = "off", @@ -51,6 +56,7 @@ export function Sparkline({