diagramo 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.
@@ -0,0 +1,1557 @@
1
+
2
+ import * as d3 from "d3";
3
+ import {
4
+ graphConnect,
5
+ sugiyama,
6
+ decrossOpt,
7
+ coordQuad,
8
+ layeringSimplex,
9
+ tweakFlip
10
+ } from "d3-dag";
11
+
12
+ const VERSION = "0.1.0";
13
+ const RESERVED_HEADS = new Set([
14
+ "program", "root", "core", "node", "->", "@attr",
15
+ "cy", "nodes", "edges", "n", "e", "@class", "@data", "@parent"
16
+ ]);
17
+
18
+ const STYLE_ID = "diagramo-library-styles";
19
+ const STYLE_TEXT = `
20
+ .diagramo-host {
21
+ --diagramo-bg: #0b0c10;
22
+ --diagramo-panel: #11131a;
23
+ --diagramo-text: #e6e6e6;
24
+ --diagramo-muted: #a9b0c3;
25
+ --diagramo-stroke: #e6e6e6;
26
+ --diagramo-stroke-muted: rgba(230,230,230,0.35);
27
+ --diagramo-compound-fill: rgba(255,255,255,0.03);
28
+ --diagramo-node-fill: rgba(255,255,255,0.06);
29
+ --diagramo-node-fill-placeholder: rgba(255, 180, 80, 0.10);
30
+ --diagramo-border: rgba(255,255,255,0.12);
31
+ position: relative;
32
+ display: block;
33
+ width: 100%;
34
+ min-height: 240px;
35
+ border-radius: 14px;
36
+ border: 1px solid var(--diagramo-border);
37
+ background: linear-gradient(180deg, rgba(17,19,26,0.98), rgba(15,17,23,0.98));
38
+ overflow: hidden;
39
+ box-sizing: border-box;
40
+ }
41
+ .diagramo-host svg {
42
+ width: 100%;
43
+ height: 100%;
44
+ display: block;
45
+ background: radial-gradient(1200px 600px at 20% 10%, rgba(255,255,255,0.05), transparent 60%);
46
+ }
47
+ .diagramo-host .diagramo-status {
48
+ position: absolute;
49
+ left: 10px;
50
+ right: 10px;
51
+ bottom: 10px;
52
+ z-index: 4;
53
+ border-radius: 10px;
54
+ padding: 7px 10px;
55
+ font: 12px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
56
+ color: var(--diagramo-muted);
57
+ background: rgba(0,0,0,0.35);
58
+ border: 1px solid rgba(255,255,255,0.1);
59
+ white-space: pre-wrap;
60
+ pointer-events: none;
61
+ }
62
+ .diagramo-host .diagramo-status.ok {
63
+ color: rgba(126,231,135,0.9);
64
+ border-color: rgba(126,231,135,0.35);
65
+ }
66
+ .diagramo-host .diagramo-status.bad {
67
+ color: rgba(255,107,107,0.95);
68
+ border-color: rgba(255,107,107,0.35);
69
+ }
70
+ .diagramo-host .compound rect.outer { fill: var(--diagramo-compound-fill); stroke: var(--diagramo-stroke-muted); stroke-width: 1.2; }
71
+ .diagramo-host .compound rect.header { fill: rgba(255,255,255,0.04); stroke: none; }
72
+ .diagramo-host .compound text { fill: var(--diagramo-muted); font-size: 12px; font-weight: 650; }
73
+ .diagramo-host .node rect { fill: var(--diagramo-node-fill); stroke: var(--diagramo-stroke); stroke-width: 1.2; }
74
+ .diagramo-host .node.placeholder rect { fill: var(--diagramo-node-fill-placeholder); stroke: rgba(255,180,80,0.9); }
75
+ .diagramo-host .node text { fill: var(--diagramo-text); font-size: 12px; font-weight: 650; }
76
+ .diagramo-host .edge { fill: none; stroke: var(--diagramo-stroke); stroke-width: 1.35; stroke-linecap: round; }
77
+ .diagramo-host .edge.m2 { stroke-dasharray: 5 4; stroke-width: 1.15; opacity: 0.95; }
78
+ .diagramo-host .edge.wiring { stroke: rgba(170,200,255,0.55); stroke-dasharray: 4 4; stroke-width: 1.0; }
79
+ .diagramo-host .edgeLabel { fill: var(--diagramo-text); font-size: 11px; opacity: 0.95; paint-order: stroke; stroke: rgba(0,0,0,0.6); stroke-width: 3px; }
80
+ .diagramo-host .arrowGlyph circle { fill: rgba(255,255,255,0.10); stroke: rgba(255,255,255,0.7); stroke-width: 1.2; }
81
+ .diagramo-host .arrowGlyph text { fill: var(--diagramo-muted); font-size: 10px; }
82
+ `;
83
+
84
+ function injectStyles() {
85
+ if (document.getElementById(STYLE_ID)) return;
86
+ const style = document.createElement("style");
87
+ style.id = STYLE_ID;
88
+ style.textContent = STYLE_TEXT;
89
+ document.head.appendChild(style);
90
+ }
91
+
92
+ function tokenizeSexpr(src) {
93
+ src = String(src ?? "").replace(/;[^\n\r]*/g, "");
94
+ const tokens = [];
95
+ let i = 0;
96
+ while (i < src.length) {
97
+ const c = src[i];
98
+ if (/\s/.test(c)) { i++; continue; }
99
+ if (c === "(" || c === ")") { tokens.push(c); i++; continue; }
100
+ if (c === '"') {
101
+ let j = i + 1;
102
+ let out = "";
103
+ while (j < src.length) {
104
+ const ch = src[j];
105
+ if (ch === "\\") {
106
+ const nxt = src[j + 1] ?? "";
107
+ if (nxt === "n") out += "\n";
108
+ else if (nxt === "t") out += "\t";
109
+ else out += nxt;
110
+ j += 2;
111
+ continue;
112
+ }
113
+ if (ch === '"') break;
114
+ out += ch;
115
+ j++;
116
+ }
117
+ if (src[j] !== '"') throw new Error("Unterminated string");
118
+ tokens.push({ type: "string", value: out });
119
+ i = j + 1;
120
+ continue;
121
+ }
122
+ let j = i;
123
+ while (j < src.length && !/\s/.test(src[j]) && src[j] !== "(" && src[j] !== ")") j++;
124
+ tokens.push({ type: "sym", value: src.slice(i, j) });
125
+ i = j;
126
+ }
127
+ return tokens;
128
+ }
129
+
130
+ function parseSexpr(src) {
131
+ const toks = tokenizeSexpr(src);
132
+ let idx = 0;
133
+ function parseOne() {
134
+ const t = toks[idx++];
135
+ if (!t) throw new Error("Unexpected EOF");
136
+ if (t === "(") {
137
+ const arr = [];
138
+ while (toks[idx] !== ")") {
139
+ if (idx >= toks.length) throw new Error("Missing ')' ");
140
+ arr.push(parseOne());
141
+ }
142
+ idx++;
143
+ return arr;
144
+ }
145
+ if (t === ")") throw new Error("Unexpected ')' ");
146
+ return t;
147
+ }
148
+ const ast = parseOne();
149
+ if (idx < toks.length) throw new Error("Extra tokens after first expression");
150
+ return ast;
151
+ }
152
+
153
+ function isList(x) { return Array.isArray(x); }
154
+ function headOf(x) { return isList(x) && x.length ? atomValue(x[0]) : ""; }
155
+ function atomValue(x) {
156
+ if (isList(x)) throw new Error("Expected atom, got list");
157
+ return typeof x === "object" && x ? String(x.value) : String(x ?? "");
158
+ }
159
+ function ensureAnyAtom(x, ctx) {
160
+ if (isList(x)) throw new Error(`${ctx}: expected atom, got list`);
161
+ return x;
162
+ }
163
+ function ensureSymbolAtom(x, ctx) {
164
+ if (isList(x)) throw new Error(`${ctx}: expected symbol, got list`);
165
+ const v = atomValue(x);
166
+ if (!v) throw new Error(`${ctx}: expected non-empty symbol`);
167
+ return v;
168
+ }
169
+ function parseLiteralAtom(x) {
170
+ if (isList(x)) throw new Error("Literal must be an atom");
171
+ if (typeof x === "object" && x) {
172
+ if (x.type === "string") return x.value;
173
+ const s = String(x.value);
174
+ if (/^-?\d+(?:\.\d+)?$/.test(s)) return Number(s);
175
+ if (s === "true") return true;
176
+ if (s === "false") return false;
177
+ return s;
178
+ }
179
+ return x;
180
+ }
181
+ function equalPrimitive(a, b) {
182
+ return JSON.stringify(a) === JSON.stringify(b);
183
+ }
184
+ function isAttrForm(x) { return isList(x) && headOf(x) === "@attr"; }
185
+ function parseAttrForm(expr, ctxLabel) {
186
+ if (!isAttrForm(expr)) throw new Error(`${ctxLabel}: expected (@attr ...)`);
187
+ if ((expr.length - 1) % 2 !== 0) throw new Error(`${ctxLabel}: @attr requires even key/value pairs`);
188
+ const out = new Map();
189
+ for (let i = 1; i < expr.length; i += 2) out.set(ensureSymbolAtom(expr[i], `${ctxLabel}: key`), parseLiteralAtom(expr[i + 1]));
190
+ return out;
191
+ }
192
+ function attrMapToObject(map) {
193
+ const out = {};
194
+ for (const [k, meta] of map.entries()) out[k] = meta.value;
195
+ return out;
196
+ }
197
+ function parseOptionalAttrAndChildren(expr, startIdx, ctxLabel) {
198
+ let idx = startIdx;
199
+ let attrMap = new Map();
200
+ if (idx < expr.length && isAttrForm(expr[idx])) {
201
+ attrMap = parseAttrForm(expr[idx], ctxLabel);
202
+ idx++;
203
+ }
204
+ const children = expr.slice(idx);
205
+ for (const child of children) if (!isList(child)) throw new Error(`${ctxLabel}: child items must be lists`);
206
+ return { attrMap, children };
207
+ }
208
+
209
+ function createSurfaceContext(rootId) {
210
+ return {
211
+ rootId,
212
+ entities: new Map(),
213
+ childrenByContainer: new Map(),
214
+ endpointInlineByArrow: new Map(),
215
+ warnings: []
216
+ };
217
+ }
218
+ function ensureChildArray(ctx, id) {
219
+ let arr = ctx.childrenByContainer.get(id);
220
+ if (!arr) {
221
+ arr = [];
222
+ ctx.childrenByContainer.set(id, arr);
223
+ }
224
+ return arr;
225
+ }
226
+ function ensureEndpointRecord(ctx, arrowId) {
227
+ let rec = ctx.endpointInlineByArrow.get(arrowId);
228
+ if (!rec) {
229
+ rec = { from: null, to: null };
230
+ ctx.endpointInlineByArrow.set(arrowId, rec);
231
+ }
232
+ return rec;
233
+ }
234
+ function samePlacement(a, b) {
235
+ if (!a || !b) return false;
236
+ if (a.slot !== b.slot) return false;
237
+ if (a.slot === "child") return a.containerId === b.containerId;
238
+ return a.arrowId === b.arrowId;
239
+ }
240
+ function ensureEntityKind(ctx, id, kind) {
241
+ let rec = ctx.entities.get(id);
242
+ if (!rec) {
243
+ rec = { id, kind, attrs: new Map(), placement: null, fromId: null, toId: null };
244
+ ctx.entities.set(id, rec);
245
+ } else if (rec.kind !== kind) {
246
+ throw new Error(`ID ${id} used as both ${rec.kind} and ${kind}`);
247
+ }
248
+ return rec;
249
+ }
250
+ function placeEntity(ctx, rec, placement) {
251
+ if (!placement) return;
252
+ if (!rec.placement) {
253
+ rec.placement = placement;
254
+ if (placement.slot === "child") {
255
+ const arr = ensureChildArray(ctx, placement.containerId);
256
+ if (!arr.includes(rec.id)) arr.push(rec.id);
257
+ } else {
258
+ const slots = ensureEndpointRecord(ctx, placement.arrowId);
259
+ if (slots[placement.slot] && slots[placement.slot] !== rec.id) {
260
+ throw new Error(`Arrow ${placement.arrowId} already has inline ${placement.slot} endpoint ${slots[placement.slot]}`);
261
+ }
262
+ slots[placement.slot] = rec.id;
263
+ }
264
+ return;
265
+ }
266
+ if (!samePlacement(rec.placement, placement)) {
267
+ const parentA = rec.placement.slot === "child" ? rec.placement.containerId : rec.placement.arrowId;
268
+ const parentB = placement.slot === "child" ? placement.containerId : placement.arrowId;
269
+ throw new Error(`Entity ${rec.id} has conflicting parents/positions (${parentA} vs ${parentB})`);
270
+ }
271
+ }
272
+ function applyAttrs(ctx, rec, attrsMap, strength, origin) {
273
+ for (const [k, v] of attrsMap.entries()) {
274
+ const old = rec.attrs.get(k);
275
+ if (!old) rec.attrs.set(k, { value: v, strength, origin });
276
+ else if (equalPrimitive(old.value, v)) {
277
+ if (strength > old.strength) rec.attrs.set(k, { value: v, strength, origin });
278
+ } else if (strength > old.strength) {
279
+ rec.attrs.set(k, { value: v, strength, origin });
280
+ } else if (strength === old.strength) {
281
+ throw new Error(`Conflicting attribute ${k} for ${rec.id}: ${JSON.stringify(old.value)} vs ${JSON.stringify(v)}`);
282
+ }
283
+ }
284
+ }
285
+ function applyDefaultLabel(rec) {
286
+ if (!rec.attrs.has("label")) rec.attrs.set("label", { value: rec.id, strength: 1, origin: "default" });
287
+ }
288
+ function ensureSynthNode(ctx, id, containerId) {
289
+ const rec = ensureEntityKind(ctx, id, "node");
290
+ placeEntity(ctx, rec, { slot: "child", containerId });
291
+ applyDefaultLabel(rec);
292
+ return rec;
293
+ }
294
+ function setArrowEndpoint(rec, slot, targetId) {
295
+ const key = slot === "from" ? "fromId" : "toId";
296
+ if (!rec[key]) rec[key] = targetId;
297
+ else if (rec[key] !== targetId) throw new Error(`Arrow ${rec.id} has conflicting ${slot} endpoint values (${rec[key]} vs ${targetId})`);
298
+ }
299
+
300
+ function normalizeSurface(ast) {
301
+ if (!isList(ast)) throw new Error("Surface input must be a list");
302
+ let rootId = "__root__";
303
+ let items = [];
304
+ const h = headOf(ast);
305
+ if (h === "program") items = ast.slice(1);
306
+ else if (h === "root") {
307
+ if (ast.length < 2) throw new Error("root form requires a root ID");
308
+ rootId = ensureSymbolAtom(ast[1], "surface root id");
309
+ items = ast.slice(2);
310
+ } else {
311
+ throw new Error("Surface input must start with (program ...) or (root ...)");
312
+ }
313
+ const ctx = createSurfaceContext(rootId);
314
+ ensureChildArray(ctx, rootId);
315
+ for (const item of items) processSurfaceItem(ctx, item, rootId);
316
+ const core = buildCoreFromSurfaceContext(ctx);
317
+ return { core, warnings: ctx.warnings };
318
+ }
319
+ function processSurfaceItem(ctx, expr, containerId) {
320
+ if (!isList(expr)) throw new Error("Surface item must be a list");
321
+ const h = headOf(expr);
322
+ if (h === "node") return processExplicitNode(ctx, expr, { slot: "child", containerId });
323
+ if (h === "->") return processExplicitArrow(ctx, expr, { slot: "child", containerId }, containerId);
324
+ if (expr.length === 1 && !RESERVED_HEADS.has(h)) {
325
+ const colonCount = (h.match(/:/g) || []).length;
326
+ if (colonCount === 1) return processChildOfShorthand(ctx, h, containerId);
327
+ if (colonCount === 0) return processNodeShorthand(ctx, h, containerId);
328
+ }
329
+ throw new Error(`Invalid surface item (${h || "?"} ...)`);
330
+ }
331
+ function processNodeShorthand(ctx, id, containerId) {
332
+ const rec = ensureEntityKind(ctx, id, "node");
333
+ placeEntity(ctx, rec, { slot: "child", containerId });
334
+ applyDefaultLabel(rec);
335
+ }
336
+ function processChildOfShorthand(ctx, pairSymbol, containerId) {
337
+ const parts = pairSymbol.split(":");
338
+ if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error(`Invalid child-of shorthand ${pairSymbol}`);
339
+ const [childId, parentId] = parts;
340
+ let parentRec = ctx.entities.get(parentId);
341
+ if (!parentRec) parentRec = ensureSynthNode(ctx, parentId, containerId);
342
+ if (parentRec.kind !== "node") throw new Error(`Child-of shorthand parent ${parentId} must be a node`);
343
+ applyDefaultLabel(parentRec);
344
+ const childRec = ensureEntityKind(ctx, childId, "node");
345
+ placeEntity(ctx, childRec, { slot: "child", containerId: parentId });
346
+ applyDefaultLabel(childRec);
347
+ }
348
+ function processExplicitNode(ctx, expr, placement) {
349
+ if (expr.length < 2) throw new Error("node form requires an ID");
350
+ const id = ensureSymbolAtom(expr[1], "node id");
351
+ const rec = ensureEntityKind(ctx, id, "node");
352
+ placeEntity(ctx, rec, placement);
353
+ applyDefaultLabel(rec);
354
+ const { attrMap, children } = parseOptionalAttrAndChildren(expr, 2, `node ${id}`);
355
+ applyAttrs(ctx, rec, attrMap, 3, `node ${id}`);
356
+ for (const child of children) processSurfaceItem(ctx, child, id);
357
+ return id;
358
+ }
359
+ function preEnsureBareSurfaceEndpoint(ctx, endpointExpr, synthContainerId) {
360
+ if (!isList(endpointExpr)) {
361
+ const id = ensureSymbolAtom(endpointExpr, "arrow endpoint");
362
+ if (!ctx.entities.has(id)) ensureSynthNode(ctx, id, synthContainerId);
363
+ }
364
+ }
365
+ function processSurfaceEndpoint(ctx, endpointExpr, arrowId, slotName) {
366
+ if (!isList(endpointExpr)) return ensureSymbolAtom(endpointExpr, `arrow ${arrowId} ${slotName}`);
367
+ const h = headOf(endpointExpr);
368
+ if (h === "node") return processExplicitNode(ctx, endpointExpr, { slot: slotName, arrowId });
369
+ if (h === "->") return processExplicitArrow(ctx, endpointExpr, { slot: slotName, arrowId }, arrowId);
370
+ throw new Error(`Arrow ${arrowId} ${slotName} endpoint must be an ID, node, or arrow`);
371
+ }
372
+ function processExplicitArrow(ctx, expr, placement, synthContainerId) {
373
+ if (expr.length < 4) throw new Error("arrow form requires ID FROM TO");
374
+ const id = ensureSymbolAtom(expr[1], "arrow id");
375
+ const fromExpr = expr[2];
376
+ const toExpr = expr[3];
377
+ preEnsureBareSurfaceEndpoint(ctx, fromExpr, synthContainerId);
378
+ preEnsureBareSurfaceEndpoint(ctx, toExpr, synthContainerId);
379
+ const rec = ensureEntityKind(ctx, id, "arrow");
380
+ placeEntity(ctx, rec, placement);
381
+ applyDefaultLabel(rec);
382
+ const { attrMap, children } = parseOptionalAttrAndChildren(expr, 4, `arrow ${id}`);
383
+ applyAttrs(ctx, rec, attrMap, 3, `arrow ${id}`);
384
+ setArrowEndpoint(rec, "from", processSurfaceEndpoint(ctx, fromExpr, id, "from"));
385
+ setArrowEndpoint(rec, "to", processSurfaceEndpoint(ctx, toExpr, id, "to"));
386
+ for (const child of children) processSurfaceItem(ctx, child, id);
387
+ return id;
388
+ }
389
+ function buildCoreFromSurfaceContext(ctx) {
390
+ for (const rec of ctx.entities.values()) {
391
+ if (!rec.placement) throw new Error(`Entity ${rec.id} was declared but not placed in the tree`);
392
+ if (rec.kind === "arrow" && (!rec.fromId || !rec.toId)) throw new Error(`Arrow ${rec.id} is missing an endpoint`);
393
+ }
394
+ const buildItem = (id) => {
395
+ const rec = ctx.entities.get(id);
396
+ if (!rec) throw new Error(`Missing entity ${id}`);
397
+ const attrs = attrMapToObject(rec.attrs);
398
+ const childIds = ensureChildArray(ctx, id).slice();
399
+ const children = childIds.map(buildItem);
400
+ if (rec.kind === "node") return { kind: "node", id, attrs, children };
401
+ const slots = ensureEndpointRecord(ctx, id);
402
+ const from = slots.from ? { type: "inline", item: buildItem(slots.from) } : { type: "ref", id: rec.fromId };
403
+ const to = slots.to ? { type: "inline", item: buildItem(slots.to) } : { type: "ref", id: rec.toId };
404
+ return { kind: "arrow", id, from, to, attrs, children };
405
+ };
406
+ const items = ensureChildArray(ctx, ctx.rootId).slice().map(buildItem);
407
+ const doc = { rootId: ctx.rootId, items };
408
+ canonicalizeCoreDoc(doc);
409
+ validateCoreDoc(doc);
410
+ return doc;
411
+ }
412
+
413
+ function parseCoreDoc(ast) {
414
+ if (!isList(ast) || headOf(ast) !== "core") throw new Error("Expected (core (root ...))");
415
+ const rootForms = ast.slice(1).filter(isList);
416
+ if (rootForms.length !== 1 || headOf(rootForms[0]) !== "root") throw new Error("Core must be exactly (core (root ROOTID ITEM...))");
417
+ const rootForm = rootForms[0];
418
+ if (rootForm.length < 2) throw new Error("Core root requires a root ID");
419
+ const rootId = ensureSymbolAtom(rootForm[1], "core root id");
420
+ const items = rootForm.slice(2).map(parseCoreItem);
421
+ const doc = { rootId, items };
422
+ canonicalizeCoreDoc(doc);
423
+ validateCoreDoc(doc);
424
+ return doc;
425
+ }
426
+ function parseCoreEnd(expr) {
427
+ if (!isList(expr)) return { type: "ref", id: ensureSymbolAtom(expr, "core endpoint") };
428
+ return { type: "inline", item: parseCoreItem(expr) };
429
+ }
430
+ function parseCoreItem(expr) {
431
+ if (!isList(expr)) throw new Error("Core item must be a list");
432
+ const h = headOf(expr);
433
+ if (h === "node") {
434
+ if (expr.length < 2) throw new Error("Core node requires an ID");
435
+ const id = ensureSymbolAtom(expr[1], "core node id");
436
+ const { attrMap, children } = parseOptionalAttrAndChildren(expr, 2, `core node ${id}`);
437
+ return { kind: "node", id, attrs: Object.fromEntries(attrMap.entries()), children: children.map(parseCoreItem) };
438
+ }
439
+ if (h === "->") {
440
+ if (expr.length < 4) throw new Error("Core arrow requires ID FROM TO");
441
+ const id = ensureSymbolAtom(expr[1], "core arrow id");
442
+ const from = parseCoreEnd(expr[2]);
443
+ const to = parseCoreEnd(expr[3]);
444
+ const { attrMap, children } = parseOptionalAttrAndChildren(expr, 4, `core arrow ${id}`);
445
+ return { kind: "arrow", id, from, to, attrs: Object.fromEntries(attrMap.entries()), children: children.map(parseCoreItem) };
446
+ }
447
+ throw new Error(`Unknown core item (${h || "?"} ...)`);
448
+ }
449
+ function canonicalizeCoreDoc(doc) {
450
+ const visit = (item) => {
451
+ if (!item.attrs) item.attrs = {};
452
+ if (!("label" in item.attrs)) item.attrs.label = item.id;
453
+ if (item.kind === "arrow") {
454
+ if (item.from.type === "inline") visit(item.from.item);
455
+ if (item.to.type === "inline") visit(item.to.item);
456
+ }
457
+ for (const child of item.children || []) visit(child);
458
+ };
459
+ for (const item of doc.items || []) visit(item);
460
+ }
461
+ function validateCoreDoc(doc) {
462
+ const entities = new Map();
463
+ const parents = new Map();
464
+ const refs = [];
465
+ function visit(item, parentId) {
466
+ if (entities.has(item.id)) throw new Error(`Duplicate ID ${item.id}`);
467
+ entities.set(item.id, item.kind);
468
+ parents.set(item.id, parentId);
469
+ if (item.kind === "arrow") {
470
+ if (item.from.type === "ref") refs.push({ owner: item.id, slot: "from", target: item.from.id });
471
+ else visit(item.from.item, item.id);
472
+ if (item.to.type === "ref") refs.push({ owner: item.id, slot: "to", target: item.to.id });
473
+ else visit(item.to.item, item.id);
474
+ }
475
+ for (const child of item.children || []) visit(child, item.id);
476
+ }
477
+ for (const item of doc.items || []) visit(item, doc.rootId);
478
+ for (const ref of refs) if (!entities.has(ref.target)) throw new Error(`Arrow ${ref.owner} references missing ${ref.slot} endpoint ${ref.target}`);
479
+ for (const [id, parentId] of parents.entries()) {
480
+ let cur = parentId;
481
+ const seen = new Set([id]);
482
+ while (cur && cur !== doc.rootId) {
483
+ if (seen.has(cur)) throw new Error(`Containment cycle involving ${id}`);
484
+ seen.add(cur);
485
+ cur = parents.get(cur) ?? doc.rootId;
486
+ }
487
+ }
488
+ }
489
+
490
+ function splitClassAttr(attrs, extraClasses = []) {
491
+ const data = {};
492
+ const classes = [...extraClasses];
493
+ for (const [k, v] of Object.entries(attrs || {})) {
494
+ if (k === "class") classes.push(...String(v ?? "").trim().split(/\s+/).filter(Boolean));
495
+ else data[k] = v;
496
+ }
497
+ return { data, classString: [...new Set(classes)].join(" ") };
498
+ }
499
+ function endpointId(end) {
500
+ return end.type === "ref" ? end.id : end.item.id;
501
+ }
502
+ function lowerCoreToNCF(doc) {
503
+ validateCoreDoc(doc);
504
+ const out = { rootId: doc.rootId, nodes: [{ id: doc.rootId, data: {}, classString: "root", parent: null }], edges: [] };
505
+ const seen = new Set([doc.rootId]);
506
+ function walk(item, parentId) {
507
+ if (seen.has(item.id)) throw new Error(`Duplicate ID during lowering: ${item.id}`);
508
+ seen.add(item.id);
509
+ if (item.kind === "node") {
510
+ const split = splitClassAttr(item.attrs, []);
511
+ out.nodes.push({ id: item.id, data: split.data, classString: split.classString, parent: parentId === doc.rootId ? null : parentId });
512
+ for (const child of item.children) walk(child, item.id);
513
+ return;
514
+ }
515
+ const split = splitClassAttr(item.attrs, ["arrow"]);
516
+ out.nodes.push({ id: item.id, data: split.data, classString: split.classString, parent: parentId === doc.rootId ? null : parentId });
517
+ if (item.from.type === "inline") walk(item.from.item, item.id);
518
+ if (item.to.type === "inline") walk(item.to.item, item.id);
519
+ for (const child of item.children) walk(child, item.id);
520
+ out.edges.push({ id: `${item.id}:from`, src: endpointId(item.from), dst: item.id, data: {}, classString: "arrowFrom" });
521
+ out.edges.push({ id: `${item.id}:to`, src: item.id, dst: endpointId(item.to), data: {}, classString: "arrowTo" });
522
+ }
523
+ for (const item of doc.items) walk(item, doc.rootId);
524
+ validateNCFDoc(out);
525
+ return out;
526
+ }
527
+
528
+ function parseKVFromDataForm(expr, ctxLabel) {
529
+ if ((expr.length - 1) % 2 !== 0) throw new Error(`${ctxLabel}: key/value form requires even pairs`);
530
+ const out = {};
531
+ for (let i = 1; i < expr.length; i += 2) out[ensureSymbolAtom(expr[i], `${ctxLabel}: key`)] = parseLiteralAtom(expr[i + 1]);
532
+ return out;
533
+ }
534
+ function parseNCFNode(expr) {
535
+ if (!isList(expr) || headOf(expr) !== "n") throw new Error("NCF node must be (n ID ...)");
536
+ if (expr.length < 2) throw new Error("NCF node requires an ID");
537
+ const node = { id: ensureSymbolAtom(expr[1], "ncf node id"), data: {}, classString: "", parent: null };
538
+ for (let i = 2; i < expr.length; i++) {
539
+ const f = expr[i];
540
+ if (!isList(f)) throw new Error(`NCF node ${node.id} field must be a list`);
541
+ const h = headOf(f);
542
+ if (h === "@data") node.data = { ...node.data, ...parseKVFromDataForm(f, `ncf node ${node.id}`) };
543
+ else if (h === "@class") node.classString = atomValue(ensureAnyAtom(f[1], `ncf node ${node.id} class`));
544
+ else if (h === "@parent") node.parent = ensureSymbolAtom(f[1], `ncf node ${node.id} parent`);
545
+ else throw new Error(`NCF node ${node.id} has unknown field ${h}`);
546
+ }
547
+ return node;
548
+ }
549
+ function parseNCFEdge(expr) {
550
+ if (!isList(expr) || headOf(expr) !== "e") throw new Error("NCF edge must be (e ID SRC DST ...)");
551
+ if (expr.length < 4) throw new Error("NCF edge requires ID SRC DST");
552
+ const edge = { id: ensureSymbolAtom(expr[1], "ncf edge id"), src: ensureSymbolAtom(expr[2], "ncf edge src"), dst: ensureSymbolAtom(expr[3], "ncf edge dst"), data: {}, classString: "" };
553
+ for (let i = 4; i < expr.length; i++) {
554
+ const f = expr[i];
555
+ if (!isList(f)) throw new Error(`NCF edge ${edge.id} field must be a list`);
556
+ const h = headOf(f);
557
+ if (h === "@data") edge.data = { ...edge.data, ...parseKVFromDataForm(f, `ncf edge ${edge.id}`) };
558
+ else if (h === "@class") edge.classString = atomValue(ensureAnyAtom(f[1], `ncf edge ${edge.id} class`));
559
+ else throw new Error(`NCF edge ${edge.id} has unknown field ${h}`);
560
+ }
561
+ return edge;
562
+ }
563
+ function parseNCFDoc(ast) {
564
+ if (!isList(ast) || headOf(ast) !== "cy") throw new Error("Expected top-level (cy ...)");
565
+ let nodesBlock = null, edgesBlock = null;
566
+ for (const item of ast.slice(1)) {
567
+ if (!isList(item)) continue;
568
+ if (headOf(item) === "nodes") nodesBlock = item;
569
+ if (headOf(item) === "edges") edgesBlock = item;
570
+ }
571
+ nodesBlock ||= [{ type: "sym", value: "nodes" }];
572
+ edgesBlock ||= [{ type: "sym", value: "edges" }];
573
+ const doc = { rootId: "__root__", nodes: [], edges: [] };
574
+ for (const expr of nodesBlock.slice(1)) doc.nodes.push(parseNCFNode(expr));
575
+ for (const expr of edgesBlock.slice(1)) doc.edges.push(parseNCFEdge(expr));
576
+ validateNCFDoc(doc);
577
+ return doc;
578
+ }
579
+ function validateNCFDoc(doc) {
580
+ const nodeIds = new Set();
581
+ const edgeIds = new Set();
582
+ for (const n of doc.nodes) {
583
+ if (nodeIds.has(n.id)) throw new Error(`Duplicate NCF node ID ${n.id}`);
584
+ nodeIds.add(n.id);
585
+ }
586
+ for (const e of doc.edges) {
587
+ if (edgeIds.has(e.id)) throw new Error(`Duplicate NCF edge ID ${e.id}`);
588
+ edgeIds.add(e.id);
589
+ if (!nodeIds.has(e.src)) throw new Error(`NCF edge ${e.id} references missing source node ${e.src}`);
590
+ if (!nodeIds.has(e.dst)) throw new Error(`NCF edge ${e.id} references missing target node ${e.dst}`);
591
+ }
592
+ for (const n of doc.nodes) if (n.parent && !nodeIds.has(n.parent)) throw new Error(`NCF node ${n.id} references missing parent ${n.parent}`);
593
+ }
594
+
595
+ function printSym(id) { return String(id); }
596
+ function printVal(v) {
597
+ if (typeof v === "number") return String(v);
598
+ if (typeof v === "boolean") return v ? "true" : "false";
599
+ const s = String(v ?? "");
600
+ return /^[^\s()";]+$/.test(s) ? s : JSON.stringify(s);
601
+ }
602
+ function emitAttr(attrs) {
603
+ const entries = Object.entries(attrs || {});
604
+ if (!entries.length) return "";
605
+ return ` (@attr ${entries.map(([k, v]) => `${printSym(k)} ${printVal(v)}`).join(" ")})`;
606
+ }
607
+ function emitData(data) {
608
+ const entries = Object.entries(data || {});
609
+ if (!entries.length) return "";
610
+ return ` (@data ${entries.map(([k, v]) => `${printSym(k)} ${printVal(v)}`).join(" ")})`;
611
+ }
612
+ function emitCoreItem(item, indent = 4) {
613
+ const pad = " ".repeat(indent);
614
+ if (item.kind === "node") {
615
+ if (!item.children.length) return `${pad}(node ${printSym(item.id)}${emitAttr(item.attrs)})`;
616
+ const lines = [`${pad}(node ${printSym(item.id)}${emitAttr(item.attrs)}`];
617
+ for (const child of item.children) lines.push(emitCoreItem(child, indent + 2));
618
+ lines.push(`${pad})`);
619
+ return lines.join("\n");
620
+ }
621
+ const lines = [`${pad}(-> ${printSym(item.id)}`];
622
+ const emitEnd = (end) => end.type === "ref" ? `${" ".repeat(indent + 2)}${printSym(end.id)}` : emitCoreItem(end.item, indent + 2);
623
+ lines.push(emitEnd(item.from));
624
+ const toLine = emitEnd(item.to);
625
+ if (Object.keys(item.attrs || {}).length && item.to.type === "ref") lines.push(`${toLine}${emitAttr(item.attrs)}`);
626
+ else {
627
+ lines.push(toLine);
628
+ if (Object.keys(item.attrs || {}).length) lines.push(`${" ".repeat(indent + 2)}(@attr ${Object.entries(item.attrs).map(([k, v]) => `${printSym(k)} ${printVal(v)}`).join(" ")})`);
629
+ }
630
+ for (const child of item.children) lines.push(emitCoreItem(child, indent + 2));
631
+ lines.push(`${pad})`);
632
+ return lines.join("\n");
633
+ }
634
+ function emitCoreDoc(doc) {
635
+ const lines = ["(core", ` (root ${printSym(doc.rootId)}`];
636
+ for (const item of doc.items) lines.push(emitCoreItem(item, 4));
637
+ lines.push(" ))");
638
+ return lines.join("\n");
639
+ }
640
+ function emitNCFDoc(doc) {
641
+ const lines = ["(cy", " (nodes"];
642
+ for (const n of doc.nodes) {
643
+ let s = ` (n ${printSym(n.id)}`;
644
+ if (Object.keys(n.data || {}).length) s += emitData(n.data);
645
+ if (String(n.classString || "").trim()) s += ` (@class ${printVal(n.classString)})`;
646
+ if (n.parent) s += ` (@parent ${printSym(n.parent)})`;
647
+ s += ")";
648
+ lines.push(s);
649
+ }
650
+ lines.push(" )");
651
+ lines.push(" (edges");
652
+ for (const e of doc.edges) {
653
+ let s = ` (e ${printSym(e.id)} ${printSym(e.src)} ${printSym(e.dst)}`;
654
+ if (Object.keys(e.data || {}).length) s += emitData(e.data);
655
+ if (String(e.classString || "").trim()) s += ` (@class ${printVal(e.classString)})`;
656
+ s += ")";
657
+ lines.push(s);
658
+ }
659
+ lines.push(" ))");
660
+ return lines.join("\n");
661
+ }
662
+
663
+ function detectInputKind(ast) {
664
+ if (!isList(ast)) throw new Error("Input must be a list");
665
+ const h = headOf(ast);
666
+ if (h === "program" || h === "root") return "surface";
667
+ if (h === "core") return "core";
668
+ if (h === "cy") return "ncf";
669
+ throw new Error(`Could not auto-detect input kind from (${h || "?"} ...)`);
670
+ }
671
+ function compileAst(ast, mode = "auto") {
672
+ const kind = mode === "auto" ? detectInputKind(ast) : mode;
673
+ if (kind === "surface") {
674
+ const { core, warnings } = normalizeSurface(ast);
675
+ const ncf = lowerCoreToNCF(core);
676
+ return { kind, core, ncf, warnings, coreText: emitCoreDoc(core), ncfText: emitNCFDoc(ncf) };
677
+ }
678
+ if (kind === "core") {
679
+ const core = parseCoreDoc(ast);
680
+ const ncf = lowerCoreToNCF(core);
681
+ return { kind, core, ncf, warnings: [], coreText: emitCoreDoc(core), ncfText: emitNCFDoc(ncf) };
682
+ }
683
+ if (kind === "ncf") {
684
+ const ncf = parseNCFDoc(ast);
685
+ return { kind, core: null, ncf, warnings: [], coreText: "", ncfText: emitNCFDoc(ncf) };
686
+ }
687
+ throw new Error(`Unsupported input mode ${kind}`);
688
+ }
689
+ function compileSource(source, mode = "auto") {
690
+ return compileAst(parseSexpr(source), mode);
691
+ }
692
+
693
+ function splitClasses(s) { return String(s || "").trim().split(/\s+/).filter(Boolean); }
694
+ function edgeKind(e) {
695
+ if (e.classSet.has("arrowFrom")) return "arrowFrom";
696
+ if (e.classSet.has("arrowTo")) return "arrowTo";
697
+ const k = String(e.data.kind || e.data.type || e.data.role || "").toLowerCase();
698
+ if (k === "arrowfrom") return "arrowFrom";
699
+ if (k === "arrowto") return "arrowTo";
700
+ return "";
701
+ }
702
+ function normalizeParent(pid, nodes) {
703
+ if (!pid) return "__root__";
704
+ if (!(pid in nodes)) return "__root__";
705
+ const n = nodes[pid];
706
+ if (n.isMetaRoot) return "__root__";
707
+ if (pid === "__root__") return "__root__";
708
+ return pid;
709
+ }
710
+ function trimToRect(rect, toward) {
711
+ const dx = toward.x - rect.cx;
712
+ const dy = toward.y - rect.cy;
713
+ const eps = 1e-9;
714
+ if (Math.abs(dx) < eps && Math.abs(dy) < eps) return { x: rect.cx, y: rect.cy };
715
+ const hw = rect.w / 2;
716
+ const hh = rect.h / 2;
717
+ const tx = Math.abs(dx) < eps ? Infinity : hw / Math.abs(dx);
718
+ const ty = Math.abs(dy) < eps ? Infinity : hh / Math.abs(dy);
719
+ const t = Math.min(tx, ty);
720
+ return { x: rect.cx + dx * t, y: rect.cy + dy * t };
721
+ }
722
+ function polylineMidpoint(points) {
723
+ if (points.length === 0) return { x: 0, y: 0 };
724
+ if (points.length === 1) return { x: points[0].x, y: points[0].y };
725
+ let total = 0;
726
+ const segs = [];
727
+ for (let i = 0; i + 1 < points.length; i++) {
728
+ const a = points[i], b = points[i + 1];
729
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
730
+ segs.push({ a, b, len });
731
+ total += len;
732
+ }
733
+ const half = total / 2;
734
+ let acc = 0;
735
+ for (const s of segs) {
736
+ if (acc + s.len >= half) {
737
+ const t = s.len === 0 ? 0 : (half - acc) / s.len;
738
+ return { x: s.a.x + (s.b.x - s.a.x) * t, y: s.a.y + (s.b.y - s.a.y) * t };
739
+ }
740
+ acc += s.len;
741
+ }
742
+ return { ...points[points.length - 1] };
743
+ }
744
+ function segmentAtHalf(points) {
745
+ if (points.length < 2) return { tan: { x: 1, y: 0 }, nrm: { x: 0, y: 1 } };
746
+ let total = 0;
747
+ const segs = [];
748
+ for (let i = 0; i + 1 < points.length; i++) {
749
+ const a = points[i], b = points[i + 1];
750
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
751
+ segs.push({ a, b, len });
752
+ total += len;
753
+ }
754
+ const half = total / 2;
755
+ let acc = 0;
756
+ for (const s of segs) {
757
+ if (acc + s.len >= half) {
758
+ const dx = s.b.x - s.a.x;
759
+ const dy = s.b.y - s.a.y;
760
+ const L = Math.hypot(dx, dy) || 1;
761
+ const tan = { x: dx / L, y: dy / L };
762
+ const nrm = { x: -tan.y, y: tan.x };
763
+ return { tan, nrm };
764
+ }
765
+ acc += s.len;
766
+ }
767
+ const a = points[points.length - 2];
768
+ const b = points[points.length - 1];
769
+ const dx = b.x - a.x;
770
+ const dy = b.y - a.y;
771
+ const L = Math.hypot(dx, dy) || 1;
772
+ return { tan: { x: dx / L, y: dy / L }, nrm: { x: -dy / L, y: dx / L } };
773
+ }
774
+ function unitNormal(a, b) {
775
+ const dx = b.x - a.x, dy = b.y - a.y;
776
+ const len = Math.hypot(dx, dy) || 1;
777
+ return { x: -dy / len, y: dx / len };
778
+ }
779
+ function baseNormalForPair(u, v, nodes) {
780
+ const a = String(u), b = String(v);
781
+ const p0 = a < b ? a : b;
782
+ const p1 = a < b ? b : a;
783
+ const n0 = nodes[p0], n1 = nodes[p1];
784
+ if (!n0 || !n1) return null;
785
+ return unitNormal({ x: n0.cx, y: n0.cy }, { x: n1.cx, y: n1.cy });
786
+ }
787
+ function pathToD(points) {
788
+ if (!points.length) return "";
789
+ let d = `M ${points[0].x} ${points[0].y}`;
790
+ for (let i = 1; i < points.length; i++) d += ` L ${points[i].x} ${points[i].y}`;
791
+ return d;
792
+ }
793
+ function quadMidpoint(A, C, B) {
794
+ return { x: 0.25 * A.x + 0.5 * C.x + 0.25 * B.x, y: 0.25 * A.y + 0.5 * C.y + 0.25 * B.y };
795
+ }
796
+ function quadToD(A, C, B) { return `M ${A.x} ${A.y} Q ${C.x} ${C.y} ${B.x} ${B.y}`; }
797
+ function simpleHash(str) {
798
+ let h = 2166136261;
799
+ for (let i = 0; i < str.length; i++) {
800
+ h ^= str.charCodeAt(i);
801
+ h = Math.imul(h, 16777619);
802
+ }
803
+ return h >>> 0;
804
+ }
805
+ function nudgePoints(points, minDist, maxTries = 24) {
806
+ const placed = [];
807
+ for (const p of points) {
808
+ let x = p.x, y = p.y, dir = p.dir;
809
+ if (!isFinite(dir.x) || !isFinite(dir.y) || (Math.abs(dir.x) + Math.abs(dir.y) < 1e-6)) dir = { x: 0, y: -1 };
810
+ const dL = Math.hypot(dir.x, dir.y) || 1;
811
+ dir = { x: dir.x / dL, y: dir.y / dL };
812
+ for (let t = 0; t < maxTries; t++) {
813
+ let collided = false;
814
+ for (const q of placed) if (Math.hypot(x - q.x, y - q.y) < minDist) { collided = true; break; }
815
+ if (!collided) break;
816
+ const step = 4 + (t % 4);
817
+ x += dir.x * step;
818
+ y += dir.y * step;
819
+ if (t % 6 === 5) dir = { x: -dir.y, y: dir.x };
820
+ }
821
+ p.x = x; p.y = y;
822
+ placed.push({ x, y });
823
+ }
824
+ return points;
825
+ }
826
+ function removeCyclesDFS(nodeIds, edges) {
827
+ const adj = new Map(nodeIds.map(id => [id, []]));
828
+ for (const [u, v] of edges) if (adj.has(u) && adj.has(v)) adj.get(u).push(v);
829
+ const state = new Map(nodeIds.map(id => [id, 0]));
830
+ const keep = [];
831
+ function dfs(u) {
832
+ state.set(u, 1);
833
+ for (const v of adj.get(u) || []) {
834
+ const st = state.get(v) || 0;
835
+ if (st === 0) { keep.push([u, v]); dfs(v); }
836
+ else if (st === 2) keep.push([u, v]);
837
+ }
838
+ state.set(u, 2);
839
+ }
840
+ for (const id of nodeIds) if ((state.get(id) || 0) === 0) dfs(id);
841
+ const seen = new Set();
842
+ const out = [];
843
+ for (const [u, v] of keep) {
844
+ const k = u + "→" + v;
845
+ if (!seen.has(k)) { seen.add(k); out.push([u, v]); }
846
+ }
847
+ return out;
848
+ }
849
+ function connectedComponents(nodeIds, edges) {
850
+ const und = new Map(nodeIds.map(id => [id, new Set()]));
851
+ for (const [u, v] of edges) {
852
+ if (!und.has(u) || !und.has(v)) continue;
853
+ und.get(u).add(v);
854
+ und.get(v).add(u);
855
+ }
856
+ const seen = new Set();
857
+ const comps = [];
858
+ for (const id of nodeIds) {
859
+ if (seen.has(id)) continue;
860
+ const q = [id];
861
+ seen.add(id);
862
+ const comp = [];
863
+ while (q.length) {
864
+ const u = q.pop();
865
+ comp.push(u);
866
+ for (const v of und.get(u) || []) if (!seen.has(v)) { seen.add(v); q.push(v); }
867
+ }
868
+ comps.push(comp);
869
+ }
870
+ return comps;
871
+ }
872
+ function gridPack(ids, sizes, opts = {}) {
873
+ const gapX = opts.gapX ?? 50, gapY = opts.gapY ?? 45, maxCols = opts.maxCols ?? 4;
874
+ const pos = new Map();
875
+ let col = 0, y = 0, rowH = 0, x = 0;
876
+ const sorted = [...ids].sort((a, b) => String(a).localeCompare(String(b)));
877
+ for (const id of sorted) {
878
+ const s = sizes.get(id) || { w: 120, h: 36 };
879
+ if (col >= maxCols) { col = 0; x = 0; y += rowH + gapY; rowH = 0; }
880
+ pos.set(id, { x: x + s.w / 2, y: y + s.h / 2 });
881
+ x += s.w + gapX;
882
+ rowH = Math.max(rowH, s.h);
883
+ col++;
884
+ }
885
+ return pos;
886
+ }
887
+ function makeSugiyamaLayout(nodeSizeFn) {
888
+ return sugiyama().layering(layeringSimplex()).decross(decrossOpt()).coord(coordQuad()).nodeSize(nodeSizeFn).gap([32, 26]).tweaks([tweakFlip("diagonal")]);
889
+ }
890
+ function boundsFromCenters(ids, centers, sizes) {
891
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
892
+ for (const id of ids) {
893
+ const c = centers.get(id) || { x: 0, y: 0 };
894
+ const s = sizes.get(id) || { w: 120, h: 36 };
895
+ left = Math.min(left, c.x - s.w / 2);
896
+ right = Math.max(right, c.x + s.w / 2);
897
+ top = Math.min(top, c.y - s.h / 2);
898
+ bottom = Math.max(bottom, c.y + s.h / 2);
899
+ }
900
+ if (!isFinite(left)) left = top = right = bottom = 0;
901
+ return { left, top, right, bottom };
902
+ }
903
+ function layoutWithD3Dag(ids, edges, sizes) {
904
+ if (ids.length === 0) return new Map();
905
+ if (ids.length === 1) {
906
+ const only = ids[0];
907
+ return new Map([[only, { x: (sizes.get(only)?.w ?? 120) / 2, y: (sizes.get(only)?.h ?? 36) / 2 }]]);
908
+ }
909
+ const comps = connectedComponents(ids, edges);
910
+ const positions = new Map();
911
+ let packY = 0;
912
+ const packGap = 70;
913
+ for (const comp of comps) {
914
+ const compSet = new Set(comp);
915
+ const compEdges = edges.filter(([u, v]) => compSet.has(u) && compSet.has(v));
916
+ let localPos;
917
+ if (compEdges.length === 0) localPos = gridPack(comp, sizes, { maxCols: 4, gapX: 60, gapY: 55 });
918
+ else {
919
+ const acyclic = removeCyclesDFS(comp, compEdges);
920
+ try {
921
+ const dag = graphConnect()(acyclic);
922
+ const nodeSizeFn = (n) => {
923
+ const id = n.data;
924
+ const s = sizes.get(id) || { w: 120, h: 36 };
925
+ return [s.h, s.w];
926
+ };
927
+ makeSugiyamaLayout(nodeSizeFn)(dag);
928
+ localPos = new Map();
929
+ for (const node of dag.nodes()) localPos.set(node.data, { x: node.x, y: node.y });
930
+ for (const id of comp) if (!localPos.has(id)) localPos.set(id, { x: 0, y: 0 });
931
+ } catch {
932
+ localPos = gridPack(comp, sizes, { maxCols: 4, gapX: 60, gapY: 55 });
933
+ }
934
+ }
935
+ const bbox = boundsFromCenters(comp, localPos, sizes);
936
+ const shiftX = -bbox.left;
937
+ const shiftY = -bbox.top + packY;
938
+ for (const id of comp) {
939
+ const p = localPos.get(id);
940
+ positions.set(id, { x: p.x + shiftX, y: p.y + shiftY });
941
+ }
942
+ packY += (bbox.bottom - bbox.top) + packGap;
943
+ }
944
+ return positions;
945
+ }
946
+
947
+ function createSvgShell(host, options) {
948
+ host.classList.add("diagramo-host");
949
+ host.style.minHeight = `${options.height}px`;
950
+ host.innerHTML = "";
951
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
952
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
953
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
954
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
955
+ marker.setAttribute("id", `diagramoArrowHead-${Math.random().toString(36).slice(2)}`);
956
+ marker.setAttribute("viewBox", "0 0 10 10");
957
+ marker.setAttribute("refX", "9");
958
+ marker.setAttribute("refY", "5");
959
+ marker.setAttribute("markerWidth", "8");
960
+ marker.setAttribute("markerHeight", "8");
961
+ marker.setAttribute("orient", "auto-start-reverse");
962
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
963
+ path.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
964
+ path.setAttribute("fill", "rgba(230,230,230,0.95)");
965
+ marker.appendChild(path);
966
+ defs.appendChild(marker);
967
+ svg.appendChild(defs);
968
+ const zoomLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
969
+ const scene = document.createElementNS("http://www.w3.org/2000/svg", "g");
970
+ zoomLayer.appendChild(scene);
971
+ svg.appendChild(zoomLayer);
972
+ const status = document.createElement("div");
973
+ status.className = "diagramo-status";
974
+ host.appendChild(svg);
975
+ host.appendChild(status);
976
+ return { svg: d3.select(svg), zoomLayer: d3.select(zoomLayer), scene: d3.select(scene), status, arrowHeadId: marker.id };
977
+ }
978
+ function setStatus(statusEl, msg, kind = "neutral") {
979
+ statusEl.textContent = msg;
980
+ statusEl.className = "diagramo-status" + (kind === "ok" ? " ok" : kind === "bad" ? " bad" : "");
981
+ }
982
+ function fitToView(svgSel, zoomLayerSel, sceneSel, host, zoomBehavior, animate = false) {
983
+ const w = host.clientWidth || 800;
984
+ const h = host.clientHeight || 360;
985
+ const g = sceneSel.node();
986
+ if (!g) return;
987
+ const bbox = g.getBBox();
988
+ if (!(bbox.width > 0 && bbox.height > 0)) return;
989
+ const pad = 24;
990
+ const scale = Math.min((w - pad * 2) / bbox.width, (h - pad * 2) / bbox.height);
991
+ const tx = (w / 2) - scale * (bbox.x + bbox.width / 2);
992
+ const ty = (h / 2) - scale * (bbox.y + bbox.height / 2);
993
+ const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
994
+ if (zoomBehavior) {
995
+ const target = animate ? svgSel.transition().duration(200) : svgSel;
996
+ target.call(zoomBehavior.transform, t);
997
+ } else {
998
+ zoomLayerSel.attr("transform", `translate(${tx},${ty}) scale(${scale})`);
999
+ }
1000
+ }
1001
+
1002
+ function renderNCFDocIntoHost(ncfDoc, host, options) {
1003
+ injectStyles();
1004
+ const warnings = [];
1005
+ const { svg, zoomLayer, scene, status, arrowHeadId } = createSvgShell(host, options);
1006
+ let zoomBehavior = null;
1007
+ if (options.zoom) {
1008
+ zoomBehavior = d3.zoom().scaleExtent([0.1, 6]).on("zoom", (event) => zoomLayer.attr("transform", event.transform));
1009
+ svg.call(zoomBehavior);
1010
+ }
1011
+
1012
+ const nodes = {};
1013
+ function ensureNode(id) {
1014
+ if (!nodes[id]) {
1015
+ nodes[id] = { id, data: {}, classSet: new Set(), parent: "__root__", isArrow: false, isMetaRoot: false, placeholder: true, w: 120, h: 36, cx: 0, cy: 0, isCompound: false, headerH: 22, childLocalCenters: new Map(), depth: 0 };
1016
+ }
1017
+ return nodes[id];
1018
+ }
1019
+ for (const expr of ncfDoc.nodes) {
1020
+ const n = ensureNode(expr.id);
1021
+ n.placeholder = false;
1022
+ n.data = { ...expr.data };
1023
+ splitClasses(expr.classString || "").forEach(c => n.classSet.add(c));
1024
+ if (expr.parent) n.parent = expr.parent;
1025
+ }
1026
+ for (const id of Object.keys(nodes)) {
1027
+ const n = nodes[id];
1028
+ n.isMetaRoot = id === "__root__" || n.classSet.has("root");
1029
+ n.isArrow = n.classSet.has("arrow");
1030
+ }
1031
+ for (const id of Object.keys(nodes)) nodes[id].parent = normalizeParent(nodes[id].parent, nodes);
1032
+
1033
+ const edges = [];
1034
+ for (const expr of ncfDoc.edges) {
1035
+ const e = { id: expr.id, src: expr.src, dst: expr.dst, data: { ...expr.data }, classSet: new Set(splitClasses(expr.classString || "")) };
1036
+ ensureNode(e.src);
1037
+ ensureNode(e.dst);
1038
+ edges.push(e);
1039
+ }
1040
+ for (const id of Object.keys(nodes)) {
1041
+ const n = nodes[id];
1042
+ if (n.placeholder) warnings.push(`Soft-created missing node "${id}" (referenced by an edge).`);
1043
+ n.isMetaRoot = id === "__root__" || n.classSet.has("root");
1044
+ n.isArrow = n.classSet.has("arrow");
1045
+ n.parent = normalizeParent(n.parent, nodes);
1046
+ }
1047
+
1048
+ const renderable = new Set(Object.keys(nodes).filter(id => !nodes[id].isMetaRoot && !nodes[id].isArrow));
1049
+ const childrenByParent = new Map();
1050
+ for (const id of Object.keys(nodes)) {
1051
+ const p = nodes[id].parent || "__root__";
1052
+ if (!childrenByParent.has(p)) childrenByParent.set(p, []);
1053
+ childrenByParent.get(p).push(id);
1054
+ }
1055
+ const renderableChildrenByParent = new Map();
1056
+ for (const [p, kids] of childrenByParent.entries()) renderableChildrenByParent.set(p, kids.filter(k => renderable.has(k)));
1057
+ for (const id of renderable) nodes[id].isCompound = (renderableChildrenByParent.get(id) || []).length > 0;
1058
+
1059
+ function computeDepth(id) {
1060
+ let d = 0;
1061
+ let cur = nodes[id]?.parent ?? "__root__";
1062
+ const guard = new Set([id]);
1063
+ while (cur && cur !== "__root__" && nodes[cur]) {
1064
+ if (guard.has(cur)) break;
1065
+ guard.add(cur);
1066
+ d++;
1067
+ cur = nodes[cur].parent ?? "__root__";
1068
+ }
1069
+ return d;
1070
+ }
1071
+ for (const id of renderable) nodes[id].depth = computeDepth(id);
1072
+
1073
+ const measurer = document.createElement("canvas").getContext("2d");
1074
+ measurer.font = "12px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
1075
+ function measureLabel(s) { return Math.ceil(measurer.measureText(String(s ?? "")).width); }
1076
+ function nodeLabel(n) { return n.data.label ?? n.data.name ?? n.data.title ?? n.id; }
1077
+ for (const id of renderable) {
1078
+ const n = nodes[id];
1079
+ n.w = Math.max(68, measureLabel(nodeLabel(n)) + 24);
1080
+ n.h = 34;
1081
+ if (n.placeholder) n.w = Math.max(n.w, 90);
1082
+ }
1083
+
1084
+ const arrowNodes = Object.keys(nodes).filter(id => nodes[id].isArrow && !nodes[id].isMetaRoot);
1085
+ const arrowWiring = new Map();
1086
+ for (const aid of arrowNodes) arrowWiring.set(aid, { fromId: null, toId: null, fromEdgeId: null, toEdgeId: null });
1087
+ for (const e of edges) {
1088
+ const k = edgeKind(e);
1089
+ if (!k) continue;
1090
+ if (k === "arrowFrom" && arrowWiring.has(e.dst)) {
1091
+ const w = arrowWiring.get(e.dst); w.fromId = e.src; w.fromEdgeId = e.id;
1092
+ } else if (k === "arrowTo" && arrowWiring.has(e.src)) {
1093
+ const w = arrowWiring.get(e.src); w.toId = e.dst; w.toEdgeId = e.id;
1094
+ }
1095
+ }
1096
+
1097
+ function directChildUnder(parentId, nodeId) {
1098
+ if (!nodes[nodeId]) return null;
1099
+ let cur = nodeId, prev = null;
1100
+ const guard = new Set();
1101
+ while (cur && cur !== parentId) {
1102
+ if (guard.has(cur)) return null;
1103
+ guard.add(cur);
1104
+ prev = cur;
1105
+ cur = nodes[cur]?.parent ?? "__root__";
1106
+ if (cur === "__root__" && parentId !== "__root__") return null;
1107
+ }
1108
+ return cur === parentId ? prev : null;
1109
+ }
1110
+ function localObjectEdgesForParent(parentId, childIdsSet) {
1111
+ const links = [];
1112
+ const seen = new Set();
1113
+ for (const [aid, w] of arrowWiring.entries()) {
1114
+ if (!w.fromId || !w.toId) continue;
1115
+ const from = w.fromId, to = w.toId;
1116
+ if (!renderable.has(from) || !renderable.has(to)) continue;
1117
+ const u = directChildUnder(parentId, from);
1118
+ const v = directChildUnder(parentId, to);
1119
+ if (!u || !v || u === v) continue;
1120
+ if (!childIdsSet.has(u) || !childIdsSet.has(v)) continue;
1121
+ const key = u + "→" + v;
1122
+ if (seen.has(key)) continue;
1123
+ seen.add(key);
1124
+ links.push([u, v]);
1125
+ }
1126
+ return links;
1127
+ }
1128
+
1129
+ const unorderedKey = (u, v) => { const a = String(u), b = String(v); return a < b ? (a + "|" + b) : (b + "|" + a); };
1130
+ const baseCountByPair = new Map();
1131
+ for (const [aid, w] of arrowWiring.entries()) if (w.fromId && w.toId && renderable.has(w.fromId) && renderable.has(w.toId)) {
1132
+ const k = unorderedKey(w.fromId, w.toId);
1133
+ baseCountByPair.set(k, (baseCountByPair.get(k) || 0) + 1);
1134
+ }
1135
+ const basePairsCache = new Map();
1136
+ function arrowBasePairs(aid, stack = new Set()) {
1137
+ if (basePairsCache.has(aid)) return basePairsCache.get(aid);
1138
+ if (stack.has(aid)) return new Set();
1139
+ stack.add(aid);
1140
+ const out = new Set();
1141
+ const w = arrowWiring.get(aid);
1142
+ if (w?.fromId && w?.toId) {
1143
+ const f = w.fromId, t = w.toId;
1144
+ if (renderable.has(f) && renderable.has(t)) out.add(unorderedKey(f, t));
1145
+ else {
1146
+ if (nodes[f]?.isArrow) for (const k of arrowBasePairs(f, stack)) out.add(k);
1147
+ if (nodes[t]?.isArrow) for (const k of arrowBasePairs(t, stack)) out.add(k);
1148
+ }
1149
+ }
1150
+ stack.delete(aid);
1151
+ basePairsCache.set(aid, out);
1152
+ return out;
1153
+ }
1154
+ const scoreByPair = new Map(baseCountByPair);
1155
+ const bump = (k, amt = 1) => scoreByPair.set(k, (scoreByPair.get(k) || 0) + amt);
1156
+ for (const [mid, w] of arrowWiring.entries()) {
1157
+ if (!w?.fromId || !w?.toId) continue;
1158
+ if (nodes[w.fromId]?.isArrow) for (const k of arrowBasePairs(w.fromId)) bump(k, 1);
1159
+ if (nodes[w.toId]?.isArrow) for (const k of arrowBasePairs(w.toId)) bump(k, 1);
1160
+ }
1161
+ function extraSpacersForPairKey(k) {
1162
+ const base = baseCountByPair.get(k) || 0;
1163
+ const score = scoreByPair.get(k) || base;
1164
+ if (base <= 0) return 0;
1165
+ const delta = Math.max(0, score - base);
1166
+ return Math.min(5, Math.ceil(delta / 2));
1167
+ }
1168
+ let spacerCounter = 0;
1169
+ function layoutGraphForParent(parentId, kids) {
1170
+ const kidSet = new Set(kids);
1171
+ const raw = localObjectEdgesForParent(parentId, kidSet);
1172
+ const spacerSizes = new Map();
1173
+ const spacerIds = [];
1174
+ const links = [];
1175
+ for (const [u, v] of raw) {
1176
+ const extra = extraSpacersForPairKey(unorderedKey(u, v));
1177
+ if (extra <= 0) { links.push([u, v]); continue; }
1178
+ let prev = u;
1179
+ for (let i = 0; i < extra; i++) {
1180
+ const sid = `__sp${++spacerCounter}`;
1181
+ spacerIds.push(sid);
1182
+ spacerSizes.set(sid, { w: 26, h: 12 });
1183
+ links.push([prev, sid]);
1184
+ prev = sid;
1185
+ }
1186
+ links.push([prev, v]);
1187
+ }
1188
+ return { ids: [...kids, ...spacerIds], links, spacerSizes };
1189
+ }
1190
+
1191
+ const compoundGuard = new Set();
1192
+ function computeCompoundGeometry(id) {
1193
+ const n = nodes[id];
1194
+ if (!n.isCompound || compoundGuard.has(id)) return;
1195
+ compoundGuard.add(id);
1196
+ const kids = renderableChildrenByParent.get(id) || [];
1197
+ for (const kid of kids) if (nodes[kid].isCompound) computeCompoundGeometry(kid);
1198
+ const childSizes = new Map(kids.map(k => [k, { w: nodes[k].w, h: nodes[k].h }]));
1199
+ const lg = layoutGraphForParent(id, kids);
1200
+ const sizesForLayout = new Map(childSizes);
1201
+ for (const [sid, sz] of lg.spacerSizes) sizesForLayout.set(sid, sz);
1202
+ const localCenters = layoutWithD3Dag(lg.ids, lg.links, sizesForLayout);
1203
+ const bbox = boundsFromCenters(kids, localCenters, childSizes);
1204
+ const contentW = bbox.right - bbox.left;
1205
+ const contentH = bbox.bottom - bbox.top;
1206
+ const pad = 14;
1207
+ const headerH = n.headerH;
1208
+ const minW = Math.max(120, measureLabel(nodeLabel(n)) + 26);
1209
+ const desiredW = contentW + 2 * pad;
1210
+ const W = Math.max(minW, desiredW);
1211
+ const extraX = (W - desiredW) / 2;
1212
+ const H = headerH + contentH + 2 * pad;
1213
+ n.childLocalCenters = new Map();
1214
+ for (const kid of kids) {
1215
+ const c = localCenters.get(kid) || { x: 0, y: 0 };
1216
+ n.childLocalCenters.set(kid, { x: (c.x - bbox.left) + pad + extraX, y: (c.y - bbox.top) + headerH + pad });
1217
+ }
1218
+ n.w = W; n.h = H;
1219
+ compoundGuard.delete(id);
1220
+ }
1221
+ for (const id of [...renderable].sort((a, b) => nodes[b].depth - nodes[a].depth)) if (nodes[id].isCompound) computeCompoundGeometry(id);
1222
+
1223
+ const topKids = (renderableChildrenByParent.get("__root__") || []).filter(id => renderable.has(id));
1224
+ const topSizes = new Map(topKids.map(k => [k, { w: nodes[k].w, h: nodes[k].h }]));
1225
+ const lgTop = layoutGraphForParent("__root__", topKids);
1226
+ const sizesForLayoutTop = new Map(topSizes);
1227
+ for (const [sid, sz] of lgTop.spacerSizes) sizesForLayoutTop.set(sid, sz);
1228
+ const topCenters = layoutWithD3Dag(lgTop.ids, lgTop.links, sizesForLayoutTop);
1229
+ const topBbox = boundsFromCenters(topKids, topCenters, topSizes);
1230
+ const margin = 60;
1231
+ const rootShiftX = -topBbox.left + margin;
1232
+ const rootShiftY = -topBbox.top + margin;
1233
+ function assignGlobal(id, cx, cy) {
1234
+ const n = nodes[id];
1235
+ n.cx = cx; n.cy = cy;
1236
+ if (n.isCompound) {
1237
+ const tlx = cx - n.w / 2, tly = cy - n.h / 2;
1238
+ for (const kid of renderableChildrenByParent.get(id) || []) {
1239
+ const off = n.childLocalCenters.get(kid);
1240
+ if (off) assignGlobal(kid, tlx + off.x, tly + off.y);
1241
+ }
1242
+ }
1243
+ }
1244
+ for (const id of topKids) {
1245
+ const c = topCenters.get(id) || { x: 0, y: 0 };
1246
+ assignGlobal(id, c.x + rootShiftX, c.y + rootShiftY);
1247
+ }
1248
+
1249
+ const ortho = !!options.ortho;
1250
+ const showWiring = !!options.wiring;
1251
+ const showGlyphs = !!options.glyphs;
1252
+ const arrowsValid = [];
1253
+ const arrowsInvalid = [];
1254
+ for (const [aid, w] of arrowWiring.entries()) {
1255
+ if (!w.fromId || !w.toId) arrowsInvalid.push({ id: aid, ...w });
1256
+ else arrowsValid.push({ id: aid, from: w.fromId, to: w.toId });
1257
+ }
1258
+ const oneMorphisms = arrowsValid.filter(a => renderable.has(a.from) && renderable.has(a.to));
1259
+ const lanes = new Map();
1260
+ const laneGap = 10;
1261
+ const grouped = new Map();
1262
+ for (const a of oneMorphisms) {
1263
+ const pair = [a.from, a.to].sort((x, y) => String(x).localeCompare(String(y)));
1264
+ const key = pair[0] + "|" + pair[1];
1265
+ if (!grouped.has(key)) grouped.set(key, { pair, forward: [], backward: [] });
1266
+ (a.from === pair[0] && a.to === pair[1] ? grouped.get(key).forward : grouped.get(key).backward).push(a.id);
1267
+ }
1268
+ for (const g of grouped.values()) {
1269
+ const hasBoth = g.forward.length && g.backward.length;
1270
+ if (hasBoth) {
1271
+ for (let i = 0; i < g.forward.length; i++) lanes.set(g.forward[i], +laneGap * (i + 1));
1272
+ for (let i = 0; i < g.backward.length; i++) lanes.set(g.backward[i], -laneGap * (i + 1));
1273
+ } else {
1274
+ const ids = g.forward.length ? g.forward : g.backward;
1275
+ const m = ids.length;
1276
+ for (let i = 0; i < m; i++) lanes.set(ids[i], laneGap * (i - (m - 1) / 2));
1277
+ }
1278
+ }
1279
+
1280
+ const arrowPath = new Map();
1281
+ const arrowAnchor = new Map();
1282
+ const arrowAnchorGuard = new Set();
1283
+ function nodeRect(id) { const n = nodes[id]; return { cx: n.cx, cy: n.cy, w: n.w, h: n.h }; }
1284
+ function approxArrowCenter(aid) {
1285
+ const w = arrowWiring.get(aid);
1286
+ if (w?.fromId && w?.toId && nodes[w.fromId] && nodes[w.toId]) return { x: (nodes[w.fromId].cx + nodes[w.toId].cx) / 2, y: (nodes[w.fromId].cy + nodes[w.toId].cy) / 2 };
1287
+ return { x: 0, y: 0 };
1288
+ }
1289
+ function endpointAnchor(endpointId) {
1290
+ const n = nodes[endpointId];
1291
+ if (!n) return { x: 0, y: 0, type: "missing" };
1292
+ if (n.isArrow) return { ...getArrowAnchor(endpointId), type: "arrow" };
1293
+ if (renderable.has(endpointId)) return { x: n.cx, y: n.cy, type: "node" };
1294
+ return { x: n.cx ?? 0, y: n.cy ?? 0, type: "other" };
1295
+ }
1296
+ function buildPathBetween(fromId, toId, opts) {
1297
+ const aA = endpointAnchor(fromId);
1298
+ const aB = endpointAnchor(toId);
1299
+ let start = { x: aA.x, y: aA.y }, end = { x: aB.x, y: aB.y };
1300
+ if (aA.type === "node") start = trimToRect(nodeRect(fromId), end);
1301
+ if (aB.type === "node") end = trimToRect(nodeRect(toId), start);
1302
+ const offset = opts.offset ?? 0;
1303
+ if (opts.ortho) {
1304
+ start = { x: start.x, y: start.y + offset };
1305
+ end = { x: end.x, y: end.y + offset };
1306
+ const midX = (start.x + end.x) / 2;
1307
+ return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end];
1308
+ }
1309
+ const nrm = opts.baseNormal ?? unitNormal(start, end);
1310
+ start = { x: start.x + nrm.x * offset, y: start.y + nrm.y * offset };
1311
+ end = { x: end.x + nrm.x * offset, y: end.y + nrm.y * offset };
1312
+ return [start, end];
1313
+ }
1314
+ function getArrowAnchor(aid) {
1315
+ if (arrowAnchor.has(aid)) return arrowAnchor.get(aid);
1316
+ if (arrowAnchorGuard.has(aid)) return approxArrowCenter(aid);
1317
+ arrowAnchorGuard.add(aid);
1318
+ const w = arrowWiring.get(aid);
1319
+ if (!w?.fromId || !w?.toId) {
1320
+ const fallback = approxArrowCenter(aid);
1321
+ arrowAnchor.set(aid, fallback);
1322
+ arrowAnchorGuard.delete(aid);
1323
+ return fallback;
1324
+ }
1325
+ const is1 = renderable.has(w.fromId) && renderable.has(w.toId);
1326
+ const offset = is1 ? (lanes.get(aid) ?? 0) : 0;
1327
+ const baseNormal = is1 ? baseNormalForPair(w.fromId, w.toId, nodes) : null;
1328
+ const points = buildPathBetween(w.fromId, w.toId, { ortho: is1 ? ortho : false, offset, baseNormal });
1329
+ const mid = polylineMidpoint(points);
1330
+ const label = nodes[aid]?.data?.label ?? nodes[aid]?.data?.name ?? aid;
1331
+ arrowPath.set(aid, { points: points.map(p => ({ ...p })), mid, kind: is1 ? "m1" : "m2", label, laneOffset: offset, baseNormal, ctrl: null });
1332
+ arrowAnchor.set(aid, mid);
1333
+ arrowAnchorGuard.delete(aid);
1334
+ return mid;
1335
+ }
1336
+ for (const a of arrowsValid) getArrowAnchor(a.id);
1337
+ function bundleCurvesForM2() {
1338
+ const curveGap = 16, curveCap = 60;
1339
+ const endpointKey = (id) => (nodes[id]?.isArrow ? `a:${id}` : `n:${id}`);
1340
+ const groups = new Map();
1341
+ for (const aid of arrowsValid.filter(a => !(renderable.has(a.from) && renderable.has(a.to))).map(a => a.id).sort()) {
1342
+ const w = arrowWiring.get(aid);
1343
+ if (!w?.fromId || !w?.toId) continue;
1344
+ const pts = buildPathBetween(w.fromId, w.toId, { ortho: false, offset: 0, baseNormal: null });
1345
+ const A = pts[0], B = pts[pts.length - 1];
1346
+ const key = endpointKey(w.fromId) + "->" + endpointKey(w.toId);
1347
+ if (!groups.has(key)) groups.set(key, []);
1348
+ groups.get(key).push({ aid, A, B });
1349
+ }
1350
+ for (const members of groups.values()) {
1351
+ members.sort((p, q) => String(p.aid).localeCompare(String(q.aid)));
1352
+ const m = members.length;
1353
+ for (let i = 0; i < m; i++) {
1354
+ const mem = members[i];
1355
+ const P = arrowPath.get(mem.aid);
1356
+ if (!P) continue;
1357
+ const t = i - (m - 1) / 2;
1358
+ const mid = { x: (mem.A.x + mem.B.x) / 2, y: (mem.A.y + mem.B.y) / 2 };
1359
+ const nrm = unitNormal(mem.A, mem.B);
1360
+ const L = Math.hypot(mem.B.x - mem.A.x, mem.B.y - mem.A.y) || 1;
1361
+ const maxMag = Math.min(curveCap, 0.35 * L);
1362
+ const mag = Math.max(-maxMag, Math.min(maxMag, t * curveGap));
1363
+ const C = { x: mid.x + nrm.x * mag, y: mid.y + nrm.y * mag };
1364
+ P.points = [mem.A, mem.B]; P.ctrl = C; P.mid = quadMidpoint(mem.A, C, mem.B);
1365
+ arrowAnchor.set(mem.aid, P.mid);
1366
+ }
1367
+ }
1368
+ }
1369
+ bundleCurvesForM2();
1370
+
1371
+ const allCompounds = [...renderable].filter(id => nodes[id].isCompound).sort((a, b) => nodes[a].depth - nodes[b].depth);
1372
+ if (showWiring) {
1373
+ const wiring = [];
1374
+ for (const [aid, w] of arrowWiring.entries()) {
1375
+ if (!w.fromId || !w.toId) continue;
1376
+ const aMid = arrowAnchor.get(aid) || approxArrowCenter(aid);
1377
+ wiring.push({ from: w.fromId, to: aid, aMid });
1378
+ wiring.push({ from: aid, to: w.toId, aMid });
1379
+ }
1380
+ scene.append("g").attr("class", "wiringLayer").selectAll("path").data(wiring).join("path")
1381
+ .attr("class", "edge wiring").attr("marker-end", `url(#${arrowHeadId})`)
1382
+ .attr("d", (d) => {
1383
+ const a = endpointAnchor(d.from);
1384
+ const b = (d.to in nodes && nodes[d.to].isArrow) ? { x: d.aMid.x, y: d.aMid.y } : endpointAnchor(d.to);
1385
+ return pathToD([{ x: a.x, y: a.y }, { x: b.x, y: b.y }]);
1386
+ });
1387
+ }
1388
+
1389
+ const visibleEdges = arrowsValid.map(a => {
1390
+ const p = arrowPath.get(a.id);
1391
+ const kind = p?.kind || ((renderable.has(a.from) && renderable.has(a.to)) ? "m1" : "m2");
1392
+ return { id: a.id, from: a.from, to: a.to, points: p?.points || [], ctrl: p?.ctrl || null, mid: p?.mid || approxArrowCenter(a.id), kind, label: p?.label || a.id, laneOffset: p?.laneOffset ?? 0, baseNormal: p?.baseNormal ?? null };
1393
+ });
1394
+ visibleEdges.sort((a, b) => (a.kind === b.kind ? 0 : (a.kind === "m1" ? -1 : 1)));
1395
+ const edgeLayer = scene.append("g").attr("class", "edgeLayer");
1396
+ edgeLayer.selectAll("path").data(visibleEdges).join("path")
1397
+ .attr("class", d => d.kind === "m2" ? "edge m2" : "edge")
1398
+ .attr("marker-end", `url(#${arrowHeadId})`)
1399
+ .attr("d", d => (d.kind === "m2" && d.ctrl && (d.points?.length || 0) >= 2) ? quadToD(d.points[0], d.ctrl, d.points[d.points.length - 1]) : pathToD(d.points));
1400
+ function labelPos(d) {
1401
+ const pts = d.points || [];
1402
+ const chordNormal = (pts.length >= 2) ? unitNormal(pts[0], pts[pts.length - 1]) : { x: 0, y: -1 };
1403
+ const nrm = (d.kind === "m1" && d.baseNormal) ? d.baseNormal : chordNormal;
1404
+ const off = d.laneOffset ?? 0;
1405
+ const sign = off === 0 ? 1 : Math.sign(off);
1406
+ const mag = (d.kind === "m2" ? 18 : 14) + Math.min(10, Math.abs(off));
1407
+ return { x: d.mid.x + nrm.x * sign * mag, y: d.mid.y + nrm.y * sign * mag, nrm };
1408
+ }
1409
+ const labelPoints = visibleEdges.map(d => {
1410
+ const lp = labelPos(d); const h = simpleHash(d.id); const sgn = (h & 1) ? 1 : -1;
1411
+ return { id: d.id, x: lp.x, y: lp.y, dir: { x: lp.nrm.x * sgn, y: lp.nrm.y * sgn } };
1412
+ });
1413
+ nudgePoints(labelPoints, 16, 24);
1414
+ const labelById = new Map(labelPoints.map(p => [p.id, p]));
1415
+ edgeLayer.selectAll("text").data(visibleEdges).join("text")
1416
+ .attr("class", "edgeLabel")
1417
+ .attr("x", d => (labelById.get(d.id)?.x ?? d.mid.x))
1418
+ .attr("y", d => (labelById.get(d.id)?.y ?? d.mid.y))
1419
+ .text(d => d.label ?? d.id);
1420
+
1421
+ const compLayer = scene.append("g").attr("class", "compoundLayer");
1422
+ const compG = compLayer.selectAll("g.compound").data(allCompounds.map(id => nodes[id])).join("g")
1423
+ .attr("class", "compound").attr("transform", d => `translate(${d.cx - d.w / 2}, ${d.cy - d.h / 2})`);
1424
+ compG.append("rect").attr("class", "outer").attr("rx", 14).attr("ry", 14).attr("width", d => d.w).attr("height", d => d.h);
1425
+ compG.append("rect").attr("class", "header").attr("rx", 14).attr("ry", 14).attr("width", d => d.w).attr("height", d => d.headerH);
1426
+ compG.append("text").attr("x", 12).attr("y", 15).text(d => nodeLabel(d));
1427
+
1428
+ const objNodes = [...renderable].filter(id => !nodes[id].isCompound).sort((a, b) => nodes[a].depth - nodes[b].depth);
1429
+ const nodeLayer = scene.append("g").attr("class", "nodeLayer");
1430
+ const nodeG = nodeLayer.selectAll("g.node").data(objNodes.map(id => nodes[id])).join("g")
1431
+ .attr("class", d => `node${d.placeholder ? " placeholder" : ""}`)
1432
+ .attr("transform", d => `translate(${d.cx - d.w / 2}, ${d.cy - d.h / 2})`);
1433
+ nodeG.append("rect").attr("rx", 12).attr("ry", 12).attr("width", d => d.w).attr("height", d => d.h);
1434
+ nodeG.append("text").attr("x", d => d.w / 2).attr("y", d => d.h / 2 + 4).attr("text-anchor", "middle").text(d => nodeLabel(d));
1435
+
1436
+ if (showGlyphs) {
1437
+ const glyphLayer = scene.append("g").attr("class", "glyphLayer");
1438
+ const glyphData = arrowNodes.map(aid => {
1439
+ const p = arrowPath.get(aid); const mid = arrowAnchor.get(aid) || approxArrowCenter(aid); const lbl = nodes[aid]?.data?.label ?? aid;
1440
+ const nrm = p?.points?.length ? segmentAtHalf(p.points).nrm : { x: 0, y: -1 };
1441
+ const h = simpleHash(aid); const sgn = (h & 1) ? 1 : -1;
1442
+ return { id: aid, x: mid.x, y: mid.y, label: lbl, dir: { x: nrm.x * sgn, y: nrm.y * sgn } };
1443
+ });
1444
+ nudgePoints(glyphData, 18, 18);
1445
+ const g = glyphLayer.selectAll("g.arrowGlyph").data(glyphData).join("g").attr("class", "arrowGlyph").attr("transform", d => `translate(${d.x}, ${d.y})`);
1446
+ g.append("circle").attr("r", 7.5);
1447
+ g.append("text").attr("x", 10).attr("y", 4).text(d => d.label);
1448
+ }
1449
+
1450
+ if (arrowsInvalid.length) warnings.push(`Skipped ${arrowsInvalid.length} arrow-entities missing arrowFrom/arrowTo.`);
1451
+ setStatus(status, warnings.length ? `Rendered with warnings:\n- ${warnings.join("\n- ")}` : "Rendered ✓", warnings.length ? "neutral" : "ok");
1452
+ fitToView(svg, zoomLayer, scene, host, zoomBehavior, false);
1453
+
1454
+ if (options.observeResize !== false && typeof ResizeObserver !== "undefined") {
1455
+ const ro = new ResizeObserver(() => fitToView(svg, zoomLayer, scene, host, zoomBehavior, false));
1456
+ ro.observe(host);
1457
+ host.__diagramoResizeObserver = ro;
1458
+ }
1459
+
1460
+ return { svg, zoomLayer, scene, status, fit: () => fitToView(svg, zoomLayer, scene, host, zoomBehavior, true), arrowHeadId };
1461
+ }
1462
+
1463
+ function normalizeRenderOptions(options = {}, el = null) {
1464
+ const ds = el?.dataset || {};
1465
+ const boolFrom = (a, b, fallback) => {
1466
+ const v = a ?? b;
1467
+ if (v === undefined || v === null || v === "") return fallback;
1468
+ return !(v === false || v === "false" || v === "0" || v === "off" || v === "no");
1469
+ };
1470
+ const numFrom = (a, b, fallback) => {
1471
+ const v = a ?? b;
1472
+ const n = Number(v);
1473
+ return Number.isFinite(n) && n > 0 ? n : fallback;
1474
+ };
1475
+ return {
1476
+ mode: options.mode ?? ds.diagramoMode ?? "auto",
1477
+ zoom: boolFrom(options.zoom, ds.diagramoZoom, false),
1478
+ ortho: boolFrom(options.ortho, ds.diagramoOrtho, false),
1479
+ wiring: boolFrom(options.wiring, ds.diagramoWiring, false),
1480
+ glyphs: boolFrom(options.glyphs, ds.diagramoGlyphs, false),
1481
+ height: numFrom(options.height, ds.diagramoHeight, 360),
1482
+ observeResize: options.observeResize ?? true,
1483
+ replaceSource: options.replaceSource ?? true
1484
+ };
1485
+ }
1486
+
1487
+ function renderSource(source, host, options = {}) {
1488
+ const opts = normalizeRenderOptions(options, host);
1489
+ injectStyles();
1490
+ const compiled = compileSource(source, opts.mode);
1491
+ const view = renderNCFDocIntoHost(compiled.ncf, host, opts);
1492
+ host.__diagramoCompiled = compiled;
1493
+ host.__diagramoView = view;
1494
+ host.__diagramoSource = source;
1495
+ return { host, compiled, fit: view.fit, rerender: (nextOptions = {}) => renderSource(source, host, { ...opts, ...nextOptions }) };
1496
+ }
1497
+
1498
+ function renderElement(el, options = {}) {
1499
+ if (!(el instanceof Element)) throw new Error("renderElement expects a DOM element");
1500
+ const source = options.source ?? el.textContent ?? "";
1501
+ const opts = normalizeRenderOptions(options, el);
1502
+ let host = el;
1503
+ if (opts.replaceSource && el.tagName === "PRE") {
1504
+ host = document.createElement("div");
1505
+ host.className = el.className;
1506
+ for (const attr of el.attributes) {
1507
+ if (attr.name === "class") continue;
1508
+ host.setAttribute(attr.name, attr.value);
1509
+ }
1510
+ el.replaceWith(host);
1511
+ }
1512
+ host.classList.add("diagramo-rendered");
1513
+ return renderSource(source, host, opts);
1514
+ }
1515
+
1516
+ function renderAll(root = document, options = {}) {
1517
+ const out = [];
1518
+ for (const el of root.querySelectorAll("pre.diagramo, .diagramo[data-diagramo-source]")) {
1519
+ if (el.__diagramoRendered) continue;
1520
+ const result = renderElement(el, options);
1521
+ const host = result.host;
1522
+ host.__diagramoRendered = true;
1523
+ out.push(result);
1524
+ }
1525
+ return out;
1526
+ }
1527
+
1528
+ function autoRender() {
1529
+ renderAll(document);
1530
+ }
1531
+
1532
+ const Diagramo = {
1533
+ version: VERSION,
1534
+ parseSexpr,
1535
+ compileAst,
1536
+ compileSource,
1537
+ emitCoreDoc,
1538
+ emitNCFDoc,
1539
+ renderSource,
1540
+ renderElement,
1541
+ renderAll,
1542
+ autoRender
1543
+ };
1544
+
1545
+ export {
1546
+ VERSION,
1547
+ Diagramo,
1548
+ parseSexpr,
1549
+ compileAst,
1550
+ compileSource,
1551
+ emitCoreDoc,
1552
+ emitNCFDoc,
1553
+ renderSource,
1554
+ renderElement,
1555
+ renderAll,
1556
+ autoRender
1557
+ };