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,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
+ }