@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
@@ -9,7 +9,8 @@ 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,
@@ -292,13 +293,14 @@ export async function playLayout(stage: AgentStage, layout: LayoutResult) {
292
293
  hand.say('Done');
293
294
  }
294
295
 
295
- /** Moves nodes and resizes containers from a layout, in one change, and marks when it happened */
296
+ /**
297
+ * Moves nodes, resizes containers and routes connections from a layout, in one change, and marks when it
298
+ * happened. Connections the layout didn't route lose their old route (drawn as a plain step instead).
299
+ */
296
300
  export const applyLayout = (doc: Y.Doc, layout: LayoutResult) =>
297
301
  doc.transact(() => {
298
- layout.forEach(({ position, size }, id) => {
299
- moveNode(doc, id, position);
300
- if (size) resizeNode(doc, id, size);
301
- });
302
+ layout.nodes.forEach(({ position, size }, id) => placeNode(doc, id, position, size));
303
+ setEdgeRoutes(doc, layout.routes);
302
304
  setMeta(doc, { layoutAt: Date.now() });
303
305
  });
304
306
 
@@ -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, CanvasStatus } from '../canvas-doc';
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,7 +1,7 @@
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';
4
+ import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData, type CanvasContent } from './canvas-doc';
5
5
  import type { CatalogRelation, CatalogResource } from './catalog-resources';
6
6
  import {
7
7
  buildContainer,
@@ -12,10 +12,11 @@ import {
12
12
  getRelatedEdges,
13
13
  indexRelations,
14
14
  } from './catalog';
15
- import { findGroupAtPoint, getAbsolutePosition, sizeOf } from './grouping';
15
+ import { containerFor, getAbsolutePosition, sizeOf } from './grouping';
16
16
  import { placeNodes, type Link } from './placement';
17
17
  import {
18
18
  canGoInContainer,
19
+ CATALOG_COLLECTIONS,
19
20
  COMPONENT_TYPES,
20
21
  getNodeDefinition,
21
22
  getNodeLabel,
@@ -34,17 +35,6 @@ import {
34
35
  * browser), so both describe and change a canvas the same way.
35
36
  */
36
37
 
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
38
  // ---- Inputs ----
49
39
 
50
40
  export const nodeSpecsSchema = z
@@ -71,6 +61,16 @@ export const nodeSpecsSchema = z
71
61
  .describe('A ref (or node id) of a container (a domain or system added as one) to put this node inside'),
72
62
  x: z.number().optional(),
73
63
  y: z.number().optional(),
64
+ width: z
65
+ .number()
66
+ .positive()
67
+ .optional()
68
+ .describe('For a container: its width to start with (it grows to fit what is put in it)'),
69
+ height: z
70
+ .number()
71
+ .positive()
72
+ .optional()
73
+ .describe('For a container: its height to start with (it grows to fit what is put in it)'),
74
74
  })
75
75
  )
76
76
  .min(1);
@@ -83,6 +83,83 @@ export const edgeSpecsSchema = z.array(
83
83
  })
84
84
  );
85
85
 
