@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.
Files changed (121) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +966 -0
  4. package/dist/activity-rings.d.ts +47 -0
  5. package/dist/activity-rings.js +55 -0
  6. package/dist/animation.d.ts +27 -0
  7. package/dist/animation.js +102 -0
  8. package/dist/area-chart.d.ts +9 -0
  9. package/dist/area-chart.js +33 -0
  10. package/dist/area-clay.d.ts +2 -0
  11. package/dist/area-clay.js +6 -0
  12. package/dist/area-material.d.ts +8 -0
  13. package/dist/area-material.js +66 -0
  14. package/dist/area-series.d.ts +11 -0
  15. package/dist/area-series.js +34 -0
  16. package/dist/bar-category.d.ts +5 -0
  17. package/dist/bar-category.js +35 -0
  18. package/dist/bar-chart.d.ts +17 -0
  19. package/dist/bar-chart.js +42 -0
  20. package/dist/bar-clay.d.ts +4 -0
  21. package/dist/bar-clay.js +6 -0
  22. package/dist/bar-material.d.ts +8 -0
  23. package/dist/bar-material.js +19 -0
  24. package/dist/bar-paper.d.ts +2 -0
  25. package/dist/bar-paper.js +6 -0
  26. package/dist/bar-series.d.ts +13 -0
  27. package/dist/bar-series.js +178 -0
  28. package/dist/box-material.d.ts +14 -0
  29. package/dist/box-material.js +30 -0
  30. package/dist/box-plot.d.ts +50 -0
  31. package/dist/box-plot.js +218 -0
  32. package/dist/category-cells.d.ts +6 -0
  33. package/dist/category-cells.js +41 -0
  34. package/dist/chart-context.d.ts +11 -0
  35. package/dist/chart-context.js +9 -0
  36. package/dist/combo-chart.d.ts +16 -0
  37. package/dist/combo-chart.js +64 -0
  38. package/dist/configured-line-chart.d.ts +79 -0
  39. package/dist/configured-line-chart.js +77 -0
  40. package/dist/emphasis.d.ts +44 -0
  41. package/dist/emphasis.js +128 -0
  42. package/dist/heatmap-model.d.ts +38 -0
  43. package/dist/heatmap-model.js +73 -0
  44. package/dist/heatmap.d.ts +56 -0
  45. package/dist/heatmap.js +366 -0
  46. package/dist/histogram-chart.d.ts +14 -0
  47. package/dist/histogram-chart.js +27 -0
  48. package/dist/histogram-data.d.ts +17 -0
  49. package/dist/histogram-data.js +66 -0
  50. package/dist/histogram-material.d.ts +13 -0
  51. package/dist/histogram-material.js +8 -0
  52. package/dist/histogram-series.d.ts +16 -0
  53. package/dist/histogram-series.js +67 -0
  54. package/dist/index.d.ts +45 -0
  55. package/dist/index.js +36 -0
  56. package/dist/legend.d.ts +16 -0
  57. package/dist/legend.js +30 -0
  58. package/dist/line-chart.d.ts +41 -0
  59. package/dist/line-chart.js +160 -0
  60. package/dist/line-material.d.ts +9 -0
  61. package/dist/line-material.js +24 -0
  62. package/dist/line-series.d.ts +15 -0
  63. package/dist/line-series.js +40 -0
  64. package/dist/pie-chart.d.ts +17 -0
  65. package/dist/pie-chart.js +68 -0
  66. package/dist/pie-material.d.ts +19 -0
  67. package/dist/pie-material.js +16 -0
  68. package/dist/pie-series.d.ts +14 -0
  69. package/dist/pie-series.js +208 -0
  70. package/dist/polar-chart.d.ts +33 -0
  71. package/dist/polar-chart.js +145 -0
  72. package/dist/polar-material.d.ts +7 -0
  73. package/dist/polar-material.js +9 -0
  74. package/dist/polar-series.d.ts +17 -0
  75. package/dist/polar-series.js +169 -0
  76. package/dist/radar-interaction.d.ts +29 -0
  77. package/dist/radar-interaction.js +138 -0
  78. package/dist/radial-bar-label.d.ts +15 -0
  79. package/dist/radial-bar-label.js +136 -0
  80. package/dist/radial-category.d.ts +4 -0
  81. package/dist/radial-category.js +4 -0
  82. package/dist/root.d.ts +14 -0
  83. package/dist/root.js +29 -0
  84. package/dist/sankey-chart.d.ts +33 -0
  85. package/dist/sankey-chart.js +142 -0
  86. package/dist/sankey-colors.d.ts +10 -0
  87. package/dist/sankey-colors.js +6 -0
  88. package/dist/sankey-data.d.ts +30 -0
  89. package/dist/sankey-data.js +65 -0
  90. package/dist/sankey-finish.d.ts +13 -0
  91. package/dist/sankey-finish.js +10 -0
  92. package/dist/sankey-legend.d.ts +14 -0
  93. package/dist/sankey-legend.js +9 -0
  94. package/dist/sankey-marks.d.ts +31 -0
  95. package/dist/sankey-marks.js +67 -0
  96. package/dist/sankey-table.d.ts +11 -0
  97. package/dist/sankey-table.js +8 -0
  98. package/dist/scatter-chart.d.ts +16 -0
  99. package/dist/scatter-chart.js +64 -0
  100. package/dist/scatter-material.d.ts +8 -0
  101. package/dist/scatter-material.js +39 -0
  102. package/dist/scatter-series.d.ts +11 -0
  103. package/dist/scatter-series.js +74 -0
  104. package/dist/scatter-tooltip.d.ts +24 -0
  105. package/dist/scatter-tooltip.js +96 -0
  106. package/dist/styles.css +443 -0
  107. package/dist/surface-material.d.ts +13 -0
  108. package/dist/surface-material.js +7 -0
  109. package/dist/tooltip-content.d.ts +19 -0
  110. package/dist/tooltip-content.js +60 -0
  111. package/dist/tooltip-number.d.ts +4 -0
  112. package/dist/tooltip-number.js +68 -0
  113. package/dist/tooltip.d.ts +29 -0
  114. package/dist/tooltip.js +73 -0
  115. package/dist/types.d.ts +11 -0
  116. package/dist/types.js +1 -0
  117. package/dist/waterfall-data.d.ts +24 -0
  118. package/dist/waterfall-data.js +45 -0
  119. package/dist/waterfall-series.d.ts +15 -0
  120. package/dist/waterfall-series.js +35 -0
  121. package/package.json +49 -0
