Composite
Chart
Chart renders line, bar, radar, donut, pie, polar area, bubble, and scatter charts from a typed `{ type, data, options }` configuration. It includes signed and stacked bars, mixed bar-line datasets, reference-style 3D pie and donut rendering, animated legend visibility, square responsive radial layouts, hover tooltips, axes, grid lines, reduced-motion-aware animation, and an optional accessible data table.
Installation
npx shadcn@latest add https://rck.vibeboxph.com//r/chart.jsonLive preview & controls
See related examplesControls
<Chart
type="line"
data={chartData}
options={{
indexAxis: "x",
plugins: {
title: { display: true, text: "Chart playground", align: "start", fontSize: 15 },
legend: { display: true },
tooltip: { enabled: true },
},
scales: { x: { grid: { display: true } }, y: { grid: { display: true } } },
animation={{ duration: 850 }}
}}
/>Anatomy
ChartThe responsive chart wrapper and SVG viewport.dataLabels and dataset values used to calculate chart geometry.optionsChart.js-inspired visual, scale, plugin, and animation configuration.data tableOptional visually hidden table for screen-reader access to chart values.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| type* | "line" | "bar" | "radar" | "donut" | "pie" | "polarArea" | "bubble" | "scatter" | — | Chart controller used to render the supplied data. |
| data* | ChartData | — | Chart.js-inspired labels and datasets configuration. |
| options | ChartOptions | ChartRadialOptions | {} | Typed subset for responsive sizing, axes, legends, tooltips, titles, styling, and animation. |
| height | number | string | — | Explicit chart height. Without it, the wrapper keeps a readable minimum height and responsive aspect ratio. |
| showDataTable | boolean | false | Renders a visually hidden accessible table containing the chart data. |
| fallback | React.ReactNode | — | Content shown when the chart has no usable data. |
| className | string | — | Merged onto the responsive chart wrapper. |
| ChartDataset.data* | (number | null | { x: number; y: number; r?: number })[] | — | Numeric values for line/bar/radial charts or coordinate points for scatter/bubble charts. |
| ChartDataset.type / stack | "bar" | "line" / string | — | Optional Cartesian dataset controller and stack group. Use dataset types together for mixed bar-line charts. |
| ChartDataset.backgroundColor / borderColor | string | string[] | — | Single or per-point colors; defaults to the kit palette and theme tokens. Pie and donut charts are intentionally borderless. |
| ChartDataset.fill / tension / pointRadius / showLine | boolean | number | — | Line and point rendering controls used by line, radar, scatter, and bubble charts. |
| options.plugins.legend / tooltip / title | ChartLegendOptions | ChartTooltipOptions | ChartTitleOptions | — | Controls chart chrome, hover feedback, titles, and clickable dataset visibility. |
| ChartTitleOptions.align | "start" | "center" | "end" | start | Aligns the chart title to the start, center, or end of the chart. String titles stay on one row; pass an array to provide explicit title rows. |
| ChartTitleOptions.fontSize | number | 15 | Sets the chart title's rendered size in pixels and is normalized across radial and Cartesian chart viewports. The default is 15; use a string for a single-row title or an array for explicit multiline content. |
| options.plugins.threeD | ChartThreeDOptions (pie/donut only) | — | Opt-in oblique SVG pie/donut projection with a 28-unit default depth, complete colored outer, inner, and radial sidewalls, an elliptical top face, perspective, and labels. |
| options.scales.x / y / r | ChartScaleOptions | — | Axis visibility, bounds, grid, tick formatting, titles, and stacking. |
| options.scales.*.ticks | ChartTicksOptions | — | Tick formatting with automatic dense-label skipping, tick limits, and optional rotation controls. |
| options.animation | false | ChartAnimation | { duration: 900, easing: 'easeOutQuart' } | Mount/update animation settings with an optional completion callback. |
| options.transitions.show / hide | false | ChartAnimation | { duration: 450, easing: 'easeOutQuart' } | Overrides the legend dataset show/hide transition; rapid toggles reverse from the current geometry. |
| options.transitions.hover | false | ChartAnimation | { duration: 180, easing: 'easeOutCubic' } | Overrides animated pie, donut, and polar-area sector hover expansion. The transition respects reduced-motion preferences and does not enable 3D on polar-area charts. |
| options.responsive / maintainAspectRatio / aspectRatio / indexAxis | boolean | number | 'x' | 'y' | — | Responsive sizing, aspect-ratio behavior, and horizontal Cartesian layout. |
Accessibility
- Renders the visual as an SVG with `role="img"`, an accessible name, and a descriptive `<desc>`.
- Legend dataset toggles are keyboard-focusable SVG controls and expose pressed state.
- Set `showDataTable` when users need a complete, linearized representation of the plotted values.
- Tooltips are supplementary visual feedback; important values should also be available through visible text or the optional data table.
- The mount animation automatically disables itself when the user prefers reduced motion.
Source
registry/rck/ui/chart.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type ChartType =
"line" | "bar" | "radar" | "donut" | "pie" | "polarArea" | "bubble" | "scatter";
export type ChartEasing =
| "linear"
| "easeInQuad"
| "easeOutQuad"
| "easeInOutQuad"
| "easeInCubic"
| "easeOutCubic"
| "easeInOutCubic"
| "easeInQuart"
| "easeOutQuart"
| "easeInOutQuart"
| "easeInQuint"
| "easeOutQuint"
| "easeInOutQuint"
| "easeInSine"
| "easeOutSine"
| "easeInOutSine"
| "easeInExpo"
| "easeOutExpo"
| "easeInOutExpo"
| "easeInCirc"
| "easeOutCirc"
| "easeInOutCirc";
export interface ChartPoint {
x: number;
y: number;
}
export interface ChartBubblePoint extends ChartPoint {
r: number;
}
export type ChartDataPoint = number | null | ChartPoint | ChartBubblePoint;
export type ChartColor = string | readonly string[];
export interface ChartDataset {
label?: string;
data: readonly ChartDataPoint[];
type?: "bar" | "line";
stack?: string;
backgroundColor?: ChartColor;
/** Ignored by pie and donut charts, which are intentionally borderless. */
borderColor?: ChartColor;
/** Ignored by pie and donut charts, which are intentionally borderless. */
borderWidth?: number;
borderRadius?: number;
borderDash?: readonly number[];
fill?: boolean;
tension?: number;
pointRadius?: number;
pointHoverRadius?: number;
showLine?: boolean;
hidden?: boolean;
hoverOffset?: number;
}
export interface ChartData {
labels?: readonly string[];
datasets: readonly ChartDataset[];
}
export interface ChartTickContext {
value: number | string;
index: number;
}
export interface ChartTooltipContext {
datasetIndex: number;
dataIndex: number;
datasetLabel?: string;
label?: string;
value: number | string;
raw: ChartDataPoint;
}
export interface ChartAnimation {
duration?: number;
delay?: number;
easing?: ChartEasing;
onComplete?: () => void;
}
export interface ChartTitleOptions {
display?: boolean;
text?: string | readonly string[];
color?: string;
fontSize?: number;
align?: "start" | "center" | "end";
}
export interface ChartLegendLabelOptions {
color?: string;
fontSize?: number;
boxWidth?: number;
}
export interface ChartLegendOptions {
display?: boolean;
position?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
labels?: ChartLegendLabelOptions;
}
export interface ChartThreeDOptions {
enabled?: boolean;
/** Extrusion depth in SVG viewBox units. */
depth?: number;
/** Horizontal top-face offset in SVG viewBox units. */
tilt?: number;
/** Vertical compression of the projected top face, from 0.35 to 1. */
perspective?: number;
labels?: boolean;
labelFormatter?: (context: ChartTooltipContext) => React.ReactNode;
}
export interface ChartScaleTitleOptions {
display?: boolean;
text?: string;
color?: string;
fontSize?: number;
}
export interface ChartGridOptions {
display?: boolean;
color?: string;
lineWidth?: number;
drawBorder?: boolean;
}
export interface ChartTicksOptions {
display?: boolean;
color?: string;
fontSize?: number;
count?: number;
stepSize?: number;
autoSkip?: boolean;
maxTicksLimit?: number;
minRotation?: number;
maxRotation?: number;
callback?: (context: ChartTickContext) => React.ReactNode;
}
export interface ChartScaleOptions {
display?: boolean;
min?: number;
max?: number;
beginAtZero?: boolean;
stacked?: boolean;
title?: ChartScaleTitleOptions;
grid?: ChartGridOptions;
ticks?: ChartTicksOptions;
}
export interface ChartTooltipOptions {
enabled?: boolean;
mode?: "nearest" | "index";
callbacks?: {
title?: (contexts: readonly ChartTooltipContext[]) => React.ReactNode;
label?: (context: ChartTooltipContext) => React.ReactNode;
};
}
interface ChartPluginOptions {
title?: ChartTitleOptions;
legend?: ChartLegendOptions;
tooltip?: ChartTooltipOptions;
}
export interface ChartOptions {
responsive?: boolean;
maintainAspectRatio?: boolean;
aspectRatio?: number;
indexAxis?: "x" | "y";
animation?: ChartAnimation | false;
transitions?: {
show?: ChartAnimation | false;
hide?: ChartAnimation | false;
hover?: ChartAnimation | false;
};
cutout?: number | string;
layout?: {
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
};
plugins?: ChartPluginOptions & { threeD?: never };
scales?: {
x?: ChartScaleOptions;
y?: ChartScaleOptions;
r?: ChartScaleOptions;
};
elements?: {
line?: { borderWidth?: number; tension?: number };
point?: { radius?: number; hoverRadius?: number; borderWidth?: number };
bar?: { borderWidth?: number; borderRadius?: number };
arc?: { borderWidth?: number };
};
}
export interface ChartRadialOptions extends Omit<ChartOptions, "plugins"> {
plugins?: ChartPluginOptions & { threeD?: ChartThreeDOptions };
}
type ChartOptionsLike = ChartOptions | ChartRadialOptions;
type NonRadialChartType = Exclude<ChartType, "pie" | "donut">;
interface ChartPropsBase extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
data: ChartData;
height?: number | string;
fallback?: React.ReactNode;
showDataTable?: boolean;
}
export type ChartProps = ChartPropsBase &
(
| { type: "pie" | "donut"; options?: ChartRadialOptions }
| { type: NonRadialChartType; options?: ChartOptions }
);
const VIEWBOX_WIDTH = 720;
const VIEWBOX_HEIGHT = 400;
const RADIAL_VIEWBOX_SIZE = 560;
const DEFAULT_PALETTE = [
"var(--primary)",
"var(--success)",
"var(--warning)",
"#8b5cf6",
"#0ea5e9",
"#f97316",
] as const;
const DEFAULT_GRID_COLOR = "var(--border)";
const DEFAULT_TEXT_COLOR = "var(--muted-foreground)";
const DEFAULT_FOREGROUND = "var(--foreground)";
const DEFAULT_TITLE_ALIGN = "start" as const;
const DEFAULT_TITLE_FONT_SIZE = 15;
const DEFAULT_DURATION = 900;
const TOOLTIP_TRANSITION_DURATION = 160;
interface Point2D {
x: number;
y: number;
}
interface CartesianPoint extends Point2D {
dataIndex: number;
datasetIndex: number;
value: number;
raw: ChartDataPoint;
}
interface BarCorners {
topLeft: boolean;
topRight: boolean;
bottomRight: boolean;
bottomLeft: boolean;
}
interface PlotLayout {
viewBoxWidth: number;
viewBoxHeight: number;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
centerX: number;
centerY: number;
radius: number;
bottomLegendHeight: number;
}
interface CartesianScales {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
xIsCategory: boolean;
x: (value: number) => number;
y: (value: number) => number;
}
interface TooltipState {
x: number;
y: number;
active: { datasetIndex: number; dataIndex: number };
contexts: readonly ChartTooltipContext[];
}
interface ChartViewport {
width: number;
height: number;
}
interface ChartRenderProps {
data: ChartData;
datasets: readonly ChartDataset[];
type: ChartType;
options: ChartOptionsLike;
layout: PlotLayout;
progress: number;
datasetProgress: Readonly<Record<number, number>>;
labels: readonly string[];
active?: { datasetIndex: number; dataIndex: number } | null;
hoverProgress?: number;
onTooltip: (x: number, y: number, context: ChartTooltipContext) => void;
onTooltipClear?: (relatedTarget: EventTarget | null) => void;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function numberValue(point: ChartDataPoint): number | null {
if (finite(point)) return point;
if (point && "y" in point && finite(point.y)) return point.y;
return null;
}
function pointValue(point: ChartDataPoint): number | string {
if (finite(point)) return point;
if (point && "y" in point) return point.y;
return "—";
}
function asPoint(
point: ChartDataPoint,
index: number,
): ChartPoint | ChartBubblePoint | null {
if (point && typeof point === "object" && finite(point.x) && finite(point.y)) {
return point;
}
if (finite(point)) return { x: index, y: point };
return null;
}
function roundedBarPath(
x: number,
y: number,
width: number,
height: number,
radius: number,
corners: BarCorners,
): string {
const safeRadius = Math.min(
Math.max(0, radius),
Math.max(0, width / 2),
Math.max(0, height / 2),
);
const topLeft = corners.topLeft ? safeRadius : 0;
const topRight = corners.topRight ? safeRadius : 0;
const bottomRight = corners.bottomRight ? safeRadius : 0;
const bottomLeft = corners.bottomLeft ? safeRadius : 0;
return [
`M${x + topLeft},${y}`,
`L${x + width - topRight},${y}`,
topRight ? `Q${x + width},${y} ${x + width},${y + topRight}` : `L${x + width},${y}`,
`L${x + width},${y + height - bottomRight}`,
bottomRight
? `Q${x + width},${y + height} ${x + width - bottomRight},${y + height}`
: `L${x + width},${y + height}`,
`L${x + bottomLeft},${y + height}`,
bottomLeft
? `Q${x},${y + height} ${x},${y + height - bottomLeft}`
: `L${x},${y + height}`,
`L${x},${y + topLeft}`,
topLeft ? `Q${x},${y} ${x + topLeft},${y}` : `L${x},${y}`,
"Z",
].join(" ");
}
function stackedBarCorners(
indexAxis: "x" | "y",
value: number,
end: number,
final: { positive: number; negative: number },
): BarCorners {
const atPositiveEnd = Math.abs(end - final.positive) < 0.0001;
const atNegativeEnd = Math.abs(end - final.negative) < 0.0001;
if (indexAxis === "y") {
return value >= 0
? {
topLeft: false,
topRight: atPositiveEnd,
bottomRight: atPositiveEnd,
bottomLeft: false,
}
: {
topLeft: atNegativeEnd,
topRight: false,
bottomRight: false,
bottomLeft: atNegativeEnd,
};
}
return value >= 0
? {
topLeft: atPositiveEnd,
topRight: atPositiveEnd,
bottomRight: false,
bottomLeft: false,
}
: {
topLeft: false,
topRight: false,
bottomRight: atNegativeEnd,
bottomLeft: atNegativeEnd,
};
}
function outerBarCorners(indexAxis: "x" | "y", value: number): BarCorners {
if (indexAxis === "y") {
return value >= 0
? { topLeft: false, topRight: true, bottomRight: true, bottomLeft: false }
: { topLeft: true, topRight: false, bottomRight: false, bottomLeft: true };
}
return value >= 0
? { topLeft: true, topRight: true, bottomRight: false, bottomLeft: false }
: { topLeft: false, topRight: false, bottomRight: true, bottomLeft: true };
}
function getColor(
color: ChartColor | undefined,
index: number,
fallback: string,
): string {
if (typeof color === "string" || color == null) return color ?? fallback;
return color[index % color.length] ?? fallback;
}
function getDatasetColor(dataset: ChartDataset, index: number): string {
return getColor(
dataset.backgroundColor,
index,
DEFAULT_PALETTE[index % DEFAULT_PALETTE.length],
);
}
function getBorderColor(dataset: ChartDataset, index: number): string {
return getColor(dataset.borderColor, index, getDatasetColor(dataset, index));
}
function getBorderWidth(
dataset: ChartDataset,
options: ChartOptionsLike,
type: ChartType,
): number {
if (type === "pie" || type === "donut") return 0;
if (dataset.borderWidth != null) return dataset.borderWidth;
if (type === "bar") return options.elements?.bar?.borderWidth ?? 0;
if (type === "polarArea") {
return options.elements?.arc?.borderWidth ?? 2;
}
return dataset.borderWidth ?? options.elements?.line?.borderWidth ?? 2;
}
function getPadding(options: ChartOptionsLike): {
top: number;
right: number;
bottom: number;
left: number;
} {
const padding = options.layout?.padding;
if (typeof padding === "number") {
return { top: padding, right: padding, bottom: padding, left: padding };
}
return {
top: padding?.top ?? 0,
right: padding?.right ?? 0,
bottom: padding?.bottom ?? 0,
left: padding?.left ?? 0,
};
}
function titleLines(title: ChartTitleOptions | undefined): string[] {
if (!title?.text) return [];
return typeof title.text === "string" ? [title.text] : [...title.text];
}
function hasLegend(
options: ChartOptionsLike,
datasets: readonly ChartDataset[],
): boolean {
return options.plugins?.legend?.display ?? datasets.some((dataset) => dataset.label);
}
interface LegendLayoutItem {
label: string;
index: number;
x: number;
y: number;
}
interface LegendLayout {
items: readonly LegendLayoutItem[];
rows: number;
height: number;
sideWidth: number;
}
function getLegendLayout(
options: ChartOptionsLike,
datasets: readonly ChartDataset[],
viewBoxWidth: number,
viewBoxHeight: number,
): LegendLayout {
const legend = options.plugins?.legend;
if (!hasLegend(options, datasets)) {
return { items: [], rows: 0, height: 0, sideWidth: 0 };
}
const position = legend?.position ?? "top";
const labels = legend?.labels;
const boxWidth = labels?.boxWidth ?? 10;
const itemHeight = 26;
const itemWidth = (label: string) => boxWidth + 16 + label.length * 6.2;
const items = datasets.map((dataset, index) => ({
label: dataset.label ?? `Dataset ${index + 1}`,
index,
}));
const sideWidth = Math.max(132, ...items.map((item) => itemWidth(item.label) + 12));
if (position === "left" || position === "right") {
const startX = position === "left" ? 10 : viewBoxWidth - sideWidth + 10;
return {
items: items.map((item, index) => ({
...item,
x: startX,
y: 43 + index * itemHeight,
})),
rows: items.length,
height: 0,
sideWidth,
};
}
const availableWidth = Math.max(1, viewBoxWidth - 24);
const rows: Array<typeof items> = [];
let currentRow: typeof items = [];
let currentWidth = 0;
items.forEach((item) => {
const width = itemWidth(item.label);
if (currentRow.length > 0 && currentWidth + width > availableWidth) {
rows.push(currentRow);
currentRow = [];
currentWidth = 0;
}
currentRow.push(item);
currentWidth += width;
});
if (currentRow.length > 0) rows.push(currentRow);
const align = legend?.align ?? "center";
const positioned = rows.flatMap((row, rowIndex) => {
const rowWidth = row.reduce((sum, item) => sum + itemWidth(item.label), 0);
const startX =
align === "start"
? 12
: align === "end"
? viewBoxWidth - rowWidth - 12
: (viewBoxWidth - rowWidth) / 2;
let x = startX;
const y =
position === "top"
? 43 + rowIndex * itemHeight
: viewBoxHeight - 17 - (rows.length - rowIndex - 1) * itemHeight;
return row.map((item) => {
const result = { ...item, x, y };
x += itemWidth(item.label);
return result;
});
});
return {
items: positioned,
rows: rows.length,
height: rows.length * itemHeight,
sideWidth,
};
}
function getLayout(
options: ChartOptionsLike,
datasets: readonly ChartDataset[],
type: ChartType,
titleFontSize = DEFAULT_TITLE_FONT_SIZE,
): PlotLayout {
const radial =
type === "radar" || type === "polarArea" || type === "pie" || type === "donut";
const viewBoxWidth = radial ? RADIAL_VIEWBOX_SIZE : VIEWBOX_WIDTH;
const viewBoxHeight = radial ? RADIAL_VIEWBOX_SIZE : VIEWBOX_HEIGHT;
const padding = getPadding(options);
const title = options.plugins?.title;
const legend = options.plugins?.legend;
const titleHeight =
title?.display && titleLines(title).length > 0
? titleLines(title).length * Math.max(17, titleFontSize * 1.15) + 8
: 0;
const legendDisplay = hasLegend(options, datasets);
const legendPosition = legend?.position ?? "top";
const legendLayout = getLegendLayout(options, datasets, viewBoxWidth, viewBoxHeight);
const threeDOptions = getThreeDOptions(options);
const threeDDepth =
(type === "pie" || type === "donut") && (threeDOptions?.enabled ?? false)
? Math.round(clamp(threeDOptions?.depth ?? 28, 0, 40))
: 0;
const xAxisTitleHeight =
options.scales?.x?.title?.display && options.scales.x.title.text ? 20 : 0;
let left = 62 + padding.left;
let right = 24 + padding.right;
let top = (radial ? 34 : 26) + padding.top + titleHeight;
let bottom = (radial ? 34 : 46) + padding.bottom + threeDDepth;
if (legendDisplay && (legendPosition === "left" || legendPosition === "right")) {
if (legendPosition === "left") left += legendLayout.sideWidth;
else right += legendLayout.sideWidth;
} else if (legendDisplay && legendPosition === "top") {
top += legendLayout.height;
} else if (legendDisplay && legendPosition === "bottom") {
bottom += legendLayout.height + xAxisTitleHeight;
} else {
bottom += xAxisTitleHeight;
}
const width = Math.max(1, viewBoxWidth - left - right);
const height = Math.max(1, viewBoxHeight - top - bottom);
return {
viewBoxWidth,
viewBoxHeight,
left,
right,
top,
bottom,
width,
height,
centerX: left + width / 2,
centerY: top + height / 2,
radius: Math.max(1, Math.min(width, height) / 2 - (radial ? 28 : 10)),
bottomLegendHeight:
legendDisplay && legendPosition === "bottom" ? legendLayout.height : 0,
};
}
function extent(
values: readonly number[],
fallbackMin = 0,
fallbackMax = 1,
): [number, number] {
const finiteValues = values.filter(finite);
if (finiteValues.length === 0) return [fallbackMin, fallbackMax];
const min = Math.min(...finiteValues);
const max = Math.max(...finiteValues);
if (min === max) {
const offset = min === 0 ? 1 : Math.abs(min) * 0.1;
return [min - offset, max + offset];
}
return [min, max];
}
function resolveExtent(
values: readonly number[],
scale: ChartScaleOptions | undefined,
defaultBeginAtZero: boolean,
): [number, number] {
let [min, max] = extent(values);
if (scale?.beginAtZero ?? defaultBeginAtZero) {
if (min > 0) min = 0;
if (max < 0) max = 0;
}
if (scale?.min != null) min = scale.min;
if (scale?.max != null) max = scale.max;
if (min === max) max = min + 1;
return [min, max];
}
function buildCartesianScales(
datasets: readonly ChartDataset[],
labels: readonly string[],
type: ChartType,
options: ChartOptionsLike,
layout: PlotLayout,
): CartesianScales {
const indexAxis = options.indexAxis ?? "x";
const pointValues = datasets.flatMap((dataset) =>
dataset.data.flatMap((value, index) => {
const point = asPoint(value, index);
return point ? [point] : [];
}),
);
const xValues = pointValues.map((point) => point.x);
const yValues = pointValues.map((point) => point.y);
const stacked = options.scales?.x?.stacked || options.scales?.y?.stacked;
if (stacked && (type === "bar" || type === "line")) {
const stackTotals = new Map<string, { positive: number; negative: number }[]>();
datasets.forEach((dataset) => {
if ((dataset.type ?? type) !== "bar") return;
const stack = dataset.stack ?? "__default";
const totals = stackTotals.get(stack) ?? [];
while (totals.length < Math.max(labels.length, dataset.data.length)) {
totals.push({ positive: 0, negative: 0 });
}
stackTotals.set(stack, totals);
dataset.data.forEach((raw, dataIndex) => {
const value = numberValue(raw);
if (value == null) return;
const total = totals[dataIndex];
if (!total) return;
if (value >= 0) {
total.positive += value;
yValues.push(total.positive);
} else {
total.negative += value;
yValues.push(total.negative);
}
});
});
}
const category = type === "line" || type === "bar" || type === "radar";
const xIsCategory = category && indexAxis === "x";
const numericXValues = category && indexAxis === "y" ? yValues : xValues;
const numericYValues = category && indexAxis === "y" ? xValues : yValues;
const hasBarDataset =
type === "bar" || datasets.some((dataset) => dataset.type === "bar");
const xExtent = xIsCategory
? [0, Math.max(0, labels.length - 1)]
: resolveExtent(
numericXValues,
options.scales?.x,
hasBarDataset && indexAxis === "y",
);
const yExtent =
indexAxis === "y" && category
? [0, Math.max(0, labels.length - 1)]
: resolveExtent(numericYValues, options.scales?.y, hasBarDataset);
const xRange = xExtent[1] - xExtent[0] || 1;
const yRange = yExtent[1] - yExtent[0] || 1;
return {
xMin: xExtent[0],
xMax: xExtent[1],
yMin: yExtent[0],
yMax: yExtent[1],
xIsCategory,
x: (value) => layout.left + ((value - xExtent[0]) / xRange) * layout.width,
y: (value) =>
layout.top + layout.height - ((value - yExtent[0]) / yRange) * layout.height,
};
}
function tickValues(
min: number,
max: number,
scale: ChartScaleOptions | undefined,
): number[] {
const count = Math.max(2, scale?.ticks?.count ?? 5);
if (scale?.ticks?.stepSize && scale.ticks.stepSize > 0) {
const values: number[] = [];
for (
let value = min;
value <= max + scale.ticks.stepSize / 2;
value += scale.ticks.stepSize
) {
values.push(Number(value.toFixed(8)));
}
return values.length > 1 ? values : [min, max];
}
return Array.from(
{ length: count },
(_, index) => min + ((max - min) * index) / (count - 1),
);
}
function formatNumber(value: number): string {
if (Number.isInteger(value)) return String(value);
return value.toFixed(Math.abs(value) < 10 ? 1 : 0);
}
function tickLabel(
value: number | string,
index: number,
ticks: ChartTicksOptions | undefined,
): React.ReactNode {
return (
ticks?.callback?.({ value, index }) ??
(typeof value === "number" ? formatNumber(value) : value)
);
}
function categoryTickStep(count: number, ticks: ChartTicksOptions | undefined): number {
if (ticks?.autoSkip === false) return 1;
const maxTicks = ticks?.maxTicksLimit ?? 10;
return Math.max(1, Math.ceil(count / Math.max(2, maxTicks)));
}
function shouldShowCategoryTick(index: number, count: number, step: number): boolean {
return index % step === 0 || index === count - 1;
}
function pathWithTension(points: readonly Point2D[], tension: number): string {
if (points.length === 0) return "";
if (points.length === 1 || tension <= 0) {
return points
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`)
.join(" ");
}
let path = `M${points[0].x},${points[0].y}`;
for (let index = 0; index < points.length - 1; index += 1) {
const current = points[index];
const next = points[index + 1];
const previous = points[index - 1] ?? current;
const afterNext = points[index + 2] ?? next;
const control1 = {
x: current.x + ((next.x - previous.x) * tension) / 6,
y: current.y + ((next.y - previous.y) * tension) / 6,
};
const control2 = {
x: next.x - ((afterNext.x - current.x) * tension) / 6,
y: next.y - ((afterNext.y - current.y) * tension) / 6,
};
path += ` C${control1.x},${control1.y} ${control2.x},${control2.y} ${next.x},${next.y}`;
}
return path;
}
function polarPoint(
centerX: number,
centerY: number,
radius: number,
angle: number,
): Point2D {
return { x: centerX + Math.cos(angle) * radius, y: centerY + Math.sin(angle) * radius };
}
function arcPath(
centerX: number,
centerY: number,
outerRadius: number,
innerRadius: number,
startAngle: number,
endAngle: number,
): string {
const startOuter = polarPoint(centerX, centerY, outerRadius, startAngle);
const endOuter = polarPoint(centerX, centerY, outerRadius, endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
if (innerRadius <= 0.5) {
return [
`M${centerX},${centerY}`,
`L${startOuter.x},${startOuter.y}`,
`A${outerRadius},${outerRadius} 0 ${largeArc} 1 ${endOuter.x},${endOuter.y}`,
"Z",
].join(" ");
}
const endInner = polarPoint(centerX, centerY, innerRadius, endAngle);
const startInner = polarPoint(centerX, centerY, innerRadius, startAngle);
return [
`M${startOuter.x},${startOuter.y}`,
`A${outerRadius},${outerRadius} 0 ${largeArc} 1 ${endOuter.x},${endOuter.y}`,
`L${endInner.x},${endInner.y}`,
`A${innerRadius},${innerRadius} 0 ${largeArc} 0 ${startInner.x},${startInner.y}`,
"Z",
].join(" ");
}
function projectedRadialPoint(
centerX: number,
centerY: number,
radius: number,
angle: number,
perspective: number,
translateX = 0,
translateY = 0,
): Point2D {
return {
x: centerX + translateX + Math.cos(angle) * radius,
y: centerY + translateY + Math.sin(angle) * radius * perspective,
};
}
function projectedRadialArcPath(
centerX: number,
centerY: number,
outerRadius: number,
innerRadius: number,
startAngle: number,
endAngle: number,
perspective: number,
translateX = 0,
translateY = 0,
): string {
const startOuter = projectedRadialPoint(
centerX,
centerY,
outerRadius,
startAngle,
perspective,
translateX,
translateY,
);
const endOuter = projectedRadialPoint(
centerX,
centerY,
outerRadius,
endAngle,
perspective,
translateX,
translateY,
);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const outerRy = Math.max(0.5, outerRadius * perspective);
if (innerRadius <= 0.5) {
return [
`M${centerX + translateX},${centerY + translateY}`,
`L${startOuter.x},${startOuter.y}`,
`A${outerRadius},${outerRy} 0 ${largeArc} 1 ${endOuter.x},${endOuter.y}`,
"Z",
].join(" ");
}
const endInner = projectedRadialPoint(
centerX,
centerY,
innerRadius,
endAngle,
perspective,
translateX,
translateY,
);
const startInner = projectedRadialPoint(
centerX,
centerY,
innerRadius,
startAngle,
perspective,
translateX,
translateY,
);
const innerRy = Math.max(0.5, innerRadius * perspective);
return [
`M${startOuter.x},${startOuter.y}`,
`A${outerRadius},${outerRy} 0 ${largeArc} 1 ${endOuter.x},${endOuter.y}`,
`L${endInner.x},${endInner.y}`,
`A${innerRadius},${innerRy} 0 ${largeArc} 0 ${startInner.x},${startInner.y}`,
"Z",
].join(" ");
}
function radialSidewallBandPath(
centerX: number,
centerY: number,
radius: number,
startAngle: number,
endAngle: number,
depth: number,
perspective = 1,
translateX = 0,
translateY = 0,
): string {
const startTop = projectedRadialPoint(
centerX,
centerY,
radius,
startAngle,
perspective,
translateX,
translateY,
);
const endTop = projectedRadialPoint(
centerX,
centerY,
radius,
endAngle,
perspective,
translateX,
translateY,
);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const ry = Math.max(0.5, radius * perspective);
return [
`M${startTop.x},${startTop.y}`,
`A${radius},${ry} 0 ${largeArc} 1 ${endTop.x},${endTop.y}`,
`L${endTop.x},${endTop.y + depth}`,
`A${radius},${ry} 0 ${largeArc} 0 ${startTop.x},${startTop.y + depth}`,
"Z",
].join(" ");
}
function radialSidewallFacePath(
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
angle: number,
depth: number,
perspective = 1,
translateX = 0,
translateY = 0,
): string {
const outerTop = projectedRadialPoint(
centerX,
centerY,
outerRadius,
angle,
perspective,
translateX,
translateY,
);
const innerTop =
innerRadius > 0.5
? projectedRadialPoint(
centerX,
centerY,
innerRadius,
angle,
perspective,
translateX,
translateY,
)
: { x: centerX + translateX, y: centerY + translateY };
return [
`M${innerTop.x},${innerTop.y}`,
`L${outerTop.x},${outerTop.y}`,
`L${outerTop.x},${outerTop.y + depth}`,
`L${innerTop.x},${innerTop.y + depth}`,
"Z",
].join(" ");
}
function visibleRadialArcSegments(
startAngle: number,
endAngle: number,
facing: "front" | "back",
): Array<[number, number]> {
if (endAngle <= startAngle) return [];
const boundaries = [startAngle, endAngle];
const firstBoundary = Math.floor(startAngle / Math.PI) - 1;
const lastBoundary = Math.ceil(endAngle / Math.PI) + 1;
for (let index = firstBoundary; index <= lastBoundary; index += 1) {
const angle = index * Math.PI;
if (angle > startAngle && angle < endAngle) boundaries.push(angle);
}
boundaries.sort((a, b) => a - b);
const segments: Array<[number, number]> = [];
for (let index = 0; index < boundaries.length - 1; index += 1) {
const segmentStart = boundaries[index];
const segmentEnd = boundaries[index + 1];
const isFront = Math.sin((segmentStart + segmentEnd) / 2) > 0.0001;
if ((facing === "front" && isFront) || (facing === "back" && !isFront)) {
segments.push([segmentStart, segmentEnd]);
}
}
return segments;
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(mediaQuery.matches);
update();
mediaQuery.addEventListener?.("change", update);
return () => mediaQuery.removeEventListener?.("change", update);
}, []);
return reduced;
}
function useChartViewport(ref: React.RefObject<HTMLDivElement | null>): ChartViewport {
const [viewport, setViewport] = React.useState<ChartViewport>({ width: 0, height: 0 });
React.useEffect(() => {
const element = ref.current;
if (!element) return;
const update = () => {
const bounds = element.getBoundingClientRect();
setViewport((current) =>
current.width === bounds.width && current.height === bounds.height
? current
: { width: bounds.width, height: bounds.height },
);
};
update();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return viewport;
}
function easingValue(name: ChartEasing | undefined, value: number): number {
const t = clamp(value, 0, 1);
switch (name) {
case "linear":
return t;
case "easeInQuad":
return t * t;
case "easeOutQuad":
return t * (2 - t);
case "easeInOutQuad":
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
case "easeInCubic":
return t * t * t;
case "easeOutCubic":
return 1 - Math.pow(1 - t, 3);
case "easeInOutCubic":
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
case "easeInQuart":
return t * t * t * t;
case "easeOutQuart":
return 1 - Math.pow(1 - t, 4);
case "easeInOutQuart":
return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
case "easeInQuint":
return t * t * t * t * t;
case "easeOutQuint":
return 1 - Math.pow(1 - t, 5);
case "easeInOutQuint":
return t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2;
case "easeInSine":
return 1 - Math.cos((t * Math.PI) / 2);
case "easeOutSine":
return Math.sin((t * Math.PI) / 2);
case "easeInOutSine":
return -(Math.cos(Math.PI * t) - 1) / 2;
case "easeInExpo":
return t === 0 ? 0 : Math.pow(2, 10 * t - 10);
case "easeOutExpo":
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
case "easeInOutExpo":
if (t === 0 || t === 1) return t;
return t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;
case "easeInCirc":
return 1 - Math.sqrt(1 - Math.pow(t, 2));
case "easeOutCirc":
return Math.sqrt(1 - Math.pow(t - 1, 2));
case "easeInOutCirc":
return t < 0.5
? (1 - Math.sqrt(1 - Math.pow(2 * t, 2))) / 2
: (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2;
default:
return 1 - Math.pow(1 - t, 4);
}
}
function useChartProgress(
type: ChartType,
data: ChartData,
animation: ChartAnimation | false | undefined,
reducedMotion: boolean,
): number {
const duration = animation === false ? 0 : (animation?.duration ?? DEFAULT_DURATION);
const delay = animation === false ? 0 : (animation?.delay ?? 0);
const shouldAnimate = !reducedMotion && duration > 0;
const [progress, setProgress] = React.useState(shouldAnimate ? 0 : 1);
React.useEffect(() => {
let frame = 0;
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
if (animation !== false) animation?.onComplete?.();
};
if (!shouldAnimate) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset animation state when chart inputs change
setProgress(1);
finish();
return () => undefined;
}
setProgress(0);
const start = performance.now() + delay;
const tick = (now: number) => {
const raw = clamp((now - start) / duration, 0, 1);
setProgress(easingValue(animation !== false ? animation?.easing : undefined, raw));
if (raw >= 1) {
finish();
return;
}
frame = window.requestAnimationFrame(tick);
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [animation, data, delay, duration, shouldAnimate, type]);
return progress;
}
interface RadialHoverState {
active: { datasetIndex: number; dataIndex: number } | null;
progress: number;
}
function useRadialHoverProgress(
type: ChartType,
active: { datasetIndex: number; dataIndex: number } | null,
options: ChartOptionsLike,
reducedMotion: boolean,
): RadialHoverState {
const enabled = type === "pie" || type === "donut" || type === "polarArea";
const animation = React.useMemo(
() =>
options.transitions?.hover ??
(options.animation === false
? false
: { duration: 180, easing: "easeOutCubic" as const }),
[options.animation, options.transitions?.hover],
);
const activeKey = active ? `${active.datasetIndex}:${active.dataIndex}` : null;
const stateRef = React.useRef<RadialHoverState>({ active: null, progress: 0 });
const [state, setState] = React.useState<RadialHoverState>({
active: null,
progress: 0,
});
React.useEffect(() => {
if (!enabled) return;
const target = active ? 1 : 0;
const previous = stateRef.current;
const sameTarget =
activeKey != null &&
previous.active != null &&
`${previous.active.datasetIndex}:${previous.active.dataIndex}` === activeKey;
const nextActive = active ?? previous.active;
const from = sameTarget || !active ? previous.progress : 0;
const duration = animation === false ? 0 : (animation.duration ?? 180);
const delay = animation === false ? 0 : (animation.delay ?? 0);
if (reducedMotion || duration <= 0) {
const next = { active: target === 0 ? null : nextActive, progress: target };
stateRef.current = next;
setState(next);
return () => undefined;
}
let frame = 0;
const start = performance.now() + delay;
const tick = (now: number) => {
const raw = clamp((now - start) / duration, 0, 1);
const value =
from +
(target - from) *
easingValue(animation === false ? undefined : animation.easing, raw);
const next = {
active: raw >= 1 && target === 0 ? null : nextActive,
progress: value,
};
stateRef.current = next;
setState(next);
if (raw < 1) frame = window.requestAnimationFrame(tick);
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [active, activeKey, animation, enabled, reducedMotion]);
return enabled ? state : { active: null, progress: 0 };
}
function getVisibilityAnimation(
options: ChartOptionsLike,
direction: "show" | "hide",
): ChartAnimation | false {
const explicit = options.transitions?.[direction];
if (explicit !== undefined) return explicit;
if (options.animation !== undefined) return options.animation;
return { duration: 450, easing: "easeOutQuart" };
}
function useDatasetVisibility(
datasets: readonly ChartDataset[],
options: ChartOptionsLike,
reducedMotion: boolean,
): {
hidden: ReadonlySet<number>;
progress: Readonly<Record<number, number>>;
toggle: (index: number) => void;
} {
const [hidden, setHidden] = React.useState<ReadonlySet<number>>(
() => new Set(datasets.flatMap((dataset, index) => (dataset.hidden ? [index] : []))),
);
const [progress, setProgress] = React.useState<Readonly<Record<number, number>>>(() =>
Object.fromEntries(datasets.map((dataset, index) => [index, dataset.hidden ? 0 : 1])),
);
const hiddenRef = React.useRef(hidden);
const progressRef = React.useRef(progress);
const framesRef = React.useRef(new Map<number, number>());
const runsRef = React.useRef(new Map<number, number>());
const updateProgress = React.useCallback((index: number, value: number) => {
const nextValue = clamp(value, 0, 1);
setProgress((current) => {
const next = { ...current, [index]: nextValue };
progressRef.current = next;
return next;
});
}, []);
const toggle = React.useCallback(
(index: number) => {
const wasHidden = hiddenRef.current.has(index);
const target = wasHidden ? 1 : 0;
const nextHidden = new Set(hiddenRef.current);
if (wasHidden) nextHidden.delete(index);
else nextHidden.add(index);
hiddenRef.current = nextHidden;
setHidden(nextHidden);
const previousFrame = framesRef.current.get(index);
if (previousFrame != null) window.cancelAnimationFrame(previousFrame);
const run = (runsRef.current.get(index) ?? 0) + 1;
runsRef.current.set(index, run);
const animation = getVisibilityAnimation(options, wasHidden ? "show" : "hide");
const duration = animation === false ? 0 : (animation.duration ?? 450);
const delay = animation === false ? 0 : (animation.delay ?? 0);
const easing = animation === false ? undefined : animation.easing;
const onComplete = animation === false ? undefined : animation.onComplete;
if (reducedMotion || duration <= 0) {
updateProgress(index, target);
onComplete?.();
return;
}
const from = progressRef.current[index] ?? (wasHidden ? 0 : 1);
const start = performance.now() + delay;
const tick = (now: number) => {
if (runsRef.current.get(index) !== run) return;
const raw = clamp((now - start) / duration, 0, 1);
const eased = easingValue(easing, raw);
updateProgress(index, from + (target - from) * eased);
if (raw >= 1) {
framesRef.current.delete(index);
onComplete?.();
return;
}
framesRef.current.set(index, window.requestAnimationFrame(tick));
};
framesRef.current.set(index, window.requestAnimationFrame(tick));
},
[options, reducedMotion, updateProgress],
);
React.useEffect(
() => () => {
framesRef.current.forEach((frame) => window.cancelAnimationFrame(frame));
framesRef.current.clear();
},
[],
);
return { hidden, progress, toggle };
}
function makeTooltipContext(
dataset: ChartDataset,
datasetIndex: number,
dataIndex: number,
labels: readonly string[],
raw: ChartDataPoint,
): ChartTooltipContext {
return {
datasetIndex,
dataIndex,
datasetLabel: dataset.label,
label: labels[dataIndex],
value: pointValue(raw),
raw,
};
}
function isRadialChart(type: ChartType): boolean {
return type === "radar" || type === "polarArea" || type === "pie" || type === "donut";
}
function getChartViewportScale(type: ChartType, viewport: ChartViewport): number {
if (viewport.width <= 0 || viewport.height <= 0) return 1;
return isRadialChart(type)
? Math.min(
viewport.width / RADIAL_VIEWBOX_SIZE,
viewport.height / RADIAL_VIEWBOX_SIZE,
)
: viewport.height / VIEWBOX_HEIGHT;
}
function getTitleFontSize(
title: ChartTitleOptions | undefined,
type: ChartType,
viewport: ChartViewport,
): number {
const requestedSize = title?.fontSize ?? DEFAULT_TITLE_FONT_SIZE;
const scale = getChartViewportScale(type, viewport);
return scale > 0 ? requestedSize / scale : requestedSize;
}
function renderTitle(
options: ChartOptionsLike,
layout: PlotLayout,
fontSize: number,
): React.ReactNode {
const title = options.plugins?.title;
const lines = titleLines(title);
if (!title?.display || lines.length === 0) return null;
const align = title.align ?? DEFAULT_TITLE_ALIGN;
const lineHeight = Math.max(17, Math.round(fontSize * 1.15));
const x =
align === "start"
? 12
: align === "end"
? layout.viewBoxWidth - 12
: layout.viewBoxWidth / 2;
return (
<text
data-slot="chart-title"
x={x}
y={Math.max(20, fontSize * 1.25)}
textAnchor={align === "start" ? "start" : align === "end" ? "end" : "middle"}
fill={title.color ?? DEFAULT_FOREGROUND}
fontSize={fontSize}
fontWeight="600"
style={{ whiteSpace: "nowrap" }}
>
{lines.map((line, index) => (
<tspan key={`${line}-${index}`} x={x} dy={index === 0 ? 0 : lineHeight}>
{line}
</tspan>
))}
</text>
);
}
function renderLegend(
options: ChartOptionsLike,
datasets: readonly ChartDataset[],
hidden: ReadonlySet<number>,
onToggle: (index: number) => void,
layout: PlotLayout,
): React.ReactNode {
if (!hasLegend(options, datasets)) return null;
const labels = options.plugins?.legend?.labels;
const legendLayout = getLegendLayout(
options,
datasets,
layout.viewBoxWidth,
layout.viewBoxHeight,
);
const boxWidth = labels?.boxWidth ?? 10;
const fontSize = labels?.fontSize ?? 11;
const color = labels?.color ?? DEFAULT_TEXT_COLOR;
const position = options.plugins?.legend?.position ?? "top";
return (
<g
data-slot="chart-legend"
role="group"
aria-label="Chart legend"
transform={position === "left" ? "translate(0 0)" : undefined}
>
{legendLayout.items.map((item) => {
const isHidden = hidden.has(item.index);
return (
<g
key={`${item.label}-${item.index}`}
data-slot="chart-legend-item"
role="button"
tabIndex={0}
aria-label={`${isHidden ? "Show" : "Hide"} ${item.label}`}
aria-pressed={!isHidden}
opacity={isHidden ? 0.42 : 1}
className="group cursor-pointer outline-none focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-[var(--primary)]"
onClick={() => onToggle(item.index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggle(item.index);
}
}}
>
<rect
x={item.x}
y={item.y - boxWidth + 2}
width={boxWidth}
height={boxWidth}
rx={2}
fill={getDatasetColor(datasets[item.index], item.index)}
/>
<text
x={item.x + boxWidth + 6}
y={item.y + 2}
fill={color}
fontSize={fontSize}
>
{item.label}
</text>
</g>
);
})}
</g>
);
}
function renderCartesianAxes(
scales: CartesianScales,
labels: readonly string[],
options: ChartOptionsLike,
layout: PlotLayout,
indexAxis: "x" | "y",
type: ChartType,
): React.ReactNode {
const xOptions = options.scales?.x;
const yOptions = options.scales?.y;
const xDisplay = xOptions?.display ?? true;
const yDisplay = yOptions?.display ?? true;
const xGrid = xOptions?.grid?.display ?? true;
const yGrid = yOptions?.grid?.display ?? true;
const xTickDisplay = xOptions?.ticks?.display ?? true;
const yTickDisplay = yOptions?.ticks?.display ?? true;
const xTicks = scales.xIsCategory
? labels.map((label, index) => ({ value: label, position: scales.x(index), index }))
: tickValues(scales.xMin, scales.xMax, xOptions).map((value, index) => ({
value,
position: scales.x(value),
index,
}));
const yIsCategory = indexAxis === "y" && (type === "bar" || type === "line");
const yTicks = yIsCategory
? labels.map((label, index) => ({ value: label, position: scales.y(index), index }))
: tickValues(scales.yMin, scales.yMax, yOptions).map((value, index) => ({
value,
position: scales.y(value),
index,
}));
const xTickStep = scales.xIsCategory
? categoryTickStep(labels.length, xOptions?.ticks)
: categoryTickStep(xTicks.length, xOptions?.ticks);
const yTickStep = yIsCategory
? categoryTickStep(labels.length, yOptions?.ticks)
: categoryTickStep(yTicks.length, yOptions?.ticks);
const xRotation =
scales.xIsCategory && xTickStep === 1 && labels.length > 10
? clamp(xOptions?.ticks?.maxRotation ?? 35, 0, 90)
: 0;
const visibleXLabels = scales.xIsCategory
? xTicks.filter((tick) =>
shouldShowCategoryTick(tick.index, labels.length, xTickStep),
)
: xTicks.filter((tick) =>
shouldShowCategoryTick(tick.index, xTicks.length, xTickStep),
);
const visibleYLabels = yIsCategory
? yTicks.filter((tick) =>
shouldShowCategoryTick(tick.index, labels.length, yTickStep),
)
: yTicks.filter((tick) =>
shouldShowCategoryTick(tick.index, yTicks.length, yTickStep),
);
const gridColorX = xOptions?.grid?.color ?? DEFAULT_GRID_COLOR;
const gridColorY = yOptions?.grid?.color ?? DEFAULT_GRID_COLOR;
const tickColorX = xOptions?.ticks?.color ?? DEFAULT_TEXT_COLOR;
const tickColorY = yOptions?.ticks?.color ?? DEFAULT_TEXT_COLOR;
return (
<g data-slot="chart-axes" aria-hidden="true">
{xGrid &&
xDisplay &&
xTicks.map((tick) => (
<line
key={`x-grid-${tick.index}`}
x1={tick.position}
x2={tick.position}
y1={layout.top}
y2={layout.top + layout.height}
stroke={gridColorX}
strokeOpacity={0.7}
strokeWidth={xOptions?.grid?.lineWidth ?? 1}
/>
))}
{yGrid &&
yDisplay &&
yTicks.map((tick) => (
<line
key={`y-grid-${tick.index}`}
x1={layout.left}
x2={layout.left + layout.width}
y1={tick.position}
y2={tick.position}
stroke={gridColorY}
strokeOpacity={0.7}
strokeWidth={yOptions?.grid?.lineWidth ?? 1}
/>
))}
{xDisplay && (
<line
x1={layout.left}
x2={layout.left + layout.width}
y1={layout.top + layout.height}
y2={layout.top + layout.height}
stroke={gridColorX}
strokeWidth={xOptions?.grid?.drawBorder === false ? 0 : 1}
/>
)}
{yDisplay && (
<line
x1={layout.left}
x2={layout.left}
y1={layout.top}
y2={layout.top + layout.height}
stroke={gridColorY}
strokeWidth={yOptions?.grid?.drawBorder === false ? 0 : 1}
/>
)}
{indexAxis === "y" && scales.xMin < 0 && scales.xMax > 0 && (
<line
data-slot="chart-zero-axis"
x1={scales.x(0)}
x2={scales.x(0)}
y1={layout.top}
y2={layout.top + layout.height}
stroke={DEFAULT_FOREGROUND}
strokeOpacity={0.55}
strokeWidth={1}
/>
)}
{indexAxis === "x" && scales.yMin < 0 && scales.yMax > 0 && (
<line
data-slot="chart-zero-axis"
x1={layout.left}
x2={layout.left + layout.width}
y1={scales.y(0)}
y2={scales.y(0)}
stroke={DEFAULT_FOREGROUND}
strokeOpacity={0.55}
strokeWidth={1}
/>
)}
{xDisplay &&
xTickDisplay &&
visibleXLabels.map((tick) => (
<text
key={`x-label-${tick.index}`}
x={tick.position}
y={layout.top + layout.height + 21}
textAnchor={xRotation ? "end" : "middle"}
transform={
xRotation
? `rotate(-${xRotation} ${tick.position} ${layout.top + layout.height + 21})`
: undefined
}
fill={tickColorX}
fontSize={xOptions?.ticks?.fontSize ?? 10}
>
{tickLabel(tick.value, tick.index, xOptions?.ticks)}
</text>
))}
{yDisplay &&
yTickDisplay &&
visibleYLabels.map((tick) => (
<text
key={`y-label-${tick.index}`}
x={layout.left - 9}
y={tick.position + 3}
textAnchor="end"
fill={tickColorY}
fontSize={yOptions?.ticks?.fontSize ?? 10}
>
{tickLabel(tick.value, tick.index, yOptions?.ticks)}
</text>
))}
{xOptions?.title?.display && xOptions.title.text && (
<text
data-slot="chart-axis-title"
x={layout.left + layout.width / 2}
y={layout.viewBoxHeight - layout.bottomLegendHeight - 7}
textAnchor="middle"
fill={xOptions.title.color ?? DEFAULT_TEXT_COLOR}
fontSize={xOptions.title.fontSize ?? 11}
>
{xOptions.title.text}
</text>
)}
{yOptions?.title?.display && yOptions.title.text && (
<text
data-slot="chart-axis-title"
transform={`translate(${options.plugins?.legend?.position === "left" ? layout.left - 42 : 14} ${layout.top + layout.height / 2}) rotate(-90)`}
textAnchor="middle"
fill={yOptions.title.color ?? DEFAULT_TEXT_COLOR}
fontSize={yOptions.title.fontSize ?? 11}
>
{yOptions.title.text}
</text>
)}
</g>
);
}
function getCartesianDatasetType(
dataset: ChartDataset,
chartType: ChartType,
): "bar" | "line" {
if (chartType === "bar" || chartType === "line") {
return dataset.type ?? chartType;
}
return "line";
}
function renderMixedCartesianChart({
datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
}: ChartRenderProps): React.ReactNode {
const indexAxis = options.indexAxis ?? "x";
const scales = buildCartesianScales(datasets, labels, type, options, layout);
const axes = renderCartesianAxes(scales, labels, options, layout, indexAxis, type);
const count = Math.max(
labels.length,
...datasets.map((dataset) => dataset.data.length),
1,
);
const stacked = options.scales?.x?.stacked || options.scales?.y?.stacked;
const barDatasets = datasets.flatMap((dataset, datasetIndex) =>
getCartesianDatasetType(dataset, type) === "bar" ? [{ dataset, datasetIndex }] : [],
);
const stackKeys = Array.from(
new Set(barDatasets.map(({ dataset }) => dataset.stack ?? "__default")),
);
const stackIndex = new Map(stackKeys.map((key, index) => [key, index]));
const stackTotals = stackKeys.map(() =>
Array.from({ length: count }, () => ({ positive: 0, negative: 0 })),
);
const stackFinalTotals = stackKeys.map(() =>
Array.from({ length: count }, () => ({ positive: 0, negative: 0 })),
);
if (stacked) {
barDatasets.forEach(({ dataset }) => {
const stackKey = dataset.stack ?? "__default";
const slot = stackIndex.get(stackKey) ?? 0;
dataset.data.forEach((raw, dataIndex) => {
const value = numberValue(raw);
if (value == null) return;
const total = stackFinalTotals[slot][dataIndex];
if (value >= 0) total.positive += value;
else total.negative += value;
});
});
}
const groupSize = indexAxis === "x" ? layout.width / count : layout.height / count;
const groupWidth = groupSize * 0.76;
const slots = stacked ? Math.max(stackKeys.length, 1) : Math.max(barDatasets.length, 1);
const barSize = groupWidth / slots;
const bars: React.ReactNode[] = [];
barDatasets.forEach(({ dataset, datasetIndex }, datasetOrder) => {
const visibility = datasetProgress[datasetIndex] ?? 1;
const stackKey = dataset.stack ?? "__default";
const slot = stacked ? (stackIndex.get(stackKey) ?? 0) : datasetOrder;
dataset.data.forEach((raw, dataIndex) => {
const value = numberValue(raw);
if (value == null) return;
const groupStart =
(indexAxis === "x" ? layout.left : layout.top) +
groupSize * dataIndex +
(groupSize - groupWidth) / 2;
let start = 0;
let end = value * visibility;
if (stacked) {
const totals = stackTotals[slot][dataIndex];
const contribution = value * visibility;
if (value >= 0) {
start = totals.positive;
totals.positive += contribution;
end = totals.positive;
} else {
start = totals.negative;
totals.negative += contribution;
end = totals.negative;
}
}
const animatedStart = start * progress;
const animatedEnd = end * progress;
const position = groupStart + barSize * slot;
const bar =
indexAxis === "x"
? {
x: position,
y: Math.min(scales.y(animatedStart), scales.y(animatedEnd)),
width: Math.max(2, barSize - 2),
height: Math.abs(scales.y(animatedStart) - scales.y(animatedEnd)),
}
: {
x: Math.min(scales.x(animatedStart), scales.x(animatedEnd)),
y: position,
width: Math.abs(scales.x(animatedStart) - scales.x(animatedEnd)),
height: Math.max(2, barSize - 2),
};
const context = makeTooltipContext(dataset, datasetIndex, dataIndex, labels, raw);
const radius = dataset.borderRadius ?? options.elements?.bar?.borderRadius ?? 4;
const path = roundedBarPath(
bar.x,
bar.y,
bar.width,
Math.max(0, bar.height),
radius,
stacked
? stackedBarCorners(indexAxis, value, end, stackFinalTotals[slot][dataIndex])
: outerBarCorners(indexAxis, value),
);
bars.push(
<path
key={`mixed-bar-${datasetIndex}-${dataIndex}`}
data-slot="chart-bar"
data-dataset-index={datasetIndex}
data-stack={stackKey}
d={path}
x={bar.x}
y={bar.y}
width={bar.width}
height={Math.max(0, bar.height)}
fill={getDatasetColor(dataset, dataIndex)}
stroke={getBorderColor(dataset, dataIndex)}
strokeWidth={getBorderWidth(dataset, options, "bar")}
opacity={visibility}
onMouseEnter={() =>
onTooltip(
indexAxis === "x" ? bar.x + bar.width / 2 : bar.x + bar.width,
indexAxis === "x" ? bar.y : bar.y + bar.height / 2,
context,
)
}
/>,
);
});
});
const lineSeries = datasets.flatMap((dataset, datasetIndex) => {
if (getCartesianDatasetType(dataset, type) !== "line") return [];
const points = dataset.data.flatMap((raw, dataIndex) => {
const point = asPoint(raw, dataIndex);
if (!point) return [];
const xValue = indexAxis === "x" ? dataIndex : point.y;
const yValue = indexAxis === "x" ? point.y : dataIndex;
return [
{
x: scales.x(xValue),
y: scales.y(yValue),
dataIndex,
datasetIndex,
value: point.y,
raw,
} satisfies CartesianPoint,
];
});
return [{ dataset, datasetIndex, points }];
});
return (
<g data-slot="chart-mixed-cartesian">
{axes}
<g data-slot="chart-bar-series">{bars}</g>
<g data-slot="chart-cartesian-series">
{lineSeries.map(({ dataset, datasetIndex, points }) => {
const visibility = datasetProgress[datasetIndex] ?? 1;
const seriesProgress = progress * visibility;
const pointRadius = dataset.pointRadius ?? options.elements?.point?.radius ?? 3;
const hoverRadius =
dataset.pointHoverRadius ??
options.elements?.point?.hoverRadius ??
pointRadius + 2;
const baselineX = clamp(scales.x(0), layout.left, layout.left + layout.width);
const baselineY = clamp(scales.y(0), layout.top, layout.top + layout.height);
const visiblePoints = points.map((point) => ({
...point,
x: baselineX + (point.x - baselineX) * seriesProgress,
y: baselineY + (point.y - baselineY) * seriesProgress,
}));
const path = pathWithTension(
visiblePoints,
dataset.tension ?? options.elements?.line?.tension ?? 0.28,
);
const fillPath =
dataset.fill && visiblePoints.length > 1
? `${path} L${visiblePoints[visiblePoints.length - 1].x},${baselineY} L${visiblePoints[0].x},${baselineY} Z`
: undefined;
return (
<g
key={`mixed-line-${datasetIndex}`}
data-slot="chart-series"
data-dataset-type="line"
opacity={visibility}
>
{fillPath && (
<path
d={fillPath}
fill={getDatasetColor(dataset, datasetIndex)}
fillOpacity={0.14}
stroke="none"
/>
)}
{path && (
<path
data-slot="chart-line"
d={path}
fill="none"
stroke={getBorderColor(dataset, datasetIndex)}
strokeDasharray={dataset.borderDash?.join(" ")}
strokeWidth={getBorderWidth(dataset, options, "line")}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{visiblePoints.map((point) => {
const context = makeTooltipContext(
dataset,
datasetIndex,
point.dataIndex,
labels,
point.raw,
);
return (
<g key={`mixed-point-${point.dataIndex}`} data-slot="chart-point">
<circle
cx={point.x}
cy={point.y}
r={Math.max(pointRadius, 7)}
fill="transparent"
onMouseEnter={() => onTooltip(point.x, point.y, context)}
/>
<circle
cx={point.x}
cy={point.y}
r={pointRadius * seriesProgress}
fill={getDatasetColor(dataset, point.dataIndex)}
stroke={getBorderColor(dataset, point.dataIndex)}
strokeWidth={
dataset.borderWidth ?? options.elements?.point?.borderWidth ?? 2
}
/>
{hoverRadius > pointRadius && (
<circle
cx={point.x}
cy={point.y}
r={hoverRadius}
fill="transparent"
pointerEvents="none"
/>
)}
</g>
);
})}
</g>
);
})}
</g>
</g>
);
}
function renderCartesianChart({
datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
}: ChartRenderProps): React.ReactNode {
if (
(type === "bar" || type === "line") &&
datasets.some((dataset) => dataset.type != null || dataset.stack != null)
) {
return renderMixedCartesianChart({
data: { labels, datasets },
datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
});
}
const indexAxis = options.indexAxis ?? "x";
const scales = buildCartesianScales(datasets, labels, type, options, layout);
const isPointChart = type === "scatter" || type === "bubble";
const isBar = type === "bar";
const axes = renderCartesianAxes(scales, labels, options, layout, indexAxis, type);
if (isBar) {
const count = Math.max(
labels.length,
...datasets.map((dataset) => dataset.data.length),
1,
);
const stacked = options.scales?.x?.stacked || options.scales?.y?.stacked;
const stackTotals = Array.from({ length: count }, () => ({
positive: 0,
negative: 0,
}));
const stackFinalTotals = Array.from({ length: count }, () => ({
positive: 0,
negative: 0,
}));
if (stacked) {
datasets.forEach((dataset, datasetIndex) => {
const visibility = datasetProgress[datasetIndex] ?? 1;
dataset.data.forEach((raw, dataIndex) => {
const value = numberValue(raw);
if (value == null) return;
if (value >= 0) stackFinalTotals[dataIndex].positive += value * visibility;
else stackFinalTotals[dataIndex].negative += value * visibility;
});
});
}
const bars: React.ReactNode[] = [];
const visibleCount = datasets.length || 1;
datasets.forEach((dataset, datasetIndex) => {
const visibility = datasetProgress[datasetIndex] ?? 1;
dataset.data.forEach((raw, dataIndex) => {
const value = numberValue(raw);
if (value == null) return;
const category = dataIndex;
const groupSize =
indexAxis === "x" ? layout.width / count : layout.height / count;
const groupWidth = groupSize * 0.76;
const barSize = stacked ? groupWidth : groupWidth / visibleCount;
const groupStart =
(indexAxis === "x" ? layout.left : layout.top) +
groupSize * category +
(groupSize - groupWidth) / 2;
let start = 0;
let end = value;
if (stacked) {
const total = stackTotals[dataIndex];
const contribution = value * visibility;
if (value >= 0) {
start = total.positive;
total.positive += contribution;
end = total.positive;
} else {
start = total.negative;
total.negative += contribution;
end = total.negative;
}
} else {
end *= visibility;
}
const animatedStart = start * progress;
const animatedEnd = end * progress;
const position = stacked ? groupStart : groupStart + barSize * datasetIndex;
const bar =
indexAxis === "x"
? {
x: position,
y: Math.min(scales.y(animatedStart), scales.y(animatedEnd)),
width: Math.max(2, barSize - 2),
height: Math.abs(scales.y(animatedStart) - scales.y(animatedEnd)),
}
: {
x: Math.min(scales.x(animatedStart), scales.x(animatedEnd)),
y: position,
width: Math.abs(scales.x(animatedStart) - scales.x(animatedEnd)),
height: Math.max(2, barSize - 2),
};
const context = makeTooltipContext(dataset, datasetIndex, dataIndex, labels, raw);
const radius = dataset.borderRadius ?? options.elements?.bar?.borderRadius ?? 4;
const path = roundedBarPath(
bar.x,
bar.y,
bar.width,
Math.max(0, bar.height),
radius,
stacked
? stackedBarCorners(indexAxis, value, end, stackFinalTotals[dataIndex])
: outerBarCorners(indexAxis, value),
);
bars.push(
<path
key={`bar-${datasetIndex}-${dataIndex}`}
data-slot="chart-bar"
d={path}
x={bar.x}
y={bar.y}
width={bar.width}
height={Math.max(0, bar.height)}
fill={getDatasetColor(dataset, dataIndex)}
stroke={getBorderColor(dataset, dataIndex)}
strokeWidth={getBorderWidth(dataset, options, type)}
opacity={visibility}
onMouseEnter={() =>
onTooltip(
indexAxis === "x" ? bar.x + bar.width / 2 : bar.x + bar.width,
indexAxis === "x" ? bar.y : bar.y + bar.height / 2,
context,
)
}
/>,
);
});
});
return (
<g data-slot="chart-bar-series">
{axes}
{bars}
</g>
);
}
const series = datasets.map((dataset, datasetIndex) => {
const points = dataset.data.flatMap((raw, dataIndex) => {
const point = asPoint(raw, dataIndex);
if (!point) return [];
const xValue = isPointChart ? point.x : indexAxis === "x" ? dataIndex : point.y;
const yValue = isPointChart ? point.y : indexAxis === "x" ? point.y : dataIndex;
const pointData: CartesianPoint = {
x: scales.x(xValue),
y: scales.y(yValue),
dataIndex,
datasetIndex,
value: point.y,
raw,
};
return [pointData];
});
return { dataset, datasetIndex, points };
});
return (
<g data-slot="chart-cartesian-series">
{axes}
{series.map(({ dataset, datasetIndex, points }) => {
const visibility = datasetProgress[datasetIndex] ?? 1;
const seriesProgress = progress * visibility;
const isLine = type === "line" || (isPointChart && dataset.showLine);
const pointRadius =
dataset.pointRadius ??
options.elements?.point?.radius ??
(isPointChart ? 5 : 3);
const hoverRadius =
dataset.pointHoverRadius ??
options.elements?.point?.hoverRadius ??
pointRadius + 2;
const borderWidth =
dataset.borderWidth ?? options.elements?.point?.borderWidth ?? 2;
const tension =
dataset.tension ??
options.elements?.line?.tension ??
(type === "line" ? 0.28 : 0);
const baselineX = clamp(scales.x(0), layout.left, layout.left + layout.width);
const baselineY = clamp(scales.y(0), layout.top, layout.top + layout.height);
const visiblePoints = points.map((point) => ({
...point,
y: baselineY + (point.y - baselineY) * seriesProgress,
x: baselineX + (point.x - baselineX) * seriesProgress,
}));
const path = pathWithTension(visiblePoints, tension);
const fillPath =
dataset.fill && visiblePoints.length > 1
? indexAxis === "x"
? `${path} L${visiblePoints[visiblePoints.length - 1].x},${baselineY} L${visiblePoints[0].x},${baselineY} Z`
: `${path} L${baselineX},${visiblePoints[visiblePoints.length - 1].y} L${baselineX},${visiblePoints[0].y} Z`
: undefined;
return (
<g key={`series-${datasetIndex}`} data-slot="chart-series" opacity={visibility}>
{isLine && fillPath && (
<path
d={fillPath}
fill={getDatasetColor(dataset, datasetIndex)}
fillOpacity={0.14}
stroke="none"
/>
)}
{isLine && path && (
<path
d={path}
fill="none"
stroke={getBorderColor(dataset, datasetIndex)}
strokeDasharray={dataset.borderDash?.join(" ")}
strokeWidth={getBorderWidth(dataset, options, type)}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{visiblePoints.map((point) => {
const context = makeTooltipContext(
dataset,
datasetIndex,
point.dataIndex,
labels,
point.raw,
);
return (
<g key={`point-${point.dataIndex}`} data-slot="chart-point">
<circle
cx={point.x}
cy={point.y}
r={Math.max(pointRadius, 7)}
fill="transparent"
onMouseEnter={() => onTooltip(point.x, point.y, context)}
/>
{type !== "scatter" || pointRadius > 0 ? (
<circle
cx={point.x}
cy={point.y}
r={
type === "bubble" &&
point.raw &&
typeof point.raw === "object" &&
"r" in point.raw
? point.raw.r * seriesProgress
: pointRadius * seriesProgress
}
fill={getDatasetColor(dataset, point.dataIndex)}
stroke={getBorderColor(dataset, point.dataIndex)}
strokeWidth={borderWidth}
/>
) : null}
{hoverRadius > pointRadius && (
<circle
cx={point.x}
cy={point.y}
r={hoverRadius}
fill="transparent"
pointerEvents="none"
/>
)}
</g>
);
})}
</g>
);
})}
</g>
);
}
function renderRadarChart({
datasets,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
}: ChartRenderProps): React.ReactNode {
const count = Math.max(labels.length, 1);
const scale = options.scales?.r;
const values = datasets.flatMap((dataset) =>
dataset.data
.map((value) => numberValue(value))
.filter((value): value is number => value != null),
);
const [min, max] = resolveExtent(values, scale, true);
const levels = tickValues(min, max, scale);
const angleFor = (index: number) => -Math.PI / 2 + (index / count) * Math.PI * 2;
const valueRadius = (value: number) =>
((value - min) / (max - min || 1)) * layout.radius;
return (
<g data-slot="chart-radar">
{levels.map((level, levelIndex) => {
const points = Array.from({ length: count }, (_, index) =>
polarPoint(layout.centerX, layout.centerY, valueRadius(level), angleFor(index)),
);
return (
<polygon
key={`radar-grid-${levelIndex}`}
points={points.map((point) => `${point.x},${point.y}`).join(" ")}
fill="none"
stroke={scale?.grid?.color ?? DEFAULT_GRID_COLOR}
strokeOpacity={0.75}
/>
);
})}
{labels.map((label, index) => {
const point = polarPoint(
layout.centerX,
layout.centerY,
layout.radius,
angleFor(index),
);
const labelPoint = polarPoint(
layout.centerX,
layout.centerY,
layout.radius + 18,
angleFor(index),
);
return (
<g key={`radar-axis-${label}`}>
<line
x1={layout.centerX}
y1={layout.centerY}
x2={point.x}
y2={point.y}
stroke={scale?.grid?.color ?? DEFAULT_GRID_COLOR}
strokeOpacity={0.6}
/>
<text
x={labelPoint.x}
y={labelPoint.y + 3}
textAnchor={
labelPoint.x < layout.centerX - 4
? "end"
: labelPoint.x > layout.centerX + 4
? "start"
: "middle"
}
fill={scale?.ticks?.color ?? DEFAULT_TEXT_COLOR}
fontSize={scale?.ticks?.fontSize ?? 10}
>
{label}
</text>
</g>
);
})}
{datasets.map((dataset, datasetIndex) => {
const seriesProgress = progress * (datasetProgress[datasetIndex] ?? 1);
const points = dataset.data.slice(0, count).map((raw, index) => {
const value = numberValue(raw) ?? min;
const point = polarPoint(
layout.centerX,
layout.centerY,
valueRadius(value) * seriesProgress,
angleFor(index),
);
return { ...point, raw, dataIndex: index };
});
const path = points.map((point) => `${point.x},${point.y}`).join(" ");
return (
<g
key={`radar-series-${datasetIndex}`}
opacity={datasetProgress[datasetIndex] ?? 1}
>
<polygon
points={path}
fill={getDatasetColor(dataset, datasetIndex)}
fillOpacity={dataset.fill === false ? 0 : 0.16}
stroke={getBorderColor(dataset, datasetIndex)}
strokeWidth={getBorderWidth(dataset, options, "radar")}
strokeLinejoin="round"
/>
{points.map((point) => {
const context = makeTooltipContext(
dataset,
datasetIndex,
point.dataIndex,
labels,
point.raw,
);
return (
<circle
key={`radar-point-${point.dataIndex}`}
cx={point.x}
cy={point.y}
r={dataset.pointRadius ?? options.elements?.point?.radius ?? 4}
fill={getDatasetColor(dataset, point.dataIndex)}
stroke={getBorderColor(dataset, point.dataIndex)}
strokeWidth={2}
onMouseEnter={() => onTooltip(point.x, point.y, context)}
/>
);
})}
</g>
);
})}
</g>
);
}
function getThreeDOptions(options: ChartOptionsLike): ChartThreeDOptions | undefined {
const plugins = options.plugins;
return plugins && "threeD" in plugins ? plugins.threeD : undefined;
}
function radialSidewallColor(color: string): string {
return `color-mix(in srgb, ${color} 58%, black)`;
}
function isRadialHoverTarget(target: EventTarget | null): boolean {
return (
typeof Element !== "undefined" &&
target instanceof Element &&
Boolean(target.closest('[data-slot="chart-arc-hit"], [data-slot="chart-tooltip"]'))
);
}
function renderRadialChart({
type,
datasets,
options,
layout,
progress,
datasetProgress,
labels,
active,
hoverProgress,
onTooltip,
onTooltipClear,
}: ChartRenderProps): React.ReactNode {
const dataset = datasets[0];
if (!dataset) return null;
const visibility = datasetProgress[0] ?? 1;
const radialProgress = progress * visibility;
const values = dataset.data.map((value) => numberValue(value) ?? 0);
const positiveValues = values.map((value) => Math.max(0, value));
const total = positiveValues.reduce((sum, value) => sum + value, 0) || 1;
const cutout = type === "donut" ? (options.cutout ?? "55%") : 0;
const innerRadius =
typeof cutout === "string"
? layout.radius * clamp(Number.parseFloat(cutout) / 100, 0, 0.95)
: layout.radius * clamp(cutout / 100, 0, 0.95);
const startAngle = -Math.PI / 2;
let currentAngle = startAngle;
const isPolar = type === "polarArea";
const radialExtent = resolveExtent(positiveValues, options.scales?.r, true);
const polarRadius = (value: number) =>
((value - radialExtent[0]) / (radialExtent[1] - radialExtent[0] || 1)) *
layout.radius;
const threeDOptions = getThreeDOptions(options);
const threeD = !isPolar && (threeDOptions?.enabled ?? false);
const depth = threeD ? Math.round(clamp(threeDOptions?.depth ?? 28, 0, 40)) : 0;
const tilt = threeD ? clamp(threeDOptions?.tilt ?? 5, -30, 30) : 0;
const perspective = threeD ? clamp(threeDOptions?.perspective ?? 0.58, 0.35, 1) : 1;
const showLabels = threeD && (threeDOptions?.labels ?? true);
const sectors = positiveValues.map((value, index) => {
const sweep = isPolar
? (Math.PI * 2) / Math.max(values.length, 1)
: (value / total) * Math.PI * 2;
const sector = { value, index, start: currentAngle, sweep };
currentAngle += sweep;
return sector;
});
const sectorGeometry = sectors.map((sector) => {
const outerRadius = isPolar
? polarRadius(sector.value) * radialProgress
: layout.radius * radialProgress;
const animatedInnerRadius = innerRadius * radialProgress;
const endAngle = sector.start + sector.sweep * radialProgress;
const isActive = active?.datasetIndex === 0 && active.dataIndex === sector.index;
const offset = isActive
? (dataset.hoverOffset ?? 0) * clamp(hoverProgress ?? 1, 0, 1)
: 0;
const midpoint = sector.start + sector.sweep / 2;
const context = makeTooltipContext(
dataset,
0,
sector.index,
labels,
dataset.data[sector.index] ?? null,
);
const offsetX = Math.cos(midpoint) * offset;
const offsetY = Math.sin(midpoint) * offset;
const path = threeD
? projectedRadialArcPath(
layout.centerX,
layout.centerY,
Math.max(1, outerRadius),
animatedInnerRadius,
sector.start,
endAngle,
perspective,
tilt,
)
: arcPath(
layout.centerX,
layout.centerY,
Math.max(1, outerRadius),
animatedInnerRadius,
sector.start,
endAngle,
);
const motionTransform = offset ? `translate(${offsetX} ${offsetY})` : undefined;
const labelRadius = layout.radius + 24;
const labelPoint = {
x: layout.centerX + Math.cos(midpoint) * labelRadius + (threeD ? tilt : 0),
y: layout.centerY + Math.sin(midpoint) * labelRadius * (threeD ? perspective : 1),
};
const calloutPoint = {
x: layout.centerX + Math.cos(midpoint) * (layout.radius + 3) + (threeD ? tilt : 0),
y:
layout.centerY +
Math.sin(midpoint) * (layout.radius + 3) * (threeD ? perspective : 1),
};
const outerSegments = visibleRadialArcSegments(sector.start, endAngle, "front");
const innerSegments =
animatedInnerRadius > 0.5
? visibleRadialArcSegments(sector.start, endAngle, "back")
: [];
const sidewallOuterPaths = outerSegments.map(([segmentStart, segmentEnd]) =>
radialSidewallBandPath(
layout.centerX,
layout.centerY,
outerRadius,
segmentStart,
segmentEnd,
depth,
perspective,
tilt,
),
);
const sidewallInnerPaths =
animatedInnerRadius > 0.5
? innerSegments.map(([segmentStart, segmentEnd]) =>
radialSidewallBandPath(
layout.centerX,
layout.centerY,
animatedInnerRadius,
segmentStart,
segmentEnd,
depth,
perspective,
tilt,
),
)
: [];
const sidewallStartPath =
sector.sweep > 0 && endAngle > sector.start
? radialSidewallFacePath(
layout.centerX,
layout.centerY,
animatedInnerRadius,
outerRadius,
sector.start,
depth,
perspective,
tilt,
)
: null;
const sidewallEndPath =
sector.sweep > 0 && endAngle > sector.start
? radialSidewallFacePath(
layout.centerX,
layout.centerY,
animatedInnerRadius,
outerRadius,
endAngle,
depth,
perspective,
tilt,
)
: null;
const labelText =
threeDOptions?.labelFormatter?.(context) ??
`${labels[sector.index] ?? `Segment ${sector.index + 1}`}, ${context.value}`;
return {
sector,
outerRadius,
path,
context,
offsetX,
offsetY,
midpoint,
topTransform: motionTransform,
labelPoint,
calloutPoint,
labelText,
isActive,
sidewallTransform: motionTransform,
sidewallOuterPaths,
sidewallInnerPaths,
sidewallStartPath,
sidewallEndPath,
};
});
const depthGeometry = [...sectorGeometry].sort(
(a, b) => Math.sin(a.midpoint) - Math.sin(b.midpoint),
);
return (
<g data-slot="chart-radial">
{isPolar &&
tickValues(radialExtent[0], radialExtent[1], options.scales?.r).map(
(value, index) => (
<circle
key={`polar-grid-${index}`}
cx={layout.centerX}
cy={layout.centerY}
r={polarRadius(value)}
fill="none"
stroke={options.scales?.r?.grid?.color ?? DEFAULT_GRID_COLOR}
strokeOpacity={0.7}
/>
),
)}
{threeD && depth > 0 && (
<g data-slot="chart-3d-depth" data-depth={depth} pointerEvents="none">
{depthGeometry.map(
({
sector,
sidewallTransform,
sidewallOuterPaths,
sidewallInnerPaths,
sidewallStartPath,
sidewallEndPath,
}) => {
const sideColor = radialSidewallColor(
getDatasetColor(dataset, sector.index),
);
const commonProps = {
fill: sideColor,
stroke: sideColor,
strokeWidth: getBorderWidth(dataset, options, type),
opacity: visibility,
transform: sidewallTransform,
pointerEvents: "none" as const,
};
return (
<React.Fragment key={`arc-depth-${sector.index}`}>
{sidewallOuterPaths.map((d, index) => (
<path
key={`outer-${index}`}
data-slot="chart-arc-depth"
data-depth-side="outer"
data-depth-segment={index}
d={d}
{...commonProps}
/>
))}
{sidewallInnerPaths.map((d, index) => (
<path
key={`inner-${index}`}
data-slot="chart-arc-depth"
data-depth-side="inner"
data-depth-segment={index}
d={d}
{...commonProps}
/>
))}
{sidewallStartPath && (
<path
data-slot="chart-arc-depth"
data-depth-side="start"
d={sidewallStartPath}
{...commonProps}
/>
)}
{sidewallEndPath && (
<path
data-slot="chart-arc-depth"
data-depth-side="end"
d={sidewallEndPath}
{...commonProps}
/>
)}
</React.Fragment>
);
},
)}
</g>
)}
{sectorGeometry.map(
({ sector, path, topTransform, labelPoint, calloutPoint, labelText }) => (
<g key={`arc-${sector.index}`} data-slot="chart-arc-group">
<path
data-slot="chart-arc"
d={path}
transform={topTransform}
fill={getDatasetColor(dataset, sector.index)}
stroke={getBorderColor(dataset, sector.index)}
strokeWidth={getBorderWidth(dataset, options, type)}
opacity={visibility}
pointerEvents="none"
/>
{showLabels && (
<g data-slot="chart-3d-label" opacity={visibility} pointerEvents="none">
<line
data-slot="chart-3d-label-line"
x1={calloutPoint.x}
y1={calloutPoint.y}
x2={labelPoint.x}
y2={labelPoint.y}
stroke={DEFAULT_TEXT_COLOR}
strokeOpacity={0.72}
strokeWidth={1}
/>
<text
data-slot="chart-3d-label-text"
x={labelPoint.x + (labelPoint.x < layout.centerX ? -4 : 4)}
y={labelPoint.y + 3}
textAnchor={labelPoint.x < layout.centerX ? "end" : "start"}
fill={DEFAULT_TEXT_COLOR}
stroke="#ffffff"
strokeWidth={2}
strokeOpacity={0.95}
strokeLinecap="round"
strokeLinejoin="round"
paintOrder="stroke"
fontSize={12}
fontWeight="600"
>
{labelText}
</text>
</g>
)}
</g>
),
)}
<g data-slot="chart-radial-hit-area">
{sectorGeometry.map(({ sector, path, context, midpoint, outerRadius }) => (
<path
key={`arc-hit-${sector.index}`}
data-slot="chart-arc-hit"
data-index={sector.index}
d={path}
fill="transparent"
stroke="transparent"
strokeWidth={1}
pointerEvents="all"
aria-hidden="true"
onMouseEnter={() =>
onTooltip(
layout.centerX + tilt + Math.cos(midpoint) * outerRadius * 0.7,
layout.centerY + Math.sin(midpoint) * outerRadius * 0.7 * perspective,
context,
)
}
onMouseLeave={(event) => {
if (!isRadialHoverTarget(event.relatedTarget)) {
onTooltipClear?.(event.relatedTarget);
}
}}
/>
))}
</g>
</g>
);
}
function tooltipValue(
context: ChartTooltipContext,
options: ChartTooltipOptions | undefined,
): React.ReactNode {
return (
options?.callbacks?.label?.(context) ??
`${context.datasetLabel ? `${context.datasetLabel}: ` : ""}${context.value}`
);
}
function ChartTooltip({
tooltip,
options,
viewBoxWidth,
viewBoxHeight,
reducedMotion,
onTooltipClear,
}: {
tooltip: TooltipState | null;
options: ChartTooltipOptions | undefined;
viewBoxWidth: number;
viewBoxHeight: number;
reducedMotion: boolean;
onTooltipClear?: (relatedTarget: EventTarget | null) => void;
}): React.ReactNode {
const [displayedTooltip, setDisplayedTooltip] = React.useState<TooltipState | null>(
null,
);
const displayedTooltipRef = React.useRef<TooltipState | null>(null);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
if (tooltip) {
const entering = displayedTooltipRef.current == null;
displayedTooltipRef.current = tooltip;
// eslint-disable-next-line react-hooks/set-state-in-effect -- synchronize tooltip content with the hovered chart value
setDisplayedTooltip(tooltip);
if (!entering || reducedMotion) {
setVisible(true);
return;
}
setVisible(false);
const frame = window.requestAnimationFrame(() => setVisible(true));
return () => window.cancelAnimationFrame(frame);
}
setVisible(false);
const timeout = window.setTimeout(
() => {
displayedTooltipRef.current = null;
setDisplayedTooltip(null);
},
reducedMotion ? 0 : TOOLTIP_TRANSITION_DURATION,
);
return () => window.clearTimeout(timeout);
}, [reducedMotion, tooltip]);
if (!displayedTooltip) return null;
const title =
options?.callbacks?.title?.(displayedTooltip.contexts) ??
displayedTooltip.contexts[0]?.label;
return (
<div
data-slot="chart-tooltip"
data-state={visible ? "visible" : "hidden"}
role="status"
className="bg-foreground text-background pointer-events-auto absolute z-10 max-w-56 rounded-md px-2.5 py-2 text-xs shadow-lg"
onMouseLeave={(event) => {
if (!isRadialHoverTarget(event.relatedTarget)) {
onTooltipClear?.(event.relatedTarget);
}
}}
style={{
left: `${(displayedTooltip.x / viewBoxWidth) * 100}%`,
top: `${(displayedTooltip.y / viewBoxHeight) * 100}%`,
opacity: visible ? 1 : 0,
transform: `translate(-50%, calc(-100% - 10px)) scale(${visible ? 1 : 0.96}) translateY(${visible ? 0 : 4}px)`,
transition: reducedMotion
? "none"
: `opacity ${TOOLTIP_TRANSITION_DURATION}ms ease-out, transform ${TOOLTIP_TRANSITION_DURATION}ms cubic-bezier(0.16, 1, 0.3, 1)`,
willChange: "opacity, transform",
}}
>
{title ? <div className="mb-1 font-medium">{title}</div> : null}
{displayedTooltip.contexts.map((context) => (
<div key={`${context.datasetIndex}-${context.dataIndex}`}>
{tooltipValue(context, options)}
</div>
))}
</div>
);
}
function renderDataTable(
data: ChartData,
labels: readonly string[],
type: ChartType,
label: string,
): React.ReactNode {
return (
<table className="sr-only" data-slot="chart-data-table">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">
{type === "scatter" || type === "bubble" ? "Point" : "Label"}
</th>
{data.datasets.map((dataset, index) => (
<th scope="col" key={`${dataset.label ?? "dataset"}-${index}`}>
{dataset.label ?? `Dataset ${index + 1}`}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from(
{
length: Math.max(
labels.length,
...data.datasets.map((dataset) => dataset.data.length),
0,
),
},
(_, rowIndex) => (
<tr key={rowIndex}>
<th scope="row">{labels[rowIndex] ?? `Point ${rowIndex + 1}`}</th>
{data.datasets.map((dataset, datasetIndex) => {
const raw = dataset.data[rowIndex];
const value =
raw && typeof raw === "object"
? `${raw.x}, ${raw.y}${"r" in raw ? `, ${raw.r}` : ""}`
: (raw ?? "—");
return <td key={`${datasetIndex}-${rowIndex}`}>{String(value)}</td>;
})}
</tr>
),
)}
</tbody>
</table>
);
}
function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null): void {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
export const Chart = React.forwardRef<HTMLDivElement, ChartProps>(
(
{
className,
style,
type,
data,
options = {},
height,
fallback,
showDataTable = false,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
...props
},
ref,
) => {
const chartRef = React.useRef<HTMLDivElement | null>(null);
const setChartRef = React.useCallback(
(node: HTMLDivElement | null) => {
chartRef.current = node;
assignRef(ref, node);
},
[ref],
);
const viewport = useChartViewport(chartRef);
const reducedMotion = usePrefersReducedMotion();
const progress = useChartProgress(type, data, options.animation, reducedMotion);
const isRadialVisual = isRadialChart(type);
const {
hidden,
progress: datasetProgress,
toggle: toggleDataset,
} = useDatasetVisibility(data.datasets, options, reducedMotion);
const [tooltip, setTooltip] = React.useState<TooltipState | null>(null);
const labels = data.labels ?? [];
const interactiveDatasets = data.datasets.filter(
(_, index) => (datasetProgress[index] ?? 1) > 0,
);
const titleFontSize = getTitleFontSize(options.plugins?.title, type, viewport);
const layout = getLayout(options, data.datasets, type, titleFontSize);
const hoverState = useRadialHoverProgress(
type,
tooltip?.active ?? null,
options,
reducedMotion,
);
const accessibleLabel = ariaLabel ?? `${type} chart`;
const hasData =
data.datasets.length > 0 &&
data.datasets.some((dataset) =>
dataset.data.some(
(point) =>
numberValue(point) != null ||
(point && typeof point === "object" && finite(point.x)),
),
);
const responsive = options.responsive !== false;
const maintainAspectRatio = options.maintainAspectRatio !== false;
const aspectRatio = options.aspectRatio ?? (isRadialVisual ? 1 : 2);
const computedStyle: React.CSSProperties = {
display: "block",
flexShrink: 0,
minWidth: 0,
...(responsive ? { width: "100%" } : { width: `${layout.viewBoxWidth}px` }),
...(height != null
? { height: typeof height === "number" ? `${height}px` : height }
: maintainAspectRatio
? { aspectRatio: String(aspectRatio) }
: { height: `${layout.viewBoxHeight}px` }),
...(height == null ? { minHeight: "320px" } : {}),
...style,
};
const onTooltip = (x: number, y: number, context: ChartTooltipContext) => {
const tooltipOptions = options.plugins?.tooltip;
if (tooltipOptions?.enabled === false) return;
const contexts =
tooltipOptions?.mode === "index"
? interactiveDatasets.flatMap((dataset) => {
const actualIndex = data.datasets.indexOf(dataset);
const raw = dataset.data[context.dataIndex];
return raw == null
? []
: [
makeTooltipContext(
dataset,
actualIndex,
context.dataIndex,
labels,
raw,
),
];
})
: [context];
setTooltip({
x,
y,
active: { datasetIndex: context.datasetIndex, dataIndex: context.dataIndex },
contexts,
});
};
return (
<div
ref={setChartRef}
data-slot="chart"
data-chart-type={type}
className={cn("relative min-w-0", className)}
style={computedStyle}
onMouseLeave={(event) => {
if (!isRadialHoverTarget(event.relatedTarget)) setTooltip(null);
}}
{...props}
>
{hasData ? (
<svg
data-slot="chart-svg"
role="img"
aria-label={accessibleLabel}
aria-describedby={ariaDescribedBy}
viewBox={`0 0 ${layout.viewBoxWidth} ${layout.viewBoxHeight}`}
preserveAspectRatio={isRadialVisual ? "xMidYMid meet" : "none"}
className="block size-full overflow-visible"
>
{ariaDescribedBy ? null : (
<desc>{`Interactive ${type} chart with ${data.datasets.length} dataset${data.datasets.length === 1 ? "" : "s"}.`}</desc>
)}
{renderTitle(options, layout, titleFontSize)}
{renderLegend(options, data.datasets, hidden, toggleDataset, layout)}
{type === "radar"
? renderRadarChart({
data,
datasets: data.datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
})
: type === "pie" || type === "donut" || type === "polarArea"
? renderRadialChart({
data,
datasets: data.datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
active: hoverState.active,
hoverProgress: hoverState.progress,
onTooltip,
onTooltipClear: (relatedTarget) => {
if (!isRadialHoverTarget(relatedTarget)) setTooltip(null);
},
})
: renderCartesianChart({
data,
datasets: data.datasets,
type,
options,
layout,
progress,
datasetProgress,
labels,
onTooltip,
})}
</svg>
) : (
<div
role="img"
aria-label={accessibleLabel}
className="text-muted-foreground flex size-full min-h-32 items-center justify-center text-sm"
>
{fallback ?? "No chart data"}
</div>
)}
<ChartTooltip
tooltip={tooltip}
options={options.plugins?.tooltip}
viewBoxWidth={layout.viewBoxWidth}
viewBoxHeight={layout.viewBoxHeight}
reducedMotion={reducedMotion}
onTooltipClear={(relatedTarget) => {
if (!isRadialHoverTarget(relatedTarget)) setTooltip(null);
}}
/>
{showDataTable && renderDataTable(data, labels, type, accessibleLabel)}
</div>
);
},
);
Chart.displayName = "Chart";