@eventcatalog/core 5.0.0-beta.10 → 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 +5 -5
  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-CRKJBDN7.js → chunk-BWC4YKW3.js} +1 -1
  7. package/dist/{chunk-LJMXOEEC.js → chunk-CX2VB7AI.js} +1 -1
  8. package/dist/{chunk-IONYKKOY.js → chunk-FY6MSP6J.js} +1 -1
  9. package/dist/{chunk-2LBDRHE4.js → chunk-QB7X34OD.js} +1 -1
  10. package/dist/{chunk-EM36OL6A.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 +3 -3
@@ -1,20 +1,22 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
2
  import {
3
- Background,
3
+ applyNodeChanges,
4
4
  ConnectionLineType,
5
5
  ReactFlow,
6
6
  ReactFlowProvider,
7
7
  useReactFlow,
8
+ type Connection,
8
9
  type Edge,
9
10
  type Node,
10
11
  type NodeChange,
11
12
  type XYPosition,
12
13
  } from '@xyflow/react';
13
- import { edgeTypes } from '@eventcatalog/visualiser';
14
+ import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
14
15
  import { useHotkeys } from 'react-hotkeys-hook';
15
16
  import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
16
- import { isDecided } from '../canvas-doc';
17
+ import { getDiagramLevel, getDiagramLevelUnavailable, isDecided, type LevelPlace } from '../canvas-doc';
17
18
  import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
19
+ import { keepUnchanged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS } from '../flow-state';
18
20
  import type { CatalogRelation, CatalogResource } from '../catalog-resources';
19
21
  import {
20
22
  buildContainer,
@@ -27,8 +29,10 @@ import {
27
29
  } from '../catalog';
28
30
  import {
29
31
  findDropTarget,
32
+ containerFor,
30
33
  findGroupAtPoint,
31
34
  getAbsolutePosition,
35
+ getAbsoluteRect,
32
36
  previewContainerGrowth,
33
37
  sizeOf,
34
38
  sortByHierarchy,
@@ -40,7 +44,17 @@ import { useStudioFlow } from '../hooks/use-studio-flow';
40
44
  import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
41
45
  import { getLayoutPositions, layoutGraph } from '../layout';
42
46
  import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
43
- import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
47
+ import { arrangeLevel, getLevelNodeSize, levelPlaces, withDiagramRoutes } from '../level-layout';
48
+ import {
49
+ canGoInContainer,
50
+ getNodeDefinition,
51
+ getNodeLabel,
52
+ getNodeName,
53
+ getNoteLevel,
54
+ isOnLevel,
55
+ isShownOnLevel,
56
+ isWrittenOnCanvas,
57
+ } from '../node-types';
44
58
  import type { ToolSync } from '../tool-sync';
45
59
  import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
46
60
  import CanvasHeader from './CanvasHeader';
@@ -96,8 +110,30 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
96
110
 
97
111
  /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
98
112
  const LEVEL_RELAYOUT_MS = 300;
113
+
114
+ /**
115
+ * Where something added goes. While L1 or L2 is shown it's added to the canvas people edit (L3), below what's on it
116
+ * or in the container it's dropped in, and kept on the level where it's dropped.
117
+ */
118
+ /** Where a node of this size goes to be centred on a point */
119
+ const centredOn = (center: XYPosition, size: { width: number; height: number }): LevelPlace => ({
120
+ x: center.x - size.width / 2,
121
+ y: center.y - size.height / 2,
122
+ });
123
+
124
+ type Landing = {
125
+ /** Where it was dropped (or added), in what's shown */
126
+ point: XYPosition;
127
+ /** Where it's centred on the canvas, and the container it goes in there (else the one at that point, if any) */
128
+ center: XYPosition;
129
+ inside?: string;
130
+ /**
131
+ * Where it's centred on the level it was dropped on (in the container's coordinates when it's in one), and the
132
+ * size it's drawn at there before it's measured
133
+ */
134
+ onLevel?: { level: 1 | 2; center: XYPosition; size: { width: number; height: number } };
135
+ };
99
136
  const DELETE_KEYS = ['Backspace', 'Delete'];
100
- const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
101
137
  const NO_THREADS: ReadonlySet<string> = new Set();
102
138
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
103
139
  const onFlowError = (code: string, message: string) => {
@@ -238,6 +274,13 @@ function Canvas({
238
274
  const edgesRef = useRef(flow.edges);
239
275
  edgesRef.current = flow.edges;
240
276
  const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
277
+ /** Selects just these nodes, changing only those whose selection changes (each change copies its node) */
278
+ const select = (ids: ReadonlySet<string>) => {
279
+ const changes: NodeChange[] = nodesRef.current.flatMap((node) =>
280
+ !!node.selected === ids.has(node.id) ? [] : [{ type: 'select' as const, id: node.id, selected: ids.has(node.id) }]
281
+ );
282
+ if (changes.length) flow.onNodesChange(changes);
283
+ };
241
284
 
242
285
  // Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
243
286
  // WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
@@ -254,8 +297,7 @@ function Canvas({
254
297
  resources,
255
298
  catalog,
256
299
  agent: { name: `${name}'s agent`, color, agent: true },
257
- select: (ids) =>
258
- flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
300
+ select: (ids) => select(new Set(ids)),
259
301
  fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
260
302
  reorder: () => reorder(),
261
303
  });
@@ -279,27 +321,35 @@ function Canvas({
279
321
  // ---- Adding things ----
280
322
 
281
323
  // A domain or system dropped on the canvas: asked whether it's a card or a container first
282
- const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
324
+ const [chooser, setChooser] = useState<{ resource: CatalogResource; at: Landing } | null>(null);
283
325
  // A service or message that connects to things not on the canvas yet: asked whether to bring them too
284
326
  const [connectionsChooser, setConnectionsChooser] = useState<{
285
327
  resource: CatalogResource;
286
- center: XYPosition;
328
+ at: Landing;
287
329
  groups: ConnectionGroup[];
288
330
  } | null>(null);
289
331
 
290
332
  // A catalog resource is added with edges to the related resources already on the canvas
291
- const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
333
+ const addResource = (key: string, point: XYPosition) => {
292
334
  const resource = catalog.resourcesByKey.get(key);
293
335
  if (!resource) return;
294
- if (canBeContainer(resource)) return setChooser({ resource, center });
336
+ const at = landingAt(point, resource.node.type);
337
+ if (canBeContainer(resource)) return setChooser({ resource, at });
295
338
  const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
296
- if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
297
- addCard(resource, center);
298
- });
339
+ if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
340
+ addCard(resource, at);
341
+ };
299
342
 
300
- const addCard = (resource: CatalogResource, center: XYPosition) =>
301
- flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
302
- getRelatedEdges(resource, id, existing, catalog.relationsByKey)
343
+ const addCard = (resource: CatalogResource, at: Landing) =>
344
+ keepOnLevel(
345
+ at,
346
+ flow.insertNode(
347
+ resource.node.type,
348
+ catalogNodeData(resource),
349
+ at.center,
350
+ (id, existing) => getRelatedEdges(resource, id, existing, catalog.relationsByKey),
351
+ at.inside
352
+ )
303
353
  );
304
354
 
305
355
  // Nodes waiting to be shown before the canvas is laid out around them
@@ -315,38 +365,42 @@ function Canvas({
315
365
  }, [flow.nodes]);
316
366
 
317
367
  /** A domain or system as a container, with everything it holds in the catalog inside if asked */
318
- const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
368
+ const addContainer = async (resource: CatalogResource, at: Landing, withContents: boolean) => {
319
369
  const existing = nodesRef.current;
320
370
  const built = withContents
321
- ? await buildContainerWithContents(resource, center, catalog, existing)
322
- : { nodes: [buildContainer(resource, center)], edges: [] };
371
+ ? await buildContainerWithContents(resource, at.center, catalog, existing)
372
+ : { nodes: [buildContainer(resource, at.center)], edges: [] };
323
373
  // Dropped in another container (e.g. a system in its domain): it goes in it
324
- const outer = findGroupAtPoint(center, existing);
374
+ const outer = containerFor(at.center, existing, at.inside);
325
375
  const nodes = outer
326
376
  ? built.nodes.map((node) =>
327
377
  node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
328
378
  )
329
379
  : built.nodes;
330
- // With its contents, the canvas is laid out again to make room for it all
331
- if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
380
+ // With its contents, the canvas is laid out again to make room for it all (not when added on a level: it's
381
+ // below everything on the canvas, and nothing on the level moves)
382
+ if (withContents && existing.length > 0 && !at.onLevel) reorderOnceShown.current = nodes.map((node) => node.id);
332
383
  flow.insertNodes(nodes, built.edges);
384
+ keepOnLevel(at, built.nodes.find((node) => !node.parentId)?.id);
333
385
  };
334
386
 
335
387
  const choose = useStableCallback((choice: ContainerChoice) => {
336
388
  if (!chooser) return;
337
- if (choice.as === 'card') addCard(chooser.resource, chooser.center);
338
- else void addContainer(chooser.resource, chooser.center, choice.withContents);
389
+ if (choice.as === 'card') addCard(chooser.resource, chooser.at);
390
+ else void addContainer(chooser.resource, chooser.at, choice.withContents);
339
391
  setChooser(null);
340
392
  });
341
393
  const cancelChooser = useCallback(() => setChooser(null), []);
342
394
 
343
395
  const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
344
396
  if (!connectionsChooser) return;
345
- const { resource, center } = connectionsChooser;
346
- if (choice.as === 'card') addCard(resource, center);
397
+ const { resource, at } = connectionsChooser;
398
+ if (choice.as === 'card') addCard(resource, at);
347
399
  else {
348
- const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
400
+ const { nodes, edges } = planWithConnections(nodesRef.current, resource, at.center, choice.include, catalog, at.inside);
349
401
  flow.insertNodes(nodes, edges);
402
+ // What it connects to is placed next to it on the level
403
+ keepOnLevel(at, nodes.find((node) => getCatalogLink(node)?.key === resource.key)?.id);
350
404
  }
351
405
  setConnectionsChooser(null);
352
406
  });
@@ -370,25 +424,55 @@ function Canvas({
370
424
  flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
371
425
  };
372
426
  /**
373
- * Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
374
- * added there (shown on L3), below what's on it
427
+ * Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
428
+ * container there (and in the same container on the canvas). Something the level doesn't show (a message on L1)
429
+ * goes to L3, where it's shown.
375
430
  */
376
- const centerOnCanvas = (center: XYPosition) => {
377
- if (levelRef.current === 3) return center;
378
- changeLevel(3);
379
- const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
380
- if (roots.length === 0) return { x: 0, y: 0 };
381
- const left = Math.min(...roots.map((node) => node.position.x));
382
- const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
383
- const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
384
- return { x: (left + right) / 2, y: bottom + 160 };
431
+ const landingAt = (point: XYPosition, type: string | undefined): Landing => {
432
+ const level = levelRef.current;
433
+ if (level === 3) return { point, center: point };
434
+ const shownHere = isShownOnLevel(type, level);
435
+ if (!shownHere) changeLevel(3);
436
+ // The container it's dropped in on the level, which the canvas has too (levels keep the canvas's ids)
437
+ const shown = shownHere && levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : [];
438
+ const container = canGoInContainer(type) ? findGroupAtPoint(point, shown) : undefined;
439
+ const canvas = nodesRef.current;
440
+ const inside = container && containerFor(point, canvas, container.id) ? container.id : undefined;
441
+ // On the canvas: below what's in that container, or below everything
442
+ const lookup = new Map(canvas.map((node) => [node.id, node]));
443
+ const beside = canvas.filter((node) => (inside ? node.parentId === inside : !node.parentId && isOnLevel(node, 3)));
444
+ const rects = (beside.length ? beside : inside ? [lookup.get(inside)!] : []).map((node) => getAbsoluteRect(node, lookup));
445
+ const center = rects.length
446
+ ? {
447
+ x: (Math.min(...rects.map((rect) => rect.x)) + Math.max(...rects.map((rect) => rect.x + rect.width))) / 2,
448
+ // Below them, or in the middle of an empty container
449
+ y: beside.length ? Math.max(...rects.map((rect) => rect.y + rect.height)) + 160 : rects[0].y + rects[0].height / 2,
450
+ }
451
+ : { x: 0, y: 0 };
452
+ if (!shownHere) return { point, center };
453
+ const origin =
454
+ container && inside ? getAbsolutePosition(container, new Map(shown.map((node) => [node.id, node]))) : { x: 0, y: 0 };
455
+ const size = getLevelNodeSize(type, level);
456
+ return { point, center, inside, onLevel: { level, center: { x: point.x - origin.x, y: point.y - origin.y }, size } };
457
+ };
458
+ // Added on a level, until they're measured, with the size they were centred at: centred again at their real size
459
+ const centring = useRef(new Map<string, { level: 1 | 2; size: { width: number; height: number } }>());
460
+ /** Keeps what was added where it was dropped on the level (undone with adding it) */
461
+ const keepOnLevel = (at: Landing, id: string | undefined) => {
462
+ if (!id || !at.onLevel) return;
463
+ const { level, center, size } = at.onLevel;
464
+ centring.current.set(id, { level, size });
465
+ flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
466
+ };
467
+ const addComponentAt = (type: string, point: XYPosition) => {
468
+ if (type === 'note') return addNoteAt(point);
469
+ const at = landingAt(point, type);
470
+ keepOnLevel(at, flow.addNode(type, at.center, at.inside));
385
471
  };
386
- const addComponentAt = (type: string, center: XYPosition) =>
387
- type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
388
472
 
389
473
  const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
390
474
  const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
391
- const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
475
+ const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
392
476
 
393
477
  const onDrop = useStableCallback((event: DragEvent) => {
394
478
  event.preventDefault();
@@ -396,7 +480,7 @@ function Canvas({
396
480
  const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
397
481
  const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
398
482
  if (type) addComponentAt(type, position);
399
- if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
483
+ if (resourceKey) addResource(resourceKey, position);
400
484
  });
401
485
 
402
486
  // ---- Pointer, panning and dragging ----
@@ -486,8 +570,13 @@ function Canvas({
486
570
  // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
487
571
  const interactions = useRef({ moving: false, dragging: false });
488
572
  const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
573
+ const wasActive = interactions.current.moving || interactions.current.dragging;
489
574
  interactions.current[kind] = on;
490
- canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
575
+ const active = interactions.current.moving || interactions.current.dragging;
576
+ if (active === wasActive) return;
577
+ canvasRef.current?.classList.toggle('ec-interaction-active', active);
578
+ // (hiding them doesn't stop the envelopes' SMIL)
579
+ pauseEdgeAnimations(canvasRef.current, active);
491
580
  }, []);
492
581
 
493
582
  // The camera follows agents unless people move it themselves (set by AgentActivity)
@@ -589,43 +678,91 @@ function Canvas({
589
678
 
590
679
  // ---- Levels of detail ----
591
680
 
592
- // Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
593
- const [level, setLevel] = useState<Level>(3);
681
+ // Like EventCatalog's diagrams: L3 is everything on the canvas, L1 and L2 less detailed views of it, arranged and
682
+ // edited on their own (what's done there changes the canvas: a system card is the system). A canvas opened
683
+ // from a diagram opens at the level it was shown at (?level=1 or 2), once: reloading opens the canvas (L3).
684
+ const [level, setLevel] = useState<Level>(() => {
685
+ const url = new URL(window.location.href);
686
+ const asked = Number(url.searchParams.get('level'));
687
+ if (!url.searchParams.has('level')) return 3;
688
+ url.searchParams.delete('level');
689
+ window.history.replaceState(window.history.state, '', url);
690
+ return asked === 1 || asked === 2 ? asked : 3;
691
+ });
594
692
  const levelRef = useRef(level);
595
693
  levelRef.current = level;
694
+ // L3 is the canvas itself: comments and other people's pointers are there (L1 and L2 are arranged on their own)
596
695
  const editable = level === 3;
597
- const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
696
+ const [levelGraph, setLevelGraphState] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
697
+ // The level shown as it is now (for changes to it, and saving where things are put)
698
+ const levelGraphRef = useRef(levelGraph);
699
+ /** Changes to the level shown (moved, selected, measured), as they happen */
700
+ const updateLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] }) => {
701
+ levelGraphRef.current = next;
702
+ setLevelGraphState(next);
703
+ }, []);
704
+ /**
705
+ * Shows a level (arranged again), keeping what hasn't changed on it as it's shown (so it doesn't render again), and
706
+ * what's selected and nodes' measured sizes (not measured again). Containers come before what's in them, as React
707
+ * Flow needs (the canvas's order can have them after).
708
+ */
709
+ const setLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] } | null) => {
710
+ const previous = levelGraphRef.current;
711
+ const same = previous && next && previous.level === next.level ? previous : null;
712
+ const shown = next && {
713
+ ...next,
714
+ nodes: sortByHierarchy(same ? keepUnchanged(same.nodes, next.nodes, LOCAL_NODE_KEYS) : next.nodes),
715
+ edges: same ? keepUnchanged(same.edges, next.edges, LOCAL_EDGE_KEYS) : next.edges,
716
+ };
717
+ levelGraphRef.current = shown;
718
+ setLevelGraphState(shown);
719
+ }, []);
598
720
 
599
721
  // What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
600
722
  // aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
601
- const levelKey =
602
- level === 3
603
- ? ''
604
- : `${flow.nodes
605
- .filter((node) => node.type !== 'note')
606
- .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
607
- .join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
723
+ // Also whether a canvas opened from a diagram still has the diagram's levels: only worked out when needed
724
+ const levelKey = level === 3 ? '' : flow.structureKey;
608
725
  const shownLevelRef = useRef<Level | undefined>(undefined);
609
726
  shownLevelRef.current = levelGraph?.level;
727
+ const levelLayout = level === 3 ? undefined : flow.levelLayouts[level];
610
728
  useEffect(() => {
611
729
  if (level === 3) return setLevelGraph(null);
612
730
  let cancelled = false;
613
- // Straight away when switching levels; after changes settle while one is shown
614
- const timer = setTimeout(
615
- () => {
616
- const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
617
- void layoutGraph(
618
- collapsed.nodes.map((node) => ({ ...node, selected: false })),
619
- collapsed.edges
620
- ).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
621
- },
622
- shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
623
- );
731
+ const show = () => {
732
+ // A canvas opened from a diagram shows the diagram's levels as it did, until what's on the canvas changes
733
+ const fromDiagram = getDiagramLevel(flow.diagramLevels, level, flow.structureKey);
734
+ const diagram = flow.diagramLevels?.[level];
735
+ const derived = fromDiagram ? undefined : getLevelGraph(nodesRef.current, edgesRef.current, level);
736
+ const content = fromDiagram ?? { ...derived!, edges: withDiagramRoutes(derived!.edges, diagram?.edges) };
737
+ // Where things are: as arranged on the level, else as the diagram had them, and anything new next to what it
738
+ // connects to (kept there, so nothing moves later)
739
+ const arranged = arrangeLevel(content, flow.levelLayouts[level], diagram && levelPlaces(diagram.nodes));
740
+ if (arranged) {
741
+ setLevelGraph({ level, ...arranged.graph });
742
+ if (arranged.placed.size) flow.setLevelLayout(level, arranged.placed, 'level');
743
+ return;
744
+ }
745
+ // Nothing on it has a place yet: laid out with the visualiser's layout once, and kept
746
+ void layoutGraph(
747
+ content.nodes.map((node) => ({ ...node, selected: false })),
748
+ content.edges
749
+ ).then((laidOut) => {
750
+ if (cancelled) return;
751
+ setLevelGraph({ level, ...laidOut });
752
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes), 'level');
753
+ });
754
+ };
755
+ // Straight away when switching levels (or from the diagram); after changes settle while one is shown
756
+ if (shownLevelRef.current !== level || getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) {
757
+ show();
758
+ return () => void (cancelled = true);
759
+ }
760
+ const timer = setTimeout(show, LEVEL_RELAYOUT_MS);
624
761
  return () => {
625
762
  cancelled = true;
626
763
  clearTimeout(timer);
627
764
  };
628
- }, [level, levelKey]);
765
+ }, [level, levelKey, flow.diagramLevels, levelLayout]);
629
766
 
630
767
  // Comments: right click > Add comment, or C for comment mode then click (like Figma)
631
768
  const [commentMode, setCommentMode] = useState(false);
@@ -652,19 +789,34 @@ function Canvas({
652
789
  });
653
790
  const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
654
791
 
655
- // Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
792
+ // Fit the camera to a level once it's laid out (and to the canvas when going back to L3). A level shown before the
793
+ // canvas has arrived (opened at ?level=) is laid out empty first: it's fitted once it has something to show.
656
794
  const shownLevel = level === 3 ? 3 : levelGraph?.level;
795
+ const levelHasNodes = !!levelGraph?.nodes.length;
657
796
  useEffect(() => {
658
- if (shownLevel === undefined) return;
659
- // Like the visualiser, a level isn't zoomed in past its real size
660
- const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
661
- const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
797
+ if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
798
+ const frame = requestAnimationFrame(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }));
662
799
  return () => cancelAnimationFrame(frame);
663
- }, [shownLevel]);
800
+ }, [shownLevel, levelHasNodes]);
664
801
 
665
- /** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
802
+ /**
803
+ * Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there). On L1 or
804
+ * L2 it lays out just that level (one step to undo), leaving the canvas as it is.
805
+ */
666
806
  const reorder = useStableCallback(async () => {
667
- if (levelRef.current !== 3) setLevel(3);
807
+ const level = levelRef.current;
808
+ if (level !== 3) {
809
+ const content =
810
+ getDiagramLevel(flow.diagramLevels, level, flow.structureKey) ?? getLevelGraph(nodesRef.current, edgesRef.current, level);
811
+ const laidOut = await layoutGraph(
812
+ content.nodes.map((node) => ({ ...node, selected: false })),
813
+ content.edges
814
+ );
815
+ glide();
816
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
817
+ setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
818
+ return;
819
+ }
668
820
  const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
669
821
  flow.applyPositions(positions);
670
822
  setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
@@ -692,27 +844,97 @@ function Canvas({
692
844
  const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
693
845
  return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
694
846
  }, [levelShown, sortedNodes]);
695
- /** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
847
+
848
+ // The legend, as the visualiser's: how many of each kind of node is shown (not what's written on the canvas), and
849
+ // clicking a kind hides or shows it. Hiding only changes what this person sees.
850
+ const [hiddenLegendKeys, setHiddenLegendKeys] = useState<string[]>([]);
851
+ const toggleLegendKey = useCallback(
852
+ (key: string) => setHiddenLegendKeys((keys) => (keys.includes(key) ? keys.filter((other) => other !== key) : [...keys, key])),
853
+ []
854
+ );
855
+ // What's shown only changes with what's on it (not as nodes are dragged, on any level)
856
+ const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
857
+ const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
858
+ const visibleNodes = useMemo(
859
+ () =>
860
+ hiddenLegendKeys.length === 0
861
+ ? shownNodes
862
+ : shownNodes.map((node) => (hiddenLegendKeys.includes(node.type ?? '') ? { ...node, hidden: true } : node)),
863
+ [shownNodes, hiddenLegendKeys]
864
+ );
865
+ /**
866
+ * Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
867
+ * removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
868
+ * kept when it's dropped.
869
+ */
696
870
  const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
697
- const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
698
- const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
871
+ const level = levelRef.current;
872
+ const current = levelGraphRef.current;
873
+ if (level === 3 || !current) return;
874
+ const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === level).map((node) => node.id));
875
+ const isNote = (change: NodeChange) => 'id' in change && notes.has(change.id);
876
+ const ofNotes = changes.filter((change) => change.type !== 'add' && isNote(change));
699
877
  if (ofNotes.length) flow.onNodesChange(ofNotes);
878
+
879
+ const ofLevel = changes.filter((change) => !isNote(change) && change.type !== 'add');
880
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
881
+ const removed = ofLevel.flatMap((change) => (change.type === 'remove' && onCanvas.has(change.id) ? [change.id] : []));
882
+ if (removed.length) flow.deleteNodes(removed);
883
+ const arranging = ofLevel.filter((change) => change.type !== 'remove');
884
+ if (!arranging.length) return;
885
+ const nodes = applyNodeChanges(arranging, current.nodes);
886
+ updateLevelGraph({ ...current, nodes });
887
+ const done = new Set(
888
+ arranging.flatMap((change) =>
889
+ (change.type === 'position' && change.dragging === false) || (change.type === 'dimensions' && change.resizing === false)
890
+ ? [change.id]
891
+ : []
892
+ )
893
+ );
894
+ if (done.size) flow.setLevelLayout(level, levelPlaces(nodes.filter((node) => done.has(node.id))));
895
+ // Something just added, now it's measured: centred where it was dropped, from where it is now (a container it's in
896
+ // may have grown around it), in one step to undo with adding it
897
+ const measured = arranging.flatMap((change) => {
898
+ const dropped = change.type === 'dimensions' && change.dimensions && centring.current.get(change.id);
899
+ const node = dropped && nodes.find((candidate) => candidate.id === change.id);
900
+ if (!dropped || !node) return [];
901
+ centring.current.delete(change.id);
902
+ const { width, height } = change.dimensions!;
903
+ const { x, y } = node.position;
904
+ const place = { x: x + (dropped.size.width - width) / 2, y: y + (dropped.size.height - height) / 2 };
905
+ return dropped.level === level ? [[change.id, place] as const] : [];
906
+ });
907
+ if (measured.length) flow.setLevelLayout(level, new Map(measured));
908
+ });
909
+ /** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
910
+ const onLevelConnect = useStableCallback((connection: Connection) => {
911
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
912
+ if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
700
913
  });
701
914
  // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
702
915
  // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
916
+ // (on L1 and L2, the level's edges: the level itself changes on every frame something on it is dragged)
917
+ const levelEdges = levelShown?.edges;
703
918
  const shownEdges = useMemo(() => {
704
- const edges = levelShown ? levelShown.edges : flow.edges;
919
+ const edges = levelEdges ?? flow.edges;
705
920
  const focused = new Set(selectedKey ? selectedKey.split(',') : []);
706
921
  return edges.map((edge) =>
707
922
  edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
708
923
  ? withoutEnvelope(edge)
709
924
  : edge
710
925
  );
711
- }, [levelShown, flow.edges, selectedKey]);
712
- const unavailableLevels = useMemo(
713
- () => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
714
- [structure]
715
- );
926
+ }, [levelEdges, flow.edges, selectedKey]);
927
+ // A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
928
+ const unavailableLevels = useMemo(() => {
929
+ const reason = (level: 1 | 2) => {
930
+ if (getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) return undefined;
931
+ return (
932
+ getDiagramLevelUnavailable(flow.diagramLevels, level, flow.structureKey) ??
933
+ getLevelUnavailableReason(nodesRef.current, level)
934
+ );
935
+ };
936
+ return { 1: reason(1), 2: reason(2) };
937
+ }, [structure, flow.structureKey, flow.diagramLevels]);
716
938
 
717
939
  // ---- People and agents ----
718
940
 
@@ -836,7 +1058,8 @@ function Canvas({
836
1058
 
837
1059
  // Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
838
1060
  const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
839
- if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
1061
+ // On L1 and L2 too: what's there is on the canvas (a system card is the system)
1062
+ if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
840
1063
  });
841
1064
  // ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
842
1065
  const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
@@ -937,8 +1160,7 @@ function Canvas({
937
1160
  // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
938
1161
  // menu (selecting a node doesn't open it). Notes are edited on the canvas.
939
1162
  const [editingId, setEditingId] = useState<string | null>(null);
940
- const editing =
941
- editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
1163
+ const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
942
1164
  // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
943
1165
  const inspected = useMemo(
944
1166
  () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
@@ -956,6 +1178,8 @@ function Canvas({
956
1178
  <DropTargetContext.Provider value={dropTargetId}>
957
1179
  <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
958
1180
  <LeftPanel
1181
+ level={level}
1182
+ onShowAll={editInL3}
959
1183
  resources={resources}
960
1184
  keysOnCanvas={keysOnCanvas}
961
1185
  onAddComponent={addComponent}
@@ -976,16 +1200,16 @@ function Canvas({
976
1200
  onDoubleClick={onCanvasDoubleClick}
977
1201
  >
978
1202
  <ReactFlow
979
- nodes={shownNodes}
1203
+ nodes={visibleNodes}
980
1204
  edges={shownEdges}
981
1205
  nodeTypes={nodeTypes}
982
1206
  edgeTypes={edgeTypes}
983
- nodesDraggable={editable}
984
- nodesConnectable={editable}
985
- elementsSelectable={editable}
1207
+ nodesDraggable
1208
+ nodesConnectable
1209
+ elementsSelectable
986
1210
  onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
987
1211
  onEdgesChange={editable ? flow.onEdgesChange : undefined}
988
- onConnect={flow.onConnect}
1212
+ onConnect={editable ? flow.onConnect : onLevelConnect}
989
1213
  // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
990
1214
  edgesReconnectable={editable}
991
1215
  onReconnect={editable ? flow.onReconnect : undefined}
@@ -1008,16 +1232,17 @@ function Canvas({
1008
1232
  connectionRadius={40}
1009
1233
  deleteKeyCode={DELETE_KEYS}
1010
1234
  multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1011
- selectionOnDrag={editable}
1235
+ selectionOnDrag
1012
1236
  // Read only levels can't select: dragging pans them
1013
- panOnDrag={editable ? PAN_BUTTONS : true}
1237
+ panOnDrag={PAN_BUTTONS}
1014
1238
  panOnScroll
1015
1239
  zoomActivationKeyCode={ZOOM_KEYS}
1016
1240
  zoomOnDoubleClick={false}
1017
1241
  minZoom={0.1}
1018
1242
  maxZoom={2}
1019
1243
  >
1020
- <Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
1244
+ <DiagramBackground />
1245
+ <LegendPanel legend={legend} hiddenKeys={hiddenLegendKeys} onLegendClick={toggleLegendKey} />
1021
1246
  <ViewportSharer onChange={flow.setViewport} />
1022
1247
  {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1023
1248
  {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
@@ -1054,11 +1279,10 @@ function Canvas({
1054
1279
  {!editable && (
1055
1280
  <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
1056
1281
  <span>
1057
- <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
1058
- read only, sticky notes can be added
1282
+ <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description}
1059
1283
  </span>
1060
1284
  <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1061
- Edit in L3
1285
+ Everything is on L3
1062
1286
  </button>
1063
1287
  </div>
1064
1288
  )}
@@ -1155,7 +1379,7 @@ function Canvas({
1155
1379
  <ContainerChooser
1156
1380
  resource={chooser.resource}
1157
1381
  contents={chooserContents}
1158
- position={flowToScreenPosition(chooser.center)}
1382
+ position={flowToScreenPosition(chooser.at.point)}
1159
1383
  onChoose={choose}
1160
1384
  onCancel={cancelChooser}
1161
1385
  />
@@ -1164,7 +1388,7 @@ function Canvas({
1164
1388
  <ConnectionsChooser
1165
1389
  resource={connectionsChooser.resource}
1166
1390
  groups={connectionsChooser.groups}
1167
- position={flowToScreenPosition(connectionsChooser.center)}
1391
+ position={flowToScreenPosition(connectionsChooser.at.point)}
1168
1392
  onChoose={chooseConnections}
1169
1393
  onCancel={cancelConnectionsChooser}
1170
1394
  />