@diagrammo/dgmo 0.30.0 → 0.32.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.
- package/.cursorrules +4 -1
- package/.github/copilot-instructions.md +4 -1
- package/.windsurfrules +4 -1
- package/README.md +21 -3
- package/SKILL.md +4 -1
- package/dist/advanced.cjs +1853 -623
- package/dist/advanced.d.cts +143 -16
- package/dist/advanced.d.ts +143 -16
- package/dist/advanced.js +1846 -623
- package/dist/auto.cjs +1640 -581
- package/dist/auto.js +99 -99
- package/dist/auto.mjs +1640 -581
- package/dist/cli.cjs +148 -147
- package/dist/index.cjs +1643 -662
- package/dist/index.js +1643 -662
- package/docs/ai-integration.md +4 -1
- package/docs/language-reference.md +282 -27
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +4 -5
- package/gallery/fixtures/c4.dgmo +2 -3
- package/package.json +7 -1
- package/src/advanced.ts +10 -0
- package/src/boxes-and-lines/focus.ts +257 -0
- package/src/boxes-and-lines/layout-search.ts +345 -65
- package/src/boxes-and-lines/layout.ts +11 -1
- package/src/boxes-and-lines/parser.ts +97 -4
- package/src/boxes-and-lines/renderer.ts +111 -8
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/c4/parser.ts +8 -7
- package/src/c4/renderer.ts +7 -5
- package/src/chart-type-registry.ts +129 -4
- package/src/chart-types.ts +3 -3
- package/src/chart.ts +18 -1
- package/src/class/renderer.ts +4 -2
- package/src/cli-banner.ts +107 -0
- package/src/cli.ts +13 -0
- package/src/colors.ts +247 -2
- package/src/cycle/parser.ts +2 -7
- package/src/d3.ts +67 -54
- package/src/diagnostics.ts +17 -0
- package/src/dimensions.ts +9 -13
- package/src/echarts.ts +42 -14
- package/src/er/parser.ts +6 -1
- package/src/er/renderer.ts +4 -2
- package/src/gantt/parser.ts +44 -7
- package/src/graph/flowchart-parser.ts +77 -3
- package/src/graph/flowchart-renderer.ts +4 -2
- package/src/graph/state-renderer.ts +6 -4
- package/src/infra/parser.ts +80 -0
- package/src/infra/renderer.ts +8 -4
- package/src/journey-map/parser.ts +23 -8
- package/src/journey-map/renderer.ts +1 -1
- package/src/kanban/parser.ts +8 -7
- package/src/kanban/renderer.ts +1 -1
- package/src/map/context-labels.ts +134 -27
- package/src/map/geo.ts +10 -2
- package/src/map/layout.ts +259 -4
- package/src/map/parser.ts +2 -0
- package/src/map/renderer.ts +49 -25
- package/src/map/resolver.ts +68 -19
- package/src/mindmap/parser.ts +15 -7
- package/src/mindmap/renderer.ts +55 -15
- package/src/org/parser.ts +8 -7
- package/src/org/renderer.ts +89 -127
- package/src/palettes/color-utils.ts +19 -4
- package/src/palettes/index.ts +1 -0
- package/src/pert/renderer.ts +15 -10
- package/src/pyramid/parser.ts +2 -7
- package/src/quadrant/renderer.ts +2 -2
- package/src/raci/parser.ts +2 -7
- package/src/raci/renderer.ts +5 -5
- package/src/ring/parser.ts +2 -7
- package/src/sequence/parser.ts +18 -7
- package/src/sequence/renderer.ts +4 -4
- package/src/sitemap/parser.ts +8 -7
- package/src/sitemap/renderer.ts +37 -39
- package/src/tech-radar/parser.ts +2 -7
- package/src/timeline/renderer.ts +15 -5
- package/src/utils/card.ts +183 -0
- package/src/utils/parsing.ts +13 -1
- package/src/utils/scaling.ts +38 -81
- package/src/utils/tag-groups.ts +48 -10
- package/src/utils/visual-conventions.ts +61 -0
- package/src/visualizations/parse.ts +6 -1
- package/src/wireframe/parser.ts +6 -1
|
@@ -60,9 +60,13 @@ const splineGen = d3line<Pt>()
|
|
|
60
60
|
.y((d) => d.y)
|
|
61
61
|
.curve(curveBasis);
|
|
62
62
|
|
|
63
|
+
// Path tokenizer for `flatten` — module-scope so it isn't recompiled per call.
|
|
64
|
+
// String.match with a /g regex is stateless (ignores lastIndex), so a shared
|
|
65
|
+
// instance is safe to reuse.
|
|
66
|
+
const PATH_TOKEN_RE = /[MLQC]|-?\d*\.?\d+(?:e-?\d+)?/gi;
|
|
63
67
|
// flatten an SVG path "d" (M/L/Q/C) into a polyline for crossing detection
|
|
64
68
|
function flatten(d: string): Pt[] {
|
|
65
|
-
const toks = d.match(
|
|
69
|
+
const toks = d.match(PATH_TOKEN_RE) ?? [];
|
|
66
70
|
const pts: Pt[] = [];
|
|
67
71
|
let i = 0,
|
|
68
72
|
cx = 0,
|
|
@@ -121,6 +125,34 @@ function flatten(d: string): Pt[] {
|
|
|
121
125
|
}
|
|
122
126
|
return pts;
|
|
123
127
|
}
|
|
128
|
+
// Flattened edge polyline + its bbox. Building it requires an SVG-string
|
|
129
|
+
// round-trip (d3 spline → regex parse), so it's memoized per layout: every
|
|
130
|
+
// candidate is scored by countSplineCrossings + countEdgeOverlaps +
|
|
131
|
+
// countEdgeNodePierces, which would otherwise each re-flatten all edges.
|
|
132
|
+
type FlatPoly = { pts: Pt[]; x0: number; y0: number; x1: number; y1: number };
|
|
133
|
+
const FLAT_CACHE = new WeakMap<object, FlatPoly[]>();
|
|
134
|
+
function flatPolys(layout: BLLayoutResult): FlatPoly[] {
|
|
135
|
+
const key = layout.edges as unknown as object;
|
|
136
|
+
const hit = FLAT_CACHE.get(key);
|
|
137
|
+
if (hit) return hit;
|
|
138
|
+
const polys = layout.edges.map((e) => {
|
|
139
|
+
const pts =
|
|
140
|
+
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
141
|
+
let x0 = Infinity,
|
|
142
|
+
y0 = Infinity,
|
|
143
|
+
x1 = -Infinity,
|
|
144
|
+
y1 = -Infinity;
|
|
145
|
+
for (const p of pts) {
|
|
146
|
+
if (p.x < x0) x0 = p.x;
|
|
147
|
+
if (p.x > x1) x1 = p.x;
|
|
148
|
+
if (p.y < y0) y0 = p.y;
|
|
149
|
+
if (p.y > y1) y1 = p.y;
|
|
150
|
+
}
|
|
151
|
+
return { pts, x0, y0, x1, y1 };
|
|
152
|
+
});
|
|
153
|
+
FLAT_CACHE.set(key, polys);
|
|
154
|
+
return polys;
|
|
155
|
+
}
|
|
124
156
|
function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
|
|
125
157
|
const den = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
|
|
126
158
|
if (Math.abs(den) < 1e-9) return null;
|
|
@@ -136,28 +168,21 @@ function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
|
|
|
136
168
|
// near a genuinely shared endpoint node; cluster near-duplicate hits.
|
|
137
169
|
// Exported so the playground + benchmark score with the SAME counter the
|
|
138
170
|
// engine optimizes against.
|
|
139
|
-
export function countSplineCrossings(
|
|
171
|
+
export function countSplineCrossings(
|
|
172
|
+
layout: BLLayoutResult,
|
|
173
|
+
/** Abort early once the running total exceeds this (the caller only needs to
|
|
174
|
+
* know X has passed the best-so-far badness — the exact value no longer
|
|
175
|
+
* matters). Returns a value > floor in that case; identical otherwise. */
|
|
176
|
+
floor = Infinity
|
|
177
|
+
): number {
|
|
140
178
|
const center = new Map<string, Pt>();
|
|
141
179
|
for (const n of layout.nodes) center.set(n.label, { x: n.x, y: n.y });
|
|
142
180
|
// collapsed group boxes are edge endpoints too (`__group_<label>`); without
|
|
143
181
|
// them, edges meeting AT a collapsed box are miscounted as crossings.
|
|
144
182
|
for (const g of layout.groups)
|
|
145
183
|
if (g.collapsed) center.set('__group_' + g.label, { x: g.x, y: g.y });
|
|
146
|
-
const polys = layout
|
|
147
|
-
|
|
148
|
-
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
149
|
-
let x0 = Infinity,
|
|
150
|
-
y0 = Infinity,
|
|
151
|
-
x1 = -Infinity,
|
|
152
|
-
y1 = -Infinity;
|
|
153
|
-
for (const p of pts) {
|
|
154
|
-
if (p.x < x0) x0 = p.x;
|
|
155
|
-
if (p.x > x1) x1 = p.x;
|
|
156
|
-
if (p.y < y0) y0 = p.y;
|
|
157
|
-
if (p.y > y1) y1 = p.y;
|
|
158
|
-
}
|
|
159
|
-
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
160
|
-
});
|
|
184
|
+
const polys = flatPolys(layout);
|
|
185
|
+
const edges = layout.edges;
|
|
161
186
|
const R = 34;
|
|
162
187
|
let total = 0;
|
|
163
188
|
for (let a = 0; a < polys.length; a++)
|
|
@@ -166,26 +191,43 @@ export function countSplineCrossings(layout: BLLayoutResult): number {
|
|
|
166
191
|
B = polys[b]!;
|
|
167
192
|
if (A.pts.length < 2 || B.pts.length < 2) continue;
|
|
168
193
|
if (A.x1 < B.x0 || B.x1 < A.x0 || A.y1 < B.y0 || B.y1 < A.y0) continue; // bbox disjoint
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
const ea = edges[a]!,
|
|
195
|
+
eb = edges[b]!;
|
|
196
|
+
// Shared-endpoint centres (≤2) — inlined to avoid allocating a filter/map
|
|
197
|
+
// array on every one of the O(E²) edge pairs.
|
|
198
|
+
let sh0: Pt | undefined, sh1: Pt | undefined;
|
|
199
|
+
if (ea.source === eb.source || ea.source === eb.target)
|
|
200
|
+
sh0 = center.get(ea.source);
|
|
201
|
+
if (ea.target === eb.source || ea.target === eb.target)
|
|
202
|
+
sh1 = center.get(ea.target);
|
|
173
203
|
const hits: Pt[] = [];
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
const ap = A.pts,
|
|
205
|
+
bp = B.pts;
|
|
206
|
+
for (let i = 1; i < ap.length; i++) {
|
|
207
|
+
const a0 = ap[i - 1]!,
|
|
208
|
+
a1 = ap[i]!;
|
|
209
|
+
const axMin = a0.x < a1.x ? a0.x : a1.x,
|
|
210
|
+
axMax = a0.x > a1.x ? a0.x : a1.x,
|
|
211
|
+
ayMin = a0.y < a1.y ? a0.y : a1.y,
|
|
212
|
+
ayMax = a0.y > a1.y ? a0.y : a1.y;
|
|
213
|
+
for (let j = 1; j < bp.length; j++) {
|
|
214
|
+
const b0 = bp[j - 1]!,
|
|
215
|
+
b1 = bp[j]!;
|
|
216
|
+
// per-segment bbox reject — disjoint segments can't cross
|
|
217
|
+
if (axMax < (b0.x < b1.x ? b0.x : b1.x)) continue;
|
|
218
|
+
if ((b0.x > b1.x ? b0.x : b1.x) < axMin) continue;
|
|
219
|
+
if (ayMax < (b0.y < b1.y ? b0.y : b1.y)) continue;
|
|
220
|
+
if ((b0.y > b1.y ? b0.y : b1.y) < ayMin) continue;
|
|
221
|
+
const p = segPoint(a0, a1, b0, b1);
|
|
182
222
|
if (!p) continue;
|
|
183
|
-
if (
|
|
184
|
-
|
|
223
|
+
if (sh0 && Math.hypot(p.x - sh0.x, p.y - sh0.y) < R) continue;
|
|
224
|
+
if (sh1 && Math.hypot(p.x - sh1.x, p.y - sh1.y) < R) continue;
|
|
185
225
|
if (!hits.some((h) => Math.hypot(h.x - p.x, h.y - p.y) < 6))
|
|
186
226
|
hits.push(p);
|
|
187
227
|
}
|
|
228
|
+
}
|
|
188
229
|
total += hits.length;
|
|
230
|
+
if (total > floor) return total; // can't win — stop counting
|
|
189
231
|
}
|
|
190
232
|
return total;
|
|
191
233
|
}
|
|
@@ -251,21 +293,8 @@ export function detectEdgeOverlaps(
|
|
|
251
293
|
h: g.height,
|
|
252
294
|
});
|
|
253
295
|
|
|
254
|
-
const polys = layout
|
|
255
|
-
|
|
256
|
-
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
257
|
-
let x0 = Infinity,
|
|
258
|
-
y0 = Infinity,
|
|
259
|
-
x1 = -Infinity,
|
|
260
|
-
y1 = -Infinity;
|
|
261
|
-
for (const p of pts) {
|
|
262
|
-
if (p.x < x0) x0 = p.x;
|
|
263
|
-
if (p.x > x1) x1 = p.x;
|
|
264
|
-
if (p.y < y0) y0 = p.y;
|
|
265
|
-
if (p.y > y1) y1 = p.y;
|
|
266
|
-
}
|
|
267
|
-
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
268
|
-
});
|
|
296
|
+
const polys = flatPolys(layout);
|
|
297
|
+
const edges = layout.edges;
|
|
269
298
|
|
|
270
299
|
const runs: OverlapRun[] = [];
|
|
271
300
|
for (let a = 0; a < polys.length; a++)
|
|
@@ -280,10 +309,14 @@ export function detectEdgeOverlaps(
|
|
|
280
309
|
B.y1 + dist < A.y0
|
|
281
310
|
)
|
|
282
311
|
continue;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
312
|
+
const ea = edges[a]!,
|
|
313
|
+
eb = edges[b]!;
|
|
314
|
+
// Shared-endpoint rects (≤2) — inlined to avoid a per-pair filter/map array.
|
|
315
|
+
let shr0: Rect | undefined, shr1: Rect | undefined;
|
|
316
|
+
if (ea.source === eb.source || ea.source === eb.target)
|
|
317
|
+
shr0 = rect.get(ea.source);
|
|
318
|
+
if (ea.target === eb.source || ea.target === eb.target)
|
|
319
|
+
shr1 = rect.get(ea.target);
|
|
287
320
|
// Walk A; accumulate contiguous "covered" runs (close to B, off any shared
|
|
288
321
|
// node). A run counts once if it reaches minLen.
|
|
289
322
|
let run: Pt[] = [];
|
|
@@ -299,7 +332,9 @@ export function detectEdgeOverlaps(
|
|
|
299
332
|
runLen = 0;
|
|
300
333
|
};
|
|
301
334
|
for (const p of A.pts) {
|
|
302
|
-
const nearShared =
|
|
335
|
+
const nearShared =
|
|
336
|
+
(shr0 !== undefined && pointRectDist(p, shr0) < nodeClear) ||
|
|
337
|
+
(shr1 !== undefined && pointRectDist(p, shr1) < nodeClear);
|
|
303
338
|
const covered = !nearShared && distToPoly(p, B.pts) < dist;
|
|
304
339
|
if (covered) {
|
|
305
340
|
if (run.length)
|
|
@@ -358,9 +393,10 @@ export function detectEdgeNodePierces(
|
|
|
358
393
|
Math.abs(p.x - r.x) < r.w / 2 - inset &&
|
|
359
394
|
Math.abs(p.y - r.y) < r.h / 2 - inset;
|
|
360
395
|
const out: NodePierce[] = [];
|
|
396
|
+
const polys = flatPolys(layout);
|
|
361
397
|
layout.edges.forEach((e, idx) => {
|
|
362
398
|
if (e.points.length < 2) return;
|
|
363
|
-
const poly =
|
|
399
|
+
const poly = polys[idx]!.pts;
|
|
364
400
|
for (const r of rects) {
|
|
365
401
|
if (
|
|
366
402
|
r.key === e.source ||
|
|
@@ -860,7 +896,7 @@ function edgeLength(layout: BLLayoutResult): number {
|
|
|
860
896
|
return total;
|
|
861
897
|
}
|
|
862
898
|
|
|
863
|
-
export function layoutBoxesAndLinesSearch(
|
|
899
|
+
export async function layoutBoxesAndLinesSearch(
|
|
864
900
|
parsed: ParsedBoxesAndLines,
|
|
865
901
|
collapseInfo?: {
|
|
866
902
|
collapsedChildCounts: Map<string, number>;
|
|
@@ -875,9 +911,21 @@ export function layoutBoxesAndLinesSearch(
|
|
|
875
911
|
lambda?: number;
|
|
876
912
|
/** How many top candidates to re-rank with the exact counter (default 6). */
|
|
877
913
|
refineK?: number;
|
|
914
|
+
/** Progress hook for the interactive path. When provided, the search yields
|
|
915
|
+
* to a macrotask after each candidate so the host UI can paint a progress
|
|
916
|
+
* indicator. Omit it (CLI/export) and the search runs straight through with
|
|
917
|
+
* no added latency. */
|
|
918
|
+
onProgress?: (done: number, total: number, phase: string) => void;
|
|
878
919
|
}
|
|
879
|
-
): BLLayoutResult {
|
|
920
|
+
): Promise<BLLayoutResult> {
|
|
880
921
|
const hideDescriptions = opts?.hideDescriptions ?? false;
|
|
922
|
+
const onProgress = opts?.onProgress;
|
|
923
|
+
// Yield to a macrotask (lets the browser repaint between heavy placements);
|
|
924
|
+
// no-op when there's no progress observer so non-interactive callers pay
|
|
925
|
+
// nothing.
|
|
926
|
+
const tick = onProgress
|
|
927
|
+
? (): Promise<void> => new Promise<void>((r) => setTimeout(r))
|
|
928
|
+
: (): undefined => undefined;
|
|
881
929
|
|
|
882
930
|
// collapsed group labels (shown as plain boxes) — mirrors the ELK path
|
|
883
931
|
const collapsedGroupLabels = new Set<string>();
|
|
@@ -1016,6 +1064,217 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1016
1064
|
Math.abs(p.x - rect.x) <= rect.w / 2 &&
|
|
1017
1065
|
Math.abs(p.y - rect.y) <= rect.h / 2;
|
|
1018
1066
|
|
|
1067
|
+
// ── Pinned-layout bypass (Canvas Editor spike, Decisions 3/7) ──
|
|
1068
|
+
// When a `layout` block positions EVERY node, place nodes directly from the
|
|
1069
|
+
// stored coordinates and skip the whole dagre search. Edges become
|
|
1070
|
+
// border-clipped straight connectors (no obstacle avoidance — honest-but-ugly).
|
|
1071
|
+
// FLAT, EXPANDED groups are honored: each group's container rect is computed
|
|
1072
|
+
// from its members' pinned positions (canvas group editing). A FLAT group can
|
|
1073
|
+
// also be COLLAPSED while pinned — it renders as a plain box at its members'
|
|
1074
|
+
// bbox-centre (so collapse no longer forces a full dagre reflow). Nested groups
|
|
1075
|
+
// still fall back to dagre (deferred).
|
|
1076
|
+
const pinned = parsed.nodePositions;
|
|
1077
|
+
const groupLabelSet = new Set(parsed.groups.map((g) => g.label));
|
|
1078
|
+
const groupsAreFlat = parsed.groups.every(
|
|
1079
|
+
(g) => !g.parentGroup && !g.children.some((c) => groupLabelSet.has(c))
|
|
1080
|
+
);
|
|
1081
|
+
// A collapsed group can stay pinned only when it's FLAT (top-level, no
|
|
1082
|
+
// sub-groups) and every one of its members has a pinned coord — otherwise we
|
|
1083
|
+
// can't place its box without a search, so fall back to dagre.
|
|
1084
|
+
const allOriginalGroupLabels = new Set(
|
|
1085
|
+
(collapseInfo?.originalGroups ?? parsed.groups).map((g) => g.label)
|
|
1086
|
+
);
|
|
1087
|
+
const collapsedAreFlatPinned =
|
|
1088
|
+
collapsedGroupLabels.size === 0 ||
|
|
1089
|
+
(pinned !== undefined &&
|
|
1090
|
+
collapseInfo !== undefined &&
|
|
1091
|
+
[...collapsedGroupLabels].every((label) => {
|
|
1092
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
1093
|
+
if (!og || og.parentGroup) return false;
|
|
1094
|
+
return og.children.every(
|
|
1095
|
+
(c) => pinned.has(c) && !allOriginalGroupLabels.has(c)
|
|
1096
|
+
);
|
|
1097
|
+
}));
|
|
1098
|
+
const allPinned =
|
|
1099
|
+
pinned !== undefined &&
|
|
1100
|
+
(parsed.nodes.length > 0 || collapsedGroupLabels.size > 0) &&
|
|
1101
|
+
parsed.nodes.every((n) => pinned.has(n.label)) &&
|
|
1102
|
+
groupsAreFlat &&
|
|
1103
|
+
collapsedAreFlatPinned;
|
|
1104
|
+
function placePinned(pins: ReadonlyMap<string, Pt>): BLLayoutResult {
|
|
1105
|
+
// Collapsed flat groups → a plain NODE-sized box at the bbox-centre of the
|
|
1106
|
+
// group's (now-hidden) members' pinned coords. The collapse transform
|
|
1107
|
+
// redirected their incident edges to `__group_<label>`, so register that id
|
|
1108
|
+
// in the position/rect lookups too.
|
|
1109
|
+
const collapsedPosByGid = new Map<string, Pt>();
|
|
1110
|
+
const collapsedBoxes: Array<{
|
|
1111
|
+
label: string;
|
|
1112
|
+
lineNumber: number;
|
|
1113
|
+
childCount: number;
|
|
1114
|
+
x: number;
|
|
1115
|
+
y: number;
|
|
1116
|
+
}> = [];
|
|
1117
|
+
if (collapseInfo)
|
|
1118
|
+
for (const label of collapsedGroupLabels) {
|
|
1119
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
1120
|
+
if (!og) continue;
|
|
1121
|
+
let cx0 = Infinity,
|
|
1122
|
+
cy0 = Infinity,
|
|
1123
|
+
cx1 = -Infinity,
|
|
1124
|
+
cy1 = -Infinity;
|
|
1125
|
+
for (const c of og.children) {
|
|
1126
|
+
const p = pins.get(c);
|
|
1127
|
+
if (!p) continue;
|
|
1128
|
+
cx0 = Math.min(cx0, p.x);
|
|
1129
|
+
cx1 = Math.max(cx1, p.x);
|
|
1130
|
+
cy0 = Math.min(cy0, p.y);
|
|
1131
|
+
cy1 = Math.max(cy1, p.y);
|
|
1132
|
+
}
|
|
1133
|
+
if (!Number.isFinite(cx0)) continue;
|
|
1134
|
+
const cx = (cx0 + cx1) / 2;
|
|
1135
|
+
const cy = (cy0 + cy1) / 2;
|
|
1136
|
+
collapsedPosByGid.set(`__group_${label}`, { x: cx, y: cy });
|
|
1137
|
+
collapsedBoxes.push({
|
|
1138
|
+
label,
|
|
1139
|
+
lineNumber: og.lineNumber,
|
|
1140
|
+
childCount:
|
|
1141
|
+
collapseInfo.collapsedChildCounts.get(label) ?? og.children.length,
|
|
1142
|
+
x: cx,
|
|
1143
|
+
y: cy,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
const posOf = (label: string): Pt | undefined =>
|
|
1147
|
+
pins.get(label) ?? collapsedPosByGid.get(label);
|
|
1148
|
+
const rectOf = (label: string) => {
|
|
1149
|
+
const p = posOf(label)!;
|
|
1150
|
+
const s = sizes.get(label) ?? { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
1151
|
+
return { x: p.x, y: p.y, w: s.width, h: s.height };
|
|
1152
|
+
};
|
|
1153
|
+
const nodes = parsed.nodes.map((n) => {
|
|
1154
|
+
const r = rectOf(n.label);
|
|
1155
|
+
return { label: n.label, x: r.x, y: r.y, width: r.w, height: r.h };
|
|
1156
|
+
});
|
|
1157
|
+
const edges: BLLayoutEdge[] = parsed.edges.flatMap((e) => {
|
|
1158
|
+
const sp = posOf(e.source);
|
|
1159
|
+
const tp = posOf(e.target);
|
|
1160
|
+
if (!sp || !tp) return [];
|
|
1161
|
+
const srcRect = rectOf(e.source);
|
|
1162
|
+
const tgtRect = rectOf(e.target);
|
|
1163
|
+
const p0 = rectBorderPoint(srcRect, tp);
|
|
1164
|
+
const p1 = rectBorderPoint(tgtRect, sp);
|
|
1165
|
+
return [
|
|
1166
|
+
{
|
|
1167
|
+
source: e.source,
|
|
1168
|
+
target: e.target,
|
|
1169
|
+
...(e.label !== undefined && { label: e.label }),
|
|
1170
|
+
bidirectional: e.bidirectional,
|
|
1171
|
+
lineNumber: e.lineNumber,
|
|
1172
|
+
points: [p0, p1],
|
|
1173
|
+
yOffset: 0,
|
|
1174
|
+
parallelCount: 1,
|
|
1175
|
+
metadata: e.metadata,
|
|
1176
|
+
straight: true,
|
|
1177
|
+
},
|
|
1178
|
+
];
|
|
1179
|
+
});
|
|
1180
|
+
// Fit the canvas around the pinned content with a uniform margin on every
|
|
1181
|
+
// side. Crucially, content must never fall off the TOP/LEFT (the viewBox
|
|
1182
|
+
// origin is 0,0): if a node was dragged past the margin we shift everything
|
|
1183
|
+
// back on-canvas by `max(0, M - min)` — clamped so in-bounds diagrams keep
|
|
1184
|
+
// their exact pinned coords (shift 0) and only off-canvas ones are nudged.
|
|
1185
|
+
// Flat-group container rects: bbox of the group's members + side/bottom
|
|
1186
|
+
// padding, with a label zone reserved at the top (mirrors the renderer).
|
|
1187
|
+
const GROUP_PAD = 16;
|
|
1188
|
+
const nodeByLabel = new Map(nodes.map((n) => [n.label, n]));
|
|
1189
|
+
const groups: BLLayoutResult['groups'][number][] = [];
|
|
1190
|
+
for (const grp of parsed.groups) {
|
|
1191
|
+
let gx0 = Infinity,
|
|
1192
|
+
gy0 = Infinity,
|
|
1193
|
+
gx1 = -Infinity,
|
|
1194
|
+
gy1 = -Infinity;
|
|
1195
|
+
for (const c of grp.children) {
|
|
1196
|
+
const n = nodeByLabel.get(c);
|
|
1197
|
+
if (!n) continue;
|
|
1198
|
+
gx0 = Math.min(gx0, n.x - n.width / 2);
|
|
1199
|
+
gx1 = Math.max(gx1, n.x + n.width / 2);
|
|
1200
|
+
gy0 = Math.min(gy0, n.y - n.height / 2);
|
|
1201
|
+
gy1 = Math.max(gy1, n.y + n.height / 2);
|
|
1202
|
+
}
|
|
1203
|
+
if (!Number.isFinite(gx0)) continue; // members not pinned / empty group
|
|
1204
|
+
const x0 = gx0 - GROUP_PAD;
|
|
1205
|
+
const x1 = gx1 + GROUP_PAD;
|
|
1206
|
+
const y0 = gy0 - GROUP_LABEL_ZONE;
|
|
1207
|
+
const y1 = gy1 + GROUP_PAD;
|
|
1208
|
+
groups.push({
|
|
1209
|
+
label: grp.label,
|
|
1210
|
+
lineNumber: grp.lineNumber,
|
|
1211
|
+
x: (x0 + x1) / 2,
|
|
1212
|
+
y: (y0 + y1) / 2,
|
|
1213
|
+
width: x1 - x0,
|
|
1214
|
+
height: y1 - y0,
|
|
1215
|
+
collapsed: false,
|
|
1216
|
+
childCount: grp.children.length,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
// Collapsed flat groups: a plain box at the members' centre.
|
|
1220
|
+
for (const cb of collapsedBoxes) {
|
|
1221
|
+
groups.push({
|
|
1222
|
+
label: cb.label,
|
|
1223
|
+
lineNumber: cb.lineNumber,
|
|
1224
|
+
x: cb.x,
|
|
1225
|
+
y: cb.y,
|
|
1226
|
+
width: NODE_WIDTH,
|
|
1227
|
+
height: NODE_HEIGHT,
|
|
1228
|
+
collapsed: true,
|
|
1229
|
+
childCount: cb.childCount,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const M = 40;
|
|
1234
|
+
let minX = Infinity,
|
|
1235
|
+
minY = Infinity,
|
|
1236
|
+
maxX = -Infinity,
|
|
1237
|
+
maxY = -Infinity;
|
|
1238
|
+
const acc = (x: number, y: number) => {
|
|
1239
|
+
if (x < minX) minX = x;
|
|
1240
|
+
if (x > maxX) maxX = x;
|
|
1241
|
+
if (y < minY) minY = y;
|
|
1242
|
+
if (y > maxY) maxY = y;
|
|
1243
|
+
};
|
|
1244
|
+
for (const n of nodes) {
|
|
1245
|
+
acc(n.x - n.width / 2, n.y - n.height / 2);
|
|
1246
|
+
acc(n.x + n.width / 2, n.y + n.height / 2);
|
|
1247
|
+
}
|
|
1248
|
+
for (const e of edges) for (const p of e.points) acc(p.x, p.y);
|
|
1249
|
+
for (const gr of groups) {
|
|
1250
|
+
acc(gr.x - gr.width / 2, gr.y - gr.height / 2);
|
|
1251
|
+
acc(gr.x + gr.width / 2, gr.y + gr.height / 2);
|
|
1252
|
+
}
|
|
1253
|
+
// Only correct genuinely off-canvas content — a small tolerance ignores the
|
|
1254
|
+
// sub-pixel jitter from rounding coords near the margin, so an already
|
|
1255
|
+
// on-canvas diagram keeps its exact pinned positions (no creeping drift).
|
|
1256
|
+
const TOL = 2;
|
|
1257
|
+
const sx = minX < M - TOL ? M - minX : 0;
|
|
1258
|
+
const sy = minY < M - TOL ? M - minY : 0;
|
|
1259
|
+
const shifted = sx !== 0 || sy !== 0;
|
|
1260
|
+
return {
|
|
1261
|
+
nodes: shifted
|
|
1262
|
+
? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
|
|
1263
|
+
: nodes,
|
|
1264
|
+
edges: shifted
|
|
1265
|
+
? edges.map((e) => ({
|
|
1266
|
+
...e,
|
|
1267
|
+
points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
|
|
1268
|
+
}))
|
|
1269
|
+
: edges,
|
|
1270
|
+
groups: shifted
|
|
1271
|
+
? groups.map((gr) => ({ ...gr, x: gr.x + sx, y: gr.y + sy }))
|
|
1272
|
+
: groups,
|
|
1273
|
+
width: maxX + sx + M,
|
|
1274
|
+
height: maxY + sy + M,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1019
1278
|
function place(cfg: {
|
|
1020
1279
|
ranker: string;
|
|
1021
1280
|
nodesep: number;
|
|
@@ -1173,6 +1432,9 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1173
1432
|
} as BLLayoutResult;
|
|
1174
1433
|
}
|
|
1175
1434
|
|
|
1435
|
+
// Pinned mode short-circuits the entire search.
|
|
1436
|
+
if (allPinned) return placePinned(pinned!);
|
|
1437
|
+
|
|
1176
1438
|
const n = parsed.nodes.length;
|
|
1177
1439
|
// ~500ms budget: search a larger pool, then refine the top few exactly.
|
|
1178
1440
|
const seedCount =
|
|
@@ -1213,7 +1475,7 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1213
1475
|
// `floor` lets callers skip the expensive O/P passes once X alone already
|
|
1214
1476
|
// exceeds the best badness found so far (it can't win, return Infinity).
|
|
1215
1477
|
const badness = (lay: BLLayoutResult, floor: number): number => {
|
|
1216
|
-
const x = countSplineCrossings(lay);
|
|
1478
|
+
const x = countSplineCrossings(lay, floor);
|
|
1217
1479
|
if (x > floor) return Infinity;
|
|
1218
1480
|
return (
|
|
1219
1481
|
x +
|
|
@@ -1228,6 +1490,18 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1228
1490
|
const objective = (lay: BLLayoutResult, viol: number) =>
|
|
1229
1491
|
viol * 1e6 + edgeLength(lay) + lambda * meanDrift(lay, prev) * 10;
|
|
1230
1492
|
|
|
1493
|
+
// Progress is reported over the two dominant phases: building the dagre
|
|
1494
|
+
// candidate pool, then exact-scoring the top few. `refineK` is clamped below,
|
|
1495
|
+
// so estimate the total here for a smooth bar.
|
|
1496
|
+
const progressTotal =
|
|
1497
|
+
configs.length + Math.min(opts?.refineK ?? 6, configs.length);
|
|
1498
|
+
let progressDone = 0;
|
|
1499
|
+
const step = async (phase: string): Promise<void> => {
|
|
1500
|
+
if (!onProgress) return;
|
|
1501
|
+
onProgress(++progressDone, progressTotal, phase);
|
|
1502
|
+
await tick();
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1231
1505
|
// Build the candidate pool.
|
|
1232
1506
|
const pool: BLLayoutResult[] = [];
|
|
1233
1507
|
for (const cfg of configs) {
|
|
@@ -1236,6 +1510,7 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1236
1510
|
} catch {
|
|
1237
1511
|
/* some rankers choke on odd graphs */
|
|
1238
1512
|
}
|
|
1513
|
+
await step('Optimizing layout');
|
|
1239
1514
|
}
|
|
1240
1515
|
if (!pool.length)
|
|
1241
1516
|
return place({ ranker: 'network-simplex', nodesep: 50, ranksep: 60 });
|
|
@@ -1256,10 +1531,13 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1256
1531
|
// Stage 1: rank the dagre pool with the cheap straight-segment counter — a
|
|
1257
1532
|
// cheap proxy to pick which candidates are worth the expensive exact scoring.
|
|
1258
1533
|
// Widen REFINE_K a little since the proxy only sees crossings, not O/P.
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1534
|
+
// Pre-score each candidate ONCE (countCrossingsFast is O(E²)) into a key map,
|
|
1535
|
+
// then sort by the stored key — the comparator otherwise recomputes the score
|
|
1536
|
+
// for both operands on every comparison (O(C log C) calls vs C). The score is a
|
|
1537
|
+
// pure function of the layout, so the resulting order is identical.
|
|
1538
|
+
const fastKey = new Map<BLLayoutResult, number>();
|
|
1539
|
+
for (const lay of pool) fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
|
|
1540
|
+
pool.sort((a, b) => fastKey.get(a)! - fastKey.get(b)!);
|
|
1263
1541
|
const refineK = Math.min(REFINE_K, pool.length);
|
|
1264
1542
|
|
|
1265
1543
|
// Stage 2: exact-score the top-K dagre candidates on the FULL badness (X+O+P)
|
|
@@ -1278,7 +1556,10 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1278
1556
|
best = lay;
|
|
1279
1557
|
}
|
|
1280
1558
|
};
|
|
1281
|
-
for (const lay of pool.slice(0, refineK))
|
|
1559
|
+
for (const lay of pool.slice(0, refineK)) {
|
|
1560
|
+
consider(lay);
|
|
1561
|
+
await step('Refining layout');
|
|
1562
|
+
}
|
|
1282
1563
|
|
|
1283
1564
|
// Adaptive escalation: a still-high badness after the base seed budget means
|
|
1284
1565
|
// the graph is genuinely hard — dense layouts (e.g. the marketplace) need many
|
|
@@ -1303,11 +1584,10 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1303
1584
|
/* ignore choking rankers */
|
|
1304
1585
|
}
|
|
1305
1586
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
);
|
|
1587
|
+
const extraKey = new Map<BLLayoutResult, number>();
|
|
1588
|
+
for (const lay of extra)
|
|
1589
|
+
extraKey.set(lay, objective(lay, countCrossingsFast(lay)));
|
|
1590
|
+
extra.sort((a, b) => extraKey.get(a)! - extraKey.get(b)!);
|
|
1311
1591
|
for (const lay of extra.slice(0, ESCALATE_REFINE)) consider(lay);
|
|
1312
1592
|
}
|
|
1313
1593
|
|
|
@@ -66,6 +66,10 @@ export interface BLLayoutEdge {
|
|
|
66
66
|
/** Marker for renderer: draw with linear curve, not curveBasis (ELK gives
|
|
67
67
|
* us orthogonal polylines and curveBasis would smooth corners into waves) */
|
|
68
68
|
readonly deferred?: boolean;
|
|
69
|
+
/** Pinned-layout connector: a border-clipped straight 2-point segment (Canvas
|
|
70
|
+
* Editor spike, Decision 7). Renderer draws it with a linear generator —
|
|
71
|
+
* curveBasis collapses a 2-point polyline. */
|
|
72
|
+
readonly straight?: boolean;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export interface BLLayoutGroup {
|
|
@@ -195,16 +199,22 @@ export async function layoutBoxesAndLines(
|
|
|
195
199
|
/** Previous node positions (label → {x,y}) for layout stability —
|
|
196
200
|
* minimizes node drift on edit/collapse. */
|
|
197
201
|
previousPositions?: ReadonlyMap<string, { x: number; y: number }>;
|
|
202
|
+
/** Progress hook (interactive path). When set, the search yields between
|
|
203
|
+
* candidates so the UI can paint a "trying X of Y" indicator. */
|
|
204
|
+
onProgress?: (done: number, total: number, phase: string) => void;
|
|
198
205
|
}
|
|
199
206
|
): Promise<BLLayoutResult> {
|
|
200
207
|
const { layoutBoxesAndLinesSearch } = await import('./layout-search');
|
|
201
|
-
const searched = layoutBoxesAndLinesSearch(parsed, collapseInfo, {
|
|
208
|
+
const searched = await layoutBoxesAndLinesSearch(parsed, collapseInfo, {
|
|
202
209
|
...(layoutOptions?.hideDescriptions !== undefined && {
|
|
203
210
|
hideDescriptions: layoutOptions.hideDescriptions,
|
|
204
211
|
}),
|
|
205
212
|
...(layoutOptions?.previousPositions !== undefined && {
|
|
206
213
|
previousPositions: layoutOptions.previousPositions,
|
|
207
214
|
}),
|
|
215
|
+
...(layoutOptions?.onProgress !== undefined && {
|
|
216
|
+
onProgress: layoutOptions.onProgress,
|
|
217
|
+
}),
|
|
208
218
|
});
|
|
209
219
|
// Engine-agnostic post-processing: fan parallel edges, then float notes
|
|
210
220
|
// (and shift the canvas to fit them).
|