86
+ const pointSchema = z.object({ x: z.number(), y: z.number() });
87
+
88
+ /**
89
+ * Edges for a new canvas (through the Studio API): as add_to_canvas takes them, and how each is drawn, if known
90
+ * (e.g. copied from a diagram): the points it goes through (canvas coordinates, at right angles) and where its
91
+ * label goes. It follows its nodes when they're moved.
92
+ */
93
+ const routeSchema = z.object({ points: z.array(pointSchema).min(2).max(200), label: pointSchema.optional() });
94
+ export const newCanvasEdgesSchema = z.array(edgeSpecsSchema.element.extend({ route: routeSchema.optional() }));
95
+ export type NewCanvasEdges = z.output<typeof newCanvasEdgesSchema>;
96
+
97
+ const MAX_LEVEL_NODES = 1000;
98
+ const levelSchema = z.object({
99
+ nodes: z
100
+ .array(
101
+ z.object({
102
+ 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)"),
103
+ type: z.string(),
104
+ data: z.record(z.string(), z.unknown()),
105
+ parent: z.string().optional().describe('The ref of the node it is shown in on this level'),
106
+ x: z.number().describe('Its top-left corner (relative to its parent)'),
107
+ y: z.number(),
108
+ width: z.number().positive().optional().describe('For a container: its size'),
109
+ height: z.number().positive().optional(),
110
+ })
111
+ )
112
+ .max(MAX_LEVEL_NODES),
113
+ edges: z
114
+ .array(
115
+ z.object({
116
+ from: z.string(),
117
+ to: z.string(),
118
+ label: z.string().optional(),
119
+ type: z.string().optional(),
120
+ style: z.record(z.string(), z.unknown()).optional(),
121
+ markerEnd: z.unknown().optional(),
122
+ data: z.record(z.string(), z.unknown()).optional().describe("The edge's data, e.g. its route"),
123
+ })
124
+ )
125
+ .max(MAX_LEVEL_NODES * 2),
126
+ });
127
+
128
+ /**
129
+ * L1 and L2 of the diagram a canvas is opened from, laid out as the diagram showed them (by refs of the nodes given
130
+ * for the canvas): shown as they are until the canvas's structure changes
131
+ */
132
+ const unavailableReason = z.string().max(300).optional();
133
+ export const diagramLevelsSchema = z.object({
134
+ 1: levelSchema.optional(),
135
+ 2: levelSchema.optional(),
136
+ unavailable: z
137
+ .object({ 1: unavailableReason, 2: unavailableReason })
138
+ .optional()
139
+ .describe("Levels the diagram doesn't have, and why"),
140
+ });
141
+ export type DiagramLevelsSpec = z.output<typeof diagramLevelsSchema>;
142
+
143
+ /** A diagram's level, with refs swapped for the ids of the nodes made for them */
144
+ const toDiagramLevel = (level: z.output<typeof levelSchema>, idOf: (ref: string) => string) => {
145
+ const shown = new Set(level.nodes.map((node) => node.ref));
146
+ return {
147
+ nodes: level.nodes.map(
148
+ ({ ref, type, data, parent, x, y, width, height }): Node => ({
149
+ id: idOf(ref),
150
+ type,
151
+ data,
152
+ position: { x, y },
153
+ ...(parent && shown.has(parent) && { parentId: idOf(parent) }),
154
+ ...(width && height && { style: { width, height } }),
155
+ })
156
+ ),
157
+ edges: level.edges
158
+ .filter((edge) => shown.has(edge.from) && shown.has(edge.to))
159
+ .map(({ from, to, ...edge }, index) => ({ ...edge, id: `diagram-${index}`, source: idOf(from), target: idOf(to) }) as Edge),
160
+ };
161
+ };
162
+
86
163
  export const ADD_TO_CANVAS_DESCRIPTION = [
87
164
  'Adds nodes (and connections between them) to the canvas, live for everyone on it.',
88
165
  '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 +276,13 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
199
276
  const errors: string[] = [];
200
277
  const hasPosition = (spec: { x?: number; y?: number }) => spec.x !== undefined && spec.y !== undefined;
201
278
  const centerOf = (spec: { x?: number; y?: number }) => (hasPosition(spec) ? { x: spec.x!, y: spec.y! } : { x: 0, y: 0 });
279
+ // A container at the size asked for, still centred where asked
280
+ const sized = (node: Node, spec: { x?: number; y?: number; width?: number; height?: number }): Node => {
281
+ if (!isGroupType(node.type) || !spec.width || !spec.height) return node;
282
+ const center = centerOf(spec);
283
+ const { width, height } = spec;
284
+ return { ...node, width, height, position: { x: center.x - width / 2, y: center.y - height / 2 } };
285
+ };
202
286
 
203
287
  // Inside a container, at the canvas point asked for (relative to the container)
204
288
  const putInside = (node: Node, parent: Node, spec: { x?: number; y?: number }): Node => {
@@ -222,7 +306,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
222
306
  }
223
307
  const asContainer = spec.container && canBeContainer(resource);
224
308
  const node = asContainer
225
- ? buildContainer(resource, centerOf(spec))
309
+ ? sized(buildContainer(resource, centerOf(spec)), spec)
226
310
  : buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
227
311
  built = { node, name: resource.name, resource };
228
312
  } else {
@@ -238,7 +322,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
238
322
  ...(spec.summary && { summary: spec.summary }),
239
323
  ...(spec.version && isVersioned(definition.type) && { version: spec.version }),
240
324
  });
