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

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 (92) 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 +4 -4
  6. package/dist/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
  8. package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
  9. package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
  10. package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
  11. package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +2 -2
  15. package/dist/eventcatalog.js +14 -14
  16. package/dist/federation/federate.js +3 -3
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  21. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  22. package/dist/runtime-dependencies/manifest.json +3 -0
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  27. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  28. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  29. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  30. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  31. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  32. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  33. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
  34. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
  35. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  36. package/eventcatalog/src/features/studio/agents.ts +28 -0
  37. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  38. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  40. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  41. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  42. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
  43. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  44. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  45. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  46. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
  47. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  48. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  49. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
  50. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  51. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  52. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  53. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  54. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
  56. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  57. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  58. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  59. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  60. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  61. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  62. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
  63. package/eventcatalog/src/features/studio/identity.ts +23 -1
  64. package/eventcatalog/src/features/studio/limits.ts +2 -0
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +2 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  68. package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
  69. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  70. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  71. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  72. package/package.json +3 -3
  73. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
  74. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  75. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  76. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  77. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  78. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  79. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  80. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  81. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  82. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  83. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  84. package/eventcatalog/src/features/studio/edges.ts +0 -56
  85. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  86. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  87. package/eventcatalog/src/features/studio/layout.ts +0 -42
  88. package/eventcatalog/src/features/studio/levels.ts +0 -282
  89. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  90. package/eventcatalog/src/features/studio/placement.ts +0 -197
  91. package/eventcatalog/src/features/studio/studio.css +0 -273
  92. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -9,11 +9,16 @@ import {
9
9
  fitContainersAround,
10
10
  getCanvasMaps,
11
11
  moveNode,
12
- resizeNode,
12
+ placeNode,
13
+ setEdgeRoutes,
13
14
  setMeta,
14
15
  type Author,
15
16
  type CommentAnchor,
16
- } from './canvas-doc';
17
+ getAbsolutePosition,
18
+ sizeOf,
19
+ type LayoutResult,
20
+ getNodeName,
21
+ } from '@eventcatalog/visualiser/studio';
17
22
  import {
18
23
  describeNode,
19
24
  nodeCenter,
@@ -27,9 +32,6 @@ import {
27
32
  type NodeUpdate,
28
33
  } from './canvas-actions';
29
34
  import { getRelatedEdges } from './catalog';
30
- import { getAbsolutePosition, sizeOf } from './grouping';
31
- import type { LayoutResult } from './layout';
32
- import { getNodeName } from './node-types';
33
35
 
34
36
  /**
35
37
  * Agents work on a canvas like a person would: their cursor travels to where they're going, they drop one node,
@@ -292,13 +294,14 @@ export async function playLayout(stage: AgentStage, layout: LayoutResult) {
292
294
  hand.say('Done');
293
295
  }
294
296
 
295
- /** Moves nodes and resizes containers from a layout, in one change, and marks when it happened */
297
+ /**
298
+ * Moves nodes, resizes containers and routes connections from a layout, in one change, and marks when it
299
+ * happened. Connections the layout didn't route lose their old route (drawn as a plain step instead).
300
+ */
296
301
  export const applyLayout = (doc: Y.Doc, layout: LayoutResult) =>
297
302
  doc.transact(() => {
298
- layout.forEach(({ position, size }, id) => {
299
- moveNode(doc, id, position);
300
- if (size) resizeNode(doc, id, size);
301
- });
303
+ layout.nodes.forEach(({ position, size }, id) => placeNode(doc, id, position, size));
304
+ setEdgeRoutes(doc, layout.routes);
302
305
  setMeta(doc, { layoutAt: Date.now() });
303
306
  });
304
307
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Who an agent is, named after its client when it doesn't say, in its brand's colour: on the server (MCP tools) and
3
+ * in a chat's canvas view (which joins as the chat's agent). Their logos are in the visualiser's ClientLogos.
4
+ */
5
+ export type AgentIdentity = { name: string; color: string };
6
+
7
+ const AGENT_COLORS: Record<string, string> = {
8
+ ChatGPT: '#10a37f',
9
+ Codex: '#10a37f',
10
+ Claude: '#d97757',
11
+ 'Claude Code': '#d97757',
12
+ Cursor: '#111827',
13
+ };
14
+
15
+ /** Agents' clients, by what's in their user agent or host name (most specific first) */
16
+ const CLIENTS: [RegExp, string][] = [
17
+ [/claude[-_ ]?code/i, 'Claude Code'],
18
+ [/claude|anthropic/i, 'Claude'],
19
+ [/codex/i, 'Codex'],
20
+ [/openai|chatgpt/i, 'ChatGPT'],
21
+ [/cursor/i, 'Cursor'],
22
+ ];
23
+
24
+ /** Who the agent is, from the name it gives, else its client's user agent (or a chat's host name) */
25
+ export const getAgent = (agentName: string | undefined, client = ''): AgentIdentity => {
26
+ const name = agentName?.trim() || CLIENTS.find(([pattern]) => pattern.test(client))?.[1] || 'AI agent';
27
+ return { name, color: AGENT_COLORS[name] ?? '#8b5cf6' };
28
+ };
@@ -1,7 +1,10 @@
1
1
  import { Hono, type Context } from 'hono';
