@pauloricca/spagbol 0.0.0-stage → 0.1.1

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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1248 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +171 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/nodeDrag.d.ts +7 -0
  114. package/dist/react/nodeDrag.d.ts.map +1 -0
  115. package/dist/react/nodeDrag.js +27 -0
  116. package/dist/react/nodeDrag.js.map +1 -0
  117. package/dist/react/portState.d.ts +10 -0
  118. package/dist/react/portState.d.ts.map +1 -0
  119. package/dist/react/portState.js +35 -0
  120. package/dist/react/portState.js.map +1 -0
  121. package/dist/react/viewport.d.ts +24 -0
  122. package/dist/react/viewport.d.ts.map +1 -0
  123. package/dist/react/viewport.js +55 -0
  124. package/dist/react/viewport.js.map +1 -0
  125. package/dist/styles.css +748 -0
  126. package/package.json +58 -4
  127. package/src/core/controller.ts +844 -0
  128. package/src/core/graph.ts +451 -0
  129. package/src/core/grouping.ts +159 -0
  130. package/src/core/groups.ts +125 -0
  131. package/src/core/index.ts +7 -0
  132. package/src/core/model.ts +304 -0
  133. package/src/core/observations.ts +31 -0
  134. package/src/core/serialization.ts +184 -0
  135. package/src/index.ts +9 -0
  136. package/src/react/Controls.tsx +355 -0
  137. package/src/react/SpagbolCanvas.tsx +1893 -0
  138. package/src/react/SpagbolCanvasControls.tsx +255 -0
  139. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  140. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  141. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  142. package/src/react/SpagbolControlNumber.tsx +297 -0
  143. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  144. package/src/react/SpagbolControlZoom.tsx +102 -0
  145. package/src/react/SpagbolNodeFrame.tsx +330 -0
  146. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  147. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  148. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  149. package/src/react/context.tsx +146 -0
  150. package/src/react/controls.css +122 -0
  151. package/src/react/duplicateDrag.ts +97 -0
  152. package/src/react/flowPresentation.ts +56 -0
  153. package/src/react/geometry.ts +53 -0
  154. package/src/react/index.ts +61 -0
  155. package/src/react/link-controls.css +123 -0
  156. package/src/react/nodeDrag.ts +33 -0
  157. package/src/react/portState.ts +24 -0
  158. package/src/react/styles.css +748 -0
  159. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,451 @@
