@eventcatalog/core 5.7.0 → 5.7.2

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 (38) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-FMWQ2YYN.js → chunk-B3FUBDJN.js} +1 -1
  6. package/dist/{chunk-4CV7VC4K.js → chunk-EPI5BHEU.js} +1 -1
  7. package/dist/{chunk-MOPKQMQP.js → chunk-NNTWQNQ5.js} +1 -1
  8. package/dist/{chunk-BE5PTWNC.js → chunk-OR4VBY3F.js} +1 -1
  9. package/dist/{chunk-22KZ4TJF.js → chunk-TYZENGGJ.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +702 -128
  19. package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
  20. package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
  21. package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
  22. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
  23. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +72 -71
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  28. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  29. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  30. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  31. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +116 -13
  32. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  33. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  34. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  35. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  36. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -15
  37. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  38. package/package.json +4 -4
@@ -3,6 +3,7 @@ import type * as Y from 'yjs';
3
3
  import {
4
4
  addNodes,
5
5
  connectNodes,
6
+ createHotspot,
6
7
  createThread,
7
8
  deleteEdges,
8
9
  deleteNodes,
@@ -14,6 +15,7 @@ import {
14
15
  setMeta,
15
16
  type Author,
16
17
  type CommentAnchor,
18
+ type HotspotSeverity,
17
19
  getAbsolutePosition,
18
20
  sizeOf,
19
21
  type LayoutResult,
@@ -21,6 +23,7 @@ import {
21
23
  } from '@eventcatalog/visualiser/studio';
22
24
  import {
23
25
  describeNode,
26
+ expandCatalogSpecs,
24
27
  nodeCenter,
25
28
  planNodes,
26
29
  resolveEdgeRefs,
@@ -63,6 +66,11 @@ export type AgentStage = {
63
66
  * end up, like a person's drag. Otherwise each step of a move is saved, so others see it move.
64
67
  */
65
68
  sharesMoves?: boolean;
69
+ /**
70
+ * How long the agent takes over everything, as a share of a person's pace (1, the default): lower is faster, e.g.
71
+ * the assistant, which someone is waiting on in the chat
72
+ */
73
+ pace?: number;
66
74
  };
67
75
 
68
76
  const FRAME_MS = 40;
@@ -88,7 +96,8 @@ const nameOf = (node: Node) => getNodeName(node.type, node.data);
88
96
  * The agent's hand: moves its pointer smoothly (in frames, so it glides for everyone) and shows what it's doing.
89
97
  * `pace` slows down or speeds up everything (big changes go faster, so a tool call doesn't take too long).
90
98
  */
91
- export function createAgentHand(stage: AgentStage, { pace = 1 }: { pace?: number } = {}) {
99
+ export function createAgentHand(stage: AgentStage, { pace: changePace = 1 }: { pace?: number } = {}) {
100
+ const pace = changePace * (stage.pace ?? 1);
92
101
  const presence: AgentPresence = { pointer: stage.lastPointer ?? null, selection: [] };
93
102
  const show = () => stage.present({ ...presence });
94
103
 
@@ -169,39 +178,59 @@ async function playConnection(
169
178
  return outcome;
170
179
  }
171
180
 
172
- /** addToCanvas, played out: each node dropped in turn, connected (to related resources and as asked) as soon as it can be */
181
+ /**
182
+ * addToCanvas, played out: each node dropped in turn, connected (to related resources and as asked) as soon as it can
183
+ * be. A domain or system with its contents is dropped in one go, as people add one from the catalog.
184
+ */
173
185
  export async function playAddToCanvas(
174
186
  stage: AgentStage,
175
- { nodes: specs, edges = [] }: { nodes: NodeSpecs; edges?: EdgeSpecs },
187
+ { nodes, edges = [] }: { nodes: NodeSpecs; edges?: EdgeSpecs },
176
188
  catalog: CatalogIndex
177
189
  ) {
178
190
  const existing = await stage.change((doc) => [...readNodes(doc).values()]);
179
- const { planned, errors } = planNodes(existing, specs, catalog, edges);
191
+ const expanded = await expandCatalogSpecs(existing, nodes, catalog);
192
+ const { planned, errors } = planNodes(existing, expanded.specs, catalog, edges, expanded.blocks);
193
+ errors.unshift(...expanded.errors);
180
194
  const pending = resolveEdgeRefs(edges, planned);
181
- const hand = createAgentHand(stage, { pace: paceFor(planned.length * 2 + pending.length) });
195
+ const drops = planned.filter((entry) => !entry.partOf);
196
+ const hand = createAgentHand(stage, { pace: paceFor(drops.length * 2 + pending.length) });
182
197
  const connections: string[] = [];
183
198
 
184
- for (const { node: plannedNode, canvasPosition, name, resource } of planned) {
185
- hand.say(`Adding ${name}`);
199
+ for (const { node: plannedNode, canvasPosition, name, resource } of drops) {
200
+ const contents = planned.filter((entry) => entry.partOf === plannedNode.id);
201
+ hand.say(contents.length ? `Adding ${name} with what it holds` : `Adding ${name}`);
186
202
  const { width, height } = sizeOf(plannedNode);
187
203
  await hand.moveTo({ x: canvasPosition.x + width / 2, y: canvasPosition.y + height / 2 });
188
204
  await hand.pause(150);
189
- const { node, related } = await stage.change((doc) => {
205
+ const { node, related, connectedWith } = await stage.change((doc) => {
190
206
  const onCanvas = readNodes(doc);
191
207
  // Where it goes on the canvas, relative to its container as it is now (containers move as they grow)
192
208
  const parent = plannedNode.parentId ? onCanvas.get(plannedNode.parentId) : undefined;
193
209
  const origin = parent ? getAbsolutePosition(parent, onCanvas) : { x: 0, y: 0 };
194
210
  const node = { ...plannedNode, position: { x: canvasPosition.x - origin.x, y: canvasPosition.y - origin.y } };
211
+ // With what it holds (positioned in it), connected to each other and to what's on the canvas
212
+ const added = [node, ...contents.map((entry) => entry.node)];
213
+ const placed = [...onCanvas.values()];
214
+ const related: Edge[] = [];
215
+ for (const [index, entry] of [{ node, resource }, ...contents].entries()) {
216
+ if (entry.resource)
217
+ related.push(...getRelatedEdges(entry.resource, entry.node.id, placed, catalog.relationsByKey, entry.node.type));
218
+ placed.push(added[index]);
219
+ }
220
+ if (contents.length) {
221
+ addNodes(doc, added, related);
222
+ fitContainersAround(doc, node.id);
223
+ return { node, related: [], connectedWith: related };
224
+ }
195
225
  addNodes(doc, [node]);
196
226
  // The containers it's in grow to fit it
197
227
  fitContainersAround(doc, node.id);
198
- const related = resource
199
- ? getRelatedEdges(resource, node.id, [...onCanvas.values()], catalog.relationsByKey, node.type)
200
- : [];
201
- return { node, related };
228
+ return { node, related, connectedWith: [] };
202
229
  });
230
+ connections.push(...connectedWith.map((edge) => edge.id));
231
+ // Just what it dropped (a domain, not everything in it)
203
232
  hand.select([node.id]);
204
- await hand.pause(300);
233
+ await hand.pause(contents.length ? 600 : 300);
205
234
 
206
235
  // Its connections to what's already there: related catalog resources, then the ones asked for
207
236
  for (const edge of related) {
@@ -229,6 +258,8 @@ export async function playAddToCanvas(
229
258
  if ('error' in outcome) errors.push(outcome.error);
230
259
  }
231
260
 
261
+ // Lets go of what it added, so it isn't left selected (and labelled with the agent) for everyone
262
+ hand.select([]);
232
263
  hand.say('Done');
233
264
  return { created: planned.map(({ ref, node, name }) => ({ ref, nodeId: node.id, name })), connections, errors };
234
265
  }
@@ -345,6 +376,43 @@ export async function playComment(
345
376
  return { threadId };
346
377
  }
347
378
 
379
+ /** Flagging a hotspot, played out: go to the node (its top right corner, where hotspots sit) or the place, then flag it */
380
+ export async function playHotspot(
381
+ stage: AgentStage,
382
+ {
383
+ severity,
384
+ title,
385
+ description,
386
+ nodeId,
387
+ x,
388
+ y,
389
+ }: { severity: HotspotSeverity; title: string; description?: string; nodeId?: string; x?: number; y?: number },
390
+ author: Author
391
+ ) {
392
+ const nodes = await stage.change(readNodes);
393
+ const node = nodeId ? nodes.get(nodeId) : undefined;
394
+ if (nodeId && !node) return { error: `No node "${nodeId}" on the canvas` };
395
+ const corner = node && positionOf(node, nodes);
396
+ const position = node && corner ? { x: corner.x + sizeOf(node).width, y: corner.y } : { x: x ?? 0, y: y ?? 0 };
397
+ const hand = createAgentHand(stage);
398
+ hand.say('Flagging a hotspot');
399
+ await hand.moveTo(position);
400
+ if (node) hand.select([node.id]);
401
+ await hand.pause(400);
402
+ const hotspotId = await stage.change((doc) =>
403
+ createHotspot(doc, {
404
+ severity,
405
+ title,
406
+ ...(description && { description }),
407
+ ...(node && { nodeId: node.id }),
408
+ position,
409
+ author,
410
+ })
411
+ );
412
+ hand.select([]);
413
+ return { hotspotId };
414
+ }
415
+
348
416
  /** Pointing at a node while changing it (e.g. a rename) */
349
417
  export async function playOnNode<T>(stage: AgentStage, nodeId: string, activity: string, change: (doc: Y.Doc) => T) {
350
418
  const nodes = await stage.change(readNodes);
@@ -362,12 +430,12 @@ export async function playOnNode<T>(stage: AgentStage, nodeId: string, activity:
362
430
  /** Updating a node, played out: point at it to rename or re-describe it, then drag it to its new centre (x/y) */
363
431
  export async function playUpdateNode(
364
432
  stage: AgentStage,
365
- { nodeId, name, summary, version, x, y }: NodeUpdate & { x?: number; y?: number }
433
+ { nodeId, name, summary, version, fontSize, x, y }: NodeUpdate & { x?: number; y?: number }
366
434
  ) {
367
435
  const missing = { error: `No node "${nodeId}" on the canvas` };
368
- if (name !== undefined || summary !== undefined || version !== undefined) {
436
+ if (name !== undefined || summary !== undefined || version !== undefined || fontSize !== undefined) {
369
437
  const outcome = await playOnNode(stage, nodeId, 'Editing', (doc) =>
370
- updateCanvasNode(doc, { nodeId, name, summary, version })
438
+ updateCanvasNode(doc, { nodeId, name, summary, version, fontSize })
371
439
  );
372
440
  if ('error' in outcome) return outcome;
373
441
  }
@@ -148,6 +148,9 @@ const readNewCanvas = async (c: ApiContext): Promise<{ details: NewCanvasDetails
148
148
  if (!parsed.success) return c.json({ error: describeIssues(parsed.error) }, 400);
149
149
  const details = detailsOf(c, parsed.data);
150
150
  if (!parsed.data.nodes) return { details };
151
+ // What they bring from the catalog is worked out on a canvas as it is (with the agent tools), not for a new one
152
+ if (parsed.data.nodes.some((node) => node.contents || node.connections))
153
+ return c.json({ error: 'contents and connections can only be used when adding to a canvas: list the nodes instead' }, 400);
151
154
  const { resources, relations } = await getCatalogResources();
152
155
  const { content, errors } = planCanvas(
153
156
  parsed.data.nodes,
@@ -25,11 +25,16 @@ import {
25
25
  isOnLevel,
26
26
  isVersioned,
27
27
  isWrittenOnCanvas,
28
+ readHotspots,
29
+ sortByHierarchy,
30
+ TEXT_FONT_SIZE,
31
+ type LayoutResult,
28
32
  updateResource,
29
33
  } from '@eventcatalog/visualiser/studio';
30
34
  import type { CatalogRelation, CatalogResource } from './catalog-resources';
31
35
  import {
32
36
  buildContainer,
37
+ buildContainerWithContents,
33
38
  canBeContainer,
34
39
  catalogNodeData,
35
40
  containerTypeFor,
@@ -45,6 +50,12 @@ import {
45
50
 
46
51
  // ---- Inputs ----
47
52
 
53
+ /** What agents can add as new components: what people add from the palette, and sticky notes and text (from the bar) */
54
+ export const AGENT_COMPONENT_TYPES = [...COMPONENT_TYPES, 'note', 'text'] as const;
55
+
56
+ /** What a catalog service or message can bring with it: a service's messages and data stores, a message's services */
57
+ export const CONNECTION_GROUP_IDS = ['receives', 'sends', 'dataStores', 'senders', 'receivers'] as const;
58
+
48
59
  export const nodeSpecsSchema = z
49
60
  .array(
50
61
  z.object({
@@ -53,16 +64,40 @@ export const nodeSpecsSchema = z
53
64
  .object({ collection: z.enum(CATALOG_COLLECTIONS), id: z.string() })
54
65
  .optional()
55
66
  .describe('A catalog resource to add (latest version)'),
56
- type: z.enum(COMPONENT_TYPES).optional().describe('For a new component: what it is'),
67
+ type: z
68
+ .enum(AGENT_COMPONENT_TYPES)
69
+ .optional()
70
+ .describe('For a new component: what it is (note: a sticky note; text: words written on the canvas)'),
57
71
  name: z.string().optional().describe('For a new component: its name'),
58
- summary: z.string().optional().describe('For a new component: what it does. For a note or text: its text'),
72
+ summary: z
73
+ .string()
74
+ .optional()
75
+ .describe('For a new component: what it does. For a note or text: its text (keep notes to a few words)'),
59
76
  version: z.string().optional().describe('For a new component: its version, e.g. 1.0.0 (defaults to 0.0.1)'),
77
+ fontSize: z
78
+ .number()
79
+ .min(8)
80
+ .max(200)
81
+ .optional()
82
+ .describe(`For text: how big it's written, in pixels (${TEXT_FONT_SIZE} by default; e.g. 48 for a heading)`),
60
83
  container: z
61
84
  .boolean()
62
85
  .optional()
63
86
  .describe(
64
87
  'For a domain or system from the catalog: add it as a container that other nodes sit inside, rather than a card'
65
88
  ),
89
+ contents: z
90
+ .boolean()
91
+ .optional()
92
+ .describe(
93
+ 'For a domain or system from the catalog: add it as a container with everything it holds in the catalog (its systems, services, and their messages and data stores), connected and laid out, in one go. Use it to add an existing domain or system rather than adding what is in it one by one'
94
+ ),
95
+ connections: z
96
+ .array(z.enum(CONNECTION_GROUP_IDS))
97
+ .optional()
98
+ .describe(
99
+ 'For a service or message from the catalog: bring what it connects to in the catalog too, placed around it and connected. For a service: receives (messages it receives), sends (messages it sends), dataStores. For a message: senders, receivers (the services that send and receive it)'
100
+ ),
66
101
  inside: z
67
102
  .string()
68
103
  .optional()
@@ -179,6 +214,7 @@ export const ADD_TO_CANVAS_DESCRIPTION = [
179
214
  '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.',
180
215
  'Give each node a ref to connect new nodes to each other in edges.',
181
216
  'Domains and systems can be containers (container: true for catalog ones, or type domain-group / system-group for new ones): put services, messages and data stores inside one with inside (its ref or node id), e.g. a domain container with its system containers inside, and their services inside those.',
217
+ 'To add a domain or system that is in the catalog with what it holds, give it contents: true: its systems, services, messages and data stores come with it, connected and laid out, in one go (as people add one from the catalog), so don\'t add them one by one. To bring what a catalog service or message connects to, give it connections (e.g. ["sends", "receives"] for a service, ["senders", "receivers"] for a message).',
182
218
  'Leave out x/y and each node is placed like an architect would: next to what it connects to (in edges, or as a related catalog resource), following the flow left to right (producers on the left of their messages, consumers on the right), lined up with it, and stacked when several connect to the same node. Nothing already on the canvas moves, and nodes connected to nothing go below what is there.',
183
219
  "Give x/y (a node's centre, like the positions from reading the canvas) only to put a node somewhere specific. Nodes are about 240 wide and 110 tall: leave about 200 between connected nodes for the labels on their connections.",
184
220
  ].join(' ');
@@ -258,6 +294,13 @@ export const describeCanvas = (doc: Y.Doc) => {
258
294
  to: edge.target,
259
295
  description: `${names.get(edge.source)} ${String(edge.label || '→').replace(/\s+/g, ' ')} ${names.get(edge.target)}`,
260
296
  })),
297
+ hotspots: readHotspots(doc).map((hotspot) => ({
298
+ hotspotId: hotspot.id,
299
+ severity: hotspot.severity,
300
+ title: hotspot.title,
301
+ ...(hotspot.description && { description: hotspot.description }),
302
+ ...(hotspot.nodeId ? { onNode: hotspot.nodeId } : { position: hotspot.position }),
303
+ })),
261
304
  comments: canvas.threads.map((thread) => ({
262
305
  threadId: thread.id,
263
306
  ...(thread.nodeId ? { onNode: thread.nodeId } : { position: thread.position }),
@@ -276,13 +319,30 @@ export type EdgeSpecs = z.output<typeof edgeSpecsSchema>;
276
319
  * Its node's position is relative to its container, as the canvas is now; `canvasPosition` is where it goes on
277
320
  * the canvas, which stays the same when containers grow to fit what's added (and move what's in them).
278
321
  */
279
- export type PlannedNode = { ref?: string; node: Node; name: string; resource?: CatalogResource; canvasPosition: XYPosition };
322
+ export type PlannedNode = {
323
+ ref?: string;
324
+ node: Node;
325
+ name: string;
326
+ resource?: CatalogResource;
327
+ canvasPosition: XYPosition;
328
+ /** In a domain or system added with its contents (the container's node id): added with it, in one go */
329
+ partOf?: string;
330
+ };
331
+
332
+ /** Domains and systems added with their contents, built and laid out (the container first), by the spec they're for */
333
+ export type CatalogBlocks = Map<NodeSpecs[number], Node[]>;
280
334
 
281
335
  /**
282
336
  * Builds the nodes to add (catalog resources, as cards or containers, or new components), and places them:
283
337
  * where asked, or next to what they connect to (the edges asked for, and related catalog resources)
284
338
  */
285
- export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIndex, edges: EdgeSpecs = []) => {
339
+ export const planNodes = (
340
+ existing: Node[],
341
+ specs: NodeSpecs,
342
+ catalog: CatalogIndex,
343
+ edges: EdgeSpecs = [],
344
+ blocks: CatalogBlocks = new Map()
345
+ ) => {
286
346
  const lookup: NodeLookup = new Map(existing.map((node) => [node.id, node]));
287
347
  const refs = new Map<string, Node>();
288
348
  const planned: PlannedNode[] = [];
@@ -298,6 +358,12 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
298
358
  return { ...node, width, height, position: { x: center.x - width / 2, y: center.y - height / 2 } };
299
359
  };
300
360
 
361
+ // A domain or system laid out with its contents, centred where asked
362
+ const centred = (node: Node, center: XYPosition): Node => {
363
+ const { width, height } = sizeOf(node);
364
+ return { ...node, position: { x: center.x - width / 2, y: center.y - height / 2 } };
365
+ };
366
+
301
367
  // Inside a container, at the canvas point asked for (relative to the container)
302
368
  const putInside = (node: Node, parent: Node, spec: { x?: number; y?: number }): Node => {
303
369
  if (!hasPosition(spec)) return { ...node, parentId: parent.id };
@@ -318,19 +384,26 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
318
384
  errors.push(`No ${spec.resource.collection} resource "${spec.resource.id}" in the catalog`);
319
385
  continue;
320
386
  }
321
- const asContainer = spec.container && canBeContainer(resource);
322
- const node = asContainer
323
- ? sized(buildContainer(resource, centerOf(spec)), spec)
324
- : buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
387
+ const asContainer = (spec.container || spec.contents) && canBeContainer(resource);
388
+ const block = blocks.get(spec);
389
+ const node = block
390
+ ? centred(block[0], centerOf(spec))
391
+ : asContainer
392
+ ? sized(buildContainer(resource, centerOf(spec)), spec)
393
+ : buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
325
394
  built = { node, name: resource.name, resource };
326
395
  } else {
327
396
  const definition = getNodeDefinition(spec.type);
328
397
  if (!definition) {
329
- errors.push(`Give each node a resource, or a type (one of ${COMPONENT_TYPES.join(', ')})`);
398
+ errors.push(`Give each node a resource, or a type (one of ${AGENT_COMPONENT_TYPES.join(', ')})`);
330
399
  continue;
331
400
  }
332
401
  const data = isWrittenOnCanvas(definition.type)
333
- ? { ...definition.createData(), text: spec.summary ?? spec.name ?? '' }
402
+ ? {
403
+ ...definition.createData(),
404
+ text: spec.summary ?? spec.name ?? '',
405
+ ...(spec.fontSize && definition.type === 'text' && { fontSize: spec.fontSize }),
406
+ }
334
407
  : updateResource(definition.type, definition.createData(), {
335
408
  ...(spec.name && { name: spec.name }),
336
409
  ...(spec.summary && { summary: spec.summary }),
@@ -351,11 +424,35 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
351
424
  lookup.set(built.node.id, built.node);
352
425
  if (spec.ref) refs.set(spec.ref, built.node);
353
426
  planned.push({ ref: spec.ref, ...built, canvasPosition: getAbsolutePosition(built.node, lookup) });
427
+
428
+ // What a domain or system holds, as it was laid out in it
429
+ for (const node of (spec.resource && blocks.get(spec)?.slice(1)) || []) {
430
+ const key = getCatalogLink(node)?.key;
431
+ const resource = key ? catalog.resourcesByKey.get(key) : undefined;
432
+ lookup.set(node.id, node);
433
+ planned.push({
434
+ node,
435
+ name: resource?.name ?? getNodeName(node.type, node.data, node.id),
436
+ resource,
437
+ canvasPosition: getAbsolutePosition(node, lookup),
438
+ partOf: built.node.id,
439
+ });
440
+ }
354
441
  }
355
442
 
356
443
  // What the new nodes connect to: the edges asked for, and related catalog resources (as they'll be connected)
357
444
  const links = new Map<string, Link>();
358
- const link = (source: string, target: string) => links.set(`${source}>${target}`, { source, target });
445
+ // What's in a domain or system added with its contents is placed with it: its links are the container's
446
+ const inBlocks = new Set(planned.flatMap((entry) => (entry.partOf ? [entry.node.id] : [])));
447
+ const placedWith = (id: string) => {
448
+ let node = lookup.get(id);
449
+ while (node?.parentId && inBlocks.has(node.id)) node = lookup.get(node.parentId);
450
+ return node?.id ?? id;
451
+ };
452
+ const link = (from: string, to: string) => {
453
+ const [source, target] = [placedWith(from), placedWith(to)];
454
+ if (source !== target) links.set(`${source}>${target}`, { source, target });
455
+ };
359
456
  const nodeIdOf = (refOrId: string) => refs.get(refOrId)?.id ?? refOrId;
360
457
  edges.forEach((edge) => link(nodeIdOf(edge.from), nodeIdOf(edge.to)));
361
458
  const nodesByKey = new Map<string, Node[]>();
@@ -390,10 +487,88 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
390
487
  entry.canvasPosition = canvasPosition;
391
488
  entry.node = { ...entry.node, position: { x: canvasPosition.x - origin.x, y: canvasPosition.y - origin.y } };
392
489
  }
490
+ // What's in a domain or system added with its contents goes where it went
491
+ for (const entry of planned) {
492
+ const parentPosition = entry.partOf && entry.node.parentId ? canvasPositions.get(entry.node.parentId) : undefined;
493
+ if (!parentPosition) continue;
494
+ entry.canvasPosition = { x: parentPosition.x + entry.node.position.x, y: parentPosition.y + entry.node.position.y };
495
+ canvasPositions.set(entry.node.id, entry.canvasPosition);
496
+ }
497
+
498
+ // Notes and text go where they're asked, or the nearest clear spot to it: never on top of anything
499
+ const existingLookup = new Map(existing.map((node) => [node.id, node]));
500
+ const sizeOnCanvas = (node: Node) => (isWrittenOnCanvas(node.type) ? writtenSize(node) : sizeOf(node));
501
+ const taken: Rect[] = [
502
+ ...existing
503
+ .filter((node) => isOnLevel(node, 3))
504
+ .map((node) => ({ ...getAbsolutePosition(node, existingLookup), ...sizeOnCanvas(node) })),
505
+ ...planned
506
+ .filter((entry) => !isWrittenOnCanvas(entry.node.type) && isOnLevel(entry.node, 3))
507
+ .map((entry) => ({ ...entry.canvasPosition, ...sizeOf(entry.node) })),
508
+ ];
509
+ for (const entry of planned) {
510
+ if (!isWrittenOnCanvas(entry.node.type) || entry.node.parentId || !isOnLevel(entry.node, 3)) continue;
511
+ const size = writtenSize(entry.node);
512
+ const spot = clearSpot(entry.canvasPosition, size, taken, entry.node.type === 'note' ? RIGHT_FIRST : UP_FIRST);
513
+ entry.canvasPosition = spot;
514
+ entry.node = { ...entry.node, position: spot };
515
+ taken.push({ ...spot, ...size });
516
+ }
393
517
 
394
518
  return { planned, errors };
395
519
  };
396
520
 
521
+ const keyOf = (resource: { collection: string; id: string }) => `${resource.collection}:${resource.id}`;
522
+
523
+ /**
524
+ * What specs bring from the catalog, as people get it there: the connections asked for (as more specs, after the
525
+ * one they're for) and the contents of domains and systems (built and laid out, for planNodes). Nothing already on
526
+ * the canvas, or brought by an earlier spec, is brought again.
527
+ */
528
+ export const expandCatalogSpecs = async (existing: Node[], specs: NodeSpecs, catalog: CatalogIndex) => {
529
+ const expanded: NodeSpecs = [];
530
+ const blocks: CatalogBlocks = new Map();
531
+ const errors: string[] = [];
532
+ const brought = [...existing];
533
+ const keys = new Set(
534
+ [...existing.map((node) => getCatalogLink(node)?.key), ...specs.map((spec) => spec.resource && keyOf(spec.resource))].filter(
535
+ (key): key is string => !!key
536
+ )
537
+ );
538
+
539
+ for (const spec of specs) {
540
+ expanded.push(spec);
541
+ const resource = spec.resource && catalog.resourcesByKey.get(keyOf(spec.resource));
542
+ if (!resource) continue;
543
+
544
+ if (spec.contents && !canBeContainer(resource))
545
+ errors.push(`Only domains and systems have contents: "${resource.name}" was added on its own`);
546
+ else if (spec.contents) {
547
+ // Nothing else in this call (its specs, or what their connections bring) is brought again
548
+ const block = sortByHierarchy((await buildContainerWithContents(resource, { x: 0, y: 0 }, catalog, brought, keys)).nodes);
549
+ blocks.set(spec, block);
550
+ brought.push(...block);
551
+ for (const node of block) {
552
+ const key = getCatalogLink(node)?.key;
553
+ if (key) keys.add(key);
554
+ }
555
+ }
556
+
557
+ if (!spec.connections?.length) continue;
558
+ const wanted = new Set<string>(spec.connections);
559
+ const groups = getCatalogConnections(resource, catalog, keys).filter((group) => wanted.has(group.id));
560
+ for (const other of groups.flatMap((group) => group.resources)) {
561
+ keys.add(other.key);
562
+ expanded.push({
563
+ resource: { collection: other.collection as (typeof CATALOG_COLLECTIONS)[number], id: other.id },
564
+ // A service's data stores go in its container with it
565
+ ...(spec.inside && other.collection === 'containers' && { inside: spec.inside }),
566
+ });
567
+ }
568
+ }
569
+ return { specs: expanded, blocks, errors };
570
+ };
571
+
397
572
  /** Edges given by refs (or ids of nodes already on the canvas), as node ids */
398
573
  const refIds = (planned: PlannedNode[]) =>
399
574
  new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
@@ -433,17 +608,157 @@ export const planCanvas = (
433
608
  };
434
609
  };
435
610
 
436
- export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string };
611
+ /** How far a sticky note or text can be from a node to be about it (to move with it when the canvas is laid out) */
612
+ const NOTE_REACH = 400;
613
+ /** Notes and text that would land on something are moved this far at a time, up to this many times, to clear it */
614
+ const NUDGE = 40;
615
+ const MAX_NUDGES = 80;
616
+ /** The least room left between a note or text and what's around it */
617
+ const NOTE_CLEARANCE = 24;
618
+
619
+ type Rect = { x: number; y: number; width: number; height: number };
620
+ const overlaps = (a: Rect, b: Rect) =>
621
+ a.x < b.x + b.width + NOTE_CLEARANCE &&
622
+ b.x < a.x + a.width + NOTE_CLEARANCE &&
623
+ a.y < b.y + b.height + NOTE_CLEARANCE &&
624
+ b.y < a.y + a.height + NOTE_CLEARANCE;
625
+ const boundsOf = (rects: Rect[]): Rect | undefined => {
626
+ if (!rects.length) return undefined;
627
+ const x = Math.min(...rects.map((rect) => rect.x));
628
+ const y = Math.min(...rects.map((rect) => rect.y));
629
+ return {
630
+ x,
631
+ y,
632
+ width: Math.max(...rects.map((rect) => rect.x + rect.width)) - x,
633
+ height: Math.max(...rects.map((rect) => rect.y + rect.height)) - y,
634
+ };
635
+ };
636
+
637
+ /** How big a note or text is drawn: text is as big as what's written (the canvas only knows once it's drawn) */
638
+ const writtenSize = (node: Node) => {
639
+ // A sticky note grows taller with what's written on it (about 20 characters a line)
640
+ if (node.type === 'note') {
641
+ const { width, height } = sizeOf(node);
642
+ const lines = Math.ceil(String(node.data.text ?? '').length / 20);
643
+ return { width, height: Math.max(height, lines * 22 + 60) };
644
+ }
645
+ if (node.type !== 'text') return sizeOf(node);
646
+ const fontSize = typeof node.data.fontSize === 'number' ? node.data.fontSize : TEXT_FONT_SIZE;
647
+ const lines = String(node.data.text ?? '').split('\n');
648
+ return {
649
+ width: Math.max(1, ...lines.map((line) => line.length)) * fontSize * 0.6,
650
+ height: lines.length * fontSize * 1.4,
651
+ };
652
+ };
653
+
654
+ const UP_FIRST = [
655
+ [0, -1],
656
+ [1, 0],
657
+ [0, 1],
658
+ [-1, 0],
659
+ ] as const;
660
+ const RIGHT_FIRST = [
661
+ [1, 0],
662
+ [0, -1],
663
+ [0, 1],
664
+ [-1, 0],
665
+ ] as const;
666
+
667
+ /**
668
+ * The nearest place to `at` where something this big is clear of everything taken, trying each way in turn: text
669
+ * upwards first (titles stay above), notes to the right first (beside what they're about)
670
+ */
671
+ const clearSpot = (
672
+ at: XYPosition,
673
+ size: { width: number; height: number },
674
+ taken: Rect[],
675
+ ways: readonly (readonly [number, number])[] = UP_FIRST
676
+ ): XYPosition => {
677
+ const isClear = (spot: XYPosition) => !taken.some((rect) => overlaps({ ...spot, ...size }, rect));
678
+ if (isClear(at)) return at;
679
+ for (let step = 1; step <= MAX_NUDGES; step++) {
680
+ const distance = step * NUDGE;
681
+ for (const [dx, dy] of ways) {
682
+ const spot = { x: at.x + dx * distance, y: at.y + dy * distance };
683
+ if (isClear(spot)) return spot;
684
+ }
685
+ }
686
+ return at;
687
+ };
688
+
689
+ /**
690
+ * A layout with sticky notes and text placed too, as they were around the diagram: each beside a node moves with it
691
+ * (the nearest one, within NOTE_REACH of its middle), the rest (e.g. a title) keep their place around the whole
692
+ * diagram. None lands on anything: one that would is moved to the nearest clear spot.
693
+ */
694
+ export const withNotesPlaced = (nodes: Node[], layout: LayoutResult): LayoutResult => {
695
+ const before = new Map(nodes.map((node) => [node.id, node]));
696
+ const after = new Map(
697
+ nodes.map((node) => {
698
+ const placed = layout.nodes.get(node.id);
699
+ return [node.id, placed ? { ...node, position: placed.position, ...(placed.size ?? {}) } : node];
700
+ })
701
+ );
702
+ const rectIn = (lookup: Map<string, Node>) => (node: Node) => ({
703
+ ...getAbsolutePosition(lookup.get(node.id)!, lookup),
704
+ ...sizeOf(lookup.get(node.id)!),
705
+ });
706
+ const drawn = nodes.filter((node) => !isWrittenOnCanvas(node.type) && isOnLevel(node, 3));
707
+ /** How far a point is from a node (0 inside it), with what's in containers nearer than the containers */
708
+ const distanceTo = (point: XYPosition, node: Node) => {
709
+ const rect = rectIn(before)(node);
710
+ const dx = Math.max(rect.x - point.x, 0, point.x - (rect.x + rect.width));
711
+ const dy = Math.max(rect.y - point.y, 0, point.y - (rect.y + rect.height));
712
+ return Math.hypot(dx, dy) + (isGroupType(node.type) ? 1 : 0);
713
+ };
714
+ const boundsBefore = boundsOf(drawn.map(rectIn(before)));
715
+ const boundsAfter = boundsOf(drawn.map(rectIn(after)));
716
+ const aroundDiagram =
717
+ boundsBefore && boundsAfter ? { x: boundsAfter.x - boundsBefore.x, y: boundsAfter.y - boundsBefore.y } : { x: 0, y: 0 };
718
+
719
+ const taken: Rect[] = drawn.map(rectIn(after));
720
+ const placed = new Map(layout.nodes);
721
+ for (const note of nodes.filter((node) => isWrittenOnCanvas(node.type) && isOnLevel(node, 3) && !node.parentId)) {
722
+ const size = writtenSize(note);
723
+ const middle = { x: note.position.x + size.width / 2, y: note.position.y + size.height / 2 };
724
+ const nearest = drawn
725
+ .map((node) => ({ node, distance: distanceTo(middle, node) }))
726
+ .filter(({ distance }) => distance <= NOTE_REACH)
727
+ .sort((a, b) => a.distance - b.distance)[0]?.node;
728
+ const shift = nearest
729
+ ? (() => {
730
+ const from = getAbsolutePosition(nearest, before);
731
+ const to = getAbsolutePosition(after.get(nearest.id)!, after);
732
+ return { x: to.x - from.x, y: to.y - from.y };
733
+ })()
734
+ : aroundDiagram;
735
+ const position = clearSpot(
736
+ { x: note.position.x + shift.x, y: note.position.y + shift.y },
737
+ size,
738
+ taken,
739
+ note.type === 'note' ? RIGHT_FIRST : UP_FIRST
740
+ );
741
+ taken.push({ ...position, ...size });
742
+ placed.set(note.id, { position });
743
+ }
744
+ return { ...layout, nodes: placed };
745
+ };
746
+
747
+ export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string; fontSize?: number };
437
748
 
438
749
  /** Renames, re-describes or re-versions a node. Catalog resources keep their catalog name, summary and version. */
439
- export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary, version }: NodeUpdate) =>
750
+ export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary, version, fontSize }: NodeUpdate) =>
440
751
  doc.transact(() => {
441
752
  const node = getCanvasMaps(doc).nodes.get(nodeId);
442
753
  if (!node) return { error: `No node "${nodeId}" on the canvas` };
443
754
  if (!getCatalogLink(node)) {
444
755
  updateNodeData(doc, nodeId, (data) =>
445
756
  isWrittenOnCanvas(node.type)
446
- ? { ...data, text: summary ?? name ?? data.text }
757
+ ? {
758
+ ...data,
759
+ text: summary ?? name ?? data.text,
760
+ ...(fontSize && node.type === 'text' && { fontSize }),
761
+ }
447
762
  : updateResource(node.type, data, {
448
763
  ...(name !== undefined && { name }),
449
764
  ...(summary !== undefined && { summary }),
@@ -459,7 +774,7 @@ export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary, version }:
459
774
  const MESSAGE_COLLECTIONS = new Set(['events', 'commands', 'queries']);
460
775
 
461
776
  /** What a group of connections is: what a service sends, receives and stores, or which services send or receive a message */
462
- export type ConnectionGroupId = 'receives' | 'sends' | 'dataStores' | 'senders' | 'receivers';
777
+ export type ConnectionGroupId = (typeof CONNECTION_GROUP_IDS)[number];
463
778
  export type ConnectionGroup = { id: ConnectionGroupId; label: string; resources: CatalogResource[] };
464
779
 
465
780
  const GROUP_LABELS: Record<ConnectionGroupId, string> = {