241
- const node = buildNode(definition.type, data, centerOf(spec));
325
+ const node = sized(buildNode(definition.type, data, centerOf(spec)), spec);
242
326
  built = { node, name: String(getResource(node.type, node.data).name ?? definition.label) };
243
327
  }
244
328
 
@@ -297,11 +381,44 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
297
381
  };
298
382
 
299
383
  /** 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] : [])));
384
+ const refIds = (planned: PlannedNode[]) =>
385
+ new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
386
+
387
+ export const resolveEdgeRefs = <Spec extends EdgeSpecs[number]>(edges: Spec[], planned: PlannedNode[]) => {
388
+ const refs = refIds(planned);
302
389
  return edges.map((edge) => ({ ...edge, from: refs.get(edge.from) ?? edge.from, to: refs.get(edge.to) ?? edge.to }));
303
390
  };
304
391
 
392
+ /**
393
+ * A new canvas's content from nodes and edges given as add_to_canvas takes them. Only the connections asked for
394
+ * are made: related catalog resources aren't connected as well, so the canvas shows just what was asked for (e.g.
395
+ * a diagram's connections, which may go through channels). Says what's wrong if anything can't be followed.
396
+ */
397
+ export const planCanvas = (
398
+ specs: NodeSpecs,
399
+ edges: NewCanvasEdges,
400
+ catalog: CatalogIndex,
401
+ levels?: DiagramLevelsSpec
402
+ ): { content: CanvasContent; errors: string[] } => {
403
+ const { planned, errors } = planNodes([], specs, catalog, edges);
404
+ const ids = new Set(planned.map((entry) => entry.node.id));
405
+ const connections = resolveEdgeRefs(edges, planned);
406
+ for (const end of connections.flatMap((connection) => [connection.from, connection.to])) {
407
+ if (!ids.has(end)) errors.push(`Edges connect nodes by their ref: no node has the ref "${end}"`);
408
+ }
409
+ const refs = refIds(planned);
410
+ const idOf = (ref: string) => refs.get(ref) ?? ref;
411
+ const diagramLevels = levels && {
412
+ ...(levels[1] && { 1: toDiagramLevel(levels[1], idOf) }),
413
+ ...(levels[2] && { 2: toDiagramLevel(levels[2], idOf) }),
414
+ ...(levels.unavailable && { unavailable: levels.unavailable }),
415
+ };
416
+ return {
417
+ content: { nodes: planned.map((entry) => entry.node), connections, ...(diagramLevels && { levels: diagramLevels }) },
418
+ errors: [...new Set(errors)],
419
+ };
420
+ };
421
+
305
422
  export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string };
306
423
 
307
424
  /** Renames, re-describes or re-versions a node. Catalog resources keep their catalog name, summary and version. */
@@ -377,9 +494,9 @@ export const getCatalogConnections = (
377
494
  };
378
495
 
379
496
  /**
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
497
+ * A catalog resource centred on a canvas point (inside the container asked for, else the one there, if any), with
498
+ * the resources it connects to placed around it like an architect would (what comes in before it, what goes out
499
+ * 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
500
  * with it. Positions are relative to the containers they're in, as the canvas is now.
384
501
  */
