@eventcatalog/core 3.11.0 → 3.12.0

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.
Files changed (31) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-744TUGLY.js → chunk-G7GG3HEB.js} +1 -1
  6. package/dist/{chunk-JGYH3AAT.js → chunk-K44BXVHU.js} +1 -1
  7. package/dist/{chunk-Q6BRAXMP.js → chunk-LXOS3MXQ.js} +1 -1
  8. package/dist/{chunk-LHPQHOE5.js → chunk-VUBZ6A7B.js} +1 -1
  9. package/dist/{chunk-TE67QRWX.js → chunk-WVKLG26T.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/MDX/NodeGraph/Edges/AnimatedMessageEdge.tsx +13 -0
  19. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeContent.tsx +294 -0
  20. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeNodeActions.tsx +92 -0
  21. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModePlaceholder.tsx +26 -0
  22. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/utils.ts +163 -0
  23. package/eventcatalog/src/components/MDX/NodeGraph/FocusModeModal.tsx +99 -0
  24. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +20 -42
  25. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Entity.tsx +4 -1
  26. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/MessageContextMenu.tsx +4 -1
  27. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Service.tsx +4 -1
  28. package/eventcatalog/src/layouts/Footer.astro +4 -1
  29. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -1
  30. package/eventcatalog/tailwind.config.mjs +10 -0
  31. package/package.json +1 -1
@@ -37,7 +37,7 @@ var import_axios = __toESM(require("axios"), 1);
37
37
  var import_os = __toESM(require("os"), 1);
38
38
 
39
39
  // package.json
40
- var version = "3.11.0";
40
+ var version = "3.12.0";
41
41
 
42
42
  // src/constants.ts
43
43
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "../chunk-JGYH3AAT.js";
4
- import "../chunk-744TUGLY.js";
3
+ } from "../chunk-K44BXVHU.js";
4
+ import "../chunk-G7GG3HEB.js";
5
5
  export {
6
6
  raiseEvent
7
7
  };
@@ -106,7 +106,7 @@ var import_axios = __toESM(require("axios"), 1);
106
106
  var import_os = __toESM(require("os"), 1);
107
107
 
108
108
  // package.json
109
- var version = "3.11.0";
109
+ var version = "3.12.0";
110
110
 
111
111
  // src/constants.ts
112
112
  var VERSION = version;
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  log_build_default
3
- } from "../chunk-LHPQHOE5.js";
4
- import "../chunk-JGYH3AAT.js";
3
+ } from "../chunk-VUBZ6A7B.js";
4
+ import "../chunk-K44BXVHU.js";
5
5
  import "../chunk-4UVFXLPI.js";
6
- import "../chunk-744TUGLY.js";
6
+ import "../chunk-G7GG3HEB.js";
7
7
  import "../chunk-UPONRQSN.js";
8
8
  export {
9
9
  log_build_default as default
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "3.11.0";
2
+ var version = "3.12.0";
3
3
 
4
4
  // src/constants.ts
5
5
  var VERSION = version;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-744TUGLY.js";
3
+ } from "./chunk-G7GG3HEB.js";
4
4
 
5
5
  // src/analytics/analytics.js
6
6
  import axios from "axios";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  logger
3
- } from "./chunk-TE67QRWX.js";
3
+ } from "./chunk-WVKLG26T.js";
4
4
  import {
5
5
  cleanup,
6
6
  getEventCatalogConfigFile
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "./chunk-JGYH3AAT.js";
3
+ } from "./chunk-K44BXVHU.js";
4
4
  import {
5
5
  countResources,
6
6
  serializeCounts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-744TUGLY.js";
3
+ } from "./chunk-G7GG3HEB.js";
4
4
 
5
5
  // src/utils/cli-logger.ts
6
6
  import pc from "picocolors";
@@ -25,7 +25,7 @@ __export(constants_exports, {
25
25
  module.exports = __toCommonJS(constants_exports);
26
26
 
27
27
  // package.json
28
- var version = "3.11.0";
28
+ var version = "3.12.0";
29
29
 
30
30
  // src/constants.ts
31
31
  var VERSION = version;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-744TUGLY.js";
3
+ } from "./chunk-G7GG3HEB.js";
4
4
  export {
5
5
  VERSION
6
6
  };
@@ -109,7 +109,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
109
109
  var import_picocolors = __toESM(require("picocolors"), 1);
110
110
 
111
111
  // package.json
112
- var version = "3.11.0";
112
+ var version = "3.12.0";
113
113
 
114
114
  // src/constants.ts
115
115
  var VERSION = version;
@@ -6,8 +6,8 @@ import {
6
6
  } from "./chunk-PLNJC7NZ.js";
7
7
  import {
8
8
  log_build_default
9
- } from "./chunk-LHPQHOE5.js";
10
- import "./chunk-JGYH3AAT.js";
9
+ } from "./chunk-VUBZ6A7B.js";
10
+ import "./chunk-K44BXVHU.js";
11
11
  import "./chunk-4UVFXLPI.js";
12
12
  import {
13
13
  runMigrations
@@ -22,13 +22,13 @@ import {
22
22
  } from "./chunk-5VBIXL6C.js";
23
23
  import {
24
24
  generate
25
- } from "./chunk-Q6BRAXMP.js";
25
+ } from "./chunk-LXOS3MXQ.js";
26
26
  import {
27
27
  logger
28
- } from "./chunk-TE67QRWX.js";
28
+ } from "./chunk-WVKLG26T.js";
29
29
  import {
30
30
  VERSION
31
- } from "./chunk-744TUGLY.js";
31
+ } from "./chunk-G7GG3HEB.js";
32
32
  import "./chunk-UPONRQSN.js";