2
2
  import { z } from 'zod';
3
3
  import { paginate } from '@features/tools/catalog-tools';
4
- import { MAX_TITLE_LENGTH, type CanvasStatus } from '../canvas-doc';
4
+ import { type CanvasContent, type CanvasStatus } from '@eventcatalog/visualiser/studio';
5
+ import { MAX_TITLE_LENGTH } from '../limits';
6
+ import { diagramLevelsSchema, indexCatalog, newCanvasEdgesSchema, nodeSpecsSchema, planCanvas } from '../canvas-actions';
7
+ import { getCatalogResources } from '../catalog-resources';
5
8
  import { MAX_NAME_LENGTH } from '../identity';
6
9
  import type { CanvasSummary, StudioRuntime } from '../server/runtime';
7
10
  import type { SignedInUser } from '../server/sign-in';
@@ -13,7 +16,13 @@ import type { SignedInUser } from '../server/sign-in';
13
16
  *
14
17
  * GET /api/studio/canvases Canvases, most recently changed first (?pageSize=, ?cursor= for the next page)
15
18
  * (not ones left empty and untitled: GET /:id still finds them)
16
- * POST /api/studio/canvases Creates one: { "title"?: string, "createdBy"?: string }
19
+ * POST /api/studio/canvases Creates one: { "title"?: string, "createdBy"?: string, "nodes"?: [...],
20
+ * "edges"?: [...] }, starting with the nodes and edges given, if any (as the
21
+ * add_to_canvas MCP tool takes them: catalog resources or new components, x/y
22
+ * their centres, and only the connections in edges are made, each drawn
23
+ * along its "route" if given: { "points": [{x, y}, ...], "label"?: {x, y} }),
24
+ * and "levels" (L1 and L2 laid out, e.g. as a diagram shows them, by the
25
+ * nodes' refs), shown as given until what's on the canvas changes
17
26
  * GET /api/studio/canvases/:id One canvas
18
27
  * POST /api/studio/canvases/:id/copy Copies its design (nodes and connections, not comments or status) to a new
19
28
  * draft: { "title"?: string, "createdBy"?: string }
@@ -57,11 +66,23 @@ export type CanvasesApiEnv = {
57
66
  };
58
67
  };
59
68
 
69
+ const MAX_NODES = 1000;
70
+ const MAX_EDGES = 2000;
71
+
60
72
  const NewCanvas = z.object({
61
73
  title: z.string().trim().max(MAX_TITLE_LENGTH).optional(),
62
74
  // Ignored when someone's signed in: it's them
63
75
  createdBy: z.string().trim().max(MAX_NAME_LENGTH).optional(),
64
76
  });
