@inkra/react 0.11.0 → 0.13.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 +35 -0
- package/dist/dependency-graph/index.d.ts +47 -0
- package/dist/dependency-graph/index.js +177 -0
- package/dist/dependency-graph/model.d.ts +68 -0
- package/dist/dependency-graph/model.js +198 -0
- package/dist/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/journey-map/index.d.ts +16 -1
- package/dist/journey-map/index.js +211 -54
- package/dist/journey-map/rendering.d.ts +36 -0
- package/dist/journey-map/rendering.js +144 -0
- package/dist/journey-map/terrain.d.ts +4 -1
- package/dist/journey-map/terrain.js +34 -2
- package/dist/node-graph/index.d.ts +15 -2
- package/dist/node-graph/index.js +17 -8
- package/dist/styles/dependency-graph.css +176 -0
- package/dist/styles/journey-map.css +3 -1
- package/dist/styles/markdown.css +2 -0
- package/dist/styles.css +39 -1
- package/package.json +8 -2
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.
|
|
@@ -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
|
+
}
|