385
502
  export const planWithConnections = (
@@ -387,9 +504,10 @@ export const planWithConnections = (
387
504
  resource: CatalogResource,
388
505
  center: XYPosition,
389
506
  include: CatalogResource[],
390
- catalog: CatalogIndex
507
+ catalog: CatalogIndex,
508
+ inside?: string
391
509
  ): { nodes: Node[]; edges: Edge[] } => {
392
- const container = findGroupAtPoint(center, existing);
510
+ const container = containerFor(center, existing, inside);
393
511
  const specOf = (entry: CatalogResource) => ({
394
512
  resource: { collection: entry.collection as (typeof CATALOG_COLLECTIONS)[number], id: entry.id },
395
513
  });
@@ -1,8 +1,9 @@
1
1
  import * as Y from 'yjs';
2
2
  import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
3
+ import type { EdgeRoute } from '@eventcatalog/visualiser/layout';
3
4
  import { createEdge, getEdgeLabel } from './edges';
4
5
  import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
5
- import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
6
+ import { getNodeDefinition, getNodeName, getNodeSize, isGroupType } from './node-types';
6
7
 
7
8
  /**
8
9
  * Everything a canvas holds, as operations on its Yjs document. The same functions run in the
@@ -35,6 +36,24 @@ export type CanvasMeta = {
35
36
  createdBy?: string;
36
37
  /** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
37
38
  layoutAt?: number;
39
+ /** L1 and L2 as the diagram the canvas was opened from showed them (see DiagramLevels) */
40
+ diagramLevels?: DiagramLevels;
41
+ };
42
+
43
+ /** A level of detail as a diagram showed it: its nodes (laid out) and edges, by canvas node ids where they're the same */
44
+ export type DiagramLevel = { nodes: Node[]; edges: Edge[] };
45
+
46
+ /**
47
+ * L1 and L2 of the diagram a canvas was opened from (the visualiser's "Open in Studio"), shown as they were while the
48
+ * canvas's structure is what was opened (`structure`, from getStructureKey): moving nodes keeps them, changing what's
49
+ * on the canvas or how it's connected doesn't (the levels are then worked out from the canvas)
50
+ */
51
+ export type DiagramLevels = {
52
+ structure: string;
53
+ 1?: DiagramLevel;
54
+ 2?: DiagramLevel;
55
+ /** Levels the diagram doesn't have, and why (as the diagram says) */
56
+ unavailable?: { 1?: string; 2?: string };
38
57
  };
39
58
 
40
59
  // Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
@@ -52,8 +71,43 @@ export const getCanvasMaps = (doc: Y.Doc) => ({
52
71
  edges: doc.getMap<Edge>('edges'),
53
72
  threads: doc.getMap<Y.Map<unknown>>('threads'),
54
73
  meta: doc.getMap<unknown>('meta'),
74
+ /** Where things are on L1 and L2 (see LevelPlace), one entry per node and level (`1:<node id>`) */
75
+ levelLayouts: doc.getMap<LevelPlace>('levelLayouts'),
55
76
  });
56
77
 
78
+ /**
79
+ * Where a node is on L1 or L2, as people arranged it there (or as the diagram it was opened from had it): its
80
+ * top-left corner, relative to the container it's shown in on that level, and a container's size
81
+ */
82
+ export type LevelPlace = { x: number; y: number; width?: number; height?: number };
83
+
84
+ /** The places on a level, by node id */
85
+ export const readLevelLayout = (doc: Y.Doc, level: 1 | 2) => {
86
+ const prefix = `${level}:`;
87
+ const places = new Map<string, LevelPlace>();
88
+ getCanvasMaps(doc).levelLayouts.forEach((place, key) => {
89
+ if (key.startsWith(prefix)) places.set(key.slice(prefix.length), place);
90
+ });
91
+ return places;
92
+ };
93
+
94
+ const samePlace = (a: LevelPlace | undefined, b: LevelPlace) =>
95
+ !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
96
+
97
+ /**
98
+ * Puts nodes where they go on a level (in one transaction). Places that haven't changed aren't written: everyone
99
+ * looking at the level works out the same places for what's new on it, and each write re-arranges the level for
100
+ * everyone else.
101
+ */
102
+ export const setLevelPlaces = (doc: Y.Doc, level: 1 | 2, places: ReadonlyMap<string, LevelPlace>) =>
103
+ doc.transact(() => {
104
+ const { levelLayouts } = getCanvasMaps(doc);
105
+ places.forEach((place, id) => {
106
+ const key = `${level}:${id}`;
107
+ if (!samePlace(levelLayouts.get(key), place)) levelLayouts.set(key, place);
108
+ });
109
+ });
110
+
57
111
  // Only the design is shared. Selection, measured sizes and drag state stay with each user.
58
112
  export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
59
113
  id,
@@ -84,9 +138,6 @@ export const buildNode = (type: string, data: Record<string, unknown>, center: X
84
138
 
85
139
  // ---- Meta ----
86
140
 
87
- /** Canvas titles are at most this long */
88
- export const MAX_TITLE_LENGTH = 200;
89
-
90
141
  export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
91
142
 
92
143
  export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
@@ -154,13 +205,23 @@ export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
154
205
  edges.forEach((edge) => maps.edges.set(edge.id, edge));
155
206
  });
