@flood-md/react 0.2.11 → 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 +39 -7
- package/dist/app-shell.js +2 -2
- 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 +4 -4
- package/dist/data-display.js +27 -3
- package/dist/data-table-columns.d.ts +25 -0
- package/dist/data-table-columns.js +271 -0
- package/dist/data-table-expansion.d.ts +21 -0
- package/dist/data-table-expansion.js +79 -0
- package/dist/data-table.js +100 -7
- package/dist/dumbbell-chart.d.ts +2 -0
- package/dist/dumbbell-chart.js +80 -0
- package/dist/editable.js +57 -18
- package/dist/feedback-actions.js +18 -3
- package/dist/foundations.css +2006 -227
- package/dist/histogram.d.ts +2 -0
- package/dist/histogram.js +78 -0
- package/dist/index.d.ts +328 -3
- package/dist/index.js +57 -4
- package/dist/menu.d.ts +1 -0
- package/dist/menu.js +6 -6
- package/dist/overlay-runtime.d.ts +5 -4
- package/dist/overlay-runtime.js +35 -12
- package/dist/overlays.d.ts +1 -0
- package/dist/overlays.js +5 -3
- 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/sparkline.d.ts +2 -0
- package/dist/sparkline.js +65 -0
- package/dist/waterfall-chart.d.ts +2 -0
- package/dist/waterfall-chart.js +89 -0
- package/package.json +2 -2
package/dist/menu.js
CHANGED
|
@@ -40,7 +40,7 @@ export function focusMenuEdge(panel, edge = "first") {
|
|
|
40
40
|
((edge === "last" ? all.at(-1) : all[0]) ?? panel)?.focus({ preventScroll: true });
|
|
41
41
|
}
|
|
42
42
|
/** Internal native top-layer engine shared by Menu and ContextMenu. */
|
|
43
|
-
export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ label, items, open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, onAction, edge, nested = false, position, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, forwardedRef) {
|
|
43
|
+
export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ label, items, open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, onAction, getFallbackFocus, edge, nested = false, position, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, forwardedRef) {
|
|
44
44
|
const generated = useId(), id = explicitId ?? `flood-menu-${generated}`;
|
|
45
45
|
const panel = useRef(null), anchors = useRef(new Map()), children = useRef(new Map());
|
|
46
46
|
const anchorRefs = useRef(new Map());
|
|
@@ -50,8 +50,8 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
|
|
|
50
50
|
const hoverIntent = useRef(undefined);
|
|
51
51
|
const latestItems = useRef(new Map());
|
|
52
52
|
const search = useRef({ text: "", time: 0 }), pointerTarget = useRef(null);
|
|
53
|
-
const current = useRef({ open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge });
|
|
54
|
-
current.current = { open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge };
|
|
53
|
+
const current = useRef({ open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge, getFallbackFocus });
|
|
54
|
+
current.current = { open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge, getFallbackFocus };
|
|
55
55
|
useImperativeHandle(forwardedRef, () => panel.current, []);
|
|
56
56
|
const clearHover = () => { clearTimeout(hover.current); hover.current = undefined; hoverIntent.current = undefined; };
|
|
57
57
|
useEffect(() => () => clearHover(), []);
|
|
@@ -166,7 +166,7 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
|
|
|
166
166
|
unregister();
|
|
167
167
|
document.removeEventListener("pointerdown", trackPointer, true);
|
|
168
168
|
element.removeEventListener("toggle", toggle);
|
|
169
|
-
returnFocus(element, current.current.anchor());
|
|
169
|
+
returnFocus(element, current.current.anchor(), current.current.getFallbackFocus);
|
|
170
170
|
if (element.matches(":popover-open"))
|
|
171
171
|
element.hidePopover();
|
|
172
172
|
};
|
|
@@ -328,7 +328,7 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
|
|
|
328
328
|
}
|
|
329
329
|
}, children: showContent && _jsx(ScrollArea, { orientation: "vertical", role: "group", tabIndex: -1, className: "flood-menu__viewport", children: rows }) });
|
|
330
330
|
});
|
|
331
|
-
export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, side = "bottom", align = "start", controlSize = "sm", ...props }, forwardedRef) {
|
|
331
|
+
export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, getFallbackFocus, side = "bottom", align = "start", controlSize = "sm", ...props }, forwardedRef) {
|
|
332
332
|
if (!label.trim())
|
|
333
333
|
throw new Error("Flood Menu requires a nonempty label");
|
|
334
334
|
validateMenuTree(items);
|
|
@@ -365,5 +365,5 @@ export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label
|
|
|
365
365
|
} };
|
|
366
366
|
const generated = useId(), id = props.id ?? `flood-menu-${generated}`;
|
|
367
367
|
triggerProps["aria-controls"] = id;
|
|
368
|
-
return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx(MenuLayer, { ...props, id: id, ref: panel, label: label, items: items, open: open, requestOpen: request, closeTree: () => request(false), anchor: () => anchor.current, rootAnchor: () => anchor.current, onAction: onAction, edge: () => edge.current, side: side, align: align, controlSize: controlSize })] });
|
|
368
|
+
return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx(MenuLayer, { ...props, id: id, ref: panel, label: label, items: items, open: open, requestOpen: request, closeTree: () => request(false), anchor: () => anchor.current, rootAnchor: () => anchor.current, onAction: onAction, getFallbackFocus: getFallbackFocus, edge: () => edge.current, side: side, align: align, controlSize: controlSize })] });
|
|
369
369
|
});
|
|
@@ -18,7 +18,7 @@ export declare function placeOverlay(anchor: Rect, size: {
|
|
|
18
18
|
top: number;
|
|
19
19
|
width: number;
|
|
20
20
|
height: number;
|
|
21
|
-
}, side: Side, align: Align, gap: number, rtl?: boolean): {
|
|
21
|
+
}, side: Side, align: Align, gap: number, rtl?: boolean, overlap?: boolean): {
|
|
22
22
|
left: number;
|
|
23
23
|
top: number;
|
|
24
24
|
side: Side;
|
|
@@ -29,13 +29,14 @@ type Layer = {
|
|
|
29
29
|
anchor: () => HTMLElement | null;
|
|
30
30
|
close: () => void;
|
|
31
31
|
outside?: () => void;
|
|
32
|
+
closeOnFocusOutside?: boolean;
|
|
32
33
|
modal?: boolean;
|
|
33
34
|
tooltip?: boolean;
|
|
34
35
|
};
|
|
35
36
|
export declare function registerLayer(document: Document, layer: Layer): () => void;
|
|
36
37
|
export declare function lockScroll(document: Document): () => void;
|
|
37
|
-
export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align): void;
|
|
38
|
-
export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null): void;
|
|
38
|
+
export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align, overlap?: boolean): void;
|
|
39
|
+
export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null, fallback?: () => HTMLElement | null): void;
|
|
39
40
|
/** Retain pixels for a CSS exit after the native layer has released focus. */
|
|
40
|
-
export declare function useLayerExitContent(panel: RefObject<HTMLElement | null>, open: boolean, enabled?: boolean): boolean;
|
|
41
|
+
export declare function useLayerExitContent(panel: RefObject<HTMLElement | null>, open: boolean, enabled?: boolean, transitionProperty?: string): boolean;
|
|
41
42
|
export {};
|
package/dist/overlay-runtime.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useState } from "react";
|
|
2
2
|
/** Pure collision handling in layout-viewport coordinates. */
|
|
3
|
-
export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false) {
|
|
3
|
+
export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false, overlap = false) {
|
|
4
4
|
const edge = { left: viewport.left + gap, right: viewport.left + viewport.width - gap, top: viewport.top + gap, bottom: viewport.top + viewport.height - gap };
|
|
5
5
|
const space = { top: anchor.top - edge.top, bottom: edge.bottom - anchor.bottom, left: anchor.left - edge.left, right: edge.right - anchor.right };
|
|
6
6
|
const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
7
7
|
const needed = (side === "top" || side === "bottom" ? size.height : size.width) + gap;
|
|
8
|
-
if (space[side] < needed && space[opposite[side]] > space[side])
|
|
8
|
+
if (!overlap && space[side] < needed && space[opposite[side]] > space[side])
|
|
9
9
|
side = opposite[side];
|
|
10
10
|
const horizontal = side === "top" || side === "bottom";
|
|
11
11
|
const resolved = horizontal && rtl ? (align === "start" ? "end" : align === "end" ? "start" : align) : align;
|
|
12
12
|
let left = horizontal ? (resolved === "start" ? anchor.left : resolved === "end" ? anchor.right - size.width : anchor.left + (anchor.width - size.width) / 2) : side === "left" ? anchor.left - size.width - gap : anchor.right + gap;
|
|
13
13
|
let top = horizontal ? (side === "top" ? anchor.top - size.height - gap : anchor.bottom + gap) : (resolved === "start" ? anchor.top : resolved === "end" ? anchor.bottom - size.height : anchor.top + (anchor.height - size.height) / 2);
|
|
14
|
+
if (overlap)
|
|
15
|
+
top = anchor.top;
|
|
14
16
|
left = Math.max(edge.left, Math.min(left, edge.right - size.width));
|
|
15
17
|
top = Math.max(edge.top, Math.min(top, edge.bottom - size.height));
|
|
16
18
|
return { left, top, side };
|
|
@@ -58,7 +60,7 @@ export function registerLayer(document, layer) {
|
|
|
58
60
|
(layer.outside ?? layer.close)();
|
|
59
61
|
};
|
|
60
62
|
const focus = (event) => {
|
|
61
|
-
if (top() !== layer || layer.modal)
|
|
63
|
+
if (top() !== layer || layer.modal || layer.closeOnFocusOutside === false)
|
|
62
64
|
return;
|
|
63
65
|
const target = event.target;
|
|
64
66
|
if (!layer.panel.contains(target) && !layer.anchor()?.contains(target))
|
|
@@ -109,7 +111,7 @@ export function lockScroll(document) {
|
|
|
109
111
|
locks.delete(document);
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
|
-
export function positionLayer(anchor, panel, side, align) {
|
|
114
|
+
export function positionLayer(anchor, panel, side, align, overlap = false) {
|
|
113
115
|
const view = panel.ownerDocument.defaultView;
|
|
114
116
|
const visual = view.visualViewport;
|
|
115
117
|
const viewport = { left: visual?.offsetLeft ?? 0, top: visual?.offsetTop ?? 0, width: visual?.width ?? view.innerWidth, height: visual?.height ?? view.innerHeight };
|
|
@@ -121,7 +123,7 @@ export function positionLayer(anchor, panel, side, align) {
|
|
|
121
123
|
panel.style.setProperty("--flood-overlay-anchor-width", anchorWidth);
|
|
122
124
|
if (panel.style.getPropertyValue("--flood-overlay-anchor-height") !== anchorHeight)
|
|
123
125
|
panel.style.setProperty("--flood-overlay-anchor-height", anchorHeight);
|
|
124
|
-
const position = placeOverlay(bounds, panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl");
|
|
126
|
+
const position = placeOverlay(bounds, panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl", overlap);
|
|
125
127
|
const left = `${position.left}px`, top = `${position.top}px`;
|
|
126
128
|
if (panel.style.left !== left)
|
|
127
129
|
panel.style.left = left;
|
|
@@ -130,13 +132,34 @@ export function positionLayer(anchor, panel, side, align) {
|
|
|
130
132
|
panel.dataset.side = position.side;
|
|
131
133
|
panel.style.visibility = "visible";
|
|
132
134
|
}
|
|
133
|
-
export function returnFocus(panel, anchor) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
export function returnFocus(panel, anchor, fallback) {
|
|
136
|
+
const document = panel.ownerDocument;
|
|
137
|
+
const needsFocus = () => {
|
|
138
|
+
const active = document.activeElement;
|
|
139
|
+
return !active || active === document.body || panel.contains(active);
|
|
140
|
+
};
|
|
141
|
+
const focus = (target) => {
|
|
142
|
+
if (!target?.isConnected || target.ownerDocument !== document || panel.contains(target) ||
|
|
143
|
+
target.matches(':disabled,[aria-disabled="true"]') || target.closest("[hidden],[inert]") ||
|
|
144
|
+
!target.getClientRects().length || document.defaultView?.getComputedStyle(target).visibility !== "visible")
|
|
145
|
+
return;
|
|
146
|
+
target.focus({ preventScroll: true });
|
|
147
|
+
};
|
|
148
|
+
if (needsFocus())
|
|
149
|
+
focus(anchor);
|
|
150
|
+
else if (document.activeElement !== anchor)
|
|
151
|
+
return;
|
|
152
|
+
if (!fallback)
|
|
153
|
+
return;
|
|
154
|
+
// A surviving opener can still be removed in the same React commit.
|
|
155
|
+
queueMicrotask(() => {
|
|
156
|
+
if (panel.matches("dialog[open]") || panel.matches(":popover-open") || !needsFocus())
|
|
157
|
+
return;
|
|
158
|
+
focus(fallback());
|
|
159
|
+
});
|
|
137
160
|
}
|
|
138
161
|
/** Retain pixels for a CSS exit after the native layer has released focus. */
|
|
139
|
-
export function useLayerExitContent(panel, open, enabled = true) {
|
|
162
|
+
export function useLayerExitContent(panel, open, enabled = true, transitionProperty = "opacity") {
|
|
140
163
|
const [retained, setRetained] = useState(false);
|
|
141
164
|
useLayerLayoutEffect(() => {
|
|
142
165
|
const node = panel.current;
|
|
@@ -149,7 +172,7 @@ export function useLayerExitContent(panel, open, enabled = true) {
|
|
|
149
172
|
return;
|
|
150
173
|
}
|
|
151
174
|
node.inert = true;
|
|
152
|
-
const transitions = node.getAnimations().filter(animation => "transitionProperty" in animation && animation.transitionProperty ===
|
|
175
|
+
const transitions = node.getAnimations().filter(animation => "transitionProperty" in animation && animation.transitionProperty === transitionProperty);
|
|
153
176
|
let active = true;
|
|
154
177
|
if (!transitions.length)
|
|
155
178
|
setRetained(false);
|
|
@@ -159,7 +182,7 @@ export function useLayerExitContent(panel, open, enabled = true) {
|
|
|
159
182
|
setRetained(false);
|
|
160
183
|
});
|
|
161
184
|
return () => { active = false; node.inert = false; };
|
|
162
|
-
}, [panel, open, enabled]);
|
|
185
|
+
}, [panel, open, enabled, transitionProperty]);
|
|
163
186
|
return open || enabled && retained;
|
|
164
187
|
}
|
|
165
188
|
const useLayerLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
package/dist/overlays.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ interface ModalOverlayProps extends DialogProps {
|
|
|
7
7
|
showCloseButton?: boolean;
|
|
8
8
|
role?: "dialog" | "alertdialog";
|
|
9
9
|
scrollBody?: boolean;
|
|
10
|
+
bodyContentInset?: "none" | "scrollbar";
|
|
10
11
|
footer?: import("react").ReactNode;
|
|
11
12
|
}
|
|
12
13
|
export declare const ModalOverlay: import("react").ForwardRefExoticComponent<ModalOverlayProps & import("react").RefAttributes<HTMLDialogElement>>;
|
package/dist/overlays.js
CHANGED
|
@@ -126,12 +126,14 @@ export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverl
|
|
|
126
126
|
leave();
|
|
127
127
|
} }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [pop.chrome === "minimal" ? _jsx(VisuallyHidden, { children: _jsx("h2", { id: `${id}-title`, children: pop.title }) }) : _jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: pop.title }), !titleOnly && _jsx(OverlayClose, { label: pop.closeLabel ?? "Close", onClick: close })] }), showContent && _jsx("div", { className: "flood-overlay-body", children: pop.children })] }) })] });
|
|
128
128
|
});
|
|
129
|
-
export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ showCloseButton = true, scrollBody = false, footer, title, description, trigger, children, open: controlled, defaultOpen = false, onOpenChange, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, initialFocusRef, id: explicitId, className, onKeyDown, "aria-describedby": describedBy, ...props }, forwardedRef) {
|
|
129
|
+
export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ showCloseButton = true, scrollBody = false, bodyContentInset, footer, title, description, trigger, children, open: controlled, defaultOpen = false, onOpenChange, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, initialFocusRef, getFallbackFocus, id: explicitId, className, onKeyDown, "aria-describedby": describedBy, ...props }, forwardedRef) {
|
|
130
130
|
const [local, setLocal] = useState(defaultOpen), open = controlled ?? local;
|
|
131
131
|
const dialog = useRef(null), anchor = useRef(null), generated = useId(), id = explicitId ?? `flood-dialog-${generated}`;
|
|
132
132
|
const request = (next) => { if (controlled === undefined)
|
|
133
133
|
setLocal(next); onOpenChange?.(next); };
|
|
134
134
|
const current = useCurrent({ open, request, closeOnOutsideClick, initialFocusRef });
|
|
135
|
+
const fallbackFocus = useRef(getFallbackFocus);
|
|
136
|
+
fallbackFocus.current = getFallbackFocus;
|
|
135
137
|
const setAnchor = useCallback((node) => { anchor.current = node; }, []);
|
|
136
138
|
useImperativeHandle(forwardedRef, () => dialog.current, []);
|
|
137
139
|
useBrowserLayoutEffect(() => {
|
|
@@ -168,7 +170,7 @@ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ s
|
|
|
168
170
|
element.addEventListener("close", closed);
|
|
169
171
|
focusContent(element, current.current.initialFocusRef?.current);
|
|
170
172
|
dispose = () => { unregister(); unlock(); element.removeEventListener("cancel", cancel); element.removeEventListener("close", closed); if (element.open)
|
|
171
|
-
element.close(); returnFocus(element, anchor.current ?? previous); };
|
|
173
|
+
element.close(); returnFocus(element, anchor.current ?? previous, fallbackFocus.current ? () => fallbackFocus.current?.() ?? null : undefined); };
|
|
172
174
|
};
|
|
173
175
|
start();
|
|
174
176
|
return () => { alive = false; view.cancelAnimationFrame(frame); dispose(); };
|
|
@@ -194,5 +196,5 @@ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ s
|
|
|
194
196
|
event.preventDefault();
|
|
195
197
|
first.focus();
|
|
196
198
|
}
|
|
197
|
-
}, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), showCloseButton && _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && (scrollBody ? _jsx("div", { className: "flood-drawer__body", children: _jsx(ScrollArea, { className: "flood-overlay-body", style: { height: "100%" }, children: children }) }) : _jsx("div", { className: "flood-overlay-body", children: children })), open && footer && _jsx("div", { className: "flood-drawer__footer", children: footer })] })] });
|
|
199
|
+
}, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), showCloseButton && _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && (scrollBody ? _jsx("div", { className: "flood-drawer__body", children: _jsx(ScrollArea, { className: "flood-overlay-body", contentInset: bodyContentInset, style: { height: "100%" }, children: children }) }) : _jsx("div", { className: "flood-overlay-body", children: children })), open && footer && _jsx("div", { className: "flood-drawer__footer", children: footer })] })] });
|
|
198
200
|
});
|
|
@@ -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,65 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useId } from "react";
|
|
3
|
+
import { Text } from "./index.js";
|
|
4
|
+
const plotWidth = 96;
|
|
5
|
+
const plotHeight = 32;
|
|
6
|
+
const plotInset = 3;
|
|
7
|
+
function isObservation(value) {
|
|
8
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
9
|
+
}
|
|
10
|
+
function getPlot(values, domain, area) {
|
|
11
|
+
let observedMin = Infinity;
|
|
12
|
+
let observedMax = -Infinity;
|
|
13
|
+
for (const value of values) {
|
|
14
|
+
if (isObservation(value)) {
|
|
15
|
+
observedMin = Math.min(observedMin, value);
|
|
16
|
+
observedMax = Math.max(observedMax, value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (observedMin === Infinity)
|
|
20
|
+
return null;
|
|
21
|
+
const [min, max] = domain && Number.isFinite(domain[0]) && Number.isFinite(domain[1]) && domain[0] < domain[1]
|
|
22
|
+
? domain
|
|
23
|
+
: [observedMin, observedMax];
|
|
24
|
+
const span = max - min;
|
|
25
|
+
const commands = [];
|
|
26
|
+
const areas = [];
|
|
27
|
+
const markers = [];
|
|
28
|
+
let lastPoint;
|
|
29
|
+
let segmentStart = 0;
|
|
30
|
+
let segmentStartX = "";
|
|
31
|
+
for (const [index, value] of values.entries()) {
|
|
32
|
+
if (!isObservation(value))
|
|
33
|
+
continue;
|
|
34
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
35
|
+
// Halve the operands before subtracting if the finite bounds span more than Number.MAX_VALUE.
|
|
36
|
+
const fraction = span === 0 ? 0.5 : Number.isFinite(span)
|
|
37
|
+
? (clamped - min) / span
|
|
38
|
+
: (clamped / 2 - min / 2) / (max / 2 - min / 2);
|
|
39
|
+
const x = values.length === 1 ? plotWidth / 2 : plotInset + index * (plotWidth - 2 * plotInset) / (values.length - 1);
|
|
40
|
+
const y = plotHeight - plotInset - fraction * (plotHeight - 2 * plotInset);
|
|
41
|
+
const continuesLine = isObservation(values[index - 1]);
|
|
42
|
+
const plotX = x.toFixed(2);
|
|
43
|
+
if (!continuesLine) {
|
|
44
|
+
segmentStart = commands.length;
|
|
45
|
+
segmentStartX = plotX;
|
|
46
|
+
}
|
|
47
|
+
commands.push(`${continuesLine ? "L" : "M"}${plotX} ${y.toFixed(2)}`);
|
|
48
|
+
lastPoint = { x, y, index };
|
|
49
|
+
if (!isObservation(values[index + 1])) {
|
|
50
|
+
if (!continuesLine)
|
|
51
|
+
markers.push(lastPoint);
|
|
52
|
+
else if (area)
|
|
53
|
+
areas.push(`${commands.slice(segmentStart).join(" ")} L${plotX} ${plotHeight} H${segmentStartX} Z`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (lastPoint && markers.at(-1) !== lastPoint)
|
|
57
|
+
markers.push(lastPoint);
|
|
58
|
+
return { path: commands.join(" "), area: areas.join(" "), markers, lastPoint };
|
|
59
|
+
}
|
|
60
|
+
const markerPath = ({ x, y }) => `M${x.toFixed(2)} ${y.toFixed(2)}h0`;
|
|
61
|
+
export const TrendPlot = /* @__PURE__ */ forwardRef(function TrendPlot({ values, domain, label, showLabel, size, tone, variant, className, ...props }, ref) {
|
|
62
|
+
const gradientId = useId();
|
|
63
|
+
const plot = getPlot(values, domain, variant === "area");
|
|
64
|
+
return (_jsxs("div", { ...props, ref: ref, className: ["flood-sparkline", className].filter(Boolean).join(" "), "data-size": size, "data-tone": tone, role: "img", "aria-label": label, children: [plot ? (_jsxs("svg", { className: "flood-sparkline__plot", viewBox: `0 0 ${plotWidth} ${plotHeight}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: [plot.area && _jsxs(_Fragment, { children: [_jsx("defs", { children: _jsxs("linearGradient", { id: gradientId, x1: "0", x2: "0", y1: "0", y2: "1", children: [_jsx("stop", { stopColor: "currentColor", stopOpacity: ".42" }), _jsx("stop", { offset: "100%", stopColor: "currentColor", stopOpacity: "0" })] }) }), _jsx("path", { className: "flood-sparkline__area", d: plot.area, fill: `url(#${gradientId})` })] }), _jsx("path", { className: "flood-sparkline__line", d: plot.path }), plot.lastPoint && _jsx("path", { className: "flood-sparkline__halo", d: markerPath(plot.lastPoint) }), plot.markers.map(point => (_jsx("path", { className: "flood-sparkline__point", d: markerPath(point), "data-latest": point === plot.lastPoint || undefined }, point.index)))] })) : (_jsx(Text, { className: "flood-sparkline__empty", size: "helper", tone: "subtle", "aria-hidden": "true", children: "\u2014" })), showLabel && _jsx(Text, { className: "flood-sparkline__label", size: "helper", tone: "subtle", "aria-hidden": "true", children: label })] }));
|
|
65
|
+
});
|