@diagc/core 0.1.0

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 (59) hide show
  1. package/LICENSE +709 -0
  2. package/README.md +27 -0
  3. package/dist/builder.d.ts +381 -0
  4. package/dist/builder.js +590 -0
  5. package/dist/children.d.ts +21 -0
  6. package/dist/children.js +45 -0
  7. package/dist/commands.d.ts +219 -0
  8. package/dist/commands.js +474 -0
  9. package/dist/compose.d.ts +19 -0
  10. package/dist/compose.js +246 -0
  11. package/dist/drawings.d.ts +13 -0
  12. package/dist/drawings.js +36 -0
  13. package/dist/eject.d.ts +20 -0
  14. package/dist/eject.js +260 -0
  15. package/dist/fishbone.d.ts +66 -0
  16. package/dist/fishbone.js +95 -0
  17. package/dist/git.d.ts +65 -0
  18. package/dist/git.js +159 -0
  19. package/dist/guards.d.ts +10 -0
  20. package/dist/guards.js +98 -0
  21. package/dist/index.d.ts +25 -0
  22. package/dist/index.js +45 -0
  23. package/dist/labels.d.ts +5 -0
  24. package/dist/labels.js +10 -0
  25. package/dist/layout-defaults.d.ts +20 -0
  26. package/dist/layout-defaults.js +20 -0
  27. package/dist/mutate.d.ts +106 -0
  28. package/dist/mutate.js +547 -0
  29. package/dist/second-order.d.ts +39 -0
  30. package/dist/second-order.js +86 -0
  31. package/dist/text.d.ts +5 -0
  32. package/dist/text.js +25 -0
  33. package/dist/threat-model.d.ts +88 -0
  34. package/dist/threat-model.js +188 -0
  35. package/dist/types.d.ts +395 -0
  36. package/dist/types.js +27 -0
  37. package/dist/util.d.ts +11 -0
  38. package/dist/util.js +13 -0
  39. package/dist/validate.d.ts +19 -0
  40. package/dist/validate.js +736 -0
  41. package/dist/view/compile.d.ts +29 -0
  42. package/dist/view/compile.js +78 -0
  43. package/dist/view/edges.d.ts +4 -0
  44. package/dist/view/edges.js +118 -0
  45. package/dist/view/hierarchy.d.ts +41 -0
  46. package/dist/view/hierarchy.js +103 -0
  47. package/dist/view/layers.d.ts +7 -0
  48. package/dist/view/layers.js +17 -0
  49. package/dist/view/lod.d.ts +15 -0
  50. package/dist/view/lod.js +17 -0
  51. package/dist/view/scope.d.ts +23 -0
  52. package/dist/view/scope.js +106 -0
  53. package/dist/view/size.d.ts +8 -0
  54. package/dist/view/size.js +34 -0
  55. package/dist/view/tree.d.ts +12 -0
  56. package/dist/view/tree.js +147 -0
  57. package/dist/view/types.d.ts +68 -0
  58. package/dist/view/types.js +1 -0
  59. package/package.json +38 -0