33
33
 
34
34
  // src/eventcatalog.ts
package/dist/generate.cjs CHANGED
@@ -73,7 +73,7 @@ var getEventCatalogConfigFile = async (projectDirectory) => {
73
73
  var import_picocolors = __toESM(require("picocolors"), 1);
74
74
 
75
75
  // package.json
76
- var version = "3.11.0";
76
+ var version = "3.12.0";
77
77
 
78
78
  // src/constants.ts
79
79
  var VERSION = version;
package/dist/generate.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  generate
3
- } from "./chunk-Q6BRAXMP.js";
4
- import "./chunk-TE67QRWX.js";
5
- import "./chunk-744TUGLY.js";
3
+ } from "./chunk-LXOS3MXQ.js";
4
+ import "./chunk-WVKLG26T.js";
5
+ import "./chunk-G7GG3HEB.js";
6
6
  import "./chunk-UPONRQSN.js";
7
7
  export {
8
8
  generate
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(cli_logger_exports);
36
36
  var import_picocolors = __toESM(require("picocolors"), 1);
37
37
 
38
38
  // package.json
39
- var version = "3.11.0";
39
+ var version = "3.12.0";
40
40
 
41
41
  // src/constants.ts
42
42
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  logger
3
- } from "../chunk-TE67QRWX.js";
4
- import "../chunk-744TUGLY.js";
3
+ } from "../chunk-WVKLG26T.js";
4
+ import "../chunk-G7GG3HEB.js";
5
5
  export {
6
6
  logger
7
7
  };
