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.
@@ -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
+ }