@kind-ui/charts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +966 -0
- package/dist/activity-rings.d.ts +47 -0
- package/dist/activity-rings.js +55 -0
- package/dist/animation.d.ts +27 -0
- package/dist/animation.js +102 -0
- package/dist/area-chart.d.ts +9 -0
- package/dist/area-chart.js +33 -0
- package/dist/area-clay.d.ts +2 -0
- package/dist/area-clay.js +6 -0
- package/dist/area-material.d.ts +8 -0
- package/dist/area-material.js +66 -0
- package/dist/area-series.d.ts +11 -0
- package/dist/area-series.js +34 -0
- package/dist/bar-category.d.ts +5 -0
- package/dist/bar-category.js +35 -0
- package/dist/bar-chart.d.ts +17 -0
- package/dist/bar-chart.js +42 -0
- package/dist/bar-clay.d.ts +4 -0
- package/dist/bar-clay.js +6 -0
- package/dist/bar-material.d.ts +8 -0
- package/dist/bar-material.js +19 -0
- package/dist/bar-paper.d.ts +2 -0
- package/dist/bar-paper.js +6 -0
- package/dist/bar-series.d.ts +13 -0
- package/dist/bar-series.js +178 -0
- package/dist/box-material.d.ts +14 -0
- package/dist/box-material.js +30 -0
- package/dist/box-plot.d.ts +50 -0
- package/dist/box-plot.js +218 -0
- package/dist/category-cells.d.ts +6 -0
- package/dist/category-cells.js +41 -0
- package/dist/chart-context.d.ts +11 -0
- package/dist/chart-context.js +9 -0
- package/dist/combo-chart.d.ts +16 -0
- package/dist/combo-chart.js +64 -0
- package/dist/configured-line-chart.d.ts +79 -0
- package/dist/configured-line-chart.js +77 -0
- package/dist/emphasis.d.ts +44 -0
- package/dist/emphasis.js +128 -0
- package/dist/heatmap-model.d.ts +38 -0
- package/dist/heatmap-model.js +73 -0
- package/dist/heatmap.d.ts +56 -0
- package/dist/heatmap.js +366 -0
- package/dist/histogram-chart.d.ts +14 -0
- package/dist/histogram-chart.js +27 -0
- package/dist/histogram-data.d.ts +17 -0
- package/dist/histogram-data.js +66 -0
- package/dist/histogram-material.d.ts +13 -0
- package/dist/histogram-material.js +8 -0
- package/dist/histogram-series.d.ts +16 -0
- package/dist/histogram-series.js +67 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/legend.d.ts +16 -0
- package/dist/legend.js +30 -0
- package/dist/line-chart.d.ts +41 -0
- package/dist/line-chart.js +160 -0
- package/dist/line-material.d.ts +9 -0
- package/dist/line-material.js +24 -0
- package/dist/line-series.d.ts +15 -0
- package/dist/line-series.js +40 -0
- package/dist/pie-chart.d.ts +17 -0
- package/dist/pie-chart.js +68 -0
- package/dist/pie-material.d.ts +19 -0
- package/dist/pie-material.js +16 -0
- package/dist/pie-series.d.ts +14 -0
- package/dist/pie-series.js +208 -0
- package/dist/polar-chart.d.ts +33 -0
- package/dist/polar-chart.js +145 -0
- package/dist/polar-material.d.ts +7 -0
- package/dist/polar-material.js +9 -0
- package/dist/polar-series.d.ts +17 -0
- package/dist/polar-series.js +169 -0
- package/dist/radar-interaction.d.ts +29 -0
- package/dist/radar-interaction.js +138 -0
- package/dist/radial-bar-label.d.ts +15 -0
- package/dist/radial-bar-label.js +136 -0
- package/dist/radial-category.d.ts +4 -0
- package/dist/radial-category.js +4 -0
- package/dist/root.d.ts +14 -0
- package/dist/root.js +29 -0
- package/dist/sankey-chart.d.ts +33 -0
- package/dist/sankey-chart.js +142 -0
- package/dist/sankey-colors.d.ts +10 -0
- package/dist/sankey-colors.js +6 -0
- package/dist/sankey-data.d.ts +30 -0
- package/dist/sankey-data.js +65 -0
- package/dist/sankey-finish.d.ts +13 -0
- package/dist/sankey-finish.js +10 -0
- package/dist/sankey-legend.d.ts +14 -0
- package/dist/sankey-legend.js +9 -0
- package/dist/sankey-marks.d.ts +31 -0
- package/dist/sankey-marks.js +67 -0
- package/dist/sankey-table.d.ts +11 -0
- package/dist/sankey-table.js +8 -0
- package/dist/scatter-chart.d.ts +16 -0
- package/dist/scatter-chart.js +64 -0
- package/dist/scatter-material.d.ts +8 -0
- package/dist/scatter-material.js +39 -0
- package/dist/scatter-series.d.ts +11 -0
- package/dist/scatter-series.js +74 -0
- package/dist/scatter-tooltip.d.ts +24 -0
- package/dist/scatter-tooltip.js +96 -0
- package/dist/styles.css +443 -0
- package/dist/surface-material.d.ts +13 -0
- package/dist/surface-material.js +7 -0
- package/dist/tooltip-content.d.ts +19 -0
- package/dist/tooltip-content.js +60 -0
- package/dist/tooltip-number.d.ts +4 -0
- package/dist/tooltip-number.js +68 -0
- package/dist/tooltip.d.ts +29 -0
- package/dist/tooltip.js +73 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/dist/waterfall-data.d.ts +24 -0
- package/dist/waterfall-data.js +45 -0
- package/dist/waterfall-series.d.ts +15 -0
- package/dist/waterfall-series.js +35 -0
- package/package.json +49 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { use, useCallback, useId, useLayoutEffect, useRef } from "react";
|
|
5
|
+
import { Bar, Rectangle, useActiveTooltipDataPoints, useActiveTooltipLabel, useChartLayout, useIsTooltipActive, usePlotArea, useXAxisDomain, useXAxisScale, useYAxisDomain, useYAxisScale, } from "recharts";
|
|
6
|
+
import { useBarCategoryHover } from "./bar-category.js";
|
|
7
|
+
import { BarMotion } from "./bar-chart.js";
|
|
8
|
+
import { BarMaterialFilter } from "./bar-material.js";
|
|
9
|
+
import { useChart } from "./chart-context.js";
|
|
10
|
+
import { useEmphasis } from "./emphasis.js";
|
|
11
|
+
import { useChartKeyboard, useLineInteraction } from "./line-chart.js";
|
|
12
|
+
/** A registered native Bar; axes, shape, cells, labels and handlers stay consumer-owned. */
|
|
13
|
+
export function BarSeries({ seriesKey, emphasisKey, hide, fill, className, style, material = "plain", ...props }) {
|
|
14
|
+
const { config, visibleSeries } = useChart();
|
|
15
|
+
const { registerSeries, invalidate, data, categoryEmphasis, registerCategoryEligibility } = useLineInteraction();
|
|
16
|
+
const { reveal, options, finish } = use(BarMotion);
|
|
17
|
+
const generatedId = useId();
|
|
18
|
+
const id = props.id || generatedId;
|
|
19
|
+
const selector = `kind-ui-bar-${generatedId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
20
|
+
const clipId = `${selector}-reveal`;
|
|
21
|
+
const filterId = `${selector}-material`;
|
|
22
|
+
const materialized = material !== "plain" &&
|
|
23
|
+
props.shape === undefined &&
|
|
24
|
+
props.filter === undefined &&
|
|
25
|
+
(props.activeBar === undefined || typeof props.activeBar === "boolean");
|
|
26
|
+
const key = seriesKey ?? (typeof props.dataKey === "string" ? props.dataKey : undefined);
|
|
27
|
+
const effectiveHide = hide === true || (visibleSeries !== undefined && !visibleSeries.includes(key ?? ""));
|
|
28
|
+
const area = usePlotArea();
|
|
29
|
+
const layout = useChartLayout();
|
|
30
|
+
const xScale = useXAxisScale(props.xAxisId);
|
|
31
|
+
const yScale = useYAxisScale(props.yAxisId);
|
|
32
|
+
const horizontal = layout === "vertical";
|
|
33
|
+
const xDomain = useXAxisDomain(props.xAxisId);
|
|
34
|
+
const yDomain = useYAxisDomain(props.yAxisId);
|
|
35
|
+
const numericDomain = horizontal ? xDomain : yDomain;
|
|
36
|
+
const domainValues = numericDomain?.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
37
|
+
const baseline = domainValues?.length === 2
|
|
38
|
+
? Math.max(Math.min(...domainValues), Math.min(0, Math.max(...domainValues)))
|
|
39
|
+
: 0;
|
|
40
|
+
// Clamp the VALUE before scaling: native domain-edge baselines include axis padding.
|
|
41
|
+
const zero = horizontal ? xScale?.(baseline) : yScale?.(baseline);
|
|
42
|
+
// Sample real domain values and both band edges: numeric probes miss string categories.
|
|
43
|
+
const axisCoordinates = [
|
|
44
|
+
xDomain?.map((value) => [
|
|
45
|
+
xScale?.(value, { position: "start" }),
|
|
46
|
+
xScale?.(value, { position: "end" }),
|
|
47
|
+
]),
|
|
48
|
+
yDomain?.map((value) => [
|
|
49
|
+
yScale?.(value, { position: "start" }),
|
|
50
|
+
yScale?.(value, { position: "end" }),
|
|
51
|
+
]),
|
|
52
|
+
];
|
|
53
|
+
const usablePlot = area &&
|
|
54
|
+
zero !== undefined &&
|
|
55
|
+
area.width > 0 &&
|
|
56
|
+
area.height > 0 &&
|
|
57
|
+
[area.x, area.y, area.width, area.height, zero].every(Number.isFinite);
|
|
58
|
+
const geometry = usablePlot
|
|
59
|
+
? `${area.x}/${area.y}/${area.width}/${area.height}/${zero}/${xScale?.(1)}/${yScale?.(1)}/${JSON.stringify([xDomain, yDomain, axisCoordinates])}/${layout}`
|
|
60
|
+
: undefined;
|
|
61
|
+
const inputs = {
|
|
62
|
+
hide: effectiveHide,
|
|
63
|
+
dataKey: props.dataKey,
|
|
64
|
+
stackId: props.stackId,
|
|
65
|
+
xAxisId: props.xAxisId,
|
|
66
|
+
yAxisId: props.yAxisId,
|
|
67
|
+
minPointSize: props.minPointSize,
|
|
68
|
+
barSize: props.barSize,
|
|
69
|
+
maxBarSize: props.maxBarSize,
|
|
70
|
+
geometry,
|
|
71
|
+
};
|
|
72
|
+
const previous = useRef(inputs);
|
|
73
|
+
const layoutEstablished = useRef(false);
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
if (geometry === undefined || layoutEstablished.current)
|
|
76
|
+
return;
|
|
77
|
+
// Native axes register and measure auto tick widths in layout effects. Treat
|
|
78
|
+
// that pre-frame geometry as provisional, then preserve normal interruption.
|
|
79
|
+
const frame = requestAnimationFrame(() => {
|
|
80
|
+
layoutEstablished.current = true;
|
|
81
|
+
});
|
|
82
|
+
return () => cancelAnimationFrame(frame);
|
|
83
|
+
}, [geometry]);
|
|
84
|
+
useLayoutEffect(() => {
|
|
85
|
+
const old = previous.current;
|
|
86
|
+
if (effectiveHide !== old.hide ||
|
|
87
|
+
props.dataKey !== old.dataKey ||
|
|
88
|
+
props.stackId !== old.stackId ||
|
|
89
|
+
props.xAxisId !== old.xAxisId ||
|
|
90
|
+
props.yAxisId !== old.yAxisId ||
|
|
91
|
+
props.minPointSize !== old.minPointSize ||
|
|
92
|
+
props.barSize !== old.barSize ||
|
|
93
|
+
props.maxBarSize !== old.maxBarSize ||
|
|
94
|
+
(layoutEstablished.current && old.geometry !== undefined && geometry !== old.geometry))
|
|
95
|
+
invalidate();
|
|
96
|
+
previous.current = inputs;
|
|
97
|
+
});
|
|
98
|
+
useLayoutEffect(() => {
|
|
99
|
+
if (key === undefined)
|
|
100
|
+
return;
|
|
101
|
+
return registerSeries(id, key);
|
|
102
|
+
}, [id, key, registerSeries]);
|
|
103
|
+
if (key === undefined && visibleSeries !== undefined)
|
|
104
|
+
throw new Error("BarSeries requires seriesKey for controlled non-string dataKey");
|
|
105
|
+
const color = fill ?? (key && Object.hasOwn(config, key) ? `var(--color-${key})` : undefined);
|
|
106
|
+
const nativeRows = (!("data" in props) &&
|
|
107
|
+
data?.every((row) => {
|
|
108
|
+
if (typeof props.dataKey !== "string" ||
|
|
109
|
+
typeof row !== "object" ||
|
|
110
|
+
row === null ||
|
|
111
|
+
!Object.hasOwn(row, props.dataKey))
|
|
112
|
+
return false;
|
|
113
|
+
const value = row[props.dataKey];
|
|
114
|
+
return typeof value === "number" && Number.isFinite(value) && value !== baseline;
|
|
115
|
+
})) ??
|
|
116
|
+
false;
|
|
117
|
+
const categoryDomain = horizontal ? yDomain : xDomain;
|
|
118
|
+
const explicitIdentities = emphasisKey ? data?.map(emphasisKey) : undefined;
|
|
119
|
+
const semanticCategories = emphasisKey
|
|
120
|
+
? // biome-ignore lint/complexity/useOptionalChain: This guard must produce boolean false, not undefined, for category eligibility.
|
|
121
|
+
explicitIdentities !== undefined &&
|
|
122
|
+
explicitIdentities.every((key) => key !== undefined) &&
|
|
123
|
+
new Set(explicitIdentities.map(String)).size === explicitIdentities.length
|
|
124
|
+
: categoryDomain !== undefined &&
|
|
125
|
+
categoryDomain.length === data?.length &&
|
|
126
|
+
categoryDomain.every((value) => typeof value === "string") &&
|
|
127
|
+
new Set(categoryDomain).size === categoryDomain.length;
|
|
128
|
+
const eligible = nativeRows &&
|
|
129
|
+
semanticCategories &&
|
|
130
|
+
props.shape === undefined &&
|
|
131
|
+
(props.activeBar === undefined || typeof props.activeBar === "boolean");
|
|
132
|
+
useLayoutEffect(() => registerCategoryEligibility(id, eligible, effectiveHide), [id, eligible, effectiveHide, registerCategoryEligibility]);
|
|
133
|
+
const categoryShape = useCallback((shapeProps) => (_jsx(CategoryBar, { ...shapeProps, seriesKey: key, emphasisKey: emphasisKey, axisId: horizontal ? props.yAxisId : props.xAxisId })), [key, emphasisKey, horizontal, props.xAxisId, props.yAxisId]);
|
|
134
|
+
const clipped = reveal && !effectiveHide && usablePlot;
|
|
135
|
+
return (_jsxs(_Fragment, { children: [_jsx("style", { children: `.${selector} { --kind-ui-bar-clip: ${clipped ? `url(#${clipId})` : "none"}; }` }), clipped && (_jsx("defs", { children: _jsx("clipPath", { id: clipId, clipPathUnits: "userSpaceOnUse", children: _jsx(motion.rect, { "data-kind-ui": "bar-reveal", initial: horizontal
|
|
136
|
+
? { x: zero, y: area.y, width: 0, height: area.height }
|
|
137
|
+
: { x: area.x, y: zero, height: 0, width: area.width }, animate: { x: area.x, y: area.y, width: area.width, height: area.height }, transition: {
|
|
138
|
+
duration: Math.max(0, options.revealDurationMs ?? 1000) / 1000,
|
|
139
|
+
ease: options.revealEasing ?? [0.25, 0.1, 0.25, 1],
|
|
140
|
+
}, onAnimationComplete: finish }, geometry) }) })), materialized && (_jsx("defs", { "data-kind-ui": "bar-material", "data-material": material, pointerEvents: "none", children: _jsx(BarMaterialFilter, { material: material, id: filterId, horizontal: horizontal }) })), _jsx(Bar, { ...props, ...(categoryEmphasis && eligible
|
|
141
|
+
? {
|
|
142
|
+
shape: categoryShape,
|
|
143
|
+
}
|
|
144
|
+
: {}), ...(materialized ? { filter: `url(#${filterId})` } : {}), id: id, hide: effectiveHide, ...(color !== undefined ? { fill: color } : {}), className: ["kind-ui-bar-series", selector, className].filter(Boolean).join(" "), style: style, isAnimationActive: false })] }));
|
|
145
|
+
}
|
|
146
|
+
function CategoryBar({ seriesKey, emphasisKey, axisId, ...props }) {
|
|
147
|
+
const { emphasisScope, pointer } = useLineInteraction();
|
|
148
|
+
const keyboard = useChartKeyboard();
|
|
149
|
+
const hover = useBarCategoryHover();
|
|
150
|
+
const layout = useChartLayout();
|
|
151
|
+
const xDomain = useXAxisDomain(axisId);
|
|
152
|
+
const yDomain = useYAxisDomain(axisId);
|
|
153
|
+
const domain = layout === "vertical" ? yDomain : xDomain;
|
|
154
|
+
const semantic = emphasisKey ? emphasisKey(props.payload) : domain?.[props.originalDataIndex];
|
|
155
|
+
// Native index-only domains cannot promise identity across data reorder.
|
|
156
|
+
const eligible = emphasisKey !== undefined ? semantic !== undefined : typeof semantic === "string";
|
|
157
|
+
const activeLabel = useActiveTooltipLabel();
|
|
158
|
+
const activePoints = useActiveTooltipDataPoints();
|
|
159
|
+
const nativeActive = useIsTooltipActive();
|
|
160
|
+
const active = nativeActive && (activePoints?.includes(props.payload) || activeLabel === semantic);
|
|
161
|
+
const emphasis = useEmphasis({
|
|
162
|
+
kind: "category",
|
|
163
|
+
key: String(semantic),
|
|
164
|
+
scope: `${emphasisScope}/${String(axisId ?? 0)}`,
|
|
165
|
+
seriesKey,
|
|
166
|
+
}, eligible);
|
|
167
|
+
// Native axis inspection owns the category, including whitespace above and between bars.
|
|
168
|
+
// A painted mark's leave must not clear a category while the cursor remains in its band.
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
if (active && keyboard)
|
|
171
|
+
emphasis.enter("keyboard");
|
|
172
|
+
if (active && pointer && !keyboard && hover)
|
|
173
|
+
emphasis.enter("pointer");
|
|
174
|
+
else if (!hover || (pointer && !active))
|
|
175
|
+
emphasis.leave("pointer");
|
|
176
|
+
}, [active, keyboard, pointer, hover, emphasis.enter, emphasis.leave]);
|
|
177
|
+
return (_jsx("g", { "data-kind-ui": "emphasis-mark", "data-emphasis": emphasis.dimmed ? "dimmed" : "baseline", children: _jsx("g", { "data-kind-ui": "emphasis-paint", style: { opacity: emphasis.factor }, children: _jsx(Rectangle, { ...props }) }) }));
|
|
178
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type BarMaterial } from "./bar-material.js";
|
|
2
|
+
import type { BoxPlotSummary } from "./box-plot.js";
|
|
3
|
+
export type BoxPlotMaterial = BarMaterial;
|
|
4
|
+
/** User-space bounds also cover line-only and tiny summaries without invented IQR. */
|
|
5
|
+
export declare function BoxMaterialFilter({ material, id, coordinates: c, center, size, horizontal, outlierRadius, strokePadding, }: {
|
|
6
|
+
material: Exclude<BoxPlotMaterial, "plain">;
|
|
7
|
+
id: string;
|
|
8
|
+
coordinates: BoxPlotSummary;
|
|
9
|
+
center: number;
|
|
10
|
+
size: number;
|
|
11
|
+
horizontal: boolean;
|
|
12
|
+
outlierRadius: number;
|
|
13
|
+
strokePadding: number;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cloneElement } from "react";
|
|
4
|
+
import { BarMaterialFilter } from "./bar-material.js";
|
|
5
|
+
/** User-space bounds also cover line-only and tiny summaries without invented IQR. */
|
|
6
|
+
export function BoxMaterialFilter({ material, id, coordinates: c, center, size, horizontal, outlierRadius, strokePadding, }) {
|
|
7
|
+
const values = [c.lowerWhisker, c.q1, c.median, c.q3, c.upperWhisker, ...(c.outliers ?? [])];
|
|
8
|
+
const low = Math.min(...values);
|
|
9
|
+
const high = Math.max(...values);
|
|
10
|
+
const padding = 12 + Math.max(0, outlierRadius) + strokePadding;
|
|
11
|
+
const bounds = horizontal
|
|
12
|
+
? {
|
|
13
|
+
x: low - padding,
|
|
14
|
+
y: center - size / 2 - padding,
|
|
15
|
+
width: high - low + padding * 2,
|
|
16
|
+
height: size + padding * 2,
|
|
17
|
+
}
|
|
18
|
+
: {
|
|
19
|
+
x: center - size / 2 - padding,
|
|
20
|
+
y: low - padding,
|
|
21
|
+
width: size + padding * 2,
|
|
22
|
+
height: high - low + padding * 2,
|
|
23
|
+
};
|
|
24
|
+
if (material !== "glow")
|
|
25
|
+
return cloneElement(BarMaterialFilter({ material, id, horizontal }), {
|
|
26
|
+
filterUnits: "userSpaceOnUse",
|
|
27
|
+
...bounds,
|
|
28
|
+
});
|
|
29
|
+
return (_jsxs("filter", { id: id, filterUnits: "userSpaceOnUse", primitiveUnits: "userSpaceOnUse", ...bounds, colorInterpolationFilters: "sRGB", children: [_jsx("feComponentTransfer", { in: "SourceAlpha", result: "footprint", children: _jsx("feFuncA", { type: "linear", slope: 100000 }) }), _jsx("feGaussianBlur", { in: "SourceGraphic", stdDeviation: 2.5, result: "halo" }), _jsx("feFlood", { floodOpacity: "var(--kind-ui-bar-glow-opacity, 0.6)", result: "strength" }), _jsx("feComposite", { in: "halo", in2: "strength", operator: "in", result: "softHalo" }), _jsx("feComposite", { in: "softHalo", in2: "footprint", operator: "out", result: "exterior" }), _jsx("feMorphology", { in: "SourceAlpha", operator: "erode", radius: 0.8, result: "inside" }), _jsx("feComposite", { in: "SourceAlpha", in2: "inside", operator: "out", result: "edge" }), _jsx("feFlood", { floodColor: "var(--kind-ui-bar-glow-light, #fff)", floodOpacity: 0.45 }), _jsx("feComposite", { in2: "edge", operator: "in", result: "light" }), _jsx("feComposite", { in: "light", in2: "SourceGraphic", operator: "atop", result: "body" }), _jsxs("feMerge", { children: [_jsx("feMergeNode", { in: "exterior" }), _jsx("feMergeNode", { in: "body" })] })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from "react";
|
|
2
|
+
import { type BarShapeProps } from "recharts";
|
|
3
|
+
import { type BarChartProps } from "./bar-chart.js";
|
|
4
|
+
import { type BarSeriesProps } from "./bar-series.js";
|
|
5
|
+
import { type BoxPlotMaterial } from "./box-material.js";
|
|
6
|
+
/** Caller-computed statistics; no sample, quartile or fence convention is inferred. */
|
|
7
|
+
export type BoxPlotSummary = {
|
|
8
|
+
lowerWhisker: number;
|
|
9
|
+
q1: number;
|
|
10
|
+
median: number;
|
|
11
|
+
q3: number;
|
|
12
|
+
upperWhisker: number;
|
|
13
|
+
outliers?: readonly number[];
|
|
14
|
+
};
|
|
15
|
+
/** Null/undefined mean missing. Malformed present summaries throw rather than fabricate values. */
|
|
16
|
+
export declare function validateBoxPlotSummary(value: unknown): BoxPlotSummary | null;
|
|
17
|
+
/** The complete native range includes every explicitly supplied outlier. */
|
|
18
|
+
export declare function boxPlotExtent(summary: BoxPlotSummary): [number, number];
|
|
19
|
+
export type BoxPlotChartProps = BarChartProps;
|
|
20
|
+
/** Public Bar composition; Recharts owns axes, layout, domains and keyboard selection. */
|
|
21
|
+
export declare function BoxPlotChart(props: BoxPlotChartProps): import("react").JSX.Element;
|
|
22
|
+
export type BoxPlotMarkProps = Omit<ComponentPropsWithRef<"g">, "children"> & {
|
|
23
|
+
/** Screen coordinates, already mapped by the consumer's numeric axis. */
|
|
24
|
+
coordinates: BoxPlotSummary;
|
|
25
|
+
/** Center and width on the category axis, in chart pixels. */
|
|
26
|
+
center: number;
|
|
27
|
+
size: number;
|
|
28
|
+
orientation?: "vertical" | "horizontal";
|
|
29
|
+
outlierRadius?: number | undefined;
|
|
30
|
+
/** Static finish; explicit filters/styles retain consumer ownership. */
|
|
31
|
+
material?: BoxPlotMaterial | undefined;
|
|
32
|
+
};
|
|
33
|
+
/** A reusable SVG mark. Zero IQR remains a line; no minimum numeric extent is invented. */
|
|
34
|
+
export declare function BoxPlotMark({ coordinates: c, center, size, orientation, outlierRadius, strokeWidth, material, ...props }: BoxPlotMarkProps): import("react").JSX.Element;
|
|
35
|
+
export type BoxPlotShapeProps = BoxPlotMarkProps & {
|
|
36
|
+
summary: BoxPlotSummary;
|
|
37
|
+
native: BarShapeProps;
|
|
38
|
+
};
|
|
39
|
+
export type BoxPlotSeriesProps<Row extends object = Record<string, unknown>> = Omit<BarSeriesProps, "dataKey" | "shape" | "activeBar" | "material" | "stackId" | "minPointSize" | "background" | "radius"> & {
|
|
40
|
+
/** Direct property name or accessor. Nested paths are not interpreted. */
|
|
41
|
+
dataKey: (keyof Row & string) | ((row: Row) => BoxPlotSummary | null | undefined);
|
|
42
|
+
/** Required metadata/visibility key, independent of the computed native range. */
|
|
43
|
+
seriesKey: string;
|
|
44
|
+
material?: BoxPlotMaterial | undefined;
|
|
45
|
+
shape?: (props: BoxPlotShapeProps) => ReactNode;
|
|
46
|
+
markProps?: Omit<ComponentPropsWithRef<"g">, "children">;
|
|
47
|
+
outlierRadius?: number | undefined;
|
|
48
|
+
};
|
|
49
|
+
/** Registered range Bar with truthful summary geometry through public scale hooks. */
|
|
50
|
+
export declare function BoxPlotSeries<Row extends object = Record<string, unknown>>({ dataKey, seriesKey, shape, markProps, outlierRadius, material, ...props }: BoxPlotSeriesProps<Row>): import("react").JSX.Element;
|
package/dist/box-plot.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { useChartLayout, useXAxisScale, useYAxisScale } from "recharts";
|
|
5
|
+
import { BarChart } from "./bar-chart.js";
|
|
6
|
+
import { BarSeries } from "./bar-series.js";
|
|
7
|
+
import { BoxMaterialFilter } from "./box-material.js";
|
|
8
|
+
const fields = ["lowerWhisker", "q1", "median", "q3", "upperWhisker"];
|
|
9
|
+
/** Null/undefined mean missing. Malformed present summaries throw rather than fabricate values. */
|
|
10
|
+
export function validateBoxPlotSummary(value) {
|
|
11
|
+
if (value == null)
|
|
12
|
+
return null;
|
|
13
|
+
if (typeof value !== "object")
|
|
14
|
+
throw new Error("BoxPlot summary must be an object or null");
|
|
15
|
+
const record = value;
|
|
16
|
+
for (const field of fields) {
|
|
17
|
+
if (typeof record[field] !== "number" || !Number.isFinite(record[field]))
|
|
18
|
+
throw new Error(`BoxPlot ${field} must be a finite number`);
|
|
19
|
+
}
|
|
20
|
+
const summary = value;
|
|
21
|
+
if (summary.lowerWhisker > summary.q1 ||
|
|
22
|
+
summary.q1 > summary.median ||
|
|
23
|
+
summary.median > summary.q3 ||
|
|
24
|
+
summary.q3 > summary.upperWhisker)
|
|
25
|
+
throw new Error("BoxPlot requires lowerWhisker <= q1 <= median <= q3 <= upperWhisker");
|
|
26
|
+
if (summary.outliers !== undefined) {
|
|
27
|
+
if (!Array.isArray(summary.outliers))
|
|
28
|
+
throw new Error("BoxPlot outliers must be an array");
|
|
29
|
+
for (const outlier of summary.outliers) {
|
|
30
|
+
if (typeof outlier !== "number" || !Number.isFinite(outlier))
|
|
31
|
+
throw new Error("BoxPlot outliers must be finite numbers");
|
|
32
|
+
if (outlier >= summary.lowerWhisker && outlier <= summary.upperWhisker)
|
|
33
|
+
throw new Error("BoxPlot outliers must lie strictly outside the whiskers");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return summary;
|
|
37
|
+
}
|
|
38
|
+
/** The complete native range includes every explicitly supplied outlier. */
|
|
39
|
+
export function boxPlotExtent(summary) {
|
|
40
|
+
validateBoxPlotSummary(summary);
|
|
41
|
+
let low = summary.lowerWhisker;
|
|
42
|
+
let high = summary.upperWhisker;
|
|
43
|
+
for (const value of summary.outliers ?? []) {
|
|
44
|
+
low = Math.min(low, value);
|
|
45
|
+
high = Math.max(high, value);
|
|
46
|
+
}
|
|
47
|
+
return [low, high];
|
|
48
|
+
}
|
|
49
|
+
/** Public Bar composition; Recharts owns axes, layout, domains and keyboard selection. */
|
|
50
|
+
export function BoxPlotChart(props) {
|
|
51
|
+
return _jsx(BarChart, { ...props });
|
|
52
|
+
}
|
|
53
|
+
/** A reusable SVG mark. Zero IQR remains a line; no minimum numeric extent is invented. */
|
|
54
|
+
export function BoxPlotMark({ coordinates: c, center, size, orientation = "vertical", outlierRadius = 3, strokeWidth = 1.5, material = "plain", ...props }) {
|
|
55
|
+
const id = `kind-ui-box-${useId().replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
56
|
+
const node = useRef(null);
|
|
57
|
+
useImperativeHandle(props.ref, () => {
|
|
58
|
+
if (!node.current)
|
|
59
|
+
throw new Error("Missing BoxPlot mark ref");
|
|
60
|
+
return node.current;
|
|
61
|
+
}, []);
|
|
62
|
+
const width = props.style?.strokeWidth ?? strokeWidth;
|
|
63
|
+
const resolvedWidth = Number.parseFloat(String(width));
|
|
64
|
+
const miter = Number(props.style?.strokeMiterlimit ?? props.strokeMiterlimit ?? 4);
|
|
65
|
+
const initialPadding = Number.isFinite(resolvedWidth) && Number.isFinite(miter)
|
|
66
|
+
? (Math.max(0, resolvedWidth) * Math.max(1, miter)) / 2
|
|
67
|
+
: 0;
|
|
68
|
+
const [paintPadding, setPaintPadding] = useState(initialPadding);
|
|
69
|
+
const materialized = material !== "plain" && props.filter === undefined && props.style?.filter === undefined;
|
|
70
|
+
// Native child selectors can change individual stroke widths. Read their resolved
|
|
71
|
+
// paint after layout, before first paint, without moving the marks or their refs.
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const mark = node.current;
|
|
74
|
+
if (!materialized || !mark)
|
|
75
|
+
return;
|
|
76
|
+
const measure = () => {
|
|
77
|
+
let padding = initialPadding;
|
|
78
|
+
for (const part of Array.from(mark.querySelectorAll("[data-box-part]"))) {
|
|
79
|
+
const style = getComputedStyle(part);
|
|
80
|
+
let width = Number.parseFloat(style.strokeWidth);
|
|
81
|
+
if (style.strokeWidth.endsWith("%")) {
|
|
82
|
+
const svg = part.ownerSVGElement;
|
|
83
|
+
const viewport = svg?.viewBox.baseVal;
|
|
84
|
+
const w = viewport?.width || svg?.width.baseVal.value || 0;
|
|
85
|
+
const h = viewport?.height || svg?.height.baseVal.value || 0;
|
|
86
|
+
width = ((width / 100) * Math.hypot(w, h)) / Math.SQRT2;
|
|
87
|
+
}
|
|
88
|
+
const limit = Number.parseFloat(style.strokeMiterlimit);
|
|
89
|
+
if (Number.isFinite(width))
|
|
90
|
+
padding = Math.max(padding, (width * (Number.isFinite(limit) ? Math.max(1, limit) : 4)) / 2);
|
|
91
|
+
}
|
|
92
|
+
setPaintPadding((previous) => (previous === padding ? previous : padding));
|
|
93
|
+
};
|
|
94
|
+
measure();
|
|
95
|
+
// Stylesheets and ancestor theme classes can change paint without a React
|
|
96
|
+
// update. Observe the actual ownership chain, plus stylesheet loads/edits.
|
|
97
|
+
const observer = new MutationObserver(measure);
|
|
98
|
+
observer.observe(mark, {
|
|
99
|
+
attributes: true,
|
|
100
|
+
subtree: true,
|
|
101
|
+
attributeFilter: ["class", "style", "stroke", "stroke-width", "stroke-miterlimit"],
|
|
102
|
+
});
|
|
103
|
+
for (let ancestor = mark.parentElement; ancestor; ancestor = ancestor.parentElement)
|
|
104
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["class", "style"] });
|
|
105
|
+
if (mark.ownerSVGElement)
|
|
106
|
+
observer.observe(mark.ownerSVGElement, {
|
|
107
|
+
childList: true,
|
|
108
|
+
subtree: true,
|
|
109
|
+
characterData: true,
|
|
110
|
+
attributes: true,
|
|
111
|
+
attributeFilter: ["class", "style", "stroke", "stroke-width", "stroke-miterlimit"],
|
|
112
|
+
});
|
|
113
|
+
observer.observe(mark.ownerDocument.head, {
|
|
114
|
+
childList: true,
|
|
115
|
+
subtree: true,
|
|
116
|
+
characterData: true,
|
|
117
|
+
attributes: true,
|
|
118
|
+
});
|
|
119
|
+
const resize = new ResizeObserver(measure);
|
|
120
|
+
if (mark.ownerSVGElement)
|
|
121
|
+
resize.observe(mark.ownerSVGElement);
|
|
122
|
+
const view = mark.ownerDocument.defaultView;
|
|
123
|
+
view?.addEventListener("resize", measure);
|
|
124
|
+
mark.ownerDocument.addEventListener("load", measure, true);
|
|
125
|
+
mark.addEventListener("pointerover", measure);
|
|
126
|
+
mark.addEventListener("pointerout", measure);
|
|
127
|
+
const dark = view?.matchMedia("(prefers-color-scheme: dark)");
|
|
128
|
+
dark?.addEventListener("change", measure);
|
|
129
|
+
return () => {
|
|
130
|
+
observer.disconnect();
|
|
131
|
+
resize.disconnect();
|
|
132
|
+
view?.removeEventListener("resize", measure);
|
|
133
|
+
mark.ownerDocument.removeEventListener("load", measure, true);
|
|
134
|
+
mark.removeEventListener("pointerover", measure);
|
|
135
|
+
mark.removeEventListener("pointerout", measure);
|
|
136
|
+
dark?.removeEventListener("change", measure);
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
const horizontal = orientation === "horizontal";
|
|
140
|
+
const start = center - size / 2;
|
|
141
|
+
const low = Math.min(c.q1, c.q3);
|
|
142
|
+
const length = Math.abs(c.q3 - c.q1);
|
|
143
|
+
const line = (a, b, value, key) => (_jsx("line", { "data-box-part": key, ...(horizontal
|
|
144
|
+
? { x1: value, x2: value, y1: a, y2: b }
|
|
145
|
+
: { x1: a, x2: b, y1: value, y2: value }) }, key));
|
|
146
|
+
const parts = (_jsxs(_Fragment, { children: [_jsx("line", { "data-box-part": "whisker", ...(horizontal
|
|
147
|
+
? { x1: c.lowerWhisker, x2: c.upperWhisker, y1: center, y2: center }
|
|
148
|
+
: { y1: c.lowerWhisker, y2: c.upperWhisker, x1: center, x2: center }) }), line(center - size / 4, center + size / 4, c.lowerWhisker, "lower-cap"), line(center - size / 4, center + size / 4, c.upperWhisker, "upper-cap"), _jsx("rect", { "data-box-part": "box", fillOpacity: props.fillOpacity === undefined && props.style?.fillOpacity === undefined
|
|
149
|
+
? 0.18
|
|
150
|
+
: undefined, ...(horizontal
|
|
151
|
+
? { x: low, y: start, width: length, height: size }
|
|
152
|
+
: { x: start, y: low, width: size, height: length }) }), length === 0 && line(start, start + size, c.q1, "collapsed-box"), line(start, start + size, c.median, "median"), (c.outliers ?? []).map((value, index) => (_jsx("circle", { "data-box-part": "outlier", fill: "none", r: outlierRadius, ...(horizontal ? { cx: value, cy: center } : { cx: center, cy: value }) }, `${index}/${value}`)))] }));
|
|
153
|
+
return (_jsxs("g", { "data-kind-ui": "box-plot-mark", fill: "currentColor", stroke: "currentColor", strokeWidth: strokeWidth, ...props, ...(materialized ? { filter: `url(#${id})` } : {}), ref: node, children: [materialized && (_jsx("defs", { "data-kind-ui": "box-material", "data-material": material, pointerEvents: "none", children: _jsx(BoxMaterialFilter, { material: material, id: id, coordinates: c, center: center, size: size, horizontal: horizontal, outlierRadius: outlierRadius, strokePadding: Math.max(initialPadding, paintPadding) }) })), parts] }));
|
|
154
|
+
}
|
|
155
|
+
function ScaledMark({ summary, native, shape, markProps, outlierRadius, material, xAxisId, yAxisId, }) {
|
|
156
|
+
const layout = useChartLayout();
|
|
157
|
+
const xScale = useXAxisScale(xAxisId);
|
|
158
|
+
const yScale = useYAxisScale(yAxisId);
|
|
159
|
+
if (!summary)
|
|
160
|
+
return null;
|
|
161
|
+
const horizontal = layout === "vertical";
|
|
162
|
+
const scale = horizontal ? xScale : yScale;
|
|
163
|
+
if (!scale)
|
|
164
|
+
return null;
|
|
165
|
+
const values = fields.map((field) => scale(summary[field]));
|
|
166
|
+
const outliers = (summary.outliers ?? []).map((value) => scale(value));
|
|
167
|
+
if ([...values, ...outliers].some((value) => typeof value !== "number" || !Number.isFinite(value)))
|
|
168
|
+
return null;
|
|
169
|
+
const [lowerWhisker, q1, median, q3, upperWhisker] = values;
|
|
170
|
+
const center = horizontal ? native.y + native.height / 2 : native.x + native.width / 2;
|
|
171
|
+
const size = Math.abs(horizontal ? native.height : native.width);
|
|
172
|
+
const props = {
|
|
173
|
+
fill: native.fill,
|
|
174
|
+
stroke: native.stroke ?? native.fill,
|
|
175
|
+
fillOpacity: native.fillOpacity,
|
|
176
|
+
fillRule: native.fillRule,
|
|
177
|
+
opacity: native.opacity,
|
|
178
|
+
strokeOpacity: native.strokeOpacity,
|
|
179
|
+
strokeWidth: native.strokeWidth,
|
|
180
|
+
strokeDasharray: native.strokeDasharray,
|
|
181
|
+
strokeDashoffset: native.strokeDashoffset,
|
|
182
|
+
strokeLinecap: native.strokeLinecap,
|
|
183
|
+
strokeLinejoin: native.strokeLinejoin,
|
|
184
|
+
strokeMiterlimit: native.strokeMiterlimit,
|
|
185
|
+
vectorEffect: native.vectorEffect,
|
|
186
|
+
color: native.color,
|
|
187
|
+
className: native.className,
|
|
188
|
+
style: native.style,
|
|
189
|
+
filter: native.filter,
|
|
190
|
+
clipPath: native.clipPath,
|
|
191
|
+
mask: native.mask,
|
|
192
|
+
visibility: native.visibility,
|
|
193
|
+
...markProps,
|
|
194
|
+
coordinates: { lowerWhisker, q1, median, q3, upperWhisker, outliers: outliers },
|
|
195
|
+
center,
|
|
196
|
+
size,
|
|
197
|
+
orientation: horizontal ? "horizontal" : "vertical",
|
|
198
|
+
outlierRadius,
|
|
199
|
+
summary,
|
|
200
|
+
native,
|
|
201
|
+
};
|
|
202
|
+
if (shape)
|
|
203
|
+
return shape(props);
|
|
204
|
+
const { summary: _summary, native: _native, ...mark } = props;
|
|
205
|
+
return _jsx(BoxPlotMark, { ...mark, material: material });
|
|
206
|
+
}
|
|
207
|
+
/** Registered range Bar with truthful summary geometry through public scale hooks. */
|
|
208
|
+
export function BoxPlotSeries({ dataKey, seriesKey, shape, markProps, outlierRadius, material = "plain", ...props }) {
|
|
209
|
+
const read = useCallback((row) => row == null
|
|
210
|
+
? null
|
|
211
|
+
: validateBoxPlotSummary(typeof dataKey === "function" ? dataKey(row) : row[dataKey]), [dataKey]);
|
|
212
|
+
const extent = useCallback((row) => {
|
|
213
|
+
const summary = read(row);
|
|
214
|
+
return summary ? boxPlotExtent(summary) : null;
|
|
215
|
+
}, [read]);
|
|
216
|
+
const render = useMemo(() => (native) => (_jsx(ScaledMark, { summary: read(native.payload), native: native, shape: shape, material: material, markProps: markProps, outlierRadius: outlierRadius, xAxisId: props.xAxisId, yAxisId: props.yAxisId })), [read, shape, material, markProps, outlierRadius, props.xAxisId, props.yAxisId]);
|
|
217
|
+
return (_jsx(BarSeries, { ...props, seriesKey: seriesKey, dataKey: extent, shape: render, activeBar: false }));
|
|
218
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { SeriesConfig } from "./types.js";
|
|
3
|
+
/** Category identity is an own field or an accessor, never an array position. */
|
|
4
|
+
export type CategoryKey<Row> = (keyof Row & string) | ((row: Row) => string);
|
|
5
|
+
/** Only Pie and Radial category mode use this native Cell defaulting seam. */
|
|
6
|
+
export declare function categoryCells<Row>(data: readonly Row[] | undefined, categoryKey: CategoryKey<Row>, config: SeriesConfig, children: ReactNode, fill: string | undefined): ReactNode;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Children, cloneElement, Fragment, isValidElement } from "react";
|
|
4
|
+
import { Cell } from "recharts";
|
|
5
|
+
/** Only Pie and Radial category mode use this native Cell defaulting seam. */
|
|
6
|
+
export function categoryCells(data, categoryKey, config, children, fill) {
|
|
7
|
+
if (!data)
|
|
8
|
+
throw new Error("Category color defaults require explicit data");
|
|
9
|
+
const colors = data.map((row) => {
|
|
10
|
+
const key = typeof categoryKey === "function"
|
|
11
|
+
? categoryKey(row)
|
|
12
|
+
: row !== null && typeof row === "object" && Object.hasOwn(row, categoryKey)
|
|
13
|
+
? row[categoryKey]
|
|
14
|
+
: undefined;
|
|
15
|
+
if (typeof key !== "string" || !Object.hasOwn(config, key))
|
|
16
|
+
throw new Error("categoryKey must resolve to a string key in Root.config for every row");
|
|
17
|
+
const datumFill = row !== null && typeof row === "object" && "fill" in row ? row.fill : undefined;
|
|
18
|
+
return fill === undefined && datumFill === undefined ? `var(--color-${key})` : undefined;
|
|
19
|
+
});
|
|
20
|
+
if (fill !== undefined)
|
|
21
|
+
return children;
|
|
22
|
+
let index = 0;
|
|
23
|
+
function defaults(parts) {
|
|
24
|
+
return Children.map(parts, (child) => {
|
|
25
|
+
if (!isValidElement(child))
|
|
26
|
+
return child;
|
|
27
|
+
if (child.type === Fragment)
|
|
28
|
+
return cloneElement(child, {}, defaults(child.props.children));
|
|
29
|
+
if (child.type !== Cell)
|
|
30
|
+
return child;
|
|
31
|
+
const color = colors[index++];
|
|
32
|
+
return child.props.fill === undefined && color !== undefined
|
|
33
|
+
? cloneElement(child, { fill: color })
|
|
34
|
+
: child;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const resolved = defaults(children);
|
|
38
|
+
return (_jsxs(_Fragment, { children: [resolved, colors.slice(index).map((color, offset) => (
|
|
39
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Native Cell alignment is positional; Cells hold no consumer state.
|
|
40
|
+
_jsx(Cell, { ...(color ? { fill: color } : {}) }, `kind-category-${index + offset}`)))] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SeriesConfig } from "./types.js";
|
|
2
|
+
type Visibility = {
|
|
3
|
+
visibleSeries?: readonly string[];
|
|
4
|
+
onVisibleSeriesChange?: (next: string[]) => void;
|
|
5
|
+
};
|
|
6
|
+
export type ChartContextValue = Visibility & {
|
|
7
|
+
config: SeriesConfig;
|
|
8
|
+
};
|
|
9
|
+
export declare const ChartContext: import("react").Context<ChartContextValue | null>;
|
|
10
|
+
export declare function useChart(): ChartContextValue;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
export const ChartContext = createContext(null);
|
|
4
|
+
export function useChart() {
|
|
5
|
+
const chart = useContext(ChartContext);
|
|
6
|
+
if (!chart)
|
|
7
|
+
throw new Error("Chart components must be inside Root");
|
|
8
|
+
return chart;
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ComponentProps } from "react";
|
|
2
|
+
import { ComposedChart as EngineComposedChart } from "recharts";
|
|
3
|
+
import { type LineAnimation } from "./animation.js";
|
|
4
|
+
type Reveal = Pick<LineAnimation, "revealDurationMs" | "revealEasing">;
|
|
5
|
+
/** Shared hover Motion, with independently configured family entrances. */
|
|
6
|
+
export type ComboAnimation = LineAnimation & {
|
|
7
|
+
lineReveal?: false | Reveal;
|
|
8
|
+
areaReveal?: false | Reveal;
|
|
9
|
+
barReveal?: false | Reveal;
|
|
10
|
+
};
|
|
11
|
+
export type ComboChartProps = ComponentProps<typeof EngineComposedChart> & {
|
|
12
|
+
animate?: boolean | ComboAnimation | undefined;
|
|
13
|
+
};
|
|
14
|
+
/** Native ComposedChart geometry with the maintained Line, Area and Bar series. */
|
|
15
|
+
export declare function ComboChart({ animate, children, ...props }: ComboChartProps): import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { useCallback, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
5
|
+
import { ComposedChart as EngineComposedChart } from "recharts";
|
|
6
|
+
import { MotionContext } from "./animation.js";
|
|
7
|
+
import { BarLifecycle, BarMotion } from "./bar-chart.js";
|
|
8
|
+
import { LineChartFrame, useLineInteraction } from "./line-chart.js";
|
|
9
|
+
const query = "(prefers-reduced-motion: reduce)";
|
|
10
|
+
function subscribe(change) {
|
|
11
|
+
const media = window.matchMedia(query);
|
|
12
|
+
media.addEventListener("change", change);
|
|
13
|
+
return () => media.removeEventListener("change", change);
|
|
14
|
+
}
|
|
15
|
+
const snapshot = () => window.matchMedia(query).matches;
|
|
16
|
+
const serverSnapshot = () => true;
|
|
17
|
+
const defaultHover = { type: "spring", stiffness: 210, damping: 28, mass: 0.8 };
|
|
18
|
+
function Entrance({ id, family, options, finish, }) {
|
|
19
|
+
return (_jsx("defs", { children: _jsx("clipPath", { id: id, clipPathUnits: "userSpaceOnUse", children: _jsx(motion.rect, { "data-combo-reveal": family, x: 0, y: 0, height: "100%", initial: { width: "0%" }, animate: { width: "100%" }, onAnimationComplete: finish, transition: {
|
|
20
|
+
duration: Math.max(0, options.revealDurationMs ?? 1000) / 1000,
|
|
21
|
+
ease: options.revealEasing ?? [0.25, 0.1, 0.25, 1],
|
|
22
|
+
} }) }) }));
|
|
23
|
+
}
|
|
24
|
+
// A changed native child can change domains, axes, stacks or custom geometry.
|
|
25
|
+
// Conservatively end entrances rather than retain a clip in the old coordinate space.
|
|
26
|
+
function CompositionLifecycle({ children }) {
|
|
27
|
+
const { invalidate } = useLineInteraction();
|
|
28
|
+
const previous = useRef(children);
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
if (children !== previous.current)
|
|
31
|
+
invalidate();
|
|
32
|
+
previous.current = children;
|
|
33
|
+
}, [children, invalidate]);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** Native ComposedChart geometry with the maintained Line, Area and Bar series. */
|
|
37
|
+
export function ComboChart({ animate = false, children, ...props }) {
|
|
38
|
+
const id = useId();
|
|
39
|
+
const reduced = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
|
|
40
|
+
const [finished, setFinished] = useState({ line: false, area: false, bar: false });
|
|
41
|
+
const interrupt = useCallback(() => setFinished({ line: true, area: true, bar: true }), []);
|
|
42
|
+
const finishLine = useCallback(() => setFinished((value) => ({ ...value, line: true })), []);
|
|
43
|
+
const finishArea = useCallback(() => setFinished((value) => ({ ...value, area: true })), []);
|
|
44
|
+
const finishBar = useCallback(() => setFinished((value) => ({ ...value, bar: true })), []);
|
|
45
|
+
const enabled = animate !== false && !reduced;
|
|
46
|
+
const options = typeof animate === "object" ? animate : {};
|
|
47
|
+
const line = options.lineReveal === false ? false : { ...options, ...options.lineReveal };
|
|
48
|
+
const area = options.areaReveal === false ? false : { ...options, ...options.areaReveal };
|
|
49
|
+
const bar = options.barReveal === false ? false : { ...options, ...options.barReveal };
|
|
50
|
+
const revealLine = enabled && !finished.line && line !== false;
|
|
51
|
+
const revealArea = enabled && !finished.area && area !== false;
|
|
52
|
+
return (_jsx(MotionContext, { value: { enabled, transition: options.hoverTransition ?? defaultHover }, children: _jsx(BarMotion, { value: {
|
|
53
|
+
reveal: enabled && !finished.bar && bar !== false,
|
|
54
|
+
options: bar || {},
|
|
55
|
+
finish: finishBar,
|
|
56
|
+
}, children: _jsxs(LineChartFrame, { engine: EngineComposedChart, chartProps: {
|
|
57
|
+
...props,
|
|
58
|
+
className: ["kind-ui-combo-chart", props.className].filter(Boolean).join(" "),
|
|
59
|
+
style: {
|
|
60
|
+
...props.style,
|
|
61
|
+
"--kind-ui-combo-area-clip": revealArea ? `url(#${id}-area)` : "none",
|
|
62
|
+
},
|
|
63
|
+
}, motionEnabled: enabled, interrupt: interrupt, clip: revealLine ? `url(#${id}-line)` : "none", children: [_jsx(BarLifecycle, { ...props }), _jsx(CompositionLifecycle, { children: children }), revealLine && (_jsx(Entrance, { id: `${id}-line`, family: "line", options: line || {}, finish: finishLine })), revealArea && (_jsx(Entrance, { id: `${id}-area`, family: "area", options: area || {}, finish: finishArea })), children] }) }) }));
|
|
64
|
+
}
|