@@ -0,0 +1,219 @@
1
+ import type { Column, DiagramLayer, DiagramLegend, DiagramModel, DiagramNode, DiagramPlane, Drawings, EdgeLabelPlacement, LayoutOverlay, LayoutSettings, Stroke, TextRun, Threat } from './types.js';
2
+ import { type ThreatTarget } from './threat-model.js';
3
+ import { type NodeDetails, type RelationOptsInput, type RelationPatch, type ThreatPatch } from './mutate.js';
4
+ export interface EditorState {
5
+ model: DiagramModel;
6
+ layout: LayoutOverlay;
7
+ /** the freehand-drawings sidecar; emptyDrawings() when the diagram has none */
8
+ drawings: Drawings;
9
+ }
10
+ export declare const emptyLayout: () => LayoutOverlay;
11
+ export declare function layoutPlaneKey(m: DiagramModel, plane?: string): string;
12
+ /**
13
+ * The pins a plane opens with: its saved `unfolded` containers, in the shape
14
+ * `compileView`'s viewport reads. One reader for every host (studio, published
15
+ * page, embed), so a saved arrangement reopens the same everywhere.
16
+ */
17
+ export declare function openingPins(layout: LayoutOverlay | undefined, m: DiagramModel, plane?: string): Record<string, 'expanded' | 'collapsed'>;
18
+ export type EditorCommand = {
19
+ type: 'add-node';
20
+ node: DiagramNode;
21
+ parent?: {
22
+ id: string;
23
+ plane?: string;
24
+ };
25
+ } | {
26
+ type: 'rename-node';
27
+ id: string;
28
+ name: string;
29
+ } | {
30
+ type: 'set-node-details';
31
+ id: string;
32
+ details: NodeDetails;
33
+ } | {
34
+ type: 'set-node-rich';
35
+ id: string;
36
+ runs: TextRun[];
37
+ } | {
38
+ type: 'set-table-columns';
39
+ id: string;
40
+ columns: Column[];
41
+ }
42
+ /** STRIDE findings ride on the node/relation they are about, so the three
43
+ * threat commands take a {@link ThreatTarget} instead of a bare id. */
44
+ | {
45
+ type: 'add-threat';
46
+ target: ThreatTarget;
47
+ threat: Threat;
48
+ } | {
49
+ type: 'update-threat';
50
+ target: ThreatTarget;
51
+ id: string;
52
+ patch: ThreatPatch;
53
+ } | {
54
+ type: 'remove-threat';
55
+ target: ThreatTarget;
56
+ id: string;
57
+ } | {
58
+ type: 'set-node-plane-hidden';
59
+ nodeId: string;
60
+ plane: string;
61
+ hidden: boolean;
62
+ } | {
63
+ type: 'set-diagram-style';
64
+ style: string | null;
65
+ } | {
66
+ type: 'set-diagram-notation';
67
+ notation: string | null;
68
+ } | {
69
+ type: 'set-diagram-legend';
70
+ legend: DiagramLegend | null;
71
+ } | {
72
+ type: 'delete-node';
73
+ id: string;
74
+ cascade?: boolean;
75
+ } | {
76
+ type: 'add-containment';
77
+ parent: string;
78
+ child: string;
79
+ plane?: string;
80
+ } | {
81
+ type: 'remove-containment';
82
+ parent: string;
83
+ child: string;
84
+ plane?: string;
85
+ } | {
86
+ type: 'group-nodes';
87
+ node: DiagramNode;
88
+ memberIds: string[];
89
+ plane?: string;
90
+ } | {
91
+ type: 'add-relation';
92
+ from: string;
93
+ to: string;
94
+ opts: RelationOptsInput;
95
+ } | {
96
+ type: 'update-relation';
97
+ id: string;
98
+ patch: RelationPatch;
99
+ } | {
100
+ type: 'delete-relation';
101
+ id: string;
102
+ } | {
103
+ type: 'upsert-layer';
104
+ layer: DiagramLayer;
105
+ } | {
106
+ type: 'delete-layer';
107
+ id: string;
108
+ } | {
109
+ type: 'merge-layers';
110
+ sources: string[];
111
+ target?: string;
112
+ } | {
113
+ type: 'upsert-plane';
114
+ plane: DiagramPlane;
115
+ } | {
116
+ type: 'delete-plane';
117
+ id: string;
118
+ } | {
119
+ type: 'set-position';
120
+ plane?: string;
121
+ nodeId: string;
122
+ x: number;
123
+ y: number;
124
+ } | {
125
+ type: 'set-size';
126
+ nodeId: string;
127
+ w: number;
128
+ h: number;
129
+ } | {
130
+ type: 'clear-position';
131
+ plane?: string;
132
+ nodeId: string;
133
+ } | {
134
+ type: 'clear-positions';
135
+ plane?: string;
136
+ } | {
137
+ type: 'set-positions';
138
+ plane?: string;
139
+ positions: Record<string, {
140
+ x: number;
141
+ y: number;
142
+ }>;
143
+ } | {
144
+ type: 'set-plane-layout';
145
+ plane?: string;
146
+ manual: boolean;
147
+ }
148
+ /** replace the list of containers the plane opens with unfolded
149
+ * (LayoutOverlay.unfolded); `[]` clears it */
150
+ | {
151
+ type: 'set-unfolded';
152
+ plane?: string;
153
+ ids: string[];
154
+ } | {
155
+ type: 'set-layout-settings';
156
+ plane?: string;
157
+ patch: Partial<LayoutSettings>;
158
+ }
159
+ /** a threat note was dragged: its offset from the automatic anchor, or null to
160
+ * let it sit beside its element again. Layout-only — the threats stay put. */
161
+ | {
162
+ type: 'set-note-offset';
163
+ target: ThreatTarget;
164
+ plane?: string;
165
+ offset: {
166
+ dx: number;
167
+ dy: number;
168
+ } | null;
169
+ }
170
+ /** open or close one element's threat bubble in this picture (saved, so the export shows it) */
171
+ | {
172
+ type: 'set-note-open';
173
+ target: ThreatTarget;
174
+ plane?: string;
175
+ open: boolean;
176
+ }
177
+ /** every element in the model that carries a threat, at once — the `Notes` chip */
178
+ | {
179
+ type: 'set-notes-open';
180
+ plane?: string;
181
+ open: boolean;
182
+ } | {
183
+ type: 'add-stroke';
184
+ plane?: string;
185
+ stroke: Stroke;
186
+ } | {
187
+ type: 'delete-stroke';
188
+ plane?: string;
189
+ id: string;
190
+ }
191
+ /** several commands as one step: applied in order, all or nothing, one undo entry */
192
+ | {
193
+ type: 'batch';
194
+ commands: EditorCommand[];
195
+ };
196
+ /**
197
+ * `layout` with the plane's unfolded list replaced. Sorted and de-duplicated so
198
+ * the file does not churn with the order boxes were clicked in; an emptied list
199
+ * is dropped and an emptied map omitted entirely (the set-plane-layout hygiene).
200
+ * Exported because the studio's view-mode save builds the same overlay without
201
+ * a command.
202
+ */
203
+ export declare function withUnfolded(layout: LayoutOverlay, key: string, ids: readonly string[]): LayoutOverlay;
204
+ /**
205
+ * `layout` with viewer label placements merged into the plane's bucket (see
206
+ * LayoutOverlay.edgeLabels). Exported because the studio's view-mode save
207
+ * builds the overlay without a command.
208
+ */
209
+ export declare function withEdgeLabelPlacements(layout: LayoutOverlay, key: string, placements: Readonly<Record<string, Readonly<Record<string, EdgeLabelPlacement>>>>): LayoutOverlay;
210
+ export declare function applyCommand(state: EditorState, command: EditorCommand): EditorState;
211
+ /**
212
+ * Like {@link applyCommand}, but surfaces the generated id for `add-relation` so
213
+ * callers (e.g. a connect gesture) can select the new relation. All other
214
+ * commands delegate unchanged and carry no id.
215
+ */
216
+ export declare function applyCommandWithResult(state: EditorState, command: EditorCommand): {
217
+ state: EditorState;
218
+ relationId?: string;
219
+ };
@@ -0,0 +1,474 @@
1
+ import { threatTargetKey } from './threat-model.js';
2
+ import { resolveContainmentPlane } from './view/compile.js';
3
+ import { addStroke, deleteStroke, pruneDrawingsPlane } from './drawings.js';
4
+ import { relationLabels } from './labels.js';
5
+ import { addContainment, addNode, addRelation, addThreat, CommandError, deleteLayer, deleteNode, deletePlane, deleteRelation, groupNodes, mergeLayers, removeContainment, removeThreat, renameNode, setDiagramLegend, setDiagramNotation, setDiagramStyle, setNodeDetails, setNodePlaneHidden, setNodeRich, setTableColumns, subtreeOf, updateRelation, updateThreat, upsertLayer, upsertPlane, } from './mutate.js';
6
+ export const emptyLayout = () => ({ version: 1, planes: {} });
7
+ export function layoutPlaneKey(m, plane) {
8
+ return resolveContainmentPlane(m, plane) ?? 'default';
9
+ }
10
+ /**
11
+ * The pins a plane opens with: its saved `unfolded` containers, in the shape
12
+ * `compileView`'s viewport reads. One reader for every host (studio, published
13
+ * page, embed), so a saved arrangement reopens the same everywhere.
14
+ */
15
+ export function openingPins(layout, m, plane) {
16
+ const ids = layout?.unfolded?.[layoutPlaneKey(m, plane)] ?? [];
17
+ return Object.fromEntries(ids.map((id) => [id, 'expanded']));
18
+ }
19
+ function setPos(layout, key, nodeId, pos) {
20
+ const plane = { ...(layout.planes[key] ?? {}) };
21
+ if (pos === undefined)
22
+ delete plane[nodeId];
23
+ else
24
+ plane[nodeId] = pos;
25
+ return { ...layout, planes: { ...layout.planes, [key]: plane } };
26
+ }
27
+ /**
28
+ * Drop every node position and unfolded entry (across all planes) and every
29
+ * size entry whose id satisfies `drop` — the shared "layout hygiene" contract for commands that
30
+ * destroy nodes (delete-node, and delete-layer's cascade). Identity is preserved
31
+ * as aggressively as possible: an untouched plane bucket keeps its reference,
32
+ * and if nothing at all is dropped the input `layout` is returned unchanged, so
33
+ * layout state unrelated to this command stays referentially stable.
34
+ */
35
+ function prunePositions(layout, drop) {
36
+ let changed = false;
37
+ let planes = layout.planes;
38
+ for (const [key, positions] of Object.entries(planes)) {
39
+ const kept = Object.fromEntries(Object.entries(positions).filter(([nid]) => !drop(nid)));
40
+ if (Object.keys(kept).length === Object.keys(positions).length)
41
+ continue; // bucket untouched → keep ref
42
+ if (!changed)
43
+ planes = { ...planes }; // first real change: start the structural copy
44
+ planes[key] = kept;
45
+ changed = true;
46
+ }
47
+ let sizes = layout.sizes;
48
+ if (sizes !== undefined) {
49
+ const kept = Object.fromEntries(Object.entries(sizes).filter(([nid]) => !drop(nid)));
50
+ if (Object.keys(kept).length !== Object.keys(sizes).length) {
51
+ sizes = kept;
52
+ changed = true;
53
+ }
54
+ }
55
+ let next = layout;
56
+ for (const [key, ids] of Object.entries(layout.unfolded ?? {})) {
57
+ const kept = ids.filter((nid) => !drop(nid));
58
+ if (kept.length !== ids.length)
59
+ next = withUnfolded(next, key, kept);
60
+ }
61
+ if (!changed && next === layout)
62
+ return layout;
63
+ return { ...next, planes, ...(sizes !== undefined ? { sizes } : {}) };
64
+ }
65
+ /**
66
+ * `layout` with the plane's unfolded list replaced. Sorted and de-duplicated so
67
+ * the file does not churn with the order boxes were clicked in; an emptied list
68
+ * is dropped and an emptied map omitted entirely (the set-plane-layout hygiene).
69
+ * Exported because the studio's view-mode save builds the same overlay without
70
+ * a command.
71
+ */
72
+ export function withUnfolded(layout, key, ids) {
73
+ const { unfolded: current = {}, ...rest } = layout;
74
+ const { [key]: _drop, ...others } = current;
75
+ const list = [...new Set(ids)].sort();
76
+ const next = list.length > 0 ? { ...others, [key]: list } : others;
77
+ return Object.keys(next).length > 0 ? { ...rest, unfolded: next } : rest;
78
+ }
79
+ /**
80
+ * `layout` with viewer label placements merged into the plane's bucket (see
81
+ * LayoutOverlay.edgeLabels). Exported because the studio's view-mode save
82
+ * builds the overlay without a command.
83
+ */
84
+ export function withEdgeLabelPlacements(layout, key, placements) {
85
+ if (Object.keys(placements).length === 0)
86
+ return layout;
87
+ const plane = { ...(layout.edgeLabels?.[key] ?? {}) };
88
+ for (const [relationId, labels] of Object.entries(placements))
89
+ plane[relationId] = { ...plane[relationId], ...labels };
90
+ return { ...layout, edgeLabels: { ...(layout.edgeLabels ?? {}), [key]: plane } };
91
+ }
92
+ /**
93
+ * `layout` with one plane's note bucket replaced, normalised: an entry at the
94
+ * automatic spot that is not open (`{ dx: 0, dy: 0 }`) says nothing and is
95
+ * dropped, then an emptied bucket and an emptied map are omitted — the
96
+ * set-plane-layout hygiene, so a bubble dragged back and closed leaves no
97
+ * trace in the file.
98
+ */
99
+ function withNoteBucket(layout, key, bucket) {
100
+ const kept = {};
101
+ for (const [tk, p] of Object.entries(bucket)) {
102
+ if (p.dx === 0 && p.dy === 0 && p.open !== true)
103
+ continue;
104
+ kept[tk] = p;
105
+ }
106
+ const { notes: current = {}, ...rest } = layout;
107
+ const { [key]: _drop, ...others } = current;
108
+ const next = Object.keys(kept).length > 0 ? { ...others, [key]: kept } : others;
109
+ return Object.keys(next).length > 0 ? { ...rest, notes: next } : rest;
110
+ }
111
+ /** one entry rewritten through `f` (absent = automatic, closed) */
112
+ function withNote(layout, key, target, f) {
113
+ const tk = threatTargetKey(target);
114
+ const bucket = { ...(layout.notes?.[key] ?? {}) };
115
+ bucket[tk] = f(bucket[tk] ?? { dx: 0, dy: 0 });
116
+ return withNoteBucket(layout, key, bucket);
117
+ }
118
+ /** `open` set or removed on `p` — never `open: false`, see NotePlacement */
119
+ function withOpen(p, open) {
120
+ const { open: _drop, ...rest } = p;
121
+ return open ? { ...rest, open: true } : rest;
122
+ }
123
+ /**
124
+ * Mirror hygiene for `notes` after a command changed the model: a note exists
125
+ * only while its element has a threat, so an offset for an element that lost
126
+ * its last threat — or was deleted — is dead data. Identity is kept when
127
+ * nothing is dropped, like pruneEdgeLabels.
128
+ */
129
+ function pruneNotes(layout, before, after) {
130
+ if (layout.notes === undefined || (before.nodes === after.nodes && before.relations === after.relations))
131
+ return layout;
132
+ const alive = new Set();
133
+ for (const n of after.nodes)
134
+ if ((n.threats?.length ?? 0) > 0)
135
+ alive.add(threatTargetKey({ node: n.id }));
136
+ for (const r of after.relations)
137
+ if ((r.threats?.length ?? 0) > 0)
138
+ alive.add(threatTargetKey({ relation: r.id }));
139
+ let changed = false;
140
+ const planes = {};
141
+ for (const [key, bucket] of Object.entries(layout.notes)) {
142
+ const kept = Object.fromEntries(Object.entries(bucket).filter(([tk]) => alive.has(tk)));
143
+ if (Object.keys(kept).length !== Object.keys(bucket).length)
144
+ changed = true;
145
+ if (Object.keys(kept).length > 0)
146
+ planes[key] = kept;
147
+ }
148
+ if (!changed)
149
+ return layout;
150
+ const { notes: _drop, ...rest } = layout;
151
+ return Object.keys(planes).length > 0 ? { ...rest, notes: planes } : rest;
152
+ }
153
+ /**
154
+ * Mirror hygiene for `edgeLabels` after a command changed the relations: drop
155
+ * the placement of a label that no longer exists (its relation or the label
156
+ * itself is gone), and of one whose position the command just set in the MODEL
157
+ * — a viewer's override must never shadow the document the author is editing,
158
+ * or dragging the label in edit mode would appear to do nothing.
159
+ */
160
+ function pruneEdgeLabels(layout, before, after) {
161
+ if (layout.edgeLabels === undefined || before.relations === after.relations)
162
+ return layout;
163
+ const labelsOf = (m) => new Map(m.relations.map((r) => [r.id, new Map(relationLabels(r).map((l) => [l.id, l]))]));
164
+ const was = labelsOf(before);
165
+ const now = labelsOf(after);
166
+ let changed = false;
167
+ const planes = {};
168
+ for (const [key, plane] of Object.entries(layout.edgeLabels)) {
169
+ const keptPlane = {};
170
+ for (const [relationId, placements] of Object.entries(plane)) {
171
+ const kept = Object.fromEntries(Object.entries(placements).filter(([labelId]) => {
172
+ const label = now.get(relationId)?.get(labelId);
173
+ const old = was.get(relationId)?.get(labelId);
174
+ return label !== undefined && (old === undefined || (old.t === label.t && old.side === label.side));
175
+ }));
176
+ if (Object.keys(kept).length !== Object.keys(placements).length)
177
+ changed = true;
178
+ if (Object.keys(kept).length > 0)
179
+ keptPlane[relationId] = kept;
180
+ }
181
+ if (Object.keys(keptPlane).length > 0)
182
+ planes[key] = keptPlane;
183
+ }
184
+ if (!changed)
185
+ return layout;
186
+ const { edgeLabels: _drop, ...rest } = layout;
187
+ return Object.keys(planes).length > 0 ? { ...rest, edgeLabels: planes } : rest;
188
+ }
189
+ /**
190
+ * Drop every layout structure keyed by `plane` — its positions bucket, manual
191
+ * flag, layout settings, unfolded list, label placements and threat-bubble
192
+ * entries (the "mirror hygiene" for deleting a plane). An
193
+ * emptied `manual`/`settings` map is omitted entirely, mirroring
194
+ * set-plane-layout / set-layout-settings. Returns the input `layout` unchanged
195
+ * when `plane` had no layout state at all.
196
+ */
197
+ function prunePlaneLayout(layout, plane) {
198
+ const hasState = plane in layout.planes ||
199
+ (layout.manual !== undefined && plane in layout.manual) ||
200
+ (layout.settings !== undefined && plane in layout.settings) ||
201
+ (layout.unfolded !== undefined && plane in layout.unfolded) ||
202
+ (layout.edgeLabels !== undefined && plane in layout.edgeLabels) ||
203
+ (layout.notes !== undefined && plane in layout.notes);
204
+ if (!hasState)
205
+ return layout;
206
+ const next = { ...withUnfolded(layout, plane, []), planes: { ...layout.planes } };
207
+ delete next.planes[plane];
208
+ if (layout.manual !== undefined) {
209
+ const { [plane]: _dropManual, ...manualRest } = layout.manual;
210
+ if (Object.keys(manualRest).length > 0)
211
+ next.manual = manualRest;
212
+ else
213
+ delete next.manual;
214
+ }
215
+ if (layout.settings !== undefined) {
216
+ const { [plane]: _dropSettings, ...settingsRest } = layout.settings;
217
+ if (Object.keys(settingsRest).length > 0)
218
+ next.settings = settingsRest;
219
+ else
220
+ delete next.settings;
221
+ }
222
+ if (layout.edgeLabels !== undefined) {
223
+ const { [plane]: _dropLabels, ...labelsRest } = layout.edgeLabels;
224
+ if (Object.keys(labelsRest).length > 0)
225
+ next.edgeLabels = labelsRest;
226
+ else
227
+ delete next.edgeLabels;
228
+ }
229
+ if (layout.notes !== undefined) {
230
+ const { [plane]: _dropNotes, ...notesRest } = layout.notes;
231
+ if (Object.keys(notesRest).length > 0)
232
+ next.notes = notesRest;
233
+ else
234
+ delete next.notes;
235
+ }
236
+ return next;
237
+ }
238
+ function applyModelLayout(state, command) {
239
+ const { model, layout } = state;
240
+ switch (command.type) {
241
+ case 'add-node': {
242
+ let next = addNode(model, command.node);
243
+ if (command.parent !== undefined) {
244
+ next = addContainment(next, command.parent.id, command.node.id, command.parent.plane);
245
+ }
246
+ return { model: next, layout };
247
+ }
248
+ case 'rename-node':
249
+ return { model: renameNode(model, command.id, command.name), layout };
250
+ case 'set-node-details':
251
+ return { model: setNodeDetails(model, command.id, command.details), layout };
252
+ case 'set-node-rich':
253
+ return { model: setNodeRich(model, command.id, command.runs), layout };
254
+ case 'set-table-columns':
255
+ return { model: setTableColumns(model, command.id, command.columns), layout };
256
+ case 'add-threat':
257
+ return { model: addThreat(model, command.target, command.threat), layout };
258
+ case 'update-threat':
259
+ return { model: updateThreat(model, command.target, command.id, command.patch), layout };
260
+ case 'remove-threat':
261
+ return { model: removeThreat(model, command.target, command.id), layout };
262
+ case 'set-node-plane-hidden':
263
+ return { model: setNodePlaneHidden(model, command.nodeId, command.plane, command.hidden), layout };
264
+ case 'set-diagram-style':
265
+ return { model: setDiagramStyle(model, command.style), layout };
266
+ case 'set-diagram-notation':
267
+ return { model: setDiagramNotation(model, command.notation), layout };
268
+ case 'set-diagram-legend':
269
+ return { model: setDiagramLegend(model, command.legend), layout };
270
+ case 'delete-node': {
271
+ // Cascade destroys the whole containment subtree (notation containers
272
+ // whose children cannot be re-homed), so the layout hygiene must cover
273
+ // every doomed id, not just the root.
274
+ const doomed = command.cascade === true ? subtreeOf(model, command.id) : new Set([command.id]);
275
+ return {
276
+ model: deleteNode(model, command.id, command.cascade === true),
277
+ layout: prunePositions(layout, (nid) => doomed.has(nid)),
278
+ };
279
+ }
280
+ case 'add-containment':
281
+ return { model: addContainment(model, command.parent, command.child, command.plane), layout };
282
+ case 'remove-containment':
283
+ return { model: removeContainment(model, command.parent, command.child, command.plane), layout };
284
+ case 'group-nodes': {
285
+ const grouped = groupNodes(model, command.node, command.memberIds, command.plane);
286
+ // The members were positioned as top-level nodes; once nested, those
287
+ // coordinates are reinterpreted parent-relative and collide. Drop them
288
+ // (and the new group's) so the view re-lays them out fresh under elk.
289
+ const key = layoutPlaneKey(grouped, command.plane);
290
+ const bucket = layout.planes[key];
291
+ if (bucket === undefined)
292
+ return { model: grouped, layout };
293
+ const positions = { ...bucket };
294
+ for (const id of [command.node.id, ...command.memberIds])
295
+ delete positions[id];
296
+ return { model: grouped, layout: { ...layout, planes: { ...layout.planes, [key]: positions } } };
297
+ }
298
+ case 'add-relation':
299
+ return { model: addRelation(model, command.from, command.to, command.opts).model, layout };
300
+ case 'update-relation':
301
+ return { model: updateRelation(model, command.id, command.patch), layout };
302
+ case 'delete-relation':
303
+ return { model: deleteRelation(model, command.id), layout };
304
+ case 'upsert-layer':
305
+ return { model: upsertLayer(model, command.layer), layout };
306
+ case 'delete-layer': {
307
+ // Destructive delete removes the layer's tagged nodes, so drop their
308
+ // layout too — the same node-pruning hygiene as delete-node, now shared.
309
+ const doomed = new Set(model.nodes.filter((n) => n.layer === command.id).map((n) => n.id));
310
+ return {
311
+ model: deleteLayer(model, command.id),
312
+ layout: prunePositions(layout, (nid) => doomed.has(nid)),
313
+ };
314
+ }
315
+ case 'merge-layers':
316
+ return { model: mergeLayers(model, command.sources, command.target), layout };
317
+ case 'upsert-plane':
318
+ return { model: upsertPlane(model, command.plane), layout };
319
+ case 'delete-plane':
320
+ // Drop the deleted plane's own positions bucket, manual flag, and layout
321
+ // settings — the same hygiene as deleting a node, now shared in one helper.
322
+ return {
323
+ model: deletePlane(model, command.id),
324
+ layout: prunePlaneLayout(layout, command.id),
325
+ };
326
+ case 'set-position':
327
+ return {
328
+ model,
329
+ layout: setPos(layout, layoutPlaneKey(model, command.plane), command.nodeId, {
330
+ x: command.x,
331
+ y: command.y,
332
+ }),
333
+ };
334
+ case 'set-size':
335
+ return {
336
+ model,
337
+ layout: { ...layout, sizes: { ...(layout.sizes ?? {}), [command.nodeId]: { w: command.w, h: command.h } } },
338
+ };
339
+ case 'clear-position':
340
+ return { model, layout: setPos(layout, layoutPlaneKey(model, command.plane), command.nodeId) };
341
+ case 'clear-positions': {
342
+ const key = layoutPlaneKey(model, command.plane);
343
+ return { model, layout: { ...layout, planes: { ...layout.planes, [key]: {} } } };
344
+ }
345
+ case 'set-positions': {
346
+ const key = layoutPlaneKey(model, command.plane);
347
+ return {
348
+ model,
349
+ layout: {
350
+ ...layout,
351
+ planes: { ...layout.planes, [key]: { ...(layout.planes[key] ?? {}), ...command.positions } },
352
+ },
353
+ };
354
+ }
355
+ case 'set-plane-layout': {
356
+ const key = layoutPlaneKey(model, command.plane);
357
+ const manual = { ...(layout.manual ?? {}) };
358
+ if (command.manual)
359
+ manual[key] = true;
360
+ else
361
+ delete manual[key];
362
+ const { manual: _drop, ...rest } = layout;
363
+ return { model, layout: Object.keys(manual).length > 0 ? { ...rest, manual } : rest };
364
+ }
365
+ case 'set-unfolded':
366
+ return { model, layout: withUnfolded(layout, layoutPlaneKey(model, command.plane), command.ids) };
367
+ case 'set-layout-settings': {
368
+ // Merge the patch into this plane's settings; a field explicitly set to
369
+ // undefined clears it. An emptied bucket is dropped, and an emptied
370
+ // settings map is omitted entirely (mirrors set-plane-layout hygiene).
371
+ const key = layoutPlaneKey(model, command.plane);
372
+ const merged = { ...(layout.settings?.[key] ?? {}) };
373
+ for (const [k, v] of Object.entries(command.patch)) {
374
+ if (v === undefined)
375
+ delete merged[k];
376
+ else
377
+ merged[k] = v;
378
+ }
379
+ const settings = { ...(layout.settings ?? {}) };
380
+ if (Object.keys(merged).length === 0)
381
+ delete settings[key];
382
+ else
383
+ settings[key] = merged;
384
+ const { settings: _drop, ...rest } = layout;
385
+ return { model, layout: Object.keys(settings).length > 0 ? { ...rest, settings } : rest };
386
+ }
387
+ case 'set-note-offset':
388
+ // `null` = back to the automatic spot; whether the bubble is open is a
389
+ // separate fact and survives the move
390
+ return {
391
+ model,
392
+ layout: withNote(layout, layoutPlaneKey(model, command.plane), command.target, (p) => ({
393
+ ...p,
394
+ ...(command.offset ?? { dx: 0, dy: 0 }),
395
+ })),
396
+ };
397
+ case 'set-note-open':
398
+ return {
399
+ model,
400
+ layout: withNote(layout, layoutPlaneKey(model, command.plane), command.target, (p) => withOpen(p, command.open)),
401
+ };
402
+ case 'set-notes-open': {
403
+ // Model-wide: every element that carries a threat, whether or not this
404
+ // plane draws it. An entry for an undrawn element is harmless (nothing
405
+ // renders it) and far simpler than threading the compiled view into
406
+ // core; allNotesOpen counts the same set, so the chip cannot disagree.
407
+ const key = layoutPlaneKey(model, command.plane);
408
+ const bucket = { ...(layout.notes?.[key] ?? {}) };
409
+ const targets = [
410
+ ...model.nodes.filter((n) => (n.threats?.length ?? 0) > 0).map((n) => ({ node: n.id })),
411
+ ...model.relations.filter((r) => (r.threats?.length ?? 0) > 0).map((r) => ({ relation: r.id })),
412
+ ];
413
+ for (const t of targets) {
414
+ const tk = threatTargetKey(t);
415
+ bucket[tk] = withOpen(bucket[tk] ?? { dx: 0, dy: 0 }, command.open);
416
+ }
417
+ return { model, layout: withNoteBucket(layout, key, bucket) };
418
+ }
419
+ default:
420
+ throw new CommandError(`Unknown command type '${command.type}'`);
421
+ }
422
+ }
423
+ export function applyCommand(state, command) {
424
+ const { model, layout, drawings } = state;
425
+ switch (command.type) {
426
+ case 'add-stroke':
427
+ return { model, layout, drawings: addStroke(drawings, layoutPlaneKey(model, command.plane), command.stroke) };
428
+ case 'delete-stroke':
429
+ return { model, layout, drawings: deleteStroke(drawings, layoutPlaneKey(model, command.plane), command.id) };
430
+ case 'batch':
431
+ return applyCommandWithResult(state, command).state;
432
+ case 'delete-plane': {
433
+ // The drawings bucket is keyed by the plane id, like the layout bucket —
434
+ // same mirror hygiene, third file.
435
+ const next = applyModelLayout(state, command);
436
+ return { model: next.model, layout: next.layout, drawings: pruneDrawingsPlane(drawings, command.id) };
437
+ }
438
+ default: {
439
+ const next = applyModelLayout(state, command);
440
+ return {
441
+ model: next.model,
442
+ layout: pruneNotes(pruneEdgeLabels(next.layout, model, next.model), model, next.model),
443
+ drawings,
444
+ };
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * Like {@link applyCommand}, but surfaces the generated id for `add-relation` so
450
+ * callers (e.g. a connect gesture) can select the new relation. All other
451
+ * commands delegate unchanged and carry no id.
452
+ */
453
+ export function applyCommandWithResult(state, command) {
454
+ if (command.type === 'batch') {
455
+ // Atomic by construction: members apply to a running copy and a throw
456
+ // unwinds before the caller sees anything. The last add-relation's id is
457
+ // surfaced exactly as a lone add-relation's would be, so a panel that ends
458
+ // a batch with a connect can still select the edge.
459
+ let next = state;
460
+ let relationId;
461
+ for (const c of command.commands) {
462
+ const r = applyCommandWithResult(next, c);
463
+ next = r.state;
464
+ if (r.relationId !== undefined)
465
+ relationId = r.relationId;
466
+ }
467
+ return relationId !== undefined ? { state: next, relationId } : { state: next };
468
+ }
469
+ if (command.type === 'add-relation') {
470
+ const { model, id } = addRelation(state.model, command.from, command.to, command.opts);
471
+ return { state: { model, layout: state.layout, drawings: state.drawings }, relationId: id };
472
+ }
473
+ return { state: applyCommand(state, command) };
474
+ }