@sentropic/design-system-react 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AreaChart.d.ts +16 -2
- package/dist/AreaChart.d.ts.map +1 -1
- package/dist/AreaChart.js +96 -1
- package/dist/AreaChart.js.map +1 -1
- package/dist/BarChart.d.ts +16 -2
- package/dist/BarChart.d.ts.map +1 -1
- package/dist/BarChart.js +90 -1
- package/dist/BarChart.js.map +1 -1
- package/dist/DataTable.d.ts +49 -2
- package/dist/DataTable.d.ts.map +1 -1
- package/dist/DataTable.js +144 -1
- package/dist/DataTable.js.map +1 -1
- package/dist/DonutChart.d.ts +19 -2
- package/dist/DonutChart.d.ts.map +1 -1
- package/dist/DonutChart.js +61 -1
- package/dist/DonutChart.js.map +1 -1
- package/dist/ForceGraph.d.ts +2 -2
- package/dist/ForceGraph.d.ts.map +1 -1
- package/dist/ForceGraph.js +1 -1
- package/dist/ForceGraph.js.map +1 -1
- package/dist/LineChart.d.ts +17 -2
- package/dist/LineChart.d.ts.map +1 -1
- package/dist/LineChart.js +92 -1
- package/dist/LineChart.js.map +1 -1
- package/dist/ScatterPlot.d.ts +19 -2
- package/dist/ScatterPlot.d.ts.map +1 -1
- package/dist/ScatterPlot.js +55 -1
- package/dist/ScatterPlot.js.map +1 -1
- package/dist/Sparkline.d.ts +13 -2
- package/dist/Sparkline.d.ts.map +1 -1
- package/dist/Sparkline.js +32 -1
- package/dist/Sparkline.js.map +1 -1
- package/dist/StackedBarChart.d.ts +20 -2
- package/dist/StackedBarChart.d.ts.map +1 -1
- package/dist/StackedBarChart.js +78 -1
- package/dist/StackedBarChart.js.map +1 -1
- package/dist/catalog.d.ts +75 -97
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +302 -124
- package/dist/catalog.js.map +1 -1
- package/dist/chartScale.d.ts +25 -0
- package/dist/chartScale.d.ts.map +1 -0
- package/dist/chartScale.js +71 -0
- package/dist/chartScale.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +38 -2
- package/package.json +1 -1
package/dist/catalog.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { classNames } from "./classNames.js";
|
|
4
|
+
// Normalises an action item so the rest of the component only deals with the
|
|
5
|
+
// React-native shape: `id` (from id|value) and `variant` (from variant|danger).
|
|
6
|
+
function actionId(item, index, prefix = "item") {
|
|
7
|
+
return item.id ?? item.value ?? (index === undefined ? undefined : `${prefix}-${index}`);
|
|
8
|
+
}
|
|
9
|
+
function isDangerAction(item) {
|
|
10
|
+
return item.variant === "danger" || item.danger === true;
|
|
11
|
+
}
|
|
4
12
|
const DATA_TONES = [
|
|
5
13
|
"category1",
|
|
6
14
|
"category2",
|
|
@@ -137,21 +145,12 @@ function pct(value, min = 0, max = 100) {
|
|
|
137
145
|
return 0;
|
|
138
146
|
return clamp(((value - min) / (max - min)) * 100, 0, 100);
|
|
139
147
|
}
|
|
140
|
-
function
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
.map((value, index) => {
|
|
145
|
-
const x = ys.length === 1 ? width / 2 : (index / (ys.length - 1)) * width;
|
|
146
|
-
const y = height - (value / max) * height;
|
|
147
|
-
return `${x},${y}`;
|
|
148
|
-
})
|
|
149
|
-
.join(" ");
|
|
150
|
-
}
|
|
151
|
-
export function Accordion({ items, openIds, defaultOpenIds = [], allowMultiple = true, onChange, className, ...rest }) {
|
|
152
|
-
const [open, setOpen] = useControlled(openIds, defaultOpenIds, onChange);
|
|
148
|
+
export function Accordion({ items, openIds, defaultOpenIds, open: openAlias, allowMultiple, multiple, onChange, className, ...rest }) {
|
|
149
|
+
const initialOpen = defaultOpenIds ?? openAlias ?? [];
|
|
150
|
+
const resolvedAllowMultiple = allowMultiple ?? multiple ?? true;
|
|
151
|
+
const [open, setOpen] = useControlled(openIds, initialOpen, onChange);
|
|
153
152
|
const toggle = (id) => {
|
|
154
|
-
const next = open.includes(id) ? open.filter((value) => value !== id) :
|
|
153
|
+
const next = open.includes(id) ? open.filter((value) => value !== id) : resolvedAllowMultiple ? [...open, id] : [id];
|
|
155
154
|
setOpen(next);
|
|
156
155
|
};
|
|
157
156
|
return (_jsx("div", { ...rest, className: classNames("st-accordion", className), children: items.map((item, index) => {
|
|
@@ -165,46 +164,6 @@ export function Accordion({ items, openIds, defaultOpenIds = [], allowMultiple =
|
|
|
165
164
|
export function Alert({ tone = "info", title, message, actions, children, className, ...rest }) {
|
|
166
165
|
return (_jsxs("section", { ...rest, className: classNames("st-alert", `st-alert--${tone}`, className), role: tone === "warning" || tone === "error" ? "alert" : "status", children: [_jsxs("div", { className: "st-alert__content", children: [_jsx("h2", { className: "st-alert__title", children: title }), message ? _jsx("p", { className: "st-alert__message", children: message }) : null, children] }), actions ? _jsx("div", { className: "st-alert__actions", children: actions }) : null] }));
|
|
167
166
|
}
|
|
168
|
-
function LinearChart({ data, label, width = 320, height = 160, className, type }) {
|
|
169
|
-
const classBase = type === "areaChart" ? "st-areaChart" : "st-lineChart";
|
|
170
|
-
const points = pointsFrom(data, width, height);
|
|
171
|
-
const accessibleLabel = label ?? (type === "areaChart" ? "Area chart" : "Line chart");
|
|
172
|
-
return (_jsxs("figure", { className: classNames(classBase, className), "aria-label": accessibleLabel, children: [_jsx("span", { className: "st-visually-hidden", children: accessibleLabel }), _jsxs("svg", { viewBox: `0 0 `, "aria-hidden": "true", children: [_jsx("polyline", { className: `${classBase}__line`, points: points, fill: "none" }), type === "areaChart" ? _jsx("polygon", { className: "st-areaChart__area", points: `0,${height} ${points} ${width},${height}` }) : null, data.map((datum, index) => (_jsx("circle", { className: `${classBase}__dot`, cx: points.split(" ")[index]?.split(",")[0], cy: points.split(" ")[index]?.split(",")[1], r: "4" }, index)))] })] }));
|
|
173
|
-
}
|
|
174
|
-
export function AreaChart(props) {
|
|
175
|
-
return _jsx(LinearChart, { ...props, type: "areaChart" });
|
|
176
|
-
}
|
|
177
|
-
export function LineChart(props) {
|
|
178
|
-
return _jsx(LinearChart, { ...props, type: "lineChart" });
|
|
179
|
-
}
|
|
180
|
-
export function BarChart({ data, label = "Bar chart", width = 320, height = 160, className, ...rest }) {
|
|
181
|
-
const max = Math.max(...data.map((datum) => datum.value ?? datum.y ?? 0), 1);
|
|
182
|
-
return (_jsxs("figure", { ...rest, className: classNames("st-barChart", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: `0 0 `, "aria-hidden": "true", children: data.map((datum, index) => {
|
|
183
|
-
const value = datum.value ?? datum.y ?? 0;
|
|
184
|
-
const barWidth = width / Math.max(data.length, 1) - 8;
|
|
185
|
-
const barHeight = (value / max) * height;
|
|
186
|
-
return (_jsx("rect", { className: classNames("st-barChart__bar", `st-barChart__bar--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), x: index * (barWidth + 8) + 4, y: height - barHeight, width: barWidth, height: barHeight }, index));
|
|
187
|
-
}) })] }));
|
|
188
|
-
}
|
|
189
|
-
export function DonutChart({ data, label = "Donut chart", className, ...rest }) {
|
|
190
|
-
const total = data.reduce((sum, datum) => sum + (datum.value ?? datum.y ?? 0), 0);
|
|
191
|
-
return (_jsxs("figure", { ...rest, className: classNames("st-donutChart", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsxs("svg", { viewBox: "0 0 120 120", "aria-hidden": "true", children: [data.map((datum, index) => (_jsx("circle", { className: classNames("st-donutChart__slice", `st-donutChart__slice--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), cx: "60", cy: "60", r: 36 - index * 3, fill: "none", strokeWidth: "8" }, index))), _jsx("text", { className: "st-donutChart__center", x: "60", y: "64", textAnchor: "middle", children: total })] })] }));
|
|
192
|
-
}
|
|
193
|
-
export function ScatterPlot({ data, label = "Scatter chart", className, ...rest }) {
|
|
194
|
-
return (_jsxs("figure", { ...rest, className: classNames("st-scatterPlot", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 320 160", "aria-hidden": "true", children: data.map((datum, index) => (_jsx("circle", { className: classNames("st-scatterPlot__point", `st-scatterPlot__point--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), cx: clamp(datum.x * 24 + 24, 12, 308), cy: clamp(148 - datum.y * 24, 12, 148), r: "5", children: _jsx("title", { children: datum.label ?? `${datum.x}, ${datum.y}` }) }, index))) })] }));
|
|
195
|
-
}
|
|
196
|
-
export function StackedBarChart({ data, label = "Stacked bar chart", className, ...rest }) {
|
|
197
|
-
return (_jsxs("figure", { ...rest, className: classNames("st-stackedBar", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 320 160", "aria-hidden": "true", children: data.map((bar, barIndex) => {
|
|
198
|
-
let x = 16;
|
|
199
|
-
const total = Math.max(bar.segments.reduce((sum, segment) => sum + segment.value, 0), 1);
|
|
200
|
-
return (_jsxs("g", { transform: `translate(0 ${barIndex * 32 + 16})`, children: [bar.segments.map((segment, index) => {
|
|
201
|
-
const width = (segment.value / total) * 220;
|
|
202
|
-
const rect = (_jsx("rect", { className: classNames("st-stackedBar__seg", `st-stackedBar__seg--${segment.tone ?? DATA_TONES[index % DATA_TONES.length]}`), x: x, y: "0", width: width, height: "20" }, segment.label));
|
|
203
|
-
x += width;
|
|
204
|
-
return rect;
|
|
205
|
-
}), _jsx("text", { className: "st-stackedBar__categoryLabel", x: "250", y: "15", children: bar.label })] }, bar.label));
|
|
206
|
-
}) })] }));
|
|
207
|
-
}
|
|
208
167
|
export function AspectRatio({ ratio = "16 / 9", className, style, children, ...rest }) {
|
|
209
168
|
const aspectRatio = typeof ratio === "number" ? String(ratio) : ratio;
|
|
210
169
|
return (_jsx("div", { ...rest, className: classNames("st-aspectRatio", className), style: { aspectRatio, ...style }, children: children }));
|
|
@@ -292,11 +251,12 @@ export function Combobox({ label, options, value, size = "md", placeholder = "Se
|
|
|
292
251
|
selectOption(option);
|
|
293
252
|
}, children: option.label }, option.value)))) : (_jsx("li", { className: "st-combobox__empty", role: "option", "aria-disabled": "true", "aria-selected": "false", children: noResultsLabel })) })) : null] }));
|
|
294
253
|
}
|
|
295
|
-
export function ContentSwitcher({ items, value, activeId, onChange, size = "md", className, ...rest }) {
|
|
254
|
+
export function ContentSwitcher({ items, value, activeId, onChange, onchange, size = "md", className, ...rest }) {
|
|
255
|
+
const handleChange = onChange ?? onchange;
|
|
296
256
|
const current = value ?? activeId ?? idFrom(items[0] ?? {}, 0, "content");
|
|
297
257
|
return (_jsx("div", { ...rest, className: classNames("st-contentSwitcher", `st-contentSwitcher--${size}`, className), role: "group", children: items.map((item, index) => {
|
|
298
258
|
const itemId = idFrom(item, index, "content");
|
|
299
|
-
return (_jsx("button", { type: "button", className: classNames("st-contentSwitcher__option st-contentSwitcher__button", itemId === current && "st-contentSwitcher__option--selected"), disabled: item.disabled, "aria-pressed": itemId === current, onClick: () =>
|
|
259
|
+
return (_jsx("button", { type: "button", className: classNames("st-contentSwitcher__option st-contentSwitcher__button", itemId === current && "st-contentSwitcher__option--selected"), disabled: item.disabled, "aria-pressed": itemId === current, onClick: () => handleChange?.(itemId), children: item.label }, itemId));
|
|
300
260
|
}) }));
|
|
301
261
|
}
|
|
302
262
|
export function CopyButton({ text: copyText, value, label = "Copy", copiedLabel = "Copied", size = "md", className, onClick, ...rest }) {
|
|
@@ -307,11 +267,6 @@ export function CopyButton({ text: copyText, value, label = "Copy", copiedLabel
|
|
|
307
267
|
onClick?.(event);
|
|
308
268
|
}, children: _jsx("span", { className: "st-copyButton__label", children: copied ? copiedLabel : label }) }));
|
|
309
269
|
}
|
|
310
|
-
export function DataTable({ columns, rows, caption, size = "md", className, pageSize, page = 1, totalItems, ...rest }) {
|
|
311
|
-
const visibleRows = pageSize ? rows.slice((page - 1) * pageSize, page * pageSize) : rows;
|
|
312
|
-
const total = totalItems ?? rows.length;
|
|
313
|
-
return (_jsxs("div", { className: "st-dataTable-wrap", children: [_jsxs("table", { ...rest, className: classNames("st-dataTable", `st-dataTable--${size}`, className), children: [caption ? _jsx("caption", { children: caption }) : null, _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { children: column.label }, column.key)) }) }), _jsx("tbody", { children: visibleRows.map((row) => (_jsx("tr", { children: columns.map((column) => (_jsx("td", { className: classNames(column.align === "center" && "st-dataTable__cell--center", column.align === "end" && "st-dataTable__cell--end"), children: column.render?.(row, column) ?? text(row[column.key]) }, column.key))) }, row.id))) })] }), pageSize ? _jsx("div", { className: "st-dataTable__pagerStatus", children: `${(page - 1) * pageSize + 1}-${Math.min(page * pageSize, total)} of ${total}` }) : null] }));
|
|
314
|
-
}
|
|
315
270
|
export function DatePicker({ label, value, size = "md", className, ...rest }) {
|
|
316
271
|
return (_jsx("div", { ...rest, className: classNames("st-datepicker", `st-datepicker--${size}`, className), children: _jsx(Field, { label: label, children: (inputId) => _jsx("input", { id: inputId, className: "st-control st-datepicker__control", type: "date", defaultValue: value }) }) }));
|
|
317
272
|
}
|
|
@@ -338,7 +293,8 @@ export function Drawer({ open = false, title, description, footer, placement = "
|
|
|
338
293
|
onClose?.();
|
|
339
294
|
}, children: _jsxs("aside", { ...rest, ref: panelRef, className: classNames("st-drawer", `st-drawer--${placement}`, className), role: "dialog", "aria-modal": "true", "aria-label": text(title) || "Drawer", tabIndex: -1, onKeyDown: (event) => trapTabKey(event, panelRef.current), children: [_jsxs("div", { className: "st-drawer__header", children: [title ? _jsx("h2", { className: "st-drawer__title", children: title }) : null, _jsx("button", { ref: closeRef, type: "button", className: "st-drawer__close", onClick: onClose, "aria-label": "Close", children: "x" })] }), description ? _jsx("p", { className: "st-drawer__description", children: description }) : null, _jsx("div", { className: "st-drawer__body", children: children }), footer ? _jsx("div", { className: "st-drawer__footer", children: footer }) : null] }) }));
|
|
340
295
|
}
|
|
341
|
-
export function Dropdown({ label = "Select", options, value, open: controlledOpen, placeholder = "Select", onSelect, className, ...rest }) {
|
|
296
|
+
export function Dropdown({ label = "Select", options, value, open: controlledOpen, placeholder = "Select", onSelect, onselect, className, ...rest }) {
|
|
297
|
+
const handleSelect = onSelect ?? onselect;
|
|
342
298
|
const hostRef = React.useRef(null);
|
|
343
299
|
const itemRefs = React.useRef([]);
|
|
344
300
|
const [open, setOpen] = useControlled(controlledOpen, false);
|
|
@@ -358,7 +314,7 @@ export function Dropdown({ label = "Select", options, value, open: controlledOpe
|
|
|
358
314
|
setCurrent(option.value);
|
|
359
315
|
setOpen(false);
|
|
360
316
|
setActiveIndex(-1);
|
|
361
|
-
|
|
317
|
+
handleSelect?.(option.value);
|
|
362
318
|
};
|
|
363
319
|
useOutsideMouseDown(open, hostRef, () => setOpen(false));
|
|
364
320
|
useEscape(open, () => setOpen(false));
|
|
@@ -392,50 +348,126 @@ export function Dropdown({ label = "Select", options, value, open: controlledOpe
|
|
|
392
348
|
export function EmptyState({ title, message, action, children, className, ...rest }) {
|
|
393
349
|
return (_jsx("section", { ...rest, className: classNames("st-empty-state st-emptyState", className), children: _jsxs("div", { className: "st-empty-state__content", children: [_jsx("h2", { className: "st-empty-state__title st-emptyState__title", children: title }), message ? _jsx("p", { className: "st-empty-state__message st-emptyState__message", children: message }) : null, children, action ? _jsx("div", { className: "st-empty-state__action", children: action }) : null] }) }));
|
|
394
350
|
}
|
|
351
|
+
function formatFileSize(bytes) {
|
|
352
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0)
|
|
353
|
+
return "";
|
|
354
|
+
if (bytes === 0)
|
|
355
|
+
return "0 B";
|
|
356
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
357
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
358
|
+
const value = bytes / Math.pow(1024, i);
|
|
359
|
+
const formatted = value >= 10 || i === 0 ? value.toFixed(0) : value.toFixed(1);
|
|
360
|
+
return `${formatted} ${units[i]}`;
|
|
361
|
+
}
|
|
362
|
+
function fileItemName(item) {
|
|
363
|
+
return item.file?.name ?? item.name;
|
|
364
|
+
}
|
|
365
|
+
function fileItemSize(item) {
|
|
366
|
+
return item.file?.size ?? item.size;
|
|
367
|
+
}
|
|
395
368
|
export function FileUploader({ label = "Upload", items = [], disabled = false, className, ...rest }) {
|
|
396
|
-
return (_jsxs("div", { ...rest, className: classNames("st-fileUploader-field", className), children: [_jsxs("div", { className: classNames("st-fileUploader__dropzone", disabled && "st-fileUploader__dropzone--disabled"), children: [_jsx("span", { className: "st-fileUploader__trigger", children: label }), _jsx("input", { className: "st-fileUploader__input", type: "file", disabled: disabled, "aria-label": text(label) })] }), _jsx("ul", { className: "st-fileUploader__list", children: items.map((item, index) =>
|
|
369
|
+
return (_jsxs("div", { ...rest, className: classNames("st-fileUploader-field", className), children: [_jsxs("div", { className: classNames("st-fileUploader__dropzone", disabled && "st-fileUploader__dropzone--disabled"), children: [_jsx("span", { className: "st-fileUploader__trigger", children: label }), _jsx("input", { className: "st-fileUploader__input", type: "file", disabled: disabled, "aria-label": text(label) })] }), _jsx("ul", { className: "st-fileUploader__list", children: items.map((item, index) => {
|
|
370
|
+
const name = fileItemName(item);
|
|
371
|
+
const size = fileItemSize(item);
|
|
372
|
+
const sizeLabel = formatFileSize(size);
|
|
373
|
+
return (_jsxs("li", { className: classNames("st-fileUploader__item", item.status && `st-fileUploader__item--${item.status}`), children: [_jsx("span", { className: "st-fileUploader__itemName st-fileUploader__name", children: name }), sizeLabel ? _jsx("span", { className: "st-fileUploader__itemSize", children: sizeLabel }) : null, item.error ? _jsx("span", { className: "st-fileUploader__itemError", children: item.error }) : null] }, item.id ?? name ?? index));
|
|
374
|
+
}) })] }));
|
|
397
375
|
}
|
|
398
376
|
export function Footer({ brand, columns, links, copyright, className, ...rest }) {
|
|
399
377
|
const groups = columns ?? (links ? [{ links }] : []);
|
|
400
378
|
return (_jsxs("footer", { ...rest, className: classNames("st-footer", className), children: [_jsxs("div", { className: "st-footer__top", children: [brand ? _jsx("div", { className: "st-footer__brand", children: brand }) : null, _jsx("div", { className: "st-footer__columns", children: groups.map((group, index) => (_jsxs("nav", { children: [group.title ? _jsx("h2", { children: group.title }) : null, group.links.map((link) => (_jsx("a", { href: link.href, children: link.label }, link.href)))] }, index))) })] }), copyright ? _jsx("div", { className: "st-footer__copyright", children: copyright }) : null] }));
|
|
401
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Maps a dash style (or the legacy `weak` flag) to an SVG stroke-dasharray.
|
|
382
|
+
* Returns null for a solid stroke.
|
|
383
|
+
*/
|
|
384
|
+
export function edgeDashArray(dash, weak) {
|
|
385
|
+
const effective = dash ?? (weak ? "dashed" : undefined);
|
|
386
|
+
switch (effective) {
|
|
387
|
+
case "dashed":
|
|
388
|
+
return "6 4";
|
|
389
|
+
case "dotted":
|
|
390
|
+
return "1 4";
|
|
391
|
+
case "long-dash":
|
|
392
|
+
return "12 6";
|
|
393
|
+
case "solid":
|
|
394
|
+
default:
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
402
398
|
// ---------------------------------------------------------------------------
|
|
403
399
|
// SVG path helpers for the various node shapes.
|
|
404
|
-
// All shapes are centered at (0,0)
|
|
400
|
+
// All shapes are centered at (0,0). Each shape is scaled so its filled area
|
|
401
|
+
// matches that of the reference circle (π·r²) — this keeps equal-weight nodes
|
|
402
|
+
// visually balanced rather than letting squares/diamonds read as "bigger".
|
|
403
|
+
//
|
|
404
|
+
// Per-shape scale factors (closed-form, area = π·r²):
|
|
405
|
+
// square / roundedbox : half-side = (√π)/2 · r ≈ 0.8862·r
|
|
406
|
+
// diamond : half-diag = √(π/2) · r ≈ 1.2533·r
|
|
407
|
+
// triangle (equilat.) : circumradius= √(π/(3√3/4)) · r ≈ 1.5551·r
|
|
408
|
+
// hexagon (regular) : circumradius= √(π/(3√3/2)) · r ≈ 1.0996·r
|
|
409
|
+
// star (5-pt, k=0.42) : outer radius= √(π/A₁) · r ≈ 1.5953·r
|
|
410
|
+
// where A₁ is the unit-star area (≈1.2343).
|
|
405
411
|
// ---------------------------------------------------------------------------
|
|
412
|
+
const FORCE_GRAPH_STAR_INNER_RATIO = 0.42;
|
|
413
|
+
const FORCE_GRAPH_STAR_AREA_FACTOR = 1.5953498885642274; // √(π / unit-star-area)
|
|
414
|
+
// Format a coordinate: 4 dp, snapping floating-point near-zero (e.g. 9e-16)
|
|
415
|
+
// to a clean 0 so paths never contain scientific notation.
|
|
416
|
+
function forceGraphFmt(n) {
|
|
417
|
+
const v = Math.abs(n) < 1e-9 ? 0 : n;
|
|
418
|
+
return Number(v.toFixed(4)).toString();
|
|
419
|
+
}
|
|
406
420
|
export function nodeShapePath(shape, r) {
|
|
407
421
|
const s = shape ?? "dot";
|
|
408
422
|
if (s === "dot" || s === "circle")
|
|
409
423
|
return null; // use <circle>
|
|
410
424
|
if (s === "diamond") {
|
|
411
|
-
|
|
425
|
+
const d = Math.sqrt(Math.PI / 2) * r; // half-diagonal
|
|
426
|
+
return `M 0 ${forceGraphFmt(-d)} L ${forceGraphFmt(d)} 0 L 0 ${forceGraphFmt(d)} L ${forceGraphFmt(-d)} 0 Z`;
|
|
412
427
|
}
|
|
413
428
|
if (s === "star") {
|
|
414
|
-
const outer = r;
|
|
415
|
-
const inner =
|
|
429
|
+
const outer = FORCE_GRAPH_STAR_AREA_FACTOR * r;
|
|
430
|
+
const inner = outer * FORCE_GRAPH_STAR_INNER_RATIO;
|
|
416
431
|
const pts = [];
|
|
417
432
|
for (let i = 0; i < 10; i++) {
|
|
418
433
|
const angle = (i * Math.PI) / 5 - Math.PI / 2;
|
|
419
434
|
const rad = i % 2 === 0 ? outer : inner;
|
|
420
|
-
pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
|
|
435
|
+
pts.push(`${forceGraphFmt(rad * Math.cos(angle))},${forceGraphFmt(rad * Math.sin(angle))}`);
|
|
421
436
|
}
|
|
422
437
|
return `M ${pts.join(" L ")} Z`;
|
|
423
438
|
}
|
|
424
439
|
if (s === "hexagon") {
|
|
440
|
+
const R = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 2)) * r; // circumradius
|
|
425
441
|
const pts = [];
|
|
426
442
|
for (let i = 0; i < 6; i++) {
|
|
427
443
|
const angle = (i * Math.PI) / 3 - Math.PI / 6;
|
|
428
|
-
pts.push(`${
|
|
444
|
+
pts.push(`${forceGraphFmt(R * Math.cos(angle))},${forceGraphFmt(R * Math.sin(angle))}`);
|
|
429
445
|
}
|
|
430
446
|
return `M ${pts.join(" L ")} Z`;
|
|
431
447
|
}
|
|
432
448
|
if (s === "box" || s === "square") {
|
|
433
|
-
const h =
|
|
434
|
-
return `M ${-h} ${-h} L ${h} ${-h} L ${h} ${h} L ${-h} ${h} Z`;
|
|
449
|
+
const h = (Math.sqrt(Math.PI) / 2) * r; // half-side, area = (2h)² = π·r²
|
|
450
|
+
return `M ${forceGraphFmt(-h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(h)} L ${forceGraphFmt(-h)} ${forceGraphFmt(h)} Z`;
|
|
451
|
+
}
|
|
452
|
+
if (s === "roundedbox") {
|
|
453
|
+
const h = (Math.sqrt(Math.PI) / 2) * r; // same footprint as square
|
|
454
|
+
const rx = h * 0.6; // ≈ r·0.3 rounding radius (h ≈ 0.886·r)
|
|
455
|
+
// Rounded rectangle via arcs, clockwise from top edge.
|
|
456
|
+
return (`M ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} ` +
|
|
457
|
+
`L ${forceGraphFmt(h - rx)} ${forceGraphFmt(-h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h)} ${forceGraphFmt(-h + rx)} ` +
|
|
458
|
+
`L ${forceGraphFmt(h)} ${forceGraphFmt(h - rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h - rx)} ${forceGraphFmt(h)} ` +
|
|
459
|
+
`L ${forceGraphFmt(-h + rx)} ${forceGraphFmt(h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h)} ${forceGraphFmt(h - rx)} ` +
|
|
460
|
+
`L ${forceGraphFmt(-h)} ${forceGraphFmt(-h + rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} Z`);
|
|
435
461
|
}
|
|
436
462
|
if (s === "triangle") {
|
|
437
|
-
|
|
438
|
-
|
|
463
|
+
// Equilateral, centred at centroid; circumradius h so apex is up.
|
|
464
|
+
const h = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 4)) * r;
|
|
465
|
+
const pts = [];
|
|
466
|
+
for (let i = 0; i < 3; i++) {
|
|
467
|
+
const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2;
|
|
468
|
+
pts.push(`${forceGraphFmt(h * Math.cos(angle))},${forceGraphFmt(h * Math.sin(angle))}`);
|
|
469
|
+
}
|
|
470
|
+
return `M ${pts.join(" L ")} Z`;
|
|
439
471
|
}
|
|
440
472
|
return null;
|
|
441
473
|
}
|
|
@@ -573,9 +605,14 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
|
|
|
573
605
|
}
|
|
574
606
|
sn.x += sn.vx;
|
|
575
607
|
sn.y += sn.vy;
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
608
|
+
// Soft clamp: allow the layout to overflow the canvas so it keeps a
|
|
609
|
+
// natural shape (fit-to-content reframes it afterwards). The wide bound
|
|
610
|
+
// only guards against runaway coordinates, it no longer glues nodes to
|
|
611
|
+
// the four edges.
|
|
612
|
+
const padX = w * 0.5 + nodeRadius * 2;
|
|
613
|
+
const padY = h * 0.5 + nodeRadius * 2;
|
|
614
|
+
sn.x = Math.max(-padX, Math.min(w + padX, sn.x));
|
|
615
|
+
sn.y = Math.max(-padY, Math.min(h + padY, sn.y));
|
|
579
616
|
}
|
|
580
617
|
temperature *= cooling;
|
|
581
618
|
}
|
|
@@ -584,7 +621,12 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
|
|
|
584
621
|
out.set(sn.id, { x: sn.x, y: sn.y });
|
|
585
622
|
return out;
|
|
586
623
|
}
|
|
587
|
-
|
|
624
|
+
// Curvature offset factor: how far (relative to chord length) the control
|
|
625
|
+
// point bows out at edgeCurve=1. Kept modest so edgeCurve≈0.15 reads "light".
|
|
626
|
+
const FORCE_GRAPH_CURVE_FACTOR = 0.5;
|
|
627
|
+
// Fit-to-content margin (per side, fraction of the content box).
|
|
628
|
+
const FORCE_GRAPH_CONTENT_MARGIN = 0.08;
|
|
629
|
+
export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, edgeCurve = 0.15, className, ...rest }) {
|
|
588
630
|
// SSR-safe reduced-motion check (window may be undefined during SSR/tests).
|
|
589
631
|
const prefersReducedMotion = typeof window !== "undefined" &&
|
|
590
632
|
typeof window.matchMedia === "function" &&
|
|
@@ -615,6 +657,7 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
615
657
|
}), [nodes, layout, width, height, nodeRadius, toneMap]);
|
|
616
658
|
const positionedEdges = React.useMemo(() => {
|
|
617
659
|
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
660
|
+
const curve = Math.max(0, edgeCurve ?? 0);
|
|
618
661
|
return edges
|
|
619
662
|
.map((e, i) => {
|
|
620
663
|
const a = layout.get(e.source);
|
|
@@ -623,22 +666,134 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
623
666
|
return null;
|
|
624
667
|
const srcNode = nodeById.get(e.source);
|
|
625
668
|
const tgtNode = nodeById.get(e.target);
|
|
669
|
+
const x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
|
|
670
|
+
// Quadratic control point: midpoint pushed perpendicular to the chord.
|
|
671
|
+
let path = null;
|
|
672
|
+
let cx = (x1 + x2) / 2;
|
|
673
|
+
let cy = (y1 + y2) / 2;
|
|
674
|
+
if (curve > 0) {
|
|
675
|
+
const dx = x2 - x1;
|
|
676
|
+
const dy = y2 - y1;
|
|
677
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
678
|
+
const off = curve * dist * FORCE_GRAPH_CURVE_FACTOR;
|
|
679
|
+
// Unit perpendicular to the chord.
|
|
680
|
+
const px = -dy / dist;
|
|
681
|
+
const py = dx / dist;
|
|
682
|
+
cx = (x1 + x2) / 2 + px * off;
|
|
683
|
+
cy = (y1 + y2) / 2 + py * off;
|
|
684
|
+
path = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`;
|
|
685
|
+
}
|
|
686
|
+
const dashArray = edgeDashArray(e.dash, e.weak);
|
|
687
|
+
const strokeWidth = typeof e.width === "number" ? e.width : e.emphasis ? 2.5 : null;
|
|
626
688
|
return {
|
|
627
689
|
edge: e,
|
|
628
690
|
i,
|
|
629
|
-
x1
|
|
630
|
-
y1
|
|
631
|
-
x2
|
|
632
|
-
y2
|
|
691
|
+
x1,
|
|
692
|
+
y1,
|
|
693
|
+
x2,
|
|
694
|
+
y2,
|
|
695
|
+
// Tooltip / label anchor follows the curve apex when curved.
|
|
696
|
+
midX: cx,
|
|
697
|
+
midY: cy,
|
|
698
|
+
path,
|
|
699
|
+
dashArray,
|
|
700
|
+
strokeWidth,
|
|
633
701
|
srcLabel: srcNode?.label ?? e.source,
|
|
634
702
|
tgtLabel: tgtNode?.label ?? e.target,
|
|
635
703
|
};
|
|
636
704
|
})
|
|
637
705
|
.filter((e) => e !== null);
|
|
638
|
-
}, [nodes, edges, layout]);
|
|
706
|
+
}, [nodes, edges, layout, edgeCurve]);
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// Fit-to-content (Feature 5): after warmup the layout may extend beyond the
|
|
709
|
+
// nominal width/height. Compute the real content bounding-box (node centres
|
|
710
|
+
// ± radius) and frame it with an 8% margin on each side. The base viewBox is
|
|
711
|
+
// this frame (not the fixed 0,0,w,h), so the graph is centred and never
|
|
712
|
+
// clipped, whatever the aspect ratio. Zoom/pan stay relative to this frame.
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
const contentBox = React.useMemo(() => {
|
|
715
|
+
if (positionedNodes.length === 0) {
|
|
716
|
+
return { x: 0, y: 0, w: width, h: height };
|
|
717
|
+
}
|
|
718
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
719
|
+
for (const p of positionedNodes) {
|
|
720
|
+
// Use the worst-case extent for non-circular shapes (area-scaled) so the
|
|
721
|
+
// glyph (and a little label room) is never clipped.
|
|
722
|
+
const ext = p.r * 1.7;
|
|
723
|
+
minX = Math.min(minX, p.x - ext);
|
|
724
|
+
minY = Math.min(minY, p.y - ext);
|
|
725
|
+
maxX = Math.max(maxX, p.x + ext);
|
|
726
|
+
maxY = Math.max(maxY, p.y + ext);
|
|
727
|
+
}
|
|
728
|
+
let w = maxX - minX;
|
|
729
|
+
let h = maxY - minY;
|
|
730
|
+
// Guard against a degenerate (single node / collinear) box.
|
|
731
|
+
if (!(w > 0)) {
|
|
732
|
+
w = width;
|
|
733
|
+
minX = maxX - w / 2;
|
|
734
|
+
}
|
|
735
|
+
if (!(h > 0)) {
|
|
736
|
+
h = height;
|
|
737
|
+
minY = maxY - h / 2;
|
|
738
|
+
}
|
|
739
|
+
const mx = w * FORCE_GRAPH_CONTENT_MARGIN;
|
|
740
|
+
const my = h * FORCE_GRAPH_CONTENT_MARGIN;
|
|
741
|
+
return { x: minX - mx, y: minY - my, w: w + 2 * mx, h: h + 2 * my };
|
|
742
|
+
}, [positionedNodes, width, height]);
|
|
639
743
|
const [hoveredNodeIndex, setHoveredNodeIndex] = React.useState(null);
|
|
640
744
|
const [hoveredEdgeIndex, setHoveredEdgeIndex] = React.useState(null);
|
|
641
745
|
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
746
|
+
// Adjacency: id -> set of directly connected node ids. Used to keep the
|
|
747
|
+
// direct neighbours of selected/focused nodes fully visible (demand 6).
|
|
748
|
+
const adjacency = React.useMemo(() => {
|
|
749
|
+
const adj = new Map();
|
|
750
|
+
const add = (a, b) => {
|
|
751
|
+
let set = adj.get(a);
|
|
752
|
+
if (!set) {
|
|
753
|
+
set = new Set();
|
|
754
|
+
adj.set(a, set);
|
|
755
|
+
}
|
|
756
|
+
set.add(b);
|
|
757
|
+
};
|
|
758
|
+
for (const e of edges) {
|
|
759
|
+
add(e.source, e.target);
|
|
760
|
+
add(e.target, e.source);
|
|
761
|
+
}
|
|
762
|
+
return adj;
|
|
763
|
+
}, [edges]);
|
|
764
|
+
// True when a selection/focus is active — only then do we dim non-related
|
|
765
|
+
// nodes. The set of "active" ids = selected ∪ focus ∪ all their neighbours.
|
|
766
|
+
const hasActiveSelection = selectedSet.size > 0 || focusId != null;
|
|
767
|
+
const activeAndNeighbours = React.useMemo(() => {
|
|
768
|
+
const active = new Set(selectedSet);
|
|
769
|
+
if (focusId != null)
|
|
770
|
+
active.add(focusId);
|
|
771
|
+
// Expand to direct neighbours so they stay fully visible.
|
|
772
|
+
const withNeighbours = new Set(active);
|
|
773
|
+
for (const id of active) {
|
|
774
|
+
const nb = adjacency.get(id);
|
|
775
|
+
if (nb)
|
|
776
|
+
for (const n of nb)
|
|
777
|
+
withNeighbours.add(n);
|
|
778
|
+
}
|
|
779
|
+
return withNeighbours;
|
|
780
|
+
}, [selectedSet, focusId, adjacency]);
|
|
781
|
+
// A node is dimmed by selection when there IS an active selection and the
|
|
782
|
+
// node is neither selected/focused nor a direct neighbour of one.
|
|
783
|
+
function isSelectionDimmed(id) {
|
|
784
|
+
if (!hasActiveSelection)
|
|
785
|
+
return false;
|
|
786
|
+
return !activeAndNeighbours.has(id);
|
|
787
|
+
}
|
|
788
|
+
// An edge stays fully visible when at least one endpoint is in the
|
|
789
|
+
// selected/focused set (it is a connection of the selection).
|
|
790
|
+
function isEdgeSelectionDimmed(e) {
|
|
791
|
+
if (!hasActiveSelection)
|
|
792
|
+
return false;
|
|
793
|
+
const srcActive = selectedSet.has(e.source) || focusId === e.source;
|
|
794
|
+
const tgtActive = selectedSet.has(e.target) || focusId === e.target;
|
|
795
|
+
return !(srcActive || tgtActive);
|
|
796
|
+
}
|
|
642
797
|
// Keyboard handler for a node element: Space/Enter → onSelect, Enter → onOpenEntity.
|
|
643
798
|
function handleNodeKeydown(id, e) {
|
|
644
799
|
if (e.key === "Enter" || e.key === " ") {
|
|
@@ -650,10 +805,11 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
650
805
|
}
|
|
651
806
|
}
|
|
652
807
|
// ---------------------------------------------------------------------------
|
|
653
|
-
// Zoom + pan state
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
808
|
+
// Zoom + pan state (framed by the fit-to-content box, Feature 5). The base
|
|
809
|
+
// frame is `contentBox` (not 0,0,w,h). Zoom is a scale multiplier and pan is
|
|
810
|
+
// an offset in SVG coords, both relative to that base frame:
|
|
811
|
+
// vbW = baseW / zoomScale, vbH = baseH / zoomScale
|
|
812
|
+
// vbX = baseX + panX, vbY = baseY + panY
|
|
657
813
|
// ---------------------------------------------------------------------------
|
|
658
814
|
const [zoomScale, setZoomScale] = React.useState(1);
|
|
659
815
|
const [panX, setPanX] = React.useState(0);
|
|
@@ -662,10 +818,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
662
818
|
const panStartRef = React.useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
|
663
819
|
const svgRef = React.useRef(null);
|
|
664
820
|
const [isPanning, setIsPanning] = React.useState(false);
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
const
|
|
668
|
-
const
|
|
821
|
+
// Base frame dimensions = fit-to-content box.
|
|
822
|
+
const baseW = contentBox.w;
|
|
823
|
+
const baseH = contentBox.h;
|
|
824
|
+
const baseX = contentBox.x;
|
|
825
|
+
const baseY = contentBox.y;
|
|
826
|
+
const vbW = baseW / zoomScale;
|
|
827
|
+
const vbH = baseH / zoomScale;
|
|
828
|
+
const vbX = baseX + panX;
|
|
829
|
+
const vbY = baseY + panY;
|
|
669
830
|
function resetView() {
|
|
670
831
|
setZoomScale(1);
|
|
671
832
|
setPanX(0);
|
|
@@ -682,14 +843,17 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
682
843
|
// Anchor zoom around the cursor position in SVG coords.
|
|
683
844
|
if (svgRef.current) {
|
|
684
845
|
const rect = svgRef.current.getBoundingClientRect();
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
const
|
|
688
|
-
const
|
|
689
|
-
const
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
846
|
+
const curW = baseW / zoomScale;
|
|
847
|
+
const curH = baseH / zoomScale;
|
|
848
|
+
const cursorSvgX = vbX + ((ev.clientX - rect.left) / rect.width) * curW;
|
|
849
|
+
const cursorSvgY = vbY + ((ev.clientY - rect.top) / rect.height) * curH;
|
|
850
|
+
const newVbW = baseW / newScale;
|
|
851
|
+
const newVbH = baseH / newScale;
|
|
852
|
+
const ratioX = (cursorSvgX - vbX) / curW;
|
|
853
|
+
const ratioY = (cursorSvgY - vbY) / curH;
|
|
854
|
+
// New top-left so the cursor anchor stays put, then back out the pan term.
|
|
855
|
+
setPanX(cursorSvgX - ratioX * newVbW - baseX);
|
|
856
|
+
setPanY(cursorSvgY - ratioY * newVbH - baseY);
|
|
693
857
|
}
|
|
694
858
|
setZoomScale(newScale);
|
|
695
859
|
}
|
|
@@ -723,10 +887,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
723
887
|
const hoveredNodeRelCount = hoveredNode
|
|
724
888
|
? positionedEdges.filter((e) => e.edge.source === hoveredNode.node.id || e.edge.target === hoveredNode.node.id).length
|
|
725
889
|
: 0;
|
|
726
|
-
return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) =>
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
890
|
+
return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) => {
|
|
891
|
+
const onHitEnter = () => {
|
|
892
|
+
setHoveredEdgeIndex(e.i);
|
|
893
|
+
onEdgeHover?.(e.edge);
|
|
894
|
+
};
|
|
895
|
+
const onHitLeave = () => setHoveredEdgeIndex(null);
|
|
896
|
+
const edgeClass = classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", e.edge.emphasis && "st-forceGraph__edge--emphasis", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered", isEdgeSelectionDimmed(e.edge) && "st-forceGraph__edge--dim");
|
|
897
|
+
return (_jsxs(React.Fragment, { children: [e.path ? (_jsx("path", { className: "st-forceGraph__edgeHit", role: "presentation", d: e.path, fill: "none", onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })) : (_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })), e.path ? (_jsx("path", { className: edgeClass, d: e.path, fill: "none", strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" })) : (_jsx("line", { className: edgeClass, x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" }))] }, e.i));
|
|
898
|
+
}) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
|
|
730
899
|
const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
|
|
731
900
|
const pressed = selectedSet.has(p.node.id);
|
|
732
901
|
const shapeProps = {
|
|
@@ -743,28 +912,32 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
743
912
|
onDoubleClick: () => onOpenEntity?.(p.node.id),
|
|
744
913
|
onKeyDown: (e) => handleNodeKeydown(p.node.id, e),
|
|
745
914
|
};
|
|
746
|
-
return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, hoveredNodeIndex !== null && hoveredNodeIndex !== p.i
|
|
915
|
+
return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, ((hoveredNodeIndex !== null && hoveredNodeIndex !== p.i) ||
|
|
916
|
+
isSelectionDimmed(p.node.id)) &&
|
|
917
|
+
"st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"), transform: `translate(${p.x} ${p.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
|
|
747
918
|
}) })] }), hoveredNode ? (_jsxs("div", { className: "st-forceGraph__tooltip", role: "presentation", style: {
|
|
748
919
|
left: `${((hoveredNode.x - vbX) / vbW) * 100}%`,
|
|
749
920
|
top: `${((hoveredNode.y - vbY) / vbH) * 100}%`,
|
|
750
921
|
}, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredNode.title }), hoveredNode.node.group !== undefined ? (_jsx("span", { className: "st-forceGraph__tooltipMeta", children: hoveredNode.node.group })) : null, hoveredNodeRelCount > 0 ? (_jsxs("span", { className: "st-forceGraph__tooltipMeta", children: [hoveredNodeRelCount, " relation", hoveredNodeRelCount === 1 ? "" : "s"] })) : null] })) : null, hoveredEdge ? (_jsxs("div", { className: "st-forceGraph__tooltip st-forceGraph__tooltip--edge", role: "presentation", style: {
|
|
751
|
-
left: `${((
|
|
752
|
-
top: `${((
|
|
922
|
+
left: `${((hoveredEdge.midX - vbX) / vbW) * 100}%`,
|
|
923
|
+
top: `${((hoveredEdge.midY - vbY) / vbH) * 100}%`,
|
|
753
924
|
}, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.srcLabel }), hoveredEdge.edge.relation ? (_jsx("span", { className: "st-forceGraph__tooltipRelation", children: hoveredEdge.edge.relation })) : null, _jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.tgtLabel })] })) : null, isZoomed ? (_jsx("button", { className: "st-forceGraph__resetBtn", type: "button", "aria-label": "Reset view", onClick: resetView, children: "\u21BA" })) : null, legend && legend.length > 0 ? (_jsx("div", { className: "st-forceGraph__legend", "aria-label": "Graph legend", children: legend.map((entry, idx) => {
|
|
754
925
|
const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
|
|
755
926
|
const swatchTone = entry.tone ?? "category1";
|
|
927
|
+
const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
|
|
756
928
|
return (_jsxs("div", { className: "st-forceGraph__legendEntry", children: [entry.shape !== undefined ? (
|
|
757
|
-
// Node shape legend entry
|
|
758
|
-
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-
|
|
929
|
+
// Node shape legend entry (viewBox widened for area-scaled glyphs)
|
|
930
|
+
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) })) : (
|
|
759
931
|
// Edge style legend entry
|
|
760
|
-
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak") }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
|
|
932
|
+
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
|
|
761
933
|
}) })) : null] }));
|
|
762
934
|
}
|
|
763
935
|
export function GraphLegend({ entries, title, className, ...rest }) {
|
|
764
936
|
return (_jsxs("div", { ...rest, className: classNames("st-graphLegend", className), "aria-label": title ?? "Graph legend", children: [title ? _jsx("p", { className: "st-graphLegend__title", children: title }) : null, _jsx("ul", { className: "st-graphLegend__list", role: "list", children: entries.map((entry, idx) => {
|
|
765
937
|
const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
|
|
766
938
|
const swatchTone = entry.tone ?? "category1";
|
|
767
|
-
|
|
939
|
+
const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
|
|
940
|
+
return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) })) : (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-graphLegend__edge", entry.weak && "st-graphLegend__edge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
|
|
768
941
|
}) })] }));
|
|
769
942
|
}
|
|
770
943
|
export function Form({ status = "idle", message, children, className, ...rest }) {
|
|
@@ -805,11 +978,15 @@ export function Link({ muted = false, standalone = false, disabled = false, clas
|
|
|
805
978
|
export function LoadingState({ label, title, variant = "spinner", className, ...rest }) {
|
|
806
979
|
return (_jsxs("section", { ...rest, className: classNames("st-loading", `st-loading--${variant}`, className), "aria-live": "polite", children: [_jsx("span", { className: "st-loading__spinner", "aria-hidden": "true" }), _jsx("span", { className: "st-loading__label", children: label ?? title ?? "Loading" })] }));
|
|
807
980
|
}
|
|
981
|
+
function itemKind(item) {
|
|
982
|
+
const tagged = item;
|
|
983
|
+
return tagged.type ?? tagged.kind;
|
|
984
|
+
}
|
|
808
985
|
function isDivider(item) {
|
|
809
|
-
return
|
|
986
|
+
return itemKind(item) === "divider";
|
|
810
987
|
}
|
|
811
988
|
function isGroup(item) {
|
|
812
|
-
return
|
|
989
|
+
return itemKind(item) === "group";
|
|
813
990
|
}
|
|
814
991
|
export function Menu({ items, dense = false, onSelect, className, role, ...rest }) {
|
|
815
992
|
const rootRef = React.useRef(null);
|
|
@@ -843,19 +1020,21 @@ export function Menu({ items, dense = false, onSelect, className, role, ...rest
|
|
|
843
1020
|
if (isDivider(item))
|
|
844
1021
|
return _jsx("div", { className: "st-menu__divider", role: "separator" }, item.id ?? index);
|
|
845
1022
|
if (isGroup(item)) {
|
|
846
|
-
return (_jsxs("section", { className: "st-menu__group", children: [_jsx("h3", { children: item.label }), item.items.map((child) => (_jsx("button", { type: "button", role: "menuitem", className: "st-menu__item", disabled: child.disabled, onClick: () => onSelect?.(child), onKeyDown: (event) => handleItemKeyDown(event, child), children: _jsx("span", { className: "st-menu__itemLabel", children: child.label }) }, child
|
|
1023
|
+
return (_jsxs("section", { className: "st-menu__group", children: [_jsx("h3", { children: item.label }), (item.items ?? []).map((child) => (_jsx("button", { type: "button", role: "menuitem", className: classNames("st-menu__item", isDangerAction(child) && "st-menu__item--danger"), disabled: child.disabled, onClick: () => onSelect?.(child), onKeyDown: (event) => handleItemKeyDown(event, child), children: _jsx("span", { className: "st-menu__itemLabel", children: child.label }) }, actionId(child) ?? text(child.label))))] }, item.id ?? index));
|
|
847
1024
|
}
|
|
848
|
-
|
|
1025
|
+
const action = item;
|
|
1026
|
+
return (_jsx("button", { type: "button", role: "menuitem", disabled: action.disabled, className: classNames("st-menu__item", isDangerAction(action) && "st-menu__item--danger"), onClick: () => onSelect?.(action), onKeyDown: (event) => handleItemKeyDown(event, action), children: _jsx("span", { className: "st-menu__itemLabel", children: action.label }) }, actionId(action) ?? text(action.label) ?? index));
|
|
849
1027
|
}) }));
|
|
850
1028
|
}
|
|
851
1029
|
export function MenuPopover({ trigger, items = [], open = true, placement = "bottom-start", children, className, ...rest }) {
|
|
852
1030
|
return (_jsxs("div", { ...rest, className: classNames("st-menuPopover", `st-menuPopover--${placement}`, className), children: [trigger, open ? _jsx("div", { className: "st-menuPopover__content", children: items.length ? _jsx(Menu, { items: items, role: "presentation" }) : children }) : null] }));
|
|
853
1031
|
}
|
|
854
|
-
export function MenuTriggerButton({ open
|
|
855
|
-
|
|
1032
|
+
export function MenuTriggerButton({ open, expanded, type = "button", className, children, ...rest }) {
|
|
1033
|
+
const isOpen = open ?? expanded ?? false;
|
|
1034
|
+
return (_jsx("button", { ...rest, type: type, className: classNames("st-menuTriggerButton st-button st-button--secondary st-button--sm", className), "aria-expanded": isOpen, children: children }));
|
|
856
1035
|
}
|
|
857
1036
|
export function MessageActions({ actions, visibility = "always", className, ...rest }) {
|
|
858
|
-
return (_jsx("nav", { ...rest, className: classNames("st-messageActions", visibility === "hover" && "st-messageActions--hoverOnly", className), "aria-label": "Message actions", children: actions.map((action, index) => (_jsx("button", { type: "button", className: classNames("st-button st-button--ghost st-button--sm", action.variant === "danger" && "st-button--danger"), disabled: action.disabled, onClick: action.onClick, children: action.label }, action.id ?? index))) }));
|
|
1037
|
+
return (_jsx("nav", { ...rest, className: classNames("st-messageActions", visibility === "hover" && "st-messageActions--hoverOnly", className), "aria-label": "Message actions", children: actions.map((action, index) => (_jsx("button", { type: "button", className: classNames("st-button st-button--ghost st-button--sm", action.variant === "danger" && "st-button--danger"), disabled: action.disabled, "aria-label": action.label == null && action.icon != null ? text(action.id) || undefined : undefined, onClick: action.onClick, children: action.label ?? action.icon }, action.id ?? index))) }));
|
|
859
1038
|
}
|
|
860
1039
|
export function MessageStatusBadge({ status, tone, labels, className, ...rest }) {
|
|
861
1040
|
const normalized = status === "sent" ? "completed" : status === "streaming" ? "processing" : status === "error" ? "failed" : status;
|
|
@@ -930,8 +1109,9 @@ export function Pagination({ page, pageSize = 10, totalItems, totalPages, pageCo
|
|
|
930
1109
|
const visiblePages = Array.from({ length: pages }, (_, index) => index + 1);
|
|
931
1110
|
return (_jsxs("nav", { ...rest, className: classNames("st-pagination", className), "aria-label": "Pagination", children: [_jsx("button", { type: "button", disabled: page <= 1, onClick: () => onPageChange?.(page - 1), children: "Previous" }), _jsx("span", { className: "st-pagination__page--active", children: totalItems ? `${start}-${end} of ${totalItems}` : `Page ${page} of ${pages}` }), _jsx("span", { className: "st-pagination__pages", children: visiblePages.map((pageNumber) => (_jsx("button", { type: "button", className: classNames("st-pagination__page", pageNumber === page && "st-pagination__page--active"), "aria-label": `Page ${pageNumber}`, "aria-current": pageNumber === page ? "page" : undefined, onClick: () => onPageChange?.(pageNumber), children: pageNumber }, pageNumber))) }), _jsx("button", { type: "button", disabled: page >= pages, onClick: () => onPageChange?.(page + 1), children: "Next" })] }));
|
|
932
1111
|
}
|
|
933
|
-
export function PaginationNav({ page = 1, totalPages
|
|
934
|
-
|
|
1112
|
+
export function PaginationNav({ page = 1, totalPages, pageCount, previousHref, nextHref, className, ...rest }) {
|
|
1113
|
+
const pages = totalPages ?? pageCount ?? 1;
|
|
1114
|
+
return (_jsxs("nav", { ...rest, className: classNames("st-paginationNav", className), "aria-label": "Pagination navigation", children: [previousHref ? _jsx("a", { href: previousHref, children: "Previous" }) : _jsx("button", { type: "button", disabled: page <= 1, children: "Previous" }), _jsx("ol", { className: "st-paginationNav__list", children: Array.from({ length: pages }, (_, index) => index + 1).map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", className: classNames("st-paginationNav__page", item === page && "st-paginationNav__page--active"), children: ["Page ", item] }) }, item))) }), nextHref ? _jsx("a", { href: nextHref, children: "Next" }) : _jsx("button", { type: "button", disabled: page >= pages, children: "Next" })] }));
|
|
935
1115
|
}
|
|
936
1116
|
export function PasswordInput({ label, helperText, errorText, size = "md", className, ...rest }) {
|
|
937
1117
|
const [shown, setShown] = React.useState(false);
|
|
@@ -978,9 +1158,6 @@ export function Slider({ label, size = "md", value, defaultValue, min = 0, max =
|
|
|
978
1158
|
const numeric = Number(value ?? defaultValue ?? 0);
|
|
979
1159
|
return (_jsxs("div", { className: classNames("st-slider", `st-slider--${size}`, className), children: [_jsxs("div", { className: "st-slider__header", children: [label ? _jsx("label", { className: "st-field__label", children: label }) : null, _jsx("span", { className: "st-slider__value", children: numeric })] }), _jsx("input", { ...rest, className: "st-slider__input", type: "range", min: min, max: max, defaultValue: defaultValue, value: value })] }));
|
|
980
1160
|
}
|
|
981
|
-
export function Sparkline({ data, label = "Sparkline", tone = "neutral", className, ...rest }) {
|
|
982
|
-
return (_jsxs("figure", { ...rest, className: classNames("st-sparkline", `st-sparkline--${tone}`, className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 120 40", "aria-hidden": "true", children: _jsx("polyline", { className: "st-sparkline__line", points: pointsFrom(data, 120, 40), fill: "none" }) })] }));
|
|
983
|
-
}
|
|
984
1161
|
export function StreamingMessage({ text: messageText, events = [], mode = "live", className, ...rest }) {
|
|
985
1162
|
return (_jsxs("section", { ...rest, className: classNames("st-streamingMessage", `st-streamingMessage--${mode}`, className), children: [_jsx("div", { className: "st-streamingMessage__text", children: messageText }), _jsx("ul", { className: "st-streamingMessage__trailList", children: events.map((event) => _jsx("li", { children: event.label }, event.id)) })] }));
|
|
986
1163
|
}
|
|
@@ -993,11 +1170,12 @@ export function Switch({ label, helperText, className, ...rest }) {
|
|
|
993
1170
|
export function Table({ columns, rows, caption = "Table", className, ...rest }) {
|
|
994
1171
|
return (_jsx("div", { className: "st-table-wrap", children: _jsxs("table", { ...rest, className: classNames("st-table", className), children: [_jsx("caption", { children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { children: column.label }, column.key)) }) }), _jsx("tbody", { children: rows.map((row, index) => (_jsx("tr", { children: columns.map((column) => _jsx("td", { className: classNames(column.align === "center" && "st-table__cell--center", (column.align === "right" || column.align === "end") && "st-table__cell--right"), children: text(row[column.key]) }, column.key)) }, text(row.id) || index))) })] }) }));
|
|
995
1172
|
}
|
|
996
|
-
export function Tabs({ items, activeValue, activeId, label = "Tabs", onChange, className, ...rest }) {
|
|
1173
|
+
export function Tabs({ items, activeValue, activeId, label = "Tabs", onChange, onchange, className, ...rest }) {
|
|
1174
|
+
const handleChange = onChange ?? onchange;
|
|
997
1175
|
const reactId = React.useId();
|
|
998
1176
|
const tabRefs = React.useRef([]);
|
|
999
1177
|
const first = items.find((item) => !item.disabled) ?? items[0];
|
|
1000
|
-
const [current, setCurrent] = useControlled(activeValue ?? activeId, idFrom(first ?? {}, 0, "tab"),
|
|
1178
|
+
const [current, setCurrent] = useControlled(activeValue ?? activeId, idFrom(first ?? {}, 0, "tab"), handleChange);
|
|
1001
1179
|
const itemIds = items.map((item, index) => idFrom(item, index, "tab"));
|
|
1002
1180
|
const activeIndex = Math.max(0, itemIds.indexOf(current));
|
|
1003
1181
|
const active = items[activeIndex] ?? first;
|