@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.12

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +3 -3
  6. package/dist/{chunk-76DAAJLV.js → chunk-BWC4YKW3.js} +1 -1
  7. package/dist/{chunk-4YFM63DS.js → chunk-CX2VB7AI.js} +1 -1
  8. package/dist/{chunk-MFEMKQZ6.js → chunk-FY6MSP6J.js} +1 -1
  9. package/dist/{chunk-LQZ7IG7B.js → chunk-QB7X34OD.js} +1 -1
  10. package/dist/{chunk-WM4QYMIA.js → chunk-TSY5CC27.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +5 -5
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  20. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
  21. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  22. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  23. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  28. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  29. package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
  30. package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
  31. package/eventcatalog/src/features/studio/catalog.ts +15 -10
  32. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
  33. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
  34. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
  35. package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
  36. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
  37. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  38. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
  39. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  40. package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
  41. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
  42. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
  43. package/eventcatalog/src/features/studio/flow-state.ts +29 -0
  44. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  45. package/eventcatalog/src/features/studio/grouping.ts +4 -0
  46. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  47. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
  48. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  49. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
  50. package/eventcatalog/src/features/studio/layout.ts +29 -15
  51. package/eventcatalog/src/features/studio/level-layout.ts +145 -0
  52. package/eventcatalog/src/features/studio/levels.ts +82 -27
  53. package/eventcatalog/src/features/studio/limits.ts +2 -0
  54. package/eventcatalog/src/features/studio/node-types.ts +44 -4
  55. package/eventcatalog/src/features/studio/pages/index.astro +2 -1
  56. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  57. package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
  58. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  59. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  60. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,145 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import { getNodeSize as getLayoutSize } from '@eventcatalog/visualiser/layout';
3
+ import type { LevelPlace } from './canvas-doc';
4
+ import { fitGroup, getAbsolutePosition } from './grouping';
5
+ import { getNodeSize, isGroupType } from './node-types';
6
+ import { placeNodes } from './placement';
7
+
8
+ /**
9
+ * L1 and L2 as people arrange them. A level's content comes from the canvas (see levels.ts), but where things are on
10
+ * it is its own: each level keeps a layout (a LevelPlace per node, in the canvas), so things stay where they're put.
11
+ * Nothing on a level moves when something's added: new things are placed next to what they connect to (like agents
12
+ * place what they add) and containers grow to fit.
13
+ */
14
+
15
+ type Graph = { nodes: Node[]; edges: Edge[] };
16
+
17
+ /** A container's size: as it's being worked on (width and height), or as laid out (levels size containers with style) */
18
+ const containerSize = (node: Node) => {
19
+ if (node.width && node.height) return { width: node.width, height: node.height };
20
+ const { width, height } = node.style ?? {};
21
+ return typeof width === 'number' && typeof height === 'number' ? { width, height } : undefined;
22
+ };
23
+
24
+ /** The size the visualiser's layout gives a container with nothing in it (levels leave containers' size to it) */
25
+ const emptyContainerSize = ({ type }: Pick<Node, 'type'>) => getLayoutSize({ id: '', type, position: { x: 0, y: 0 }, data: {} });
26
+
27
+ /**
28
+ * The size something new of a type is shown at on L1 or L2: L1 shows domains and systems with nothing in them as
29
+ * cards, L2 as empty containers
30
+ */
31
+ export const getLevelNodeSize = (type: string | undefined, level: 1 | 2) => {
32
+ if (!isGroupType(type)) return getNodeSize(type);
33
+ return level === 1 ? getNodeSize() : emptyContainerSize({ type });
34
+ };
35
+
36
+ /** Where a node is on a level, and a container's size */
37
+ const placeOf = (node: Node): LevelPlace => ({
38
+ x: node.position.x,
39
+ y: node.position.y,
40
+ ...(isGroupType(node.type) && containerSize(node)),
41
+ });
42
+
43
+ /** A level's nodes where they are, by id (not its notes, which are kept on the canvas): what its layout keeps */
44
+ export const levelPlaces = (nodes: Node[]) =>
45
+ new Map(nodes.filter((node) => node.type !== 'note').map((node) => [node.id, placeOf(node)]));
46
+
47
+ /**
48
+ * A level arranged: each node where the level's layout has it, else where the diagram the canvas was opened from had
49
+ * it (`fallback`), and anything with neither placed next to what it connects to. Containers grow to fit what's in
50
+ * them. `placed` is where things the layout didn't have were put, and containers that grew (to keep, so they stay
51
+ * there). Undefined when nothing on the level has a place yet: it's laid out from scratch then.
52
+ */
53
+ export const arrangeLevel = (
54
+ graph: Graph,
55
+ layout: ReadonlyMap<string, LevelPlace>,
56
+ fallback: ReadonlyMap<string, LevelPlace> = new Map()
57
+ ): { graph: Graph; placed: Map<string, LevelPlace> } | undefined => {
58
+ const known = (id: string) => layout.get(id) ?? fallback.get(id);
59
+ if (!graph.nodes.some((node) => known(node.id))) return undefined;
60
+
61
+ const placed = new Map<string, LevelPlace>();
62
+ // Containers carry their size as width and height while they're worked on (what placement and fitting read)
63
+ let nodes: Node[] = graph.nodes.map((node) => {
64
+ const place = known(node.id);
65
+ const size = isGroupType(node.type)
66
+ ? place?.width && place.height
67
+ ? { width: place.width, height: place.height }
68
+ : (containerSize(node) ?? emptyContainerSize(node))
69
+ : undefined;
70
+ return { ...node, position: place ? { x: place.x, y: place.y } : { x: 0, y: 0 }, ...size };
71
+ });
72
+
73
+ // Anything new, next to what it connects to (on the level), moving nothing that's there
74
+ const toPlace = nodes.filter((node) => !known(node.id)).map((node) => node.id);
75
+ if (toPlace.length) {
76
+ const positions = placeNodes(nodes, toPlace, graph.edges);
77
+ const lookup = new Map(nodes.map((node) => [node.id, node]));
78
+ nodes = nodes.map((node) => {
79
+ const at = positions.get(node.id);
80
+ if (!at) return node;
81
+ const parent = node.parentId ? lookup.get(node.parentId) : undefined;
82
+ const origin = parent ? getAbsolutePosition(parent, lookup) : { x: 0, y: 0 };
83
+ const moved = { ...node, position: { x: at.x - origin.x, y: at.y - origin.y } };
84
+ placed.set(node.id, placeOf(moved));
85
+ return moved;
86
+ });
87
+ }
88
+
89
+ // Containers grow to fit what's in them (the innermost first), moving nothing on the level
90
+ const byId = new Map(nodes.map((node) => [node.id, node]));
91
+ const depth = (node: Node): number => {
92
+ const parent = node.parentId ? byId.get(node.parentId) : undefined;
93
+ return parent ? 1 + depth(parent) : 0;
94
+ };
95
+ const containers = nodes.filter((node) => isGroupType(node.type)).sort((a, b) => depth(b) - depth(a));
96
+ for (const { id } of containers) {
97
+ const fit = fitGroup(id, nodes);
98
+ const current = nodes.find((node) => node.id === id)!;
99
+ const { x: dx, y: dy } = fit?.shift ?? { x: 0, y: 0 };
100
+ if (!fit || (!dx && !dy && fit.width === current.width && fit.height === current.height)) continue;
101
+ nodes = nodes.map((node) => {
102
+ if (node.parentId === id && (dx || dy)) {
103
+ const shifted = { ...node, position: { x: node.position.x + dx, y: node.position.y + dy } };
104
+ placed.set(node.id, placeOf(shifted));
105
+ return shifted;
106
+ }
107
+ if (node.id !== id) return node;
108
+ const grown = {
109
+ ...node,
110
+ position: { x: node.position.x - dx, y: node.position.y - dy },
111
+ width: fit.width,
112
+ height: fit.height,
113
+ };
114
+ placed.set(id, placeOf(grown));
115
+ return grown;
116
+ });
117
+ }
118
+
119
+ return {
120
+ graph: {
121
+ edges: graph.edges,
122
+ // Containers drawn at their size with style, like levels laid out from scratch
123
+ nodes: nodes.map(({ width, height, ...node }) =>
124
+ isGroupType(node.type) && width && height ? { ...node, style: { ...node.style, width, height } } : node
125
+ ),
126
+ },
127
+ placed,
128
+ };
129
+ };
130
+
131
+ /**
132
+ * A level's edges drawn along the routes the diagram the canvas was opened from had for the same connections (between
133
+ * the same two nodes), once the level's worked out from the canvas: routes follow their nodes when they move, so
134
+ * connections the diagram had stay drawn around what's in their way
135
+ */
136
+ export const withDiagramRoutes = (edges: Edge[], diagramEdges: Edge[] = []) => {
137
+ const routes = new Map(
138
+ diagramEdges.flatMap((edge) => (edge.data?.route ? [[`${edge.source}>${edge.target}`, edge.data.route] as const] : []))
139
+ );
140
+ if (routes.size === 0) return edges;
141
+ return edges.map((edge) => {
142
+ const route = routes.get(`${edge.source}>${edge.target}`);
143
+ return route && !edge.data?.route ? { ...edge, data: { ...edge.data, route } } : edge;
144
+ });
145
+ };
@@ -31,7 +31,8 @@ const isContainer = (node: Node) => CONTAINER_TYPES.has(node.type ?? '');
31
31
  const messageName = (node: Node) => String((node.data as { message?: { name?: string } }).message?.name ?? node.id);
32
32
 
33
33
  // Edges as the visualiser's levels draw them: what's folded together is joined by a plain line, and what was joined
34
- // through hidden messages or channels by a dashed, muted one ("bridged"), each with the visualiser's arrow
34
+ // through hidden messages or channels by a dashed, muted one ("bridged"), each with the visualiser's arrow. On L1
35
+ // the visualiser folds messages into the domain or system they're in, so edges to and from them are plain.
35
36
  const EDGE_STROKE = 'var(--ec-edge-stroke, #6b7280)';
36
37
  const MUTED = 'rgb(var(--ec-page-text-muted))';
37
38
  const FOLDED_EDGE = {
@@ -53,12 +54,25 @@ type Representative = { kind: 'node'; id: string } | { kind: 'carrier' } | { kin
53
54
  * (messages and channels: the nodes either side get connected directly, "bridged") or dropped. Edges between two
54
55
  * nodes that are kept as they are stay as they were (`keepDirect`, for L2); the rest are merged per pair and kind
55
56
  * (folded or bridged), labelled with the messages they carry.
57
+ *
58
+ * With `relationships` (L1), edges drawn straight between two nodes that are kept as they are (e.g. one system to
59
+ * another) are relationships, like the visualiser's context relationships: labelled as they were, and shown
60
+ * instead of the messages between the same two nodes (either way).
56
61
  */
57
62
  const collapseGraph = (
58
63
  nodes: Node[],
59
64
  edges: Edge[],
60
65
  representativeOf: (node: Node) => Representative,
61
- { keepDirect = false }: { keepDirect?: boolean } = {}
66
+ {
67
+ keepDirect = false,
68
+ relationships = false,
69
+ foldsMessagesInto,
70
+ }: {
71
+ keepDirect?: boolean;
72
+ relationships?: boolean;
73
+ /** Nodes the messages they send or receive are folded into (L1: domains and systems), so edges through them are plain */
74
+ foldsMessagesInto?: (node: Node) => boolean;
75
+ } = {}
62
76
  ) => {
63
77
  const byId = new Map(nodes.map((node) => [node.id, node]));
64
78
  // Whether one node is inside another (e.g. a system in its domain): they're not connected, one holds the other
@@ -70,7 +84,14 @@ const collapseGraph = (
70
84
  const outgoing = new Map<string, Edge[]>();
71
85
  edges.forEach((edge) => outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]));
72
86
 
73
- type Merged = { source: string; target: string; bridged: boolean; messages: Map<string, string>; labels: string[] };
87
+ type Merged = {
88
+ source: string;
89
+ target: string;
90
+ bridged: boolean;
91
+ relationship: boolean;
92
+ messages: Map<string, string>;
93
+ labels: string[];
94
+ };
74
95
  const merged = new Map<string, Merged>();
75
96
  const kept: Edge[] = [];
76
97
  const addMessage = (messages: Map<string, string>, node: Node) => {
@@ -81,7 +102,8 @@ const collapseGraph = (
81
102
  const from = representatives.get(edge.source);
82
103
  if (from?.kind !== 'node') return;
83
104
  const to = representatives.get(edge.target);
84
- if (keepDirect && from.id === edge.source && to?.kind === 'node' && to.id === edge.target) return kept.push(edge);
105
+ const direct = from.id === edge.source && to?.kind === 'node' && to.id === edge.target;
106
+ if (keepDirect && direct) return kept.push(edge);
85
107
  // Follow the edge through carried nodes to the node(s) it reaches, collecting the messages on the way
86
108
  const stack = [{ id: edge.target, messages: new Map<string, string>(), labels: [String(edge.label ?? '')], bridged: false }];
87
109
  const visited = new Set<string>();
@@ -104,11 +126,13 @@ const collapseGraph = (
104
126
  // Edges inside what was collapsed disappear, and so do edges to what a node is in (e.g. a service loose in a
105
127
  // domain, folded into it, and a system in the same domain)
106
128
  if (from.id === reached.id || isInside(from.id, reached.id) || isInside(reached.id, from.id)) continue;
107
- const key = `${current.bridged ? 'bridged' : 'level'}-${from.id}-${reached.id}`;
129
+ const relationship = relationships && direct && current.id === edge.target;
130
+ const key = `${relationship ? 'relationship' : current.bridged ? 'bridged' : 'level'}-${from.id}-${reached.id}`;
108
131
  const entry: Merged = merged.get(key) ?? {
109
132
  source: from.id,
110
133
  target: reached.id,
111
134
  bridged: current.bridged,
135
+ relationship,
112
136
  messages: new Map(),
113
137
  labels: [],
114
138
  };
@@ -119,18 +143,39 @@ const collapseGraph = (
119
143
  });
120
144
 
121
145
  const keptIds = new Set([...representatives.values()].flatMap((rep) => (rep.kind === 'node' ? [rep.id] : [])));
122
- // Actors' connections are relationships, labelled as they were (like the visualiser's context relationships)
146
+ // A relationship between two nodes is shown instead of the messages between them (either way)
147
+ const related = new Set(
148
+ [...merged.values()]
149
+ .filter((entry) => entry.relationship)
150
+ .flatMap(({ source, target }) => [`${source}>${target}`, `${target}>${source}`])
151
+ );
152
+ // Actors' connections are relationships too, labelled as they were (even when what they use is folded)
123
153
  const isActor = (id: string) => byId.get(id)?.type === 'actor';
154
+ const labelOf = ({ source, target, bridged, relationship, messages, labels }: Merged) => {
155
+ const written = labels.length ? [...new Set(labels)].join(', ') : undefined;
156
+ if (relationship) return written;
157
+ return getMessagesLabel(messages) ?? (!bridged && (isActor(source) || isActor(target)) ? written : undefined);
158
+ };
124
159
  return {
125
160
  nodes: nodes.filter((node) => keptIds.has(node.id)),
126
161
  edges: [
127
162
  ...kept,
128
- ...[...merged.entries()].map(([id, { source, target, bridged, messages, labels }]): Edge => {
129
- const label =
130
- getMessagesLabel(messages) ??
131
- (!bridged && (isActor(source) || isActor(target)) && labels.length ? [...new Set(labels)].join(', ') : undefined);
132
- return { id, source, target, ...(bridged ? BRIDGED_EDGE : FOLDED_EDGE), ...(label && { label }) };
133
- }),
163
+ ...[...merged.entries()]
164
+ .filter(([, entry]) => entry.relationship || !related.has(`${entry.source}>${entry.target}`))
165
+ .map(([id, entry]): Edge => {
166
+ const label = labelOf(entry);
167
+ const folded = [entry.source, entry.target].some((nodeId) => {
168
+ const node = byId.get(nodeId);
169
+ return !!node && !!foldsMessagesInto?.(node);
170
+ });
171
+ return {
172
+ id,
173
+ source: entry.source,
174
+ target: entry.target,
175
+ ...(entry.bridged && !folded ? BRIDGED_EDGE : FOLDED_EDGE),
176
+ ...(label && { label }),
177
+ };
178
+ }),
134
179
  ],
135
180
  };
136
181
  };
@@ -188,8 +233,8 @@ const isWithin = (node: Node, containerId: string, byId: Map<string, Node>) => {
188
233
  };
189
234
 
190
235
  /**
191
- * L1 as EventCatalog's diagrams draw it: systems as cards with what's in them counted, and domains with no system in
192
- * them (or in their subdomains) as domain cards
236
+ * L1 as EventCatalog's diagrams draw it: systems as cards with what's in them counted, and domains with nothing
237
+ * shown in them (no system, in them or their subdomains, or subdomain) as domain cards
193
238
  */
194
239
  const asSystemsLevel = (
195
240
  { nodes, edges }: { nodes: Node[]; edges: Edge[] },
@@ -226,7 +271,12 @@ const asSystemsLevel = (
226
271
  messagesCount: messagesOf(id),
227
272
  },
228
273
  };
229
- if (node.type === GROUP_TYPES.domain && !all.some((other) => isSystem(other) && isWithin(other, id, byId)))
274
+ // A domain container stays one while something is shown in it (a system, or a subdomain shown as a card)
275
+ if (
276
+ node.type === GROUP_TYPES.domain &&
277
+ !all.some((other) => isSystem(other) && isWithin(other, id, byId)) &&
278
+ !nodes.some((other) => isWithin(other, id, byId))
279
+ )
230
280
  return {
231
281
  id,
232
282
  type: 'context-domain',
@@ -248,18 +298,23 @@ const asSystemsLevel = (
248
298
  /** L1: services folded into their system (or domain); actors, external systems and loose services stay */
249
299
  const level1 = (nodes: Node[], edges: Edge[]) => {
250
300
  const containers = getContainers(nodes, edges);
251
- const collapsed = collapseGraph(nodes, edges, (node) => {
252
- const container = containers.get(node.id);
253
- // Systems stay (inside their domain's container, if they're in one); everything else folds into its system or domain
254
- if (isContainer(node) && (isSystem(node) || !container?.system)) return { kind: 'node', id: node.id };
255
- if (container?.system) return { kind: 'node', id: container.system };
256
- // Messages and channels connect the systems either side, even when they sit in a domain rather than a system
257
- // (the visualiser has them outside its domains; catalog domains on a canvas hold their messages)
258
- if (isCarrier(node)) return { kind: 'carrier' };
259
- if (container?.domain) return { kind: 'node', id: container.domain };
260
- if (node.type === 'data' || isWrittenOnCanvas(node.type)) return { kind: 'drop' };
261
- return { kind: 'node', id: node.id };
262
- });
301
+ const collapsed = collapseGraph(
302
+ nodes,
303
+ edges,
304
+ (node) => {
305
+ const container = containers.get(node.id);
306
+ // Systems stay (inside their domain's container, if they're in one); everything else folds into its system or domain
307
+ if (isContainer(node) && (isSystem(node) || !container?.system)) return { kind: 'node', id: node.id };
308
+ if (container?.system) return { kind: 'node', id: container.system };
309
+ // Messages and channels connect the systems either side, even when they sit in a domain rather than a system
310
+ // (the visualiser has them outside its domains; catalog domains on a canvas hold their messages)
311
+ if (isCarrier(node)) return { kind: 'carrier' };
312
+ if (container?.domain) return { kind: 'node', id: container.domain };
313
+ if (node.type === 'data' || isWrittenOnCanvas(node.type)) return { kind: 'drop' };
314
+ return { kind: 'node', id: node.id };
315
+ },
316
+ { relationships: true, foldsMessagesInto: isContainer }
317
+ );
263
318
  return asSystemsLevel(collapsed, { all: nodes, allEdges: edges, containers });
264
319
  };
265
320
 
@@ -0,0 +1,2 @@
1
+ /** Canvas titles are at most this long (no imports, so the browser can check it without loading a canvas) */
2
+ export const MAX_TITLE_LENGTH = 200;
@@ -12,6 +12,22 @@ export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
12
12
  { id: 'other', label: 'Other' },
13
13
  ];
14
14
 
15
+ /** The catalog collections whose resources can go on a canvas */
16
+ export const CATALOG_COLLECTIONS = [
17
+ 'domains',
18
+ 'systems',
19
+ 'services',
20
+ 'events',
21
+ 'commands',
22
+ 'queries',
23
+ 'channels',
24
+ 'containers',
25
+ ] as const;
26
+
27
+ export type CatalogCollection = (typeof CATALOG_COLLECTIONS)[number];
28
+ export const isCatalogCollection = (collection: string): collection is CatalogCollection =>
29
+ (CATALOG_COLLECTIONS as readonly string[]).includes(collection);
30
+
15
31
  /** Domains and systems as containers: boxes other nodes sit in */
16
32
  export const GROUP_TYPES = { domain: 'domain-group', system: 'system-group' } as const;
17
33
  export const isGroupType = (type?: string) => type === GROUP_TYPES.domain || type === GROUP_TYPES.system;
@@ -29,6 +45,11 @@ export type NodeDefinition = {
29
45
  defaultSize?: { width: number; height: number };
30
46
  /** Only its width is set: its height follows what's in it (text) */
31
47
  autoHeight?: boolean;
48
+ /**
49
+ * The first level of detail that shows it (it's on the more detailed ones too, see levels.ts): L1 domains, systems
50
+ * and actors, L2 services, data stores, text and the like, L3 messages and channels. Notes are on every level.
51
+ */
52
+ firstLevel: 1 | 2 | 3;
32
53
  createData: () => Record<string, unknown>;
33
54
  };
34
55
 
@@ -37,6 +58,7 @@ const resource = (name: string, summary: string) => ({ name, version: '0.0.1', s
37
58
  export const nodeDefinitions: NodeDefinition[] = [
38
59
  {
39
60
  type: GROUP_TYPES.domain,
61
+ firstLevel: 1,
40
62
  label: 'Domain',
41
63
  category: 'boundaries',
42
64
  resourceKey: 'domain',
@@ -45,6 +67,7 @@ export const nodeDefinitions: NodeDefinition[] = [
45
67
  },
46
68
  {
47
69
  type: GROUP_TYPES.system,
70
+ firstLevel: 1,
48
71
  label: 'System',
49
72
  category: 'boundaries',
50
73
  resourceKey: 'system',
@@ -53,6 +76,7 @@ export const nodeDefinitions: NodeDefinition[] = [
53
76
  },
54
77
  {
55
78
  type: 'service',
79
+ firstLevel: 2,
56
80
  category: 'architecture',
57
81
  label: 'Service',
58
82
  resourceKey: 'service',
@@ -63,6 +87,7 @@ export const nodeDefinitions: NodeDefinition[] = [
63
87
  },
64
88
  {
65
89
  type: 'event',
90
+ firstLevel: 3,
66
91
  category: 'messages',
67
92
  label: 'Event',
68
93
  resourceKey: 'message',
@@ -70,6 +95,7 @@ export const nodeDefinitions: NodeDefinition[] = [
70
95
  },
71
96
  {
72
97
  type: 'command',
98
+ firstLevel: 3,
73
99
  category: 'messages',
74
100
  label: 'Command',
75
101
  resourceKey: 'message',
@@ -77,6 +103,7 @@ export const nodeDefinitions: NodeDefinition[] = [
77
103
  },
78
104
  {
79
105
  type: 'query',
106
+ firstLevel: 3,
80
107
  category: 'messages',
81
108
  label: 'Query',
82
109
  resourceKey: 'message',
@@ -84,6 +111,7 @@ export const nodeDefinitions: NodeDefinition[] = [
84
111
  },
85
112
  {
86
113
  type: 'channel',
114
+ firstLevel: 3,
87
115
  category: 'architecture',
88
116
  label: 'Channel',
89
117
  resourceKey: 'channel',
@@ -91,6 +119,7 @@ export const nodeDefinitions: NodeDefinition[] = [
91
119
  },
92
120
  {
93
121
  type: 'data',
122
+ firstLevel: 2,
94
123
  category: 'architecture',
95
124
  label: 'Data Store',
96
125
  resourceKey: 'data',
@@ -98,6 +127,7 @@ export const nodeDefinitions: NodeDefinition[] = [
98
127
  },
99
128
  {
100
129
  type: 'view',
130
+ firstLevel: 2,
101
131
  category: 'architecture',
102
132
  label: 'View',
103
133
  resourceKey: 'view',
@@ -105,6 +135,7 @@ export const nodeDefinitions: NodeDefinition[] = [
105
135
  },
106
136
  {
107
137
  type: 'externalSystem',
138
+ firstLevel: 1,
108
139
  category: 'architecture',
109
140
  unlisted: true,
110
141
  label: 'External System',
@@ -113,6 +144,7 @@ export const nodeDefinitions: NodeDefinition[] = [
113
144
  },
114
145
  {
115
146
  type: 'agent',
147
+ firstLevel: 2,
116
148
  category: 'architecture',
117
149
  label: 'Agent',
118
150
  resourceKey: 'agent',
@@ -120,12 +152,15 @@ export const nodeDefinitions: NodeDefinition[] = [
120
152
  },
121
153
  {
122
154
  type: 'actor',
155
+ firstLevel: 1,
123
156
  category: 'other',
124
157
  label: 'Actor',
125
- createData: () => ({ mode: 'full', name: 'Customer', summary: 'A person using the system.' }),
158
+ // Drawn like the visualiser's actors (by name only); the summary is for agents and the properties panel
159
+ createData: () => ({ mode: 'full', name: 'Customer', summary: '' }),
126
160
  },
127
161
  {
128
162
  type: 'text',
163
+ firstLevel: 2,
129
164
  category: 'other',
130
165
  label: 'Text',
131
166
  defaultSize: { width: 240, height: 40 },
@@ -134,6 +169,7 @@ export const nodeDefinitions: NodeDefinition[] = [
134
169
  },
135
170
  {
136
171
  type: 'note',
172
+ firstLevel: 1,
137
173
  category: 'other',
138
174
  label: 'Sticky Note',
139
175
  // About square, like a post-it (taller if more is written on it)
@@ -151,9 +187,9 @@ export const COMPONENT_TYPES = nodeDefinitions
151
187
  const definitionsByType = new Map(nodeDefinitions.map((definition) => [definition.type, definition]));
152
188
 
153
189
  // Node types that only come from the catalog (not in the components list)
154
- const catalogNodeTypes: Record<string, { label: string; resourceKey: string }> = {
155
- system: { label: 'System', resourceKey: 'system' },
156
- 'context-domain': { label: 'Domain', resourceKey: 'domain' },
190
+ const catalogNodeTypes: Record<string, { label: string; resourceKey: string; firstLevel: 1 | 2 | 3 }> = {
191
+ system: { label: 'System', resourceKey: 'system', firstLevel: 1 },
192
+ 'context-domain': { label: 'Domain', resourceKey: 'domain', firstLevel: 1 },
157
193
  };
158
194
 
159
195
  export const getNodeDefinition = (type?: string) => (type ? definitionsByType.get(type) : undefined);
@@ -188,6 +224,10 @@ export const getNoteLevel = (node: { type?: string; data: Record<string, unknown
188
224
  export const isOnLevel = (node: { type?: string; data: Record<string, unknown> }, level: 1 | 2 | 3) =>
189
225
  node.type !== 'note' || getNoteLevel(node) === level;
190
226
 
227
+ /** Whether a level of detail shows a kind of node (so it can be added there, and stay there) */
228
+ export const isShownOnLevel = (type: string | undefined, level: 1 | 2 | 3) =>
229
+ level >= (getNodeDefinition(type)?.firstLevel ?? catalogNodeTypes[type ?? '']?.firstLevel ?? 3);
230
+
191
231
  /** Notes and text: written on the canvas itself (their text is their data, not a resource with a name) */
192
232
  export const isWrittenOnCanvas = (type?: string) => type === 'note' || type === 'text';
193
233
 
@@ -4,7 +4,8 @@ import { ChevronDown, ChevronLeft, ChevronRight, Copy, EllipsisVertical, Pencil,
4
4
  import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
5
5
  import StudioNotice from '../components/StudioNotice.astro';
6
6
  import { buildUrl } from '@utils/url-builder';
7
- import { CANVAS_STATUSES, MAX_TITLE_LENGTH, type CanvasStatus } from '../canvas-doc';
7
+ import { CANVAS_STATUSES, type CanvasStatus } from '../canvas-doc';
8
+ import { MAX_TITLE_LENGTH } from '../limits';
8
9
  import { MAX_NAME_LENGTH } from '../identity';
9
10
  import { CANVAS_STATUS_LOOK, STATUS } from '../components/status';
10
11
  import { CANVASES_API_PATH } from '../api/canvases-api';
@@ -427,7 +427,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
427
427
  const { nodes, edges } = await runtime.withCanvas(id, readCanvas);
428
428
  const positions = await getLayoutPositions(nodes, edges);
429
429
  await playLayout(asAgent(id, name).stage, positions);
430
- return text({ laidOut: positions.size });
430
+ return text({ laidOut: positions.nodes.size });
431
431
  }
432
432
  );
433
433
 
@@ -6,16 +6,18 @@ import type { XYPosition } from '@xyflow/react';
6
6
  import type { AgentPresence } from '../agent-choreography';
7
7
  import {
8
8
  CANVAS_DELETED_REASON,
9
- MAX_TITLE_LENGTH,
9
+ addCanvasContent,
10
10
  canvasDocumentName,
11
11
  canvasIdFromDocumentName,
12
12
  getCanvasMaps,
13
13
  readMeta,
14
14
  setMeta,
15
15
  getCanvasStatus,
16
+ type CanvasContent,
16
17
  type CanvasStatus,
17
18
  } from '../canvas-doc';
18
19
  import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
20
+ import { MAX_TITLE_LENGTH } from '../limits';
19
21
  import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
20
22
  import config from '../../../utils/eventcatalog-config/source';
21
23
  import { isAuthEnabled } from '../../../utils/feature';
@@ -299,10 +301,16 @@ export class StudioRuntime {
299
301
  return true;
300
302
  }
301
303
 
302
- /** Starts a canvas with a title, recording when and by whom (a person, or an agent). Returns its id. */
303
- async createCanvas(started: { title?: string; createdBy?: string }) {
304
+ /**
305
+ * Starts a canvas with a title, recording when and by whom (a person, or an agent), and what's on it to begin
306
+ * with, if anything. Returns its id.
307
+ */
308
+ async createCanvas(started: { title?: string; createdBy?: string }, content?: CanvasContent) {
304
309
  const canvasId = crypto.randomUUID();
305
- await this.withCanvas(canvasId, (doc) => setStarted(doc, started));
310
+ await this.withCanvas(canvasId, (doc) => {
311
+ setStarted(doc, started);
312
+ if (content) addCanvasContent(doc, content);
313
+ });
306
314
  return canvasId;
307
315
  }
308
316
 
@@ -267,7 +267,7 @@ export const buildDomainGraph = ({
267
267
  draggable: false,
268
268
  selectable: false,
269
269
  data: {
270
- domain: { name: d.data.name, version: d.data.version },
270
+ domain: { id: d.data.id, name: d.data.name, version: d.data.version },
271
271
  // The domain viewed may itself be a subdomain of another
272
272
  subdomain: !!parentId || parentDomainOf.has(d.data.id),
273
273
  isFocused: !parentId,
@@ -309,7 +309,7 @@ export const buildDomainGraph = ({
309
309
  position: { x: 0, y: 0 },
310
310
  ...(parentId ? { parentId } : {}),
311
311
  data: {
312
- domain: { name: other.data.name, version: other.data.version },
312
+ domain: { id: other.data.id, name: other.data.name, version: other.data.version },
313
313
  subdomain: !!parentId,
314
314
  isFocused: false,
315
315
  otherDomain: true,
@@ -375,7 +375,7 @@ export const buildDomainGraph = ({
375
375
  type: 'system-group',
376
376
  parentId: domainGroup,
377
377
  position: { x: 0, y: 0 },
378
- data: { system: { name: system.data.name, version: system.data.version } },
378
+ data: { system: { id: system.data.id, name: system.data.name, version: system.data.version } },
379
379
  });
380
380
  }
381
381
  addNodes(graph.nodes);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The catalog resource a diagram node shows, for the node types the node graphs build. Has no imports so it can
3
+ * be shared by the server and the browser (the visualiser's "Open in Studio", the MCP App views).
4
+ */
5
+
6
+ // The collection each node type shows a resource of
7
+ const NODE_COLLECTIONS: Record<string, string> = {
8
+ service: 'services',
9
+ services: 'services',
10
+ agent: 'agents',
11
+ agents: 'agents',
12
+ event: 'events',
13
+ events: 'events',
14
+ command: 'commands',
15
+ commands: 'commands',
16
+ query: 'queries',
17
+ queries: 'queries',
18
+ channel: 'channels',
19
+ channels: 'channels',
20
+ flow: 'flows',
21
+ flows: 'flows',
22
+ data: 'containers',
23
+ containers: 'containers',
24
+ 'data-products': 'data-products',
25
+ domain: 'domains',
26
+ domains: 'domains',
27
+ 'context-domain': 'domains',
28
+ 'domain-group': 'domains',
29
+ system: 'systems',
30
+ systems: 'systems',
31
+ 'system-group': 'systems',
32
+ };
33
+
34
+ // Where each node type keeps the resource it shows
35
+ const RESOURCE_DATA_KEYS = [
36
+ 'service',
37
+ 'agent',
38
+ 'message',
39
+ 'channel',
40
+ 'data',
41
+ 'dataProduct',
42
+ 'flow',
43
+ 'entity',
44
+ 'domain',
45
+ 'system',
46
+ 'custom',
47
+ ];
48
+
49
+ type DiagramNode = { type?: string; data?: Record<string, unknown> };
50
+ type ResourceDetails = { id?: unknown; version?: unknown; [key: string]: unknown };
51
+
52
+ /** The resource a node shows, as in its data: the fields shown, or the catalog entry's data (if it holds the entry) */
53
+ export const getNodeResourceDetails = (node: DiagramNode): ResourceDetails | undefined => {
54
+ const data = node.data ?? {};
55
+ const resource = RESOURCE_DATA_KEYS.map((key) => data[key]).find(Boolean) as
56
+ | (ResourceDetails & { data?: ResourceDetails })
57
+ | undefined;
58
+ return resource?.data ?? resource;
59
+ };
60
+
61
+ /** The catalog resource a node shows (its collection, id and version), if it shows one */
62
+ export const getNodeCatalogResource = (node: DiagramNode): { collection: string; id: string; version?: string } | undefined => {
63
+ const collection = NODE_COLLECTIONS[node.type ?? ''];
64
+ const details = getNodeResourceDetails(node);
65
+ if (!collection || typeof details?.id !== 'string') return undefined;
66
+ return { collection, id: details.id, ...(typeof details.version === 'string' && { version: details.version }) };
67
+ };