@flood-md/react 0.2.12 → 0.2.13
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/README.md +1 -1
- package/dist/box-plot.d.ts +2 -0
- package/dist/box-plot.js +148 -0
- package/dist/bullet-chart.d.ts +2 -0
- package/dist/bullet-chart.js +27 -0
- package/dist/charts.d.ts +6 -0
- package/dist/charts.js +561 -0
- package/dist/collection-toolbar.js +2 -2
- package/dist/data-display.js +1 -1
- package/dist/dumbbell-chart.d.ts +2 -0
- package/dist/dumbbell-chart.js +80 -0
- package/dist/feedback-actions.js +18 -3
- package/dist/foundations.css +1616 -199
- package/dist/histogram.d.ts +2 -0
- package/dist/histogram.js +78 -0
- package/dist/index.d.ts +257 -0
- package/dist/index.js +46 -2
- package/dist/menu.d.ts +1 -0
- package/dist/menu.js +6 -6
- package/dist/overlay-runtime.d.ts +1 -1
- package/dist/overlay-runtime.js +25 -4
- package/dist/overlays.js +4 -2
- package/dist/range-chart.d.ts +2 -0
- package/dist/range-chart.js +97 -0
- package/dist/scatter-chart.d.ts +2 -0
- package/dist/scatter-chart.js +170 -0
- package/dist/waterfall-chart.d.ts +2 -0
- package/dist/waterfall-chart.js +89 -0
- package/package.json +2 -2
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useId, useState } from "react";
|
|
3
|
+
import { Surface, Text, VisuallyHidden } from "./index.js";
|
|
4
|
+
const number = new Intl.NumberFormat("en", { maximumSignificantDigits: 21 });
|
|
5
|
+
export const RangePlot = /* @__PURE__ */ forwardRef(function RangePlot({ label, items, domain, formatValue = value => number.format(value), lowLabel = "Low", highLabel = "High", midpointLabel = "Midpoint", emptyLabel = "No observations", missingLabel = "No data", navigationLabel = "Use the arrow keys to explore categories, or Home and End to jump.", className, ...props }, ref) {
|
|
6
|
+
const instructions = useId();
|
|
7
|
+
const [cursor, setCursor] = useState(0);
|
|
8
|
+
const [hovered, setHovered] = useState();
|
|
9
|
+
const [tapped, setTapped] = useState();
|
|
10
|
+
const [focused, setFocused] = useState(false);
|
|
11
|
+
let min = Infinity;
|
|
12
|
+
let max = -Infinity;
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
const intervals = items.map(item => {
|
|
15
|
+
const { id, label: name, low, high, midpoint } = item;
|
|
16
|
+
if (!id.trim() || ids.has(id) || !name.trim()) {
|
|
17
|
+
throw new Error("Flood RangeChart needs unique item IDs and nonempty labels");
|
|
18
|
+
}
|
|
19
|
+
ids.add(id);
|
|
20
|
+
if ((low !== null && !Number.isFinite(low)) || (high !== null && !Number.isFinite(high)) ||
|
|
21
|
+
(midpoint !== undefined && !Number.isFinite(midpoint))) {
|
|
22
|
+
throw new RangeError("Flood RangeChart bounds and midpoints must be finite");
|
|
23
|
+
}
|
|
24
|
+
if (low === null || high === null)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (low > high || (midpoint !== undefined && (midpoint < low || midpoint > high))) {
|
|
27
|
+
throw new RangeError("Flood RangeChart midpoint must lie within its ordered bounds");
|
|
28
|
+
}
|
|
29
|
+
min = Math.min(min, low);
|
|
30
|
+
max = Math.max(max, high);
|
|
31
|
+
return { low, high, midpoint };
|
|
32
|
+
});
|
|
33
|
+
const hasData = min <= max;
|
|
34
|
+
if (domain && (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) ||
|
|
35
|
+
domain[0] >= domain[1] || (hasData && (domain[0] > min || domain[1] < max)))) {
|
|
36
|
+
throw new RangeError("Flood RangeChart domain must contain all complete intervals and have finite increasing bounds");
|
|
37
|
+
}
|
|
38
|
+
const [floor, ceiling] = domain ?? [min, max];
|
|
39
|
+
const span = ceiling - floor;
|
|
40
|
+
const scale = (value) => span === 0 ? .5 : Number.isFinite(span)
|
|
41
|
+
? (value - floor) / span
|
|
42
|
+
: (value / 2 - floor / 2) / (ceiling / 2 - floor / 2);
|
|
43
|
+
const last = items.length - 1;
|
|
44
|
+
const active = Math.min(hovered ?? tapped ?? cursor, last);
|
|
45
|
+
const inspecting = hovered !== undefined || tapped !== undefined || focused;
|
|
46
|
+
const current = intervals[active];
|
|
47
|
+
const readings = current ? [
|
|
48
|
+
{ name: highLabel, value: formatValue(current.high) },
|
|
49
|
+
...(current.midpoint === undefined ? [] : [{ name: midpointLabel, value: formatValue(current.midpoint) }]),
|
|
50
|
+
{ name: lowLabel, value: formatValue(current.low) },
|
|
51
|
+
] : [];
|
|
52
|
+
const accessibleValue = [items[active]?.label, ...(current
|
|
53
|
+
? readings.map(reading => `${reading.name}: ${reading.value}`)
|
|
54
|
+
: [missingLabel])].filter(Boolean).join(", ");
|
|
55
|
+
const navigate = (event) => {
|
|
56
|
+
let next;
|
|
57
|
+
switch (event.key) {
|
|
58
|
+
case "ArrowRight":
|
|
59
|
+
case "ArrowUp":
|
|
60
|
+
next = active + 1;
|
|
61
|
+
break;
|
|
62
|
+
case "ArrowLeft":
|
|
63
|
+
case "ArrowDown":
|
|
64
|
+
next = active - 1;
|
|
65
|
+
break;
|
|
66
|
+
case "Home":
|
|
67
|
+
next = 0;
|
|
68
|
+
break;
|
|
69
|
+
case "End":
|
|
70
|
+
next = last;
|
|
71
|
+
break;
|
|
72
|
+
case "Escape":
|
|
73
|
+
setHovered(undefined);
|
|
74
|
+
setTapped(undefined);
|
|
75
|
+
setFocused(false);
|
|
76
|
+
return;
|
|
77
|
+
default: return;
|
|
78
|
+
}
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setHovered(undefined);
|
|
81
|
+
setTapped(undefined);
|
|
82
|
+
setFocused(true);
|
|
83
|
+
setCursor(Math.max(0, Math.min(last, next)));
|
|
84
|
+
};
|
|
85
|
+
return _jsx("div", { ...props, ref: ref, className: ["flood-range-chart", className].filter(Boolean).join(" "), role: "group", "aria-label": label, children: hasData ? _jsxs(_Fragment, { children: [last > 0 && _jsx(VisuallyHidden, { id: instructions, children: navigationLabel }), _jsxs("div", { className: "flood-range-chart__plot", dir: "ltr", style: { "--flood-range-columns": items.length }, role: last > 0 ? "slider" : "img", tabIndex: last > 0 ? 0 : undefined, "aria-label": last > 0 ? label : accessibleValue, "aria-describedby": last > 0 ? instructions : undefined, "aria-valuemin": last > 0 ? 1 : undefined, "aria-valuemax": last > 0 ? items.length : undefined, "aria-valuenow": last > 0 ? active + 1 : undefined, "aria-valuetext": last > 0 ? accessibleValue : undefined, onKeyDown: navigate, onFocus: event => setFocused(event.currentTarget.matches(":focus-visible")), onBlur: () => setFocused(false), onPointerLeave: () => setHovered(undefined), children: [_jsx("div", { className: "flood-range-chart__columns", "aria-hidden": "true", children: items.map((item, index) => {
|
|
86
|
+
const interval = intervals[index];
|
|
87
|
+
return _jsxs("div", { className: "flood-range-chart__column", "data-active": inspecting && index === active || undefined, onPointerEnter: event => { if (event.pointerType !== "touch")
|
|
88
|
+
setHovered(index); }, onPointerUp: event => { if (event.pointerType === "touch")
|
|
89
|
+
setTapped(previous => previous === index ? undefined : index); }, children: [_jsx("div", { className: "flood-range-chart__track", children: interval && _jsxs(_Fragment, { children: [_jsx("span", { className: "flood-range-chart__band", style: {
|
|
90
|
+
insetBlockStart: `${(1 - scale(interval.high)) * 100}%`,
|
|
91
|
+
insetBlockEnd: interval.low === interval.high ? undefined : `${scale(interval.low) * 100}%`,
|
|
92
|
+
}, "data-flat": interval.low === interval.high || undefined }), interval.midpoint !== undefined && _jsx("span", { className: "flood-range-chart__midpoint", style: { insetBlockStart: `${(1 - scale(interval.midpoint)) * 100}%` } })] }) }), _jsx(Text, { className: "flood-range-chart__category", size: "helper", tone: "subtle", dir: "auto", title: item.label, children: item.label })] }, item.id);
|
|
93
|
+
}) }), inspecting && _jsxs(Surface, { className: "flood-chart__tooltip flood-range-chart__tooltip", padding: "2", elevation: "raised", "aria-hidden": "true", style: {
|
|
94
|
+
"--flood-range-position": `${(active + .5) / items.length * 100}%`,
|
|
95
|
+
"--flood-range-tip-side": active >= items.length / 2 ? -1 : 0,
|
|
96
|
+
}, children: [_jsx(Text, { size: "helper", children: items[active].label }), current ? readings.map((reading, index) => _jsxs("div", { className: "flood-range-chart__reading", children: [_jsx(Text, { size: "helper", tone: "subtle", children: reading.name }), _jsx(Text, { size: "helper", children: reading.value })] }, index)) : _jsx(Text, { size: "helper", tone: "subtle", children: missingLabel })] })] })] }) : _jsx(Text, { className: "flood-range-chart__empty", size: "compact", tone: "subtle", children: emptyLabel }) });
|
|
97
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Surface, Text, VisuallyHidden } from "./index.js";
|
|
4
|
+
import { placeOverlay } from "./overlay-runtime.js";
|
|
5
|
+
const numberFormat = new Intl.NumberFormat("en", { maximumSignificantDigits: 21 });
|
|
6
|
+
const formatNumber = (value) => numberFormat.format(value);
|
|
7
|
+
const useChartLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
8
|
+
function scaleFor(points, axis, domain) {
|
|
9
|
+
let low = Infinity;
|
|
10
|
+
let high = -Infinity;
|
|
11
|
+
for (const point of points) {
|
|
12
|
+
low = Math.min(low, point[axis]);
|
|
13
|
+
high = Math.max(high, point[axis]);
|
|
14
|
+
}
|
|
15
|
+
if (domain) {
|
|
16
|
+
const [min, max] = domain;
|
|
17
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max || low < min || high > max) {
|
|
18
|
+
throw new RangeError(`Flood ScatterChart ${axis} domain must have finite increasing bounds containing all observations`);
|
|
19
|
+
}
|
|
20
|
+
return domain;
|
|
21
|
+
}
|
|
22
|
+
return points.length ? [low, high] : [0, 1];
|
|
23
|
+
}
|
|
24
|
+
function normalize(value, [min, max]) {
|
|
25
|
+
if (min === max)
|
|
26
|
+
return .5;
|
|
27
|
+
const span = max - min;
|
|
28
|
+
return Number.isFinite(span) ? (value - min) / span : (value / 2 - min / 2) / (max / 2 - min / 2);
|
|
29
|
+
}
|
|
30
|
+
function ticks([min, max]) {
|
|
31
|
+
const midpoint = min / 2 + max / 2;
|
|
32
|
+
return min === max ? [min] : midpoint === min || midpoint === max ? [min, max] : [min, midpoint, max];
|
|
33
|
+
}
|
|
34
|
+
export const ScatterPlot = /* @__PURE__ */ forwardRef(function ScatterPlot({ label, xLabel, yLabel, points, xDomain, yDomain, formatX = formatNumber, formatY = formatNumber, emptyLabel = "No data", navigationLabel = "Use arrow keys to inspect points, or Home and End to jump.", className, ...props }, ref) {
|
|
35
|
+
const instructions = useId();
|
|
36
|
+
const plot = useRef(null);
|
|
37
|
+
const area = useRef(null);
|
|
38
|
+
const tooltip = useRef(null);
|
|
39
|
+
const [cursor, setCursor] = useState();
|
|
40
|
+
const [hover, setHover] = useState();
|
|
41
|
+
const [touch, setTouch] = useState();
|
|
42
|
+
const [focused, setFocused] = useState(false);
|
|
43
|
+
const data = useMemo(() => {
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
for (const point of points) {
|
|
46
|
+
if (!point.id.trim() || ids.has(point.id) || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
47
|
+
throw new Error("Flood ScatterChart needs unique nonempty point IDs and finite coordinates");
|
|
48
|
+
}
|
|
49
|
+
ids.add(point.id);
|
|
50
|
+
}
|
|
51
|
+
const xScale = scaleFor(points, "x", xDomain);
|
|
52
|
+
const yScale = scaleFor(points, "y", yDomain);
|
|
53
|
+
return {
|
|
54
|
+
xScale,
|
|
55
|
+
yScale,
|
|
56
|
+
positions: points.map(point => ({
|
|
57
|
+
point,
|
|
58
|
+
x: normalize(point.x, xScale),
|
|
59
|
+
y: 1 - normalize(point.y, yScale),
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
}, [points, xDomain, yDomain]);
|
|
63
|
+
const activeId = hover ?? touch ?? cursor;
|
|
64
|
+
const index = activeId === undefined ? 0 : Math.max(0, points.findIndex(point => point.id === activeId));
|
|
65
|
+
const active = data.positions[index];
|
|
66
|
+
const inspecting = Boolean(active && (hover !== undefined || touch !== undefined || focused));
|
|
67
|
+
const xText = active ? formatX(active.point.x) : "";
|
|
68
|
+
const yText = active ? formatY(active.point.y) : "";
|
|
69
|
+
const valueText = [active?.point.label, `${xLabel}: ${xText}`, `${yLabel}: ${yText}`].filter(Boolean).join(", ");
|
|
70
|
+
const canNavigate = points.length > 1;
|
|
71
|
+
useChartLayoutEffect(() => {
|
|
72
|
+
const panel = tooltip.current;
|
|
73
|
+
const frame = plot.current;
|
|
74
|
+
const content = area.current;
|
|
75
|
+
if (!panel || !frame || !content || !active || !inspecting)
|
|
76
|
+
return;
|
|
77
|
+
const position = () => {
|
|
78
|
+
const bounds = frame.getBoundingClientRect();
|
|
79
|
+
if (!bounds.width || !frame.offsetWidth)
|
|
80
|
+
return;
|
|
81
|
+
const region = content.getBoundingClientRect();
|
|
82
|
+
const scale = bounds.width / frame.offsetWidth;
|
|
83
|
+
const pointX = region.left + active.x * region.width;
|
|
84
|
+
const pointY = region.top + active.y * region.height;
|
|
85
|
+
// The panel's column gap is the anchor clearance. The inner grid owns text spacing.
|
|
86
|
+
const gap = parseFloat(getComputedStyle(panel).columnGap) * scale;
|
|
87
|
+
const anchor = { left: pointX, right: pointX, top: pointY, bottom: pointY, width: 0, height: 0 };
|
|
88
|
+
const size = panel.getBoundingClientRect();
|
|
89
|
+
const edge = gap / 4;
|
|
90
|
+
const insetAdjustment = gap - edge;
|
|
91
|
+
const placementBounds = {
|
|
92
|
+
left: bounds.left - insetAdjustment,
|
|
93
|
+
top: bounds.top - insetAdjustment,
|
|
94
|
+
width: bounds.width + 2 * insetAdjustment,
|
|
95
|
+
height: bounds.height + 2 * insetAdjustment,
|
|
96
|
+
};
|
|
97
|
+
const hasSideRoom = Math.max(pointX - bounds.left, bounds.right - pointX) >= size.width + gap + edge;
|
|
98
|
+
const side = hasSideRoom ? (active.x < .5 ? "right" : "left") : "top";
|
|
99
|
+
const { left, top } = placeOverlay(anchor, size, placementBounds, side, "center", gap);
|
|
100
|
+
panel.style.left = `${(left - bounds.left) / scale}px`;
|
|
101
|
+
panel.style.top = `${(top - bounds.top) / scale}px`;
|
|
102
|
+
panel.dataset.positioned = "";
|
|
103
|
+
};
|
|
104
|
+
position();
|
|
105
|
+
if (typeof ResizeObserver === "undefined")
|
|
106
|
+
return;
|
|
107
|
+
const observer = new ResizeObserver(position);
|
|
108
|
+
observer.observe(frame);
|
|
109
|
+
observer.observe(panel);
|
|
110
|
+
return () => observer.disconnect();
|
|
111
|
+
}, [active, inspecting, xText, yText]);
|
|
112
|
+
const nearest = (event) => {
|
|
113
|
+
const bounds = area.current?.getBoundingClientRect();
|
|
114
|
+
if (!bounds)
|
|
115
|
+
return undefined;
|
|
116
|
+
let distance = Infinity;
|
|
117
|
+
let id;
|
|
118
|
+
for (const { point, x, y } of data.positions) {
|
|
119
|
+
const next = (bounds.left + x * bounds.width - event.clientX) ** 2 + (bounds.top + y * bounds.height - event.clientY) ** 2;
|
|
120
|
+
if (next < distance) {
|
|
121
|
+
distance = next;
|
|
122
|
+
id = point.id;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return id;
|
|
126
|
+
};
|
|
127
|
+
const trackPointer = (event) => {
|
|
128
|
+
if (event.pointerType !== "touch")
|
|
129
|
+
setHover(nearest(event));
|
|
130
|
+
};
|
|
131
|
+
const navigate = (event) => {
|
|
132
|
+
let next;
|
|
133
|
+
switch (event.key) {
|
|
134
|
+
case "ArrowRight":
|
|
135
|
+
case "ArrowUp":
|
|
136
|
+
next = index + 1;
|
|
137
|
+
break;
|
|
138
|
+
case "ArrowLeft":
|
|
139
|
+
case "ArrowDown":
|
|
140
|
+
next = index - 1;
|
|
141
|
+
break;
|
|
142
|
+
case "Home":
|
|
143
|
+
next = 0;
|
|
144
|
+
break;
|
|
145
|
+
case "End":
|
|
146
|
+
next = points.length - 1;
|
|
147
|
+
break;
|
|
148
|
+
case "Escape":
|
|
149
|
+
if (inspecting)
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
setHover(undefined);
|
|
152
|
+
setTouch(undefined);
|
|
153
|
+
setFocused(false);
|
|
154
|
+
return;
|
|
155
|
+
default: return;
|
|
156
|
+
}
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
setHover(undefined);
|
|
159
|
+
setTouch(undefined);
|
|
160
|
+
setFocused(true);
|
|
161
|
+
setCursor(points[Math.max(0, Math.min(next, points.length - 1))].id);
|
|
162
|
+
};
|
|
163
|
+
return _jsx("div", { ...props, ref: ref, className: ["flood-scatter-chart", className].filter(Boolean).join(" "), role: "group", "aria-label": label, children: !active ? _jsx(Text, { className: "flood-scatter-chart__empty", size: "compact", tone: "subtle", children: emptyLabel }) : _jsxs(_Fragment, { children: [canNavigate && _jsx(VisuallyHidden, { id: instructions, children: navigationLabel }), _jsx(Text, { className: "flood-scatter-chart__vertical-label", size: "helper", tone: "subtle", "aria-hidden": "true", dir: "auto", children: yLabel }), _jsxs("div", { className: "flood-scatter-chart__layout", dir: "ltr", children: [_jsx("div", { className: "flood-scatter-chart__ticks flood-scatter-chart__ticks--y", "aria-hidden": "true", children: ticks(data.yScale).slice().reverse().map(tick => _jsx(Text, { size: "helper", tone: "subtle", children: formatY(tick) }, tick)) }), _jsxs("div", { ref: plot, className: "flood-scatter-chart__plot", role: canNavigate ? "slider" : "img", tabIndex: 0, "aria-label": canNavigate ? label : `${label}. ${valueText}`, "aria-describedby": canNavigate ? instructions : undefined, "aria-valuemin": canNavigate ? 1 : undefined, "aria-valuemax": canNavigate ? points.length : undefined, "aria-valuenow": canNavigate ? index + 1 : undefined, "aria-valuetext": canNavigate ? valueText : undefined, onFocus: event => setFocused(event.currentTarget.matches(":focus-visible")), onBlur: () => setFocused(false), onKeyDown: navigate, onPointerEnter: trackPointer, onPointerMove: trackPointer, onPointerLeave: () => setHover(undefined), onPointerUp: event => {
|
|
164
|
+
if (event.pointerType !== "touch")
|
|
165
|
+
return;
|
|
166
|
+
const next = nearest(event);
|
|
167
|
+
setHover(undefined);
|
|
168
|
+
setTouch(current => current === next ? undefined : next);
|
|
169
|
+
}, children: [_jsxs("div", { ref: area, className: "flood-scatter-chart__area", "aria-hidden": "true", children: [inspecting && _jsx("span", { className: "flood-scatter-chart__guides", style: { "--flood-scatter-x": `${active.x * 100}%`, "--flood-scatter-y": `${active.y * 100}%` } }), data.positions.map(({ point, x, y }, i) => _jsx("span", { className: "flood-scatter-chart__point", "data-active": inspecting && i === index || undefined, style: { left: `${x * 100}%`, top: `${y * 100}%` } }, point.id))] }), inspecting && _jsx(Surface, { ref: tooltip, className: "flood-chart__tooltip flood-scatter-chart__tooltip", elevation: "raised", padding: "2", "aria-hidden": "true", children: _jsxs("div", { className: "flood-scatter-chart__tooltip-content", children: [active.point.label && _jsx(Text, { size: "helper", dir: "auto", "data-point-label": "", children: active.point.label }), _jsx(Text, { size: "helper", tone: "subtle", dir: "auto", children: xLabel }), _jsx(Text, { size: "helper", "data-coordinate": "", children: xText }), _jsx(Text, { size: "helper", tone: "subtle", dir: "auto", children: yLabel }), _jsx(Text, { size: "helper", "data-coordinate": "", children: yText })] }) })] }), _jsx("div", { className: "flood-scatter-chart__ticks flood-scatter-chart__ticks--x", "aria-hidden": "true", children: ticks(data.xScale).map(tick => _jsx(Text, { size: "helper", tone: "subtle", children: formatX(tick) }, tick)) }), _jsx(Text, { className: "flood-scatter-chart__horizontal-label", size: "helper", tone: "subtle", "aria-hidden": "true", dir: "auto", children: xLabel })] })] }) });
|
|
170
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useId, useState } from "react";
|
|
3
|
+
import { Surface, Text, VisuallyHidden } from "./index.js";
|
|
4
|
+
const number = new Intl.NumberFormat("en", { maximumSignificantDigits: 21 });
|
|
5
|
+
export const WaterfallPlot = /* @__PURE__ */ forwardRef(function WaterfallPlot({ label, steps, domain, formatValue = number.format, changeLabel = "Change", totalLabel = "Total", emptyLabel = "No steps", navigationLabel = "Use arrow keys to explore steps, or Home and End to jump.", className, ...props }, ref) {
|
|
6
|
+
const instructions = useId();
|
|
7
|
+
const [cursor, setCursor] = useState(0);
|
|
8
|
+
const [hovered, setHovered] = useState();
|
|
9
|
+
const [tapped, setTapped] = useState();
|
|
10
|
+
const [focused, setFocused] = useState(false);
|
|
11
|
+
const ids = new Set();
|
|
12
|
+
let running = 0;
|
|
13
|
+
let low = 0;
|
|
14
|
+
let high = 0;
|
|
15
|
+
const bars = steps.map(step => {
|
|
16
|
+
if (!step.id.trim() || ids.has(step.id) || !step.label.trim() || !Number.isFinite(step.value)) {
|
|
17
|
+
throw new Error("Flood WaterfallChart needs unique step IDs, labels and finite values");
|
|
18
|
+
}
|
|
19
|
+
ids.add(step.id);
|
|
20
|
+
const total = step.kind === "total";
|
|
21
|
+
const start = total ? 0 : running;
|
|
22
|
+
const end = start + step.value;
|
|
23
|
+
if (!Number.isFinite(end))
|
|
24
|
+
throw new RangeError("Flood WaterfallChart running totals must be finite");
|
|
25
|
+
low = Math.min(low, start, end);
|
|
26
|
+
high = Math.max(high, start, end);
|
|
27
|
+
running = end;
|
|
28
|
+
return { ...step, start, end, total };
|
|
29
|
+
});
|
|
30
|
+
if (domain && (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) ||
|
|
31
|
+
domain[0] >= domain[1] || domain[0] > low || domain[1] < high)) {
|
|
32
|
+
throw new RangeError("Flood WaterfallChart domain must contain zero and all running totals");
|
|
33
|
+
}
|
|
34
|
+
const [min, max] = domain ?? [low, low === high ? 1 : high];
|
|
35
|
+
const span = max - min;
|
|
36
|
+
const percent = (value) => 100 * (Number.isFinite(span)
|
|
37
|
+
? (value - min) / span
|
|
38
|
+
: (value / 2 - min / 2) / (max / 2 - min / 2));
|
|
39
|
+
const last = bars.length - 1;
|
|
40
|
+
const active = Math.min(last, hovered ?? tapped ?? cursor);
|
|
41
|
+
const current = bars[active];
|
|
42
|
+
const inspecting = hovered !== undefined || tapped !== undefined || focused;
|
|
43
|
+
const change = current && !current.total ? `${current.value > 0 ? "+" : ""}${formatValue(current.value)}` : undefined;
|
|
44
|
+
const total = current ? formatValue(current.end) : undefined;
|
|
45
|
+
const accessible = current && [current.label, ...(change === undefined ? [] : [`${changeLabel}: ${change}`]), `${totalLabel}: ${total}`].join(", ");
|
|
46
|
+
const navigate = (event) => {
|
|
47
|
+
let next;
|
|
48
|
+
switch (event.key) {
|
|
49
|
+
case "ArrowRight":
|
|
50
|
+
case "ArrowUp":
|
|
51
|
+
next = active + 1;
|
|
52
|
+
break;
|
|
53
|
+
case "ArrowLeft":
|
|
54
|
+
case "ArrowDown":
|
|
55
|
+
next = active - 1;
|
|
56
|
+
break;
|
|
57
|
+
case "Home":
|
|
58
|
+
next = 0;
|
|
59
|
+
break;
|
|
60
|
+
case "End":
|
|
61
|
+
next = last;
|
|
62
|
+
break;
|
|
63
|
+
case "Escape":
|
|
64
|
+
setHovered(undefined);
|
|
65
|
+
setTapped(undefined);
|
|
66
|
+
setFocused(false);
|
|
67
|
+
return;
|
|
68
|
+
default: return;
|
|
69
|
+
}
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
setHovered(undefined);
|
|
72
|
+
setTapped(undefined);
|
|
73
|
+
setFocused(true);
|
|
74
|
+
setCursor(Math.max(0, Math.min(next, last)));
|
|
75
|
+
};
|
|
76
|
+
return _jsx("div", { ...props, ref: ref, className: ["flood-waterfall-chart", className].filter(Boolean).join(" "), role: "group", "aria-label": label, children: current ? _jsxs(_Fragment, { children: [last > 0 && _jsx(VisuallyHidden, { id: instructions, children: navigationLabel }), _jsxs("div", { className: "flood-waterfall-chart__plot", dir: "ltr", role: last > 0 ? "slider" : "img", tabIndex: last > 0 ? 0 : undefined, "aria-label": last > 0 ? label : accessible, "aria-describedby": last > 0 ? instructions : undefined, "aria-valuemin": last > 0 ? 1 : undefined, "aria-valuemax": last > 0 ? bars.length : undefined, "aria-valuenow": last > 0 ? active + 1 : undefined, "aria-valuetext": last > 0 ? accessible : undefined, style: { "--flood-waterfall-columns": bars.length, "--flood-waterfall-zero": 1 - percent(0) / 100 }, onFocus: event => setFocused(event.currentTarget.matches(":focus-visible")), onBlur: () => setFocused(false), onKeyDown: navigate, onPointerLeave: () => setHovered(undefined), children: [_jsx("div", { className: "flood-waterfall-chart__columns", "aria-hidden": "true", children: bars.map((bar, index) => {
|
|
77
|
+
const start = percent(bar.start);
|
|
78
|
+
const end = percent(bar.end);
|
|
79
|
+
const next = bars[index + 1];
|
|
80
|
+
const connectBefore = index > 0 && (!bar.total || bars[index - 1].end === bar.end);
|
|
81
|
+
const connectAfter = next && (!next.total || bar.end === next.end);
|
|
82
|
+
return _jsxs("div", { className: "flood-waterfall-chart__column", "data-active": inspecting && index === active || undefined, onPointerEnter: event => { if (event.pointerType !== "touch")
|
|
83
|
+
setHovered(index); }, onPointerUp: event => { if (event.pointerType === "touch")
|
|
84
|
+
setTapped(previous => previous === index ? undefined : index); }, children: [_jsxs("div", { className: "flood-waterfall-chart__track", children: [connectBefore && _jsx("span", { className: "flood-waterfall-chart__connector", "data-side": "before", style: { insetBlockEnd: `${bar.total ? end : start}%` } }), connectAfter && _jsx("span", { className: "flood-waterfall-chart__connector", "data-side": "after", style: { insetBlockEnd: `${end}%` } }), _jsx("span", { className: "flood-waterfall-chart__bar", "data-kind": bar.total ? "total" : bar.value < 0 ? "decrease" : "increase", "data-flat": start === end || undefined, style: { insetBlockEnd: `${Math.min(start, end)}%`, blockSize: `${Math.abs(start - end)}%` } })] }), _jsx(Text, { className: "flood-waterfall-chart__category", size: "helper", tone: "subtle", dir: "auto", title: bar.label, children: bar.label })] }, bar.id);
|
|
85
|
+
}) }), inspecting && _jsxs(Surface, { className: "flood-chart__tooltip flood-waterfall-chart__tooltip", elevation: "raised", padding: "2", "aria-hidden": "true", style: {
|
|
86
|
+
"--flood-waterfall-position": `${(active + .5) / bars.length * 100}%`,
|
|
87
|
+
"--flood-waterfall-tip-side": active >= bars.length / 2 ? -1 : 0,
|
|
88
|
+
}, children: [_jsx(Text, { size: "helper", children: current.label }), change !== undefined && _jsxs("div", { className: "flood-waterfall-chart__reading", children: [_jsx(Text, { size: "helper", tone: "subtle", children: changeLabel }), _jsx(Text, { size: "helper", children: change })] }), _jsxs("div", { className: "flood-waterfall-chart__reading", children: [_jsx(Text, { size: "helper", tone: "subtle", children: totalLabel }), _jsx(Text, { size: "helper", children: total })] })] })] })] }) : _jsx(Text, { className: "flood-waterfall-chart__empty", size: "compact", tone: "subtle", children: emptyLabel }) });
|
|
89
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flood-md/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Original Flood components for React and Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@flood-md/tokens": "0.2.
|
|
33
|
+
"@flood-md/tokens": "0.2.13"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|