77
+ // A new canvas can start with nodes and edges; a copy starts with the original's
78
+ const NewCanvasWithContent = NewCanvas.extend({
79
+ nodes: nodeSpecsSchema.max(MAX_NODES).optional(),
80
+ edges: newCanvasEdgesSchema.max(MAX_EDGES).optional(),
81
+ levels: diagramLevelsSchema.optional(),
82
+ }).refine((canvas) => !canvas.edges?.length || canvas.nodes, {
83
+ message: 'Edges connect the nodes given with them: give nodes too',
84
+ path: ['edges'],
85
+ });
65
86
  const ListQuery = z.object({
66
87
  cursor: z.string().optional(),
67
88
  pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).optional(),
@@ -86,26 +107,54 @@ const describeIssues = (error: z.ZodError) =>
86
107
  type ApiContext = Context<CanvasesApiEnv>;
87
108
  const noCanvas = (c: ApiContext, id: string) => c.json({ error: `No canvas "${id}"` }, 404);
88
109
 
89
- /** A new canvas's details from the request (optional), or the response saying what's wrong with them */
90
- const readNewCanvas = async (c: ApiContext): Promise<{ title?: string; createdBy?: string } | Response> => {
110
+ type NewCanvasDetails = { title?: string; createdBy?: string };
111
+
112
+ /** The request's JSON body (or {} without one), or the response saying what's wrong with it */
113
+ const readJson = async (c: ApiContext): Promise<unknown> => {
91
114
  // JSON only: a page on another site can send a form or plain text here without asking first, but not JSON
92
115
  const body = await c.req.text();
93
116
  if (body && !c.req.header('content-type')?.toLowerCase().startsWith('application/json')) {
94
117
  return c.json({ error: 'Send the canvas as JSON (Content-Type: application/json)' }, 415);
95
118
  }
96
- let json: unknown = {};
97
119
  try {
98
- if (body) json = JSON.parse(body);
120
+ return body ? JSON.parse(body) : {};
99
121
  } catch {
100
122
  return c.json({ error: 'The body is not valid JSON' }, 400);
101
123
  }
124
+ };
125
+
126
+ const detailsOf = (c: ApiContext, canvas: z.output<typeof NewCanvas>): NewCanvasDetails => ({
127
+ title: canvas.title || undefined,
128
+ // Whoever's signed in, if anyone
129
+ createdBy: c.env.signedInAs?.name ?? (canvas.createdBy || undefined),
130
+ });
131
+
132
+ /** A copy's details from the request (optional), or the response saying what's wrong with them */
133
+ const readCopy = async (c: ApiContext): Promise<NewCanvasDetails | Response> => {
134
+ const json = await readJson(c);
135
+ if (json instanceof Response) return json;
102
136
  const parsed = NewCanvas.safeParse(json);
103
137
  if (!parsed.success) return c.json({ error: describeIssues(parsed.error) }, 400);
104
- return {
105
- title: parsed.data.title || undefined,
106
- // Whoever's signed in, if anyone
107
- createdBy: c.env.signedInAs?.name ?? (parsed.data.createdBy || undefined),
108
- };
138
+ return detailsOf(c, parsed.data);
139
+ };
140
+
141
+ /** A new canvas's details and what it starts with (all optional), or the response saying what's wrong with them */
142
+ const readNewCanvas = async (c: ApiContext): Promise<{ details: NewCanvasDetails; content?: CanvasContent } | Response> => {
143
+ const json = await readJson(c);
144
+ if (json instanceof Response) return json;
145
+ const parsed = NewCanvasWithContent.safeParse(json);
146
+ if (!parsed.success) return c.json({ error: describeIssues(parsed.error) }, 400);
147
+ const details = detailsOf(c, parsed.data);
148
+ if (!parsed.data.nodes) return { details };
149
+ const { resources, relations } = await getCatalogResources();
150
+ const { content, errors } = planCanvas(
151
+ parsed.data.nodes,
152
+ parsed.data.edges ?? [],
153
+ indexCatalog(resources, relations),
154
+ parsed.data.levels
155
+ );
156
+ if (errors.length) return c.json({ error: errors.join('; ') }, 400);
157
+ return { details, content };
109
158
  };
110
159
 
111
160
  /** The API, served under `basePath` (the catalog's base URL, then CANVASES_API_PATH) */
@@ -132,15 +181,15 @@ export const createCanvasesApi = (basePath: string) => {
132
181
  };
133
182
 
134
183
  app.post('/', async (c) => {
135
- const details = await readNewCanvas(c);
136
- if (details instanceof Response) return details;
137
- return created(c, await c.env.runtime.createCanvas(details));
184
+ const canvas = await readNewCanvas(c);
185
+ if (canvas instanceof Response) return canvas;
186
+ return created(c, await c.env.runtime.createCanvas(canvas.details, canvas.content));
138
187
  });
139
188
 
140
189
  app.post('/:id/copy', async (c) => {
141
190
  const id = c.req.param('id');
142
191
  if (!CANVAS_ID.test(id)) return noCanvas(c, id);
143
- const details = await readNewCanvas(c);
192
+ const details = await readCopy(c);
144
193
  if (details instanceof Response) return details;
145
194
  const copyId = await c.env.runtime.copyCanvas(id, details);
146
195
  return copyId ? created(c, copyId) : noCanvas(c, id);
@@ -1,4 +1,5 @@
1
1
  import type { ApiCanvas } from './canvases-api';
2
+ import type { DiagramLevelsSpec, NewCanvasEdges, NodeSpecs } from '../canvas-actions';
2
3
 
3
4
  /** Using the Studio API from the browser (Studio's pages), at `apiUrl` (its address on this catalog) */
4
5
 
@@ -7,7 +8,11 @@ const errorOf = async (response: Response, fallback: string) => {
7
8
  return new Error(body?.error ?? `${fallback} (${response.status})`);
8
9
  };
9
10
 
10
- export const createCanvasThroughApi = async (apiUrl: string, canvas: { title?: string; createdBy?: string }) => {
11
+ /** Creates a canvas, starting with the nodes and edges given, if any */
12
+ export const createCanvasThroughApi = async (
13
+ apiUrl: string,
14
+ canvas: { title?: string; createdBy?: string; nodes?: NodeSpecs; edges?: NewCanvasEdges; levels?: DiagramLevelsSpec }
15
+ ) => {
11
16
  const response = await fetch(apiUrl, {
12
17
  method: 'POST',
13
18
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -1,21 +1,20 @@
1
1
  import type { Edge, Node, XYPosition } from '@xyflow/react';
2
2
  import type * as Y from 'yjs';
3
3
  import { z } from 'zod';
4
- import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData } from './canvas-doc';
5
- import type { CatalogRelation, CatalogResource } from './catalog-resources';
6
- import {
7
- buildContainer,
8
- canBeContainer,
9
- catalogNodeData,
10
- containerTypeFor,
11
- getCatalogLink,
12
- getRelatedEdges,
13
- indexRelations,
14
- } from './catalog';
15
- import { findGroupAtPoint, getAbsolutePosition, sizeOf } from './grouping';
16
- import { placeNodes, type Link } from './placement';
17
4
  import {
5
+ buildNode,
6
+ getCanvasMaps,
7
+ getCanvasStatus,
8
+ readCanvas,
9
+ updateNodeData,
10
+ type CanvasContent,
11
+ containerFor,
12
+ getAbsolutePosition,
13
+ sizeOf,
14
+ placeNodes,
15
+ type Link,
18
16
  canGoInContainer,
17
+ CATALOG_COLLECTIONS,
19
18
  COMPONENT_TYPES,
20
19
  getNodeDefinition,
21
20
  getNodeLabel,
@@ -27,24 +26,23 @@ import {
27
26
  isVersioned,
28
27
  isWrittenOnCanvas,
29
28
  updateResource,
30
- } from './node-types';
29
+ } from '@eventcatalog/visualiser/studio';
30
+ import type { CatalogRelation, CatalogResource } from './catalog-resources';
31
+ import {
32
+ buildContainer,
33
+ canBeContainer,
34
+ catalogNodeData,
35
+ containerTypeFor,
36
+ getCatalogLink,
37
+ getRelatedEdges,
38
+ indexRelations,
39
+ } from './catalog';
31
40
 
32
41
  /**
33
42
  * What agents can do on a canvas, shared by the MCP tools (on the server) and the WebMCP tools (in the
34
43
  * browser), so both describe and change a canvas the same way.
35
44
  */
36
45
 
37
- export const CATALOG_COLLECTIONS = [
38
- 'domains',
39
- 'systems',
40
- 'services',
41
- 'events',
42
- 'commands',
43
- 'queries',
44
- 'channels',
45
- 'containers',
46
- ] as const;
47
-
48
46
  // ---- Inputs ----
49
47
 
50
48
  export const nodeSpecsSchema = z
@@ -71,6 +69,16 @@ export const nodeSpecsSchema = z
71
69
  .describe('A ref (or node id) of a container (a domain or system added as one) to put this node inside'),
72
70
  x: z.number().optional(),
73
71
  y: z.number().optional(),
72
+ width: z
73
+ .number()
74
+ .positive()
75
+ .optional()
76
+ .describe('For a container: its width to start with (it grows to fit what is put in it)'),
77
+ height: z
78
+ .number()
79
+ .positive()
80
+ .optional()
81
+ .describe('For a container: its height to start with (it grows to fit what is put in it)'),
74
82
  })
75
83
  )
76
84
  .min(1);
@@ -83,6 +91,83 @@ export const edgeSpecsSchema = z.array(
83
91
  })
84
92
  );
85
93
 
94
+ const pointSchema = z.object({ x: z.number(), y: z.number() });
95
+
96
+ /**
97
+ * Edges for a new canvas (through the Studio API): as add_to_canvas takes them, and how each is drawn, if known
98
+ * (e.g. copied from a diagram): the points it goes through (canvas coordinates, at right angles) and where its
99
+ * label goes. It follows its nodes when they're moved.
100
+ */
101
+ const routeSchema = z.object({ points: z.array(pointSchema).min(2).max(200), label: pointSchema.optional() });
102
+ export const newCanvasEdgesSchema = z.array(edgeSpecsSchema.element.extend({ route: routeSchema.optional() }));
103
+ export type NewCanvasEdges = z.output<typeof newCanvasEdgesSchema>;
104
+
105
+ const MAX_LEVEL_NODES = 1000;
106
+ const levelSchema = z.object({
107
+ nodes: z
108
+ .array(
109
+ z.object({
110
+ ref: z.string().describe("The ref of the node it is on the canvas (or its own id, if it's only on this level)"),
111
+ type: z.string(),
112
+ data: z.record(z.string(), z.unknown()),
113
+ parent: z.string().optional().describe('The ref of the node it is shown in on this level'),
114
+ x: z.number().describe('Its top-left corner (relative to its parent)'),
115
+ y: z.number(),
116
+ width: z.number().positive().optional().describe('For a container: its size'),
117
+ height: z.number().positive().optional(),
118
+ })
119
+ )
120
+ .max(MAX_LEVEL_NODES),
121
+ edges: z
122
+ .array(
123
+ z.object({
124
+ from: z.string(),
125
+ to: z.string(),
126
+ label: z.string().optional(),
127
+ type: z.string().optional(),
128
+ style: z.record(z.string(), z.unknown()).optional(),
129
+ markerEnd: z.unknown().optional(),
130
+ data: z.record(z.string(), z.unknown()).optional().describe("The edge's data, e.g. its route"),
131
+ })
132
+ )
133
+ .max(MAX_LEVEL_NODES * 2),
134
+ });
135
+
136
+ /**
137
+ * L1 and L2 of the diagram a canvas is opened from, laid out as the diagram showed them (by refs of the nodes given
138
+ * for the canvas): shown as they are until the canvas's structure changes
139
+ */
140
+ const unavailableReason = z.string().max(300).optional();
141
+ export const diagramLevelsSchema = z.object({
142
+ 1: levelSchema.optional(),
143
+ 2: levelSchema.optional(),
144
+ unavailable: z
145
+ .object({ 1: unavailableReason, 2: unavailableReason })
146
+ .optional()
147
+ .describe("Levels the diagram doesn't have, and why"),
148
+ });
149
+ export type DiagramLevelsSpec = z.output<typeof diagramLevelsSchema>;
150
+
151
+ /** A diagram's level, with refs swapped for the ids of the nodes made for them */
152
+ const toDiagramLevel = (level: z.output<typeof levelSchema>, idOf: (ref: string) => string) => {
153
+ const shown = new Set(level.nodes.map((node) => node.ref));
154
+ return {
155
+ nodes: level.nodes.map(
156
+ ({ ref, type, data, parent, x, y, width, height }): Node => ({
157
+ id: idOf(ref),
158
+ type,
159
+ data,
160
+ position: { x, y },
161
+ ...(parent && shown.has(parent) && { parentId: idOf(parent) }),
162
+ ...(width && height && { style: { width, height } }),
163
+ })
164
+ ),
165
+ edges: level.edges
166
+ .filter((edge) => shown.has(edge.from) && shown.has(edge.to))
167
+ .map(({ from, to, ...edge }, index) => ({ ...edge, id: `diagram-${index}`, source: idOf(from), target: idOf(to) }) as Edge),
168
+ };
169
+ };
170
+
86
171
  export const ADD_TO_CANVAS_DESCRIPTION = [
87
172
  'Adds nodes (and connections between them) to the canvas, live for everyone on it.',
88
173
  'A node is either a catalog resource (resource: { collection, id }), shown with its real name and summary and automatically connected to the related resources already on the canvas (e.g. a service to the events it publishes), or a new component (type, name, summary) for things that are not in the catalog yet, like a proposed service.',
@@ -199,6 +284,13 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
199
284
  const errors: string[] = [];
200
285
  const hasPosition = (spec: { x?: number; y?: number }) => spec.x !== undefined && spec.y !== undefined;
201
286
  const centerOf = (spec: { x?: number; y?: number }) => (hasPosition(spec) ? { x: spec.x!, y: spec.y! } : { x: 0, y: 0 });
287
+ // A container at the size asked for, still centred where asked
288
+ const sized = (node: Node, spec: { x?: number; y?: number; width?: number; height?: number }): Node => {
289
+ if (!isGroupType(node.type) || !spec.width || !spec.height) return node;
290
+ const center = centerOf(spec);
291
+ const { width, height } = spec;
292
+ return { ...node, width, height, position: { x: center.x - width / 2, y: center.y - height / 2 } };
293
+ };
202
294
 
203
295
  // Inside a container, at the canvas point asked for (relative to the container)
204
296
  const putInside = (node: Node, parent: Node, spec: { x?: number; y?: number }): Node => {
@@ -222,7 +314,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
222
314
  }
223
315
  const asContainer = spec.container && canBeContainer(resource);
224
316
  const node = asContainer
225
- ? buildContainer(resource, centerOf(spec))
317
+ ? sized(buildContainer(resource, centerOf(spec)), spec)
226
318
  : buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
227
319
  built = { node, name: resource.name, resource };
228
320
  } else {
@@ -238,7 +330,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
238
330
  ...(spec.summary && { summary: spec.summary }),
239
331
  ...(spec.version && isVersioned(definition.type) && { version: spec.version }),
240
332
  });
241
- const node = buildNode(definition.type, data, centerOf(spec));
333
+ const node = sized(buildNode(definition.type, data, centerOf(spec)), spec);
242
334
  built = { node, name: String(getResource(node.type, node.data).name ?? definition.label) };
243
335
  }