1
+ import {
2
+ clone,
3
+ createId,
4
+ graphAt,
5
+ portsFor,
6
+ type Diagnostic,
7
+ type GraphArea,
8
+ type GraphDocument,
9
+ type GraphLink,
10
+ type GraphNode,
11
+ type GraphPolicy,
12
+ type LinkModeRegistry,
13
+ type LinkTarget,
14
+ type NodeRegistry,
15
+ type Point,
16
+ type PortEndpoint,
17
+ } from "./model.js";
18
+
19
+ export function sameEndpoint(a: PortEndpoint, b: PortEndpoint): boolean {
20
+ return a.nodeId === b.nodeId && a.portId === b.portId;
21
+ }
22
+ export function sameTarget(a: LinkTarget, b: LinkTarget): boolean {
23
+ return a.type === "port" && b.type === "port"
24
+ ? sameEndpoint(a, b)
25
+ : a.type === "link" &&
26
+ b.type === "link" &&
27
+ a.linkId === b.linkId &&
28
+ a.property === b.property;
29
+ }
30
+ export function descendantLinks(
31
+ graph: GraphDocument,
32
+ ids: Iterable<string>,
33
+ ): Set<string> {
34
+ const result = new Set(ids);
35
+ let changed = true;
36
+ while (changed) {
37
+ changed = false;
38
+ for (const link of graph.links)
39
+ if (
40
+ link.target.type === "link" &&
41
+ result.has(link.target.linkId) &&
42
+ !result.has(link.id)
43
+ ) {
44
+ result.add(link.id);
45
+ changed = true;
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+ export function connectionError(
51
+ graph: GraphDocument,
52
+ nodes: NodeRegistry,
53
+ modes: LinkModeRegistry,
54
+ policy: GraphPolicy,
55
+ source: PortEndpoint,
56
+ target: LinkTarget,
57
+ mode: string,
58
+ replacingId?: string,
59
+ ): string | undefined {
60
+ const sourceNode = graph.nodes.find((node) => node.id === source.nodeId);
61
+ const sourcePort =
62
+ sourceNode &&
63
+ portsFor(sourceNode, nodes).find(
64
+ (port) => port.id === source.portId && port.direction === "output",
65
+ );
66
+ if (!sourcePort || sourcePort.connectable === false)
67
+ return "The source must be a connectable output.";
68
+ if (!modes.get(mode) || (policy.modes && !policy.modes.includes(mode)))
69
+ return `Link mode ${mode} is unavailable.`;
70
+ if (target.type === "port") {
71
+ const targetNode = graph.nodes.find((node) => node.id === target.nodeId);
72
+ const targetPort =
73
+ targetNode &&
74
+ portsFor(targetNode, nodes).find(
75
+ (port) => port.id === target.portId && port.direction === "input",
76
+ );
77
+ if (!targetPort || targetPort.connectable === false)
78
+ return "The destination must be a connectable input.";
79
+ if (policy.allowSelfLinks === false && source.nodeId === target.nodeId)
80
+ return "Self connections are disabled.";
81
+ if (
82
+ sourcePort.dataType &&
83
+ targetPort.dataType &&
84
+ sourcePort.dataType !== targetPort.dataType
85
+ )
86
+ return "These port data types are incompatible.";
87
+ } else {
88
+ if (!(policy.linkProperties ?? []).includes(target.property))
89
+ return `Link property ${target.property} is unavailable.`;
90
+ if (!graph.links.some((link) => link.id === target.linkId))
91
+ return "The target link does not exist.";
92
+ if (replacingId && descendantLinks(graph, [replacingId]).has(target.linkId))
93
+ return "A link cannot target itself or one of its descendants.";
94
+ }
95
+ if (
96
+ !policy.allowDuplicateLinks &&
97
+ graph.links.some(
98
+ (link) =>
99
+ link.id !== replacingId &&
100
+ sameEndpoint(link.source, source) &&
101
+ sameTarget(link.target, target),
102
+ )
103
+ )
104
+ return "This connection already exists.";
105
+ return policy.canConnect?.(source, target, graph);
106
+ }
107
+ export function removeLinks(graph: GraphDocument, ids: Iterable<string>): void {
108
+ const removed = descendantLinks(graph, ids);
109
+ graph.links = graph.links.filter((link) => !removed.has(link.id));
110
+ }
111
+ export function removeNodes(graph: GraphDocument, ids: Iterable<string>): void {
112
+ const removed = new Set(ids);
113
+ graph.nodes = graph.nodes.filter((node) => !removed.has(node.id));
114
+ removeLinks(
115
+ graph,
116
+ graph.links
117
+ .filter(
118
+ (link) =>
119
+ removed.has(link.source.nodeId) ||
120
+ (link.target.type === "port" && removed.has(link.target.nodeId)),
121
+ )
122
+ .map((link) => link.id),
123
+ );
124
+ for (const area of graph.areas)
125
+ if (area.nodeIds)
126
+ area.nodeIds = area.nodeIds.filter((id) => !removed.has(id));
127
+ }
128
+ export function pruneInvalidPorts(
129
+ graph: GraphDocument,
130
+ definitions: NodeRegistry,
131
+ changedNodeId?: string,
132
+ ): void {
133
+ const removed: string[] = [];
134
+ for (const link of graph.links) {
135
+ if (
136
+ changedNodeId &&
137
+ link.source.nodeId !== changedNodeId &&
138
+ !(link.target.type === "port" && link.target.nodeId === changedNodeId)
139
+ )
140
+ continue;
141
+ const source = graph.nodes.find((node) => node.id === link.source.nodeId);
142
+ const sourceValid =
143
+ source &&
144
+ portsFor(source, definitions).some(
145
+ (port) =>
146
+ port.direction === "output" &&
147
+ port.id === link.source.portId &&
148
+ port.connectable !== false,
149
+ );
150
+ const target = link.target;
151
+ const destination =
152
+ target.type === "port"
153
+ ? graph.nodes.find((node) => node.id === target.nodeId)
154
+ : undefined;
155
+ const targetValid =
156
+ target.type === "link" ||
157
+ (destination &&
158
+ portsFor(destination, definitions).some(
159
+ (port) =>
160
+ port.direction === "input" &&
161
+ port.id === target.portId &&
162
+ port.connectable !== false,
163
+ ));
164
+ if (!sourceValid || !targetValid) removed.push(link.id);
165
+ }
166
+ removeLinks(graph, removed);
167
+ }
168
+ export function validateGraph(
169
+ graph: GraphDocument,
170
+ definitions: NodeRegistry,
171
+ modes: LinkModeRegistry,
172
+ policy: GraphPolicy = {},
173
+ path: readonly string[] = [],
174
+ ): Diagnostic[] {
175
+ const result: Diagnostic[] = [];
176
+ const error = (
177
+ code: string,
178
+ message: string,
179
+ extra: Partial<Diagnostic> = {},
180
+ ) => result.push({ severity: "error", code, message, path, ...extra });
181
+ const ids = new Set<string>();
182
+ for (const node of graph.nodes) {
183
+ if (ids.has(node.id))
184
+ error("duplicate-node", `Duplicate node ${node.id}.`, {
185
+ nodeId: node.id,
186
+ });
187
+ ids.add(node.id);
188
+ const definition = definitions.get(node.kind);
189
+ if (!definition)
190
+ error(
191
+ "unknown-node",
192
+ `Unknown node kind ${node.kind}. Its data is retained.`,
193
+ { nodeId: node.id },
194
+ );
195
+ const portIds = new Set<string>();
196
+ for (const port of portsFor(node, definitions)) {
197
+ const key = `${port.direction}:${port.id}`;
198
+ if (portIds.has(key))
199
+ error("duplicate-port", `Duplicate port ${port.label}.`, {
200
+ nodeId: node.id,
201
+ portId: port.id,
202
+ });
203
+ portIds.add(key);
204
+ }
205
+ for (const message of definition?.validate?.(node) ?? [])
206
+ error("node-data", message, { nodeId: node.id });
207
+ if (node.graph)
208
+ result.push(
209
+ ...validateGraph(node.graph, definitions, modes, policy, [
210
+ ...path,
211
+ node.id,
212
+ ]),
213
+ );
214
+ }
215
+ const links = new Set<string>();
216
+ for (const link of graph.links) {
217
+ if (links.has(link.id))
218
+ error("duplicate-link", `Duplicate link ${link.id}.`, {
219
+ linkId: link.id,
220
+ });
221
+ links.add(link.id);
222
+ const message = connectionError(
223
+ graph,
224
+ definitions,
225
+ modes,
226
+ policy,
227
+ link.source,
228
+ link.target,
229
+ link.mode,
230
+ link.id,
231
+ );
232
+ if (message) error("invalid-connection", message, { linkId: link.id });
233
+ }
234
+ const areas = new Set<string>();
235
+ for (const area of graph.areas) {
236
+ if (areas.has(area.id))
237
+ error("duplicate-area", `Duplicate area ${area.id}.`);
238
+ areas.add(area.id);
239
+ }
240
+ return result;
241
+ }
242
+ export function contains(area: GraphArea, point: Point): boolean {
243
+ return (
244
+ point.x >= area.position.x &&
245
+ point.y >= area.position.y &&
246
+ point.x < area.position.x + area.size.width &&
247
+ point.y < area.position.y + area.size.height
248
+ );
249
+ }
250
+ export function areaMembers(
251
+ graph: GraphDocument,
252
+ id: string,
253
+ visited = new Set<string>(),
254
+ ): { nodeIds: Set<string>; areaIds: Set<string> } {
255
+ const area = graph.areas.find((candidate) => candidate.id === id);
256
+ const nodeIds = new Set<string>(),
257
+ areaIds = new Set<string>();
258
+ if (!area || visited.has(id)) return { nodeIds, areaIds };
259
+ const next = new Set([...visited, id]);
260
+ if (area.ownerNodeId && graph.nodes.some(node => node.id === area.ownerNodeId)) nodeIds.add(area.ownerNodeId);
261
+ for (const node of graph.nodes)
262
+ if (
263
+ area.locked
264
+ ? area.nodeIds?.includes(node.id)
265
+ : contains(area, node.position)
266
+ )
267
+ nodeIds.add(node.id);
268
+ for (const other of graph.areas) {
269
+ if (other.id === id || next.has(other.id)) continue;
270
+ if (
271
+ !(area.locked
272
+ ? area.areaIds?.includes(other.id)
273
+ : contains(area, other.position) &&
274
+ other.size.width <= area.size.width &&
275
+ other.size.height <= area.size.height)
276
+ )
277
+ continue;
278
+ areaIds.add(other.id);
279
+ const children = areaMembers(graph, other.id, next);
280
+ for (const child of children.nodeIds) nodeIds.add(child);
281
+ for (const child of children.areaIds) areaIds.add(child);
282
+ }
283
+ return { nodeIds, areaIds };
284
+ }
285
+ export interface GraphFragment {
286
+ format: "spagbol-fragment";
287
+ version: 1;
288
+ nodes: GraphNode[];
289
+ links: GraphLink[];
290
+ areas: GraphArea[];
291
+ boundaryLinks: GraphLink[];
292
+ }
293
+ export function copyFragment(
294
+ graph: GraphDocument,
295
+ nodeIds: Iterable<string>,
296
+ areaIds: Iterable<string> = [],
297
+ ): GraphFragment {
298
+ const included = new Set(nodeIds),
299
+ includedAreas = new Set(areaIds);
300
+ for (const id of areaIds) {
301
+ const members = areaMembers(graph, id);
302
+ for (const child of members.nodeIds) included.add(child);
303
+ for (const child of members.areaIds) includedAreas.add(child);
304
+ }
305
+ const links = new Set(
306
+ graph.links
307
+ .filter(
308
+ (link) =>
309
+ included.has(link.source.nodeId) &&
310
+ link.target.type === "port" &&
311
+ included.has(link.target.nodeId),
312
+ )
313
+ .map((link) => link.id),
314
+ );
315
+ let length = -1;
316
+ while (length !== links.size) {
317
+ length = links.size;
318
+ for (const link of graph.links)
319
+ if (
320
+ included.has(link.source.nodeId) &&
321
+ link.target.type === "link" &&
322
+ links.has(link.target.linkId)
323
+ )
324
+ links.add(link.id);
325
+ }
326
+ return clone({
327
+ format: "spagbol-fragment",
328
+ version: 1,
329
+ nodes: graph.nodes.filter((node) => included.has(node.id)),
330
+ links: graph.links.filter((link) => links.has(link.id)),
331
+ areas: graph.areas.filter((area) => includedAreas.has(area.id)),
332
+ boundaryLinks: graph.links.filter(
333
+ (link) =>
334
+ !links.has(link.id) &&
335
+ (included.has(link.source.nodeId) ||
336
+ (link.target.type === "port" && included.has(link.target.nodeId))),
337
+ ),
338
+ });
339
+ }
340
+ export function pasteFragment(
341
+ graph: GraphDocument,
342
+ fragment: GraphFragment,
343
+ definitions: NodeRegistry,
344
+ offset: Point,
345
+ external = false,
346
+ ): { nodeIds: string[]; areaIds: string[] } {
347
+ const ids = new Map<string, string>();
348
+ for (const node of fragment.nodes) ids.set(node.id, createId("node"));
349
+ const areaIds = new Map(
350
+ fragment.areas.map((area) => [area.id, createId("area")]),
351
+ );
352
+ const candidates = [
353
+ ...fragment.links,
354
+ ...(external ? fragment.boundaryLinks : []),
355
+ ];
356
+ const linkIds = new Map(
357
+ candidates.map((link) => [link.id, createId("link")]),
358
+ );
359
+ const nodes = clone(fragment.nodes).map((node) => ({
360
+ ...node,
361
+ id: ids.get(node.id)!,
362
+ position: { x: node.position.x + offset.x, y: node.position.y + offset.y },
363
+ data: definitions.get(node.kind)?.cloneData?.(node.data, ids) ?? node.data,
364
+ }));
365
+ const available = new Set([
366
+ ...graph.nodes.map((node) => node.id),
367
+ ...nodes.map((node) => node.id),
368
+ ]);
369
+ const links = candidates.flatMap((original) => {
370
+ const link = clone(original);
371
+ link.id = linkIds.get(original.id)!;
372
+ link.source.nodeId =
373
+ ids.get(original.source.nodeId) ?? original.source.nodeId;
374
+ link.target =
375
+ original.target.type === "port"
376
+ ? {
377
+ ...original.target,
378
+ nodeId: ids.get(original.target.nodeId) ?? original.target.nodeId,
379
+ }
380
+ : {
381
+ ...original.target,
382
+ linkId:
383
+ linkIds.get(original.target.linkId) ?? original.target.linkId,
384
+ };
385
+ if (
386
+ !available.has(link.source.nodeId) ||
387
+ (link.target.type === "port" && !available.has(link.target.nodeId))
388
+ )
389
+ return [];
390
+ return [link];
391
+ });
392
+ graph.nodes.push(...nodes);
393
+ graph.links.push(...links);
394
+ // A copied modulation can reference a boundary link which was not included.
395
+ let count = -1;
396
+ while (count !== graph.links.length) {
397
+ count = graph.links.length;
398
+ const existing = new Set(graph.links.map((link) => link.id));
399
+ graph.links = graph.links.filter(
400
+ (link) => link.target.type !== "link" || existing.has(link.target.linkId),
401
+ );
402
+ }
403
+ graph.areas.push(
404
+ ...clone(fragment.areas).map((area) => ({
405
+ ...area,
406
+ id: areaIds.get(area.id)!,
407
+ ...(area.ownerNodeId ? { ownerNodeId: ids.get(area.ownerNodeId) ?? area.ownerNodeId } : {}),
408
+ position: {
409
+ x: area.position.x + offset.x,
410
+ y: area.position.y + offset.y,
411
+ },
412
+ ...(area.nodeIds
413
+ ? {
414
+ nodeIds: area.nodeIds.flatMap((id) =>
415
+ ids.has(id) ? [ids.get(id)!] : [],
416
+ ),
417
+ }
418
+ : {}),
419
+ ...(area.areaIds
420
+ ? {
421
+ areaIds: area.areaIds.flatMap((id) =>
422
+ areaIds.has(id) ? [areaIds.get(id)!] : [],
423
+ ),
424
+ }
425
+ : {}),
426
+ })),
427
+ );
428
+ return {
429
+ nodeIds: nodes.map((node) => node.id),
430
+ areaIds: [...areaIds.values()],
431
+ };
432
+ }
433
+ /** Propagate a nested graph edit to explicitly linked instances, never their overrides. */
434
+ export function synchronizeSharedGraph(
435
+ document: GraphDocument,
436
+ path: readonly string[],
437
+ ): void {
438
+ if (!path.length) return;
439
+ const parent = graphAt(document, path.slice(0, -1));
440
+ const owner = parent.nodes.find((node) => node.id === path.at(-1));
441
+ if (!owner?.sharedGraphId || !owner.graph) return;
442
+ const sharedId = owner.sharedGraphId,
443
+ content = clone(owner.graph);
444
+ const visit = (graph: GraphDocument) => {
445
+ for (const node of graph.nodes) {
446
+ if (node.sharedGraphId === sharedId) node.graph = clone(content);
447
+ else if (node.graph) visit(node.graph);
448
+ }
449
+ };
450
+ visit(document);
451
+ }
@@ -0,0 +1,159 @@
1
+ import { areaMembers } from "./graph.js";
2
+ import {
3
+ clone,
4
+ createId,
5
+ portsFor,
6
+ emptyGraph,
7
+ spagbolGroupKinds,
8
+ type GraphDocument,
9
+ type GraphLink,
10
+ type GraphNode,
11
+ type NodeRegistry,
12
+ type PortDefinition,
13
+ type PortEndpoint,
14
+ } from "./model.js";
15
+
16
+ export interface GroupOptions {
17
+ groupKind: string;
18
+ inputKind: string;
19
+ outputKind: string;
20
+ label?: string;
21
+ }
22
+ /** Structural grouping only. Consumers decide how these boundary kinds execute. */
23
+ export function groupNodes(
24
+ graph: GraphDocument,
25
+ ids: readonly string[],
26
+ definitions: NodeRegistry,
27
+ options: GroupOptions = { groupKind: spagbolGroupKinds.group, inputKind: spagbolGroupKinds.inputs, outputKind: spagbolGroupKinds.outputs },
28
+ ): string | undefined {
29
+ const selected = new Set(ids),
30
+ members = graph.nodes.filter((node) => selected.has(node.id));
31
+ if (!members.length) return;
32
+ for (const kind of [options.groupKind, options.inputKind, options.outputKind])
33
+ if (!definitions.get(kind))
34
+ throw new Error(`Grouping requires registered kind ${kind}.`);
35
+ const owner = new Map<string, boolean>();
36
+ const byId = new Map(graph.links.map((link) => [link.id, link]));
37
+ function inside(link: GraphLink, seen = new Set<string>()): boolean {
38
+ if (owner.has(link.id)) return owner.get(link.id)!;
39
+ if (seen.has(link.id))
40
+ throw new Error("Cannot group a cyclic link-property dependency.");
41
+ const next = new Set([...seen, link.id]);
42
+ const target = link.target;
43
+ const parent = target.type === "link" ? byId.get(target.linkId) : undefined;
44
+ if (target.type === "link" && !parent)
45
+ throw new Error("Cannot group a dangling link-property target.");
46
+ const value =
47
+ target.type === "port"
48
+ ? selected.has(target.nodeId)
49
+ : inside(parent!, next);
50
+ owner.set(link.id, value);
51
+ return value;
52
+ }
53
+ const minX = Math.min(...members.map((node) => node.position.x)),
54
+ minY = Math.min(...members.map((node) => node.position.y));
55
+ const maxX = Math.max(
56
+ ...members.map((node) => node.position.x + (node.size?.width ?? 220)),
57
+ );
58
+ const group = definitions.create(options.groupKind, { x: minX, y: minY });
59
+ const input = definitions.create(options.inputKind, {
60
+ x: minX - 270,
61
+ y: minY,
62
+ });
63
+ const output = definitions.create(options.outputKind, {
64
+ x: maxX + 70,
65
+ y: minY,
66
+ });
67
+ group.label = options.label ?? "Group";
68
+ group.graph = emptyGraph();
69
+ group.sharedGraphId = createId("shared");
70
+ group.ports = [];
71
+ input.ports = [];
72
+ output.ports = [];
73
+ const inputPorts = new Map<string, string>(),
74
+ outputPorts = new Map<string, string>();
75
+ const outerInputs: GraphLink[] = [],
76
+ innerOutputs: GraphLink[] = [];
77
+ const key = (endpoint: PortEndpoint) =>
78
+ JSON.stringify([endpoint.nodeId, endpoint.portId]);
79
+ function expose(
80
+ source: PortEndpoint,
81
+ direction: "input" | "output",
82
+ preferredLabel?: string,
83
+ ): PortEndpoint {
84
+ const map = direction === "input" ? inputPorts : outputPorts;
85
+ const previous = map.get(key(source));
86
+ if (previous)
87
+ return {
88
+ nodeId: direction === "input" ? input.id : group.id,
89
+ portId: previous,
90
+ };
91
+ const id = createId("port");
92
+ const sourceNode = graph.nodes.find(node => node.id === source.nodeId);
93
+ const sourcePort = sourceNode && portsFor(sourceNode, definitions).find(port => port.id === source.portId && port.direction === 'output');
94
+ const base = (preferredLabel ?? sourcePort?.label ?? source.portId).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase() || 'signal';
95
+ let label = base, suffix = 2;
96
+ while (group.ports!.some(port => port.direction === direction && port.label === label)) label = `${base} ${suffix++}`;
97
+ const port: PortDefinition = { id, label, direction, defaultValue: 0 };
98
+ group.ports!.push(port);
99
+ map.set(key(source), id);
100
+ if (direction === "input") {
101
+ group.values[id] = 0;
102
+ input.ports!.push({ ...port, direction: "output" });
103
+ outerInputs.push({
104
+ id: createId("link"),
105
+ source: { ...source },
106
+ target: { type: "port", nodeId: group.id, portId: id },
107
+ mode: "set",
108
+ weight: 1,
109
+ });
110
+ } else {
111
+ output.ports!.push({ ...port, direction: "input" });
112
+ output.values[id] = 0;
113
+ innerOutputs.push({
114
+ id: createId("link"),
115
+ source: { ...source },
116
+ target: { type: "port", nodeId: output.id, portId: id },
117
+ mode: "set",
118
+ weight: 1,
119
+ });
120
+ }
121
+ return { nodeId: direction === "input" ? input.id : group.id, portId: id };
122
+ }
123
+ const innerLinks: GraphLink[] = [],
124
+ outerLinks: GraphLink[] = [];
125
+ for (const original of graph.links) {
126
+ const link = clone(original);
127
+ if (inside(original)) {
128
+ if (!selected.has(original.source.nodeId))
129
+ link.source = expose(original.source, "input", original.target.type === 'link' ? 'strength' : (() => {
130
+ const targetNode = graph.nodes.find(node => original.target.type === 'port' && node.id === original.target.nodeId);
131
+ return targetNode && portsFor(targetNode, definitions).find(port => port.direction === 'input' && original.target.type === 'port' && port.id === original.target.portId)?.label;
132
+ })());
133
+ innerLinks.push(link);
134
+ } else {
135
+ if (selected.has(original.source.nodeId))
136
+ link.source = expose(original.source, "output");
137
+ outerLinks.push(link);
138
+ }
139
+ }
140
+ group.graph.nodes = [input, ...members, output];
141
+ group.graph.links = [...innerLinks, ...innerOutputs];
142
+ const movedAreas = graph.areas.filter(area => {
143
+ const members = areaMembers(graph, area.id).nodeIds;
144
+ return members.size > 0 && [...members].every(id => selected.has(id));
145
+ });
146
+ const movedAreaIds = new Set(movedAreas.map(area => area.id));
147
+ group.graph.areas = movedAreas;
148
+ graph.areas = graph.areas.filter(area => !movedAreaIds.has(area.id));
149
+ graph.nodes = [
150
+ ...graph.nodes.filter((node) => !selected.has(node.id)),
151
+ group,
152
+ ];
153
+ graph.links = [...outerLinks, ...outerInputs];
154
+ for (const area of graph.areas) {
155
+ if (area.nodeIds) area.nodeIds = [...new Set(area.nodeIds.map(id => selected.has(id) ? group.id : id))];
156
+ if (area.areaIds) area.areaIds = area.areaIds.filter(id => !movedAreaIds.has(id));
157
+ }
158
+ return group.id;
159
+ }