@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6

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 (86) 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 +2 -2
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
  6. package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
  7. package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
  8. package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
  9. package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
  10. package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +6 -6
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/dist/utils/license-status.cjs +1 -1
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/astro.config.mjs +2 -0
  31. package/eventcatalog/integrations/studio-server.mjs +17 -0
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  34. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  35. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  36. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  37. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  38. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  39. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  40. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  41. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  42. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  43. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  44. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  45. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  46. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  47. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  48. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  49. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  51. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  52. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  53. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  54. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  56. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  57. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  58. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  59. package/eventcatalog/src/features/studio/edges.ts +54 -0
  60. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  61. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  62. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  63. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  64. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  65. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  66. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  67. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  68. package/eventcatalog/src/features/studio/layout.ts +39 -0
  69. package/eventcatalog/src/features/studio/levels.ts +216 -0
  70. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  71. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  72. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  73. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  74. package/eventcatalog/src/features/studio/placement.ts +197 -0
  75. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  76. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  77. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  78. package/eventcatalog/src/features/studio/studio.css +89 -0
  79. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  80. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  81. package/eventcatalog/src/utils/feature.ts +4 -0
  82. package/eventcatalog/src/utils/trial.ts +1 -1
  83. package/package.json +9 -2
  84. package/eventcatalog/public/studio-bg.png +0 -0
  85. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  86. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,423 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import * as Y from 'yjs';