@@ -0,0 +1,138 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cloneElement, createContext, isValidElement, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useState, } from "react";
4
+ import { Dot } from "recharts";
5
+ import { useChart } from "./chart-context.js";
6
+ import { useEmphasisActions } from "./emphasis.js";
7
+ import { useLineInteraction } from "./line-chart.js";
8
+ const Context = createContext(null);
9
+ const Mode = createContext(false);
10
+ /** Persistent selection is local to one RadarChart and never changes Root visibility. */
11
+ export function RadarSelectionProvider({ selection = "none", selectedSeries, onSelectedSeriesChange, children, }) {
12
+ if (selectedSeries !== undefined && onSelectedSeriesChange === undefined)
13
+ throw new Error("RadarChart requires onSelectedSeriesChange for controlled selectedSeries");
14
+ const { enabled: emphasisEnabled } = useEmphasisActions();
15
+ const enabled = selection === "series" && emphasisEnabled;
16
+ const [internal, setInternal] = useState(null);
17
+ const [series, setSeries] = useState(() => new Map());
18
+ const register = useCallback((owner, key, visible) => {
19
+ setSeries((old) => {
20
+ if (old.get(owner)?.key === key && old.get(owner)?.visible === visible)
21
+ return old;
22
+ return new Map(old).set(owner, { key, visible });
23
+ });
24
+ return () => setSeries((old) => {
25
+ if (!old.has(owner))
26
+ return old;
27
+ const next = new Map(old);
28
+ next.delete(owner);
29
+ return next;
30
+ });
31
+ }, []);
32
+ const requested = selectedSeries === undefined ? internal : selectedSeries;
33
+ const visible = requested !== null &&
34
+ [...series.values()].some((item) => item.key === requested && item.visible);
35
+ const selected = enabled && visible ? requested : null;
36
+ // No synthetic consumer callbacks: only user selection/reset requests emit changes.
37
+ // A controlled value remains consumer-owned; hidden values simply have no visible paint.
38
+ useEffect(() => {
39
+ if (selectedSeries === undefined && internal !== null && (!enabled || !visible))
40
+ setInternal(null);
41
+ }, [selectedSeries, internal, enabled, visible]);
42
+ const change = useCallback((next) => {
43
+ if (selectedSeries === undefined)
44
+ setInternal(next);
45
+ onSelectedSeriesChange?.(next);
46
+ }, [selectedSeries, onSelectedSeriesChange]);
47
+ const toggle = useCallback((key) => {
48
+ if (enabled)
49
+ change(selected === key ? null : key);
50
+ }, [enabled, selected, change]);
51
+ const value = useMemo(() => ({ enabled, selected, register, toggle }), [enabled, selected, register, toggle]);
52
+ return (_jsx(Mode, { value: enabled, children: _jsx(Context, { value: value, children: _jsx("div", { style: { display: "contents" }, onKeyDown: (event) => {
53
+ if (event.key === "Escape" && !event.defaultPrevented && enabled && requested !== null)
54
+ change(null);
55
+ }, children: children }) }) }));
56
+ }
57
+ /** An extra paint/control layer preserves native shape, data, refs and event handlers. */
58
+ export function RadarSelectionLayer({ seriesKey, hidden, children, }) {
59
+ const selection = use(Context);
60
+ const { config } = useChart();
61
+ const { data } = useLineInteraction();
62
+ const owner = useId();
63
+ const enabled = Boolean(selection?.enabled);
64
+ const present = !hidden && Boolean(data?.length);
65
+ useLayoutEffect(() => {
66
+ if (!enabled || seriesKey === undefined)
67
+ return;
68
+ return selection?.register(owner, seriesKey, present);
69
+ }, [enabled, selection?.register, owner, seriesKey, present]);
70
+ if (enabled && seriesKey === undefined)
71
+ throw new Error("RadarSeries requires seriesKey for selection with a non-string dataKey");
72
+ const interactive = enabled && present && seriesKey !== undefined;
73
+ const selected = interactive && selection?.selected === seriesKey;
74
+ const dimmed = interactive && selection?.selected != null && !selected;
75
+ return (
76
+ // biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useAriaPropsSupportedByRole: The role, pressed state and handlers are enabled together only for opted-in SVG controls.
77
+ _jsx("g", { "data-kind-ui": interactive ? "radar-selection" : undefined, "data-series": interactive ? seriesKey : undefined, "data-selection": interactive ? (selected ? "selected" : dimmed ? "dimmed" : "baseline") : undefined, role: interactive ? "button" : undefined, tabIndex: interactive ? 0 : undefined, "aria-label": interactive ? `Highlight ${config[seriesKey]?.label ?? seriesKey}` : undefined, "aria-pressed": interactive ? selected : undefined, onClick: (event) => {
78
+ // React portal events bubble through this component even when their paint is elsewhere.
79
+ // Annotations, native active-dot overlays and consumer portals retain their own interaction.
80
+ if (interactive &&
81
+ !event.defaultPrevented &&
82
+ event.button === 0 &&
83
+ event.currentTarget.contains(event.target))
84
+ selection?.toggle(seriesKey);
85
+ }, onKeyDown: (event) => {
86
+ if (interactive &&
87
+ event.target === event.currentTarget &&
88
+ !event.defaultPrevented &&
89
+ (event.key === "Enter" || event.key === " ")) {
90
+ event.preventDefault();
91
+ if (!event.repeat)
92
+ selection?.toggle(seriesKey);
93
+ }
94
+ }, children: _jsx("g", { "data-kind-ui": "radar-selection-paint", style: { opacity: dimmed ? 0.28 : 1 }, children: children }) }));
95
+ }
96
+ /** Recharts clones this public dot element with its original computed dot props. */
97
+ export function useRadarSelectionDot(dot, seriesKey) {
98
+ const enabled = use(Mode);
99
+ return useMemo(() => {
100
+ if (!enabled)
101
+ return dot;
102
+ const props = isValidElement(dot)
103
+ ? dot.props
104
+ : typeof dot === "object"
105
+ ? dot
106
+ : {};
107
+ return _jsx(RadarSelectionDot, { ...props, option: dot, seriesKey: seriesKey });
108
+ }, [enabled, dot, seriesKey]);
109
+ }
110
+ function RadarSelectionDot({ option, seriesKey, ...props }) {
111
+ const selection = use(Context);
112
+ // Native Radar draws a fallback dot for a one-point series even when dot is false.
113
+ if (!option && (!Array.isArray(props.points) || props.points.length !== 1))
114
+ return null;
115
+ const dimmed = selection?.enabled && selection.selected !== null && selection.selected !== seriesKey;
116
+ let paint;
117
+ if (isValidElement(option))
118
+ paint = cloneElement(option, props);
119
+ // The engine injects the complete public DotItemDotProps when cloning the adapter above.
120
+ else if (typeof option === "function")
121
+ paint = option(props);
122
+ else
123
+ paint = (_jsx(Dot, { ...props, className: [
124
+ "recharts-radar-dot",
125
+ typeof option === "object" ? option?.className : undefined,
126
+ ]
127
+ .filter(Boolean)
128
+ .join(" ") }));
129
+ return (
130
+ // biome-ignore lint/a11y/noStaticElementInteractions: Pointer target for the corresponding keyboard-operable series control, not an additional tab stop.
131
+ _jsx("g", { "data-kind-ui": "radar-selection-dot", style: { opacity: dimmed ? 0.28 : 1 }, onClick: (event) => {
132
+ if (!event.defaultPrevented &&
133
+ event.button === 0 &&
134
+ seriesKey !== undefined &&
135
+ event.currentTarget.contains(event.target))
136
+ selection?.toggle(seriesKey);
137
+ }, children: paint }));
138
+ }
@@ -0,0 +1,15 @@
1
+ import { type Ref } from "react";
2
+ import type { LabelProps } from "recharts";
3
+ export type RadialBarLabelProps = Omit<LabelProps, "fontSize" | "position" | "content" | "children" | "offset" | "labelRef" | "angle" | "textBreakAll" | "x" | "y" | "dx" | "dy" | "transform"> & {
4
+ /** Independently controls this visual label, without affecting payload or tooltip state. */
5
+ show?: boolean;
6
+ /** Numeric SVG pixels; reduced to fit the native ring band, never below minFontSize. */
7
+ fontSize?: number;
8
+ minFontSize?: number;
9
+ /** Space reserved at the arc endpoints and both edges of the band. */
10
+ padding?: number;
11
+ ref?: Ref<SVGTextElement>;
12
+ labelRef?: Ref<SVGTextElement>;
13
+ };
14
+ /** LabelList content placed along the actual native sector's mid-radius, within its endpoints. */
15
+ export declare function RadialBarLabel({ show, fontSize, minFontSize, padding, viewBox, parentViewBox: _parentViewBox, value, formatter, index: _index, zIndex: _zIndex, id, ref, labelRef, style, fill, pointerEvents, ...props }: RadialBarLabelProps): import("react").JSX.Element | null;
@@ -0,0 +1,136 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useId, useLayoutEffect, useState } from "react";
4
+ function attach(ref, node) {
5
+ if (typeof ref === "function")
6
+ return ref(node);
7
+ if (ref)
8
+ ref.current = node;
9
+ }
10
+ // Native Label forwards these layout/content fields to custom content; they are not SVG attributes.
11
+ function svgAttributes({ x: _x, y: _y, dx: _dx, dy: _dy, transform: _transform, position: _position, content: _content, offset: _offset, children: _children, angle: _angle, textBreakAll: _textBreakAll, viewBox: _viewBox, parentViewBox: _parentViewBox, value: _value, formatter: _formatter, index: _index, zIndex: _zIndex, labelRef: _labelRef, ...props }) {
12
+ return props;
13
+ }
14
+ /** LabelList content placed along the actual native sector's mid-radius, within its endpoints. */
15
+ export function RadialBarLabel({ show = true, fontSize = 11, minFontSize = 9, padding = 2, viewBox, parentViewBox: _parentViewBox, value, formatter, index: _index, zIndex: _zIndex, id, ref, labelRef, style, fill = "white", pointerEvents = "none", ...props }) {
16
+ const generatedId = useId();
17
+ const pathId = `${id ?? generatedId}-arc`;
18
+ const [node, setNode] = useState(null);
19
+ const [measurement, setMeasurement] = useState({
20
+ length: Infinity,
21
+ fontSize: Infinity,
22
+ contained: false,
23
+ });
24
+ const [fitSize, setFitSize] = useState(fontSize);
25
+ const text = formatter ? formatter(value) : value;
26
+ const textKey = typeof text === "string" || typeof text === "number" ? String(text) : String(value);
27
+ const box = viewBox && "cx" in viewBox ? viewBox : undefined;
28
+ const band = box ? box.outerRadius - box.innerRadius : 0;
29
+ const inset = Number.isFinite(padding) ? Math.max(0, padding) : 2;
30
+ const size = Math.min(fontSize, fitSize, band - inset * 2);
31
+ const radius = box ? (box.innerRadius + box.outerRadius) / 2 : 0;
32
+ const rawDelta = box ? box.endAngle - box.startAngle : 0;
33
+ const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 359.999);
34
+ const length = ((Math.abs(delta) * Math.PI) / 180) * radius;
35
+ const valid = box !== undefined &&
36
+ [box.cx, box.cy, radius, delta, band, size, fontSize, minFontSize].every(Number.isFinite) &&
37
+ box.innerRadius >= 0 &&
38
+ radius > 0 &&
39
+ band > 0 &&
40
+ fontSize > 0 &&
41
+ minFontSize > 0 &&
42
+ Math.abs(delta) > 0 &&
43
+ size >= minFontSize;
44
+ const attachRef = useCallback((element) => {
45
+ setNode(element);
46
+ const first = attach(ref, element);
47
+ const second = attach(labelRef, element);
48
+ return () => {
49
+ if (first)
50
+ first();
51
+ else
52
+ attach(ref, null);
53
+ if (second)
54
+ second();
55
+ else
56
+ attach(labelRef, null);
57
+ };
58
+ }, [ref, labelRef]);
59
+ // biome-ignore lint/correctness/useExhaustiveDependencies: New geometry or text retries the requested size before measuring its fit.
60
+ useLayoutEffect(() => {
61
+ setFitSize(fontSize);
62
+ }, [fontSize, textKey, band, radius, delta, minFontSize, inset, box?.startAngle]);
63
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Text and font/style changes must remeasure the retained SVG node before paint.
64
+ useLayoutEffect(() => {
65
+ if (!node)
66
+ return;
67
+ const measure = () => {
68
+ const cx = Number(node.dataset.cx), cy = Number(node.dataset.cy);
69
+ const inner = Number(node.dataset.innerRadius), outer = Number(node.dataset.outerRadius);
70
+ const start = Number(node.dataset.startAngle), end = Number(node.dataset.endAngle);
71
+ const direction = Math.sign(end - start), span = Math.abs(end - start);
72
+ const contained = Array.from({ length: node.getNumberOfChars() }, (_, index) => {
73
+ const extent = node.getExtentOfChar(index);
74
+ return [extent.x, extent.x + extent.width].every((x) => [extent.y, extent.y + extent.height].every((y) => {
75
+ const r = Math.hypot(x - cx, y - cy);
76
+ const angle = (Math.atan2(cy - y, x - cx) * 180) / Math.PI;
77
+ const along = (((direction * (angle - start)) % 360) + 360) % 360;
78
+ return r >= inner && r <= outer && along <= span;
79
+ }));
80
+ }).every(Boolean);
81
+ const next = {
82
+ contained,
83
+ length: node.getComputedTextLength(),
84
+ fontSize: Number.parseFloat(getComputedStyle(node).fontSize),
85
+ };
86
+ if ((!contained || next.length > length - inset * 2) && size > minFontSize)
87
+ setFitSize(minFontSize);
88
+ setMeasurement((old) => old.length === next.length &&
89
+ old.fontSize === next.fontSize &&
90
+ old.contained === next.contained
91
+ ? old
92
+ : next);
93
+ };
94
+ measure();
95
+ const observer = new ResizeObserver(measure);
96
+ observer.observe(node);
97
+ document.fonts?.addEventListener("loadingdone", measure);
98
+ return () => {
99
+ observer.disconnect();
100
+ document.fonts?.removeEventListener("loadingdone", measure);
101
+ };
102
+ }, [
103
+ node,
104
+ text,
105
+ size,
106
+ length,
107
+ inset,
108
+ minFontSize,
109
+ style,
110
+ box?.cx,
111
+ box?.cy,
112
+ box?.innerRadius,
113
+ box?.outerRadius,
114
+ box?.startAngle,
115
+ box?.endAngle,
116
+ ]);
117
+ if (!show || !valid || !box || text == null || typeof text === "boolean")
118
+ return null;
119
+ const middle = box.startAngle + delta / 2;
120
+ // Reverse a left-facing tangent so text reads upright on either sweep direction.
121
+ const reverse = -Math.sign(delta) * Math.sin((middle * Math.PI) / 180) < 0;
122
+ const start = reverse ? box.startAngle + delta : box.startAngle;
123
+ const sweep = reverse ? -delta : delta;
124
+ const point = (angle) => {
125
+ const radians = (angle * Math.PI) / 180;
126
+ return `${box.cx + radius * Math.cos(radians)},${box.cy - radius * Math.sin(radians)}`;
127
+ };
128
+ const pieces = Math.ceil(Math.abs(sweep) / 180);
129
+ let path = `M${point(start)}`;
130
+ for (let part = 1; part <= pieces; part++)
131
+ path += ` A${radius},${radius} 0 0 ${sweep < 0 ? 1 : 0} ${point(start + (sweep * part) / pieces)}`;
132
+ const fits = measurement.contained &&
133
+ measurement.length <= length - inset * 2 &&
134
+ measurement.fontSize <= band - inset * 2;
135
+ return (_jsxs("g", { "data-kind-ui": "radial-label-frame", children: [_jsx("defs", { children: _jsx("path", { id: pathId, d: path }) }), _jsx("text", { ...svgAttributes(props), ref: attachRef, id: id, fill: fill, pointerEvents: pointerEvents, "data-kind-ui": "radial-label", "data-fit": fits ? "yes" : "no", "data-cx": box.cx, "data-cy": box.cy, "data-inner-radius": box.innerRadius, "data-outer-radius": box.outerRadius, "data-start-angle": box.startAngle, "data-end-angle": box.startAngle + delta, "data-arc-length": length, "data-text-length": measurement.length, dominantBaseline: "central", textAnchor: "middle", style: { fontSize: size, ...style, visibility: fits ? style?.visibility : "hidden" }, children: _jsx("textPath", { href: `#${pathId}`, startOffset: "50%", children: text }) })] }));
136
+ }
@@ -0,0 +1,4 @@
1
+ export declare const RadialCategory: import("react").Context<{
2
+ data: readonly unknown[];
3
+ key: (row: unknown) => string;
4
+ } | null>;
@@ -0,0 +1,4 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ // The native RadialBar reads chart data, so category identity is scoped there.
4
+ export const RadialCategory = createContext(null);
package/dist/root.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ComponentPropsWithRef } from "react";
2
+ import type { SeriesConfig } from "./types.js";
3
+ export type RootProps = ComponentPropsWithRef<"div"> & {
4
+ config: SeriesConfig;
5
+ emphasis?: "auto" | "none";
6
+ } & ({
7
+ visibleSeries?: undefined;
8
+ onVisibleSeriesChange?: never;
9
+ } | {
10
+ visibleSeries: readonly string[];
11
+ onVisibleSeriesChange?: (next: string[]) => void;
12
+ });
13
+ /** Scopes presentation metadata and CSS colors; the consumer owns chart geometry and state. */
14
+ export declare function Root({ config, emphasis, visibleSeries, onVisibleSeriesChange, style, children, ...props }: RootProps): import("react").JSX.Element;
package/dist/root.js ADDED
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ChartContext } from "./chart-context.js";
4
+ import { EmphasisProvider, useEmphasisActions } from "./emphasis.js";
5
+ /** Scopes presentation metadata and CSS colors; the consumer owns chart geometry and state. */
6
+ export function Root({ config, emphasis = "auto", visibleSeries, onVisibleSeriesChange, style, children, ...props }) {
7
+ if (onVisibleSeriesChange && !visibleSeries)
8
+ throw new Error("Root requires visibleSeries when onVisibleSeriesChange is provided");
9
+ const colors = {};
10
+ for (const [key, item] of Object.entries(config)) {
11
+ if (!/^[a-zA-Z][\w-]*$/.test(key))
12
+ throw new Error(`Chart series key "${key}" must start with a letter and contain only letters, numbers, underscores or hyphens`);
13
+ colors[`--color-${key}`] = item.color;
14
+ }
15
+ const value = {
16
+ config,
17
+ ...(visibleSeries ? { visibleSeries } : {}),
18
+ ...(onVisibleSeriesChange ? { onVisibleSeriesChange } : {}),
19
+ };
20
+ return (_jsx(ChartContext, { value: value, children: _jsx(EmphasisProvider, { enabled: emphasis === "auto", children: _jsx(RootFrame, { ...props, style: { ...colors, ...style }, children: children }) }) }));
21
+ }
22
+ function RootFrame({ children, ...props }) {
23
+ const { reset } = useEmphasisActions();
24
+ return (_jsx("div", { ...props, "data-kind-ui": "chart", onKeyDownCapture: (event) => {
25
+ if (event.key === "Escape")
26
+ reset();
27
+ props.onKeyDownCapture?.(event);
28
+ }, children: children }));
29
+ }
@@ -0,0 +1,33 @@
1
+ import { type MotionValue } from "motion/react";
2
+ import { type ComponentProps, type ReactNode } from "react";
3
+ import { Sankey as EngineSankey, type SankeyLinkProps as NativeLinkProps, type SankeyNodeProps as NativeNodeProps } from "recharts";
4
+ import { type SankeyNodeConfig } from "./sankey-colors.js";
5
+ import { type SankeyFlowData } from "./sankey-data.js";
6
+ import { type SankeyLinkProps, type SankeyNodeProps } from "./sankey-marks.js";
7
+ type NativeProps = ComponentProps<typeof EngineSankey>;
8
+ type NativeEvent = NonNullable<NativeProps["onClick"]>;
9
+ type IdentityEvent = (item: SankeyNodeProps | SankeyLinkProps, type: Parameters<NativeEvent>[1], event: Parameters<NativeEvent>[2]) => void;
10
+ export interface SankeyAnimation {
11
+ revealDurationMs?: number;
12
+ }
13
+ export type SankeyChartProps = Omit<NativeProps, "data" | "node" | "link" | "onClick" | "onMouseEnter" | "onMouseLeave"> & {
14
+ data: SankeyFlowData;
15
+ /** Explicit node-ID metadata; enables matching default Kind node/source-link paint. */
16
+ nodeConfig?: SankeyNodeConfig | undefined;
17
+ node?: Exclude<NativeProps["node"], (props: NativeNodeProps) => ReactNode> | ((props: SankeyNodeProps) => ReactNode);
18
+ link?: Exclude<NativeProps["link"], (props: NativeLinkProps) => ReactNode> | ((props: SankeyLinkProps) => ReturnType<NonNullable<Extract<NativeProps["link"], (props: NativeLinkProps) => ReactNode>>>);
19
+ onClick?: IdentityEvent;
20
+ onMouseEnter?: IdentityEvent;
21
+ onMouseLeave?: IdentityEvent;
22
+ /** Kind SankeyLink paint flows along unchanged paths; custom renderers retain ownership. */
23
+ animate?: boolean | SankeyAnimation;
24
+ empty?: ReactNode;
25
+ };
26
+ export declare const SankeyMotion: import("react").Context<{
27
+ reveal: boolean;
28
+ progress: MotionValue<number> | null;
29
+ width: number;
30
+ }>;
31
+ /** Recharts owns layout, renderers, labels and events. Zero flows remain in the data alternative. */
32
+ export declare function SankeyChart({ data, nodeConfig, animate, empty, ...props }: SankeyChartProps): import("react").JSX.Element;
33
+ export {};
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { animate as animateValue, useMotionValue } from "motion/react";
4
+ import { createContext, useCallback, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
5
+ import { Sankey as EngineSankey, useChartHeight, useChartWidth, } from "recharts";
6
+ import { SankeyColors } from "./sankey-colors.js";
7
+ import { prepareSankeyData } from "./sankey-data.js";
8
+ import { SankeyLink, SankeyNode, } from "./sankey-marks.js";
9
+ const configuredNode = (shape) => _jsx(SankeyNode, { ...shape });
10
+ const configuredLink = (shape) => _jsx(SankeyLink, { ...shape });
11
+ function NativeSize({ onSize }) {
12
+ const width = useChartWidth();
13
+ const height = useChartHeight();
14
+ useLayoutEffect(() => {
15
+ if (width !== undefined && height !== undefined)
16
+ onSize(width, height);
17
+ }, [width, height, onSize]);
18
+ return null;
19
+ }
20
+ const query = "(prefers-reduced-motion: reduce)";
21
+ function subscribe(change) {
22
+ const media = window.matchMedia(query);
23
+ media.addEventListener("change", change);
24
+ return () => media.removeEventListener("change", change);
25
+ }
26
+ const snapshot = () => window.matchMedia(query).matches;
27
+ const serverSnapshot = () => true;
28
+ export const SankeyMotion = createContext({ reveal: false, progress: null, width: 1 });
29
+ /** Recharts owns layout, renderers, labels and events. Zero flows remain in the data alternative. */
30
+ export function SankeyChart({ data, nodeConfig, animate = false, empty = "No positive flows", ...props }) {
31
+ const duration = typeof animate === "object" ? (animate.revealDurationMs ?? 450) : 450;
32
+ if (!Number.isFinite(duration) || duration < 0)
33
+ throw new Error("Sankey revealDurationMs must be finite and nonnegative");
34
+ const validated = prepareSankeyData(data);
35
+ if (nodeConfig) {
36
+ for (const node of validated.nodes) {
37
+ if (!Object.hasOwn(nodeConfig, node.id))
38
+ throw new Error(`Sankey nodeConfig requires metadata for node "${node.id}"`);
39
+ }
40
+ }
41
+ const positive = validated.links.filter((link) => link.value > 0);
42
+ const nativeKeys = new Set();
43
+ for (const link of positive) {
44
+ const key = `${link.source}-${link.target}-${link.value}`;
45
+ if (nativeKeys.has(key))
46
+ throw new Error("Sankey native renderer cannot distinguish equal-value parallel links; keep the table or supply a renderer that supports them.");
47
+ nativeKeys.add(key);
48
+ }
49
+ const used = new Set(positive.flatMap((link) => [link.source, link.target]));
50
+ const mapping = new Map();
51
+ const nodes = validated.nodes.filter((_, index) => {
52
+ if (!used.has(index))
53
+ return false;
54
+ mapping.set(index, mapping.size);
55
+ return true;
56
+ });
57
+ const links = positive.map((link) => ({
58
+ ...link,
59
+ source: mapping.get(link.source),
60
+ target: mapping.get(link.target),
61
+ }));
62
+ const reduced = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
63
+ const [interrupted, setInterrupted] = useState(false);
64
+ const [size, setSize] = useState(null);
65
+ const measured = useRef(null);
66
+ const onSize = useCallback((width, height) => {
67
+ const prior = measured.current;
68
+ if (prior?.width === width && prior?.height === height)
69
+ return;
70
+ if (prior)
71
+ setInterrupted(true);
72
+ measured.current = { width, height };
73
+ setSize(measured.current);
74
+ }, []);
75
+ const inputs = [
76
+ duration,
77
+ data,
78
+ props.width,
79
+ props.height,
80
+ props.nodePadding,
81
+ props.nodeWidth,
82
+ props.iterations,
83
+ props.sort,
84
+ props.align,
85
+ props.verticalAlign,
86
+ props.margin,
87
+ props.linkCurvature,
88
+ props.node,
89
+ props.link,
90
+ ];
91
+ const started = useRef(false);
92
+ const previous = useRef(inputs);
93
+ useLayoutEffect(() => {
94
+ if (inputs.some((value, index) => (index !== 0 || started.current) && value !== previous.current[index]))
95
+ setInterrupted(true);
96
+ previous.current = inputs;
97
+ });
98
+ const enabled = Boolean(animate) && !reduced;
99
+ const previousEnabled = useRef(enabled);
100
+ useLayoutEffect(() => {
101
+ if (previousEnabled.current && !enabled)
102
+ setInterrupted(true);
103
+ previousEnabled.current = enabled;
104
+ }, [enabled]);
105
+ const reveal = enabled && !interrupted && size !== null;
106
+ const progress = useMotionValue(1);
107
+ useLayoutEffect(() => {
108
+ if (!reveal) {
109
+ progress.set(1);
110
+ return;
111
+ }
112
+ started.current = true;
113
+ progress.set(0);
114
+ const playback = animateValue(progress, 1, {
115
+ duration: duration / 1000,
116
+ onComplete: () => setInterrupted(true),
117
+ });
118
+ return () => playback.stop();
119
+ }, [reveal, progress, duration]);
120
+ const margin = { top: 5, bottom: 5, left: 5, right: 5, ...props.margin };
121
+ const height = size ? size.height - margin.top - margin.bottom : 0;
122
+ const width = size ? size.width - margin.left - margin.right : 0;
123
+ const padding = props.nodePadding ?? 10;
124
+ const drawable = height > Math.max(0, nodes.length - 1) * padding && width > (props.nodeWidth ?? 10);
125
+ if (drawable && links.length) {
126
+ const total = links.reduce((sum, link) => sum + link.value, 0);
127
+ const scale = height / total;
128
+ const minimumScale = (height - Math.max(0, nodes.length - 1) * padding) / total;
129
+ if (!Number.isFinite(total) ||
130
+ !Number.isFinite(scale) ||
131
+ scale <= 0 ||
132
+ !Number.isFinite(total * height) ||
133
+ links.some((link) => link.value * minimumScale === 0))
134
+ throw new Error("Sankey flows exceed native renderer numeric limits at this size; use explicitly rescaled input units.");
135
+ }
136
+ return (_jsx(SankeyColors, { value: nodeConfig, children: _jsx(SankeyMotion, { value: { reveal, progress, width: size?.width ?? 1 }, children: _jsxs("div", { "data-kind-ui": "sankey", onPointerDownCapture: () => setInterrupted(true), onFocusCapture: () => setInterrupted(true), style: { position: "relative", width: "fit-content", height: "fit-content" }, children: [_jsxs(EngineSankey, { ...props, ...(nodeConfig
137
+ ? {
138
+ node: props.node ?? configuredNode,
139
+ link: props.link ?? configuredLink,
140
+ }
141
+ : {}), data: drawable && links.length ? { nodes, links } : { nodes: [], links: [] }, children: [_jsx(NativeSize, { onSize: onSize }), props.children] }), !links.length ? (_jsx("div", { role: "status", style: { position: "absolute", inset: 0 }, children: empty })) : !drawable ? (_jsx("div", { role: "status", style: { position: "absolute", inset: 0 }, children: "Insufficient space for flows; use the data table" })) : null] }) }) }));
142
+ }
@@ -0,0 +1,10 @@
1
+ /** Sankey IDs are arbitrary nonempty strings, independent from Root CSS variable keys. */
2
+ export type SankeyNodeConfig = Readonly<Record<string, {
3
+ label: string;
4
+ color: string;
5
+ }>>;
6
+ export declare const SankeyColors: import("react").Context<Readonly<Record<string, {
7
+ label: string;
8
+ color: string;
9
+ }>> | undefined>;
10
+ export declare function sankeyColor(config: SankeyNodeConfig | undefined, id: string): string | undefined;
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ export const SankeyColors = createContext(undefined);
4
+ export function sankeyColor(config, id) {
5
+ return config && Object.hasOwn(config, id) ? config[id]?.color : undefined;
6
+ }
@@ -0,0 +1,30 @@
1
+ export interface SankeyFlowNode {
2
+ id: string;
3
+ name: string;
4
+ [key: string]: unknown;
5
+ }
6
+ export interface SankeyFlowLink {
7
+ id: string;
8
+ source: string | number;
9
+ target: string | number;
10
+ value: number;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface SankeyFlowData {
14
+ nodes: readonly SankeyFlowNode[];
15
+ links: readonly SankeyFlowLink[];
16
+ }
17
+ /** Validate the entire DAG, including zero flows. Never infer a missing flow. */
18
+ export declare function prepareSankeyData(data: SankeyFlowData): {
19
+ nodes: {
20
+ [key: string]: unknown;
21
+ id: string;
22
+ name: string;
23
+ }[];
24
+ links: {
25
+ source: number;
26
+ target: number;
27
+ id: string;
28
+ value: number;
29
+ }[];
30
+ };
@@ -0,0 +1,65 @@
1
+ /** Validate the entire DAG, including zero flows. Never infer a missing flow. */
2
+ export function prepareSankeyData(data) {
3
+ const ids = new Map();
4
+ const checkId = (id, kind) => {
5
+ if (typeof id !== "string" || !id.trim())
6
+ throw new Error(`Sankey ${kind} requires a nonempty string id`);
7
+ };
8
+ data.nodes.forEach((node, index) => {
9
+ checkId(node.id, "node");
10
+ if (ids.has(node.id))
11
+ throw new Error(`Duplicate Sankey node id: ${node.id}`);
12
+ if (typeof node.name !== "string")
13
+ throw new Error(`Sankey node ${node.id} requires a name`);
14
+ ids.set(node.id, index);
15
+ });
16
+ const endpoint = (value) => {
17
+ const index = typeof value === "string" ? ids.get(value) : value;
18
+ if (index === undefined || !Number.isInteger(index) || index < 0 || index >= data.nodes.length)
19
+ throw new Error(`Invalid Sankey endpoint: ${String(value)}`);
20
+ return index;
21
+ };
22
+ const linkIds = new Set();
23
+ const incoming = data.nodes.map(() => 0);
24
+ const outgoing = data.nodes.map(() => 0);
25
+ const edges = data.nodes.map(() => []);
26
+ const indegrees = data.nodes.map(() => 0);
27
+ const hasIncoming = data.nodes.map(() => false);
28
+ const links = data.links.map((link) => {
29
+ checkId(link.id, "link");
30
+ if (linkIds.has(link.id))
31
+ throw new Error(`Duplicate Sankey link id: ${link.id}`);
32
+ linkIds.add(link.id);
33
+ if (typeof link.value !== "number" || !Number.isFinite(link.value) || link.value < 0)
34
+ throw new Error(`Sankey link ${link.id} requires a finite nonnegative value`);
35
+ const source = endpoint(link.source);
36
+ const target = endpoint(link.target);
37
+ outgoing[source] = (outgoing[source] ?? 0) + link.value;
38
+ incoming[target] = (incoming[target] ?? 0) + link.value;
39
+ edges[source]?.push(target);
40
+ hasIncoming[target] = true;
41
+ indegrees[target] = (indegrees[target] ?? 0) + 1;
42
+ return { ...link, source, target };
43
+ });
44
+ const queue = indegrees.flatMap((degree, index) => (degree === 0 ? [index] : []));
45
+ for (let cursor = 0; cursor < queue.length; cursor++) {
46
+ for (const target of edges[queue[cursor] ?? 0] ?? []) {
47
+ indegrees[target] = (indegrees[target] ?? 0) - 1;
48
+ if (indegrees[target] === 0)
49
+ queue.push(target);
50
+ }
51
+ }
52
+ if (queue.length !== data.nodes.length)
53
+ throw new Error("Sankey cycles are unsupported (including zero-value cycles)");
54
+ data.nodes.forEach((node, index) => {
55
+ const input = incoming[index] ?? 0;
56
+ const output = outgoing[index] ?? 0;
57
+ if (!Number.isFinite(input) || !Number.isFinite(output))
58
+ throw new Error(`Sankey totals overflow at ${node.id}`);
59
+ const hasInput = hasIncoming[index];
60
+ const hasOutput = edges[index]?.length;
61
+ if (hasInput && hasOutput && Math.abs(input - output) > 1e-9 * Math.max(input, output))
62
+ throw new Error(`Inconsistent Sankey totals at ${node.id}: incoming ${input}, outgoing ${output}. Supply explicit loss/gain flows.`);
63
+ });
64
+ return { nodes: data.nodes.map((node) => ({ ...node })), links };
65
+ }
@@ -0,0 +1,13 @@
1
+ export type SankeyFinish = "plain" | "paper" | "clay" | "glow";
2
+ /** Sankey-local surface treatment. Never displace the quantitative silhouette. */
3
+ export declare function SankeyFinishFilter({ id, finish, thickness, bounds, }: {
4
+ id: string;
5
+ finish: Exclude<SankeyFinish, "plain">;
6
+ thickness: number;
7
+ bounds: {
8
+ x: number | string;
9
+ y: number | string;
10
+ width: number | string;
11
+ height: number | string;
12
+ };
13
+ }): import("react").JSX.Element;