@graph-render/react 1.0.1
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/.eslintrc.json +6 -0
- package/CHANGELOG.md +58 -0
- package/README.md +0 -0
- package/dist/index.js +2509 -0
- package/dist/src/components/EdgePath.d.ts +25 -0
- package/dist/src/components/EdgePath.d.ts.map +1 -0
- package/dist/src/components/Graph.d.ts +6 -0
- package/dist/src/components/Graph.d.ts.map +1 -0
- package/dist/src/components/GraphLabels.d.ts +30 -0
- package/dist/src/components/GraphLabels.d.ts.map +1 -0
- package/dist/src/components/GraphNode.d.ts +35 -0
- package/dist/src/components/GraphNode.d.ts.map +1 -0
- package/dist/src/constants/defaults.d.ts +3 -0
- package/dist/src/constants/defaults.d.ts.map +1 -0
- package/dist/src/hooks/useGraphCollapse.d.ts +15 -0
- package/dist/src/hooks/useGraphCollapse.d.ts.map +1 -0
- package/dist/src/hooks/useGraphHover.d.ts +24 -0
- package/dist/src/hooks/useGraphHover.d.ts.map +1 -0
- package/dist/src/hooks/useGraphModel.d.ts +51 -0
- package/dist/src/hooks/useGraphModel.d.ts.map +1 -0
- package/dist/src/hooks/useGraphSearchState.d.ts +29 -0
- package/dist/src/hooks/useGraphSearchState.d.ts.map +1 -0
- package/dist/src/hooks/useStableConfig.d.ts +25 -0
- package/dist/src/hooks/useStableConfig.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/columns.d.ts +7 -0
- package/dist/src/utils/columns.d.ts.map +1 -0
- package/dist/src/utils/pathHighlight.d.ts +29 -0
- package/dist/src/utils/pathHighlight.d.ts.map +1 -0
- package/dist/src/utils/viewport.d.ts +14 -0
- package/dist/src/utils/viewport.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +52 -0
- package/project.json +60 -0
- package/src/components/EdgePath.tsx +103 -0
- package/src/components/Graph.tsx +1545 -0
- package/src/components/GraphLabels.tsx +141 -0
- package/src/components/GraphNode.tsx +214 -0
- package/src/constants/defaults.ts +62 -0
- package/src/hooks/useGraphCollapse.ts +53 -0
- package/src/hooks/useGraphHover.ts +206 -0
- package/src/hooks/useGraphModel.ts +445 -0
- package/src/hooks/useGraphSearchState.ts +215 -0
- package/src/hooks/useStableConfig.ts +108 -0
- package/src/index.ts +57 -0
- package/src/utils/columns.ts +51 -0
- package/src/utils/pathHighlight.ts +160 -0
- package/src/utils/viewport.ts +100 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +11 -0
- package/vite.config.ts +17 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { PositionedNode, LayoutType, LayoutDirection } from '@graph-render/types';
|
|
3
|
+
import { groupPositionedNodesByColumn } from '../utils/columns';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Canonical dimensions for column-label pills. Exported so that the parent
|
|
7
|
+
* Graph component can compute label bounds using the same values for fit-view
|
|
8
|
+
* calculations, eliminating the two-source-of-truth problem.
|
|
9
|
+
*/
|
|
10
|
+
export const LABEL_PILL_MIN_WIDTH = 64;
|
|
11
|
+
export const LABEL_PILL_HEIGHT = 20;
|
|
12
|
+
export const LABEL_PILL_RADIUS = 8;
|
|
13
|
+
const LABEL_PILL_PADDING_X = 12;
|
|
14
|
+
const LABEL_PILL_CHAR_WIDTH = 7;
|
|
15
|
+
const MAX_LABEL_PILL_CHARS = 48;
|
|
16
|
+
|
|
17
|
+
const LABEL_PILL_FONT_SIZE = 12;
|
|
18
|
+
const LABEL_PILL_FONT_WEIGHT = 700;
|
|
19
|
+
|
|
20
|
+
export function getLabelPillWidth(label: string): number {
|
|
21
|
+
const charCount = Math.min(Array.from(label).length, MAX_LABEL_PILL_CHARS);
|
|
22
|
+
return Math.max(
|
|
23
|
+
LABEL_PILL_MIN_WIDTH,
|
|
24
|
+
charCount * LABEL_PILL_CHAR_WIDTH + LABEL_PILL_PADDING_X * 2
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getEffectiveGraphLabels(
|
|
29
|
+
positionedNodes: PositionedNode[],
|
|
30
|
+
layout: LayoutType,
|
|
31
|
+
layoutDirection: LayoutDirection,
|
|
32
|
+
labels?: string[],
|
|
33
|
+
autoLabels = false
|
|
34
|
+
): { orderedXs: number[]; orderedLabels: string[] } {
|
|
35
|
+
const columns = groupPositionedNodesByColumn(positionedNodes);
|
|
36
|
+
const xs = columns.map((column) => column.centerX);
|
|
37
|
+
|
|
38
|
+
if (!xs.length) {
|
|
39
|
+
return { orderedXs: [], orderedLabels: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const levelCounts = columns.map((column) => column.nodes.length);
|
|
43
|
+
|
|
44
|
+
const inferred = levelCounts.map((count) => {
|
|
45
|
+
const denom = count * 2;
|
|
46
|
+
if (denom <= 2) return 'Final';
|
|
47
|
+
return `1/${denom}`;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const effectiveLabels = labels && labels.length ? labels : autoLabels ? inferred : [];
|
|
51
|
+
const orderedXs =
|
|
52
|
+
layout === LayoutType.Tree && layoutDirection === LayoutDirection.RTL ? [...xs].reverse() : xs;
|
|
53
|
+
const orderedLabels =
|
|
54
|
+
layout === LayoutType.Tree && layoutDirection === LayoutDirection.RTL
|
|
55
|
+
? [...effectiveLabels].reverse()
|
|
56
|
+
: effectiveLabels;
|
|
57
|
+
|
|
58
|
+
return { orderedXs, orderedLabels };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GraphLabelsProps {
|
|
62
|
+
positionedNodes: PositionedNode[];
|
|
63
|
+
layout: LayoutType;
|
|
64
|
+
layoutDirection: LayoutDirection;
|
|
65
|
+
labels?: string[];
|
|
66
|
+
autoLabels: boolean;
|
|
67
|
+
labelOffset: number;
|
|
68
|
+
/** Background fill of the label pill. Defaults to `#eef1f6`. */
|
|
69
|
+
pillBackground?: string;
|
|
70
|
+
/** Border stroke of the label pill. Defaults to `#d7dbe3`. */
|
|
71
|
+
pillBorderColor?: string;
|
|
72
|
+
/** Text color inside the label pill. Defaults to `#3f434b`. */
|
|
73
|
+
pillTextColor?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function GraphLabels({
|
|
77
|
+
positionedNodes,
|
|
78
|
+
layout,
|
|
79
|
+
layoutDirection,
|
|
80
|
+
labels,
|
|
81
|
+
autoLabels,
|
|
82
|
+
labelOffset,
|
|
83
|
+
pillBackground = '#eef1f6',
|
|
84
|
+
pillBorderColor = '#d7dbe3',
|
|
85
|
+
pillTextColor = '#3f434b',
|
|
86
|
+
}: GraphLabelsProps) {
|
|
87
|
+
const { orderedXs, orderedLabels } = getEffectiveGraphLabels(
|
|
88
|
+
positionedNodes,
|
|
89
|
+
layout,
|
|
90
|
+
layoutDirection,
|
|
91
|
+
labels,
|
|
92
|
+
autoLabels
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (!orderedXs.length || !orderedLabels.length) return null;
|
|
96
|
+
|
|
97
|
+
// FIX: avoid spreading a potentially large array into Math.min, which can
|
|
98
|
+
// throw a RangeError when the argument count exceeds the JS engine limit.
|
|
99
|
+
const minY = positionedNodes.reduce(
|
|
100
|
+
(min, n) => Math.min(min, n.position.y),
|
|
101
|
+
Number.POSITIVE_INFINITY
|
|
102
|
+
);
|
|
103
|
+
const y = minY - labelOffset;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<g aria-label="labels">
|
|
107
|
+
{orderedXs.map((x, idx) => {
|
|
108
|
+
const label = orderedLabels[idx] ?? '';
|
|
109
|
+
const pillWidth = getLabelPillWidth(label);
|
|
110
|
+
const cx = x;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<g
|
|
114
|
+
key={`col-${x}`}
|
|
115
|
+
transform={`translate(${cx - pillWidth / 2}, ${y - LABEL_PILL_HEIGHT + 6})`}
|
|
116
|
+
>
|
|
117
|
+
<rect
|
|
118
|
+
width={pillWidth}
|
|
119
|
+
height={LABEL_PILL_HEIGHT}
|
|
120
|
+
rx={LABEL_PILL_RADIUS}
|
|
121
|
+
ry={LABEL_PILL_RADIUS}
|
|
122
|
+
fill={pillBackground}
|
|
123
|
+
stroke={pillBorderColor}
|
|
124
|
+
strokeWidth={1}
|
|
125
|
+
/>
|
|
126
|
+
<text
|
|
127
|
+
x={pillWidth / 2}
|
|
128
|
+
y={LABEL_PILL_HEIGHT / 2 + 4}
|
|
129
|
+
fill={pillTextColor}
|
|
130
|
+
fontSize={LABEL_PILL_FONT_SIZE}
|
|
131
|
+
fontWeight={LABEL_PILL_FONT_WEIGHT}
|
|
132
|
+
textAnchor="middle"
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</text>
|
|
136
|
+
</g>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</g>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { PositionedNode, Size, VertexComponent } from '@graph-render/types';
|
|
3
|
+
|
|
4
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
5
|
+
|
|
6
|
+
interface GraphNodeProps {
|
|
7
|
+
node: PositionedNode;
|
|
8
|
+
Vertex: VertexComponent;
|
|
9
|
+
isSelected: boolean;
|
|
10
|
+
isFocused: boolean;
|
|
11
|
+
isHighlighted: boolean;
|
|
12
|
+
activePathKey?: string;
|
|
13
|
+
activePathNodeIds?: Set<string>;
|
|
14
|
+
highlightColor: string;
|
|
15
|
+
selectionColor: string;
|
|
16
|
+
nodeBorderColor?: string;
|
|
17
|
+
nodeBorderWidth: number;
|
|
18
|
+
hoverNodeBorderColor: string;
|
|
19
|
+
hoverNodeBothColor: string;
|
|
20
|
+
hoverNodeInColor: string;
|
|
21
|
+
hoverNodeOutColor: string;
|
|
22
|
+
hoverNodeHighlight: boolean;
|
|
23
|
+
hoveredNodeStates: Map<string, { in?: boolean; out?: boolean }> | undefined;
|
|
24
|
+
onNodeMeasure?: (nodeId: string, size: Size) => void;
|
|
25
|
+
onNodeFocus?: (nodeId: string) => void;
|
|
26
|
+
onNodeClick?: (node: PositionedNode) => void;
|
|
27
|
+
onNodeDoubleClick?: (node: PositionedNode) => void;
|
|
28
|
+
onNodeMouseEnter: (nodeId: string) => void;
|
|
29
|
+
onNodeMouseLeave: () => void;
|
|
30
|
+
onPathHover: (nodeId: string, sourceIndex: number, playerKey?: string) => void;
|
|
31
|
+
onPathLeave: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const GraphNode = React.memo<GraphNodeProps>(
|
|
35
|
+
({
|
|
36
|
+
node,
|
|
37
|
+
Vertex,
|
|
38
|
+
isSelected,
|
|
39
|
+
isFocused,
|
|
40
|
+
selectionColor,
|
|
41
|
+
isHighlighted,
|
|
42
|
+
activePathKey,
|
|
43
|
+
activePathNodeIds,
|
|
44
|
+
highlightColor,
|
|
45
|
+
nodeBorderColor,
|
|
46
|
+
nodeBorderWidth,
|
|
47
|
+
hoverNodeBorderColor,
|
|
48
|
+
hoverNodeBothColor,
|
|
49
|
+
hoverNodeInColor,
|
|
50
|
+
hoverNodeOutColor,
|
|
51
|
+
hoverNodeHighlight,
|
|
52
|
+
hoveredNodeStates,
|
|
53
|
+
onNodeMeasure,
|
|
54
|
+
onNodeFocus,
|
|
55
|
+
onNodeClick,
|
|
56
|
+
onNodeDoubleClick,
|
|
57
|
+
onNodeMouseEnter,
|
|
58
|
+
onNodeMouseLeave,
|
|
59
|
+
onPathHover,
|
|
60
|
+
onPathLeave,
|
|
61
|
+
}) => {
|
|
62
|
+
const groupRef = useRef<SVGGElement>(null);
|
|
63
|
+
const width = node.size?.width ?? 180;
|
|
64
|
+
const height = node.size?.height ?? 72;
|
|
65
|
+
const radius = 8;
|
|
66
|
+
const hoverState = hoveredNodeStates?.get(node.id);
|
|
67
|
+
const isHoveredIn = hoverState?.in ?? false;
|
|
68
|
+
const isHoveredOut = hoverState?.out ?? false;
|
|
69
|
+
const isHoveredBoth = isHoveredIn && isHoveredOut;
|
|
70
|
+
const isHoveredNode = isHoveredIn || isHoveredOut;
|
|
71
|
+
const hasBorder = (!!nodeBorderColor && nodeBorderWidth > 0) || isSelected;
|
|
72
|
+
|
|
73
|
+
let borderStroke = nodeBorderColor;
|
|
74
|
+
if (isSelected) {
|
|
75
|
+
borderStroke = selectionColor;
|
|
76
|
+
} else if (isHighlighted) {
|
|
77
|
+
borderStroke = highlightColor;
|
|
78
|
+
} else if (!hasBorder) {
|
|
79
|
+
borderStroke = 'none';
|
|
80
|
+
} else if (hoverNodeHighlight) {
|
|
81
|
+
if (isHoveredBoth) {
|
|
82
|
+
borderStroke = hoverNodeBothColor;
|
|
83
|
+
} else if (isHoveredOut) {
|
|
84
|
+
borderStroke = hoverNodeOutColor;
|
|
85
|
+
} else if (isHoveredIn) {
|
|
86
|
+
borderStroke = hoverNodeInColor;
|
|
87
|
+
} else if (isHoveredNode) {
|
|
88
|
+
borderStroke = hoverNodeBorderColor;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let borderOpacity = 0;
|
|
93
|
+
if (isSelected || isHighlighted) {
|
|
94
|
+
borderOpacity = 1;
|
|
95
|
+
} else if (hasBorder) {
|
|
96
|
+
borderOpacity = hoverNodeHighlight && isHoveredNode ? 1 : 0.4;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
useIsomorphicLayoutEffect(() => {
|
|
100
|
+
if (!groupRef.current || !onNodeMeasure) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reportFallbackSize = () => {
|
|
105
|
+
if (width > 0 && height > 0) {
|
|
106
|
+
onNodeMeasure(node.id, { width: Math.ceil(width), height: Math.ceil(height) });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const frame = requestAnimationFrame(() => {
|
|
111
|
+
try {
|
|
112
|
+
if (typeof groupRef.current?.getBBox !== 'function') {
|
|
113
|
+
reportFallbackSize();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const bounds = groupRef.current?.getBBox();
|
|
118
|
+
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
|
119
|
+
onNodeMeasure(node.id, {
|
|
120
|
+
width: Math.ceil(bounds.width),
|
|
121
|
+
height: Math.ceil(bounds.height),
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
reportFallbackSize();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reportFallbackSize();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return () => cancelAnimationFrame(frame);
|
|
133
|
+
}, [node.id, node.label, node.meta, onNodeMeasure, width, height]);
|
|
134
|
+
|
|
135
|
+
const borderWidth =
|
|
136
|
+
isSelected || isHighlighted ? Math.max(2, nodeBorderWidth) : hasBorder ? nodeBorderWidth : 0;
|
|
137
|
+
const focusStrokeWidth = isFocused ? Math.max(2, borderWidth || 2) : 0;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<g
|
|
141
|
+
ref={groupRef}
|
|
142
|
+
transform={`translate(${node.position.x}, ${node.position.y})`}
|
|
143
|
+
data-graph-node-interactive="true"
|
|
144
|
+
role="button"
|
|
145
|
+
tabIndex={0}
|
|
146
|
+
aria-selected={isSelected}
|
|
147
|
+
onMouseDown={(event) => {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
}}
|
|
150
|
+
onFocus={() => onNodeFocus?.(node.id)}
|
|
151
|
+
onClick={() => onNodeClick?.(node)}
|
|
152
|
+
onDoubleClick={() => onNodeDoubleClick?.(node)}
|
|
153
|
+
onMouseEnter={() => onNodeMouseEnter(node.id)}
|
|
154
|
+
onMouseLeave={onNodeMouseLeave}
|
|
155
|
+
onKeyDown={(event) => {
|
|
156
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
onNodeClick?.(node);
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<rect
|
|
163
|
+
x={0}
|
|
164
|
+
y={0}
|
|
165
|
+
width={width}
|
|
166
|
+
height={height}
|
|
167
|
+
rx={radius}
|
|
168
|
+
ry={radius}
|
|
169
|
+
fill="none"
|
|
170
|
+
stroke={borderStroke}
|
|
171
|
+
strokeOpacity={borderOpacity}
|
|
172
|
+
strokeWidth={borderWidth}
|
|
173
|
+
pointerEvents="none"
|
|
174
|
+
/>
|
|
175
|
+
{isFocused ? (
|
|
176
|
+
<rect
|
|
177
|
+
x={-3}
|
|
178
|
+
y={-3}
|
|
179
|
+
width={width + 6}
|
|
180
|
+
height={height + 6}
|
|
181
|
+
rx={radius + 2}
|
|
182
|
+
ry={radius + 2}
|
|
183
|
+
fill="none"
|
|
184
|
+
stroke={selectionColor}
|
|
185
|
+
strokeOpacity={0.7}
|
|
186
|
+
strokeWidth={focusStrokeWidth}
|
|
187
|
+
strokeDasharray="4 3"
|
|
188
|
+
pointerEvents="none"
|
|
189
|
+
/>
|
|
190
|
+
) : null}
|
|
191
|
+
<Vertex
|
|
192
|
+
node={node}
|
|
193
|
+
isSelected={isSelected}
|
|
194
|
+
isHovered={isHoveredNode}
|
|
195
|
+
isHoveredIn={isHoveredIn}
|
|
196
|
+
isHoveredOut={isHoveredOut}
|
|
197
|
+
isHoveredBoth={isHoveredBoth}
|
|
198
|
+
activePathKey={activePathKey}
|
|
199
|
+
activePathNodeIds={activePathNodeIds}
|
|
200
|
+
hoverInColor={hoverNodeInColor}
|
|
201
|
+
hoverOutColor={hoverNodeOutColor}
|
|
202
|
+
hoverBothColor={hoverNodeBothColor}
|
|
203
|
+
onPathHover={(sourceIndex, opts) =>
|
|
204
|
+
sourceIndex !== null &&
|
|
205
|
+
onPathHover(node.id, sourceIndex, opts?.pathKey ?? opts?.playerKey)
|
|
206
|
+
}
|
|
207
|
+
onPathLeave={onPathLeave}
|
|
208
|
+
/>
|
|
209
|
+
</g>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
GraphNode.displayName = 'GraphNode';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EdgeType, GraphConfig, LayoutDirection, LayoutType } from '@graph-render/types';
|
|
2
|
+
|
|
3
|
+
// These three fields are intentionally absent from the default object
|
|
4
|
+
// (their undefined values let consumers override them selectively).
|
|
5
|
+
// Using Partial<Pick<...>> makes that intent explicit: the type says
|
|
6
|
+
// "these may or may not be set" so TypeScript won't complain about them
|
|
7
|
+
// being missing from the object literal below.
|
|
8
|
+
export const DEFAULT_CONFIG: Required<
|
|
9
|
+
Pick<
|
|
10
|
+
GraphConfig,
|
|
11
|
+
| 'width'
|
|
12
|
+
| 'height'
|
|
13
|
+
| 'defaultEdgeType'
|
|
14
|
+
| 'curveEdges'
|
|
15
|
+
| 'curveStrength'
|
|
16
|
+
| 'arrowPadding'
|
|
17
|
+
| 'routingStyle'
|
|
18
|
+
| 'edgeSeparation'
|
|
19
|
+
| 'selfLoopRadius'
|
|
20
|
+
| 'layout'
|
|
21
|
+
| 'layoutDirection'
|
|
22
|
+
| 'autoLabels'
|
|
23
|
+
| 'labelOffset'
|
|
24
|
+
| 'nodeSizing'
|
|
25
|
+
| 'labelMeasurementPaddingX'
|
|
26
|
+
| 'labelMeasurementPaddingY'
|
|
27
|
+
| 'labelMeasurementCharWidth'
|
|
28
|
+
| 'labelMeasurementLineHeight'
|
|
29
|
+
| 'hoverHighlight'
|
|
30
|
+
| 'hoverEdgeColor'
|
|
31
|
+
| 'hoverNodeInColor'
|
|
32
|
+
| 'hoverNodeOutColor'
|
|
33
|
+
| 'hoverNodeHighlight'
|
|
34
|
+
| 'edgeLabelColor'
|
|
35
|
+
>
|
|
36
|
+
> &
|
|
37
|
+
Partial<Pick<GraphConfig, 'hoverNodeBorderColor' | 'hoverNodeBothColor' | 'forceRightToLeft'>> = {
|
|
38
|
+
width: 960,
|
|
39
|
+
height: 720,
|
|
40
|
+
defaultEdgeType: EdgeType.Directed,
|
|
41
|
+
curveEdges: true,
|
|
42
|
+
curveStrength: 0.3,
|
|
43
|
+
arrowPadding: 6,
|
|
44
|
+
routingStyle: 'smart',
|
|
45
|
+
edgeSeparation: 18,
|
|
46
|
+
selfLoopRadius: 32,
|
|
47
|
+
layout: LayoutType.Centered,
|
|
48
|
+
layoutDirection: LayoutDirection.LTR,
|
|
49
|
+
autoLabels: false,
|
|
50
|
+
labelOffset: 32,
|
|
51
|
+
nodeSizing: 'fixed',
|
|
52
|
+
labelMeasurementPaddingX: 18,
|
|
53
|
+
labelMeasurementPaddingY: 12,
|
|
54
|
+
labelMeasurementCharWidth: 8,
|
|
55
|
+
labelMeasurementLineHeight: 18,
|
|
56
|
+
hoverHighlight: true,
|
|
57
|
+
hoverEdgeColor: '#4da3ff',
|
|
58
|
+
edgeLabelColor: '#334155',
|
|
59
|
+
hoverNodeInColor: '#2ecc71',
|
|
60
|
+
hoverNodeOutColor: '#ff5b5b',
|
|
61
|
+
hoverNodeHighlight: true,
|
|
62
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseGraphCollapseOptions {
|
|
4
|
+
collapsedNodeIds?: string[];
|
|
5
|
+
defaultCollapsedNodeIds?: string[];
|
|
6
|
+
onCollapsedNodeIdsChange?: (nodeIds: string[]) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface UseGraphCollapseResult {
|
|
10
|
+
collapsedIds: string[];
|
|
11
|
+
collapsedNodeSet: Set<string>;
|
|
12
|
+
pendingExpansionNodeSet: Set<string>;
|
|
13
|
+
updateCollapsedNodeIds: (next: string[] | ((current: string[]) => string[])) => void;
|
|
14
|
+
setPendingExpansionNodeIds: React.Dispatch<React.SetStateAction<string[]>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useGraphCollapse = ({
|
|
18
|
+
collapsedNodeIds,
|
|
19
|
+
defaultCollapsedNodeIds,
|
|
20
|
+
onCollapsedNodeIdsChange,
|
|
21
|
+
}: UseGraphCollapseOptions): UseGraphCollapseResult => {
|
|
22
|
+
const [internalCollapsedNodeIds, setInternalCollapsedNodeIds] = useState<string[]>(
|
|
23
|
+
defaultCollapsedNodeIds ?? []
|
|
24
|
+
);
|
|
25
|
+
const [pendingExpansionNodeIds, setPendingExpansionNodeIds] = useState<string[]>([]);
|
|
26
|
+
|
|
27
|
+
const collapsedIds = collapsedNodeIds ?? internalCollapsedNodeIds;
|
|
28
|
+
const collapsedNodeSet = useMemo(() => new Set(collapsedIds), [collapsedIds]);
|
|
29
|
+
const pendingExpansionNodeSet = useMemo(
|
|
30
|
+
() => new Set(pendingExpansionNodeIds),
|
|
31
|
+
[pendingExpansionNodeIds]
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const updateCollapsedNodeIds = useCallback(
|
|
35
|
+
(next: string[] | ((current: string[]) => string[])) => {
|
|
36
|
+
const current = collapsedIds;
|
|
37
|
+
const resolved = typeof next === 'function' ? next(current) : next;
|
|
38
|
+
if (collapsedNodeIds == null) {
|
|
39
|
+
setInternalCollapsedNodeIds(resolved);
|
|
40
|
+
}
|
|
41
|
+
onCollapsedNodeIdsChange?.(resolved);
|
|
42
|
+
},
|
|
43
|
+
[collapsedIds, collapsedNodeIds, onCollapsedNodeIdsChange]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
collapsedIds,
|
|
48
|
+
collapsedNodeSet,
|
|
49
|
+
pendingExpansionNodeSet,
|
|
50
|
+
updateCollapsedNodeIds,
|
|
51
|
+
setPendingExpansionNodeIds,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { PositionedEdge, EdgeId } from '@graph-render/types';
|
|
3
|
+
import { groupEdgesByTarget, sortEdgesBySourcePosition } from '@graph-render/core';
|
|
4
|
+
import {
|
|
5
|
+
extractPathKeysFromNodes,
|
|
6
|
+
FocusedPath,
|
|
7
|
+
traverseHighlightedPath,
|
|
8
|
+
} from '../utils/pathHighlight';
|
|
9
|
+
|
|
10
|
+
export function useGraphHover(
|
|
11
|
+
positionedNodes: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
position: { x: number; y: number };
|
|
14
|
+
meta?: Record<string, unknown>;
|
|
15
|
+
}>,
|
|
16
|
+
positionedEdges: PositionedEdge[],
|
|
17
|
+
hoverHighlight: boolean
|
|
18
|
+
) {
|
|
19
|
+
const [hoveredEdgeId, setHoveredEdgeId] = useState<EdgeId | null>(null);
|
|
20
|
+
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
|
21
|
+
const [focusedPath, setFocusedPath] = useState<FocusedPath | null>(null);
|
|
22
|
+
const edgeById = useMemo(
|
|
23
|
+
() => new Map(positionedEdges.map((edge) => [edge.id, edge])),
|
|
24
|
+
[positionedEdges]
|
|
25
|
+
);
|
|
26
|
+
const edgesByNodeId = useMemo(() => {
|
|
27
|
+
const map = new Map<string, PositionedEdge[]>();
|
|
28
|
+
|
|
29
|
+
positionedEdges.forEach((edge) => {
|
|
30
|
+
map.set(edge.source, [...(map.get(edge.source) ?? []), edge]);
|
|
31
|
+
if (edge.target !== edge.source) {
|
|
32
|
+
map.set(edge.target, [...(map.get(edge.target) ?? []), edge]);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return map;
|
|
37
|
+
}, [positionedEdges]);
|
|
38
|
+
|
|
39
|
+
const nodePos = useMemo(() => {
|
|
40
|
+
const m = new Map<string, { x: number; y: number }>();
|
|
41
|
+
positionedNodes.forEach((n) => m.set(n.id, n.position));
|
|
42
|
+
return m;
|
|
43
|
+
}, [positionedNodes]);
|
|
44
|
+
|
|
45
|
+
const pathKeysByNode = useMemo(
|
|
46
|
+
() => extractPathKeysFromNodes(positionedNodes),
|
|
47
|
+
[positionedNodes]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const incomingEdgesByTarget = useMemo(() => {
|
|
51
|
+
const map = groupEdgesByTarget(positionedEdges);
|
|
52
|
+
map.forEach((edges, targetId) => {
|
|
53
|
+
map.set(targetId, sortEdgesBySourcePosition(edges, nodePos));
|
|
54
|
+
});
|
|
55
|
+
return map;
|
|
56
|
+
}, [positionedEdges, nodePos]);
|
|
57
|
+
|
|
58
|
+
const pathHighlight = useMemo(() => {
|
|
59
|
+
if (!focusedPath) return null;
|
|
60
|
+
|
|
61
|
+
return traverseHighlightedPath({
|
|
62
|
+
startNodeId: focusedPath.nodeId,
|
|
63
|
+
sourceIndex: focusedPath.sourceIndex,
|
|
64
|
+
pathKey: focusedPath.pathKey,
|
|
65
|
+
incomingEdgesByTarget,
|
|
66
|
+
pathKeysByNode,
|
|
67
|
+
});
|
|
68
|
+
}, [focusedPath, incomingEdgesByTarget, pathKeysByNode]);
|
|
69
|
+
|
|
70
|
+
const hoveredNodeStates = useMemo(() => {
|
|
71
|
+
if (!hoverHighlight && !pathHighlight) return null;
|
|
72
|
+
|
|
73
|
+
const map = new Map<string, { in: boolean; out: boolean }>();
|
|
74
|
+
|
|
75
|
+
const applyPath = () => {
|
|
76
|
+
if (pathHighlight) {
|
|
77
|
+
pathHighlight.nodes.forEach((id) => {
|
|
78
|
+
const curr = map.get(id) ?? { in: false, out: false };
|
|
79
|
+
map.set(id, { ...curr, in: true, out: true });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (focusedPath) {
|
|
85
|
+
applyPath();
|
|
86
|
+
} else {
|
|
87
|
+
if (hoverHighlight) {
|
|
88
|
+
if (hoveredEdgeId) {
|
|
89
|
+
const match = edgeById.get(hoveredEdgeId);
|
|
90
|
+
if (match) {
|
|
91
|
+
const add = (id: string, dir: 'in' | 'out') => {
|
|
92
|
+
const curr = map.get(id) ?? { in: false, out: false };
|
|
93
|
+
map.set(id, { ...curr, [dir]: true });
|
|
94
|
+
};
|
|
95
|
+
if (match.type === 'undirected') {
|
|
96
|
+
add(match.source, 'in');
|
|
97
|
+
add(match.source, 'out');
|
|
98
|
+
add(match.target, 'in');
|
|
99
|
+
add(match.target, 'out');
|
|
100
|
+
} else {
|
|
101
|
+
// For a directed edge A→B the source is the origin (out only)
|
|
102
|
+
// and the target is the destination (in only).
|
|
103
|
+
add(match.source, 'out');
|
|
104
|
+
add(match.target, 'in');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (hoveredNodeId) {
|
|
108
|
+
const add = (id: string, dir: 'in' | 'out') => {
|
|
109
|
+
const curr = map.get(id) ?? { in: false, out: false };
|
|
110
|
+
map.set(id, { ...curr, [dir]: true });
|
|
111
|
+
};
|
|
112
|
+
(edgesByNodeId.get(hoveredNodeId) ?? []).forEach((e) => {
|
|
113
|
+
const isUndir = e.type === 'undirected';
|
|
114
|
+
if (e.source === hoveredNodeId) {
|
|
115
|
+
if (isUndir) {
|
|
116
|
+
add(e.source, 'in');
|
|
117
|
+
add(e.source, 'out');
|
|
118
|
+
add(e.target, 'in');
|
|
119
|
+
add(e.target, 'out');
|
|
120
|
+
} else {
|
|
121
|
+
add(e.source, 'out');
|
|
122
|
+
add(e.target, 'in');
|
|
123
|
+
}
|
|
124
|
+
} else if (e.target === hoveredNodeId) {
|
|
125
|
+
if (isUndir) {
|
|
126
|
+
add(e.source, 'in');
|
|
127
|
+
add(e.source, 'out');
|
|
128
|
+
add(e.target, 'in');
|
|
129
|
+
add(e.target, 'out');
|
|
130
|
+
} else {
|
|
131
|
+
add(e.source, 'out');
|
|
132
|
+
add(e.target, 'in');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
add(hoveredNodeId, 'in');
|
|
137
|
+
add(hoveredNodeId, 'out');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
applyPath();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return map.size ? map : null;
|
|
144
|
+
}, [
|
|
145
|
+
edgeById,
|
|
146
|
+
edgesByNodeId,
|
|
147
|
+
hoverHighlight,
|
|
148
|
+
hoveredEdgeId,
|
|
149
|
+
hoveredNodeId,
|
|
150
|
+
pathHighlight,
|
|
151
|
+
focusedPath,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const edgesForRender = useMemo(() => {
|
|
155
|
+
const highlightIds = new Set<string>();
|
|
156
|
+
|
|
157
|
+
if (focusedPath && pathHighlight) {
|
|
158
|
+
pathHighlight.edges.forEach((id) => highlightIds.add(id));
|
|
159
|
+
} else {
|
|
160
|
+
if (hoverHighlight) {
|
|
161
|
+
if (hoveredEdgeId) {
|
|
162
|
+
highlightIds.add(hoveredEdgeId);
|
|
163
|
+
} else if (hoveredNodeId) {
|
|
164
|
+
(edgesByNodeId.get(hoveredNodeId) ?? []).forEach((e) => {
|
|
165
|
+
if (e.source === hoveredNodeId || e.target === hoveredNodeId) {
|
|
166
|
+
highlightIds.add(e.id);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (pathHighlight) {
|
|
172
|
+
pathHighlight.edges.forEach((id) => highlightIds.add(id));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!highlightIds.size) return positionedEdges;
|
|
177
|
+
|
|
178
|
+
const front: PositionedEdge[] = [];
|
|
179
|
+
const back: PositionedEdge[] = [];
|
|
180
|
+
positionedEdges.forEach((e) => {
|
|
181
|
+
if (highlightIds.has(e.id)) front.push(e);
|
|
182
|
+
else back.push(e);
|
|
183
|
+
});
|
|
184
|
+
return [...back, ...front];
|
|
185
|
+
}, [
|
|
186
|
+
edgesByNodeId,
|
|
187
|
+
hoverHighlight,
|
|
188
|
+
hoveredEdgeId,
|
|
189
|
+
hoveredNodeId,
|
|
190
|
+
positionedEdges,
|
|
191
|
+
pathHighlight,
|
|
192
|
+
focusedPath,
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
hoveredEdgeId,
|
|
197
|
+
setHoveredEdgeId,
|
|
198
|
+
hoveredNodeId,
|
|
199
|
+
setHoveredNodeId,
|
|
200
|
+
focusedPath,
|
|
201
|
+
setFocusedPath,
|
|
202
|
+
pathHighlight,
|
|
203
|
+
hoveredNodeStates,
|
|
204
|
+
edgesForRender,
|
|
205
|
+
};
|
|
206
|
+
}
|