Chart
Core chart components and utilities for data visualization
registry/preskok/ui/preskok-ui/chart.tsx
"use client";
import {
createContext,
startTransition,
use,
useState,
type HTMLAttributes,
type ReactNode,
} from "react";
import {
defineChart,
type ChartPoint,
type ChartValue,
type StaticChartDefinition,
} from "@tanstack/charts";
import { motion } from "@tanstack/charts/motion";
import {
RendererChart as ChartPrimitive,
type ChartProps as TanStackChartProps,
} from "@tanstack/charts/react/tooltip";
import { tooltip as tooltipExtension } from "@tanstack/charts/tooltip";
import { twMerge } from "cn";
import {
ToggleButton,
ToggleButtonGroup,
} from "react-aria-components/ToggleButtonGroup";
import {
defaultValueFormatter,
getChartColors,
getLabel,
getTextLabel,
type ChartColorPalette,
type ChartConfig,
type ChartLegendProps,
type ChartSizeProps,
type ChartTooltipContentProps,
type ChartTooltipProps,
type ChartTooltipRenderer,
type TooltipDatum,
} from "./chart-core";
const ENTRANCE_DURATION = 900;
// Marks grow from their baseline on mount. The entrance also replays over
// server-rendered SVG, which is hidden until the chart has measured its container.
const entranceRenderer = motion({
initial: "always",
transition: {
type: "tween",
duration: ENTRANCE_DURATION,
easing: "ease-out",
},
});
type ChartFrameContextValue = {
colors?: ChartColorPalette;
config: ChartConfig;
selectedSeries: string | null;
selectSeries: (series: string | null) => void;
};
const ChartFrameContext = createContext<ChartFrameContextValue | null>(null);
function useChartFrame() {
const context = use(ChartFrameContext);
if (!context) {
throw new Error("Chart components must be rendered inside <ChartFrame>");
}
return context;
}
/** Series highlighted from the legend or by selecting a mark. */
function useSeriesSelection() {
const [selectedSeries, setSelectedSeries] = useState<string | null>(null);
const selectSeries = (series: string | null) => {
startTransition(() => {
setSelectedSeries(series);
});
};
return [selectedSeries, selectSeries] as const;
}
type ChartProps<
TDatum extends TooltipDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
> = Pick<
TanStackChartProps<TDatum, TXValue, TYValue>,
"className" | "style"
> & {
ariaLabel: string;
config: ChartConfig;
/** Height used when `size` sets neither `height` nor `aspectRatio`. */
defaultHeight?: number;
definition: StaticChartDefinition<TDatum, TXValue, TYValue, "dom">;
/**
* How marks appear on mount: `grow` from their baseline, or `reveal` left to
* right. Marks that reveal should opt out of `grow` with
* `motion: revealEntranceMotion`.
*/
entrance?: "grow" | "reveal";
onSelect?: (point: ChartPoint<TDatum, TXValue, TYValue> | null) => void;
size?: ChartSizeProps;
tooltip?: ChartTooltipRenderer<TDatum, TXValue, TYValue> | false;
tooltipProps?: ChartTooltipProps;
valueFormatter?: (value: number) => string;
};
function Chart<
TDatum extends TooltipDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
>({
ariaLabel,
className,
config,
defaultHeight = 288,
definition,
entrance = "grow",
onSelect,
size,
style,
tooltip,
tooltipProps,
valueFormatter = defaultValueFormatter,
}: ChartProps<TDatum, TXValue, TYValue>) {
// Hide the first paint until the chart matches its container width, so the
// server-rendered `initialWidth` layout never flashes.
const [ready, setReady] = useState(false);
const hasTooltip = tooltip !== false;
let height = size?.height;
if (height === undefined && size?.aspectRatio === undefined) {
height = defaultHeight;
}
return (
<ChartPrimitive
ariaLabel={ariaLabel}
aspectRatio={size?.aspectRatio}
className={twMerge(
"min-w-0 text-xs text-muted-foreground [&_svg.ts-chart]:outline-none",
// TanStack creates the positioning shell before React fills it. Paint the panel only with the React content below.
"[--ts-chart-tooltip-background:transparent] [--ts-chart-tooltip-border:0] [--ts-chart-tooltip-padding:0] [--ts-chart-tooltip-shadow:none]",
ready ? "opacity-100" : "opacity-0",
className,
)}
definition={
hasTooltip
? defineChart(definition, {
tooltip: {
anchor: tooltipProps?.anchor,
offset: tooltipProps?.offset,
placement: tooltipProps?.placement,
// Follow the pointer immediately; the renderer's tween made the tooltip trail and wobble between points.
motion: false,
// A click already selects a series, so it should not also pin.
sticky: onSelect === undefined,
use: tooltipExtension,
},
})
: definition
}
height={height}
initialWidth={size?.initialWidth ?? 720}
onRender={(context) => {
if (ready) {
return;
}
const measuredWidth = context.container.getBoundingClientRect().width;
if (
measuredWidth > 0 &&
Math.abs(context.scene.width - measuredWidth) < 1
) {
if (entrance === "reveal") {
revealMarks(context.container);
}
setReady(true);
}
}}
onSelect={onSelect}
renderer={entranceRenderer}
renderTooltipBody={
hasTooltip
? ({ points, primaryPoint }) => {
const contentProps = {
activeSeries: primaryPoint?.datum.series,
config,
points: uniqueSeriesPoints(points),
tooltipProps,
valueFormatter,
};
// `empty:hidden` drops the panel when the content renders nothing.
return (
<div className="rounded-lg border-[0.5px] border-[color-mix(in_oklab,var(--overlay-foreground)_16%,transparent)] bg-[color-mix(in_oklab,var(--overlay)_70%,transparent)] px-3 py-2 text-overlay-foreground shadow-[0_6px_24px_rgb(0_0_0/0.14)] backdrop-blur-lg empty:hidden">
{tooltip ? (
tooltip(contentProps)
) : (
<ChartTooltipContent {...contentProps} />
)}
</div>
);
}
: undefined
}
style={style}
/>
);
}
/** Wipes the marks layer in from the left, leaving axes and grid in place. */
function revealMarks(container: HTMLElement) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
// The negative insets keep strokes at the plot edges from being clipped.
container
.querySelector(".ts-chart__marks")
?.animate(
[
{ clipPath: "inset(-8% 100% -8% -8%)" },
{ clipPath: "inset(-8% -8% -8% -8%)" },
],
{ duration: 900, easing: "cubic-bezier(0.25, 0.1, 0.25, 1)" },
);
}
/** Keeps one tooltip row per series when several marks share a datum. */
function uniqueSeriesPoints<TPoint extends { datum: { series: string } }>(
points: readonly TPoint[],
) {
const seen = new Set<string>();
return points.filter((point) => {
if (seen.has(point.datum.series)) {
return false;
}
seen.add(point.datum.series);
return true;
});
}
type ChartFrameProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: ReactNode;
colors?: ChartColorPalette;
config: ChartConfig;
legend?: ReactNode | false;
onSelectedSeriesChange: (series: string | null) => void;
selectedSeries: string | null;
};
function ChartFrame({
children,
className,
colors,
config,
legend = <ChartLegend />,
onSelectedSeriesChange,
selectedSeries,
...props
}: ChartFrameProps) {
return (
<ChartFrameContext
value={{
colors,
config,
selectedSeries,
selectSeries: onSelectedSeriesChange,
}}
>
<div
{...props}
className={twMerge("z-20 flex w-full min-w-0 flex-col", className)}
>
{children}
{legend}
</div>
</ChartFrameContext>
);
}
function ChartLegend({
align = "center",
className,
hideIcon = false,
verticalAlign = "bottom",
...props
}: ChartLegendProps) {
const { colors, config, selectedSeries, selectSeries } = useChartFrame();
const chartColors = getChartColors(config, colors);
let justifyClass = "justify-center";
if (align === "left") {
justifyClass = "justify-start";
} else if (align === "right") {
justifyClass = "justify-end";
}
return (
<ToggleButtonGroup
aria-label="Chart series"
className={twMerge(
"flex flex-wrap items-center gap-1",
verticalAlign === "top" ? "order-first pb-2" : "order-last pt-2",
justifyClass,
className,
)}
onSelectionChange={(keys) => {
selectSeries([...keys][0]?.toString() ?? null);
}}
selectedKeys={selectedSeries ? [selectedSeries] : []}
selectionMode="single"
{...props}
>
{Object.entries(config).map(([series, item]) => {
const Icon = item.icon;
return (
<ToggleButton
aria-label={`${getTextLabel(config, series)} series`}
className={twMerge(
"relative flex min-h-10 items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none",
"transition-[background-color,color,opacity,scale,box-shadow] duration-150 ease-out",
"hover:bg-muted/70 hover:text-foreground selected:bg-muted selected:text-foreground selected:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--border)_80%,transparent)]",
"focus-visible:ring-2 focus-visible:ring-ring/40 active:scale-[0.96]",
)}
id={series}
key={series}
>
{Icon && !hideIcon ? (
<Icon data-slot="icon" />
) : (
<span
aria-hidden
className="size-2 shrink-0 rounded-full shadow-[0_0_0_1px_color-mix(in_srgb,currentColor_10%,transparent)]"
style={{ backgroundColor: chartColors[series] }}
/>
)}
{item.label ?? series}
</ToggleButton>
);
})}
</ToggleButtonGroup>
);
}
function ChartTooltipContent<
TDatum extends TooltipDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
>({
config,
points,
tooltipProps,
valueFormatter,
}: ChartTooltipContentProps<TDatum, TXValue, TYValue>) {
const seriesColors = getChartColors(config);
const seriesOrder = Object.keys(config);
const {
className,
hideIndicator = false,
hideLabel = false,
hint,
indicator = "dot",
labelFormatter,
labelSeparator = true,
} = tooltipProps ?? {};
const firstPoint = points[0];
if (!firstPoint) {
return null;
}
const rawLabel = String(firstPoint.datum.category);
const hintContent = hint?.(rawLabel);
// Same order as the legend.
const sortedPoints = points.toSorted(
(a, b) =>
seriesOrder.indexOf(a.datum.series) - seriesOrder.indexOf(b.datum.series),
);
return (
<div
className={twMerge(
"grid min-w-36 items-start text-xs text-current",
className,
)}
>
{hideLabel ? null : (
<span className="font-semibold text-foreground">
{labelFormatter ? labelFormatter(rawLabel) : rawLabel}
</span>
)}
{hideLabel || !labelSeparator ? null : (
<span
aria-hidden
className="mt-2 mb-2.5 block h-px w-full bg-border/70"
/>
)}
<div className="grid gap-2.5">
{sortedPoints.map((point) => {
const { series, value } = point.datum;
if (value === null) {
return null;
}
// Every row shows its series color, matching the legend, whichever mark the pointer is over.
const indicatorColor = seriesColors[series] ?? point.color;
return (
<div className="flex items-center gap-2.5" key={point.key}>
{hideIndicator ? null : (
<span
aria-hidden
className={twMerge(
"shrink-0 border-current",
indicator === "dot" && "size-2.5 rounded-full",
indicator === "line" && "h-4 w-1 rounded-full",
indicator === "dashed" &&
"h-4 w-0 border-l-2 border-dashed bg-transparent",
)}
style={{
backgroundColor:
indicator === "dashed" ? "transparent" : indicatorColor,
borderColor: indicatorColor,
}}
/>
)}
<span className="flex-1 text-muted-foreground">
{getLabel(config, series)}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{valueFormatter(value)}
</span>
</div>
);
})}
</div>
{hintContent ? (
<span className="mt-2.5 flex items-center gap-1 border-t border-border/70 pt-2 text-muted-foreground">
{hintContent}
</span>
) : null}
</div>
);
}
/** Value and caption centered over a donut or radial chart. */
function ChartCenterLabel({
label,
value,
}: {
label?: ReactNode;
value: ReactNode;
}) {
return (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-xl font-semibold tracking-tight text-foreground tabular-nums">
{value}
</span>
<span className="max-w-24 text-xs text-muted-foreground">{label}</span>
</div>
);
}
export type { ChartFrameContextValue, ChartFrameProps, ChartProps };
export {
Chart,
ChartCenterLabel,
ChartFrame,
ChartLegend,
ChartTooltipContent,
useChartFrame,
useSeriesSelection,
};
export * from "./chart-core";
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · medium
- charts
- recharts