@eventcatalog/core 5.0.0-beta.5 → 5.0.0-beta.6
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 +4 -4
- package/dist/{chunk-43AEF3JE.js → chunk-56CD2NH6.js} +1 -1
- package/dist/{chunk-YYMBYOQR.js → chunk-DE3QHVJP.js} +1 -1
- package/dist/{chunk-XQXMSQA5.js → chunk-L2IRFOE7.js} +1 -1
- package/dist/{chunk-SHHO3JJI.js → chunk-QBP3BBSV.js} +1 -1
- package/dist/{chunk-EBDTVZDW.js → chunk-XMPFHLK4.js} +4 -4
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +11 -11
- package/dist/federation/federate.js +4 -4
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -0
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/astro.config.mjs +2 -0
- package/eventcatalog/integrations/studio-server.mjs +17 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
- package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
- package/eventcatalog/src/features/studio/components/icons.ts +48 -0
- package/eventcatalog/src/features/studio/components/status.ts +22 -0
- package/eventcatalog/src/features/studio/edges.ts +54 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +133 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
- package/eventcatalog/src/features/studio/layout.ts +39 -0
- package/eventcatalog/src/features/studio/levels.ts +216 -0
- package/eventcatalog/src/features/studio/node-types.ts +173 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
- package/eventcatalog/src/features/studio/pages/index.astro +8 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +89 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
- package/eventcatalog/src/utils/feature.ts +4 -0
- package/package.json +9 -2
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
- package/dist/{chunk-ZEY2XUJN.js → chunk-AYNZXUB5.js} +7 -7
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds of node people (and agents) can put on a canvas, and what's in their data. Shared by the browser and
|
|
3
|
+
* the server, so it has no UI in it: the icons for each type are in components/icons.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type NodeCategory = 'boundaries' | 'messages' | 'architecture' | 'other';
|
|
7
|
+
|
|
8
|
+
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
|
|
9
|
+
{ id: 'boundaries', label: 'Boundaries' },
|
|
10
|
+
{ id: 'messages', label: 'Messages' },
|
|
11
|
+
{ id: 'architecture', label: 'Architecture' },
|
|
12
|
+
{ id: 'other', label: 'Other' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Domains and systems as containers: boxes other nodes sit in */
|
|
16
|
+
export const GROUP_TYPES = { domain: 'domain-group', system: 'system-group' } as const;
|
|
17
|
+
export const isGroupType = (type?: string) => type === GROUP_TYPES.domain || type === GROUP_TYPES.system;
|
|
18
|
+
|
|
19
|
+
export type NodeDefinition = {
|
|
20
|
+
/** React Flow node type, matches the visualiser node component keys */
|
|
21
|
+
type: string;
|
|
22
|
+
label: string;
|
|
23
|
+
category: NodeCategory;
|
|
24
|
+
/** Not in the components list (still drawn, for canvases that have them) */
|
|
25
|
+
unlisted?: boolean;
|
|
26
|
+
/** Where the resource lives inside node.data (e.g. `service` for data.service). Undefined = data root */
|
|
27
|
+
resourceKey?: string;
|
|
28
|
+
/** Nodes that are sized on the canvas (containers, notes) start at this size */
|
|
29
|
+
defaultSize?: { width: number; height: number };
|
|
30
|
+
createData: () => Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const resource = (name: string, summary: string) => ({ name, version: '0.0.1', summary, owners: [] });
|
|
34
|
+
|
|
35
|
+
export const nodeDefinitions: NodeDefinition[] = [
|
|
36
|
+
{
|
|
37
|
+
type: GROUP_TYPES.domain,
|
|
38
|
+
label: 'Domain',
|
|
39
|
+
category: 'boundaries',
|
|
40
|
+
resourceKey: 'domain',
|
|
41
|
+
defaultSize: { width: 720, height: 460 },
|
|
42
|
+
createData: () => ({ domain: { name: 'New Domain', version: '0.0.1' } }),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: GROUP_TYPES.system,
|
|
46
|
+
label: 'System',
|
|
47
|
+
category: 'boundaries',
|
|
48
|
+
resourceKey: 'system',
|
|
49
|
+
defaultSize: { width: 560, height: 360 },
|
|
50
|
+
createData: () => ({ system: { name: 'New System', version: '0.0.1' } }),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'service',
|
|
54
|
+
category: 'architecture',
|
|
55
|
+
label: 'Service',
|
|
56
|
+
resourceKey: 'service',
|
|
57
|
+
createData: () => ({
|
|
58
|
+
mode: 'full',
|
|
59
|
+
service: { ...resource('New Service', 'Describe what this service does.'), sends: [], receives: [] },
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'event',
|
|
64
|
+
category: 'messages',
|
|
65
|
+
label: 'Event',
|
|
66
|
+
resourceKey: 'message',
|
|
67
|
+
createData: () => ({ mode: 'full', message: resource('NewEvent', 'Something happened.') }),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: 'command',
|
|
71
|
+
category: 'messages',
|
|
72
|
+
label: 'Command',
|
|
73
|
+
resourceKey: 'message',
|
|
74
|
+
createData: () => ({ mode: 'full', message: resource('NewCommand', 'Asks a service to do something.') }),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'query',
|
|
78
|
+
category: 'messages',
|
|
79
|
+
label: 'Query',
|
|
80
|
+
resourceKey: 'message',
|
|
81
|
+
createData: () => ({ mode: 'full', message: resource('NewQuery', 'Asks for some information.') }),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: 'channel',
|
|
85
|
+
category: 'architecture',
|
|
86
|
+
label: 'Channel',
|
|
87
|
+
resourceKey: 'channel',
|
|
88
|
+
createData: () => ({ mode: 'full', channel: { ...resource('new.channel', 'Where messages are routed.'), protocols: [] } }),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'data',
|
|
92
|
+
category: 'architecture',
|
|
93
|
+
label: 'Data Store',
|
|
94
|
+
resourceKey: 'data',
|
|
95
|
+
createData: () => ({ mode: 'full', data: { ...resource('New Database', 'Stores data.'), container_type: 'database' } }),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'view',
|
|
99
|
+
category: 'architecture',
|
|
100
|
+
label: 'View',
|
|
101
|
+
resourceKey: 'view',
|
|
102
|
+
createData: () => ({ mode: 'full', view: resource('New View', 'What the user sees.') }),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'externalSystem',
|
|
106
|
+
category: 'architecture',
|
|
107
|
+
unlisted: true,
|
|
108
|
+
label: 'External System',
|
|
109
|
+
resourceKey: 'externalSystem',
|
|
110
|
+
createData: () => ({ mode: 'full', externalSystem: resource('External System', 'A third party system.') }),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'agent',
|
|
114
|
+
category: 'architecture',
|
|
115
|
+
label: 'Agent',
|
|
116
|
+
resourceKey: 'agent',
|
|
117
|
+
createData: () => ({ mode: 'full', agent: { ...resource('New Agent', 'What this agent does.'), sends: [], receives: [] } }),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'actor',
|
|
121
|
+
category: 'other',
|
|
122
|
+
label: 'Actor',
|
|
123
|
+
createData: () => ({ mode: 'full', name: 'Customer', summary: 'A person using the system.' }),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: 'note',
|
|
127
|
+
category: 'other',
|
|
128
|
+
label: 'Sticky Note',
|
|
129
|
+
defaultSize: { width: 200, height: 160 },
|
|
130
|
+
createData: () => ({ text: 'Double-click to edit...', color: 'yellow' }),
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** The types people and agents can add (not the unlisted ones) */
|
|
135
|
+
export const COMPONENT_TYPES = nodeDefinitions
|
|
136
|
+
.filter((definition) => !definition.unlisted)
|
|
137
|
+
.map((definition) => definition.type) as [string, ...string[]];
|
|
138
|
+
|
|
139
|
+
const definitionsByType = new Map(nodeDefinitions.map((definition) => [definition.type, definition]));
|
|
140
|
+
|
|
141
|
+
// Node types that only come from the catalog (not in the components list)
|
|
142
|
+
const catalogNodeTypes: Record<string, { label: string; resourceKey: string }> = {
|
|
143
|
+
system: { label: 'System', resourceKey: 'system' },
|
|
144
|
+
'context-domain': { label: 'Domain', resourceKey: 'domain' },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const getNodeDefinition = (type?: string) => (type ? definitionsByType.get(type) : undefined);
|
|
148
|
+
|
|
149
|
+
export const getNodeLabel = (type?: string) => getNodeDefinition(type)?.label ?? catalogNodeTypes[type ?? '']?.label ?? 'Node';
|
|
150
|
+
|
|
151
|
+
// About the size the visualiser's resource cards render at
|
|
152
|
+
export const DEFAULT_NODE_SIZE = { width: 240, height: 112 };
|
|
153
|
+
|
|
154
|
+
/** The size a new node of a type starts at (or, for cards, about the size it renders at) */
|
|
155
|
+
export const getNodeSize = (type?: string) => getNodeDefinition(type)?.defaultSize ?? DEFAULT_NODE_SIZE;
|
|
156
|
+
|
|
157
|
+
const getResourceKey = (type?: string) => getNodeDefinition(type)?.resourceKey ?? catalogNodeTypes[type ?? '']?.resourceKey;
|
|
158
|
+
|
|
159
|
+
/** The resource object for a node (e.g. data.service), or the data itself for flat nodes */
|
|
160
|
+
export const getResource = (type: string | undefined, data: Record<string, unknown>): Record<string, unknown> => {
|
|
161
|
+
const key = getResourceKey(type);
|
|
162
|
+
return key ? ((data[key] as Record<string, unknown>) ?? {}) : data;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Returns new node data with fields of the resource changed */
|
|
166
|
+
export const updateResource = (type: string | undefined, data: Record<string, unknown>, fields: Record<string, unknown>) => {
|
|
167
|
+
const key = getResourceKey(type);
|
|
168
|
+
return key ? { ...data, [key]: { ...getResource(type, data), ...fields } } : { ...data, ...fields };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** A node's name, as people and agents see it */
|
|
172
|
+
export const getNodeName = (type: string | undefined, data: Record<string, unknown>, fallback = 'node') =>
|
|
173
|
+
String(getResource(type, data).name ?? data.text ?? fallback);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Visualiser styles ship in the page head so ClientRouter navigations keep them (see NodeGraph.astro).
|
|
3
|
+
import '@eventcatalog/visualiser/styles-core.css';
|
|
4
|
+
import '../studio.css';
|
|
5
|
+
import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
|
|
6
|
+
import { buildUrl } from '@utils/url-builder';
|
|
7
|
+
import StudioDesigner from '../components/StudioDesigner';
|
|
8
|
+
import { getCatalogResources } from '../catalog-resources';
|
|
9
|
+
import { STUDIO_SOCKET_PATH } from '../server/runtime';
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
const { id } = Astro.params;
|
|
14
|
+
|
|
15
|
+
if (!id || !/^[0-9a-f-]{36}$/i.test(id)) {
|
|
16
|
+
return Astro.redirect(buildUrl('/studio'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { resources, relations } = await getCatalogResources();
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<VerticalSideBarLayout title="Studio - EventCatalog" showNestedSideBar={false}>
|
|
23
|
+
<main
|
|
24
|
+
class="min-h-0 overflow-hidden bg-[rgb(var(--ec-page-bg))]"
|
|
25
|
+
style="margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1); margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1); height: calc(100dvh - 60px);"
|
|
26
|
+
>
|
|
27
|
+
<StudioDesigner
|
|
28
|
+
client:only="react"
|
|
29
|
+
canvasId={id}
|
|
30
|
+
socketPath={buildUrl(STUDIO_SOCKET_PATH, true)}
|
|
31
|
+
newCanvasUrl={buildUrl('/studio')}
|
|
32
|
+
mcpUrl={buildUrl('/docs/mcp', true)}
|
|
33
|
+
relayScriptPath={buildUrl('/webmcp-relay/embed.js', true)}
|
|
34
|
+
resources={resources}
|
|
35
|
+
relations={relations}
|
|
36
|
+
/>
|
|
37
|
+
</main>
|
|
38
|
+
</VerticalSideBarLayout>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Serves the WebMCP local relay's browser files (embed.js loads widget.html next to it), so the canvas page
|
|
8
|
+
* can hand its WebMCP tools to desktop MCP clients (Claude Code, Cursor...) running
|
|
9
|
+
* `npx -y @mcp-b/webmcp-local-relay@5.1.0`. Served from the installed package, so the version is pinned.
|
|
10
|
+
*/
|
|
11
|
+
const FILES: Record<string, string> = {
|
|
12
|
+
'embed.js': 'text/javascript',
|
|
13
|
+
'widget.html': 'text/html',
|
|
14
|
+
'widget.js': 'text/javascript',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const browserDirectory = path.join(path.dirname(createRequire(import.meta.url).resolve('@mcp-b/webmcp-local-relay')), 'browser');
|
|
18
|
+
|
|
19
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
20
|
+
const file = params.file ?? '';
|
|
21
|
+
const contentType = FILES[file];
|
|
22
|
+
if (!contentType) return new Response('Not found', { status: 404 });
|
|
23
|
+
const body = await fs.readFile(path.join(browserDirectory, file), 'utf-8');
|
|
24
|
+
return new Response(body, { headers: { 'Content-Type': contentType, 'Cache-Control': 'no-cache' } });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const prerender = false;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Node, XYPosition } from '@xyflow/react';
|
|
2
|
+
import { fitGroup, getAbsolutePosition, GROUP_PADDING, sizeOf } from './grouping';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where agents put what they add without a position, the way an architect would: next to what it connects to,
|
|
6
|
+
* following the flow left to right (what sends to it on its left, what it sends to on its right), lined up with
|
|
7
|
+
* it, and stacked around it when several things connect to the same node. Nothing already on the canvas moves.
|
|
8
|
+
* Things connected to nothing go in a row below what's there. Containers are filled first, so what goes around them
|
|
9
|
+
* leaves room for what they grow to hold.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A connection between two nodes, from the one that sends to the one that receives */
|
|
13
|
+
export type Link = { source: string; target: string };
|
|
14
|
+
|
|
15
|
+
/** Between connected nodes side by side (room for the labels on their connections), and nodes stacked in a column */
|
|
16
|
+
const GAP = { x: 200, y: 80 };
|
|
17
|
+
/** The least room left around nodes */
|
|
18
|
+
const CLEARANCE = { x: 120, y: 60 };
|
|
19
|
+
/** Between what's on the canvas and things connected to nothing added below it */
|
|
20
|
+
const BELOW_GAP = 120;
|
|
21
|
+
/** Things connected to nothing go side by side (so connecting them later is easy), this many to a row */
|
|
22
|
+
const ROW_LENGTH = 4;
|
|
23
|
+
/** How far to look for free space before giving up and going below everything */
|
|
24
|
+
const MAX_ROWS = 12;
|
|
25
|
+
const MAX_COLUMNS = 8;
|
|
26
|
+
|
|
27
|
+
type Rect = { x: number; y: number; width: number; height: number };
|
|
28
|
+
type Anchor = { rect: Rect; centerY: number; upstream: boolean };
|
|
29
|
+
|
|
30
|
+
const rectOf = (node: Node): Rect => ({ ...node.position, ...sizeOf(node) });
|
|
31
|
+
|
|
32
|
+
const tooClose = (a: Rect, b: Rect) =>
|
|
33
|
+
a.x < b.x + b.width + CLEARANCE.x &&
|
|
34
|
+
b.x < a.x + a.width + CLEARANCE.x &&
|
|
35
|
+
a.y < b.y + b.height + CLEARANCE.y &&
|
|
36
|
+
b.y < a.y + a.height + CLEARANCE.y;
|
|
37
|
+
|
|
38
|
+
const below = (rects: Rect[]): XYPosition => ({
|
|
39
|
+
x: Math.min(...rects.map((rect) => rect.x)),
|
|
40
|
+
y: Math.max(...rects.map((rect) => rect.y + rect.height)) + BELOW_GAP,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** 0, 1, -1, 2, -2, …: rows below and above in turn, so things stacked around a node stay centred on it */
|
|
44
|
+
const rowOffset = (row: number) => (row % 2 ? (row + 1) / 2 : -row / 2);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where the nodes to place go on the canvas (not relative to their containers: containers that grow to fit them
|
|
48
|
+
* move what's in them, but nothing moves on the canvas). They're among `nodes`, with their containers set.
|
|
49
|
+
* Everything else in `nodes` stays where it is.
|
|
50
|
+
*/
|
|
51
|
+
export const placeNodes = (nodes: Node[], toPlace: string[], links: Link[]): Map<string, XYPosition> => {
|
|
52
|
+
const working = new Map(nodes.map((node) => [node.id, node]));
|
|
53
|
+
const remaining = new Set(toPlace.filter((id) => working.has(id)));
|
|
54
|
+
|
|
55
|
+
const neighbours = new Map<string, { id: string; upstream: boolean }[]>();
|
|
56
|
+
const addNeighbour = (id: string, neighbour: { id: string; upstream: boolean }) =>
|
|
57
|
+
neighbours.set(id, [...(neighbours.get(id) ?? []), neighbour]);
|
|
58
|
+
for (const { source, target } of links) {
|
|
59
|
+
addNeighbour(source, { id: target, upstream: false });
|
|
60
|
+
addNeighbour(target, { id: source, upstream: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const depthOf = (id: string | undefined): number => {
|
|
64
|
+
const node = id ? working.get(id) : undefined;
|
|
65
|
+
return node ? 1 + depthOf(node.parentId) : 0;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The node, or the container it's in, that sits directly in a level (undefined for the canvas itself) */
|
|
69
|
+
const atLevel = (id: string, level: string | undefined) => {
|
|
70
|
+
let node = working.get(id);
|
|
71
|
+
while (node && node.parentId !== level) node = node.parentId ? working.get(node.parentId) : undefined;
|
|
72
|
+
return node;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The containers things are put in grow to fit them (innermost first), as they will on the canvas */
|
|
76
|
+
const growing = new Set<string>();
|
|
77
|
+
for (const id of remaining) {
|
|
78
|
+
for (let parentId = working.get(id)!.parentId; parentId; parentId = working.get(parentId)?.parentId) growing.add(parentId);
|
|
79
|
+
}
|
|
80
|
+
const fitContainers = () => {
|
|
81
|
+
const containers = [...growing].filter((id) => working.has(id)).sort((a, b) => depthOf(b) - depthOf(a));
|
|
82
|
+
for (const id of containers) {
|
|
83
|
+
const fit = fitGroup(
|
|
84
|
+
id,
|
|
85
|
+
[...working.values()].filter((node) => node.parentId !== id || !remaining.has(node.id))
|
|
86
|
+
);
|
|
87
|
+
if (!fit) continue;
|
|
88
|
+
const container = working.get(id)!;
|
|
89
|
+
// Growing up or left: what's in it moves the other way, so nothing moves on the canvas
|
|
90
|
+
if (fit.shift.x || fit.shift.y) {
|
|
91
|
+
for (const child of working.values()) {
|
|
92
|
+
if (child.parentId !== id) continue;
|
|
93
|
+
working.set(child.id, {
|
|
94
|
+
...child,
|
|
95
|
+
position: { x: child.position.x + fit.shift.x, y: child.position.y + fit.shift.y },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
working.set(id, {
|
|
100
|
+
...container,
|
|
101
|
+
position: { x: container.position.x - fit.shift.x, y: container.position.y - fit.shift.y },
|
|
102
|
+
width: fit.width,
|
|
103
|
+
height: fit.height,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Innermost containers first, then outwards to the canvas
|
|
109
|
+
const levels = [...new Set([...remaining].map((id) => working.get(id)!.parentId))].sort((a, b) => depthOf(b) - depthOf(a));
|
|
110
|
+
|
|
111
|
+
for (const level of levels) {
|
|
112
|
+
fitContainers();
|
|
113
|
+
const parent = level ? working.get(level) : undefined;
|
|
114
|
+
const origin = parent ? getAbsolutePosition(parent, working) : { x: 0, y: 0 };
|
|
115
|
+
// Inside a container: clear of its header, so it grows down rather than up
|
|
116
|
+
const start = parent ? { x: GROUP_PADDING.left, y: GROUP_PADDING.top } : { x: 0, y: 0 };
|
|
117
|
+
const todo = [...remaining].filter((id) => working.get(id)!.parentId === level);
|
|
118
|
+
// Things connected to nothing: a row below what was there, each to the right of what's been placed so far
|
|
119
|
+
let row: { y: number; length: number } | undefined;
|
|
120
|
+
const placedHere: Rect[] = [];
|
|
121
|
+
const obstacles = () =>
|
|
122
|
+
[...working.values()].filter((node) => node.parentId === level && !remaining.has(node.id)).map(rectOf);
|
|
123
|
+
|
|
124
|
+
/** What a node connects to that's already placed: the node itself, or the container it's in, at this level */
|
|
125
|
+
const anchorsOf = (id: string): Anchor[] =>
|
|
126
|
+
(neighbours.get(id) ?? []).flatMap(({ id: other, upstream }) => {
|
|
127
|
+
const shown = atLevel(other, level);
|
|
128
|
+
if (!shown || shown.id === id || remaining.has(shown.id)) return [];
|
|
129
|
+
const node = working.get(other)!;
|
|
130
|
+
// Lined up with the node itself, even when it's inside a container
|
|
131
|
+
const centerY = getAbsolutePosition(node, working).y + sizeOf(node).height / 2 - origin.y;
|
|
132
|
+
return [{ rect: rectOf(shown), centerY, upstream }];
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Nothing placed to start from: the first one with nothing sending to it, so the flow grows left to right */
|
|
136
|
+
const startOf = (ids: string[]) =>
|
|
137
|
+
ids.find(
|
|
138
|
+
(id) =>
|
|
139
|
+
!(neighbours.get(id) ?? []).some(({ id: other, upstream }) => {
|
|
140
|
+
const shown = atLevel(other, level);
|
|
141
|
+
return upstream && shown && shown.id !== id && ids.includes(shown.id);
|
|
142
|
+
})
|
|
143
|
+
) ?? ids[0];
|
|
144
|
+
|
|
145
|
+
const findSpot = (from: XYPosition, size: { width: number; height: number }, direction: 1 | -1): XYPosition => {
|
|
146
|
+
const rects = obstacles();
|
|
147
|
+
for (let column = 0; column < MAX_COLUMNS; column++) {
|
|
148
|
+
const x = from.x + direction * column * (size.width + GAP.x);
|
|
149
|
+
for (let row = 0; row < MAX_ROWS * 2; row++) {
|
|
150
|
+
const spot = { x, y: from.y + rowOffset(row) * (size.height + GAP.y) };
|
|
151
|
+
if (parent && spot.y < GROUP_PADDING.top) continue;
|
|
152
|
+
if (!rects.some((rect) => tooClose({ ...spot, ...size }, rect))) return spot;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return rects.length ? below(rects) : start;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
while (todo.length > 0) {
|
|
159
|
+
const candidates = todo.map((id) => ({ id, anchors: anchorsOf(id) }));
|
|
160
|
+
const connected = candidates.reduce((best, candidate) =>
|
|
161
|
+
candidate.anchors.length > best.anchors.length ? candidate : best
|
|
162
|
+
);
|
|
163
|
+
const { id, anchors } = connected.anchors.length > 0 ? connected : { id: startOf(todo), anchors: [] };
|
|
164
|
+
const node = working.get(id)!;
|
|
165
|
+
const size = sizeOf(node);
|
|
166
|
+
|
|
167
|
+
let spot: XYPosition;
|
|
168
|
+
if (anchors.length > 0) {
|
|
169
|
+
const upstream = anchors.filter((anchor) => anchor.upstream);
|
|
170
|
+
const downstream = anchors.filter((anchor) => !anchor.upstream);
|
|
171
|
+
const x = upstream.length
|
|
172
|
+
? Math.max(...upstream.map(({ rect }) => rect.x + rect.width)) + GAP.x
|
|
173
|
+
: Math.min(...downstream.map(({ rect }) => rect.x)) - GAP.x - size.width;
|
|
174
|
+
const centerY = anchors.reduce((sum, anchor) => sum + anchor.centerY, 0) / anchors.length;
|
|
175
|
+
spot = findSpot({ x, y: centerY - size.height / 2 }, size, upstream.length ? 1 : -1);
|
|
176
|
+
} else {
|
|
177
|
+
const rects = obstacles();
|
|
178
|
+
if (!row || row.length === ROW_LENGTH) {
|
|
179
|
+
spot = findSpot(rects.length ? below(rects) : start, size, 1);
|
|
180
|
+
row = { y: spot.y, length: 1 };
|
|
181
|
+
} else {
|
|
182
|
+
spot = findSpot({ x: Math.max(...placedHere.map((rect) => rect.x + rect.width)) + GAP.x, y: row.y }, size, 1);
|
|
183
|
+
row.length++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const position = { x: Math.round(spot.x), y: Math.round(spot.y) };
|
|
188
|
+
working.set(id, { ...node, position });
|
|
189
|
+
placedHere.push({ ...position, ...size });
|
|
190
|
+
remaining.delete(id);
|
|
191
|
+
todo.splice(todo.indexOf(id), 1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fitContainers();
|
|
196
|
+
return new Map(toPlace.filter((id) => working.has(id)).map((id) => [id, getAbsolutePosition(working.get(id)!, working)]));
|
|
197
|
+
};
|