244
336
 
@@ -297,11 +389,44 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
297
389
  };
298
390
 
299
391
  /** Edges given by refs (or ids of nodes already on the canvas), as node ids */
300
- export const resolveEdgeRefs = (edges: EdgeSpecs, planned: PlannedNode[]) => {
301
- const refs = new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
392
+ const refIds = (planned: PlannedNode[]) =>
393
+ new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
394
+
395
+ export const resolveEdgeRefs = <Spec extends EdgeSpecs[number]>(edges: Spec[], planned: PlannedNode[]) => {
396
+ const refs = refIds(planned);
302
397
  return edges.map((edge) => ({ ...edge, from: refs.get(edge.from) ?? edge.from, to: refs.get(edge.to) ?? edge.to }));
303
398
  };
304
399
 
400
+ /**
401
+ * A new canvas's content from nodes and edges given as add_to_canvas takes them. Only the connections asked for
402
+ * are made: related catalog resources aren't connected as well, so the canvas shows just what was asked for (e.g.
403
+ * a diagram's connections, which may go through channels). Says what's wrong if anything can't be followed.
404
+ */
405
+ export const planCanvas = (
406
+ specs: NodeSpecs,
407
+ edges: NewCanvasEdges,
408
+ catalog: CatalogIndex,
409
+ levels?: DiagramLevelsSpec
410
+ ): { content: CanvasContent; errors: string[] } => {
411
+ const { planned, errors } = planNodes([], specs, catalog, edges);
412
+ const ids = new Set(planned.map((entry) => entry.node.id));
413
+ const connections = resolveEdgeRefs(edges, planned);
414
+ for (const end of connections.flatMap((connection) => [connection.from, connection.to])) {
415
+ if (!ids.has(end)) errors.push(`Edges connect nodes by their ref: no node has the ref "${end}"`);
416
+ }
417
+ const refs = refIds(planned);
418
+ const idOf = (ref: string) => refs.get(ref) ?? ref;
419
+ const diagramLevels = levels && {
420
+ ...(levels[1] && { 1: toDiagramLevel(levels[1], idOf) }),
421
+ ...(levels[2] && { 2: toDiagramLevel(levels[2], idOf) }),
422
+ ...(levels.unavailable && { unavailable: levels.unavailable }),
423
+ };
424
+ return {
425
+ content: { nodes: planned.map((entry) => entry.node), connections, ...(diagramLevels && { levels: diagramLevels }) },
426
+ errors: [...new Set(errors)],
427
+ };
428
+ };
429
+
305
430
  export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string };
