infrahub-schema-visualizer 0.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/LICENSE +190 -0
- package/README.md +121 -0
- package/dist/webview/schema-visualizer.css +1 -0
- package/dist/webview/schema-visualizer.js +17 -0
- package/index.ts +38 -0
- package/package.json +66 -0
- package/src/components/BottomToolbar.tsx +195 -0
- package/src/components/EdgeContextMenu.tsx +134 -0
- package/src/components/FilterPanel.tsx +291 -0
- package/src/components/FloatingEdge.tsx +231 -0
- package/src/components/LegendPanel.tsx +191 -0
- package/src/components/NodeContextMenu.tsx +114 -0
- package/src/components/NodeDetailsPanel.tsx +200 -0
- package/src/components/SchemaNode.tsx +260 -0
- package/src/components/SchemaVisualizer.tsx +1027 -0
- package/src/components/index.ts +12 -0
- package/src/types/index.ts +1 -0
- package/src/types/schema.ts +73 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +20 -0
- package/src/utils/layout.ts +60 -0
- package/src/utils/persistence.ts +69 -0
- package/src/utils/schema-to-flow.ts +623 -0
- package/src/webview-entry.tsx +94 -0
- package/src/webview.css +152 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Background,
|
|
3
|
+
BackgroundVariant,
|
|
4
|
+
ConnectionMode,
|
|
5
|
+
type Edge,
|
|
6
|
+
type EdgeTypes,
|
|
7
|
+
getNodesBounds,
|
|
8
|
+
getViewportForBounds,
|
|
9
|
+
type Node,
|
|
10
|
+
type NodeTypes,
|
|
11
|
+
Panel,
|
|
12
|
+
ReactFlow,
|
|
13
|
+
ReactFlowProvider,
|
|
14
|
+
SelectionMode,
|
|
15
|
+
useEdgesState,
|
|
16
|
+
useNodesState,
|
|
17
|
+
useReactFlow,
|
|
18
|
+
} from "@xyflow/react";
|
|
19
|
+
import { toPng, toSvg } from "html-to-image";
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
21
|
+
import "@xyflow/react/dist/style.css";
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
NodeSchema,
|
|
25
|
+
ProfileSchema,
|
|
26
|
+
SchemaVisualizerData,
|
|
27
|
+
TemplateSchema,
|
|
28
|
+
} from "../types/schema";
|
|
29
|
+
import { cn } from "../utils/cn";
|
|
30
|
+
import { getLayoutedElements } from "../utils/layout";
|
|
31
|
+
import {
|
|
32
|
+
clearPersistedState,
|
|
33
|
+
loadPersistedState,
|
|
34
|
+
savePersistedState,
|
|
35
|
+
} from "../utils/persistence";
|
|
36
|
+
import { getSchemaKind, schemaToFlowFiltered } from "../utils/schema-to-flow";
|
|
37
|
+
import {
|
|
38
|
+
BottomToolbar,
|
|
39
|
+
type EdgeStyle,
|
|
40
|
+
type ExportFormat,
|
|
41
|
+
type LayoutDirection,
|
|
42
|
+
} from "./BottomToolbar";
|
|
43
|
+
import { EdgeContextMenu, type EdgeInfo } from "./EdgeContextMenu";
|
|
44
|
+
import { FilterPanel } from "./FilterPanel";
|
|
45
|
+
import { FloatingEdge } from "./FloatingEdge";
|
|
46
|
+
import { LegendPanel } from "./LegendPanel";
|
|
47
|
+
import { NodeContextMenu } from "./NodeContextMenu";
|
|
48
|
+
import { NodeDetailsPanel } from "./NodeDetailsPanel";
|
|
49
|
+
import { SchemaNode } from "./SchemaNode";
|
|
50
|
+
|
|
51
|
+
const nodeTypes: NodeTypes = {
|
|
52
|
+
schemaNode: SchemaNode,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const edgeTypes: EdgeTypes = {
|
|
56
|
+
floating: FloatingEdge,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Compute default hidden nodes based on schema data
|
|
60
|
+
function getDefaultHiddenNodes(data: SchemaVisualizerData): Set<string> {
|
|
61
|
+
const hidden = new Set<string>();
|
|
62
|
+
const hiddenNamespaces = ["Core", "Builtin"];
|
|
63
|
+
// Hide nodes from hidden namespaces
|
|
64
|
+
for (const node of data.nodes) {
|
|
65
|
+
if (hiddenNamespaces.includes(node.namespace)) {
|
|
66
|
+
hidden.add(getSchemaKind(node));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Hide all profiles by default
|
|
70
|
+
for (const profile of data.profiles ?? []) {
|
|
71
|
+
hidden.add(getSchemaKind(profile));
|
|
72
|
+
}
|
|
73
|
+
// Hide all templates by default
|
|
74
|
+
for (const template of data.templates ?? []) {
|
|
75
|
+
hidden.add(getSchemaKind(template));
|
|
76
|
+
}
|
|
77
|
+
return hidden;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SchemaVisualizerProps {
|
|
81
|
+
/**
|
|
82
|
+
* Schema data to visualize. Contains nodes and generics.
|
|
83
|
+
*/
|
|
84
|
+
data: SchemaVisualizerData;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optional CSS class name for the container
|
|
88
|
+
*/
|
|
89
|
+
className?: string;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether to show the background grid
|
|
93
|
+
* @default true
|
|
94
|
+
*/
|
|
95
|
+
showBackground?: boolean;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Number of nodes per row in grid layout
|
|
99
|
+
* @default 4
|
|
100
|
+
*/
|
|
101
|
+
rowSize?: number;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Spacing between nodes
|
|
105
|
+
* @default 400
|
|
106
|
+
*/
|
|
107
|
+
nodeSpacing?: number;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Callback when a node is clicked
|
|
111
|
+
*/
|
|
112
|
+
onNodeClick?: (nodeId: string, schema: NodeSchema) => void;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether to show the filter panel initially
|
|
116
|
+
* @default false
|
|
117
|
+
*/
|
|
118
|
+
defaultFilterOpen?: boolean;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether to show the node details panel when a node is selected
|
|
122
|
+
* @default true
|
|
123
|
+
*/
|
|
124
|
+
showNodeDetails?: boolean;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether to show the bottom toolbar
|
|
128
|
+
* @default true
|
|
129
|
+
*/
|
|
130
|
+
showToolbar?: boolean;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether to show the stats panel
|
|
134
|
+
* @default true
|
|
135
|
+
*/
|
|
136
|
+
showStats?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Inner component that has access to ReactFlow context
|
|
140
|
+
function SchemaVisualizerInner({
|
|
141
|
+
data,
|
|
142
|
+
className,
|
|
143
|
+
showBackground = true,
|
|
144
|
+
rowSize = 4,
|
|
145
|
+
nodeSpacing = 400,
|
|
146
|
+
onNodeClick,
|
|
147
|
+
defaultFilterOpen = false,
|
|
148
|
+
showNodeDetails = true,
|
|
149
|
+
showToolbar = true,
|
|
150
|
+
showStats = true,
|
|
151
|
+
}: SchemaVisualizerProps) {
|
|
152
|
+
const { setCenter, getNode, fitView } = useReactFlow();
|
|
153
|
+
|
|
154
|
+
const [isFilterOpen, setIsFilterOpen] = useState(defaultFilterOpen);
|
|
155
|
+
const [selectedNodeKind, setSelectedNodeKind] = useState<string | null>(null);
|
|
156
|
+
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
|
157
|
+
const [filterHoveredNodeId, setFilterHoveredNodeId] = useState<string | null>(
|
|
158
|
+
null,
|
|
159
|
+
);
|
|
160
|
+
const [contextMenu, setContextMenu] = useState<{
|
|
161
|
+
nodeId: string;
|
|
162
|
+
x: number;
|
|
163
|
+
y: number;
|
|
164
|
+
} | null>(null);
|
|
165
|
+
const [edgeContextMenu, setEdgeContextMenu] = useState<{
|
|
166
|
+
edge: EdgeInfo;
|
|
167
|
+
x: number;
|
|
168
|
+
y: number;
|
|
169
|
+
} | null>(null);
|
|
170
|
+
const [highlightedEdgeId, setHighlightedEdgeId] = useState<string | null>(
|
|
171
|
+
null,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Load persisted state or use defaults
|
|
175
|
+
const persistedState = useMemo(() => loadPersistedState(), []);
|
|
176
|
+
|
|
177
|
+
const [edgeStyle, setEdgeStyle] = useState<EdgeStyle>(
|
|
178
|
+
() => persistedState?.edgeStyle ?? "smoothstep",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Initialize hidden nodes from persisted state or defaults
|
|
182
|
+
const [hiddenNodes, setHiddenNodes] = useState<Set<string>>(() => {
|
|
183
|
+
if (persistedState?.hiddenNodes) {
|
|
184
|
+
return new Set(persistedState.hiddenNodes);
|
|
185
|
+
}
|
|
186
|
+
return getDefaultHiddenNodes(data);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Track if user has customized the view (for showing reset button)
|
|
190
|
+
const [hasCustomizedView, setHasCustomizedView] = useState(
|
|
191
|
+
() => persistedState !== null,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Store saved node positions to apply after layout
|
|
195
|
+
const savedNodePositions = useMemo(() => {
|
|
196
|
+
if (!persistedState?.nodePositions) return null;
|
|
197
|
+
const posMap = new Map<string, { x: number; y: number }>();
|
|
198
|
+
for (const pos of persistedState.nodePositions) {
|
|
199
|
+
posMap.set(pos.id, { x: pos.x, y: pos.y });
|
|
200
|
+
}
|
|
201
|
+
return posMap;
|
|
202
|
+
}, [persistedState]);
|
|
203
|
+
|
|
204
|
+
// Ref to track if we've applied saved positions (only do once on initial load)
|
|
205
|
+
const appliedSavedPositions = useRef(false);
|
|
206
|
+
|
|
207
|
+
// Track whether we've done the initial layout (declared before usage in handleResetView)
|
|
208
|
+
const hasInitialLayout = useRef(false);
|
|
209
|
+
|
|
210
|
+
// Store current node positions map for preserving positions when filtering
|
|
211
|
+
const currentPositionsRef = useRef<Map<string, { x: number; y: number }>>(
|
|
212
|
+
new Map(),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Reset to default view
|
|
216
|
+
const handleResetView = useCallback(() => {
|
|
217
|
+
const defaultHidden = getDefaultHiddenNodes(data);
|
|
218
|
+
setHiddenNodes(defaultHidden);
|
|
219
|
+
setEdgeStyle("smoothstep");
|
|
220
|
+
clearPersistedState();
|
|
221
|
+
setHasCustomizedView(false);
|
|
222
|
+
// Reset the saved positions flag so next load uses defaults
|
|
223
|
+
appliedSavedPositions.current = false;
|
|
224
|
+
// Reset layout flag and clear positions to force full recalculation
|
|
225
|
+
hasInitialLayout.current = false;
|
|
226
|
+
currentPositionsRef.current.clear();
|
|
227
|
+
// Fit view after layout recalculates (need small delay for React state + layout)
|
|
228
|
+
setTimeout(() => {
|
|
229
|
+
fitView({ padding: 0.2 });
|
|
230
|
+
}, 100);
|
|
231
|
+
}, [data, fitView]);
|
|
232
|
+
|
|
233
|
+
// Type for schema items with their type info
|
|
234
|
+
type SchemaItem = {
|
|
235
|
+
schema: NodeSchema | ProfileSchema | TemplateSchema;
|
|
236
|
+
type: "node" | "profile" | "template";
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Group all schemas by namespace
|
|
240
|
+
const namespaceSchemas = useMemo(() => {
|
|
241
|
+
const groups = new Map<string, SchemaItem[]>();
|
|
242
|
+
for (const node of data.nodes) {
|
|
243
|
+
if (!groups.has(node.namespace)) {
|
|
244
|
+
groups.set(node.namespace, []);
|
|
245
|
+
}
|
|
246
|
+
groups.get(node.namespace)?.push({ schema: node, type: "node" });
|
|
247
|
+
}
|
|
248
|
+
for (const profile of data.profiles ?? []) {
|
|
249
|
+
if (!groups.has(profile.namespace)) {
|
|
250
|
+
groups.set(profile.namespace, []);
|
|
251
|
+
}
|
|
252
|
+
groups.get(profile.namespace)?.push({ schema: profile, type: "profile" });
|
|
253
|
+
}
|
|
254
|
+
for (const template of data.templates ?? []) {
|
|
255
|
+
if (!groups.has(template.namespace)) {
|
|
256
|
+
groups.set(template.namespace, []);
|
|
257
|
+
}
|
|
258
|
+
groups
|
|
259
|
+
.get(template.namespace)
|
|
260
|
+
?.push({ schema: template, type: "template" });
|
|
261
|
+
}
|
|
262
|
+
return groups;
|
|
263
|
+
}, [data.nodes, data.profiles, data.templates]);
|
|
264
|
+
|
|
265
|
+
// Get visible schemas count
|
|
266
|
+
const visibleSchemasCount = useMemo(() => {
|
|
267
|
+
let count = 0;
|
|
268
|
+
count += data.nodes.filter(
|
|
269
|
+
(node) => !hiddenNodes.has(getSchemaKind(node)),
|
|
270
|
+
).length;
|
|
271
|
+
count += (data.profiles ?? []).filter(
|
|
272
|
+
(profile) => !hiddenNodes.has(getSchemaKind(profile)),
|
|
273
|
+
).length;
|
|
274
|
+
count += (data.templates ?? []).filter(
|
|
275
|
+
(template) => !hiddenNodes.has(getSchemaKind(template)),
|
|
276
|
+
).length;
|
|
277
|
+
return count;
|
|
278
|
+
}, [data.nodes, data.profiles, data.templates, hiddenNodes]);
|
|
279
|
+
|
|
280
|
+
// Get total schemas count
|
|
281
|
+
const totalSchemasCount = useMemo(() => {
|
|
282
|
+
return (
|
|
283
|
+
data.nodes.length +
|
|
284
|
+
(data.profiles?.length ?? 0) +
|
|
285
|
+
(data.templates?.length ?? 0)
|
|
286
|
+
);
|
|
287
|
+
}, [data.nodes.length, data.profiles?.length, data.templates?.length]);
|
|
288
|
+
|
|
289
|
+
// Convert schema to flow data
|
|
290
|
+
const flowData = useMemo(() => {
|
|
291
|
+
return schemaToFlowFiltered(data.nodes, data.generics, hiddenNodes, {
|
|
292
|
+
nodeSpacing,
|
|
293
|
+
rowSize,
|
|
294
|
+
profiles: data.profiles,
|
|
295
|
+
templates: data.templates,
|
|
296
|
+
});
|
|
297
|
+
}, [
|
|
298
|
+
data.nodes,
|
|
299
|
+
data.generics,
|
|
300
|
+
data.profiles,
|
|
301
|
+
data.templates,
|
|
302
|
+
hiddenNodes,
|
|
303
|
+
nodeSpacing,
|
|
304
|
+
rowSize,
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
|
308
|
+
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
309
|
+
|
|
310
|
+
// Update positions ref whenever flowNodes change
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
for (const node of flowNodes) {
|
|
313
|
+
currentPositionsRef.current.set(node.id, { ...node.position });
|
|
314
|
+
}
|
|
315
|
+
}, [flowNodes]);
|
|
316
|
+
|
|
317
|
+
// Initial layout or when showing previously hidden nodes (need layout for new nodes)
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
// Build a map of existing positions (from ref or saved state)
|
|
320
|
+
const existingPositions = new Map<string, { x: number; y: number }>();
|
|
321
|
+
|
|
322
|
+
// First, use saved positions if this is initial load
|
|
323
|
+
if (savedNodePositions && !appliedSavedPositions.current) {
|
|
324
|
+
for (const [id, pos] of savedNodePositions) {
|
|
325
|
+
existingPositions.set(id, pos);
|
|
326
|
+
}
|
|
327
|
+
appliedSavedPositions.current = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Then, overlay with current positions from ref (these take priority)
|
|
331
|
+
for (const [id, pos] of currentPositionsRef.current) {
|
|
332
|
+
existingPositions.set(id, pos);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Find which nodes need layout (don't have existing positions)
|
|
336
|
+
const nodesNeedingLayout = flowData.nodes.filter(
|
|
337
|
+
(node) => !existingPositions.has(node.id),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
let finalNodes: Node[];
|
|
341
|
+
|
|
342
|
+
if (
|
|
343
|
+
!hasInitialLayout.current ||
|
|
344
|
+
(nodesNeedingLayout.length > 0 &&
|
|
345
|
+
nodesNeedingLayout.length === flowData.nodes.length)
|
|
346
|
+
) {
|
|
347
|
+
// First time or all nodes are new - do full layout
|
|
348
|
+
const { nodes: layoutedNodes, edges: layoutedEdges } =
|
|
349
|
+
getLayoutedElements(flowData.nodes, flowData.edges, {
|
|
350
|
+
direction: "TB",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Apply any existing positions we might have
|
|
354
|
+
finalNodes = layoutedNodes.map((node) => {
|
|
355
|
+
const existingPos = existingPositions.get(node.id);
|
|
356
|
+
if (existingPos) {
|
|
357
|
+
return { ...node, position: existingPos };
|
|
358
|
+
}
|
|
359
|
+
return node;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
hasInitialLayout.current = true;
|
|
363
|
+
|
|
364
|
+
// Update edges with style
|
|
365
|
+
const edgesWithStyle = layoutedEdges.map((edge) => ({
|
|
366
|
+
...edge,
|
|
367
|
+
data: {
|
|
368
|
+
...edge.data,
|
|
369
|
+
edgeStyle,
|
|
370
|
+
},
|
|
371
|
+
}));
|
|
372
|
+
setFlowEdges(edgesWithStyle);
|
|
373
|
+
} else if (nodesNeedingLayout.length > 0) {
|
|
374
|
+
// Some nodes are new - layout only the new nodes
|
|
375
|
+
// Use simple positioning for new nodes (find a free spot)
|
|
376
|
+
const maxY =
|
|
377
|
+
Math.max(...Array.from(existingPositions.values()).map((p) => p.y), 0) +
|
|
378
|
+
400;
|
|
379
|
+
|
|
380
|
+
finalNodes = flowData.nodes.map((node) => {
|
|
381
|
+
const existingPos = existingPositions.get(node.id);
|
|
382
|
+
if (existingPos) {
|
|
383
|
+
return { ...node, position: existingPos };
|
|
384
|
+
}
|
|
385
|
+
// Position new nodes below existing ones
|
|
386
|
+
const newIndex = nodesNeedingLayout.indexOf(node);
|
|
387
|
+
const col = newIndex % 4;
|
|
388
|
+
const row = Math.floor(newIndex / 4);
|
|
389
|
+
return {
|
|
390
|
+
...node,
|
|
391
|
+
position: { x: col * 400, y: maxY + row * 400 },
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Update edges with style
|
|
396
|
+
const edgesWithStyle = flowData.edges.map((edge) => ({
|
|
397
|
+
...edge,
|
|
398
|
+
data: {
|
|
399
|
+
...edge.data,
|
|
400
|
+
edgeStyle,
|
|
401
|
+
},
|
|
402
|
+
}));
|
|
403
|
+
setFlowEdges(edgesWithStyle);
|
|
404
|
+
} else {
|
|
405
|
+
// All nodes have positions - just filter and preserve positions
|
|
406
|
+
finalNodes = flowData.nodes.map((node) => {
|
|
407
|
+
const existingPos = existingPositions.get(node.id);
|
|
408
|
+
return {
|
|
409
|
+
...node,
|
|
410
|
+
position: existingPos ?? node.position,
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Update edges with style
|
|
415
|
+
const edgesWithStyle = flowData.edges.map((edge) => ({
|
|
416
|
+
...edge,
|
|
417
|
+
data: {
|
|
418
|
+
...edge.data,
|
|
419
|
+
edgeStyle,
|
|
420
|
+
},
|
|
421
|
+
}));
|
|
422
|
+
setFlowEdges(edgesWithStyle);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
setFlowNodes(finalNodes);
|
|
426
|
+
}, [flowData, edgeStyle, setFlowNodes, setFlowEdges, savedNodePositions]);
|
|
427
|
+
|
|
428
|
+
// Save state to localStorage when hidden nodes, edge style, or node positions change
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
// Don't save until we have nodes
|
|
431
|
+
if (flowNodes.length === 0) return;
|
|
432
|
+
|
|
433
|
+
savePersistedState({
|
|
434
|
+
hiddenNodes: Array.from(hiddenNodes),
|
|
435
|
+
edgeStyle,
|
|
436
|
+
nodePositions: flowNodes.map((n) => ({
|
|
437
|
+
id: n.id,
|
|
438
|
+
x: n.position.x,
|
|
439
|
+
y: n.position.y,
|
|
440
|
+
})),
|
|
441
|
+
});
|
|
442
|
+
setHasCustomizedView(true);
|
|
443
|
+
}, [hiddenNodes, edgeStyle, flowNodes]);
|
|
444
|
+
|
|
445
|
+
// Compute connected nodes when hovering (from graph or filter panel)
|
|
446
|
+
const activeHoveredId = hoveredNodeId ?? filterHoveredNodeId;
|
|
447
|
+
const connectedNodeIds = useMemo(() => {
|
|
448
|
+
if (!activeHoveredId) return null;
|
|
449
|
+
const connected = new Set<string>([activeHoveredId]);
|
|
450
|
+
for (const edge of flowEdges) {
|
|
451
|
+
if (edge.source === activeHoveredId) {
|
|
452
|
+
connected.add(edge.target);
|
|
453
|
+
}
|
|
454
|
+
if (edge.target === activeHoveredId) {
|
|
455
|
+
connected.add(edge.source);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return connected;
|
|
459
|
+
}, [activeHoveredId, flowEdges]);
|
|
460
|
+
|
|
461
|
+
// Get nodes connected to highlighted edge
|
|
462
|
+
const highlightedEdgeNodes = useMemo(() => {
|
|
463
|
+
if (!highlightedEdgeId) return null;
|
|
464
|
+
const edge = flowEdges.find((e) => e.id === highlightedEdgeId);
|
|
465
|
+
if (!edge) return null;
|
|
466
|
+
return new Set([edge.source, edge.target]);
|
|
467
|
+
}, [highlightedEdgeId, flowEdges]);
|
|
468
|
+
|
|
469
|
+
// Apply dimmed styles to nodes not connected to hovered node or highlighted edge
|
|
470
|
+
// Also add highlight ring when hovering from filter panel
|
|
471
|
+
const styledNodes = useMemo(() => {
|
|
472
|
+
if (!connectedNodeIds && !filterHoveredNodeId && !highlightedEdgeNodes)
|
|
473
|
+
return flowNodes;
|
|
474
|
+
return flowNodes.map((node) => {
|
|
475
|
+
const isHighlighted = filterHoveredNodeId === node.id;
|
|
476
|
+
const isConnected = connectedNodeIds?.has(node.id) ?? true;
|
|
477
|
+
const isEdgeConnected = highlightedEdgeNodes?.has(node.id) ?? true;
|
|
478
|
+
const shouldDim =
|
|
479
|
+
(connectedNodeIds && !isConnected) ||
|
|
480
|
+
(highlightedEdgeNodes && !isEdgeConnected);
|
|
481
|
+
return {
|
|
482
|
+
...node,
|
|
483
|
+
style: {
|
|
484
|
+
...node.style,
|
|
485
|
+
opacity: shouldDim ? 0.25 : 1,
|
|
486
|
+
transition: "opacity 0.2s ease-in-out, box-shadow 0.2s ease-in-out",
|
|
487
|
+
boxShadow: isHighlighted
|
|
488
|
+
? "0 0 0 3px #6366f1, 0 0 20px rgba(99, 102, 241, 0.4)"
|
|
489
|
+
: undefined,
|
|
490
|
+
borderRadius: isHighlighted ? "8px" : undefined,
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
}, [flowNodes, connectedNodeIds, filterHoveredNodeId, highlightedEdgeNodes]);
|
|
495
|
+
|
|
496
|
+
// Apply dimmed styles to edges not connected to hovered node or highlighted edge
|
|
497
|
+
const styledEdges = useMemo(() => {
|
|
498
|
+
if (!activeHoveredId && !highlightedEdgeId) return flowEdges;
|
|
499
|
+
return flowEdges.map((edge) => {
|
|
500
|
+
const isConnectedToHover =
|
|
501
|
+
activeHoveredId &&
|
|
502
|
+
(edge.source === activeHoveredId || edge.target === activeHoveredId);
|
|
503
|
+
const isHighlighted = highlightedEdgeId === edge.id;
|
|
504
|
+
const shouldDim =
|
|
505
|
+
(activeHoveredId && !isConnectedToHover) ||
|
|
506
|
+
(highlightedEdgeId && !isHighlighted);
|
|
507
|
+
return {
|
|
508
|
+
...edge,
|
|
509
|
+
style: {
|
|
510
|
+
...edge.style,
|
|
511
|
+
opacity: shouldDim ? 0.15 : 1,
|
|
512
|
+
strokeWidth: isHighlighted ? 3 : (edge.style?.strokeWidth ?? 2),
|
|
513
|
+
transition: "opacity 0.2s ease-in-out, stroke-width 0.2s ease-in-out",
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}, [flowEdges, activeHoveredId, highlightedEdgeId]);
|
|
518
|
+
|
|
519
|
+
const selectedSchema = useMemo(() => {
|
|
520
|
+
if (!selectedNodeKind) return null;
|
|
521
|
+
// Search in nodes
|
|
522
|
+
const node = data.nodes.find((n) => getSchemaKind(n) === selectedNodeKind);
|
|
523
|
+
if (node) return node;
|
|
524
|
+
// Search in profiles
|
|
525
|
+
const profile = (data.profiles ?? []).find(
|
|
526
|
+
(p) => getSchemaKind(p) === selectedNodeKind,
|
|
527
|
+
);
|
|
528
|
+
if (profile) return profile;
|
|
529
|
+
// Search in templates
|
|
530
|
+
const template = (data.templates ?? []).find(
|
|
531
|
+
(t) => getSchemaKind(t) === selectedNodeKind,
|
|
532
|
+
);
|
|
533
|
+
if (template) return template;
|
|
534
|
+
return null;
|
|
535
|
+
}, [data.nodes, data.profiles, data.templates, selectedNodeKind]);
|
|
536
|
+
|
|
537
|
+
const handleNodeMouseEnter = useCallback(
|
|
538
|
+
(_: React.MouseEvent, node: Node) => {
|
|
539
|
+
setHoveredNodeId(node.id);
|
|
540
|
+
},
|
|
541
|
+
[],
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const handleNodeMouseLeave = useCallback(() => {
|
|
545
|
+
setHoveredNodeId(null);
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
const handleNodeContextMenu = useCallback(
|
|
549
|
+
(event: React.MouseEvent, node: Node) => {
|
|
550
|
+
event.preventDefault();
|
|
551
|
+
setContextMenu({
|
|
552
|
+
nodeId: node.id,
|
|
553
|
+
x: event.clientX,
|
|
554
|
+
y: event.clientY,
|
|
555
|
+
});
|
|
556
|
+
},
|
|
557
|
+
[],
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const handleCloseContextMenu = useCallback(() => {
|
|
561
|
+
setContextMenu(null);
|
|
562
|
+
setEdgeContextMenu(null);
|
|
563
|
+
setHighlightedEdgeId(null);
|
|
564
|
+
}, []);
|
|
565
|
+
|
|
566
|
+
const handleEdgeContextMenu = useCallback(
|
|
567
|
+
(event: React.MouseEvent, edge: Edge) => {
|
|
568
|
+
event.preventDefault();
|
|
569
|
+
const edgeData = edge.data as {
|
|
570
|
+
sourceRelName?: string;
|
|
571
|
+
targetRelName?: string | null;
|
|
572
|
+
};
|
|
573
|
+
// Determine cardinality from the animated property
|
|
574
|
+
const cardinality: "one" | "many" = edge.animated ? "many" : "one";
|
|
575
|
+
setEdgeContextMenu({
|
|
576
|
+
edge: {
|
|
577
|
+
id: edge.id,
|
|
578
|
+
source: edge.source,
|
|
579
|
+
target: edge.target,
|
|
580
|
+
sourceRelName: edgeData?.sourceRelName ?? "",
|
|
581
|
+
targetRelName: edgeData?.targetRelName ?? null,
|
|
582
|
+
cardinality,
|
|
583
|
+
},
|
|
584
|
+
x: event.clientX,
|
|
585
|
+
y: event.clientY,
|
|
586
|
+
});
|
|
587
|
+
},
|
|
588
|
+
[],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const handleCloseEdgeContextMenu = useCallback(() => {
|
|
592
|
+
setEdgeContextMenu(null);
|
|
593
|
+
}, []);
|
|
594
|
+
|
|
595
|
+
const handleHighlightEdge = useCallback((edgeId: string) => {
|
|
596
|
+
setHighlightedEdgeId(edgeId);
|
|
597
|
+
}, []);
|
|
598
|
+
|
|
599
|
+
const handleSelectConnectedNodes = useCallback(
|
|
600
|
+
(nodeId: string) => {
|
|
601
|
+
// Find all connected node IDs
|
|
602
|
+
const connectedIds = new Set<string>([nodeId]);
|
|
603
|
+
for (const edge of flowEdges) {
|
|
604
|
+
if (edge.source === nodeId) {
|
|
605
|
+
connectedIds.add(edge.target);
|
|
606
|
+
}
|
|
607
|
+
if (edge.target === nodeId) {
|
|
608
|
+
connectedIds.add(edge.source);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Update nodes to set selected state
|
|
613
|
+
setFlowNodes((nodes) =>
|
|
614
|
+
nodes.map((node) => ({
|
|
615
|
+
...node,
|
|
616
|
+
selected: connectedIds.has(node.id),
|
|
617
|
+
})),
|
|
618
|
+
);
|
|
619
|
+
},
|
|
620
|
+
[flowEdges, setFlowNodes],
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const handleSelectSingleNode = useCallback(
|
|
624
|
+
(nodeId: string) => {
|
|
625
|
+
setFlowNodes((nodes) =>
|
|
626
|
+
nodes.map((node) => ({
|
|
627
|
+
...node,
|
|
628
|
+
selected: node.id === nodeId,
|
|
629
|
+
})),
|
|
630
|
+
);
|
|
631
|
+
},
|
|
632
|
+
[setFlowNodes],
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
// Get all peer node kinds for a given schema kind (based on relationships)
|
|
636
|
+
const getPeerKinds = useCallback(
|
|
637
|
+
(nodeKind: string): Set<string> => {
|
|
638
|
+
const peers = new Set<string>();
|
|
639
|
+
|
|
640
|
+
// Helper to find peers from a schema's relationships
|
|
641
|
+
const addPeersFromSchema = (
|
|
642
|
+
schema: NodeSchema | ProfileSchema | TemplateSchema,
|
|
643
|
+
) => {
|
|
644
|
+
for (const rel of schema.relationships ?? []) {
|
|
645
|
+
// Add direct peer
|
|
646
|
+
peers.add(rel.peer);
|
|
647
|
+
// If peer is a generic, also find nodes that inherit from it
|
|
648
|
+
const generic = data.generics.find(
|
|
649
|
+
(g) => getSchemaKind(g) === rel.peer,
|
|
650
|
+
);
|
|
651
|
+
if (generic) {
|
|
652
|
+
// Find all nodes/templates that inherit from this generic
|
|
653
|
+
for (const node of data.nodes) {
|
|
654
|
+
if (node.inherit_from?.includes(rel.peer)) {
|
|
655
|
+
peers.add(getSchemaKind(node));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
for (const template of data.templates ?? []) {
|
|
659
|
+
if (template.inherit_from?.includes(rel.peer)) {
|
|
660
|
+
peers.add(getSchemaKind(template));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Search in nodes
|
|
668
|
+
const node = data.nodes.find((n) => getSchemaKind(n) === nodeKind);
|
|
669
|
+
if (node) {
|
|
670
|
+
addPeersFromSchema(node);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Search in profiles
|
|
674
|
+
const profile = (data.profiles ?? []).find(
|
|
675
|
+
(p) => getSchemaKind(p) === nodeKind,
|
|
676
|
+
);
|
|
677
|
+
if (profile) {
|
|
678
|
+
addPeersFromSchema(profile);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Search in templates
|
|
682
|
+
const template = (data.templates ?? []).find(
|
|
683
|
+
(t) => getSchemaKind(t) === nodeKind,
|
|
684
|
+
);
|
|
685
|
+
if (template) {
|
|
686
|
+
addPeersFromSchema(template);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return peers;
|
|
690
|
+
},
|
|
691
|
+
[data.nodes, data.profiles, data.templates, data.generics],
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const handleShowPeers = useCallback(
|
|
695
|
+
(nodeId: string) => {
|
|
696
|
+
const peerKinds = getPeerKinds(nodeId);
|
|
697
|
+
|
|
698
|
+
setHiddenNodes((prev) => {
|
|
699
|
+
const next = new Set(prev);
|
|
700
|
+
// Unhide all peer nodes that are currently hidden
|
|
701
|
+
for (const peerKind of peerKinds) {
|
|
702
|
+
next.delete(peerKind);
|
|
703
|
+
}
|
|
704
|
+
return next;
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
[getPeerKinds],
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
const handleHideNode = useCallback((nodeId: string) => {
|
|
711
|
+
setHiddenNodes((prev) => {
|
|
712
|
+
const next = new Set(prev);
|
|
713
|
+
next.add(nodeId);
|
|
714
|
+
return next;
|
|
715
|
+
});
|
|
716
|
+
// Clear selection if we're hiding the selected node
|
|
717
|
+
setSelectedNodeKind((current) => (current === nodeId ? null : current));
|
|
718
|
+
}, []);
|
|
719
|
+
|
|
720
|
+
const handleNodeClick = useCallback(
|
|
721
|
+
(_: React.MouseEvent, node: Node) => {
|
|
722
|
+
setSelectedNodeKind(node.id);
|
|
723
|
+
// Search in nodes first
|
|
724
|
+
let schema = data.nodes.find((n) => getSchemaKind(n) === node.id);
|
|
725
|
+
if (!schema) {
|
|
726
|
+
// Search in profiles
|
|
727
|
+
schema = (data.profiles ?? []).find(
|
|
728
|
+
(p) => getSchemaKind(p) === node.id,
|
|
729
|
+
) as NodeSchema | undefined;
|
|
730
|
+
}
|
|
731
|
+
if (!schema) {
|
|
732
|
+
// Search in templates
|
|
733
|
+
schema = (data.templates ?? []).find(
|
|
734
|
+
(t) => getSchemaKind(t) === node.id,
|
|
735
|
+
) as NodeSchema | undefined;
|
|
736
|
+
}
|
|
737
|
+
if (schema && onNodeClick) {
|
|
738
|
+
onNodeClick(node.id, schema);
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
[data.nodes, data.profiles, data.templates, onNodeClick],
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
const toggleNamespace = useCallback(
|
|
745
|
+
(namespace: string) => {
|
|
746
|
+
const nsSchemas = namespaceSchemas.get(namespace) ?? [];
|
|
747
|
+
const schemaKinds = nsSchemas.map((item) => getSchemaKind(item.schema));
|
|
748
|
+
const visibleCount = schemaKinds.filter(
|
|
749
|
+
(k) => !hiddenNodes.has(k),
|
|
750
|
+
).length;
|
|
751
|
+
|
|
752
|
+
setHiddenNodes((prev) => {
|
|
753
|
+
const next = new Set(prev);
|
|
754
|
+
if (visibleCount > 0) {
|
|
755
|
+
// Some or all are visible, hide all
|
|
756
|
+
for (const kind of schemaKinds) {
|
|
757
|
+
next.add(kind);
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
// All are hidden, show all
|
|
761
|
+
for (const kind of schemaKinds) {
|
|
762
|
+
next.delete(kind);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return next;
|
|
766
|
+
});
|
|
767
|
+
},
|
|
768
|
+
[namespaceSchemas, hiddenNodes],
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const toggleNode = useCallback((kind: string) => {
|
|
772
|
+
setHiddenNodes((prev) => {
|
|
773
|
+
const next = new Set(prev);
|
|
774
|
+
if (next.has(kind)) {
|
|
775
|
+
next.delete(kind);
|
|
776
|
+
} else {
|
|
777
|
+
next.add(kind);
|
|
778
|
+
}
|
|
779
|
+
return next;
|
|
780
|
+
});
|
|
781
|
+
}, []);
|
|
782
|
+
|
|
783
|
+
const focusNode = useCallback(
|
|
784
|
+
(kind: string) => {
|
|
785
|
+
const node = getNode(kind);
|
|
786
|
+
if (node) {
|
|
787
|
+
setCenter(node.position.x + 150, node.position.y + 100, {
|
|
788
|
+
zoom: 0.8,
|
|
789
|
+
duration: 500,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
[getNode, setCenter],
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const handleLayout = useCallback(
|
|
797
|
+
(direction: LayoutDirection) => {
|
|
798
|
+
const { nodes: layoutedNodes, edges: layoutedEdges } =
|
|
799
|
+
getLayoutedElements(flowNodes, flowEdges, { direction });
|
|
800
|
+
|
|
801
|
+
// Add edge style to the layouted edges
|
|
802
|
+
const edgesWithStyle = layoutedEdges.map((edge) => ({
|
|
803
|
+
...edge,
|
|
804
|
+
data: {
|
|
805
|
+
...edge.data,
|
|
806
|
+
edgeStyle,
|
|
807
|
+
},
|
|
808
|
+
}));
|
|
809
|
+
|
|
810
|
+
setFlowNodes(layoutedNodes);
|
|
811
|
+
setFlowEdges(edgesWithStyle);
|
|
812
|
+
|
|
813
|
+
// Fit view after layout
|
|
814
|
+
window.requestAnimationFrame(() => {
|
|
815
|
+
fitView({ padding: 0.2 });
|
|
816
|
+
});
|
|
817
|
+
},
|
|
818
|
+
[flowNodes, flowEdges, edgeStyle, setFlowNodes, setFlowEdges, fitView],
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
const handleExport = useCallback(
|
|
822
|
+
(format: ExportFormat) => {
|
|
823
|
+
const viewport = document.querySelector(
|
|
824
|
+
".react-flow__viewport",
|
|
825
|
+
) as HTMLElement;
|
|
826
|
+
if (!viewport) return;
|
|
827
|
+
|
|
828
|
+
// Calculate bounds to fit all nodes
|
|
829
|
+
const nodesBounds = getNodesBounds(flowNodes);
|
|
830
|
+
const padding = 50;
|
|
831
|
+
const imageWidth = nodesBounds.width + padding * 2;
|
|
832
|
+
const imageHeight = nodesBounds.height + padding * 2;
|
|
833
|
+
|
|
834
|
+
// Get viewport transform to fit all nodes
|
|
835
|
+
const viewportForBounds = getViewportForBounds(
|
|
836
|
+
nodesBounds,
|
|
837
|
+
imageWidth,
|
|
838
|
+
imageHeight,
|
|
839
|
+
0.5,
|
|
840
|
+
2,
|
|
841
|
+
0,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
const exportOptions = {
|
|
845
|
+
backgroundColor: "#f8fafc",
|
|
846
|
+
width: imageWidth,
|
|
847
|
+
height: imageHeight,
|
|
848
|
+
style: {
|
|
849
|
+
width: `${imageWidth}px`,
|
|
850
|
+
height: `${imageHeight}px`,
|
|
851
|
+
transform: `translate(${viewportForBounds.x}px, ${viewportForBounds.y}px) scale(${viewportForBounds.zoom})`,
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const downloadFile = (dataUrl: string, extension: string) => {
|
|
856
|
+
const link = document.createElement("a");
|
|
857
|
+
link.download = `schema-graph.${extension}`;
|
|
858
|
+
link.href = dataUrl;
|
|
859
|
+
link.click();
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
if (format === "png") {
|
|
863
|
+
toPng(viewport, exportOptions).then((dataUrl) => {
|
|
864
|
+
downloadFile(dataUrl, "png");
|
|
865
|
+
});
|
|
866
|
+
} else {
|
|
867
|
+
toSvg(viewport, exportOptions).then((dataUrl) => {
|
|
868
|
+
downloadFile(dataUrl, "svg");
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
[flowNodes],
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
return (
|
|
876
|
+
<div className={cn("w-full h-full min-h-[500px] flex", className)}>
|
|
877
|
+
<div className="relative flex-1">
|
|
878
|
+
<ReactFlow
|
|
879
|
+
nodes={styledNodes}
|
|
880
|
+
edges={styledEdges}
|
|
881
|
+
onNodesChange={onNodesChange}
|
|
882
|
+
onEdgesChange={onEdgesChange}
|
|
883
|
+
onNodeClick={handleNodeClick}
|
|
884
|
+
onNodeContextMenu={handleNodeContextMenu}
|
|
885
|
+
onNodeMouseEnter={handleNodeMouseEnter}
|
|
886
|
+
onNodeMouseLeave={handleNodeMouseLeave}
|
|
887
|
+
onEdgeContextMenu={handleEdgeContextMenu}
|
|
888
|
+
onPaneClick={handleCloseContextMenu}
|
|
889
|
+
nodeTypes={nodeTypes}
|
|
890
|
+
edgeTypes={edgeTypes}
|
|
891
|
+
connectionMode={ConnectionMode.Loose}
|
|
892
|
+
fitView
|
|
893
|
+
fitViewOptions={{ padding: 0.2 }}
|
|
894
|
+
minZoom={0.1}
|
|
895
|
+
maxZoom={1.5}
|
|
896
|
+
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
|
897
|
+
proOptions={{ hideAttribution: true }}
|
|
898
|
+
selectionOnDrag
|
|
899
|
+
selectionMode={SelectionMode.Partial}
|
|
900
|
+
>
|
|
901
|
+
{showBackground && (
|
|
902
|
+
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
|
903
|
+
)}
|
|
904
|
+
|
|
905
|
+
{/* Stats Panel */}
|
|
906
|
+
{showStats && (
|
|
907
|
+
<Panel
|
|
908
|
+
position="top-left"
|
|
909
|
+
className="rounded-lg bg-white p-3 shadow-md"
|
|
910
|
+
>
|
|
911
|
+
<div className="text-sm">
|
|
912
|
+
<div className="mb-2 font-semibold text-gray-700">
|
|
913
|
+
Schema Overview
|
|
914
|
+
</div>
|
|
915
|
+
<div className="space-y-1 text-gray-600">
|
|
916
|
+
<div className="flex justify-between gap-4">
|
|
917
|
+
<span>Visible:</span>
|
|
918
|
+
<span className="font-medium">{visibleSchemasCount}</span>
|
|
919
|
+
</div>
|
|
920
|
+
<div className="flex justify-between gap-4">
|
|
921
|
+
<span>Total:</span>
|
|
922
|
+
<span className="font-medium">{totalSchemasCount}</span>
|
|
923
|
+
</div>
|
|
924
|
+
<div className="flex justify-between gap-4">
|
|
925
|
+
<span>Nodes:</span>
|
|
926
|
+
<span className="font-medium">{data.nodes.length}</span>
|
|
927
|
+
</div>
|
|
928
|
+
{(data.profiles?.length ?? 0) > 0 && (
|
|
929
|
+
<div className="flex justify-between gap-4">
|
|
930
|
+
<span>Profiles:</span>
|
|
931
|
+
<span className="font-medium">
|
|
932
|
+
{data.profiles?.length}
|
|
933
|
+
</span>
|
|
934
|
+
</div>
|
|
935
|
+
)}
|
|
936
|
+
{(data.templates?.length ?? 0) > 0 && (
|
|
937
|
+
<div className="flex justify-between gap-4">
|
|
938
|
+
<span>Templates:</span>
|
|
939
|
+
<span className="font-medium">
|
|
940
|
+
{data.templates?.length}
|
|
941
|
+
</span>
|
|
942
|
+
</div>
|
|
943
|
+
)}
|
|
944
|
+
<div className="flex justify-between gap-4">
|
|
945
|
+
<span>Generics:</span>
|
|
946
|
+
<span className="font-medium">{data.generics.length}</span>
|
|
947
|
+
</div>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
</Panel>
|
|
951
|
+
)}
|
|
952
|
+
|
|
953
|
+
{/* Legend Panel */}
|
|
954
|
+
<LegendPanel />
|
|
955
|
+
|
|
956
|
+
{/* Bottom Toolbar */}
|
|
957
|
+
{showToolbar && (
|
|
958
|
+
<BottomToolbar
|
|
959
|
+
onFilterClick={() => setIsFilterOpen(!isFilterOpen)}
|
|
960
|
+
isFilterOpen={isFilterOpen}
|
|
961
|
+
edgeStyle={edgeStyle}
|
|
962
|
+
onEdgeStyleChange={setEdgeStyle}
|
|
963
|
+
onLayout={handleLayout}
|
|
964
|
+
onExport={handleExport}
|
|
965
|
+
onReset={handleResetView}
|
|
966
|
+
showReset={hasCustomizedView}
|
|
967
|
+
/>
|
|
968
|
+
)}
|
|
969
|
+
</ReactFlow>
|
|
970
|
+
</div>
|
|
971
|
+
|
|
972
|
+
{/* Filter Panel */}
|
|
973
|
+
{isFilterOpen && (
|
|
974
|
+
<FilterPanel
|
|
975
|
+
namespaceSchemas={namespaceSchemas}
|
|
976
|
+
hiddenNodes={hiddenNodes}
|
|
977
|
+
onToggleNamespace={toggleNamespace}
|
|
978
|
+
onToggleNode={toggleNode}
|
|
979
|
+
onFocusNode={focusNode}
|
|
980
|
+
onClose={() => setIsFilterOpen(false)}
|
|
981
|
+
onHoverNode={setFilterHoveredNodeId}
|
|
982
|
+
/>
|
|
983
|
+
)}
|
|
984
|
+
|
|
985
|
+
{/* Node Details Panel */}
|
|
986
|
+
{showNodeDetails && selectedSchema && !isFilterOpen && (
|
|
987
|
+
<NodeDetailsPanel
|
|
988
|
+
schema={selectedSchema}
|
|
989
|
+
onClose={() => setSelectedNodeKind(null)}
|
|
990
|
+
/>
|
|
991
|
+
)}
|
|
992
|
+
|
|
993
|
+
{/* Node Context Menu */}
|
|
994
|
+
{contextMenu && (
|
|
995
|
+
<NodeContextMenu
|
|
996
|
+
nodeId={contextMenu.nodeId}
|
|
997
|
+
x={contextMenu.x}
|
|
998
|
+
y={contextMenu.y}
|
|
999
|
+
onClose={handleCloseContextMenu}
|
|
1000
|
+
onSelectConnected={handleSelectConnectedNodes}
|
|
1001
|
+
onSelectNode={handleSelectSingleNode}
|
|
1002
|
+
onShowPeers={handleShowPeers}
|
|
1003
|
+
onHideNode={handleHideNode}
|
|
1004
|
+
/>
|
|
1005
|
+
)}
|
|
1006
|
+
|
|
1007
|
+
{/* Edge Context Menu */}
|
|
1008
|
+
{edgeContextMenu && (
|
|
1009
|
+
<EdgeContextMenu
|
|
1010
|
+
edge={edgeContextMenu.edge}
|
|
1011
|
+
x={edgeContextMenu.x}
|
|
1012
|
+
y={edgeContextMenu.y}
|
|
1013
|
+
onClose={handleCloseEdgeContextMenu}
|
|
1014
|
+
onHighlight={handleHighlightEdge}
|
|
1015
|
+
/>
|
|
1016
|
+
)}
|
|
1017
|
+
</div>
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export function SchemaVisualizer(props: SchemaVisualizerProps) {
|
|
1022
|
+
return (
|
|
1023
|
+
<ReactFlowProvider>
|
|
1024
|
+
<SchemaVisualizerInner {...props} />
|
|
1025
|
+
</ReactFlowProvider>
|
|
1026
|
+
);
|
|
1027
|
+
}
|