@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.
- package/README.md +5 -5
- 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-CRKJBDN7.js → chunk-BWC4YKW3.js} +1 -1
- package/dist/{chunk-LJMXOEEC.js → chunk-CX2VB7AI.js} +1 -1
- package/dist/{chunk-IONYKKOY.js → chunk-FY6MSP6J.js} +1 -1
- package/dist/{chunk-2LBDRHE4.js → chunk-QB7X34OD.js} +1 -1
- package/dist/{chunk-EM36OL6A.js → chunk-TSY5CC27.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 +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
- package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
- package/eventcatalog/src/features/studio/catalog.ts +15 -10
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
- package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
- package/eventcatalog/src/features/studio/flow-state.ts +29 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/grouping.ts +4 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
- package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
- package/eventcatalog/src/features/studio/layout.ts +29 -15
- package/eventcatalog/src/features/studio/level-layout.ts +145 -0
- package/eventcatalog/src/features/studio/levels.ts +82 -27
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/node-types.ts +44 -4
- package/eventcatalog/src/features/studio/pages/index.astro +2 -1
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
- package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
fitContainersAround,
|
|
10
10
|
getCanvasMaps,
|
|
11
11
|
moveNode,
|
|
12
|
-
|
|
12
|
+
placeNode,
|
|
13
|
+
setEdgeRoutes,
|
|
13
14
|
setMeta,
|
|
14
15
|
type Author,
|
|
15
16
|
type CommentAnchor,
|
|
@@ -292,13 +293,14 @@ export async function playLayout(stage: AgentStage, layout: LayoutResult) {
|
|
|
292
293
|
hand.say('Done');
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* Moves nodes, resizes containers and routes connections from a layout, in one change, and marks when it
|
|
298
|
+
* happened. Connections the layout didn't route lose their old route (drawn as a plain step instead).
|
|
299
|
+
*/
|
|
296
300
|
export const applyLayout = (doc: Y.Doc, layout: LayoutResult) =>
|
|
297
301
|
doc.transact(() => {
|
|
298
|
-
layout.forEach(({ position, size }, id) =>
|
|
299
|
-
|
|
300
|
-
if (size) resizeNode(doc, id, size);
|
|
301
|
-
});
|
|
302
|
+
layout.nodes.forEach(({ position, size }, id) => placeNode(doc, id, position, size));
|
|
303
|
+
setEdgeRoutes(doc, layout.routes);
|
|
302
304
|
setMeta(doc, { layoutAt: Date.now() });
|
|
303
305
|
});
|
|
304
306
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Hono, type Context } from 'hono';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { paginate } from '@features/tools/catalog-tools';
|
|
4
|
-
import {
|
|
4
|
+
import type { CanvasContent, CanvasStatus } from '../canvas-doc';
|
|
5
|
+
import { MAX_TITLE_LENGTH } from '../limits';
|
|
6
|
+
import { diagramLevelsSchema, indexCatalog, newCanvasEdgesSchema, nodeSpecsSchema, planCanvas } from '../canvas-actions';
|
|
7
|
+
import { getCatalogResources } from '../catalog-resources';
|
|
5
8
|
import { MAX_NAME_LENGTH } from '../identity';
|
|
6
9
|
import type { CanvasSummary, StudioRuntime } from '../server/runtime';
|
|
7
10
|
import type { SignedInUser } from '../server/sign-in';
|
|
@@ -13,7 +16,13 @@ import type { SignedInUser } from '../server/sign-in';
|
|
|
13
16
|
*
|
|
14
17
|
* GET /api/studio/canvases Canvases, most recently changed first (?pageSize=, ?cursor= for the next page)
|
|
15
18
|
* (not ones left empty and untitled: GET /:id still finds them)
|
|
16
|
-
* POST /api/studio/canvases Creates one: { "title"?: string, "createdBy"?: string
|
|
19
|
+
* POST /api/studio/canvases Creates one: { "title"?: string, "createdBy"?: string, "nodes"?: [...],
|
|
20
|
+
* "edges"?: [...] }, starting with the nodes and edges given, if any (as the
|
|
21
|
+
* add_to_canvas MCP tool takes them: catalog resources or new components, x/y
|
|
22
|
+
* their centres, and only the connections in edges are made, each drawn
|
|
23
|
+
* along its "route" if given: { "points": [{x, y}, ...], "label"?: {x, y} }),
|
|
24
|
+
* and "levels" (L1 and L2 laid out, e.g. as a diagram shows them, by the
|
|
25
|
+
* nodes' refs), shown as given until what's on the canvas changes
|
|
17
26
|
* GET /api/studio/canvases/:id One canvas
|
|
18
27
|
* POST /api/studio/canvases/:id/copy Copies its design (nodes and connections, not comments or status) to a new
|
|
19
28
|
* draft: { "title"?: string, "createdBy"?: string }
|
|
@@ -57,11 +66,23 @@ export type CanvasesApiEnv = {
|
|
|
57
66
|
};
|
|
58
67
|
};
|
|
59
68
|
|
|
69
|
+
const MAX_NODES = 1000;
|
|
70
|
+
const MAX_EDGES = 2000;
|
|
71
|
+
|
|
60
72
|
const NewCanvas = z.object({
|
|
61
73
|
title: z.string().trim().max(MAX_TITLE_LENGTH).optional(),
|
|
62
74
|
// Ignored when someone's signed in: it's them
|
|
63
75
|
createdBy: z.string().trim().max(MAX_NAME_LENGTH).optional(),
|
|
64
76
|
});
|
|
77
|
+
// A new canvas can start with nodes and edges; a copy starts with the original's
|
|
78
|
+
const NewCanvasWithContent = NewCanvas.extend({
|
|
79
|
+
nodes: nodeSpecsSchema.max(MAX_NODES).optional(),
|
|
80
|
+
edges: newCanvasEdgesSchema.max(MAX_EDGES).optional(),
|
|
81
|
+
levels: diagramLevelsSchema.optional(),
|
|
82
|
+
}).refine((canvas) => !canvas.edges?.length || canvas.nodes, {
|
|
83
|
+
message: 'Edges connect the nodes given with them: give nodes too',
|
|
84
|
+
path: ['edges'],
|
|
85
|
+
});
|
|
65
86
|
const ListQuery = z.object({
|
|
66
87
|
cursor: z.string().optional(),
|
|
67
88
|
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).optional(),
|
|
@@ -86,26 +107,54 @@ const describeIssues = (error: z.ZodError) =>
|
|
|
86
107
|
type ApiContext = Context<CanvasesApiEnv>;
|
|
87
108
|
const noCanvas = (c: ApiContext, id: string) => c.json({ error: `No canvas "${id}"` }, 404);
|
|
88
109
|
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
type NewCanvasDetails = { title?: string; createdBy?: string };
|
|
111
|
+
|
|
112
|
+
/** The request's JSON body (or {} without one), or the response saying what's wrong with it */
|
|
113
|
+
const readJson = async (c: ApiContext): Promise<unknown> => {
|
|
91
114
|
// JSON only: a page on another site can send a form or plain text here without asking first, but not JSON
|
|
92
115
|
const body = await c.req.text();
|
|
93
116
|
if (body && !c.req.header('content-type')?.toLowerCase().startsWith('application/json')) {
|
|
94
117
|
return c.json({ error: 'Send the canvas as JSON (Content-Type: application/json)' }, 415);
|
|
95
118
|
}
|
|
96
|
-
let json: unknown = {};
|
|
97
119
|
try {
|
|
98
|
-
|
|
120
|
+
return body ? JSON.parse(body) : {};
|
|
99
121
|
} catch {
|
|
100
122
|
return c.json({ error: 'The body is not valid JSON' }, 400);
|
|
101
123
|
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const detailsOf = (c: ApiContext, canvas: z.output<typeof NewCanvas>): NewCanvasDetails => ({
|
|
127
|
+
title: canvas.title || undefined,
|
|
128
|
+
// Whoever's signed in, if anyone
|
|
129
|
+
createdBy: c.env.signedInAs?.name ?? (canvas.createdBy || undefined),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** A copy's details from the request (optional), or the response saying what's wrong with them */
|
|
133
|
+
const readCopy = async (c: ApiContext): Promise<NewCanvasDetails | Response> => {
|
|
134
|
+
const json = await readJson(c);
|
|
135
|
+
if (json instanceof Response) return json;
|
|
102
136
|
const parsed = NewCanvas.safeParse(json);
|
|
103
137
|
if (!parsed.success) return c.json({ error: describeIssues(parsed.error) }, 400);
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
138
|
+
return detailsOf(c, parsed.data);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** A new canvas's details and what it starts with (all optional), or the response saying what's wrong with them */
|
|
142
|
+
const readNewCanvas = async (c: ApiContext): Promise<{ details: NewCanvasDetails; content?: CanvasContent } | Response> => {
|
|
143
|
+
const json = await readJson(c);
|
|
144
|
+
if (json instanceof Response) return json;
|
|
145
|
+
const parsed = NewCanvasWithContent.safeParse(json);
|
|
146
|
+
if (!parsed.success) return c.json({ error: describeIssues(parsed.error) }, 400);
|
|
147
|
+
const details = detailsOf(c, parsed.data);
|
|
148
|
+
if (!parsed.data.nodes) return { details };
|
|
149
|
+
const { resources, relations } = await getCatalogResources();
|
|
150
|
+
const { content, errors } = planCanvas(
|
|
151
|
+
parsed.data.nodes,
|
|
152
|
+
parsed.data.edges ?? [],
|
|
153
|
+
indexCatalog(resources, relations),
|
|
154
|
+
parsed.data.levels
|
|
155
|
+
);
|
|
156
|
+
if (errors.length) return c.json({ error: errors.join('; ') }, 400);
|
|
157
|
+
return { details, content };
|
|
109
158
|
};
|
|
110
159
|
|
|
111
160
|
/** The API, served under `basePath` (the catalog's base URL, then CANVASES_API_PATH) */
|
|
@@ -132,15 +181,15 @@ export const createCanvasesApi = (basePath: string) => {
|
|
|
132
181
|
};
|
|
133
182
|
|
|
134
183
|
app.post('/', async (c) => {
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
return created(c, await c.env.runtime.createCanvas(details));
|
|
184
|
+
const canvas = await readNewCanvas(c);
|
|
185
|
+
if (canvas instanceof Response) return canvas;
|
|
186
|
+
return created(c, await c.env.runtime.createCanvas(canvas.details, canvas.content));
|
|
138
187
|
});
|
|
139
188
|
|
|
140
189
|
app.post('/:id/copy', async (c) => {
|
|
141
190
|
const id = c.req.param('id');
|
|
142
191
|
if (!CANVAS_ID.test(id)) return noCanvas(c, id);
|
|
143
|
-
const details = await
|
|
192
|
+
const details = await readCopy(c);
|
|
144
193
|
if (details instanceof Response) return details;
|
|
145
194
|
const copyId = await c.env.runtime.copyCanvas(id, details);
|
|
146
195
|
return copyId ? created(c, copyId) : noCanvas(c, id);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApiCanvas } from './canvases-api';
|
|
2
|
+
import type { DiagramLevelsSpec, NewCanvasEdges, NodeSpecs } from '../canvas-actions';
|
|
2
3
|
|
|
3
4
|
/** Using the Studio API from the browser (Studio's pages), at `apiUrl` (its address on this catalog) */
|
|
4
5
|
|
|
@@ -7,7 +8,11 @@ const errorOf = async (response: Response, fallback: string) => {
|
|
|
7
8
|
return new Error(body?.error ?? `${fallback} (${response.status})`);
|
|
8
9
|
};
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/** Creates a canvas, starting with the nodes and edges given, if any */
|
|
12
|
+
export const createCanvasThroughApi = async (
|
|
13
|
+
apiUrl: string,
|
|
14
|
+
canvas: { title?: string; createdBy?: string; nodes?: NodeSpecs; edges?: NewCanvasEdges; levels?: DiagramLevelsSpec }
|
|
15
|
+
) => {
|
|
11
16
|
const response = await fetch(apiUrl, {
|
|
12
17
|
method: 'POST',
|
|
13
18
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
2
|
import type * as Y from 'yjs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData } from './canvas-doc';
|
|
4
|
+
import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData, type CanvasContent } from './canvas-doc';
|
|
5
5
|
import type { CatalogRelation, CatalogResource } from './catalog-resources';
|
|
6
6
|
import {
|
|
7
7
|
buildContainer,
|
|
@@ -12,10 +12,11 @@ import {
|
|
|
12
12
|
getRelatedEdges,
|
|
13
13
|
indexRelations,
|
|
14
14
|
} from './catalog';
|
|
15
|
-
import {
|
|
15
|
+
import { containerFor, getAbsolutePosition, sizeOf } from './grouping';
|
|
16
16
|
import { placeNodes, type Link } from './placement';
|
|
17
17
|
import {
|
|
18
18
|
canGoInContainer,
|
|
19
|
+
CATALOG_COLLECTIONS,
|
|
19
20
|
COMPONENT_TYPES,
|
|
20
21
|
getNodeDefinition,
|
|
21
22
|
getNodeLabel,
|
|
@@ -34,17 +35,6 @@ import {
|
|
|
34
35
|
* browser), so both describe and change a canvas the same way.
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
|
-
export const CATALOG_COLLECTIONS = [
|
|
38
|
-
'domains',
|
|
39
|
-
'systems',
|
|
40
|
-
'services',
|
|
41
|
-
'events',
|
|
42
|
-
'commands',
|
|
43
|
-
'queries',
|
|
44
|
-
'channels',
|
|
45
|
-
'containers',
|
|
46
|
-
] as const;
|
|
47
|
-
|
|
48
38
|
// ---- Inputs ----
|
|
49
39
|
|
|
50
40
|
export const nodeSpecsSchema = z
|
|
@@ -71,6 +61,16 @@ export const nodeSpecsSchema = z
|
|
|
71
61
|
.describe('A ref (or node id) of a container (a domain or system added as one) to put this node inside'),
|
|
72
62
|
x: z.number().optional(),
|
|
73
63
|
y: z.number().optional(),
|
|
64
|
+
width: z
|
|
65
|
+
.number()
|
|
66
|
+
.positive()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('For a container: its width to start with (it grows to fit what is put in it)'),
|
|
69
|
+
height: z
|
|
70
|
+
.number()
|
|
71
|
+
.positive()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('For a container: its height to start with (it grows to fit what is put in it)'),
|
|
74
74
|
})
|
|
75
75
|
)
|
|
76
76
|
.min(1);
|
|
@@ -83,6 +83,83 @@ export const edgeSpecsSchema = z.array(
|
|
|
83
83
|
})
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
const pointSchema = z.object({ x: z.number(), y: z.number() });
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Edges for a new canvas (through the Studio API): as add_to_canvas takes them, and how each is drawn, if known
|
|
90
|
+
* (e.g. copied from a diagram): the points it goes through (canvas coordinates, at right angles) and where its
|
|
91
|
+
* label goes. It follows its nodes when they're moved.
|
|
92
|
+
*/
|
|
93
|
+
const routeSchema = z.object({ points: z.array(pointSchema).min(2).max(200), label: pointSchema.optional() });
|
|
94
|
+
export const newCanvasEdgesSchema = z.array(edgeSpecsSchema.element.extend({ route: routeSchema.optional() }));
|
|
95
|
+
export type NewCanvasEdges = z.output<typeof newCanvasEdgesSchema>;
|
|
96
|
+
|
|
97
|
+
const MAX_LEVEL_NODES = 1000;
|
|
98
|
+
const levelSchema = z.object({
|
|
99
|
+
nodes: z
|
|
100
|
+
.array(
|
|
101
|
+
z.object({
|
|
102
|
+
ref: z.string().describe("The ref of the node it is on the canvas (or its own id, if it's only on this level)"),
|
|
103
|
+
type: z.string(),
|
|
104
|
+
data: z.record(z.string(), z.unknown()),
|
|
105
|
+
parent: z.string().optional().describe('The ref of the node it is shown in on this level'),
|
|
106
|
+
x: z.number().describe('Its top-left corner (relative to its parent)'),
|
|
107
|
+
y: z.number(),
|
|
108
|
+
width: z.number().positive().optional().describe('For a container: its size'),
|
|
109
|
+
height: z.number().positive().optional(),
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
.max(MAX_LEVEL_NODES),
|
|
113
|
+
edges: z
|
|
114
|
+
.array(
|
|
115
|
+
z.object({
|
|
116
|
+
from: z.string(),
|
|
117
|
+
to: z.string(),
|
|
118
|
+
label: z.string().optional(),
|
|
119
|
+
type: z.string().optional(),
|
|
120
|
+
style: z.record(z.string(), z.unknown()).optional(),
|
|
121
|
+
markerEnd: z.unknown().optional(),
|
|
122
|
+
data: z.record(z.string(), z.unknown()).optional().describe("The edge's data, e.g. its route"),
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
.max(MAX_LEVEL_NODES * 2),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* L1 and L2 of the diagram a canvas is opened from, laid out as the diagram showed them (by refs of the nodes given
|
|
130
|
+
* for the canvas): shown as they are until the canvas's structure changes
|
|
131
|
+
*/
|
|
132
|
+
const unavailableReason = z.string().max(300).optional();
|
|
133
|
+
export const diagramLevelsSchema = z.object({
|
|
134
|
+
1: levelSchema.optional(),
|
|
135
|
+
2: levelSchema.optional(),
|
|
136
|
+
unavailable: z
|
|
137
|
+
.object({ 1: unavailableReason, 2: unavailableReason })
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("Levels the diagram doesn't have, and why"),
|
|
140
|
+
});
|
|
141
|
+
export type DiagramLevelsSpec = z.output<typeof diagramLevelsSchema>;
|
|
142
|
+
|
|
143
|
+
/** A diagram's level, with refs swapped for the ids of the nodes made for them */
|
|
144
|
+
const toDiagramLevel = (level: z.output<typeof levelSchema>, idOf: (ref: string) => string) => {
|
|
145
|
+
const shown = new Set(level.nodes.map((node) => node.ref));
|
|
146
|
+
return {
|
|
147
|
+
nodes: level.nodes.map(
|
|
148
|
+
({ ref, type, data, parent, x, y, width, height }): Node => ({
|
|
149
|
+
id: idOf(ref),
|
|
150
|
+
type,
|
|
151
|
+
data,
|
|
152
|
+
position: { x, y },
|
|
153
|
+
...(parent && shown.has(parent) && { parentId: idOf(parent) }),
|
|
154
|
+
...(width && height && { style: { width, height } }),
|
|
155
|
+
})
|
|
156
|
+
),
|
|
157
|
+
edges: level.edges
|
|
158
|
+
.filter((edge) => shown.has(edge.from) && shown.has(edge.to))
|
|
159
|
+
.map(({ from, to, ...edge }, index) => ({ ...edge, id: `diagram-${index}`, source: idOf(from), target: idOf(to) }) as Edge),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
86
163
|
export const ADD_TO_CANVAS_DESCRIPTION = [
|
|
87
164
|
'Adds nodes (and connections between them) to the canvas, live for everyone on it.',
|
|
88
165
|
'A node is either a catalog resource (resource: { collection, id }), shown with its real name and summary and automatically connected to the related resources already on the canvas (e.g. a service to the events it publishes), or a new component (type, name, summary) for things that are not in the catalog yet, like a proposed service.',
|
|
@@ -199,6 +276,13 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
199
276
|
const errors: string[] = [];
|
|
200
277
|
const hasPosition = (spec: { x?: number; y?: number }) => spec.x !== undefined && spec.y !== undefined;
|
|
201
278
|
const centerOf = (spec: { x?: number; y?: number }) => (hasPosition(spec) ? { x: spec.x!, y: spec.y! } : { x: 0, y: 0 });
|
|
279
|
+
// A container at the size asked for, still centred where asked
|
|
280
|
+
const sized = (node: Node, spec: { x?: number; y?: number; width?: number; height?: number }): Node => {
|
|
281
|
+
if (!isGroupType(node.type) || !spec.width || !spec.height) return node;
|
|
282
|
+
const center = centerOf(spec);
|
|
283
|
+
const { width, height } = spec;
|
|
284
|
+
return { ...node, width, height, position: { x: center.x - width / 2, y: center.y - height / 2 } };
|
|
285
|
+
};
|
|
202
286
|
|
|
203
287
|
// Inside a container, at the canvas point asked for (relative to the container)
|
|
204
288
|
const putInside = (node: Node, parent: Node, spec: { x?: number; y?: number }): Node => {
|
|
@@ -222,7 +306,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
222
306
|
}
|
|
223
307
|
const asContainer = spec.container && canBeContainer(resource);
|
|
224
308
|
const node = asContainer
|
|
225
|
-
? buildContainer(resource, centerOf(spec))
|
|
309
|
+
? sized(buildContainer(resource, centerOf(spec)), spec)
|
|
226
310
|
: buildNode(resource.node.type, catalogNodeData(resource), centerOf(spec));
|
|
227
311
|
built = { node, name: resource.name, resource };
|
|
228
312
|
} else {
|
|
@@ -238,7 +322,7 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
238
322
|
...(spec.summary && { summary: spec.summary }),
|
|
239
323
|
...(spec.version && isVersioned(definition.type) && { version: spec.version }),
|
|
240
324
|
});
|
|
241
|
-
const node = buildNode(definition.type, data, centerOf(spec));
|
|
325
|
+
const node = sized(buildNode(definition.type, data, centerOf(spec)), spec);
|
|
242
326
|
built = { node, name: String(getResource(node.type, node.data).name ?? definition.label) };
|
|
243
327
|
}
|
|
244
328
|
|
|
@@ -297,11 +381,44 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
297
381
|
};
|
|
298
382
|
|
|
299
383
|
/** Edges given by refs (or ids of nodes already on the canvas), as node ids */
|
|
300
|
-
|
|
301
|
-
|
|
384
|
+
const refIds = (planned: PlannedNode[]) =>
|
|
385
|
+
new Map(planned.flatMap((entry) => (entry.ref ? [[entry.ref, entry.node.id] as const] : [])));
|
|
386
|
+
|
|
387
|
+
export const resolveEdgeRefs = <Spec extends EdgeSpecs[number]>(edges: Spec[], planned: PlannedNode[]) => {
|
|
388
|
+
const refs = refIds(planned);
|
|
302
389
|
return edges.map((edge) => ({ ...edge, from: refs.get(edge.from) ?? edge.from, to: refs.get(edge.to) ?? edge.to }));
|
|
303
390
|
};
|
|
304
391
|
|
|
392
|
+
/**
|
|
393
|
+
* A new canvas's content from nodes and edges given as add_to_canvas takes them. Only the connections asked for
|
|
394
|
+
* are made: related catalog resources aren't connected as well, so the canvas shows just what was asked for (e.g.
|
|
395
|
+
* a diagram's connections, which may go through channels). Says what's wrong if anything can't be followed.
|
|
396
|
+
*/
|
|
397
|
+
export const planCanvas = (
|
|
398
|
+
specs: NodeSpecs,
|
|
399
|
+
edges: NewCanvasEdges,
|
|
400
|
+
catalog: CatalogIndex,
|
|
401
|
+
levels?: DiagramLevelsSpec
|
|
402
|
+
): { content: CanvasContent; errors: string[] } => {
|
|
403
|
+
const { planned, errors } = planNodes([], specs, catalog, edges);
|
|
404
|
+
const ids = new Set(planned.map((entry) => entry.node.id));
|
|
405
|
+
const connections = resolveEdgeRefs(edges, planned);
|
|
406
|
+
for (const end of connections.flatMap((connection) => [connection.from, connection.to])) {
|
|
407
|
+
if (!ids.has(end)) errors.push(`Edges connect nodes by their ref: no node has the ref "${end}"`);
|
|
408
|
+
}
|
|
409
|
+
const refs = refIds(planned);
|
|
410
|
+
const idOf = (ref: string) => refs.get(ref) ?? ref;
|
|
411
|
+
const diagramLevels = levels && {
|
|
412
|
+
...(levels[1] && { 1: toDiagramLevel(levels[1], idOf) }),
|
|
413
|
+
...(levels[2] && { 2: toDiagramLevel(levels[2], idOf) }),
|
|
414
|
+
...(levels.unavailable && { unavailable: levels.unavailable }),
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
content: { nodes: planned.map((entry) => entry.node), connections, ...(diagramLevels && { levels: diagramLevels }) },
|
|
418
|
+
errors: [...new Set(errors)],
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
|
|
305
422
|
export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string };
|
|
306
423
|
|
|
307
424
|
/** Renames, re-describes or re-versions a node. Catalog resources keep their catalog name, summary and version. */
|
|
@@ -377,9 +494,9 @@ export const getCatalogConnections = (
|
|
|
377
494
|
};
|
|
378
495
|
|
|
379
496
|
/**
|
|
380
|
-
* A catalog resource centred on a canvas point (inside the container
|
|
381
|
-
* it connects to placed around it like an architect would (what comes in before it, what goes out
|
|
382
|
-
* the connections between them and to what's already on the canvas. A service's data stores go in its container
|
|
497
|
+
* A catalog resource centred on a canvas point (inside the container asked for, else the one there, if any), with
|
|
498
|
+
* the resources it connects to placed around it like an architect would (what comes in before it, what goes out
|
|
499
|
+
* after it), and the connections between them and to what's already on the canvas. A service's data stores go in its container
|
|
383
500
|
* with it. Positions are relative to the containers they're in, as the canvas is now.
|
|
384
501
|
*/
|
|
385
502
|
export const planWithConnections = (
|
|
@@ -387,9 +504,10 @@ export const planWithConnections = (
|
|
|
387
504
|
resource: CatalogResource,
|
|
388
505
|
center: XYPosition,
|
|
389
506
|
include: CatalogResource[],
|
|
390
|
-
catalog: CatalogIndex
|
|
507
|
+
catalog: CatalogIndex,
|
|
508
|
+
inside?: string
|
|
391
509
|
): { nodes: Node[]; edges: Edge[] } => {
|
|
392
|
-
const container =
|
|
510
|
+
const container = containerFor(center, existing, inside);
|
|
393
511
|
const specOf = (entry: CatalogResource) => ({
|
|
394
512
|
resource: { collection: entry.collection as (typeof CATALOG_COLLECTIONS)[number], id: entry.id },
|
|
395
513
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
2
|
import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
|
|
3
|
+
import type { EdgeRoute } from '@eventcatalog/visualiser/layout';
|
|
3
4
|
import { createEdge, getEdgeLabel } from './edges';
|
|
4
5
|
import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
|
|
5
|
-
import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
|
|
6
|
+
import { getNodeDefinition, getNodeName, getNodeSize, isGroupType } from './node-types';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Everything a canvas holds, as operations on its Yjs document. The same functions run in the
|
|
@@ -35,6 +36,24 @@ export type CanvasMeta = {
|
|
|
35
36
|
createdBy?: string;
|
|
36
37
|
/** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
|
|
37
38
|
layoutAt?: number;
|
|
39
|
+
/** L1 and L2 as the diagram the canvas was opened from showed them (see DiagramLevels) */
|
|
40
|
+
diagramLevels?: DiagramLevels;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A level of detail as a diagram showed it: its nodes (laid out) and edges, by canvas node ids where they're the same */
|
|
44
|
+
export type DiagramLevel = { nodes: Node[]; edges: Edge[] };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* L1 and L2 of the diagram a canvas was opened from (the visualiser's "Open in Studio"), shown as they were while the
|
|
48
|
+
* canvas's structure is what was opened (`structure`, from getStructureKey): moving nodes keeps them, changing what's
|
|
49
|
+
* on the canvas or how it's connected doesn't (the levels are then worked out from the canvas)
|
|
50
|
+
*/
|
|
51
|
+
export type DiagramLevels = {
|
|
52
|
+
structure: string;
|
|
53
|
+
1?: DiagramLevel;
|
|
54
|
+
2?: DiagramLevel;
|
|
55
|
+
/** Levels the diagram doesn't have, and why (as the diagram says) */
|
|
56
|
+
unavailable?: { 1?: string; 2?: string };
|
|
38
57
|
};
|
|
39
58
|
|
|
40
59
|
// Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
|
|
@@ -52,8 +71,43 @@ export const getCanvasMaps = (doc: Y.Doc) => ({
|
|
|
52
71
|
edges: doc.getMap<Edge>('edges'),
|
|
53
72
|
threads: doc.getMap<Y.Map<unknown>>('threads'),
|
|
54
73
|
meta: doc.getMap<unknown>('meta'),
|
|
74
|
+
/** Where things are on L1 and L2 (see LevelPlace), one entry per node and level (`1:<node id>`) */
|
|
75
|
+
levelLayouts: doc.getMap<LevelPlace>('levelLayouts'),
|
|
55
76
|
});
|
|
56
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Where a node is on L1 or L2, as people arranged it there (or as the diagram it was opened from had it): its
|
|
80
|
+
* top-left corner, relative to the container it's shown in on that level, and a container's size
|
|
81
|
+
*/
|
|
82
|
+
export type LevelPlace = { x: number; y: number; width?: number; height?: number };
|
|
83
|
+
|
|
84
|
+
/** The places on a level, by node id */
|
|
85
|
+
export const readLevelLayout = (doc: Y.Doc, level: 1 | 2) => {
|
|
86
|
+
const prefix = `${level}:`;
|
|
87
|
+
const places = new Map<string, LevelPlace>();
|
|
88
|
+
getCanvasMaps(doc).levelLayouts.forEach((place, key) => {
|
|
89
|
+
if (key.startsWith(prefix)) places.set(key.slice(prefix.length), place);
|
|
90
|
+
});
|
|
91
|
+
return places;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const samePlace = (a: LevelPlace | undefined, b: LevelPlace) =>
|
|
95
|
+
!!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Puts nodes where they go on a level (in one transaction). Places that haven't changed aren't written: everyone
|
|
99
|
+
* looking at the level works out the same places for what's new on it, and each write re-arranges the level for
|
|
100
|
+
* everyone else.
|
|
101
|
+
*/
|
|
102
|
+
export const setLevelPlaces = (doc: Y.Doc, level: 1 | 2, places: ReadonlyMap<string, LevelPlace>) =>
|
|
103
|
+
doc.transact(() => {
|
|
104
|
+
const { levelLayouts } = getCanvasMaps(doc);
|
|
105
|
+
places.forEach((place, id) => {
|
|
106
|
+
const key = `${level}:${id}`;
|
|
107
|
+
if (!samePlace(levelLayouts.get(key), place)) levelLayouts.set(key, place);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
57
111
|
// Only the design is shared. Selection, measured sizes and drag state stay with each user.
|
|
58
112
|
export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
|
|
59
113
|
id,
|
|
@@ -84,9 +138,6 @@ export const buildNode = (type: string, data: Record<string, unknown>, center: X
|
|
|
84
138
|
|
|
85
139
|
// ---- Meta ----
|
|
86
140
|
|
|
87
|
-
/** Canvas titles are at most this long */
|
|
88
|
-
export const MAX_TITLE_LENGTH = 200;
|
|
89
|
-
|
|
90
141
|
export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
|
|
91
142
|
|
|
92
143
|
export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
|
|
@@ -154,13 +205,23 @@ export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
|
|
|
154
205
|
edges.forEach((edge) => maps.edges.set(edge.id, edge));
|
|
155
206
|
});
|
|
156
207
|
|
|
157
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Moves a node, and resizes it if a size is given, in one write. Nodes are only written when they change: every
|
|
210
|
+
* write is sent to everyone, re-renders the node for them, and is kept in the document (and the undo stack), even
|
|
211
|
+
* when it writes what was already there.
|
|
212
|
+
*/
|
|
213
|
+
export const placeNode = (doc: Y.Doc, id: string, position: XYPosition, size?: { width: number; height: number }) => {
|
|
158
214
|
const { nodes } = getCanvasMaps(doc);
|
|
159
215
|
const node = nodes.get(id);
|
|
160
|
-
if (node)
|
|
161
|
-
|
|
216
|
+
if (!node) return false;
|
|
217
|
+
const moved = node.position.x !== position.x || node.position.y !== position.y;
|
|
218
|
+
const resized = !!size && (node.width !== size.width || node.height !== size.height);
|
|
219
|
+
if (moved || resized) nodes.set(id, { ...node, position, ...(size && { width: size.width, height: size.height }) });
|
|
220
|
+
return true;
|
|
162
221
|
};
|
|
163
222
|
|
|
223
|
+
export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => placeNode(doc, id, position);
|
|
224
|
+
|
|
164
225
|
/** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
|
|
165
226
|
export const resizeNode = (
|
|
166
227
|
doc: Y.Doc,
|
|
@@ -170,7 +231,9 @@ export const resizeNode = (
|
|
|
170
231
|
) => {
|
|
171
232
|
const { nodes } = getCanvasMaps(doc);
|
|
172
233
|
const node = nodes.get(id);
|
|
173
|
-
|
|
234
|
+
const width = attributes !== 'height' && node?.width !== size.width;
|
|
235
|
+
const height = attributes !== 'width' && node?.height !== size.height;
|
|
236
|
+
if (node && (width || height))
|
|
174
237
|
nodes.set(id, {
|
|
175
238
|
...node,
|
|
176
239
|
...(attributes !== 'height' && { width: size.width }),
|
|
@@ -235,6 +298,93 @@ export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<st
|
|
|
235
298
|
return !!node;
|
|
236
299
|
};
|
|
237
300
|
|
|
301
|
+
/**
|
|
302
|
+
* How a connection is drawn, as the visualiser's layout routes it (in data.route, which the visualiser's edges
|
|
303
|
+
* are drawn along): the points it goes through, where its label goes, and where its nodes were (top-left) when
|
|
304
|
+
* it was routed, for it to follow them when they move
|
|
305
|
+
*/
|
|
306
|
+
export type { EdgeRoute };
|
|
307
|
+
|
|
308
|
+
/** An edge's route, if it has one */
|
|
309
|
+
export const getEdgeRoute = (edge: Pick<Edge, 'data'>) => (edge.data as { route?: EdgeRoute } | undefined)?.route;
|
|
310
|
+
|
|
311
|
+
/** Routes connections (by edge id), in one transaction. Connections not in it lose any route they had. */
|
|
312
|
+
export const setEdgeRoutes = (doc: Y.Doc, routes: ReadonlyMap<string, EdgeRoute>) =>
|
|
313
|
+
doc.transact(() => {
|
|
314
|
+
const { edges } = getCanvasMaps(doc);
|
|
315
|
+
edges.forEach((edge, id) => {
|
|
316
|
+
const route = routes.get(id);
|
|
317
|
+
if (route) edges.set(id, { ...edge, data: { ...edge.data, route } });
|
|
318
|
+
else if (getEdgeRoute(edge)) {
|
|
319
|
+
const { route: _, ...data } = edge.data!;
|
|
320
|
+
edges.set(id, { ...edge, data });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What's on a canvas and how it's connected, not where: its nodes (not notes), what they're in and called, and its
|
|
327
|
+
* connections. The same for the same canvas wherever it's worked out (the server, any browser).
|
|
328
|
+
*/
|
|
329
|
+
export const getStructureKey = (nodes: Node[], edges: Edge[]) => {
|
|
330
|
+
const parts = [
|
|
331
|
+
...nodes
|
|
332
|
+
.filter((node) => node.type !== 'note')
|
|
333
|
+
.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
|
|
334
|
+
.sort(),
|
|
335
|
+
'#',
|
|
336
|
+
...edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).sort(),
|
|
337
|
+
];
|
|
338
|
+
// FNV-1a, so it's short enough to keep in the canvas
|
|
339
|
+
let hash = 0x811c9dc5;
|
|
340
|
+
for (const char of parts.join('|')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
|
|
341
|
+
return `${parts.length}-${(hash >>> 0).toString(36)}`;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** The diagram's L1 or L2 while the canvas still has the structure it was opened with (`structure`, its key now) */
|
|
345
|
+
export const getDiagramLevel = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
|
|
346
|
+
levels?.[level] && !levels.unavailable?.[level] && levels.structure === structure ? levels[level] : undefined;
|
|
347
|
+
|
|
348
|
+
/** Why the diagram doesn't have a level, while the canvas still has the structure it was opened with */
|
|
349
|
+
export const getDiagramLevelUnavailable = (levels: DiagramLevels | undefined, level: 1 | 2, structure: string) =>
|
|
350
|
+
levels?.unavailable?.[level] && levels.structure === structure ? levels.unavailable[level] : undefined;
|
|
351
|
+
|
|
352
|
+
/** What a new canvas starts with: its nodes, the connections between them (by node id) with their routes, and the
|
|
353
|
+
* levels of the diagram it's opened from */
|
|
354
|
+
export type CanvasContent = {
|
|
355
|
+
nodes: Node[];
|
|
356
|
+
connections: { from: string; to: string; label?: string; route?: Pick<EdgeRoute, 'points' | 'label'> }[];
|
|
357
|
+
levels?: Pick<DiagramLevels, 1 | 2 | 'unavailable'>;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Puts a new canvas's content on it (in one transaction), with its containers fitted to what's in them. Routes
|
|
362
|
+
* are from where the nodes are put.
|
|
363
|
+
*/
|
|
364
|
+
export const addCanvasContent = (doc: Y.Doc, { nodes, connections, levels }: CanvasContent) =>
|
|
365
|
+
doc.transact(() => {
|
|
366
|
+
addNodes(doc, nodes);
|
|
367
|
+
nodes.forEach((node) => node.parentId && fitContainersAround(doc, node.id));
|
|
368
|
+
const placed = new Map(getCanvasMaps(doc).nodes.entries());
|
|
369
|
+
const routes = new Map<string, EdgeRoute>();
|
|
370
|
+
connections.forEach(({ from, to, label, route }) => {
|
|
371
|
+
const edge = connectNodes(doc, { source: from, target: to }, label);
|
|
372
|
+
const [source, target] = [placed.get(from), placed.get(to)];
|
|
373
|
+
if (!route || 'error' in edge || !source || !target) return;
|
|
374
|
+
routes.set(edge.id, {
|
|
375
|
+
...route,
|
|
376
|
+
source: getAbsolutePosition(source, placed),
|
|
377
|
+
target: getAbsolutePosition(target, placed),
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
if (routes.size) setEdgeRoutes(doc, routes);
|
|
381
|
+
if (levels) {
|
|
382
|
+
const maps = getCanvasMaps(doc);
|
|
383
|
+
const structure = getStructureKey(Array.from(maps.nodes.values()), Array.from(maps.edges.values()));
|
|
384
|
+
setMeta(doc, { diagramLevels: { structure, ...levels } });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
238
388
|
/** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
|
|
239
389
|
export const connectNodes = (
|
|
240
390
|
doc: Y.Doc,
|
|
@@ -299,7 +449,7 @@ export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
|
|
|
299
449
|
/** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
|
|
300
450
|
export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
|
|
301
451
|
doc.transact(() => {
|
|
302
|
-
const { nodes, edges, threads } = getCanvasMaps(doc);
|
|
452
|
+
const { nodes, edges, threads, levelLayouts } = getCanvasMaps(doc);
|
|
303
453
|
const all = Array.from(nodes.values());
|
|
304
454
|
const lookup = new Map(all.map((node) => [node.id, node]));
|
|
305
455
|
const removed = withDescendants(ids, all);
|
|
@@ -313,7 +463,12 @@ export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
|
|
|
313
463
|
thread.delete('nodeId');
|
|
314
464
|
thread.delete('offset');
|
|
315
465
|
});
|
|
316
|
-
removed.forEach((id) =>
|
|
466
|
+
removed.forEach((id) => {
|
|
467
|
+
nodes.delete(id);
|
|
468
|
+
// Where it was on L1 and L2 (undoing the delete puts it back there)
|
|
469
|
+
levelLayouts.delete(`1:${id}`);
|
|
470
|
+
levelLayouts.delete(`2:${id}`);
|
|
471
|
+
});
|
|
317
472
|
edges.forEach((edge, id) => {
|
|
318
473
|
if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
|
|
319
474
|
});
|