306
431
 
307
432
  /** Renames, re-describes or re-versions a node. Catalog resources keep their catalog name, summary and version. */
@@ -377,9 +502,9 @@ export const getCatalogConnections = (
377
502
  };
378
503
 
379
504
  /**
380
- * A catalog resource centred on a canvas point (inside the container there, if there is one), with the resources
381
- * it connects to placed around it like an architect would (what comes in before it, what goes out after it), and
382
- * the connections between them and to what's already on the canvas. A service's data stores go in its container
505
+ * A catalog resource centred on a canvas point (inside the container asked for, else the one there, if any), with
506
+ * the resources it connects to placed around it like an architect would (what comes in before it, what goes out
507
+ * after it), and the connections between them and to what's already on the canvas. A service's data stores go in its container
383
508
  * with it. Positions are relative to the containers they're in, as the canvas is now.
384
509
  */
385
510
  export const planWithConnections = (
@@ -387,9 +512,10 @@ export const planWithConnections = (
387
512
  resource: CatalogResource,
388
513
  center: XYPosition,
389
514
  include: CatalogResource[],
390
- catalog: CatalogIndex
515
+ catalog: CatalogIndex,
516
+ inside?: string
391
517
  ): { nodes: Node[]; edges: Edge[] } => {
392
- const container = findGroupAtPoint(center, existing);
518
+ const container = containerFor(center, existing, inside);
393
519
  const specOf = (entry: CatalogResource) => ({
394
520
  resource: { collection: entry.collection as (typeof CATALOG_COLLECTIONS)[number], id: entry.id },
395
521
  });
@@ -1,6 +1,5 @@
1
1
  import type { Edge, Node } from '@xyflow/react';
2
- import { getAbsolutePosition, sizeOf } from './grouping';
3
- import { isGroupType } from './node-types';
2
+ import { getAbsolutePosition, sizeOf, isGroupType } from '@eventcatalog/visualiser/studio';
4
3
 
5
4
  /**
6
5
  * A canvas drawn small, for lists of canvases: each node as a box where it is on the canvas (containers as outlines
@@ -1,9 +1,6 @@
1
1
  import type { Edge, Node } from '@xyflow/react';
2
2
  import type { CatalogRelation, CatalogResource } from './catalog-resources';
3
- import { buildNode } from './canvas-doc';
4
- import { createEdge } from './edges';
5
- import { layoutGraph, withGroupSizes } from './layout';
6
- import { GROUP_TYPES, isGroupType } from './node-types';
3
+ import { buildNode, createEdge, layoutGraph, withGroupSizes, GROUP_TYPES, isGroupType } from '@eventcatalog/visualiser/studio';
7
4
 
8
5
  /**
9
6
  * Catalog resources on a canvas: the node for a resource, its connections to related resources already there,
@@ -66,16 +63,21 @@ export const canBeContainer = (resource: CatalogResource) =>
66
63
  export const containerTypeFor = (resource: CatalogResource) =>
67
64
  resource.collection === 'domains' ? GROUP_TYPES.domain : GROUP_TYPES.system;
68
65
 
69
- /** A container's data: the domain or system it stands for, and where it is in the catalog */
70
- export const catalogGroupData = (resource: CatalogResource) => ({
71
- [resource.collection === 'domains' ? 'domain' : 'system']: {
72
- id: resource.id,
73
- name: resource.name,
74
- version: resource.version,
75
- summary: resource.summary,
76
- },
77
- catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
78
- });
66
+ /** A container's data: the domain or system it stands for (an external system marked as one), and where it is in the catalog */
67
+ export const catalogGroupData = (resource: CatalogResource) => {
68
+ const key = resource.collection === 'domains' ? 'domain' : 'system';
69
+ const { scope } = (resource.node.data[key] ?? {}) as { scope?: string };
70
+ return {
71
+ [key]: {
72
+ id: resource.id,
73
+ name: resource.name,
74
+ version: resource.version,
75
+ summary: resource.summary,
76
+ ...(scope && { scope }),
77
+ },
78
+ catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
79
+ };
80
+ };
79
81
 
80
82
  /** What a domain or system contains in the catalog: a domain's systems and services, a system's services */
81
83
  export const getContents = (