@@ -81,6 +81,19 @@ const AnimatedMessageEdge = ({
81
81
  {/* <g className={`z-30 ${opacity === 1 ? 'opacity-100' : 'opacity-10'}`}>
82
82
  </g> */}
83
83
  <g>
84
+ {/* Background rect for label */}
85
+ {label && (
86
+ <rect
87
+ x={labelX - 30}
88
+ y={labelY - lines.length * 6}
89
+ width={60}
90
+ height={lines.length * 14}
91
+ fill="white"
92
+ fillOpacity={0.3}
93
+ rx={4}
94
+ ry={4}
95
+ />
96
+ )}
84
97
  {/* Text element */}
85
98
  <text x={labelX} y={labelY} textAnchor="middle" dominantBaseline="middle" fontSize="10px" pointerEvents="none">
86
99
  {lines.map((line, i) => (
@@ -0,0 +1,294 @@
1
+ import React, { useMemo, useCallback, useEffect, useState, useRef } from 'react';
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ useReactFlow,
7
+ useNodesState,
8
+ useEdgesState,
9
+ type Node,
10
+ type Edge,
11
+ type NodeTypes,
12
+ type EdgeTypes,
13
+ } from '@xyflow/react';
14
+ import { getConnectedNodes, getNodeDisplayInfo } from './utils';
15
+ import FocusModeNodeActions from './FocusModeNodeActions';
16
+ import FocusModePlaceholder from './FocusModePlaceholder';
17
+
18
+ interface FocusModeContentProps {
19
+ centerNodeId: string;
20
+ nodes: Node[];
21
+ edges: Edge[];
22
+ nodeTypes: NodeTypes;
23
+ edgeTypes: EdgeTypes;
24
+ onSwitchCenter: (newCenterNodeId: string, direction: 'left' | 'right') => void;
25
+ }
26
+
27
+ const HORIZONTAL_SPACING = 450;
28
+ const VERTICAL_SPACING = 200;
29
+ const SLIDE_DURATION = 300;
30
+
31
+ const FocusModeContent: React.FC<FocusModeContentProps> = ({
32
+ centerNodeId,
33
+ nodes: allNodes,
34
+ edges: allEdges,
35
+ nodeTypes,
36
+ edgeTypes,
37
+ onSwitchCenter,
38
+ }) => {
39
+ const { fitView } = useReactFlow();
40
+ const [isAnimating, setIsAnimating] = useState(false);
41
+ const [needsFitView, setNeedsFitView] = useState(false);
42
+ const [hoveredEdgeId, setHoveredEdgeId] = useState<string | null>(null);
43
+ const reactFlowInitialized = useRef(false);
44
+ const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
45
+
46
+ // Cleanup timeout on unmount
47
+ useEffect(() => {
48
+ return () => {
49
+ if (animationTimeoutRef.current) {
50
+ clearTimeout(animationTimeoutRef.current);
51
+ }
52
+ };
53
+ }, []);
54
+
55
+ // Calculate focused nodes and edges with positions
56
+ const calculateFocusedGraph = useCallback(
57
+ (centerId: string) => {
58
+ const centerNode = allNodes.find((n) => n.id === centerId);
59
+ if (!centerNode) {
60
+ return { nodes: [], edges: [] };
61
+ }
62
+
63
+ const { leftNodes, rightNodes } = getConnectedNodes(centerId, allNodes, allEdges);
64
+ const centerNodeInfo = getNodeDisplayInfo(centerNode);
65
+ const positionedNodes: Node[] = [];
66
+
67
+ // Center node at origin
68
+ positionedNodes.push({
69
+ ...centerNode,
70
+ position: { x: 0, y: 0 },
71
+ style: { ...centerNode.style, opacity: 1 },
72
+ data: { ...centerNode.data, isFocusCenter: true },
73
+ });
74
+
75
+ // Left nodes (incoming)
76
+ leftNodes.forEach((node, index) => {
77
+ const yOffset = (index - (leftNodes.length - 1) / 2) * VERTICAL_SPACING;
78
+ positionedNodes.push({
79
+ ...node,
80
+ position: { x: -HORIZONTAL_SPACING, y: yOffset },
81
+ style: { ...node.style, opacity: 1 },
82
+ });
83
+ });
84
+
85
+ // Right nodes (outgoing)
86
+ rightNodes.forEach((node, index) => {
87
+ const yOffset = (index - (rightNodes.length - 1) / 2) * VERTICAL_SPACING;
88
+ positionedNodes.push({
89
+ ...node,
90
+ position: { x: HORIZONTAL_SPACING, y: yOffset },
91
+ style: { ...node.style, opacity: 1 },
92
+ });
93
+ });
94
+
95
+ // Add placeholder nodes if no connections exist
96
+ if (leftNodes.length === 0) {
97
+ positionedNodes.push({
98
+ id: '__placeholder-left__',
99
+ type: 'placeholder',
100
+ position: { x: -HORIZONTAL_SPACING, y: 0 },
101
+ data: { label: `No inputs found for "${centerNodeInfo.name}" in this diagram`, side: 'left' },
102
+ draggable: false,
103
+ selectable: false,
104
+ } as Node);
105
+ }
106
+
107
+ if (rightNodes.length === 0) {
108
+ positionedNodes.push({
109
+ id: '__placeholder-right__',
110
+ type: 'placeholder',
111
+ position: { x: HORIZONTAL_SPACING, y: 0 },
112
+ data: { label: `No outputs found for "${centerNodeInfo.name}" in this diagram`, side: 'right' },
113
+ draggable: false,
114
+ selectable: false,
115
+ } as Node);
116
+ }
117
+
118
+ // Filter edges - only show edges connected to center node
119
+ const focusedNodeIds = new Set(positionedNodes.map((n) => n.id));
120
+ const filteredEdges = allEdges
121
+ .filter((edge) => {
122
+ // Only include edges where center node is either source or target
123
+ const connectsToCenter = edge.source === centerId || edge.target === centerId;
124
+ // And the other end is in our focused nodes
125
+ const otherEndInFocus = focusedNodeIds.has(edge.source) && focusedNodeIds.has(edge.target);
126
+ return connectsToCenter && otherEndInFocus;
127
+ })
128
+ .map((edge) => ({
129
+ ...edge,
130
+ style: { ...edge.style, opacity: 1 },
131
+ labelStyle: { ...edge.labelStyle, opacity: 1 },
132
+ data: { ...edge.data, opacity: 1, animated: false },
133
+ animated: false,
134
+ }));
135
+
136
+ return { nodes: positionedNodes, edges: filteredEdges };
137
+ },
138
+ [allNodes, allEdges]
139
+ );
140
+
141
+ // Initial graph
142
+ const initialGraph = useMemo(() => calculateFocusedGraph(centerNodeId), [centerNodeId, calculateFocusedGraph]);
143
+
144
+ const [displayNodes, setDisplayNodes] = useNodesState(initialGraph.nodes);
145
+ const [displayEdges, setDisplayEdges] = useEdgesState(initialGraph.edges);
146
+
147
+ // Update when centerNodeId changes externally
148
+ useEffect(() => {
149
+ const { nodes, edges } = calculateFocusedGraph(centerNodeId);
150
+ setDisplayNodes(nodes);
151
+ setDisplayEdges(edges);
152
+ setNeedsFitView(true);
153
+ }, [centerNodeId, calculateFocusedGraph, setDisplayNodes, setDisplayEdges]);
154
+
155
+ // FitView when needed - triggered after nodes are updated
156
+ useEffect(() => {
157
+ if (needsFitView && reactFlowInitialized.current) {
158
+ // Wait for nodes to be fully rendered, then fit view with animation
159
+ const timer = setTimeout(() => {
160
+ fitView({ padding: 0.2, duration: 400 });
161
+ setNeedsFitView(false);
162
+ }, 150);
163
+ return () => clearTimeout(timer);
164
+ }
165
+ }, [needsFitView, displayNodes, fitView]);
166
+
167
+ // Handle ReactFlow initialization
168
+ const handleInit = useCallback(() => {
169
+ reactFlowInitialized.current = true;
170
+ // Wait for nodes to be fully rendered, then fit view with animation
171
+ setTimeout(() => {
172
+ fitView({ padding: 0.2, duration: 400 });
173
+ }, 150);
174
+ }, [fitView]);
175
+
176
+ // Handle switching to a new center node with animation
177
+ const handleSwitchNode = useCallback(
178
+ (nodeId: string, direction: 'left' | 'right') => {
179
+ if (nodeId === centerNodeId || isAnimating) return;
180
+
181
+ setIsAnimating(true);
182
+
183
+ // Animate: clicked node slides to center, current center hides
184
+ setDisplayNodes((currentNodes) =>
185
+ currentNodes.map((node) => {
186
+ if (node.id === nodeId) {
187
+ // Clicked node slides to center
188
+ return {
189
+ ...node,
190
+ position: { x: 0, y: 0 },
191
+ style: { ...node.style, transition: `all ${SLIDE_DURATION}ms ease-out` },
192
+ };
193
+ }
194
+ if (node.id === centerNodeId) {
195
+ // Current center node hides
196
+ return {
197
+ ...node,
198
+ style: { ...node.style, opacity: 0, transition: `opacity ${SLIDE_DURATION}ms ease-out` },
199
+ };
200
+ }
201
+ return node;
202
+ })
203
+ );
204
+
205
+ // After slide completes, switch to new center
206
+ animationTimeoutRef.current = setTimeout(() => {
207
+ onSwitchCenter(nodeId, direction);
208
+ setIsAnimating(false);
209
+ }, SLIDE_DURATION);
210
+ },
211
+ [centerNodeId, isAnimating, setDisplayNodes, onSwitchCenter]
212
+ );
213
+
214
+ // Handle node click with animation
215
+ const handleNodeClick = useCallback(
216
+ (_: React.MouseEvent, clickedNode: Node) => {
217
+ if (clickedNode.id === centerNodeId || isAnimating) return;
218
+ const direction = (clickedNode.position?.x ?? 0) < 0 ? 'left' : 'right';
219
+ handleSwitchNode(clickedNode.id, direction);
220
+ },
221
+ [centerNodeId, isAnimating, handleSwitchNode]
222
+ );
223
+
224
+ // Handle edge hover for animation
225
+ const handleEdgeMouseEnter = useCallback((_: React.MouseEvent, edge: Edge) => {
226
+ setHoveredEdgeId(edge.id);
227
+ }, []);
228
+
229
+ const handleEdgeMouseLeave = useCallback(() => {
230
+ setHoveredEdgeId(null);
231
+ }, []);
232
+
233
+ // Apply hover animation to edges - use ReactFlow's built-in animated property
234
+ const edgesWithHover = useMemo(() => {
235
+ return displayEdges.map((edge) => {
236
+ if (edge.id === hoveredEdgeId) {
237
+ return {
238
+ ...edge,
239
+ animated: true,
240
+ };
241
+ }
242
+ return edge;
243
+ });
244
+ }, [displayEdges, hoveredEdgeId]);
245
+
246
+ // Merge nodeTypes with placeholder
247
+ const mergedNodeTypes = useMemo(
248
+ () => ({
249
+ ...nodeTypes,
250
+ placeholder: FocusModePlaceholder,
251
+ }),
252
+ [nodeTypes]
253
+ );
254
+
255
+ if (displayNodes.length === 0) {
256
+ return <div className="flex items-center justify-center h-full text-[rgb(var(--ec-page-text-muted))]">Node not found</div>;
257
+ }
258
+
259
+ return (
260
+ <div className="h-full w-full focus-mode-container">
261
+ <ReactFlow
262
+ nodes={displayNodes}
263
+ edges={edgesWithHover}
264
+ nodeTypes={mergedNodeTypes}
265
+ edgeTypes={edgeTypes}
266
+ onNodeClick={handleNodeClick}
267
+ onEdgeMouseEnter={handleEdgeMouseEnter}
268
+ onEdgeMouseLeave={handleEdgeMouseLeave}
269
+ onInit={handleInit}
270
+ proOptions={{ hideAttribution: true }}
271
+ nodesDraggable={true}
272
+ nodesConnectable={false}
273
+ elementsSelectable={true}
274
+ panOnDrag={true}
275
+ zoomOnScroll={true}
276
+ minZoom={0.3}
277
+ maxZoom={2}
278
+ >
279
+ <Background color="rgb(var(--ec-page-border))" gap={20} />
280
+ <Controls showInteractive={false} />
281
+ {displayNodes.map((node, index) => (
282
+ <FocusModeNodeActions
283
+ key={`actions-${node.id}-${index}`}
284
+ node={node}
285
+ isCenter={node.id === centerNodeId}
286
+ onSwitch={handleSwitchNode}
287
+ />
288
+ ))}
289
+ </ReactFlow>
290
+ </div>
291
+ );
292
+ };
293
+
294
+ export default FocusModeContent;
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { NodeToolbar, Position, useViewport, type Node } from '@xyflow/react';
3
+ import { ArrowRightLeft, FileText } from 'lucide-react';
4
+ import { getNodeDocUrl } from './utils';
5
+ import { buildUrl } from '@utils/url-builder';
6
+
7
+ interface FocusModeNodeActionsProps {
8
+ node: Node;
9
+ isCenter: boolean;
10
+ onSwitch: (nodeId: string, direction: 'left' | 'right') => void;
11
+ }
12
+
13
+ const FocusModeNodeActions: React.FC<FocusModeNodeActionsProps> = ({ node, isCenter, onSwitch }) => {
14
+ const { zoom } = useViewport();
15
+
16
+ // Don't show actions for placeholder nodes
17
+ if (node.type === 'placeholder') return null;
18
+
19
+ const docUrl = getNodeDocUrl(node);
20
+ const direction = (node.position?.x ?? 0) < 0 ? 'left' : 'right';
21
+
22
+ // Scale sizes based on zoom (inverse relationship - smaller when zoomed out)
23
+ const baseButtonSize = 32;
24
+ const baseIconSize = 16;
25
+ const scaleFactor = Math.max(0.6, Math.min(1, zoom));
26
+ const buttonSize = Math.round(baseButtonSize * scaleFactor);
27
+ const iconSize = Math.round(baseIconSize * scaleFactor);
28
+
29
+ const handleSwitch = (e: React.MouseEvent) => {
30
+ e.stopPropagation();
31
+ onSwitch(node.id, direction);
32
+ };
33
+
34
+ const handleDocClick = (e: React.MouseEvent) => {
35
+ e.stopPropagation();
36
+ if (docUrl) {
37
+ window.location.href = buildUrl(docUrl);
38
+ }
39
+ };
40
+
41
+ // Center node only shows docs icon (if available)
42
+ if (isCenter) {
43
+ if (!docUrl) return null;
44
+ return (
45
+ <NodeToolbar nodeId={node.id} position={Position.Bottom} isVisible={true} offset={-16}>
46
+ <div
47
+ className="flex items-center gap-1 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md"
48
+ style={{ padding: Math.round(4 * scaleFactor) }}
49
+ >
50
+ <button
51
+ onClick={handleDocClick}
52
+ className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
53
+ style={{ width: buttonSize, height: buttonSize }}
54
+ title="View documentation"
55
+ >
56
+ <FileText style={{ width: iconSize, height: iconSize }} />
57
+ </button>
58
+ </div>
59
+ </NodeToolbar>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <NodeToolbar nodeId={node.id} position={Position.Bottom} isVisible={true} offset={-16}>
65
+ <div
66
+ className="flex items-center gap-1 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md"
67
+ style={{ padding: Math.round(4 * scaleFactor) }}
68
+ >
69
+ {docUrl && (
70
+ <button
71
+ onClick={handleDocClick}
72
+ className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
73
+ style={{ width: buttonSize, height: buttonSize }}
74
+ title="View documentation"
75
+ >
76
+ <FileText style={{ width: iconSize, height: iconSize }} />
77
+ </button>
78
+ )}
79
+ <button
80
+ onClick={handleSwitch}
81
+ className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
82
+ style={{ width: buttonSize, height: buttonSize }}
83
+ title="Focus on this node"
84
+ >
85
+ <ArrowRightLeft style={{ width: iconSize, height: iconSize }} />
86
+ </button>
87
+ </div>
88
+ </NodeToolbar>
89
+ );
90
+ };
91
+
92
+ export default FocusModeNodeActions;
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { Handle, Position } from '@xyflow/react';
3
+
4
+ interface FocusModePlaceholderProps {
5
+ data: {
6
+ label: string;
7
+ side: 'left' | 'right';
8
+ };
9
+ }
10
+
11
+ const FocusModePlaceholder: React.FC<FocusModePlaceholderProps> = ({ data }) => {
12
+ const { label, side } = data;
13
+
14
+ return (
15
+ <div
16
+ className="px-4 py-4 rounded-lg border-2 border-dashed border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg)/0.5)] max-w-[280px] flex items-center justify-center"
17
+ style={{ opacity: 0.6, minHeight: '130px' }}
18
+ >
19
+ {side === 'right' && <Handle type="target" position={Position.Left} style={{ visibility: 'hidden' }} />}
20
+ <div className="text-center text-sm text-[rgb(var(--ec-page-text-muted))] italic">{label}</div>
21
+ {side === 'left' && <Handle type="source" position={Position.Right} style={{ visibility: 'hidden' }} />}
22
+ </div>
23
+ );
24
+ };
25
+
26
+ export default FocusModePlaceholder;
@@ -0,0 +1,163 @@
1
+ import type { Node, Edge } from '@xyflow/react';
2
+
3
+ export interface NodeDisplayInfo {
4
+ id: string;
5
+ name: string;
6
+ type: string;
7
+ version?: string;
8
+ description?: string;
9
+ }
10
+
11
+ export interface ConnectedNodes {
12
+ leftNodes: Node[];
13
+ rightNodes: Node[];
14
+ }
15
+
16
+ export const NODE_COLOR_CLASSES: Record<string, string> = {
17
+ events: 'bg-orange-600',
18
+ services: 'bg-pink-600',
19
+ flows: 'bg-teal-600',
20
+ commands: 'bg-blue-600',
21
+ queries: 'bg-green-600',
22
+ channels: 'bg-gray-600',
23
+ externalSystem: 'bg-pink-600',
24
+ actor: 'bg-yellow-500',
25
+ step: 'bg-gray-700',
26
+ data: 'bg-blue-600',
27
+ 'data-products': 'bg-indigo-600',
28
+ domains: 'bg-yellow-600',
29
+ entities: 'bg-purple-600',
30
+ };
31
+
32
+ export const NODE_TYPE_LABELS: Record<string, string> = {
33
+ events: 'Event',
34
+ services: 'Service',
35
+ flows: 'Flow',
36
+ commands: 'Command',
37
+ queries: 'Query',
38
+ channels: 'Channel',
39
+ externalSystem: 'External System',
40
+ actor: 'Actor',
41
+ step: 'Step',
42
+ data: 'Data',
43
+ 'data-products': 'Data Product',
44
+ domains: 'Domain',
45
+ entities: 'Entity',
46
+ };
47
+
48
+ /**
49
+ * Get connected nodes for a given center node
50
+ * Left nodes: incoming edges (they send TO this node)
51
+ * Right nodes: outgoing edges (this node sends TO them)
52
+ */
53
+ export function getConnectedNodes(centerNodeId: string, nodes: Node[], edges: Edge[]): ConnectedNodes {
54
+ const leftIds = new Set<string>();
55
+ const rightIds = new Set<string>();
56
+
57
+ edges.forEach((edge) => {
58
+ if (edge.target === centerNodeId) {
59
+ leftIds.add(edge.source);
60
+ }
61
+ if (edge.source === centerNodeId) {
62
+ rightIds.add(edge.target);
63
+ }
64
+ });
65
+
66
+ return {
67
+ leftNodes: nodes.filter((n) => leftIds.has(n.id)),
68
+ rightNodes: nodes.filter((n) => rightIds.has(n.id)),
69
+ };
70
+ }
71
+
72
+ // Entity keys that follow the standard data structure pattern
73
+ const ENTITY_KEYS = ['service', 'message', 'flow', 'channel', 'domain', 'entity', 'dataProduct'] as const;
74
+
75
+ /**
76
+ * Extract display information from a ReactFlow node
77
+ */
78
+ export function getNodeDisplayInfo(node: Node): NodeDisplayInfo {
79
+ const nodeType = node.type || 'unknown';
80
+ const data = node.data as any;
81
+
82
+ // Find the entity in data using standard keys
83
+ const entityKey = ENTITY_KEYS.find((key) => data[key]);
84
+ const entity = entityKey ? data[entityKey] : null;
85
+
86
+ const name = entity?.data?.name || entity?.id || data.label || data.name || node.id;
87
+ const version = entity?.data?.version || entity?.version || data.version || '';
88
+ const description = entity?.data?.summary || entity?.data?.description || data.summary || data.description || '';
89
+
90
+ return {
91
+ id: node.id,
92
+ name,
93
+ type: nodeType,
94
+ version,
95
+ description: description ? truncateDescription(description, 100) : undefined,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Truncate description to a max length
101
+ */
102
+ function truncateDescription(text: string, maxLength: number): string {
103
+ if (text.length <= maxLength) return text;
104
+ return text.slice(0, maxLength).trim() + '...';
105
+ }
106
+
107
+ /**
108
+ * Get the color class for a node type
109
+ */
110
+ export function getNodeColorClass(nodeType: string): string {
111
+ return NODE_COLOR_CLASSES[nodeType] || 'bg-gray-500';
112
+ }
113
+
114
+ /**
115
+ * Get the display label for a node type
116
+ */
117
+ export function getNodeTypeLabel(nodeType: string): string {
118
+ return NODE_TYPE_LABELS[nodeType] || nodeType;
119
+ }
120
+
121
+ // Mapping from entity key to doc path
122
+ const DOC_PATH_MAP: Record<string, string> = {
123
+ service: 'services',
124
+ flow: 'flows',
125
+ channel: 'channels',
126
+ domain: 'domains',
127
+ entity: 'entities',
128
+ dataProduct: 'data-products',
129
+ };
130
+
131
+ /**
132
+ * Get the documentation URL for a node
133
+ */
134
+ export function getNodeDocUrl(node: Node): string | null {
135
+ const nodeType = node.type || 'unknown';
136
+ const data = node.data as any;
137
+
138
+ // Handle message type separately due to type mapping
139
+ if (data.message) {
140
+ const id = data.message.data?.id || data.message.id || '';
141
+ const version = data.message.data?.version || data.message.version || '';
142
+ const collectionType = nodeType === 'events' ? 'events' : nodeType === 'commands' ? 'commands' : 'queries';
143
+ return id && version ? `/docs/${collectionType}/${id}/${version}` : null;
144
+ }
145
+
146
+ // Handle data/container nodes with nested data.data structure
147
+ if (data.data && nodeType === 'data') {
148
+ const id = data.data.id || '';
149
+ const version = data.data.version || '';
150
+ return id && version ? `/docs/containers/${id}/${version}` : null;
151
+ }
152
+
153
+ // Handle standard entity types
154
+ for (const [key, path] of Object.entries(DOC_PATH_MAP)) {
155
+ if (data[key]) {
156
+ const id = data[key].data?.id || data[key].id || '';
157
+ const version = data[key].data?.version || data[key].version || '';
158
+ return id && version ? `/docs/${path}/${id}/${version}` : null;
159
+ }
160
+ }
161
+
162
+ return null;
163
+ }
@@ -0,0 +1,99 @@
1
+ import React, { useState, useCallback, useEffect } from 'react';
2
+ import * as Dialog from '@radix-ui/react-dialog';
3
+ import { XIcon, FocusIcon } from 'lucide-react';
4
+ import { ReactFlowProvider, type Node, type Edge, type NodeTypes, type EdgeTypes } from '@xyflow/react';
5
+ import FocusModeContent from './FocusMode/FocusModeContent';
6
+ import { getNodeDisplayInfo } from './FocusMode/utils';
7
+
8
+ interface FocusModeModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ initialNodeId: string | null;
12
+ nodes: Node[];
13
+ edges: Edge[];
14
+ nodeTypes: NodeTypes;
15
+ edgeTypes: EdgeTypes;
16
+ }
17
+
18
+ const FocusModeModal: React.FC<FocusModeModalProps> = ({
19
+ isOpen,
20
+ onClose,
21
+ initialNodeId,
22
+ nodes,
23
+ edges,
24
+ nodeTypes,
25
+ edgeTypes,
26
+ }) => {
27
+ const [centerNodeId, setCenterNodeId] = useState<string | null>(initialNodeId);
28
+
29
+ // Reset center node when modal opens with new initial node
30
+ useEffect(() => {
31
+ if (isOpen && initialNodeId) {
32
+ setCenterNodeId(initialNodeId);
33
+ }
34
+ }, [isOpen, initialNodeId]);
35
+
36
+ const handleSwitchCenter = useCallback((newCenterNodeId: string, _direction: 'left' | 'right') => {
37
+ setCenterNodeId(newCenterNodeId);
38
+ }, []);
39
+
40
+ // Get center node info for title
41
+ const centerNode = centerNodeId ? nodes.find((n) => n.id === centerNodeId) : null;
42
+ const centerNodeInfo = centerNode ? getNodeDisplayInfo(centerNode) : null;
43
+
44
+ if (!centerNodeId) {
45
+ return null;
46
+ }
47
+
48
+ return (
49
+ <Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
50
+ <Dialog.Portal container={typeof document !== 'undefined' ? document.body : undefined}>
51
+ <div className="fixed inset-0 z-[99999]" style={{ isolation: 'isolate' }}>
52
+ <Dialog.Overlay className="fixed inset-0 bg-black/70 data-[state=open]:animate-overlayShow" />
53
+ <Dialog.Content className="fixed inset-4 md:inset-8 lg:inset-12 rounded-lg bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] shadow-xl focus:outline-none data-[state=open]:animate-contentShow flex flex-col overflow-hidden">
54
+ {/* Header */}
55
+ <div className="flex items-center justify-between px-6 py-4 border-b border-[rgb(var(--ec-page-border))] flex-shrink-0">
56
+ <div className="flex items-center gap-3">
57
+ <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-[rgb(var(--ec-accent-subtle))]">
58
+ <FocusIcon className="w-5 h-5 text-[rgb(var(--ec-accent))]" />
59
+ </div>
60
+ <div>
61
+ <Dialog.Title className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Focus Mode</Dialog.Title>
62
+ <Dialog.Description className="text-sm text-[rgb(var(--ec-page-text-muted))]">
63
+ {centerNodeInfo
64
+ ? `Exploring: ${centerNodeInfo.name} - Click on connected nodes to navigate`
65
+ : 'Explore node connections'}
66
+ </Dialog.Description>
67
+ </div>
68
+ </div>
69
+ <Dialog.Close asChild>
70
+ <button
71
+ className="flex items-center justify-center w-10 h-10 rounded-lg text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover,var(--ec-page-border)/0.5))] transition-colors"
72
+ aria-label="Close"
73
+ >
74
+ <XIcon className="w-5 h-5" />
75
+ </button>
76
+ </Dialog.Close>
77
+ </div>
78
+
79
+ {/* Content */}
80
+ <div className="flex-1 overflow-hidden">
81
+ <ReactFlowProvider>
82
+ <FocusModeContent
83
+ centerNodeId={centerNodeId}
84
+ nodes={nodes}
85
+ edges={edges}
86
+ nodeTypes={nodeTypes}
87
+ edgeTypes={edgeTypes}
88
+ onSwitchCenter={handleSwitchCenter}
89
+ />
90
+ </ReactFlowProvider>
91
+ </div>
92
+ </Dialog.Content>
93
+ </div>
94
+ </Dialog.Portal>
95
+ </Dialog.Root>
96
+ );
97
+ };
98
+
99
+ export default FocusModeModal;
@@ -54,6 +54,7 @@ import { useEventCatalogVisualiser } from 'src/hooks/eventcatalog-visualizer';
54
54
  import VisualiserSearch, { type VisualiserSearchRef } from './VisualiserSearch';
55
55
  import StepWalkthrough from './StepWalkthrough';
56
56
  import StudioModal from './StudioModal';
57
+ import FocusModeModal from './FocusModeModal';
57
58
  import MermaidView from './MermaidView';
58
59
  import VisualizerDropdownContent from './VisualizerDropdownContent';
59
60
  import { convertToMermaid } from '@utils/node-graphs/export-mermaid';
@@ -165,6 +166,8 @@ const NodeGraphBuilder = ({
165
166
  const [isSavingLayout, setIsSavingLayout] = useState(false);
166
167
  const initialPositionsRef = useRef<Record<string, { x: number; y: number }>>({});
167
168
  // const [isStudioModalOpen, setIsStudioModalOpen] = useState(false);
169
+ const [focusModeOpen, setFocusModeOpen] = useState(false);
170
+ const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
168
171
 
169
172
  // Check if there are channels to determine if we need the visualizer functionality
170
173
  const hasChannels = useMemo(() => initialNodes.some((node: any) => node.type === 'channels'), [initialNodes]);
@@ -251,50 +254,16 @@ const NodeGraphBuilder = ({
251
254
  return;
252
255
  }
253
256
 
254
- resetNodesAndEdges();
257
+ // Disable focus mode for flow and entity visualizations
258
+ const isFlow = edges.some((edge: Edge) => edge.type === 'flow-edge');
259
+ const isEntityVisualizer = nodes.some((n: Node) => n.type === 'entities');
260
+ if (isFlow || isEntityVisualizer) return;
255
261
 
256
- const connectedNodeIds = new Set<string>();
257
- connectedNodeIds.add(node.id);
258
-
259
- const updatedEdges = edges.map((edge) => {
260
- if (edge.source === node.id || edge.target === node.id) {
261
- connectedNodeIds.add(edge.source);
262
- connectedNodeIds.add(edge.target);
263
- return {
264
- ...edge,
265
- data: { ...edge.data, opacity: 1, animated: animateMessages },
266
- style: { ...edge.style, opacity: 1 },
267
- labelStyle: { ...edge.labelStyle, opacity: 1 },
268
- animated: true,
269
- };
270
- }
271
- return {
272
- ...edge,
273
- data: { ...edge.data, opacity: 0.1, animated: animateMessages },
274
- style: { ...edge.style, opacity: 0.1 },
275
- labelStyle: { ...edge.labelStyle, opacity: 0.1 },
276
- animated: animateMessages,
277
- };
278
- });
279
-
280
- const updatedNodes = nodes.map((n) => {
281
- if (connectedNodeIds.has(n.id)) {
282
- return { ...n, style: { ...n.style, opacity: 1 } };
283
- }
284
- return { ...n, style: { ...n.style, opacity: 0.1 } };
285
- });
286
-
287
- setNodes(updatedNodes);
288
- setEdges(updatedEdges);
289
-
290
- // Fit the clicked node and its connected nodes into view
291
- fitView({
292
- padding: 0.2,
293
- duration: 800,
294
- nodes: updatedNodes.filter((n) => connectedNodeIds.has(n.id)),
295
- });
262
+ // Open focus mode modal
263
+ setFocusedNodeId(node.id);
264
+ setFocusModeOpen(true);
296
265
  },
297
- [nodes, edges, setNodes, setEdges, resetNodesAndEdges, fitView]
266
+ [linksToVisualiser, edges, nodes]
298
267
  );
299
268
 
300
269
  const toggleAnimateMessages = () => {
@@ -1007,6 +976,15 @@ const NodeGraphBuilder = ({
1007
976
  </ReactFlow>
1008
977
  )}
1009
978
  <StudioModal isOpen={isStudioModalOpen || false} onClose={() => setIsStudioModalOpen(false)} />
979
+ <FocusModeModal
980
+ isOpen={focusModeOpen}
981
+ onClose={() => setFocusModeOpen(false)}
982
+ initialNodeId={focusedNodeId}
983
+ nodes={nodes}
984
+ edges={edges}
985
+ nodeTypes={nodeTypes}
986
+ edgeTypes={edgeTypes}
987
+ />
1010
988
 
1011
989
  {/* Share Link Modal */}
1012
990
  {isShareModalOpen && (
@@ -137,7 +137,10 @@ export default function EntityNode({ data, sourcePosition, targetPosition }: any
137
137
  </div>
138
138
  </ContextMenu.Trigger>
139
139
  <ContextMenu.Portal>
140
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
140
+ <ContextMenu.Content
141
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
142
+ onClick={(e) => e.stopPropagation()}
143
+ >
141
144
  <ContextMenu.Item
142
145
  asChild
143
146
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -22,7 +22,10 @@ export default function MessageContextMenu(data: Data) {
22
22
  <ContextMenu.Root>
23
23
  <ContextMenu.Trigger>{children}</ContextMenu.Trigger>
24
24
  <ContextMenu.Portal>
25
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
25
+ <ContextMenu.Content
26
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
27
+ onClick={(e) => e.stopPropagation()}
28
+ >
26
29
  <ContextMenu.Item
27
30
  asChild
28
31
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -57,7 +57,10 @@ export default function ServiceNode(props: ServiceNode) {
57
57
  </div>
58
58
  </ContextMenu.Trigger>
59
59
  <ContextMenu.Portal>
60
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
60
+ <ContextMenu.Content
61
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
62
+ onClick={(e) => e.stopPropagation()}
63
+ >
61
64
  <ContextMenu.Item
62
65
  asChild
63
66
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -4,7 +4,10 @@ import { showEventCatalogBranding } from '@utils/feature';
4
4
  const { className } = Astro.props;
5
5
  ---
6
6
 
7
- <footer class={`relative py-4 space-y-8 border-t border-[rgb(var(--ec-page-border))] ${className}`}>
7
+ <footer
8
+ transition:persist="site-footer"
9
+ class={`relative py-4 space-y-8 border-t border-[rgb(var(--ec-page-border))] ${className}`}
10
+ >
8
11
  {
9
12
  showEventCatalogBranding() && (
10
13
  <div class="flex justify-between items-center py-8 text-[rgb(var(--ec-page-text-muted))] text-sm font-light">
@@ -277,7 +277,9 @@ const canPageBeEmbedded = isEmbedEnabled();
277
277
  {/* Load search data even when sidebar is hidden */}
278
278
  <SearchDataLoader />
279
279
  <main id="eventcatalog-application" class="relative">
280
- <Header />
280
+ <div transition:persist="site-header">
281
+ <Header />
282
+ </div>
281
283
  <div class="flex">
282
284
  <aside class="flex" id="eventcatalog-vertical-nav">
283
285
  <div
@@ -44,10 +44,20 @@ export default {
44
44
  '0%': { transform: 'translateX(100%)' },
45
45
  '100%': { transform: 'translateX(-100%)' },
46
46
  },
47
+ overlayShow: {
48
+ from: { opacity: '0' },
49
+ to: { opacity: '1' },
50
+ },
51
+ contentShow: {
52
+ from: { opacity: '0', transform: 'scale(0.96)' },
53
+ to: { opacity: '1', transform: 'scale(1)' },
54
+ },
47
55
  },
48
56
  animation: {
49
57
  'progress-bar': 'progress-bar 2s linear infinite',
50
58
  'progress-bar-reverse': 'progress-bar-reverse 2s linear infinite',
59
+ overlayShow: 'overlayShow 200ms ease-out',
60
+ contentShow: 'contentShow 200ms ease-out',
51
61
  },
52
62
  },
53
63
  },
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "https://github.com/event-catalog/eventcatalog.git"
7
7
  },
8
8
  "type": "module",
9
- "version": "3.11.0",
9
+ "version": "3.12.0",
10
10
  "publishConfig": {
11
11
  "access": "public"
12
12
  },