@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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-FMWQ2YYN.js → chunk-B3FUBDJN.js} +1 -1
- package/dist/{chunk-4CV7VC4K.js → chunk-EPI5BHEU.js} +1 -1
- package/dist/{chunk-MOPKQMQP.js → chunk-NNTWQNQ5.js} +1 -1
- package/dist/{chunk-BE5PTWNC.js → chunk-OR4VBY3F.js} +1 -1
- package/dist/{chunk-22KZ4TJF.js → chunk-TYZENGGJ.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +702 -128
- package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
- package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
- package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
- package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
- package/eventcatalog/src/features/ai/chat-api.ts +24 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +72 -71
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
- package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
- package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
- package/eventcatalog/src/features/studio/catalog.ts +5 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +116 -13
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
- package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
- package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -15
- package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
- 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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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 = (
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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 ${
|
|
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
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? {
|
|
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 =
|
|
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> = {
|