@inkra/react 0.10.0 → 0.12.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/README.md CHANGED
@@ -17,6 +17,41 @@ Inkra components require a React client boundary. Next.js and React Server Compo
17
17
 
18
18
  ## Usage
19
19
 
20
+ ### Dependency Graphs
21
+
22
+ `DependencyGraph.Root` lays out skill trees, technology trees and other directed
23
+ dependency graphs without manual node positions:
24
+
25
+ ```tsx
26
+ import '@inkra/react/styles.css';
27
+ import { DependencyGraph } from '@inkra/react';
28
+
29
+ <DependencyGraph.Root
30
+ nodes={[
31
+ { id: 'basics', label: 'Basics', status: 'completed' },
32
+ { id: 'practice', label: 'Practice', status: 'available' },
33
+ { id: 'mastery', label: 'Mastery', status: 'locked' },
34
+ ]}
35
+ edges={[
36
+ { id: 'basics-practice', source: 'basics', target: 'practice' },
37
+ { id: 'practice-mastery', source: 'practice', target: 'mastery' },
38
+ ]}
39
+ />;
40
+ ```
41
+
42
+ The application owns unlock rules and progress. Nodes support icon, content,
43
+ actions and footer regions; `nodeVariant="icon"` shows compact icon nodes.
44
+ `renderNode` owns the complete node appearance, including frameless images.
45
+ Its `renderSurface` helper can wrap both faces of a Flip card so the border and
46
+ paper rotate together. Node/edge `sourceAnchor` and `targetAnchor` accept
47
+ normalized `{ x, y, position? }` coordinates; edge settings override node defaults.
48
+
49
+ Configure individual viewport actions with
50
+ `controls={{ zoomIn: true, zoomOut: false, fitView: true }}`, or hide the toolbar
51
+ with `controls={false}`. `extraControls` appends custom buttons and can receive
52
+ the viewport API. The standalone entry is `@inkra/react/dependency-graph`;
53
+ its optional stylesheet is `@inkra/react/styles/dependency-graph.css`.
54
+
20
55
  ### Guided And Asynchronous Experiences
21
56
 
22
57
  `Guide.Root`, `Guide.Trigger` and `Guide.Step` compose a targeted walkthrough.
@@ -487,16 +522,37 @@ shrinking values and sign changes expand or collapse the affected columns.
487
522
  Grouping, signs, currency and compact units remain formatted text, not digit
488
523
  reels. `rollDirection="auto"` rolls up for increasing values and down for
489
524
  decreasing values; `up` or `down` fixes the direction. `duration` and `easing`
490
- apply to both modes. The default `animation="count"` retains value interpolation.
525
+ apply to all modes. The default `animation="count"` retains value interpolation.
491
526
  `renderValue` takes precedence over the built-in reels. Changing the formatting
492
527
  or animation mode settles immediately, as do disabled/reduced motion, invalid
493
528
  durations, and transitions with more than 128 formatted tokens. Exact strings
494
- and bigint values retain their original precision in either mode.
529
+ and bigint values retain their original precision in every mode.
495
530
 
496
531
  ```tsx
497
532
  <NumberTicker value={amount} animation="roll" rollDirection="auto" duration={650} />
498
533
  ```
499
534
 
535
+ Use `animation="morph"` to preserve digit identities during exact nonzero,
536
+ same-sign multiplication or division by a power of ten. For example, `1 → 100`
537
+ moves the existing `1` left and reveals `00` from the right; `12.34 → 123.4`
538
+ preserves all four digits while the decimal moves. The displayed significand and
539
+ decimal shift must agree with the exact values after formatting. Other changes,
540
+ including carries, sign changes, unit changes and exponent notation, use
541
+ place-aligned vertical rolling. This mode does not search for arbitrary matching
542
+ digits or reorder repeated glyphs.
543
+
544
+ Glyph widths stay fixed: motion uses position, clipping and a smooth opacity
545
+ fade rather than stretching text. Rapid updates continue from visible positions
546
+ and opacity. Keep the caller's container right-aligned to hold the numeric right
547
+ edge fixed as its width changes, and allow enough space for the formatted value;
548
+ the animation is clipped within its number area. The default duration is 420ms.
549
+
550
+ ```tsx
551
+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
552
+ <NumberTicker value={amount} animation="morph" duration={420} />
553
+ </div>
554
+ ```
555
+
500
556
  Set `compact` to follow `locale` (Chinese units for `zh`, English units otherwise),
501
557
  or use `compact="en"` / `compact="zh"` to select a unit table explicitly.
