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