Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion r/area-chart.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <AreaChart\n data={rows}\n config={config}\n interactive={false}\n animate={animate}\n markerIndex={markerIndex}\n hovered={hovered}\n bloom={bloom}\n bloomOnHover={bloomOnHover}\n margins={{ top: 0, right: 0, bottom: 0, left: 0 }}\n className={className}\n >\n <Area dataKey=\"v\" variant={variant} />\n </AreaChart>\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 <AreaChart\n data={rows}\n config={config}\n yDomain={yDomain}\n interactive={false}\n animate={animate}\n markerIndex={markerIndex}\n hovered={hovered}\n bloom={bloom}\n bloomOnHover={bloomOnHover}\n margins={{ top: 0, right: 0, bottom: 0, left: 0 }}\n className={className}\n >\n <Area dataKey=\"v\" variant={variant} />\n </AreaChart>\n )\n}\n"
}
]
}
6 changes: 3 additions & 3 deletions r/core.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion r/dither-kit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
7 changes: 6 additions & 1 deletion registry/dither-kit/cartesian-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>`: interfaces don't get an
Expand All @@ -36,6 +36,9 @@ export type CartesianChartProps<TData extends Row> = {
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<Margins>
className?: string
animate?: boolean
Expand Down Expand Up @@ -81,6 +84,7 @@ export function CartesianRoot<TData extends Row>({
config,
children,
stackType = "default",
yDomain = "zero",
margins: marginsProp,
className,
animate = true,
Expand All @@ -107,6 +111,7 @@ export function CartesianRoot<TData extends Row>({
data: data as Record<string, unknown>[],
config,
stackType,
yDomain,
dimensions: size,
margins,
animate,
Expand Down
26 changes: 21 additions & 5 deletions registry/dither-kit/chart-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
indexAtBand,
nearestIndex,
type StackType,
type YDomain,
} from "./scales"
import type { Dimensions } from "./use-chart-dimensions"

Expand Down Expand Up @@ -181,6 +182,7 @@ export function useChartController({
data,
config,
stackType,
yDomain = "zero",
dimensions,
margins,
animate = true,
Expand All @@ -197,6 +199,7 @@ export function useChartController({
data: Row[]
config: ChartConfig
stackType: StackType
yDomain?: YDomain
dimensions: Dimensions
margins: Margins
animate?: boolean
Expand Down Expand Up @@ -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<YDomain>(
() => 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"
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion registry/dither-kit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
71 changes: 64 additions & 7 deletions registry/dither-kit/scales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>

const num = (v: unknown) =>
Expand All @@ -20,9 +33,44 @@ const num = (v: unknown) =>
export function computeBands(
data: Row[],
keys: string[],
stackType: StackType
): { bands: Record<string, [number, number][]>; max: number } {
stackType: StackType,
yDomain: YDomain = "zero"
): { bands: Record<string, [number, number][]>; 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<string, [number, number][]> = {}
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<string, [number, number][]> = {}
for (const key of keys) {
bands[key] = data.map((row) => [domainMin, num(row[key])])
}
return { bands, max: domainMax, min: domainMin }
}
const bands: Record<string, [number, number][]> = {}
let max = 0
for (const key of keys) {
Expand All @@ -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<Row>()
Expand All @@ -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. */
Expand All @@ -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. */
Expand Down
6 changes: 6 additions & 0 deletions registry/dither-kit/sparkline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -34,6 +38,7 @@ export function Sparkline({
data,
color,
variant = "gradient",
yDomain = "zero",
markerIndex = null,
hovered = false,
bloom = "off",
Expand All @@ -51,6 +56,7 @@ export function Sparkline({
<AreaChart
data={rows}
config={config}
yDomain={yDomain}
interactive={false}
animate={animate}
markerIndex={markerIndex}
Expand Down