502
558
  English uses this library's short-scale suffix convention: K, M, B, T, Qa, Qi,
@@ -570,7 +626,10 @@ technology; decorative affixes are not announced twice.
570
626
 
571
627
  `Log` renders caller-ordered records with stable ids, optional level filters and
572
628
  pause/follow controls. It follows only append updates while the viewport remains
573
- near the bottom. The named scroll region supports Home/End and intentionally is
629
+ near the bottom. When scrolled away or paused, a localized icon button floats
630
+ at the viewport's bottom end corner without adding a footer row. Clicking it
631
+ returns focus to the scroll region and resumes following or jumps to the latest
632
+ record. The named scroll region supports Home/End and intentionally is
574
633
  not a live region, so a long stream is not repeatedly announced. Log does not
575
634
  provide persistence, virtualization or transport.
576
635
 
@@ -0,0 +1,47 @@
1
+ import { type HTMLAttributes, type ReactElement, type ReactNode, type RefAttributes } from 'react';
2
+ import { type NodeGraphRootProps, type NodeGraphRef } from '../node-graph/index.js';
3
+ import { layoutDependencyGraph, type DependencyGraphNode, type DependencyGraphEdge, type DependencyGraphStatus, type DependencyGraphDirection } from './model.js';
4
+ export * from './model.js';
5
+ export type DependencyGraphRef = NodeGraphRef;
6
+ export interface DependencyGraphNodeRenderState {
7
+ selected: boolean;
8
+ highlighted: boolean;
9
+ activate: () => void;
10
+ /** Wrap each animated face so its paper, border and selection move together. */
11
+ renderSurface: (children: ReactNode) => ReactElement;
12
+ }
13
+ export interface DependencyGraphRootProps<Data = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'> {
14
+ nodes: DependencyGraphNode<Data>[];
15
+ edges: DependencyGraphEdge[];
16
+ direction?: DependencyGraphDirection;
17
+ selectedId?: string | null;
18
+ defaultSelectedId?: string | null;
19
+ onSelectedIdChange?: (id: string | null) => void;
20
+ /** Activation does not infer or mutate business unlock rules. */
21
+ onNodeActivate?: (node: DependencyGraphNode<Data>) => void;
22
+ highlight?: 'ancestors' | 'descendants' | 'both' | 'none';
23
+ nodeWidth?: number;
24
+ nodeHeight?: number;
25
+ rankGap?: number;
26
+ nodeGap?: number;
27
+ nodeVariant?: 'card' | 'icon';
28
+ renderNode?: (node: DependencyGraphNode<Data>, state: DependencyGraphNodeRenderState) => ReactNode;
29
+ statusLabels?: Partial<Record<DependencyGraphStatus, string>>;
30
+ onIssues?: (issues: ReturnType<typeof layoutDependencyGraph>['issues']) => void;
31
+ controls?: NodeGraphRootProps['controls'];
32
+ extraControls?: NodeGraphRootProps['extraControls'];
33
+ fitView?: boolean;
34
+ minZoom?: number;
35
+ maxZoom?: number;
36
+ panOnDrag?: boolean;
37
+ zoomOnScroll?: boolean;
38
+ zoomOnPinch?: boolean;
39
+ onlyRenderVisibleElements?: boolean;
40
+ hideAttribution?: boolean;
41
+ emptyState?: ReactNode;
42
+ controlLabels?: NodeGraphRootProps['controlLabels'];
43
+ }
44
+ export declare const DependencyGraphRoot: <Data = unknown>(props: DependencyGraphRootProps<Data> & RefAttributes<DependencyGraphRef>) => ReactElement;
45
+ export declare const DependencyGraph: {
46
+ Root: <Data = unknown>(props: DependencyGraphRootProps<Data> & RefAttributes<DependencyGraphRef>) => ReactElement;
47
+ };
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { Handle, Position, useNodesInitialized, useReactFlow } from '@xyflow/react';
4
+ import { NodeGraph } from '../node-graph/index.js';
5
+ import { PencilSurface } from '../PencilSurface.js';
6
+ import { PencilText } from '../PencilText.js';
7
+ import { PencilIcon } from '../PencilIcon.js';
8
+ import { useInkraLocale } from '../i18n/index.js';
9
+ import { layoutDependencyGraph, getDependencyGraphHighlight } from './model.js';
10
+ export * from './model.js';
11
+ function NodeFrame({ bare, seed, variant, ...props }) {
12
+ return bare ? _jsx("div", { ...props }) : _jsx(PencilSurface, { ...props, seed: seed, variant: variant });
13
+ }
14
+ function NodeText({ children, className, ellipsis }) {
15
+ return typeof children === 'string' || typeof children === 'number'
16
+ ? _jsx(PencilText, { className: className, ellipsis: ellipsis, children: String(children) })
17
+ : _jsx("span", { className: className, children: children });
18
+ }
19
+ // The arrow follows the final tangent of the very same curve drawn by NodeGraph.
20
+ // Both the open and filled silhouettes remain actual pencil geometry.
21
+ function DirectionArrow({ path }) {
22
+ const measure = useRef(null);
23
+ const [tip, setTip] = useState(null);
24
+ useLayoutEffect(() => {
25
+ const node = measure.current;
26
+ if (!node)
27
+ return;
28
+ try {
29
+ const length = node.getTotalLength();
30
+ if (!Number.isFinite(length) || length < 1) {
31
+ setTip(null);
32
+ return;
33
+ }
34
+ const end = node.getPointAtLength(length);
35
+ const before = node.getPointAtLength(Math.max(0, length - 4));
36
+ const angle = Math.atan2(end.y - before.y, end.x - before.x);
37
+ if (![end.x, end.y, angle].every(Number.isFinite)) {
38
+ setTip(null);
39
+ return;
40
+ }
41
+ setTip({ x: end.x - Math.cos(angle) * 7, y: end.y - Math.sin(angle) * 7, angle: angle * 180 / Math.PI });
42
+ }
43
+ catch {
44
+ setTip(null);
45
+ }
46
+ }, [path]);
47
+ return _jsxs("g", { "aria-hidden": "true", pointerEvents: "none", children: [_jsx("path", { ref: measure, d: path, fill: "none", stroke: "none" }), tip && _jsx("g", { transform: `translate(${tip.x} ${tip.y}) rotate(${tip.angle})`, children: _jsx("foreignObject", { x: -10, y: -10, width: 20, height: 20, overflow: "visible", children: _jsx(PencilIcon, { decorative: true, size: 20, sourceSize: 40, strokeWidth: 1.8, breaks: 0, paths: [{ d: 'M6 8L34 20L6 32' }], filledPaths: [{ d: 'M6 6L34 20L6 34L4 30L24 20L4 10Z' }], seed: "dependency-direction" }) }) })] });
48
+ }
49
+ function anchorFraction(value, fallback) {
50
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback;
51
+ }
52
+ function DependencyViewport({ layoutKey, enabled, onFit }) {
53
+ const initialized = useNodesInitialized();
54
+ const { fitView } = useReactFlow();
55
+ const fitted = useRef(null);
56
+ useEffect(() => {
57
+ if (!enabled || !initialized || fitted.current === layoutKey)
58
+ return;
59
+ fitted.current = layoutKey;
60
+ let cancelled = false;
61
+ void fitView().then(success => { if (success && !cancelled)
62
+ onFit(layoutKey); });
63
+ return () => { cancelled = true; if (fitted.current === layoutKey)
64
+ fitted.current = null; };
65
+ }, [enabled, initialized, layoutKey, fitView, onFit]);
66
+ return null;
67
+ }
68
+ function DependencyGraphRootInner(props, ref) {
69
+ const { t } = useInkraLocale();
70
+ const { nodes, edges, direction = 'LR', selectedId, defaultSelectedId = null, onSelectedIdChange, onNodeActivate, highlight = 'ancestors', nodeVariant = 'card', nodeWidth = nodeVariant === 'icon' ? 80 : 200, nodeHeight = nodeVariant === 'icon' ? 80 : 104, rankGap = 96, nodeGap = 40, renderNode, statusLabels, onIssues, className, fitView = true, onlyRenderVisibleElements = false, hideAttribution = true, ...rest } = props;
71
+ const [internalSelected, setInternalSelected] = useState(defaultSelectedId);
72
+ const [fittedLayout, setFittedLayout] = useState(null);
73
+ const graphRef = useRef(null);
74
+ useImperativeHandle(ref, () => ({
75
+ fitView: options => graphRef.current?.fitView(options) ?? Promise.resolve(false),
76
+ zoomIn: () => graphRef.current?.zoomIn() ?? Promise.resolve(false),
77
+ zoomOut: () => graphRef.current?.zoomOut() ?? Promise.resolve(false),
78
+ getViewport: () => graphRef.current?.getViewport() ?? { x: 0, y: 0, zoom: 1 },
79
+ }), []);
80
+ const candidate = selectedId === undefined ? internalSelected : selectedId;
81
+ const activeId = nodes.some(node => node.id === candidate) ? candidate : null;
82
+ const layout = useMemo(() => layoutDependencyGraph(nodes, edges, { direction, nodeWidth, nodeHeight, rankGap, nodeGap }), [nodes, edges, direction, nodeWidth, nodeHeight, rankGap, nodeGap]);
83
+ const issuesCallback = useRef(onIssues);
84
+ issuesCallback.current = onIssues;
85
+ useEffect(() => { issuesCallback.current?.(layout.issues); }, [layout.issues]);
86
+ const layoutKey = layout.nodes.map(node => `${node.id}:${node.position.x}:${node.position.y}:${node.width}:${node.height}`).join('|');
87
+ const highlighted = useMemo(() => getDependencyGraphHighlight(nodes, layout.edges, activeId, highlight), [nodes, layout.edges, activeId, highlight]);
88
+ const graphNodes = useMemo(() => {
89
+ const byId = new Map();
90
+ nodes.forEach(node => { if (!byId.has(node.id))
91
+ byId.set(node.id, node); });
92
+ const sourcePosition = ({ LR: Position.Right, RL: Position.Left, TB: Position.Bottom, BT: Position.Top })[direction];
93
+ const targetPosition = ({ LR: Position.Left, RL: Position.Right, TB: Position.Top, BT: Position.Bottom })[direction];
94
+ const positions = new Map(layout.nodes.map(node => [node.id, node]));
95
+ const incoming = new Map(layout.nodes.map(node => [node.id, []]));
96
+ const outgoing = new Map(layout.nodes.map(node => [node.id, []]));
97
+ layout.edges.forEach(edge => { incoming.get(edge.target).push(edge); outgoing.get(edge.source).push(edge); });
98
+ const crossAxis = direction === 'LR' || direction === 'RL' ? 'y' : 'x';
99
+ return layout.nodes.map(positioned => {
100
+ const original = byId.get(positioned.id);
101
+ const anchors = [];
102
+ for (const type of ['source', 'target']) {
103
+ const position = type === 'source' ? sourcePosition : targetPosition;
104
+ const connected = [...(type === 'source' ? outgoing : incoming).get(positioned.id)];
105
+ const neighbor = (edge) => positions.get(type === 'source' ? edge.target : edge.source).position[crossAxis];
106
+ connected.sort((a, b) => neighbor(a) - neighbor(b));
107
+ connected.forEach((edge, index) => {
108
+ const fraction = (index + 1) / (connected.length + 1);
109
+ const key = type === 'source' ? 'sourceAnchor' : 'targetAnchor';
110
+ const custom = edge[key] ?? original[key];
111
+ const side = custom?.position;
112
+ const anchorPosition = side === 'left' || side === 'right' || side === 'top' || side === 'bottom'
113
+ ? side : position;
114
+ const defaultX = position === Position.Left ? 0 : position === Position.Right ? 1 : fraction;
115
+ const defaultY = position === Position.Top ? 0 : position === Position.Bottom ? 1 : fraction;
116
+ anchors.push({ id: `${type}-${edge.id}`, type, position: anchorPosition,
117
+ x: positioned.width * anchorFraction(custom?.x, defaultX),
118
+ y: positioned.height * anchorFraction(custom?.y, defaultY) });
119
+ });
120
+ }
121
+ return { ...positioned, focusable: false,
122
+ measured: { width: positioned.width, height: positioned.height },
123
+ handles: anchors.map(anchor => ({ ...anchor, width: 0, height: 0 })),
124
+ sourcePosition, targetPosition, data: {
125
+ label: original.label, original, anchors,
126
+ } };
127
+ });
128
+ }, [nodes, layout.nodes, layout.edges, direction]);
129
+ const graphEdges = useMemo(() => layout.edges.map(edge => ({ ...edge, focusable: false, selectable: false,
130
+ sourceHandle: `source-${edge.id}`, targetHandle: `target-${edge.id}`, data: { highlighted: highlighted.edgeIds.has(edge.id) } })), [layout.edges, highlighted]);
131
+ const renderGraphNode = ({ id, data }) => {
132
+ const node = data.original;
133
+ const selected = id === activeId;
134
+ const related = highlighted.nodeIds.has(id);
135
+ const status = node.status;
136
+ const label = typeof node.label === 'string' || typeof node.label === 'number' ? String(node.label) : id;
137
+ const activate = () => {
138
+ if (node.disabled)
139
+ return;
140
+ if (selectedId === undefined)
141
+ setInternalSelected(id);
142
+ onSelectedIdChange?.(id);
143
+ onNodeActivate?.(node);
144
+ };
145
+ const decoration = _jsxs(_Fragment, { children: [(selected || related) && _jsx(PencilSurface, { as: "span", "aria-hidden": "true", variant: "outline", className: "inkra-dependency-graph__highlight", seed: `dependency-outline-${id}` }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", variant: "outline", className: "inkra-dependency-graph__focus", seed: `dependency-focus-${id}` })] });
146
+ const renderSurface = (children) => _jsxs(PencilSurface, { variant: selected ? 'action' : 'paper', seed: `dependency-${id}`, className: "inkra-dependency-graph__card inkra-dependency-graph__card--custom", children: [decoration, children] });
147
+ return _jsxs("div", { className: "inkra-dependency-graph__node", "data-status": status, "data-selected": selected || undefined, "data-highlighted": related || undefined, "data-disabled": node.disabled || undefined, "data-dimmed": activeId !== null && highlight !== 'none' && !related && !selected || undefined, children: [!renderNode && nodeVariant === 'icon'
148
+ ? _jsxs(PencilSurface, { as: "button", type: "button", variant: selected ? 'action' : 'paper', className: "inkra-dependency-graph__icon-button nodrag nopan", disabled: node.disabled, "aria-label": label, title: label, "aria-pressed": selected, seed: `dependency-${id}`, onClick: activate, children: [decoration, node.icon ?? _jsx(NodeText, { children: label.slice(0, 1) })] })
149
+ : _jsxs(NodeFrame, { bare: Boolean(renderNode), variant: selected ? 'action' : 'paper', seed: `dependency-${id}`, className: `inkra-dependency-graph__card nodrag nopan${renderNode ? ' inkra-dependency-graph__card--custom' : ''}`, role: "group", "aria-label": label, "aria-disabled": node.disabled || undefined, tabIndex: renderNode && !node.disabled ? 0 : undefined, onClick: event => {
150
+ if (!(event.target instanceof Element) || event.target.closest('button,a,input,select,textarea,[role="button"],[contenteditable="true"]'))
151
+ return;
152
+ activate();
153
+ }, onKeyDown: event => {
154
+ if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) {
155
+ event.preventDefault();
156
+ activate();
157
+ }
158
+ }, children: [!renderNode && decoration, renderNode ? renderNode(node, { selected, highlighted: related, activate, renderSurface }) : _jsxs(_Fragment, { children: [_jsxs("div", { className: "inkra-dependency-graph__header", children: [node.icon != null && _jsx("span", { className: "inkra-dependency-graph__icon", "aria-hidden": "true", children: node.icon }), _jsx("button", { type: "button", className: "inkra-dependency-graph__title", disabled: node.disabled, "aria-pressed": selected, onClick: activate, children: _jsx(NodeText, { ellipsis: true, children: node.label }) }), node.actions != null && _jsx("div", { className: "inkra-dependency-graph__actions", children: node.actions })] }), (node.content ?? node.description) != null && _jsx("div", { className: "inkra-dependency-graph__body nowheel", children: typeof (node.content ?? node.description) === 'string' || typeof (node.content ?? node.description) === 'number'
159
+ ? _jsx(NodeText, { children: node.content ?? node.description }) : node.content ?? node.description }), status && _jsx(PencilText, { className: "inkra-dependency-graph__status", children: statusLabels?.[status] ?? t(`dependency.${status}`) }), node.footer != null && _jsx("div", { className: "inkra-dependency-graph__footer", children: node.footer })] })] }), data.anchors.map(anchor => _jsx(Handle, { id: anchor.id, type: anchor.type, position: anchor.position, isConnectable: false, "aria-hidden": "true", className: "inkra-dependency-graph__anchor", style: { left: anchor.x, top: anchor.y } }, anchor.id)), id === layout.nodes[0]?.id && _jsx(DependencyViewport, { layoutKey: layoutKey, enabled: fitView, onFit: setFittedLayout })] });
160
+ };
161
+ const renderGraphEdge = ({ id, edge, path, labelX, labelY }) => {
162
+ const related = Boolean(edge.data?.highlighted);
163
+ return _jsxs("g", { className: "inkra-dependency-graph__edge", "data-highlighted": related || undefined, "data-dimmed": activeId !== null && highlight !== 'none' && !related || undefined, children: [_jsx(NodeGraph.Edge, { id: id, path: path, label: typeof edge.label === 'string' || typeof edge.label === 'number' ? _jsx(PencilText, { children: String(edge.label) }) : edge.label, labelX: labelX, labelY: labelY, interactionWidth: 0, className: related ? 'inkra-dependency-graph__edge--highlighted' : undefined }), _jsx(DirectionArrow, { path: path })] });
164
+ };
165
+ return _jsx(NodeGraph.Root, { ...rest, ref: graphRef, nodes: graphNodes, edges: graphEdges, readOnly: true, fitView: false, hideAttribution: hideAttribution, onlyRenderVisibleElements: onlyRenderVisibleElements && (!fitView || fittedLayout === layoutKey), onKeyDown: event => {
166
+ props.onKeyDown?.(event);
167
+ if (event.key === 'Escape' && activeId !== null && !event.defaultPrevented) {
168
+ event.preventDefault();
169
+ event.stopPropagation();
170
+ if (selectedId === undefined)
171
+ setInternalSelected(null);
172
+ onSelectedIdChange?.(null);
173
+ }
174
+ }, "aria-label": props['aria-label'] ?? t('dependency.label'), className: ['inkra-dependency-graph', className].filter(Boolean).join(' '), renderNode: renderGraphNode, renderEdge: renderGraphEdge });
175
+ }
176
+ export const DependencyGraphRoot = forwardRef(DependencyGraphRootInner);
177
+ export const DependencyGraph = { Root: DependencyGraphRoot };
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from 'react';
2
+ export type DependencyGraphDirection = 'LR' | 'RL' | 'TB' | 'BT';
3
+ export type DependencyGraphStatus = 'locked' | 'available' | 'active' | 'completed';
4
+ /** A point in the node's local box. Coordinates are fractions clamped to 0..1.
5
+ * position controls the curve tangent; explicit points do not rotate with direction.
6
+ */
7
+ export interface DependencyGraphAnchor {
8
+ x: number;
9
+ y: number;
10
+ position?: 'left' | 'right' | 'top' | 'bottom';
11
+ }
12
+ export interface DependencyGraphNode<Data = unknown> {
13
+ id: string;
14
+ label: ReactNode;
15
+ icon?: ReactNode;
16
+ content?: ReactNode;
17
+ actions?: ReactNode;
18
+ footer?: ReactNode;
19
+ description?: ReactNode;
20
+ status?: DependencyGraphStatus;
21
+ disabled?: boolean;
22
+ sourceAnchor?: DependencyGraphAnchor;
23
+ targetAnchor?: DependencyGraphAnchor;
24
+ data?: Data;
25
+ }
26
+ export interface DependencyGraphEdge {
27
+ id: string;
28
+ source: string;
29
+ target: string;
30
+ label?: ReactNode;
31
+ /** Overrides the source node's default anchor for this edge. */
32
+ sourceAnchor?: DependencyGraphAnchor;
33
+ /** Overrides the target node's default anchor for this edge. */
34
+ targetAnchor?: DependencyGraphAnchor;
35
+ }
36
+ export interface DependencyGraphIssue {
37
+ type: 'duplicate-node' | 'duplicate-edge' | 'missing-node' | 'cycle';
38
+ ids: string[];
39
+ }
40
+ export interface DependencyGraphLayoutOptions {
41
+ direction?: DependencyGraphDirection;
42
+ nodeWidth?: number;
43
+ nodeHeight?: number;
44
+ rankGap?: number;
45
+ nodeGap?: number;
46
+ }
47
+ /** Stable input order within each rank; edge direction is source prerequisite → target.
48
+ * Duplicate IDs keep their first entry. Missing endpoints and intra-cycle edges
49
+ * are omitted and reported through issues; downstream dependencies remain intact.
50
+ */
51
+ export declare function layoutDependencyGraph<Data>(nodes: readonly DependencyGraphNode<Data>[], inputEdges: readonly DependencyGraphEdge[], options?: DependencyGraphLayoutOptions): {
52
+ nodes: {
53
+ id: string;
54
+ position: {
55
+ x: number;
56
+ y: number;
57
+ };
58
+ width: number;
59
+ height: number;
60
+ }[];
61
+ edges: DependencyGraphEdge[];
62
+ issues: DependencyGraphIssue[];
63
+ };
64
+ /** Traversal is independent of layout and terminates even for invalid cyclic input. */
65
+ export declare function getDependencyGraphHighlight<Data>(nodes: readonly DependencyGraphNode<Data>[], edges: readonly DependencyGraphEdge[], selectedId: string | null, mode: 'ancestors' | 'descendants' | 'both' | 'none'): {
66
+ nodeIds: Set<string>;
67
+ edgeIds: Set<string>;
68
+ };
@@ -0,0 +1,198 @@
1
+ function normalize(nodes, edges) {
2
+ const unique = new Map();
3
+ const issues = [];
4
+ for (const node of nodes) {
5
+ if (unique.has(node.id))
6
+ issues.push({ type: 'duplicate-node', ids: [node.id] });
7
+ else
8
+ unique.set(node.id, node);
9
+ }
10
+ const edgeIds = new Set();
11
+ const valid = [];
12
+ for (const edge of edges) {
13
+ if (edgeIds.has(edge.id)) {
14
+ issues.push({ type: 'duplicate-edge', ids: [edge.id] });
15
+ continue;
16
+ }
17
+ edgeIds.add(edge.id);
18
+ const missing = [edge.source, edge.target].filter(id => !unique.has(id));
19
+ if (missing.length)
20
+ issues.push({ type: 'missing-node', ids: [edge.id, ...new Set(missing)] });
21
+ else
22
+ valid.push(edge);
23
+ }
24
+ return { ids: [...unique.keys()], edges: valid, issues };
25
+ }
26
+ function adjacency(ids, edges, reverse = false) {
27
+ const result = new Map(ids.map(id => [id, []]));
28
+ for (const edge of edges)
29
+ result.get(reverse ? edge.target : edge.source).push(reverse ? edge.source : edge.target);
30
+ return result;
31
+ }
32
+ // Iterative Kosaraju traversal avoids a call-stack limit on long dependency chains.
33
+ function components(ids, edges) {
34
+ const forward = adjacency(ids, edges);
35
+ const backward = adjacency(ids, edges, true);
36
+ const visited = new Set();
37
+ const finished = [];
38
+ for (const id of ids) {
39
+ if (visited.has(id))
40
+ continue;
41
+ visited.add(id);
42
+ const stack = [{ id, next: 0 }];
43
+ while (stack.length) {
44
+ const frame = stack[stack.length - 1];
45
+ const neighbors = forward.get(frame.id);
46
+ if (frame.next >= neighbors.length) {
47
+ finished.push(frame.id);
48
+ stack.pop();
49
+ }
50
+ else {
51
+ const next = neighbors[frame.next++];
52
+ if (!visited.has(next)) {
53
+ visited.add(next);
54
+ stack.push({ id: next, next: 0 });
55
+ }
56
+ }
57
+ }
58
+ }
59
+ const membership = new Map();
60
+ const groups = [];
61
+ for (let index = finished.length - 1; index >= 0; index--) {
62
+ const id = finished[index];
63
+ if (membership.has(id))
64
+ continue;
65
+ const group = groups.length;
66
+ const stack = [id];
67
+ membership.set(id, group);
68
+ const members = [];
69
+ while (stack.length) {
70
+ const current = stack.pop();
71
+ members.push(current);
72
+ for (const previous of backward.get(current)) {
73
+ if (membership.has(previous))
74
+ continue;
75
+ membership.set(previous, group);
76
+ stack.push(previous);
77
+ }
78
+ }
79
+ groups.push(members);
80
+ }
81
+ return { membership, groups };
82
+ }
83
+ function dimension(value, fallback, minimum) {
84
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(minimum, Math.min(2000, value)) : fallback;
85
+ }
86
+ /** Stable input order within each rank; edge direction is source prerequisite → target.
87
+ * Duplicate IDs keep their first entry. Missing endpoints and intra-cycle edges
88
+ * are omitted and reported through issues; downstream dependencies remain intact.
89
+ */
90
+ export function layoutDependencyGraph(nodes, inputEdges, options = {}) {
91
+ const normalized = normalize(nodes, inputEdges);
92
+ const { ids, issues } = normalized;
93
+ const { membership, groups } = components(ids, normalized.edges);
94
+ const cyclic = new Set();
95
+ for (const edge of normalized.edges) {
96
+ const group = membership.get(edge.source);
97
+ if (group === membership.get(edge.target) && (groups[group].length > 1 || edge.source === edge.target))
98
+ cyclic.add(group);
99
+ }
100
+ // A downstream node is not itself cyclic. Report only actual SCC members.
101
+ const cycleMembers = new Map();
102
+ for (const id of ids) {
103
+ const group = membership.get(id);
104
+ if (!cyclic.has(group))
105
+ continue;
106
+ const members = cycleMembers.get(group) ?? [];
107
+ members.push(id);
108
+ cycleMembers.set(group, members);
109
+ }
110
+ for (const group of cyclic)
111
+ issues.push({ type: 'cycle', ids: cycleMembers.get(group) });
112
+ const edges = normalized.edges.filter(edge => {
113
+ const group = membership.get(edge.source);
114
+ return !cyclic.has(group) || membership.get(edge.target) !== group;
115
+ });
116
+ const children = adjacency(ids, edges);
117
+ const indegree = new Map(ids.map(id => [id, 0]));
118
+ const rank = new Map(ids.map(id => [id, 0]));
119
+ for (const edge of edges)
120
+ indegree.set(edge.target, indegree.get(edge.target) + 1);
121
+ const queue = ids.filter(id => indegree.get(id) === 0);
122
+ for (let cursor = 0; cursor < queue.length; cursor++) {
123
+ const id = queue[cursor];
124
+ for (const child of children.get(id)) {
125
+ rank.set(child, Math.max(rank.get(child), rank.get(id) + 1));
126
+ const remaining = indegree.get(child) - 1;
127
+ indegree.set(child, remaining);
128
+ if (!remaining)
129
+ queue.push(child);
130
+ }
131
+ }
132
+ const layers = new Map();
133
+ for (const id of ids) {
134
+ const level = rank.get(id);
135
+ const layer = layers.get(level) ?? [];
136
+ layer.push(id);
137
+ layers.set(level, layer);
138
+ }
139
+ const width = dimension(options.nodeWidth, 200, 80);
140
+ const height = dimension(options.nodeHeight, 104, 44);
141
+ const rankGap = dimension(options.rankGap, 96, 0);
142
+ const nodeGap = dimension(options.nodeGap, 40, 0);
143
+ const direction = options.direction ?? 'LR';
144
+ const horizontal = direction === 'LR' || direction === 'RL';
145
+ let maxRank = 0;
146
+ let maxCount = 0;
147
+ for (const [level, layer] of layers) {
148
+ maxRank = Math.max(maxRank, level);
149
+ maxCount = Math.max(maxCount, layer.length);
150
+ }
151
+ const positions = new Map();
152
+ for (const [level, layer] of layers) {
153
+ const mainLevel = direction === 'RL' || direction === 'BT' ? maxRank - level : level;
154
+ const main = mainLevel * ((horizontal ? width : height) + rankGap);
155
+ const step = (horizontal ? height : width) + nodeGap;
156
+ const offset = (maxCount - layer.length) * step / 2;
157
+ layer.forEach((id, index) => {
158
+ const cross = offset + index * step;
159
+ positions.set(id, horizontal ? { x: main, y: cross } : { x: cross, y: main });
160
+ });
161
+ }
162
+ return { nodes: ids.map(id => ({ id, position: positions.get(id), width, height })), edges, issues };
163
+ }
164
+ /** Traversal is independent of layout and terminates even for invalid cyclic input. */
165
+ export function getDependencyGraphHighlight(nodes, edges, selectedId, mode) {
166
+ const valid = normalize(nodes, edges);
167
+ const nodeIds = new Set();
168
+ const edgeIds = new Set();
169
+ if (selectedId === null || !valid.ids.includes(selectedId))
170
+ return { nodeIds, edgeIds };
171
+ nodeIds.add(selectedId);
172
+ if (mode === 'none')
173
+ return { nodeIds, edgeIds };
174
+ const walk = (reverse) => {
175
+ const adjacent = new Map(valid.ids.map(id => [id, []]));
176
+ for (const edge of valid.edges)
177
+ adjacent.get(reverse ? edge.target : edge.source).push(edge);
178
+ const visited = new Set([selectedId]);
179
+ const stack = [selectedId];
180
+ while (stack.length) {
181
+ const current = stack.pop();
182
+ for (const edge of adjacent.get(current)) {
183
+ const next = reverse ? edge.source : edge.target;
184
+ edgeIds.add(edge.id);
185
+ nodeIds.add(next);
186
+ if (!visited.has(next)) {
187
+ visited.add(next);
188
+ stack.push(next);
189
+ }
190
+ }
191
+ }
192
+ };
193
+ if (mode === 'ancestors' || mode === 'both')
194
+ walk(true);
195
+ if (mode === 'descendants' || mode === 'both')
196
+ walk(false);
197
+ return { nodeIds, edgeIds };
198
+ }