3
+ import { HocuspocusProvider } from '@hocuspocus/provider';
4
+ import { Awareness } from 'y-protocols/awareness';
5
+ import {
6
+ applyEdgeChanges,
7
+ applyNodeChanges,
8
+ type Connection,
9
+ type Edge,
10
+ type EdgeChange,
11
+ type Node,
12
+ type NodeChange,
13
+ type XYPosition,
14
+ } from '@xyflow/react';
15
+ import {
16
+ addNodes,
17
+ buildNode,
18
+ canvasDocumentName,
19
+ connectNodes,
20
+ deleteEdges,
21
+ deleteNodes as deleteNodesInDoc,
22
+ fitGroupToChildren,
23
+ getCanvasMaps,
24
+ moveNode,
25
+ readMeta,
26
+ resizeNode,
27
+ setMeta,
28
+ setNodeParent,
29
+ updateNodeData as updateNodeDataInDoc,
30
+ } from '../canvas-doc';
31
+ import { applyLayout } from '../agent-choreography';
32
+ import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
33
+ import { findGroupAtPoint, toRelativePosition } from '../grouping';
34
+ import type { LayoutResult } from '../layout';
35
+ import { getNodeDefinition } from '../node-types';
36
+ import { startToolSync, type ToolSync } from '../tool-sync';
37
+ import { createPresenceSender, type PresenceSender } from './presence-sender';
38
+ import { createPresenceStore, type PresenceStore } from './presence-store';
39
+
40
+ export type { Peer } from './presence-store';
41
+
42
+ /** How long the WebSocket gets to connect before a view that can sync through tool calls switches to them */
43
+ const SOCKET_TIMEOUT_MS = 4000;
44
+ /**
45
+ * After someone drops what they were dragging, how long until it goes back to where the document has it, if the
46
+ * drop doesn't arrive (they left mid-drag). The drop normally arrives first, or within a few milliseconds.
47
+ */
48
+ const DROP_SETTLE_MS = 500;
49
+
50
+ /**
51
+ * Changes made here that are already shown here (nodes dragged and resized with React Flow): the document
52
+ * observers skip them, and undo includes them
53
+ */
54
+ const LOCAL_ORIGIN = Symbol('studio-local');
55
+ /** Changes an agent working through this tab (WebMCP) makes: synced like anyone's, and not in the user's undo */
56
+ export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
57
+
58
+ export type Status = 'connecting' | 'connected' | 'disconnected';
59
+ export type Transport = 'websocket' | 'tools';
60
+
61
+ type Shared = { doc: Y.Doc; awareness: Awareness; undoManager: Y.UndoManager; sender: PresenceSender };
62
+
63
+ /**
64
+ * A React Flow design kept in a Yjs document (one map of nodes, one of edges), synced through Hocuspocus.
65
+ * Presence (who is here, their pointer and what they have selected) goes over Yjs awareness, in a store
66
+ * (see presence-store) rather than React state.
67
+ *
68
+ * Nodes being dragged go to everyone with our presence (with the pointer, about 30 times a second), and only
69
+ * where they're dropped is saved in the document: one change per drag, so the document doesn't grow with every
70
+ * step of every drag, and undo takes back the whole drag. Others show where we're dragging them as we do.
71
+ */
72
+ export function useStudioFlow({
73
+ canvasId,
74
+ socketUrl,
75
+ name,
76
+ color,
77
+ syncViaTools,
78
+ onLayout,
79
+ }: {
80
+ canvasId: string;
81
+ /** e.g. wss://catalog.example.com/_eventcatalog/studio */
82
+ socketUrl: string;
83
+ name: string;
84
+ color: string;
85
+ /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
86
+ syncViaTools?: ToolSync;
87
+ /** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
88
+ onLayout?: () => void;
89
+ }) {
90
+ const sharedRef = useRef<Shared | null>(null);
91
+ const [nodes, setNodes] = useState<Node[]>([]);
92
+ const [edges, setEdges] = useState<Edge[]>([]);
93
+ const [status, setStatus] = useState<Status>('connecting');
94
+ const [presence, setPresence] = useState<PresenceStore | null>(null);
95
+ const [clientId, setClientId] = useState<number | null>(null);
96
+ // Exposed so other shared state (e.g. comments) can live in the same document
97
+ const [doc, setDoc] = useState<Y.Doc | null>(null);
98
+ const [history, setHistory] = useState({ canUndo: false, canRedo: false });
99
+ const [title, setTitle] = useState<string | undefined>();
100
+ const [transport, setTransport] = useState<Transport>('websocket');
101
+ const user = useRef({ name, color });
102
+ user.current = { name, color };
103
+ const onLayoutRef = useRef(onLayout);
104
+ onLayoutRef.current = onLayout;
105
+
106
+ useEffect(() => {
107
+ // A new connection starts from what the server has. Clear anything left from a previous one,
108
+ // since an empty document never fires an update that would remove it.
109
+ setNodes([]);
110
+ setEdges([]);
111
+
112
+ const doc = new Y.Doc();
113
+ const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
114
+ const syncMeta = (event: Y.YMapEvent<unknown>) => {
115
+ setTitle(readMeta(doc).title);
116
+ if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
117
+ };
118
+ yMeta.observe(syncMeta);
119
+ setTitle(undefined);
120
+ // One presence for whichever connection is used
121
+ const awareness = new Awareness(doc);
122
+ awareness.setLocalStateField('user', user.current);
123
+ const presenceStore = createPresenceStore(awareness);
124
+ setPresence(presenceStore);
125
+ const sender = createPresenceSender(awareness);
126
+ let socketConnected = false;
127
+ let stopToolSync: (() => void) | undefined;
128
+ const provider = new HocuspocusProvider({
129
+ url: socketUrl,
130
+ name: canvasDocumentName(canvasId),
131
+ document: doc,
132
+ awareness,
133
+ onStatus: ({ status }) => {
134
+ if (status === 'connected') socketConnected = true;
135
+ socketStatus = status as Status;
136
+ if (!stopToolSync) setStatus(status as Status);
137
+ },
138
+ });
139
+ let socketStatus: Status = 'connecting';
140
+ setTransport('websocket');
141
+
142
+ // Back online, or back on the tab: reconnect now rather than when the next retry is due
143
+ const reconnect = () => {
144
+ if (!stopToolSync && socketStatus === 'disconnected') void provider.connect();
145
+ };
146
+ // Away from the tab: our pointer goes from everyone's canvas
147
+ const onVisibilityChange = () => {
148
+ if (document.visibilityState === 'visible') return reconnect();
149
+ sender.send({ pointer: null });
150
+ sender.flush();
151
+ };
152
+ window.addEventListener('online', reconnect);
153
+ document.addEventListener('visibilitychange', onVisibilityChange);
154
+
155
+ // Some views can't open the WebSocket (a chat's sandbox may block it): sync through tool calls instead
156
+ const fallback = syncViaTools
157
+ ? setTimeout(() => {
158
+ if (socketConnected) return;
159
+ provider.disconnect();
160
+ setTransport('tools');
161
+ stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
162
+ }, SOCKET_TIMEOUT_MS)
163
+ : undefined;
164
+ // Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
165
+ const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
166
+ captureTimeout: 500,
167
+ trackedOrigins: new Set([null, LOCAL_ORIGIN]),
168
+ });
169
+ const updateHistory = () =>
170
+ setHistory({ canUndo: undoManager.undoStack.length > 0, canRedo: undoManager.redoStack.length > 0 });
171
+ undoManager.on('stack-item-added', updateHistory);
172
+ undoManager.on('stack-item-popped', updateHistory);
173
+ undoManager.on('stack-cleared', updateHistory);
174
+ setHistory({ canUndo: false, canRedo: false });
175
+ sharedRef.current = { doc, awareness, undoManager, sender };
176
+ setClientId(doc.clientID);
177
+ setDoc(doc);
178
+
179
+ // Where others are dragging nodes, from their presence, until they drop them
180
+ let remoteMoves = new Map<string, XYPosition>();
181
+ const settling = new Set<ReturnType<typeof setTimeout>>();
182
+ const syncMoves = (_: unknown, origin: unknown) => {
183
+ if (origin === 'local') return;
184
+ const next = new Map<string, XYPosition>();
185
+ awareness.getStates().forEach((state, id) => {
186
+ if (id === doc.clientID || !state.moving) return;
187
+ Object.entries(state.moving as Record<string, XYPosition>).forEach(([nodeId, position]) => next.set(nodeId, position));
188
+ });
189
+ const moved = new Map([...next].filter(([id, { x, y }]) => remoteMoves.get(id)?.x !== x || remoteMoves.get(id)?.y !== y));
190
+ const dropped = [...remoteMoves.keys()].filter((id) => !next.has(id));
191
+ remoteMoves = next;
192
+ if (moved.size) setNodes((local) => withPositions(local, moved));
193
+ if (dropped.length === 0) return;
194
+ // The drop is saved in the document. If it doesn't arrive, back to where the document has it.
195
+ const settle = setTimeout(() => {
196
+ settling.delete(settle);
197
+ const saved = dropped.flatMap((id) => {
198
+ const node = remoteMoves.has(id) ? undefined : yNodes.get(id);
199
+ return node ? [[id, node.position] as const] : [];
200
+ });
201
+ setNodes((local) => withPositions(local, new Map(saved)));
202
+ }, DROP_SETTLE_MS);
203
+ settling.add(settle);
204
+ };
205
+ awareness.on('change', syncMoves);
206
+
207
+ // Only what changed is replaced, so React Flow re-renders just those nodes
208
+ const syncNodes = (event: Y.YMapEvent<Node>) => {
209
+ if (event.transaction.origin === LOCAL_ORIGIN) return;
210
+ // A node someone was dragging changed in the document: they dropped it there
211
+ event.keysChanged.forEach((id) => remoteMoves.delete(id));
212
+ setNodes((local) =>
213
+ keepDragged(
214
+ patchShared(local, event.keysChanged, (id) => yNodes.get(id), LOCAL_NODE_KEYS),
215
+ local
216
+ )
217
+ );
218
+ };
219
+ const syncEdges = (event: Y.YMapEvent<Edge>) => {
220
+ if (event.transaction.origin === LOCAL_ORIGIN) return;
221
+ setEdges((local) => patchShared(local, event.keysChanged, (id) => yEdges.get(id), LOCAL_EDGE_KEYS));
222
+ };
223
+ yNodes.observe(syncNodes);
224
+ yEdges.observe(syncEdges);
225
+
226
+ return () => {
227
+ clearTimeout(fallback);
228
+ settling.forEach(clearTimeout);
229
+ window.removeEventListener('online', reconnect);
230
+ document.removeEventListener('visibilitychange', onVisibilityChange);
231
+ awareness.off('change', syncMoves);
232
+ sender.destroy();
233
+ stopToolSync?.();
234
+ yNodes.unobserve(syncNodes);
235
+ yEdges.unobserve(syncEdges);
236
+ yMeta.unobserve(syncMeta);
237
+ presenceStore.destroy();
238
+ undoManager.destroy();
239
+ provider.destroy();
240
+ doc.destroy();
241
+ sharedRef.current = null;
242
+ setDoc(null);
243
+ setPresence(null);
244
+ };
245
+ }, [canvasId, socketUrl, syncViaTools]);
246
+
247
+ useEffect(() => {
248
+ sharedRef.current?.awareness.setLocalStateField('user', { name, color });
249
+ }, [name, color]);
250
+
251
+ // Tell others what we have selected so they can see it
252
+ const selectionKey = useMemo(
253
+ () =>
254
+ nodes
255
+ .filter((node) => node.selected)
256
+ .map((node) => node.id)
257
+ .join(','),
258
+ [nodes]
259
+ );
260
+ useEffect(() => {
261
+ sharedRef.current?.sender.send({ selection: selectionKey ? selectionKey.split(',') : [] });
262
+ }, [selectionKey]);
263
+
264
+ const withDoc = <T>(fn: (doc: Y.Doc) => T, origin: unknown = null) => {
265
+ const doc = sharedRef.current?.doc;
266
+ return doc ? doc.transact(() => fn(doc), origin) : undefined;
267
+ };
268
+
269
+ // Where the nodes being dragged are, until they're dropped
270
+ const dragged = useRef(new Map<string, XYPosition>());
271
+ /** Saves where dragged nodes were dropped (in the transaction it's called in), and stops showing them as dragged */
272
+ const saveDrop = (doc: Y.Doc) => {
273
+ dragged.current.forEach((position, id) => moveNode(doc, id, position));
274
+ dragged.current.clear();
275
+ const shared = sharedRef.current;
276
+ shared?.sender.send({ moving: null });
277
+ // After the drop is in the document (it's sent as the transaction ends)
278
+ queueMicrotask(() => shared?.sender.flush());
279
+ };
280
+
281
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
282
+ setNodes((local) => applyNodeChanges(changes, local));
283
+ let dropped = false;
284
+ const resized: { id: string; dimensions: { width: number; height: number } }[] = [];
285
+ for (const change of changes) {
286
+ if (change.type === 'position' && change.position) {
287
+ dragged.current.set(change.id, change.position);
288
+ if (!change.dragging) dropped = true;
289
+ }
290
+ // Containers resized with their handles
291
+ if (change.type === 'dimensions' && change.setAttributes && change.dimensions)
292
+ resized.push({ id: change.id, dimensions: change.dimensions });
293
+ }
294
+ // While dragging, nodes move here every frame and go to everyone with our presence; where they're dropped is saved
295
+ if (dropped) {
296
+ // Each drag is its own step to undo
297
+ sharedRef.current?.undoManager.stopCapturing();
298
+ withDoc(saveDrop, LOCAL_ORIGIN);
299
+ } else if (dragged.current.size) sharedRef.current?.sender.send({ moving: Object.fromEntries(dragged.current) });
300
+ if (resized.length) withDoc((doc) => resized.forEach(({ id, dimensions }) => resizeNode(doc, id, dimensions)), LOCAL_ORIGIN);
301
+ // Removing a node also removes what's in it, its edges, and moves its comments: those come back through the observers
302
+ const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
303
+ if (removed.length) withDoc((doc) => deleteNodesInDoc(doc, removed));
304
+ }, []);
305
+
306
+ const onEdgesChange = useCallback((changes: EdgeChange[]) => {
307
+ setEdges((local) => applyEdgeChanges(changes, local));
308
+ const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
309
+ if (removed.length) withDoc((doc) => deleteEdges(doc, removed));
310
+ }, []);
311
+
312
+ const onConnect = useCallback((connection: Connection) => {
313
+ withDoc((doc) => connectNodes(doc, connection));
314
+ }, []);
315
+
316
+ /**
317
+ * Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
318
+ * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
319
+ */
320
+ const insertNode = useCallback(
321
+ (type: string, data: Record<string, unknown>, center: XYPosition, connect?: (id: string, existing: Node[]) => Edge[]) => {
322
+ const built = buildNode(type, data, center);
323
+ withDoc((doc) => {
324
+ const existing = Array.from(getCanvasMaps(doc).nodes.values());
325
+ // Dropped on a container: it goes in it
326
+ const group = findGroupAtPoint(center, existing);
327
+ const node = group
328
+ ? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
329
+ : built;
330
+ addNodes(doc, [node], connect?.(node.id, existing) ?? []);
331
+ if (group) fitGroupToChildren(doc, group.id);
332
+ });
333
+ return built.id;
334
+ },
335
+ []
336
+ );
337
+
338
+ /** Add a node of a registry type, centred on a canvas position */
339
+ const addNode = useCallback(
340
+ (type: string, center: XYPosition) => {
341
+ const definition = getNodeDefinition(type);
342
+ return definition ? insertNode(type, definition.createData(), center) : undefined;
343
+ },
344
+ [insertNode]
345
+ );
346
+
347
+ const updateNodeData = useCallback(
348
+ (id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) =>
349
+ withDoc((doc) => updateNodeDataInDoc(doc, id, updater)),
350
+ []
351
+ );
352
+
353
+ const deleteNodes = useCallback((ids: string[]) => withDoc((doc) => deleteNodesInDoc(doc, ids)), []);
354
+
355
+ /** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
356
+ const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
357
+
358
+ /** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
359
+ const insertNodes = useCallback(
360
+ (nodes: Node[], edges: Edge[] = []) =>
361
+ withDoc((doc) => {
362
+ addNodes(doc, nodes, edges);
363
+ // Containers already on the canvas that got something new inside grow to fit it
364
+ const added = new Set(nodes.map((node) => node.id));
365
+ new Set(nodes.flatMap((node) => (node.parentId && !added.has(node.parentId) ? [node.parentId] : []))).forEach((groupId) =>
366
+ fitGroupToChildren(doc, groupId)
367
+ );
368
+ }),
369
+ []
370
+ );
371
+
372
+ /** Puts a node in a container (or takes it out), e.g. after it's dragged there */
373
+ const setParent = useCallback(
374
+ (nodeId: string, parentId: string | undefined) =>
375
+ withDoc((doc) => {
376
+ // Where it was dropped goes first, so it's moved into (or out of) the container from there
377
+ if (dragged.current.size) saveDrop(doc);
378
+ return setNodeParent(doc, nodeId, parentId);
379
+ }),
380
+ []
381
+ );
382
+
383
+ const rename = useCallback((next: string) => withDoc((doc) => setMeta(doc, { title: next || undefined })), []);
384
+
385
+ const undo = useCallback(() => sharedRef.current?.undoManager.undo(), []);
386
+ const redo = useCallback(() => sharedRef.current?.undoManager.redo(), []);
387
+
388
+ /** Our pointer on the canvas, for everyone (sent about 30 times a second, so call it on every move) */
389
+ const setPointer = useCallback((pointer: XYPosition | null) => {
390
+ sharedRef.current?.sender.send({ pointer });
391
+ }, []);
392
+
393
+ const setViewport = useCallback((viewport: { x: number; y: number; zoom: number }) => {
394
+ sharedRef.current?.sender.send({ viewport });
395
+ }, []);
396
+
397
+ return {
398
+ nodes,
399
+ edges,
400
+ status,
401
+ presence,
402
+ clientId,
403
+ doc,
404
+ onNodesChange,
405
+ onEdgesChange,
406
+ onConnect,
407
+ addNode,
408
+ insertNode,
409
+ updateNodeData,
410
+ deleteNodes,
411
+ applyPositions,
412
+ insertNodes,
413
+ setParent,
414
+ title,
415
+ rename,
416
+ transport,
417
+ undo,
418
+ redo,
419
+ ...history,
420
+ setPointer,
421
+ setViewport,
422
+ };
423
+ }
@@ -0,0 +1,39 @@
1
+ import type { Edge, Node, XYPosition } from '@xyflow/react';
2
+ import { layoutWithElk } from '@eventcatalog/visualiser/layout';
3
+ import { isGroupType } from './node-types';
4
+
5
+ /**
6
+ * Lays a canvas out with the same ELK layout as EventCatalog's visualiser (left to right, edges routed
7
+ * around nodes, containers laid out with what's in them and sized to fit). Nodes are sized as rendered when
8
+ * they have been (in the browser), or by their type.
9
+ */
10
+ export const layoutGraph = (nodes: Node[], edges: Edge[]) => layoutWithElk({ nodes, edges });
11
+
12
+ /** The layout sizes containers with style.width/height; the canvas sizes them with width/height */
13
+ export const withGroupSizes = (nodes: Node[]) =>
14
+ nodes.map((node) => {
15
+ const { width, height, ...style } = node.style ?? {};
16
+ if (typeof width !== 'number' || typeof height !== 'number') return node;
17
+ return { ...node, width, height, style };
18
+ });
19
+
20
+ export type LayoutResult = Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
21
+
22
+ /**
23
+ * New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
24
+ * containers. Only these are kept for the canvas people edit: the edge routes ELK draws would be out of date
25
+ * as soon as someone moves a node.
26
+ */
27
+ export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
28
+ if (nodes.length === 0) return new Map();
29
+ const laidOut = withGroupSizes((await layoutGraph(nodes, edges)).nodes);
30
+ return new Map(
31
+ laidOut.map((node) => [
32
+ node.id,
33
+ {
34
+ position: node.position,
35
+ ...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
36
+ },
37
+ ])
38
+ );
39
+ };
@@ -0,0 +1,216 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import { getMessagesLabel } from '@eventcatalog/visualiser/layout';
3
+ import { EDGE_MARKER, MESSAGE_COLLECTIONS } from './edges';
4
+ import { GROUP_TYPES, isGroupType } from './node-types';
5
+
6
+ /**
7
+ * Levels of detail, the same as EventCatalog's diagrams:
8
+ * - L1: domains, systems and how they relate (services folded into the system or domain that contains them)
9
+ * - L2: services and data stores, with messages and channels hidden (the nodes either side connected directly)
10
+ * - L3: everything, including messages and channels (the canvas people edit)
11
+ *
12
+ * What a domain or system contains is what sits inside it (as a container), or what it's connected to with
13
+ * "contains" (as a card, which catalog domains and systems get when their services are on the canvas).
14
+ */
15
+ export type Level = 1 | 2 | 3;
16
+
17
+ export const LEVELS: { level: Level; description: string }[] = [
18
+ { level: 1, description: 'Domains, systems and their relationships' },
19
+ { level: 2, description: 'Services and data stores' },
20
+ { level: 3, description: 'Everything, including messages and channels' },
21
+ ];
22
+
23
+ // Domains and systems: as cards (what they contain is connected with "contains") or containers (it's inside)
24
+ const CONTAINER_TYPES = new Set(['system', 'context-domain', GROUP_TYPES.system, GROUP_TYPES.domain]);
25
+ const isSystem = (node: Node) => node.type === 'system' || node.type === GROUP_TYPES.system;
26
+ const COLLAPSED_GROUP_SIZE = { width: 280, height: 110 };
27
+
28
+ const isMessage = (node: Node) => !!MESSAGE_COLLECTIONS[node.type ?? ''];
29
+ const isCarrier = (node: Node) => isMessage(node) || node.type === 'channel';
30
+ const isContainer = (node: Node) => CONTAINER_TYPES.has(node.type ?? '');
31
+ const messageName = (node: Node) => String((node.data as { message?: { name?: string } }).message?.name ?? node.id);
32
+
33
+ /** What a node becomes at a level: kept (as itself or another node), carried through (messages, channels) or dropped */
34
+ type Representative = { kind: 'node'; id: string } | { kind: 'carrier' } | { kind: 'drop' };
35
+
36
+ /**
37
+ * Collapse a graph: each node is kept, replaced by another node (e.g. a service by its system),
38
+ * carried through (messages and channels: the nodes either side get connected directly) or dropped.
39
+ * Edges between the same two nodes are merged, labelled with the messages they carry.
40
+ */
41
+ const collapseGraph = (nodes: Node[], edges: Edge[], representativeOf: (node: Node) => Representative) => {
42
+ const byId = new Map(nodes.map((node) => [node.id, node]));
43
+ // Whether one node is inside another (e.g. a system in its domain): they're not connected, one holds the other
44
+ const isInside = (id: string, containerId: string) => {
45
+ for (let node = byId.get(id); node?.parentId; node = byId.get(node.parentId)) if (node.parentId === containerId) return true;
46
+ return false;
47
+ };
48
+ const representatives = new Map(nodes.map((node) => [node.id, representativeOf(node)]));
49
+ const outgoing = new Map<string, Edge[]>();
50
+ edges.forEach((edge) => outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]));
51
+
52
+ type Merged = { source: string; target: string; messages: Map<string, string>; labels: string[] };
53
+ const merged = new Map<string, Merged>();
54
+ const addMessage = (messages: Map<string, string>, node: Node) => {
55
+ if (isMessage(node)) messages.set(messageName(node), MESSAGE_COLLECTIONS[node.type!]);
56
+ };
57
+
58
+ edges.forEach((edge) => {
59
+ const from = representatives.get(edge.source);
60
+ if (from?.kind !== 'node') return;
61
+ // Follow the edge through carried nodes to the node(s) it reaches, collecting the messages on the way
62
+ const stack = [{ id: edge.target, messages: new Map<string, string>(), labels: [String(edge.label ?? '')] }];
63
+ const visited = new Set<string>();
64
+ addMessage(stack[0].messages, byId.get(edge.source)!);
65
+ while (stack.length) {
66
+ const current = stack.pop()!;
67
+ if (visited.has(current.id)) continue;
68
+ visited.add(current.id);
69
+ const node = byId.get(current.id);
70
+ const to = representatives.get(current.id);
71
+ if (!node || !to || to.kind === 'drop') continue;
72
+ const messages = new Map(current.messages);
73
+ addMessage(messages, node);
74
+ if (to.kind === 'carrier') {
75
+ (outgoing.get(current.id) ?? []).forEach((next) => stack.push({ id: next.target, messages, labels: [] as string[] }));
76
+ continue;
77
+ }
78
+ // Edges inside what was collapsed disappear, and so do edges to what a node is in (e.g. a system and the
79
+ // messages left loose in its domain, which fold into the domain)
80
+ if (from.id === to.id || isInside(from.id, to.id) || isInside(to.id, from.id)) continue;
81
+ const key = `${from.id}->${to.id}`;
82
+ const entry: Merged = merged.get(key) ?? { source: from.id, target: to.id, messages: new Map(), labels: [] };
83
+ messages.forEach((collection, name) => entry.messages.set(name, collection));
84
+ entry.labels.push(...current.labels.filter(Boolean));
85
+ merged.set(key, entry);
86
+ }
87
+ });
88
+
89
+ const keptIds = new Set([...representatives.values()].flatMap((rep) => (rep.kind === 'node' ? [rep.id] : [])));
90
+ return {
91
+ nodes: nodes.filter((node) => keptIds.has(node.id)),
92
+ edges: [...merged.values()].map(({ source, target, messages, labels }): Edge => {
93
+ const collections = new Set(messages.values());
94
+ const collection = collections.size === 1 ? [...collections][0] : undefined;
95
+ return {
96
+ id: `level-${source}-${target}`,
97
+ source,
98
+ target,
99
+ type: collection ? 'animated' : 'smoothstep',
100
+ label: getMessagesLabel(messages) ?? [...new Set(labels)].join(', '),
101
+ markerEnd: EDGE_MARKER,
102
+ data: collection ? { message: { collection } } : {},
103
+ };
104
+ }),
105
+ };
106
+ };
107
+
108
+ /**
109
+ * The system and domain each node is in: the containers it sits inside, or the domain and system cards
110
+ * connected to it with "contains"
111
+ */
112
+ const getContainers = (nodes: Node[], edges: Edge[]) => {
113
+ const byId = new Map(nodes.map((node) => [node.id, node]));
114
+ const containers = new Map<string, { system?: string; domain?: string }>();
115
+ const add = (nodeId: string, container: Node) => {
116
+ const entry = containers.get(nodeId) ?? {};
117
+ if (isSystem(container)) entry.system ??= container.id;
118
+ else entry.domain ??= container.id;
119
+ containers.set(nodeId, entry);
120
+ };
121
+ for (const node of nodes) {
122
+ for (
123
+ let parent = node.parentId ? byId.get(node.parentId) : undefined;
124
+ parent;
125
+ parent = parent.parentId ? byId.get(parent.parentId) : undefined
126
+ ) {
127
+ if (isContainer(parent)) add(node.id, parent);
128
+ }
129
+ }
130
+ for (const edge of edges) {
131
+ const container = byId.get(edge.source);
132
+ if (container && isContainer(container) && edge.label === 'contains') add(edge.target, container);
133
+ }
134
+ return containers;
135
+ };
136
+
137
+ /**
138
+ * Containers left with nothing inside them at a level become compact boxes (same id, so switching levels
139
+ * grows and shrinks them); containers with something inside are sized to fit by the layout
140
+ */
141
+ const compactEmptyGroups = ({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) => {
142
+ const parents = new Set(nodes.map((node) => node.parentId).filter(Boolean));
143
+ return {
144
+ edges,
145
+ nodes: nodes.map((node) => {
146
+ if (!isGroupType(node.type)) return node;
147
+ if (parents.has(node.id)) return { ...node, width: undefined, height: undefined, measured: undefined };
148
+ return { ...node, ...COLLAPSED_GROUP_SIZE, measured: undefined };
149
+ }),
150
+ };
151
+ };
152
+
153
+ /** L2: messages and channels hidden, the nodes either side connected directly */
154
+ const level2 = (nodes: Node[], edges: Edge[]) =>
155
+ collapseGraph(nodes, edges, (node) => (isCarrier(node) ? { kind: 'carrier' } : { kind: 'node', id: node.id }));
156
+
157
+ /**
158
+ * L1 as EventCatalog's diagrams draw it: systems as cards (with how many services they hold), connected by plain
159
+ * edges labelled with the messages between them
160
+ */
161
+ const asSystemsLevel = (
162
+ { nodes, edges }: { nodes: Node[]; edges: Edge[] },
163
+ { all, containers }: { all: Node[]; containers: ReturnType<typeof getContainers> }
164
+ ) => {
165
+ const servicesIn = new Map<string, number>();
166
+ for (const node of all) {
167
+ const system = node.type === 'service' ? containers.get(node.id)?.system : undefined;
168
+ if (system) servicesIn.set(system, (servicesIn.get(system) ?? 0) + 1);
169
+ }
170
+ return {
171
+ nodes: nodes.map((node): Node => {
172
+ if (node.type !== GROUP_TYPES.system) return node;
173
+ const { id, parentId, position, data } = node;
174
+ return {
175
+ id,
176
+ type: 'system',
177
+ position,
178
+ ...(parentId && { parentId }),
179
+ data: { mode: 'full', ...data, servicesCount: servicesIn.get(id) ?? 0 },
180
+ };
181
+ }),
182
+ edges: edges.map(({ type: _, data: __, ...edge }): Edge => ({ ...edge, type: 'default' })),
183
+ };
184
+ };
185
+
186
+ /** L1: services folded into their system (or domain); actors, external systems and loose services stay */
187
+ const level1 = (nodes: Node[], edges: Edge[]) => {
188
+ const containers = getContainers(nodes, edges);
189
+ const collapsed = collapseGraph(nodes, edges, (node) => {
190
+ const container = containers.get(node.id);
191
+ // Systems stay (inside their domain's container, if they're in one); everything else folds into its system or domain
192
+ if (isContainer(node) && (isSystem(node) || !container?.system)) return { kind: 'node', id: node.id };
193
+ if (container?.system) return { kind: 'node', id: container.system };
194
+ // Messages and channels connect the systems either side, even when they sit in a domain rather than a system
195
+ if (isCarrier(node)) return { kind: 'carrier' };
196
+ if (container?.domain) return { kind: 'node', id: container.domain };
197
+ if (node.type === 'data' || node.type === 'note') return { kind: 'drop' };
198
+ return { kind: 'node', id: node.id };
199
+ });
200
+ return asSystemsLevel(collapsed, { all: nodes, containers });
201
+ };
202
+
203
+ /** The graph for a level (not laid out: positions come from the layout) */
204
+ export const getLevelGraph = (nodes: Node[], edges: Edge[], level: Level) => {
205
+ if (level === 1) return compactEmptyGroups(level1(nodes, edges));
206
+ if (level === 2) return compactEmptyGroups(level2(nodes, edges));
207
+ return { nodes, edges };
208
+ };
209
+
210
+ /** Why a level can't be shown for this canvas, if it can't */
211
+ export const getLevelUnavailableReason = (nodes: Node[], level: Level): string | undefined => {
212
+ if (level === 1 && !nodes.some(isContainer))
213
+ return 'Not available: add a domain or system from the catalog to see how they relate';
214
+ if (level === 2 && !nodes.some(isCarrier)) return 'Not available: there are no messages or channels to hide';
215
+ return undefined;
216
+ };