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,291 @@
|
|
|
1
|
+
import { Icon } from "@iconify-icon/react";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
import type {
|
|
4
|
+
NodeSchema,
|
|
5
|
+
ProfileSchema,
|
|
6
|
+
TemplateSchema,
|
|
7
|
+
} from "../types/schema";
|
|
8
|
+
import { cn } from "../utils/cn";
|
|
9
|
+
import { getSchemaKind } from "../utils/schema-to-flow";
|
|
10
|
+
|
|
11
|
+
export type SchemaItem = {
|
|
12
|
+
schema: NodeSchema | ProfileSchema | TemplateSchema;
|
|
13
|
+
type: "node" | "profile" | "template";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface FilterPanelProps {
|
|
17
|
+
namespaceSchemas: Map<string, SchemaItem[]>;
|
|
18
|
+
hiddenNodes: Set<string>;
|
|
19
|
+
onToggleNamespace: (namespace: string) => void;
|
|
20
|
+
onToggleNode: (kind: string) => void;
|
|
21
|
+
onFocusNode: (kind: string) => void;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
onHoverNode?: (kind: string | null) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get badge color based on schema type
|
|
27
|
+
const getTypeBadgeColor = (type: "node" | "profile" | "template") => {
|
|
28
|
+
switch (type) {
|
|
29
|
+
case "profile":
|
|
30
|
+
return "bg-pink-100 text-pink-700";
|
|
31
|
+
case "template":
|
|
32
|
+
return "bg-amber-100 text-amber-700";
|
|
33
|
+
default:
|
|
34
|
+
return "bg-indigo-100 text-indigo-700";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function FilterPanel({
|
|
39
|
+
namespaceSchemas,
|
|
40
|
+
hiddenNodes,
|
|
41
|
+
onToggleNamespace,
|
|
42
|
+
onToggleNode,
|
|
43
|
+
onFocusNode,
|
|
44
|
+
onClose,
|
|
45
|
+
onHoverNode,
|
|
46
|
+
}: FilterPanelProps) {
|
|
47
|
+
const [expandedNamespaces, setExpandedNamespaces] = useState<Set<string>>(
|
|
48
|
+
new Set(),
|
|
49
|
+
);
|
|
50
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
51
|
+
|
|
52
|
+
const toggleExpand = (namespace: string) => {
|
|
53
|
+
setExpandedNamespaces((prev) => {
|
|
54
|
+
const next = new Set(prev);
|
|
55
|
+
if (next.has(namespace)) {
|
|
56
|
+
next.delete(namespace);
|
|
57
|
+
} else {
|
|
58
|
+
next.add(namespace);
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const namespaces = Array.from(namespaceSchemas.keys()).sort();
|
|
65
|
+
|
|
66
|
+
// Filter namespaces and schemas based on search term
|
|
67
|
+
const filteredNamespaces = useMemo(() => {
|
|
68
|
+
if (!searchTerm.trim()) {
|
|
69
|
+
return namespaces;
|
|
70
|
+
}
|
|
71
|
+
const term = searchTerm.toLowerCase().trim();
|
|
72
|
+
return namespaces.filter((namespace) => {
|
|
73
|
+
// Check if namespace matches
|
|
74
|
+
if (namespace.toLowerCase().includes(term)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// Check if any schema in namespace matches
|
|
78
|
+
const nsSchemas = namespaceSchemas.get(namespace) ?? [];
|
|
79
|
+
return nsSchemas.some(
|
|
80
|
+
(item) =>
|
|
81
|
+
item.schema.name.toLowerCase().includes(term) ||
|
|
82
|
+
(item.schema.label?.toLowerCase().includes(term) ?? false) ||
|
|
83
|
+
(item.schema.kind?.toLowerCase().includes(term) ?? false) ||
|
|
84
|
+
item.type.toLowerCase().includes(term),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}, [namespaces, namespaceSchemas, searchTerm]);
|
|
88
|
+
|
|
89
|
+
// Get filtered schemas for a namespace
|
|
90
|
+
const getFilteredSchemas = useCallback(
|
|
91
|
+
(namespace: string) => {
|
|
92
|
+
const nsSchemas = namespaceSchemas.get(namespace) ?? [];
|
|
93
|
+
if (!searchTerm.trim()) {
|
|
94
|
+
return nsSchemas;
|
|
95
|
+
}
|
|
96
|
+
const term = searchTerm.toLowerCase().trim();
|
|
97
|
+
// If namespace matches, show all schemas
|
|
98
|
+
if (namespace.toLowerCase().includes(term)) {
|
|
99
|
+
return nsSchemas;
|
|
100
|
+
}
|
|
101
|
+
// Otherwise filter schemas
|
|
102
|
+
return nsSchemas.filter(
|
|
103
|
+
(item) =>
|
|
104
|
+
item.schema.name.toLowerCase().includes(term) ||
|
|
105
|
+
(item.schema.label?.toLowerCase().includes(term) ?? false) ||
|
|
106
|
+
(item.schema.kind?.toLowerCase().includes(term) ?? false) ||
|
|
107
|
+
item.type.toLowerCase().includes(term),
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
[namespaceSchemas, searchTerm],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="flex h-full w-80 flex-col border-gray-200 border-l bg-white">
|
|
115
|
+
<div className="flex items-center justify-between border-gray-200 border-b px-4 py-3">
|
|
116
|
+
<h3 className="font-semibold text-gray-700">Filter Schemas</h3>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onClose}
|
|
120
|
+
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
|
121
|
+
>
|
|
122
|
+
<Icon icon="mdi:close" className="text-lg" />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Search input */}
|
|
127
|
+
<div className="border-gray-200 border-b px-3 py-2">
|
|
128
|
+
<div className="relative">
|
|
129
|
+
<Icon
|
|
130
|
+
icon="mdi:magnify"
|
|
131
|
+
className="-translate-y-1/2 absolute top-1/2 left-2.5 text-gray-400 text-lg"
|
|
132
|
+
/>
|
|
133
|
+
<input
|
|
134
|
+
type="text"
|
|
135
|
+
placeholder="Search schemas..."
|
|
136
|
+
value={searchTerm}
|
|
137
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
138
|
+
className="w-full rounded-md border border-gray-200 py-1.5 pr-8 pl-9 text-sm placeholder:text-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
139
|
+
/>
|
|
140
|
+
{searchTerm && (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => setSearchTerm("")}
|
|
144
|
+
className="-translate-y-1/2 absolute top-1/2 right-2 text-gray-400 hover:text-gray-600"
|
|
145
|
+
>
|
|
146
|
+
<Icon icon="mdi:close-circle" className="text-lg" />
|
|
147
|
+
</button>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
153
|
+
{filteredNamespaces.length === 0 && searchTerm && (
|
|
154
|
+
<div className="px-4 py-8 text-center text-gray-400 text-sm">
|
|
155
|
+
No schemas found matching "{searchTerm}"
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
{filteredNamespaces.map((namespace) => {
|
|
159
|
+
const nsSchemas = namespaceSchemas.get(namespace) ?? [];
|
|
160
|
+
const filteredSchemas = getFilteredSchemas(namespace);
|
|
161
|
+
const isExpanded =
|
|
162
|
+
expandedNamespaces.has(namespace) || searchTerm.trim() !== "";
|
|
163
|
+
const visibleCount = nsSchemas.filter(
|
|
164
|
+
(item) => !hiddenNodes.has(getSchemaKind(item.schema)),
|
|
165
|
+
).length;
|
|
166
|
+
const allHidden = visibleCount === 0;
|
|
167
|
+
const allVisible = visibleCount === nsSchemas.length;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div key={namespace} className="mb-1">
|
|
171
|
+
<div
|
|
172
|
+
className={cn(
|
|
173
|
+
"flex items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-100",
|
|
174
|
+
allHidden ? "opacity-50" : "",
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => toggleExpand(namespace)}
|
|
180
|
+
className="flex h-5 w-5 items-center justify-center text-gray-500"
|
|
181
|
+
>
|
|
182
|
+
<Icon
|
|
183
|
+
icon={isExpanded ? "mdi:chevron-down" : "mdi:chevron-right"}
|
|
184
|
+
className="text-lg"
|
|
185
|
+
/>
|
|
186
|
+
</button>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => onToggleNamespace(namespace)}
|
|
190
|
+
className="flex h-5 w-5 items-center justify-center"
|
|
191
|
+
>
|
|
192
|
+
<Icon
|
|
193
|
+
icon={
|
|
194
|
+
allHidden
|
|
195
|
+
? "mdi:checkbox-blank-outline"
|
|
196
|
+
: allVisible
|
|
197
|
+
? "mdi:checkbox-marked"
|
|
198
|
+
: "mdi:minus-box"
|
|
199
|
+
}
|
|
200
|
+
className={cn(
|
|
201
|
+
"text-lg",
|
|
202
|
+
allHidden ? "text-gray-400" : "text-indigo-600",
|
|
203
|
+
)}
|
|
204
|
+
/>
|
|
205
|
+
</button>
|
|
206
|
+
<span className="flex-1 font-medium text-gray-700 text-sm">
|
|
207
|
+
{namespace}
|
|
208
|
+
</span>
|
|
209
|
+
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">
|
|
210
|
+
{visibleCount}/{nsSchemas.length}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
{isExpanded && (
|
|
214
|
+
<div className="ml-6 space-y-0.5 py-1">
|
|
215
|
+
{filteredSchemas.map((item) => {
|
|
216
|
+
const kind = getSchemaKind(item.schema);
|
|
217
|
+
const isSchemaHidden = hiddenNodes.has(kind);
|
|
218
|
+
return (
|
|
219
|
+
// biome-ignore lint/a11y/useSemanticElements: Using div for hover highlighting wrapper is intentional
|
|
220
|
+
<div
|
|
221
|
+
key={kind}
|
|
222
|
+
role="group"
|
|
223
|
+
className={cn(
|
|
224
|
+
"flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-gray-100",
|
|
225
|
+
isSchemaHidden ? "opacity-50" : "",
|
|
226
|
+
)}
|
|
227
|
+
onMouseEnter={() =>
|
|
228
|
+
!isSchemaHidden && onHoverNode?.(kind)
|
|
229
|
+
}
|
|
230
|
+
onMouseLeave={() => onHoverNode?.(null)}
|
|
231
|
+
>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => onToggleNode(kind)}
|
|
235
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center"
|
|
236
|
+
>
|
|
237
|
+
<Icon
|
|
238
|
+
icon={
|
|
239
|
+
isSchemaHidden
|
|
240
|
+
? "mdi:checkbox-blank-outline"
|
|
241
|
+
: "mdi:checkbox-marked"
|
|
242
|
+
}
|
|
243
|
+
className={cn(
|
|
244
|
+
"text-lg",
|
|
245
|
+
isSchemaHidden
|
|
246
|
+
? "text-gray-400"
|
|
247
|
+
: "text-indigo-600",
|
|
248
|
+
)}
|
|
249
|
+
/>
|
|
250
|
+
</button>
|
|
251
|
+
{item.schema.icon && (
|
|
252
|
+
<Icon
|
|
253
|
+
icon={item.schema.icon}
|
|
254
|
+
className="shrink-0 text-gray-500 text-sm"
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
<span className="min-w-0 flex-1 truncate text-left text-gray-600 text-sm">
|
|
258
|
+
{item.schema.label ?? item.schema.name}
|
|
259
|
+
</span>
|
|
260
|
+
{item.type !== "node" && (
|
|
261
|
+
<span
|
|
262
|
+
className={cn(
|
|
263
|
+
"px-1 py-0.5 rounded text-[9px] uppercase font-medium",
|
|
264
|
+
getTypeBadgeColor(item.type),
|
|
265
|
+
)}
|
|
266
|
+
>
|
|
267
|
+
{item.type}
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
{!isSchemaHidden && (
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => onFocusNode(kind)}
|
|
274
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center text-gray-400 hover:text-indigo-600"
|
|
275
|
+
title="Focus on schema"
|
|
276
|
+
>
|
|
277
|
+
<Icon icon="mdi:target" className="text-sm" />
|
|
278
|
+
</button>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseEdge,
|
|
3
|
+
type EdgeProps,
|
|
4
|
+
getBezierPath,
|
|
5
|
+
getSmoothStepPath,
|
|
6
|
+
Position,
|
|
7
|
+
useInternalNode,
|
|
8
|
+
} from "@xyflow/react";
|
|
9
|
+
|
|
10
|
+
export type EdgeStyle = "bezier" | "smoothstep";
|
|
11
|
+
|
|
12
|
+
type NodeType = NonNullable<ReturnType<typeof useInternalNode>>;
|
|
13
|
+
|
|
14
|
+
// Get handle position by ID from a node
|
|
15
|
+
function getHandlePosition(
|
|
16
|
+
node: NodeType,
|
|
17
|
+
handleId: string,
|
|
18
|
+
): { x: number; y: number; position: Position } | null {
|
|
19
|
+
// Check both source and target handle bounds
|
|
20
|
+
const sourceHandle = node.internals.handleBounds?.source?.find(
|
|
21
|
+
(h) => h.id === handleId,
|
|
22
|
+
);
|
|
23
|
+
const targetHandle = node.internals.handleBounds?.target?.find(
|
|
24
|
+
(h) => h.id === handleId,
|
|
25
|
+
);
|
|
26
|
+
const handle = sourceHandle ?? targetHandle;
|
|
27
|
+
|
|
28
|
+
if (!handle) return null;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
x: node.internals.positionAbsolute.x + handle.x + handle.width / 2,
|
|
32
|
+
y: node.internals.positionAbsolute.y + handle.y + handle.height / 2,
|
|
33
|
+
position: handle.position,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Get the best handles to use based on node positions
|
|
38
|
+
function getClosestHandles(
|
|
39
|
+
sourceNode: NodeType,
|
|
40
|
+
targetNode: NodeType,
|
|
41
|
+
sourceRelName: string,
|
|
42
|
+
targetRelName: string | null,
|
|
43
|
+
): {
|
|
44
|
+
sx: number;
|
|
45
|
+
sy: number;
|
|
46
|
+
tx: number;
|
|
47
|
+
ty: number;
|
|
48
|
+
sourcePos: Position;
|
|
49
|
+
targetPos: Position;
|
|
50
|
+
} {
|
|
51
|
+
// Get source handles (left and right for the relationship)
|
|
52
|
+
const sourceLeftHandle = getHandlePosition(
|
|
53
|
+
sourceNode,
|
|
54
|
+
`rel-${sourceRelName}-left`,
|
|
55
|
+
);
|
|
56
|
+
const sourceRightHandle = getHandlePosition(
|
|
57
|
+
sourceNode,
|
|
58
|
+
`rel-${sourceRelName}-right`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Get target handles - either relationship-specific or fallback to node center
|
|
62
|
+
let targetLeftHandle: { x: number; y: number; position: Position } | null =
|
|
63
|
+
null;
|
|
64
|
+
let targetRightHandle: { x: number; y: number; position: Position } | null =
|
|
65
|
+
null;
|
|
66
|
+
|
|
67
|
+
if (targetRelName) {
|
|
68
|
+
targetLeftHandle = getHandlePosition(
|
|
69
|
+
targetNode,
|
|
70
|
+
`rel-${targetRelName}-left`,
|
|
71
|
+
);
|
|
72
|
+
targetRightHandle = getHandlePosition(
|
|
73
|
+
targetNode,
|
|
74
|
+
`rel-${targetRelName}-right`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If no target handles found, use node top (for edges to generic/node without matching relationship)
|
|
79
|
+
if (!targetLeftHandle || !targetRightHandle) {
|
|
80
|
+
const targetWidth = targetNode.measured?.width ?? 280;
|
|
81
|
+
// Use top of the node header area (approximately 40px from top)
|
|
82
|
+
const targetTopY = targetNode.internals.positionAbsolute.y + 20;
|
|
83
|
+
|
|
84
|
+
targetLeftHandle = {
|
|
85
|
+
x: targetNode.internals.positionAbsolute.x,
|
|
86
|
+
y: targetTopY,
|
|
87
|
+
position: Position.Left,
|
|
88
|
+
};
|
|
89
|
+
targetRightHandle = {
|
|
90
|
+
x: targetNode.internals.positionAbsolute.x + targetWidth,
|
|
91
|
+
y: targetTopY,
|
|
92
|
+
position: Position.Right,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If no source handles, use node center edges
|
|
97
|
+
if (!sourceLeftHandle || !sourceRightHandle) {
|
|
98
|
+
const sourceWidth = sourceNode.measured?.width ?? 280;
|
|
99
|
+
const sourceHeight = sourceNode.measured?.height ?? 200;
|
|
100
|
+
const sourceCenterY =
|
|
101
|
+
sourceNode.internals.positionAbsolute.y + sourceHeight / 2;
|
|
102
|
+
|
|
103
|
+
// Determine which side based on relative position
|
|
104
|
+
const sourceX = sourceNode.internals.positionAbsolute.x;
|
|
105
|
+
const targetX = targetNode.internals.positionAbsolute.x;
|
|
106
|
+
const useRightSide = targetX > sourceX;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
sx: useRightSide ? sourceX + sourceWidth : sourceX,
|
|
110
|
+
sy: sourceCenterY,
|
|
111
|
+
tx: useRightSide ? targetLeftHandle.x : targetRightHandle.x,
|
|
112
|
+
ty: useRightSide ? targetLeftHandle.y : targetRightHandle.y,
|
|
113
|
+
sourcePos: useRightSide ? Position.Right : Position.Left,
|
|
114
|
+
targetPos: useRightSide ? Position.Left : Position.Right,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Calculate distances for all combinations to find the shortest path
|
|
119
|
+
const combinations = [
|
|
120
|
+
{
|
|
121
|
+
source: sourceLeftHandle,
|
|
122
|
+
target: targetRightHandle,
|
|
123
|
+
sourcePos: Position.Left,
|
|
124
|
+
targetPos: Position.Right,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
source: sourceLeftHandle,
|
|
128
|
+
target: targetLeftHandle,
|
|
129
|
+
sourcePos: Position.Left,
|
|
130
|
+
targetPos: Position.Left,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
source: sourceRightHandle,
|
|
134
|
+
target: targetLeftHandle,
|
|
135
|
+
sourcePos: Position.Right,
|
|
136
|
+
targetPos: Position.Left,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
source: sourceRightHandle,
|
|
140
|
+
target: targetRightHandle,
|
|
141
|
+
sourcePos: Position.Right,
|
|
142
|
+
targetPos: Position.Right,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
let bestCombo = combinations[0];
|
|
147
|
+
let shortestDistance = Number.POSITIVE_INFINITY;
|
|
148
|
+
|
|
149
|
+
for (const combo of combinations) {
|
|
150
|
+
const dx = combo.target.x - combo.source.x;
|
|
151
|
+
const dy = combo.target.y - combo.source.y;
|
|
152
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
153
|
+
|
|
154
|
+
if (distance < shortestDistance) {
|
|
155
|
+
shortestDistance = distance;
|
|
156
|
+
bestCombo = combo;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
sx: bestCombo.source.x,
|
|
162
|
+
sy: bestCombo.source.y,
|
|
163
|
+
tx: bestCombo.target.x,
|
|
164
|
+
ty: bestCombo.target.y,
|
|
165
|
+
sourcePos: bestCombo.sourcePos,
|
|
166
|
+
targetPos: bestCombo.targetPos,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function FloatingEdge({
|
|
171
|
+
id,
|
|
172
|
+
source,
|
|
173
|
+
target,
|
|
174
|
+
style,
|
|
175
|
+
animated,
|
|
176
|
+
data,
|
|
177
|
+
}: EdgeProps) {
|
|
178
|
+
const sourceNode = useInternalNode(source);
|
|
179
|
+
const targetNode = useInternalNode(target);
|
|
180
|
+
|
|
181
|
+
if (!sourceNode || !targetNode) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Self-loop edges are not rendered - show a marker on the node instead
|
|
186
|
+
if (source === target) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Get relationship names and edge style from edge data
|
|
191
|
+
const edgeData = data as {
|
|
192
|
+
sourceRelName?: string;
|
|
193
|
+
targetRelName?: string | null;
|
|
194
|
+
edgeStyle?: EdgeStyle;
|
|
195
|
+
};
|
|
196
|
+
const sourceRelName = edgeData?.sourceRelName ?? "";
|
|
197
|
+
const targetRelName = edgeData?.targetRelName ?? null;
|
|
198
|
+
const edgeStyle = edgeData?.edgeStyle ?? "bezier";
|
|
199
|
+
|
|
200
|
+
const { sx, sy, tx, ty, sourcePos, targetPos } = getClosestHandles(
|
|
201
|
+
sourceNode,
|
|
202
|
+
targetNode,
|
|
203
|
+
sourceRelName,
|
|
204
|
+
targetRelName,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const pathParams = {
|
|
208
|
+
sourceX: sx,
|
|
209
|
+
sourceY: sy,
|
|
210
|
+
sourcePosition: sourcePos,
|
|
211
|
+
targetX: tx,
|
|
212
|
+
targetY: ty,
|
|
213
|
+
targetPosition: targetPos,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const [edgePath] =
|
|
217
|
+
edgeStyle === "smoothstep"
|
|
218
|
+
? getSmoothStepPath(pathParams)
|
|
219
|
+
: getBezierPath(pathParams);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<BaseEdge
|
|
223
|
+
id={id}
|
|
224
|
+
path={edgePath}
|
|
225
|
+
style={style}
|
|
226
|
+
className={
|
|
227
|
+
animated ? "react-flow__edge-path animated" : "react-flow__edge-path"
|
|
228
|
+
}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Icon } from "@iconify-icon/react";
|
|
2
|
+
import { Panel } from "@xyflow/react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function LegendPanel() {
|
|
6
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
{/* Help button */}
|
|
11
|
+
<Panel position="top-right" className="mr-2 mt-2">
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
15
|
+
className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-md hover:bg-gray-50 text-gray-600 hover:text-gray-800 transition-colors"
|
|
16
|
+
title="Show legend"
|
|
17
|
+
>
|
|
18
|
+
<Icon icon="mdi:help-circle-outline" className="text-xl" />
|
|
19
|
+
</button>
|
|
20
|
+
</Panel>
|
|
21
|
+
|
|
22
|
+
{/* Legend popup */}
|
|
23
|
+
{isOpen && (
|
|
24
|
+
<Panel position="top-right" className="mr-2 mt-12">
|
|
25
|
+
<div className="bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[280px] max-w-[320px]">
|
|
26
|
+
<div className="flex items-center justify-between mb-3">
|
|
27
|
+
<h3 className="font-semibold text-sm text-gray-700">Legend</h3>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={() => setIsOpen(false)}
|
|
31
|
+
className="text-gray-400 hover:text-gray-600"
|
|
32
|
+
>
|
|
33
|
+
<Icon icon="mdi:close" className="text-lg" />
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Schema Types */}
|
|
38
|
+
<div className="mb-4">
|
|
39
|
+
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
|
40
|
+
Schema Types
|
|
41
|
+
</h4>
|
|
42
|
+
<div className="space-y-2">
|
|
43
|
+
<div className="flex items-center gap-2">
|
|
44
|
+
<div className="w-4 h-4 rounded bg-gradient-to-r from-indigo-500 to-indigo-600" />
|
|
45
|
+
<span className="text-xs text-gray-600">Node</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<div className="w-4 h-4 rounded bg-gradient-to-r from-pink-500 to-pink-600" />
|
|
49
|
+
<span className="text-xs text-gray-600">Profile</span>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex items-center gap-2">
|
|
52
|
+
<div className="w-4 h-4 rounded bg-gradient-to-r from-amber-500 to-amber-600" />
|
|
53
|
+
<span className="text-xs text-gray-600">Template</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex items-center gap-2">
|
|
56
|
+
<div className="w-4 h-4 rounded bg-gradient-to-r from-emerald-500 to-emerald-600" />
|
|
57
|
+
<span className="text-xs text-gray-600">Generic</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Edge Colors */}
|
|
63
|
+
<div className="mb-4">
|
|
64
|
+
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
|
65
|
+
Edge Colors
|
|
66
|
+
</h4>
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
<div className="w-8 h-0.5 bg-indigo-500" />
|
|
70
|
+
<span className="text-xs text-gray-600">
|
|
71
|
+
Node relationship
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<div className="w-8 h-0.5 bg-pink-500" />
|
|
76
|
+
<span className="text-xs text-gray-600">
|
|
77
|
+
Profile relationship
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
<div className="w-8 h-0.5 bg-amber-500" />
|
|
82
|
+
<span className="text-xs text-gray-600">
|
|
83
|
+
Template relationship
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
<div className="w-8 h-0.5 bg-gray-400" />
|
|
88
|
+
<span className="text-xs text-gray-600">
|
|
89
|
+
Inherited relationship
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Line Styles */}
|
|
96
|
+
<div className="mb-4">
|
|
97
|
+
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
|
98
|
+
Line Styles
|
|
99
|
+
</h4>
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<div className="w-8 h-0.5 bg-indigo-500" />
|
|
103
|
+
<span className="text-xs text-gray-600">
|
|
104
|
+
Solid - One cardinality
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<div
|
|
109
|
+
className="w-8 h-0.5 relative overflow-hidden"
|
|
110
|
+
style={{ background: "#6366f1" }}
|
|
111
|
+
>
|
|
112
|
+
<div
|
|
113
|
+
className="absolute inset-0"
|
|
114
|
+
style={{
|
|
115
|
+
backgroundImage:
|
|
116
|
+
"repeating-linear-gradient(90deg, transparent, transparent 2px, #6366f1 2px, #6366f1 6px)",
|
|
117
|
+
animation: "dash-move 0.5s linear infinite",
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<span className="text-xs text-gray-600">
|
|
122
|
+
Animated - Many cardinality
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<div
|
|
127
|
+
className="w-8 h-0.5"
|
|
128
|
+
style={{
|
|
129
|
+
backgroundImage:
|
|
130
|
+
"repeating-linear-gradient(90deg, #10b981, #10b981 3px, transparent 3px, transparent 6px)",
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
<span className="text-xs text-gray-600">
|
|
134
|
+
Dashed green - Via generic
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* Special Markers */}
|
|
141
|
+
<div className="mb-4">
|
|
142
|
+
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
|
143
|
+
Special Markers
|
|
144
|
+
</h4>
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
<div className="flex items-center gap-2">
|
|
147
|
+
<span className="text-orange-500 flex items-center gap-0.5">
|
|
148
|
+
<Icon icon="mdi:reload" className="text-sm" />
|
|
149
|
+
</span>
|
|
150
|
+
<span className="text-xs text-gray-600">
|
|
151
|
+
Self-referencing relationship
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Tips */}
|
|
158
|
+
<div className="border-t border-gray-100 pt-3">
|
|
159
|
+
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
|
160
|
+
Tips
|
|
161
|
+
</h4>
|
|
162
|
+
<ul className="text-xs text-gray-600 space-y-1">
|
|
163
|
+
<li className="flex items-start gap-1">
|
|
164
|
+
<Icon
|
|
165
|
+
icon="mdi:circle-small"
|
|
166
|
+
className="text-gray-400 shrink-0 mt-0.5"
|
|
167
|
+
/>
|
|
168
|
+
<span>Hover over a node to highlight connections</span>
|
|
169
|
+
</li>
|
|
170
|
+
<li className="flex items-start gap-1">
|
|
171
|
+
<Icon
|
|
172
|
+
icon="mdi:circle-small"
|
|
173
|
+
className="text-gray-400 shrink-0 mt-0.5"
|
|
174
|
+
/>
|
|
175
|
+
<span>Right-click a node for more options</span>
|
|
176
|
+
</li>
|
|
177
|
+
<li className="flex items-start gap-1">
|
|
178
|
+
<Icon
|
|
179
|
+
icon="mdi:circle-small"
|
|
180
|
+
className="text-gray-400 shrink-0 mt-0.5"
|
|
181
|
+
/>
|
|
182
|
+
<span>Drag to select multiple nodes</span>
|
|
183
|
+
</li>
|
|
184
|
+
</ul>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</Panel>
|
|
188
|
+
)}
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
}
|