156
207
 
157
- export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => {
208
+ /**
209
+ * Moves a node, and resizes it if a size is given, in one write. Nodes are only written when they change: every
210
+ * write is sent to everyone, re-renders the node for them, and is kept in the document (and the undo stack), even
211
+ * when it writes what was already there.
212
+ */
213
+ export const placeNode = (doc: Y.Doc, id: string, position: XYPosition, size?: { width: number; height: number }) => {
158
214
  const { nodes } = getCanvasMaps(doc);
159
215
  const node = nodes.get(id);
160
- if (node) nodes.set(id, { ...node, position });
161
- return !!node;
216
+ if (!node) return false;
217
+ const moved = node.position.x !== position.x || node.position.y !== position.y;
218
+ const resized = !!size && (node.width !== size.width || node.height !== size.height);
219
+ if (moved || resized) nodes.set(id, { ...node, position, ...(size && { width: size.width, height: size.height }) });
220
+ return true;
162
221
  };
163
222
 
223
+ export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => placeNode(doc, id, position);
224
+
164
225
  /** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
165
226
  export const resizeNode = (
166
227
  doc: Y.Doc,
@@ -170,7 +231,9 @@ export const resizeNode = (
170
231
  ) => {
171
232
  const { nodes } = getCanvasMaps(doc);
172
233
  const node = nodes.get(id);
173
- if (node)
234
+ const width = attributes !== 'height' && node?.width !== size.width;
235
+ const height = attributes !== 'width' && node?.height !== size.height;
236
+ if (node && (width || height))
174
237
  nodes.set(id, {
175
238
  ...node,
176
239
  ...(attributes !== 'height' && { width: size.width }),
@@ -235,6 +298,93 @@ export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<st
235
298
  return !!node;
236
299
  };
237
300
 
301
+ /**
302
+ * How a connection is drawn, as the visualiser's layout routes it (in data.route, which the visualiser's edges
303
+ * are drawn along): the points it goes through, where its label goes, and where its nodes were (top-left) when
304
+ * it was routed, for it to follow them when they move
305
+ */
306
+ export type { EdgeRoute };
307
+
308
+ /** An edge's route, if it has one */
309
+ export const getEdgeRoute = (edge: Pick<Edge, 'data'>) => (edge.data as { route?: EdgeRoute } | undefined)?.route;
310
+
311
+ /** Routes connections (by edge id), in one transaction. Connections not in it lose any route they had. */
312
+ export const setEdgeRoutes = (doc: Y.Doc, routes: ReadonlyMap<string, EdgeRoute>) =>
313
+ doc.transact(() => {
314
+ const { edges } = getCanvasMaps(doc);
315
+ edges.forEach((edge, id) => {
316
+ const route = routes.get(id);
317
+ if (route) edges.set(id, { ...edge, data: { ...edge.data, route } });
318
+ else if (getEdgeRoute(edge)) {
319
+ const { route: _, ...data } = edge.data!;
320
+ edges.set(id, { ...edge, data });
321
+ }
322
+ });
323
+ });
324
+
325
+ /**
326
+ * What's on a canvas and how it's connected, not where: its nodes (not notes), what they're in and called, and its
327
+ * connections. The same for the same canvas wherever it's worked out (the server, any browser).
328
+ */
329
+ export const getStructureKey = (nodes: Node[], edges: Edge[]) => {
330
+ const parts = [
331
+ ...nodes
332
+ .filter((node) => node.type !== 'note')
333
+ .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
334
+ .sort(),
335
+ '#',
336
+ ...edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).sort(),
337
+ ];
338
+ // FNV-1a, so it's short enough to keep in the canvas
339
+ let hash = 0x811c9dc5;
340
+ for (const char of parts.join('|')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
341
+ return `${parts.length}-${(hash >>> 0).toString(36)}`;
342
+ };
343
+
344
+ /** The diagram's L1 or L2 while the canvas still has the structure it was opened with (`structure`, its key now) */
345
+ export const getDiagramLevel = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
346
+ levels?.[level] && !levels.unavailable?.[level] && levels.structure === structure ? levels[level] : undefined;
347
+
348
+ /** Why the diagram doesn't have a level, while the canvas still has the structure it was opened with */
349
+ export const getDiagramLevelUnavailable = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
350
+ levels?.unavailable?.[level] && levels.structure === structure ? levels.unavailable[level] : undefined;
351
+
352
+ /** What a new canvas starts with: its nodes, the connections between them (by node id) with their routes, and the
353
+ * levels of the diagram it's opened from */
354
+ export type CanvasContent = {
355
+ nodes: Node[];
356
+ connections: { from: string; to: string; label?: string; route?: Pick<EdgeRoute, 'points' | 'label'> }[];
357
+ levels?: Pick<DiagramLevels, 1 | 2 | 'unavailable'>;
358
+ };
359
+
360
+ /**
361
+ * Puts a new canvas's content on it (in one transaction), with its containers fitted to what's in them. Routes
362
+ * are from where the nodes are put.
363
+ */
364
+ export const addCanvasContent = (doc: Y.Doc, { nodes, connections, levels }: CanvasContent) =>
365
+ doc.transact(() => {
366
+ addNodes(doc, nodes);
367
+ nodes.forEach((node) => node.parentId && fitContainersAround(doc, node.id));
368
+ const placed = new Map(getCanvasMaps(doc).nodes.entries());
369
+ const routes = new Map<string, EdgeRoute>();
370
+ connections.forEach(({ from, to, label, route }) => {
371
+ const edge = connectNodes(doc, { source: from, target: to }, label);
372
+ const [source, target] = [placed.get(from), placed.get(to)];
373
+ if (!route || 'error' in edge || !source || !target) return;
374
+ routes.set(edge.id, {
375
+ ...route,
376
+ source: getAbsolutePosition(source, placed),
377
+ target: getAbsolutePosition(target, placed),
378
+ });
379
+ });
380
+ if (routes.size) setEdgeRoutes(doc, routes);
381
+ if (levels) {
382
+ const maps = getCanvasMaps(doc);
383
+ const structure = getStructureKey(Array.from(maps.nodes.values()), Array.from(maps.edges.values()));
384
+ setMeta(doc, { diagramLevels: { structure, ...levels } });
385
+ }
386
+ });
387
+
238
388
  /** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
239
389
  export const connectNodes = (
240
390
  doc: Y.Doc,
@@ -299,7 +449,7 @@ export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
299
449
  /** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
300
450
  export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
301
451
  doc.transact(() => {
302
- const { nodes, edges, threads } = getCanvasMaps(doc);
452
+ const { nodes, edges, threads, levelLayouts } = getCanvasMaps(doc);
303
453
  const all = Array.from(nodes.values());
304
454
  const lookup = new Map(all.map((node) => [node.id, node]));
305
455
  const removed = withDescendants(ids, all);
@@ -313,7 +463,12 @@ export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
313
463
  thread.delete('nodeId');
314
464
  thread.delete('offset');
315
465
  });
316
- removed.forEach((id) => nodes.delete(id));
466
+ removed.forEach((id) => {
467
+ nodes.delete(id);
468
+ // Where it was on L1 and L2 (undoing the delete puts it back there)
469
+ levelLayouts.delete(`1:${id}`);
470
+ levelLayouts.delete(`2:${id}`);
471
+ });
317
472
  edges.forEach((edge, id) => {
318
473
  if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
319
474
  });