@crazyhappyone/auto-graph 0.2.1 → 0.2.3

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/dist/index.cjs CHANGED
@@ -79,6 +79,17 @@ function intersectsAabb(a, b) {
79
79
  validateBox(b, "b");
80
80
  return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
81
81
  }
82
+ function overlapArea(first, second) {
83
+ const x = Math.max(
84
+ 0,
85
+ Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x)
86
+ );
87
+ const y = Math.max(
88
+ 0,
89
+ Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y)
90
+ );
91
+ return x * y;
92
+ }
82
93
  function validateMargin(value, label) {
83
94
  validateFinite(value, label);
84
95
  if (value < 0) {
@@ -91,6 +102,72 @@ function validateFinite(value, label) {
91
102
  }
92
103
  }
93
104
 
105
+ // src/geometry/spatial-index.ts
106
+ function createBoxSpatialIndex(entries, cellSize = 128) {
107
+ const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : 128;
108
+ const boxes = /* @__PURE__ */ new Map();
109
+ const mutableCells = /* @__PURE__ */ new Map();
110
+ for (const entry of entries) {
111
+ boxes.set(entry.id, { ...entry.box });
112
+ for (const key of cellKeysForBox(entry.box, normalizedCellSize)) {
113
+ const ids = mutableCells.get(key) ?? [];
114
+ ids.push(entry.id);
115
+ mutableCells.set(key, ids);
116
+ }
117
+ }
118
+ const cells = /* @__PURE__ */ new Map();
119
+ for (const [key, ids] of mutableCells) {
120
+ cells.set(key, [...new Set(ids)].sort());
121
+ }
122
+ return { cellSize: normalizedCellSize, entries: boxes, cells };
123
+ }
124
+ function queryBoxSpatialIndex(index, box) {
125
+ const ids = /* @__PURE__ */ new Set();
126
+ for (const key of cellKeysForBox(box, index.cellSize)) {
127
+ for (const id of index.cells.get(key) ?? []) {
128
+ ids.add(id);
129
+ }
130
+ }
131
+ return [...ids].sort().flatMap((id) => {
132
+ const candidate = index.entries.get(id);
133
+ return candidate !== void 0 && intersectsAabb(candidate, box) ? [{ id, box: candidate }] : [];
134
+ });
135
+ }
136
+ function querySegmentSpatialIndex(index, start, end) {
137
+ return queryBoxSpatialIndex(index, segmentBox(start, end));
138
+ }
139
+ function expandBoxForQuery(box, margin) {
140
+ return {
141
+ x: box.x - margin,
142
+ y: box.y - margin,
143
+ width: box.width + margin * 2,
144
+ height: box.height + margin * 2
145
+ };
146
+ }
147
+ function cellKeysForBox(box, cellSize) {
148
+ const minCol = Math.floor(box.x / cellSize);
149
+ const maxCol = Math.floor((box.x + Math.max(1, box.width)) / cellSize);
150
+ const minRow = Math.floor(box.y / cellSize);
151
+ const maxRow = Math.floor((box.y + Math.max(1, box.height)) / cellSize);
152
+ const keys = [];
153
+ for (let col = minCol; col <= maxCol; col += 1) {
154
+ for (let row = minRow; row <= maxRow; row += 1) {
155
+ keys.push(`${col}:${row}`);
156
+ }
157
+ }
158
+ return keys;
159
+ }
160
+ function segmentBox(start, end) {
161
+ const x = Math.min(start.x, end.x);
162
+ const y = Math.min(start.y, end.y);
163
+ return {
164
+ x,
165
+ y,
166
+ width: Math.max(1, Math.abs(start.x - end.x)),
167
+ height: Math.max(1, Math.abs(start.y - end.y))
168
+ };
169
+ }
170
+
94
171
  // src/constraints/solver.ts
95
172
  function applyLayoutConstraints(input) {
96
173
  const diagnostics = [];
@@ -122,7 +199,12 @@ function applyLayoutConstraints(input) {
122
199
  dedupReplayDiagnostics(diagnostics, diagBefore);
123
200
  }
124
201
  removeResolvedConstraintDiagnostics(input.constraints, boxes, diagnostics);
125
- reportOverlaps(boxes, diagnostics, containmentOverlapKeys(input.constraints));
202
+ reportOverlaps(
203
+ boxes,
204
+ diagnostics,
205
+ containmentOverlapKeys(input.constraints),
206
+ locks
207
+ );
126
208
  reportIntraContainerOverflow(input, boxes, diagnostics);
127
209
  return { boxes, locks, diagnostics };
128
210
  }
@@ -288,14 +370,19 @@ function applyContainment(constraints, boxes, locks, diagnostics, reportOverflow
288
370
  if (samePosition(child, next)) {
289
371
  continue;
290
372
  }
291
- if (locks.has(childId)) {
373
+ const lock = locks.get(childId);
374
+ if (lock !== void 0) {
292
375
  if (!reportOverflow) {
293
376
  diagnostics.push({
294
377
  severity: "warning",
295
378
  code: "constraints.locked-target-not-moved",
296
379
  message: `Locked child ${childId} was not moved into containment.`,
297
380
  path: ["constraints", constraint.id ?? constraint.containerId],
298
- detail: { nodeId: childId, containerId: constraint.containerId }
381
+ detail: {
382
+ nodeId: childId,
383
+ containerId: constraint.containerId,
384
+ lockSource: lock.source
385
+ }
299
386
  });
300
387
  if (!isInside(child, content)) {
301
388
  diagnostics.push({
@@ -410,18 +497,29 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
410
497
  const secondaryAxis = axis === "x" ? "y" : "x";
411
498
  const ignoredPairs = containmentOverlapKeys(input.constraints);
412
499
  const ids = [...boxes.keys()].sort();
500
+ const index = createBoxSpatialIndex(
501
+ ids.flatMap((id) => {
502
+ const box = boxes.get(id);
503
+ return box === void 0 ? [] : [{ id, box }];
504
+ }),
505
+ spacing
506
+ );
413
507
  for (let pass = 0; pass < 2; pass += 1) {
414
508
  for (const firstId of ids) {
415
- for (const secondId of ids) {
509
+ const first = boxes.get(firstId);
510
+ if (first === void 0) {
511
+ continue;
512
+ }
513
+ const candidateIds = queryBoxSpatialIndex(index, first).map((candidate) => candidate.id).filter((id) => id > firstId).sort();
514
+ for (const secondId of candidateIds) {
416
515
  if (firstId >= secondId) {
417
516
  continue;
418
517
  }
419
518
  if (ignoredPairs.has(overlapKey(firstId, secondId))) {
420
519
  continue;
421
520
  }
422
- const first = boxes.get(firstId);
423
521
  const second = boxes.get(secondId);
424
- if (first === void 0 || second === void 0 || !intersectsAabb(first, second)) {
522
+ if (second === void 0 || !intersectsAabb(first, second)) {
425
523
  continue;
426
524
  }
427
525
  const firstLocked = locks.has(firstId);
@@ -445,7 +543,7 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
445
543
  }
446
544
  }
447
545
  }
448
- reportOverlaps(boxes, diagnostics, ignoredPairs);
546
+ reportOverlaps(boxes, diagnostics, ignoredPairs, locks);
449
547
  }
450
548
  function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
451
549
  for (let i = diagnostics.length - 1; i >= 0; i -= 1) {
@@ -501,29 +599,56 @@ function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
501
599
  }
502
600
  }
503
601
  }
504
- function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set()) {
602
+ function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set(), locks = /* @__PURE__ */ new Map()) {
505
603
  const ids = [...boxes.keys()].sort();
506
604
  const reported = new Set(
507
605
  diagnostics.filter(
508
- (diagnostic) => diagnostic.code === "constraints.overlap.unresolved"
606
+ (diagnostic) => diagnostic.code === "constraints.overlap.unresolved" || diagnostic.code === "constraints.overlap.locked-conflict"
509
607
  ).map((diagnostic) => {
510
608
  const firstId = diagnostic.detail?.firstId;
511
609
  const secondId = diagnostic.detail?.secondId;
512
610
  return typeof firstId === "string" && typeof secondId === "string" ? overlapKey(firstId, secondId) : void 0;
513
611
  }).filter((key) => key !== void 0)
514
612
  );
613
+ const index = createBoxSpatialIndex(
614
+ ids.flatMap((id) => {
615
+ const box = boxes.get(id);
616
+ return box === void 0 ? [] : [{ id, box }];
617
+ }),
618
+ 40
619
+ );
515
620
  for (const firstId of ids) {
516
- for (const secondId of ids) {
517
- if (firstId >= secondId) {
518
- continue;
519
- }
621
+ const first = boxes.get(firstId);
622
+ if (first === void 0) {
623
+ continue;
624
+ }
625
+ const candidateIds = queryBoxSpatialIndex(index, first).map((candidate) => candidate.id).filter((id) => id > firstId).sort();
626
+ for (const secondId of candidateIds) {
520
627
  const key = overlapKey(firstId, secondId);
521
628
  if (reported.has(key) || ignoredPairs.has(key)) {
522
629
  continue;
523
630
  }
524
- const first = boxes.get(firstId);
525
631
  const second = boxes.get(secondId);
526
- if (first !== void 0 && second !== void 0 && intersectsAabb(first, second)) {
632
+ if (second !== void 0 && intersectsAabb(first, second)) {
633
+ const firstLock = locks.get(firstId);
634
+ const secondLock = locks.get(secondId);
635
+ if (firstLock !== void 0 && secondLock !== void 0) {
636
+ diagnostics.push({
637
+ severity: "warning",
638
+ code: "constraints.overlap.locked-conflict",
639
+ message: `Locked boxes ${firstId} (${firstLock.source}) and ${secondId} (${secondLock.source}) overlap and cannot be repaired.`,
640
+ path: ["boxes"],
641
+ detail: {
642
+ firstId,
643
+ secondId,
644
+ firstLockSource: firstLock.source,
645
+ secondLockSource: secondLock.source,
646
+ overlapArea: overlapArea(first, second)
647
+ }
648
+ });
649
+ reported.add(key);
650
+ continue;
651
+ }
527
652
  diagnostics.push({
528
653
  severity: "warning",
529
654
  code: "constraints.overlap.unresolved",
@@ -679,12 +804,17 @@ function setUnlockedBox(id, next, boxes, locks, diagnostics, constraint) {
679
804
  return;
680
805
  }
681
806
  if (locks.has(id) && !samePosition(current, next)) {
807
+ const lock = locks.get(id);
682
808
  diagnostics.push({
683
809
  severity: "warning",
684
810
  code: "constraints.locked-target-not-moved",
685
811
  message: `Locked target ${id} was not moved by ${constraint.kind}.`,
686
812
  path: ["constraints", constraint.id ?? id],
687
- detail: { nodeId: id, constraintKind: constraint.kind }
813
+ detail: {
814
+ nodeId: id,
815
+ constraintKind: constraint.kind,
816
+ ...lock === void 0 ? {} : { lockSource: lock.source }
817
+ }
688
818
  });
689
819
  return;
690
820
  }
@@ -853,7 +983,28 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
853
983
  if (distributable.length < 2) {
854
984
  continue;
855
985
  }
986
+ const spread = typeof input.distributeContainedChildren === "string";
987
+ let effectiveGap = minGap;
856
988
  let pos = content[axis];
989
+ if (spread) {
990
+ let totalChildSpan = 0;
991
+ for (const child of distributable) {
992
+ totalChildSpan += child.box[mainSize];
993
+ }
994
+ let reservedSpan = 0;
995
+ const contentEnd = content[axis] + content[mainSize];
996
+ for (const r of reserved) {
997
+ const rStart = Math.max(r.start, content[axis]);
998
+ const rEnd = Math.min(r.end, contentEnd);
999
+ if (rEnd > rStart) {
1000
+ reservedSpan += rEnd - rStart + minGap;
1001
+ }
1002
+ }
1003
+ const remaining = content[mainSize] - totalChildSpan - reservedSpan - minGap * (distributable.length - 1);
1004
+ if (remaining > 0) {
1005
+ effectiveGap = minGap + remaining / (distributable.length - 1);
1006
+ }
1007
+ }
857
1008
  for (const child of distributable) {
858
1009
  pos = advancePastReserved(pos, child.box[mainSize], reserved, minGap);
859
1010
  const crossPos = content[crossAxis] + Math.max(0, (content[crossSize] - child.box[crossSize]) / 2);
@@ -872,7 +1023,7 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
872
1023
  }
873
1024
  boxes.set(child.id, clamped);
874
1025
  locks.delete(child.id);
875
- pos = clamped[axis] + clamped[mainSize] + minGap;
1026
+ pos = clamped[axis] + clamped[mainSize] + effectiveGap;
876
1027
  }
877
1028
  diagnostics.push({
878
1029
  severity: "info",
@@ -1628,6 +1779,7 @@ function normalizeDiagramDsl(dslValue, options = {}) {
1628
1779
  const measurer = options.textMeasurer ?? createDefaultTextMeasurer();
1629
1780
  const routeKind = dsl.routing?.kind ?? "orthogonal";
1630
1781
  const portShifting = normalizePortShifting(dsl.routing?.portShifting);
1782
+ const initialLayout = dsl.layout?.mode;
1631
1783
  const primaryReadingDirection = dsl.layout?.primaryReadingDirection;
1632
1784
  const matrices = normalizeMatrices(dsl);
1633
1785
  const tables = normalizeTables(dsl);
@@ -1648,6 +1800,7 @@ function normalizeDiagramDsl(dslValue, options = {}) {
1648
1800
  ...dsl.frame === void 0 ? {} : { frame: normalizeFrame(dsl.frame) },
1649
1801
  metadata: {
1650
1802
  routeKind,
1803
+ ...initialLayout === void 0 ? {} : { initialLayout },
1651
1804
  ...primaryReadingDirection === void 0 ? {} : { primaryReadingDirection },
1652
1805
  ...portShifting === void 0 ? {} : { portShifting }
1653
1806
  }
@@ -2197,6 +2350,7 @@ function point(value) {
2197
2350
  return { x: value.x, y: value.y };
2198
2351
  }
2199
2352
  var directionSchema = zod.z.enum(["TB", "LR", "BT", "RL"]);
2353
+ var layoutModeSchema = zod.z.enum(["dagre", "positions"]);
2200
2354
  var routeKindSchema = zod.z.enum(["orthogonal", "straight", "obstacle-avoiding"]);
2201
2355
  var outputFormatSchema = zod.z.enum(["svg", "excalidraw"]);
2202
2356
  var edgeStrokeStyleSchema = zod.z.enum(["solid", "dashed"]);
@@ -2507,6 +2661,7 @@ var diagramDslSchema = zod.z.object({
2507
2661
  direction: directionSchema.optional(),
2508
2662
  layout: zod.z.object({
2509
2663
  direction: directionSchema.optional(),
2664
+ mode: layoutModeSchema.optional(),
2510
2665
  primaryReadingDirection: primaryReadingDirectionSchema.optional()
2511
2666
  }).optional(),
2512
2667
  routing: zod.z.object({
@@ -2843,13 +2998,22 @@ function exportExcalidraw(diagram, options = {}) {
2843
2998
  appState: {
2844
2999
  name: options.title ?? diagram.title ?? diagram.id,
2845
3000
  viewBackgroundColor: "#ffffff",
2846
- gridSize: null
3001
+ gridSize: null,
3002
+ ...options.viewportPadding === void 0 ? {} : viewportAppState(diagram.bounds, options.viewportPadding)
2847
3003
  },
2848
3004
  files: {}
2849
3005
  };
2850
3006
  return `${JSON.stringify(scene, null, 2)}
2851
3007
  `;
2852
3008
  }
3009
+ function viewportAppState(bounds, padding) {
3010
+ const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
3011
+ return {
3012
+ scrollX: finite(-bounds.x + safePadding),
3013
+ scrollY: finite(-bounds.y + safePadding),
3014
+ zoom: { value: 1 }
3015
+ };
3016
+ }
2853
3017
  function renderGroup(group) {
2854
3018
  return {
2855
3019
  ...baseElement(`group:${group.id}`, "rectangle", group.box),
@@ -3173,6 +3337,9 @@ function exportSvg(diagram, options = {}) {
3173
3337
  return `${[
3174
3338
  `<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="${formatBoxViewBox(diagram.bounds)}">`,
3175
3339
  ...title === void 0 ? [] : [` <title>${escapeXml(title)}</title>`],
3340
+ ...options.viewportPadding === void 0 ? [] : [
3341
+ ` <metadata data-dge-viewport="${escapeAttribute(viewportMetadata(diagram.bounds, options.viewportPadding))}"></metadata>`
3342
+ ],
3176
3343
  ` <rect class="background" x="${formatNumber(diagram.bounds.x)}" y="${formatNumber(diagram.bounds.y)}" width="${formatNumber(diagram.bounds.width)}" height="${formatNumber(diagram.bounds.height)}" fill="#ffffff"/>`,
3177
3344
  ...diagram.frame === void 0 ? [] : [indent(renderFrame(diagram.frame, annotations))],
3178
3345
  ...(diagram.swimlanes ?? []).flatMap(
@@ -3206,6 +3373,16 @@ function exportSvg(diagram, options = {}) {
3206
3373
  ].join("\n")}
3207
3374
  `;
3208
3375
  }
3376
+ function viewportMetadata(bounds, padding) {
3377
+ const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
3378
+ return JSON.stringify({
3379
+ x: bounds.x - safePadding,
3380
+ y: bounds.y - safePadding,
3381
+ width: bounds.width + safePadding * 2,
3382
+ height: bounds.height + safePadding * 2,
3383
+ padding: safePadding
3384
+ });
3385
+ }
3209
3386
  function renderGroup2(group) {
3210
3387
  return `<rect class="group" data-id="${escapeAttribute(group.id)}" x="${formatNumber(group.box.x)}" y="${formatNumber(group.box.y)}" width="${formatNumber(group.box.width)}" height="${formatNumber(group.box.height)}" fill="${GROUP_FILL}" stroke="${STROKE}" stroke-dasharray="6 4"/>`;
3211
3388
  }
@@ -3877,9 +4054,49 @@ function indentLines(values) {
3877
4054
  return values.map(indent);
3878
4055
  }
3879
4056
 
4057
+ // src/solver/pipeline/pipeline.ts
4058
+ var LayoutPipeline = class {
4059
+ phases = [];
4060
+ addPhase(phase) {
4061
+ this.phases.push(phase);
4062
+ return this;
4063
+ }
4064
+ addBefore(refName, phase) {
4065
+ const idx = this.phases.findIndex((p) => p.name === refName);
4066
+ if (idx === -1) throw new Error(`Phase "${refName}" not found`);
4067
+ this.phases.splice(idx, 0, phase);
4068
+ return this;
4069
+ }
4070
+ addAfter(refName, phase) {
4071
+ const idx = this.phases.findIndex((p) => p.name === refName);
4072
+ if (idx === -1) throw new Error(`Phase "${refName}" not found`);
4073
+ this.phases.splice(idx + 1, 0, phase);
4074
+ return this;
4075
+ }
4076
+ replacePhase(name, phase) {
4077
+ const idx = this.phases.findIndex((p) => p.name === name);
4078
+ if (idx === -1) throw new Error(`Phase "${name}" not found`);
4079
+ this.phases[idx] = phase;
4080
+ return this;
4081
+ }
4082
+ run(state) {
4083
+ for (const phase of this.phases) {
4084
+ const before = state.diagnostics.length;
4085
+ const start = performance.now();
4086
+ phase.run(state);
4087
+ state.phaseTrace.push({
4088
+ phase: phase.name,
4089
+ durationMs: performance.now() - start,
4090
+ diagnosticsAdded: state.diagnostics.length - before
4091
+ });
4092
+ }
4093
+ }
4094
+ };
4095
+
3880
4096
  // src/ir/diagnostics.ts
3881
4097
  var DELIVERABILITY_DIAGNOSTIC_CODES = /* @__PURE__ */ new Set([
3882
4098
  "constraints.locked-target-not-moved",
4099
+ "constraints.overlap.locked-conflict",
3883
4100
  "routing.evidence.crossing_forbidden",
3884
4101
  "routing.obstacle.unavoidable",
3885
4102
  "route_obstacle_fallback",
@@ -3891,6 +4108,7 @@ var DEFAULT_OPTIONS = {
3891
4108
  edgesep: 40,
3892
4109
  marginx: 0,
3893
4110
  marginy: 0,
4111
+ componentGap: 160,
3894
4112
  ranker: "network-simplex"
3895
4113
  };
3896
4114
  function runDagreInitialLayout(input) {
@@ -3979,9 +4197,116 @@ function runDagreInitialLayout(input) {
3979
4197
  }
3980
4198
  return { boxes, diagnostics };
3981
4199
  }
4200
+ function runComponentAwareDagreInitialLayout(input) {
4201
+ const options = { ...DEFAULT_OPTIONS, ...input.options };
4202
+ const diagnostics = reportMissingEdgeReferences(input);
4203
+ const validNodeIds = new Set(input.nodes.map((node) => node.id));
4204
+ const validEdges = input.edges.filter(
4205
+ (edge) => validNodeIds.has(edge.sourceId) && validNodeIds.has(edge.targetId)
4206
+ );
4207
+ const components = connectedComponents(input.nodes, validEdges);
4208
+ if (components.length <= 1) {
4209
+ const layout2 = runDagreInitialLayout({ ...input, edges: validEdges });
4210
+ return {
4211
+ boxes: layout2.boxes,
4212
+ diagnostics: [...diagnostics, ...layout2.diagnostics]
4213
+ };
4214
+ }
4215
+ const boxes = /* @__PURE__ */ new Map();
4216
+ let cursor = 0;
4217
+ for (const component of components) {
4218
+ const componentNodeIds = new Set(component.map((node) => node.id));
4219
+ const componentLayout = runDagreInitialLayout({
4220
+ ...input,
4221
+ nodes: component,
4222
+ edges: validEdges.filter(
4223
+ (edge) => componentNodeIds.has(edge.sourceId) && componentNodeIds.has(edge.targetId)
4224
+ )
4225
+ });
4226
+ diagnostics.push(...componentLayout.diagnostics);
4227
+ if (componentLayout.boxes.size === 0) {
4228
+ continue;
4229
+ }
4230
+ const bounds = unionBoxes([...componentLayout.boxes.values()]);
4231
+ const axis = input.direction === "LR" || input.direction === "RL" ? "x" : "y";
4232
+ const dx = axis === "x" ? cursor - bounds.x : -bounds.x;
4233
+ const dy = axis === "y" ? cursor - bounds.y : -bounds.y;
4234
+ for (const [id, box] of componentLayout.boxes) {
4235
+ boxes.set(id, { ...box, x: box.x + dx, y: box.y + dy });
4236
+ }
4237
+ cursor += (axis === "x" ? bounds.width : bounds.height) + options.componentGap;
4238
+ }
4239
+ return { boxes, diagnostics };
4240
+ }
4241
+ function reportMissingEdgeReferences(input) {
4242
+ const validNodeIds = new Set(input.nodes.map((node) => node.id));
4243
+ return input.edges.flatMap((edge) => {
4244
+ if (validNodeIds.has(edge.sourceId) && validNodeIds.has(edge.targetId)) {
4245
+ return [];
4246
+ }
4247
+ return [
4248
+ {
4249
+ severity: "error",
4250
+ code: "layout.edge-reference.missing",
4251
+ message: `Edge ${edge.id} references a missing layout node.`,
4252
+ path: ["edges", edge.id],
4253
+ detail: {
4254
+ edgeId: edge.id,
4255
+ sourceId: edge.sourceId,
4256
+ targetId: edge.targetId
4257
+ }
4258
+ }
4259
+ ];
4260
+ });
4261
+ }
3982
4262
  function isValidDimension(value) {
3983
4263
  return Number.isFinite(value) && value >= 0;
3984
4264
  }
4265
+ function connectedComponents(nodes, edges) {
4266
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
4267
+ const adjacency = new Map(nodes.map((node) => [node.id, /* @__PURE__ */ new Set()]));
4268
+ for (const edge of edges) {
4269
+ if (!nodeById.has(edge.sourceId) || !nodeById.has(edge.targetId)) {
4270
+ continue;
4271
+ }
4272
+ adjacency.get(edge.sourceId)?.add(edge.targetId);
4273
+ adjacency.get(edge.targetId)?.add(edge.sourceId);
4274
+ }
4275
+ const visited = /* @__PURE__ */ new Set();
4276
+ const components = [];
4277
+ for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) {
4278
+ if (visited.has(node.id)) {
4279
+ continue;
4280
+ }
4281
+ const ids = [];
4282
+ const stack = [node.id];
4283
+ visited.add(node.id);
4284
+ while (stack.length > 0) {
4285
+ const id = stack.pop();
4286
+ if (id === void 0) {
4287
+ continue;
4288
+ }
4289
+ ids.push(id);
4290
+ for (const neighbor of [...adjacency.get(id) ?? []].sort().reverse()) {
4291
+ if (!visited.has(neighbor)) {
4292
+ visited.add(neighbor);
4293
+ stack.push(neighbor);
4294
+ }
4295
+ }
4296
+ }
4297
+ components.push(
4298
+ ids.sort().flatMap((id) => {
4299
+ const componentNode = nodeById.get(id);
4300
+ return componentNode === void 0 ? [] : [componentNode];
4301
+ })
4302
+ );
4303
+ }
4304
+ return components.sort((a, b) => {
4305
+ const left = a[0]?.id ?? "";
4306
+ const right = b[0]?.id ?? "";
4307
+ return left.localeCompare(right);
4308
+ });
4309
+ }
3985
4310
 
3986
4311
  // src/routing/astar.ts
3987
4312
  function findObstacleFreePath(source, target, obstacles, options = {}, diagnostics) {
@@ -4024,7 +4349,7 @@ function collectXs(source, target, obstacles, margin) {
4024
4349
  for (const obs of obstacles) {
4025
4350
  raw.push(obs.x - margin - 2, obs.x + obs.width + margin + 2);
4026
4351
  }
4027
- const deduped = dedupSorted(raw);
4352
+ const deduped = insertChannelMidpoints(dedupSorted(raw));
4028
4353
  for (const v of [source.x, target.x]) {
4029
4354
  if (!deduped.includes(v)) {
4030
4355
  deduped.push(v);
@@ -4037,7 +4362,7 @@ function collectYs(source, target, obstacles, margin) {
4037
4362
  for (const obs of obstacles) {
4038
4363
  raw.push(obs.y - margin - 2, obs.y + obs.height + margin + 2);
4039
4364
  }
4040
- const deduped = dedupSorted(raw);
4365
+ const deduped = insertChannelMidpoints(dedupSorted(raw));
4041
4366
  for (const v of [source.y, target.y]) {
4042
4367
  if (!deduped.includes(v)) {
4043
4368
  deduped.push(v);
@@ -4056,6 +4381,19 @@ function dedupSorted(values) {
4056
4381
  }
4057
4382
  return result;
4058
4383
  }
4384
+ function insertChannelMidpoints(sorted, minGap = 8) {
4385
+ const result = [];
4386
+ for (let i = 0; i < sorted.length - 1; i++) {
4387
+ const a = sorted[i];
4388
+ const b = sorted[i + 1];
4389
+ result.push(a);
4390
+ if (b - a > minGap) {
4391
+ result.push((a + b) / 2);
4392
+ }
4393
+ }
4394
+ result.push(sorted[sorted.length - 1]);
4395
+ return result.sort((a, b) => a - b);
4396
+ }
4059
4397
  function buildGraph(xs, ys) {
4060
4398
  const nodes = [];
4061
4399
  const nodeIndex = /* @__PURE__ */ new Map();
@@ -4218,10 +4556,36 @@ function areCollinear(a, b, c) {
4218
4556
  }
4219
4557
 
4220
4558
  // src/routing/routes.ts
4559
+ function checkBacktracking(points, source, target, diagnostics) {
4560
+ if (points.length < 2) return;
4561
+ const direct = Math.hypot(target.x - source.x, target.y - source.y);
4562
+ if (direct <= 0) return;
4563
+ let routeLen = 0;
4564
+ for (let i = 0; i < points.length - 1; i++) {
4565
+ const a = points[i];
4566
+ const b = points[i + 1];
4567
+ routeLen += Math.hypot(b.x - a.x, b.y - a.y);
4568
+ }
4569
+ const threshold = 10;
4570
+ if (routeLen > direct * threshold) {
4571
+ diagnostics.push({
4572
+ severity: "warning",
4573
+ code: "routing.backtracking_excessive",
4574
+ message: `Route length ${Math.round(routeLen)} px exceeds ${threshold}\xD7 direct distance ${Math.round(direct)} px.`,
4575
+ detail: {
4576
+ routeLength: Math.round(routeLen),
4577
+ directDistance: Math.round(direct),
4578
+ threshold
4579
+ }
4580
+ });
4581
+ }
4582
+ }
4221
4583
  function routeEdge(input) {
4222
4584
  const diagnostics = [];
4223
4585
  const softObstacles = input.obstacles ?? [];
4224
4586
  const hardObstacles = input.hardObstacles ?? [];
4587
+ const softObstacleIndex = input.obstacleIndex ?? createBoxSpatialIndex(indexedBoxes(softObstacles));
4588
+ const hardObstacleIndex = input.hardObstacleIndex ?? createBoxSpatialIndex(indexedBoxes(hardObstacles));
4225
4589
  const maxAttempts = input.maxRoutingAttempts ?? 5;
4226
4590
  const defaultAnchors = defaultAnchorsForGeometry(
4227
4591
  input.source.box,
@@ -4243,9 +4607,11 @@ function routeEdge(input) {
4243
4607
  [source, target],
4244
4608
  softObstacles,
4245
4609
  hardObstacles,
4246
- diagnostics
4610
+ diagnostics,
4611
+ softObstacleIndex,
4612
+ hardObstacleIndex
4247
4613
  );
4248
- if (routeCrossesBoxes(points, hardObstacles)) {
4614
+ if (routeCrossesBoxes(points, hardObstacles, hardObstacleIndex)) {
4249
4615
  diagnostics.push({
4250
4616
  severity: "error",
4251
4617
  code: "routing.evidence.crossing_forbidden",
@@ -4253,7 +4619,7 @@ function routeEdge(input) {
4253
4619
  });
4254
4620
  return { points, diagnostics };
4255
4621
  }
4256
- if (routeCrossesBoxes(points, softObstacles)) {
4622
+ if (routeCrossesBoxes(points, softObstacles, softObstacleIndex)) {
4257
4623
  diagnostics.push({
4258
4624
  severity: "warning",
4259
4625
  code: "routing.obstacle.unavoidable",
@@ -4292,9 +4658,16 @@ function routeEdge(input) {
4292
4658
  path,
4293
4659
  softObstacles,
4294
4660
  hardObstacles,
4295
- diagnostics
4661
+ diagnostics,
4662
+ softObstacleIndex,
4663
+ hardObstacleIndex
4296
4664
  );
4297
- if (!routeIntersectsObstacles(finalized, softObstacles) && !routeIntersectsObstacles(finalized, hardObstacles)) {
4665
+ if (!routeIntersectsObstacles(
4666
+ finalized,
4667
+ softObstacles,
4668
+ softObstacleIndex
4669
+ ) && !routeIntersectsObstacles(finalized, hardObstacles, hardObstacleIndex)) {
4670
+ checkBacktracking(finalized, source, target, diagnostics);
4298
4671
  return { points: finalized, diagnostics };
4299
4672
  }
4300
4673
  }
@@ -4334,23 +4707,41 @@ function routeEdge(input) {
4334
4707
  }
4335
4708
  );
4336
4709
  for (const candidate of candidateRoutes) {
4337
- if (!routeIntersectsObstacles(candidate.points, softObstacles) && !routeIntersectsObstacles(candidate.points, hardObstacles) && !routeIntersectsEndpointInteriors(
4710
+ if (!routeIntersectsObstacles(candidate.points, softObstacles) && !routeIntersectsObstacles(
4711
+ candidate.points,
4712
+ softObstacles,
4713
+ softObstacleIndex
4714
+ ) && !routeIntersectsObstacles(
4715
+ candidate.points,
4716
+ hardObstacles,
4717
+ hardObstacleIndex
4718
+ ) && !routeIntersectsEndpointInteriors(
4338
4719
  candidate.points,
4339
4720
  candidate.endpointObstacles
4340
4721
  )) {
4341
- return {
4342
- points: finalizeRoute(
4343
- candidate.points,
4344
- softObstacles,
4345
- hardObstacles,
4346
- diagnostics
4347
- ),
4722
+ const finalizedClean = finalizeRoute(
4723
+ candidate.points,
4724
+ softObstacles,
4725
+ hardObstacles,
4726
+ diagnostics,
4727
+ softObstacleIndex,
4728
+ hardObstacleIndex
4729
+ );
4730
+ checkBacktracking(
4731
+ finalizedClean,
4732
+ candidate.points[0],
4733
+ candidate.points[candidate.points.length - 1],
4348
4734
  diagnostics
4349
- };
4735
+ );
4736
+ return { points: finalizedClean, diagnostics };
4350
4737
  }
4351
4738
  }
4352
4739
  const hardClearCandidate = candidateRoutes.find(
4353
- (candidate) => !routeIntersectsObstacles(candidate.points, hardObstacles) && !routeIntersectsEndpointInteriors(
4740
+ (candidate) => !routeIntersectsObstacles(
4741
+ candidate.points,
4742
+ hardObstacles,
4743
+ hardObstacleIndex
4744
+ ) && !routeIntersectsEndpointInteriors(
4354
4745
  candidate.points,
4355
4746
  candidate.endpointObstacles
4356
4747
  )
@@ -4501,13 +4892,21 @@ function routeEdge(input) {
4501
4892
  diagnostics
4502
4893
  };
4503
4894
  }
4504
- function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
4895
+ function finalizeRoute(points, softObstacles, hardObstacles, diagnostics, softObstacleIndex, hardObstacleIndex) {
4505
4896
  const simplified = simplifyRoute2(points);
4506
4897
  if (simplified.length >= 3) {
4507
4898
  return simplified;
4508
4899
  }
4509
- const crossesHardObstacles = routeCrossesBoxes(simplified, hardObstacles);
4510
- const crossesSoftObstacles = routeCrossesBoxes(simplified, softObstacles);
4900
+ const crossesHardObstacles = routeCrossesBoxes(
4901
+ simplified,
4902
+ hardObstacles,
4903
+ hardObstacleIndex
4904
+ );
4905
+ const crossesSoftObstacles = routeCrossesBoxes(
4906
+ simplified,
4907
+ softObstacles,
4908
+ softObstacleIndex
4909
+ );
4511
4910
  if (!crossesHardObstacles && !crossesSoftObstacles) {
4512
4911
  return simplified;
4513
4912
  }
@@ -4515,8 +4914,16 @@ function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
4515
4914
  ...softObstacles,
4516
4915
  ...hardObstacles
4517
4916
  ]);
4518
- const expandedCrossesHard = routeCrossesBoxes(expanded, hardObstacles);
4519
- const expandedCrossesSoft = routeCrossesBoxes(expanded, softObstacles);
4917
+ const expandedCrossesHard = routeCrossesBoxes(
4918
+ expanded,
4919
+ hardObstacles,
4920
+ hardObstacleIndex
4921
+ );
4922
+ const expandedCrossesSoft = routeCrossesBoxes(
4923
+ expanded,
4924
+ softObstacles,
4925
+ softObstacleIndex
4926
+ );
4520
4927
  if (expandedCrossesHard || expandedCrossesSoft) {
4521
4928
  diagnostics.push({
4522
4929
  severity: expandedCrossesHard ? "error" : "warning",
@@ -4958,15 +5365,20 @@ function sortedUniqueLanes(lanes, midpoint) {
4958
5365
  return distance === 0 ? left - right : distance;
4959
5366
  });
4960
5367
  }
4961
- function routeIntersectsObstacles(points, obstacles) {
4962
- for (let index = 0; index < points.length - 1; index += 1) {
4963
- const a = points[index];
4964
- const b = points[index + 1];
5368
+ function routeIntersectsObstacles(points, obstacles, spatialIndex) {
5369
+ for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) {
5370
+ const a = points[pointIndex];
5371
+ const b = points[pointIndex + 1];
4965
5372
  if (a === void 0 || b === void 0) {
4966
5373
  continue;
4967
5374
  }
4968
- const segment = segmentBox(a, b);
4969
- for (const obstacle of obstacles) {
5375
+ const segment = segmentBox2(a, b);
5376
+ for (const obstacle of candidateBoxesForSegment(
5377
+ obstacles,
5378
+ a,
5379
+ b,
5380
+ spatialIndex
5381
+ )) {
4970
5382
  validateBox(obstacle);
4971
5383
  if (intersectsAabb(segment, obstacle)) {
4972
5384
  return true;
@@ -4982,7 +5394,7 @@ function routeIntersectsEndpointInteriors(points, endpointInteriors) {
4982
5394
  if (a === void 0 || b === void 0) {
4983
5395
  continue;
4984
5396
  }
4985
- const segment = segmentBox(a, b);
5397
+ const segment = segmentBox2(a, b);
4986
5398
  for (const endpointInterior of endpointInteriors) {
4987
5399
  validateBox(endpointInterior);
4988
5400
  if (intersectsAabb(segment, endpointInterior)) {
@@ -4992,14 +5404,19 @@ function routeIntersectsEndpointInteriors(points, endpointInteriors) {
4992
5404
  }
4993
5405
  return false;
4994
5406
  }
4995
- function routeCrossesBoxes(points, obstacles) {
4996
- for (let index = 0; index < points.length - 1; index += 1) {
4997
- const a = points[index];
4998
- const b = points[index + 1];
5407
+ function routeCrossesBoxes(points, obstacles, spatialIndex) {
5408
+ for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) {
5409
+ const a = points[pointIndex];
5410
+ const b = points[pointIndex + 1];
4999
5411
  if (a === void 0 || b === void 0) {
5000
5412
  continue;
5001
5413
  }
5002
- for (const obstacle of obstacles) {
5414
+ for (const obstacle of candidateBoxesForSegment(
5415
+ obstacles,
5416
+ a,
5417
+ b,
5418
+ spatialIndex
5419
+ )) {
5003
5420
  validateBox(obstacle);
5004
5421
  if (segmentIntersectsBox(a, b, obstacle)) {
5005
5422
  return true;
@@ -5008,6 +5425,12 @@ function routeCrossesBoxes(points, obstacles) {
5008
5425
  }
5009
5426
  return false;
5010
5427
  }
5428
+ function candidateBoxesForSegment(obstacles, start, end, index) {
5429
+ return index === void 0 ? obstacles : querySegmentSpatialIndex(index, start, end).map((entry) => entry.box);
5430
+ }
5431
+ function indexedBoxes(obstacles) {
5432
+ return obstacles.map((box, index) => ({ id: `obstacle:${index}`, box }));
5433
+ }
5011
5434
  function segmentIntersectsBox(start, end, box) {
5012
5435
  const left = box.x;
5013
5436
  const right = box.x + box.width;
@@ -5041,7 +5464,7 @@ function segmentIntersectsBoxEdge(start, end, x1, y1, x2, y2) {
5041
5464
  const u = ((x1 - start.x) * (end.y - start.y) - (y1 - start.y) * (end.x - start.x)) / denominator;
5042
5465
  return t > 0 && t < 1 && u > 0 && u < 1;
5043
5466
  }
5044
- function segmentBox(a, b) {
5467
+ function segmentBox2(a, b) {
5045
5468
  const minX = Math.min(a.x, b.x);
5046
5469
  const minY = Math.min(a.y, b.y);
5047
5470
  return {
@@ -5055,6 +5478,217 @@ function areCollinear2(a, b, c) {
5055
5478
  return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
5056
5479
  }
5057
5480
 
5481
+ // src/solver/pipeline/quality.ts
5482
+ function scoreLayoutQuality(nodes, edges) {
5483
+ const diagnostics = [];
5484
+ const metrics = [];
5485
+ const overlapCount = countNodeOverlaps(nodes);
5486
+ const overlapScore = Math.max(0, 20 - overlapCount * 5);
5487
+ metrics.push({
5488
+ kind: "node-overlap",
5489
+ value: overlapCount,
5490
+ label: `${overlapCount} overlaps`
5491
+ });
5492
+ if (overlapCount > 0) {
5493
+ diagnostics.push({
5494
+ severity: "warning",
5495
+ code: "quality.node_overlap",
5496
+ message: `${overlapCount} node pair(s) overlap.`,
5497
+ detail: { overlapCount }
5498
+ });
5499
+ }
5500
+ const crossingCount = countEdgeCrossings(edges);
5501
+ const crossingScore = Math.max(0, 20 - crossingCount * 2);
5502
+ metrics.push({
5503
+ kind: "edge-crossing",
5504
+ value: crossingCount,
5505
+ label: `${crossingCount} crossings`
5506
+ });
5507
+ if (crossingCount > 0) {
5508
+ diagnostics.push({
5509
+ severity: "warning",
5510
+ code: "quality.edge_crossing",
5511
+ message: `${crossingCount} edge segment pair(s) cross.`,
5512
+ detail: { crossingCount }
5513
+ });
5514
+ }
5515
+ const totalBends = countTotalBends(edges);
5516
+ const bendScore = Math.max(0, 20 - totalBends * 0.5);
5517
+ metrics.push({
5518
+ kind: "bend-count",
5519
+ value: totalBends,
5520
+ label: `${totalBends} bends`
5521
+ });
5522
+ const backtrackCount = countBacktrackingEdges(edges);
5523
+ const backtrackScore = Math.max(0, 20 - backtrackCount * 5);
5524
+ metrics.push({
5525
+ kind: "route-backtrack",
5526
+ value: backtrackCount,
5527
+ label: `${backtrackCount} backtracking`
5528
+ });
5529
+ if (backtrackCount > 0) {
5530
+ diagnostics.push({
5531
+ severity: "warning",
5532
+ code: "quality.route_backtrack",
5533
+ message: `${backtrackCount} edge(s) are excessively long (>3\xD7 direct).`,
5534
+ detail: { backtrackCount }
5535
+ });
5536
+ }
5537
+ const labelCollisions = countLabelCollisions(nodes, edges);
5538
+ const labelScore = Math.max(0, 20 - labelCollisions * 3);
5539
+ metrics.push({
5540
+ kind: "label-collision",
5541
+ value: labelCollisions,
5542
+ label: `${labelCollisions} label collisions`
5543
+ });
5544
+ const score = Math.max(
5545
+ 0,
5546
+ Math.min(
5547
+ 100,
5548
+ overlapScore + crossingScore + bendScore + backtrackScore + labelScore
5549
+ )
5550
+ );
5551
+ return { metrics, score, diagnostics };
5552
+ }
5553
+ function countNodeOverlaps(nodes) {
5554
+ let count = 0;
5555
+ for (let i = 0; i < nodes.length; i++) {
5556
+ for (let j = i + 1; j < nodes.length; j++) {
5557
+ if (intersectsAabb(nodes[i].box, nodes[j].box)) {
5558
+ count++;
5559
+ }
5560
+ }
5561
+ }
5562
+ return count;
5563
+ }
5564
+ function countEdgeCrossings(edges) {
5565
+ let count = 0;
5566
+ for (let i = 0; i < edges.length; i++) {
5567
+ const aPts = edges[i].points;
5568
+ for (let j = i + 1; j < edges.length; j++) {
5569
+ const bPts = edges[j].points;
5570
+ for (let ai = 0; ai < aPts.length - 1; ai++) {
5571
+ for (let bi = 0; bi < bPts.length - 1; bi++) {
5572
+ if (segmentsIntersect(
5573
+ aPts[ai],
5574
+ aPts[ai + 1],
5575
+ bPts[bi],
5576
+ bPts[bi + 1]
5577
+ )) {
5578
+ count++;
5579
+ }
5580
+ }
5581
+ }
5582
+ }
5583
+ }
5584
+ return count;
5585
+ }
5586
+ function segmentsIntersect(a, b, c, d) {
5587
+ const d1 = cross(c, d, a);
5588
+ const d2 = cross(c, d, b);
5589
+ const d3 = cross(a, b, c);
5590
+ const d4 = cross(a, b, d);
5591
+ return (d1 > 0 && d2 < 0 || d1 < 0 && d2 > 0) && (d3 > 0 && d4 < 0 || d3 < 0 && d4 > 0);
5592
+ }
5593
+ function cross(o, a, b) {
5594
+ return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
5595
+ }
5596
+ function countTotalBends(edges) {
5597
+ const sign = (n) => n > 0 ? 1 : n < 0 ? -1 : 0;
5598
+ let bends = 0;
5599
+ for (const e of edges) {
5600
+ if (e.points.length < 3) continue;
5601
+ for (let i = 1; i < e.points.length - 1; i++) {
5602
+ const prev = e.points[i - 1];
5603
+ const curr = e.points[i];
5604
+ const next = e.points[i + 1];
5605
+ const dx1 = curr.x - prev.x;
5606
+ const dy1 = curr.y - prev.y;
5607
+ const dx2 = next.x - curr.x;
5608
+ const dy2 = next.y - curr.y;
5609
+ if (sign(dx1) !== sign(dx2) || sign(dy1) !== sign(dy2)) {
5610
+ bends++;
5611
+ }
5612
+ }
5613
+ }
5614
+ return bends;
5615
+ }
5616
+ function countBacktrackingEdges(edges) {
5617
+ let count = 0;
5618
+ for (const e of edges) {
5619
+ if (e.points.length < 2) continue;
5620
+ const first = e.points[0];
5621
+ const last = e.points[e.points.length - 1];
5622
+ const direct = Math.hypot(last.x - first.x, last.y - first.y);
5623
+ if (direct <= 0) continue;
5624
+ let routeLen = 0;
5625
+ for (let i = 0; i < e.points.length - 1; i++) {
5626
+ routeLen += Math.hypot(
5627
+ e.points[i + 1].x - e.points[i].x,
5628
+ e.points[i + 1].y - e.points[i].y
5629
+ );
5630
+ }
5631
+ if (routeLen > direct * 3) count++;
5632
+ }
5633
+ return count;
5634
+ }
5635
+ function countLabelCollisions(nodes, edges) {
5636
+ let count = 0;
5637
+ const nodeBoxes = /* @__PURE__ */ new Map();
5638
+ for (const n of nodes) {
5639
+ nodeBoxes.set(n.id, n.box);
5640
+ if (n.label?.text !== void 0) {
5641
+ const lw = n.label.text.length * 8;
5642
+ const labelBox = {
5643
+ x: n.box.x + n.box.width / 2 - lw / 2,
5644
+ y: n.box.y - 8,
5645
+ width: lw,
5646
+ height: 14
5647
+ };
5648
+ for (const [id, box] of nodeBoxes) {
5649
+ if (id === n.id) continue;
5650
+ if (intersectsAabb(labelBox, box)) count++;
5651
+ }
5652
+ for (const e of edges) {
5653
+ for (let i = 0; i < e.points.length - 1; i++) {
5654
+ if (segmentIntersectsBox2(e.points[i], e.points[i + 1], labelBox)) {
5655
+ count++;
5656
+ break;
5657
+ }
5658
+ }
5659
+ }
5660
+ }
5661
+ }
5662
+ return count;
5663
+ }
5664
+ function segmentIntersectsBox2(start, end, box) {
5665
+ const left = box.x;
5666
+ const right = box.x + box.width;
5667
+ const top = box.y;
5668
+ const bottom = box.y + box.height;
5669
+ if (start.x > left && start.x < right && start.y > top && start.y < bottom || end.x > left && end.x < right && end.y > top && end.y < bottom)
5670
+ return true;
5671
+ if (start.x === end.x) {
5672
+ return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
5673
+ }
5674
+ if (start.y === end.y) {
5675
+ return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
5676
+ }
5677
+ return edgeIntersect(start, end, left, top, right, top) || edgeIntersect(start, end, right, top, right, bottom) || edgeIntersect(start, end, right, bottom, left, bottom) || edgeIntersect(start, end, left, bottom, left, top);
5678
+ }
5679
+ function rangesOverlap3(a, b, min, max) {
5680
+ const lo = Math.min(a, b);
5681
+ const hi = Math.max(a, b);
5682
+ return hi > min && lo < max;
5683
+ }
5684
+ function edgeIntersect(start, end, x1, y1, x2, y2) {
5685
+ const denom = (end.x - start.x) * (y2 - y1) - (end.y - start.y) * (x2 - x1);
5686
+ if (denom === 0) return false;
5687
+ const t = ((start.x - x1) * (y2 - y1) - (start.y - y1) * (x2 - x1)) / denom;
5688
+ const u = ((start.x - x1) * (end.y - start.y) - (start.y - y1) * (end.x - start.x)) / denom;
5689
+ return t > 0 && t < 1 && u > 0 && u < 1;
5690
+ }
5691
+
5058
5692
  // src/solver/solve.ts
5059
5693
  var DEFAULT_MATRIX_CELL_SIZE2 = { width: 120, height: 36 };
5060
5694
  var DEFAULT_TABLE_CELL_SIZE2 = { width: 128, height: 34 };
@@ -5113,17 +5747,16 @@ function solveDiagram(diagram, options = {}) {
5113
5747
  (swimlane) => enhanceSwimlaneCjkTypography(swimlane, cjkTypography, diagnostics)
5114
5748
  );
5115
5749
  const constraints = stableByConstraintId(diagram.constraints);
5116
- const layout2 = runDagreInitialLayout({
5750
+ const initialLayoutMode = options.initialLayout ?? "dagre";
5751
+ const layout2 = runInitialLayout({
5752
+ mode: initialLayoutMode,
5753
+ componentAware: options.maxStackDepth === void 0,
5117
5754
  direction: diagram.direction,
5118
- nodes: styledNodes.map((node) => ({ id: node.id, size: node.size })),
5119
- edges: styledEdges.map((edge) => ({
5120
- id: edge.id,
5121
- sourceId: edge.source.nodeId,
5122
- targetId: edge.target.nodeId
5123
- }))
5755
+ nodes: styledNodes,
5756
+ edges: styledEdges
5124
5757
  });
5125
5758
  diagnostics.push(...layout2.diagnostics);
5126
- const initialNodeBoxes = wrapVerticalStackIfNeeded(
5759
+ const initialNodeBoxes = initialLayoutMode === "positions" ? layout2.boxes : wrapVerticalStackIfNeeded(
5127
5760
  layout2.boxes,
5128
5761
  styledNodes,
5129
5762
  styledEdges,
@@ -5383,6 +6016,84 @@ function solveDiagram(diagram, options = {}) {
5383
6016
  function solveDiagramSafe(diagram, options = {}) {
5384
6017
  return solveDiagram(diagram, { ...options, prefitLabelSize: true });
5385
6018
  }
6019
+ function runInitialLayout(input) {
6020
+ if (input.mode === "positions") {
6021
+ return runPositionSeededInitialLayout(input);
6022
+ }
6023
+ const runAutoLayout = input.componentAware ? runComponentAwareDagreInitialLayout : runDagreInitialLayout;
6024
+ return runAutoLayout({
6025
+ direction: input.direction,
6026
+ nodes: input.nodes.map((node) => ({ id: node.id, size: node.size })),
6027
+ edges: input.edges.map((edge) => ({
6028
+ id: edge.id,
6029
+ sourceId: edge.source.nodeId,
6030
+ targetId: edge.target.nodeId
6031
+ }))
6032
+ });
6033
+ }
6034
+ function runPositionSeededInitialLayout(input) {
6035
+ const diagnostics = [];
6036
+ const boxes = /* @__PURE__ */ new Map();
6037
+ const autoNodes = [];
6038
+ for (const node of input.nodes) {
6039
+ if (!isValidInitialDimension(node.size.width) || !isValidInitialDimension(node.size.height)) {
6040
+ diagnostics.push({
6041
+ severity: "error",
6042
+ code: "layout.node-size.invalid",
6043
+ message: `Node ${node.id} has invalid layout dimensions.`,
6044
+ path: ["nodes", node.id, "size"],
6045
+ detail: { nodeId: node.id }
6046
+ });
6047
+ continue;
6048
+ }
6049
+ if (node.position === void 0) {
6050
+ autoNodes.push(node);
6051
+ continue;
6052
+ }
6053
+ if (!isFiniteInitialPoint(node.position)) {
6054
+ diagnostics.push({
6055
+ severity: "error",
6056
+ code: "layout.node-position.invalid",
6057
+ message: `Node ${node.id} has an invalid seeded position.`,
6058
+ path: ["nodes", node.id, "position"],
6059
+ detail: { nodeId: node.id }
6060
+ });
6061
+ continue;
6062
+ }
6063
+ boxes.set(node.id, {
6064
+ x: node.position.x,
6065
+ y: node.position.y,
6066
+ width: node.size.width,
6067
+ height: node.size.height
6068
+ });
6069
+ }
6070
+ if (autoNodes.length === 0) {
6071
+ return { boxes, diagnostics };
6072
+ }
6073
+ const autoNodeIds = new Set(autoNodes.map((node) => node.id));
6074
+ const autoLayout = runComponentAwareDagreInitialLayout({
6075
+ direction: input.direction,
6076
+ nodes: autoNodes.map((node) => ({ id: node.id, size: node.size })),
6077
+ edges: input.edges.filter(
6078
+ (edge) => autoNodeIds.has(edge.source.nodeId) && autoNodeIds.has(edge.target.nodeId)
6079
+ ).map((edge) => ({
6080
+ id: edge.id,
6081
+ sourceId: edge.source.nodeId,
6082
+ targetId: edge.target.nodeId
6083
+ }))
6084
+ });
6085
+ diagnostics.push(...autoLayout.diagnostics);
6086
+ for (const [id, box] of autoLayout.boxes) {
6087
+ boxes.set(id, box);
6088
+ }
6089
+ return { boxes, diagnostics };
6090
+ }
6091
+ function isValidInitialDimension(value) {
6092
+ return Number.isFinite(value) && value >= 0;
6093
+ }
6094
+ function isFiniteInitialPoint(point2) {
6095
+ return Number.isFinite(point2.x) && Number.isFinite(point2.y);
6096
+ }
5386
6097
  function prefitNodeLabelSize(node, options, diagnostics) {
5387
6098
  if (node.label === void 0) {
5388
6099
  return node;
@@ -7134,6 +7845,10 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7134
7845
  const coordinatedNodeById = new Map(
7135
7846
  coordinatedNodes.map((node) => [node.id, node])
7136
7847
  );
7848
+ const nodeObstacleIndex = createBoxSpatialIndex(
7849
+ obstacles.map((box, index) => ({ id: `node-obstacle:${index}`, box })),
7850
+ options.routingGutter ?? 160
7851
+ );
7137
7852
  for (const edge of edges) {
7138
7853
  const source = nodes.get(edge.source.nodeId);
7139
7854
  const target = nodes.get(edge.target.nodeId);
@@ -7154,6 +7869,14 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7154
7869
  const sourcePort = coordinatedNodeById.get(edge.source.nodeId)?.ports?.find((port) => port.id === edge.source.portId);
7155
7870
  const targetPort = coordinatedNodeById.get(edge.target.nodeId)?.ports?.find((port) => port.id === edge.target.portId);
7156
7871
  const routeTextObstacles = textObstacles.filter((annotation) => !isEdgeConnectedTextAnnotation(edge, annotation)).map((annotation) => annotation.box);
7872
+ const corridor = edgeCorridorBox(
7873
+ source.box,
7874
+ target.box,
7875
+ options.routingGutter ?? 160
7876
+ );
7877
+ const routeNodeObstacles = queryBoxSpatialIndex(nodeObstacleIndex, corridor).map((entry) => entry.box).filter(
7878
+ (obstacle) => !sameBox(obstacle, source.obstacleBox) && !sameBox(obstacle, target.obstacleBox)
7879
+ );
7157
7880
  const route = routeEdge({
7158
7881
  kind: options.routeKind ?? "orthogonal",
7159
7882
  direction,
@@ -7162,9 +7885,7 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7162
7885
  ...edge.source.anchor === void 0 ? {} : { sourceAnchor: edge.source.anchor },
7163
7886
  ...edge.target.anchor === void 0 ? {} : { targetAnchor: edge.target.anchor },
7164
7887
  obstacles: [
7165
- ...obstacles.filter(
7166
- (obstacle) => obstacle !== source.obstacleBox && obstacle !== target.obstacleBox
7167
- ),
7888
+ ...routeNodeObstacles,
7168
7889
  ...softObstacles,
7169
7890
  ...groupObstaclesForEdge(edge, groups, options.obstacleMargin ?? 0),
7170
7891
  ...routeTextObstacles
@@ -7185,6 +7906,19 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7185
7906
  }
7186
7907
  return coordinated;
7187
7908
  }
7909
+ function edgeCorridorBox(source, target, margin) {
7910
+ const minX = Math.min(source.x, target.x);
7911
+ const minY = Math.min(source.y, target.y);
7912
+ const maxX = Math.max(source.x + source.width, target.x + target.width);
7913
+ const maxY = Math.max(source.y + source.height, target.y + target.height);
7914
+ return expandBoxForQuery(
7915
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
7916
+ margin
7917
+ );
7918
+ }
7919
+ function sameBox(first, second) {
7920
+ return first.x === second.x && first.y === second.y && first.width === second.width && first.height === second.height;
7921
+ }
7188
7922
  function isEdgeConnectedTextAnnotation(edge, annotation) {
7189
7923
  switch (annotation.surfaceKind) {
7190
7924
  case "edge-label":
@@ -7642,13 +8376,13 @@ function routeIntersectsTextBox(points, box) {
7642
8376
  if (start === void 0 || end === void 0) {
7643
8377
  continue;
7644
8378
  }
7645
- if (segmentIntersectsBox2(start, end, box)) {
8379
+ if (segmentIntersectsBox3(start, end, box)) {
7646
8380
  return true;
7647
8381
  }
7648
8382
  }
7649
8383
  return false;
7650
8384
  }
7651
- function segmentIntersectsBox2(start, end, box) {
8385
+ function segmentIntersectsBox3(start, end, box) {
7652
8386
  const left = box.x;
7653
8387
  const right = box.x + box.width;
7654
8388
  const top = box.y;
@@ -7657,17 +8391,17 @@ function segmentIntersectsBox2(start, end, box) {
7657
8391
  return true;
7658
8392
  }
7659
8393
  if (start.x === end.x) {
7660
- return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
8394
+ return start.x > left && start.x < right && rangesOverlap4(start.y, end.y, top, bottom);
7661
8395
  }
7662
8396
  if (start.y === end.y) {
7663
- return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
8397
+ return start.y > top && start.y < bottom && rangesOverlap4(start.x, end.x, left, right);
7664
8398
  }
7665
8399
  return segmentIntersectsBoxEdge2(start, end, left, top, right, top) || segmentIntersectsBoxEdge2(start, end, right, top, right, bottom) || segmentIntersectsBoxEdge2(start, end, right, bottom, left, bottom) || segmentIntersectsBoxEdge2(start, end, left, bottom, left, top);
7666
8400
  }
7667
8401
  function pointInsideBox2(point2, box) {
7668
8402
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
7669
8403
  }
7670
- function rangesOverlap3(a, b, min, max) {
8404
+ function rangesOverlap4(a, b, min, max) {
7671
8405
  const low = Math.min(a, b);
7672
8406
  const high = Math.max(a, b);
7673
8407
  return high > min && low < max;
@@ -8030,6 +8764,30 @@ function groupReferenceMissing(groupId, referenceKind, id) {
8030
8764
  detail: id === void 0 ? { groupId } : { groupId, id }
8031
8765
  };
8032
8766
  }
8767
+ function createDefaultPipeline() {
8768
+ return new LayoutPipeline().addPhase({
8769
+ name: "solve-diagram",
8770
+ run(state) {
8771
+ const result = solveDiagram(state.diagram, state.options);
8772
+ state.diagnostics.push(...result.diagnostics);
8773
+ state.bounds = result.bounds;
8774
+ state.degraded = result.degraded ?? false;
8775
+ state.coordinatedNodes = result.nodes;
8776
+ state.coordinatedEdges = result.edges;
8777
+ }
8778
+ }).addPhase({
8779
+ name: "quality-score",
8780
+ run(state) {
8781
+ if (!state.options.qualityScore) return;
8782
+ const report = scoreLayoutQuality(
8783
+ state.coordinatedNodes,
8784
+ state.coordinatedEdges
8785
+ );
8786
+ state.qualityReport = report;
8787
+ state.diagnostics.push(...report.diagnostics);
8788
+ }
8789
+ });
8790
+ }
8033
8791
 
8034
8792
  // src/dsl/render.ts
8035
8793
  function resolveOutputFormat(cliFormat, dslFormat) {
@@ -8073,6 +8831,7 @@ function renderDiagramDsl(source, options = {}) {
8073
8831
  return { diagnostics };
8074
8832
  }
8075
8833
  const solved = solveDiagram(normalized.diagram, {
8834
+ ...solveInitialLayoutOption(normalized.diagram.metadata?.initialLayout),
8076
8835
  routeKind: normalized.diagram.metadata?.routeKind === "straight" ? "straight" : normalized.diagram.metadata?.routeKind === "obstacle-avoiding" ? "obstacle-avoiding" : "orthogonal",
8077
8836
  ...solvePortShiftingOption(normalized.diagram.metadata?.portShifting),
8078
8837
  ...options.textMeasurer === void 0 ? {} : { textMeasurer: options.textMeasurer }
@@ -8114,6 +8873,9 @@ function renderDiagramDsl(source, options = {}) {
8114
8873
  function toSolveDiagnostic(diagnostic) {
8115
8874
  return { ...diagnostic, layer: "solve" };
8116
8875
  }
8876
+ function solveInitialLayoutOption(value) {
8877
+ return value === "positions" ? { initialLayout: "positions" } : {};
8878
+ }
8117
8879
  function solvePortShiftingOption(value) {
8118
8880
  if (!isJsonObject(value)) {
8119
8881
  return {};
@@ -8268,6 +9030,7 @@ exports.DEFAULT_DSL_MAX_BYTES = DEFAULT_DSL_MAX_BYTES;
8268
9030
  exports.DELIVERABILITY_DIAGNOSTIC_CODES = DELIVERABILITY_DIAGNOSTIC_CODES;
8269
9031
  exports.DeterministicTextMeasurer = DeterministicTextMeasurer;
8270
9032
  exports.LabelFitter = LabelFitter;
9033
+ exports.LayoutPipeline = LayoutPipeline;
8271
9034
  exports.PretextTextMeasurer = PretextTextMeasurer;
8272
9035
  exports.applyLayoutConstraints = applyLayoutConstraints;
8273
9036
  exports.assertFiniteNonNegative = assertFiniteNonNegative;
@@ -8277,8 +9040,11 @@ exports.canonicalize = canonicalize;
8277
9040
  exports.computeArrowhead = computeArrowhead;
8278
9041
  exports.computeContainerGeometry = computeContainerGeometry;
8279
9042
  exports.computeShapeGeometry = computeShapeGeometry;
9043
+ exports.createBoxSpatialIndex = createBoxSpatialIndex;
9044
+ exports.createDefaultPipeline = createDefaultPipeline;
8280
9045
  exports.createDefaultTextMeasurer = createDefaultTextMeasurer;
8281
9046
  exports.expandBox = expandBox;
9047
+ exports.expandBoxForQuery = expandBoxForQuery;
8282
9048
  exports.exportExcalidraw = exportExcalidraw;
8283
9049
  exports.exportSvg = exportSvg;
8284
9050
  exports.fitLabel = fitLabel;
@@ -8288,12 +9054,16 @@ exports.intersectsAabb = intersectsAabb;
8288
9054
  exports.isPretextRuntimeAvailable = isPretextRuntimeAvailable;
8289
9055
  exports.normalizeDiagramDsl = normalizeDiagramDsl;
8290
9056
  exports.normalizeInsets = normalizeInsets;
9057
+ exports.overlapArea = overlapArea;
8291
9058
  exports.parseDiagramDsl = parseDiagramDsl;
8292
9059
  exports.parseEdgeShorthand = parseEdgeShorthand;
9060
+ exports.queryBoxSpatialIndex = queryBoxSpatialIndex;
9061
+ exports.querySegmentSpatialIndex = querySegmentSpatialIndex;
8293
9062
  exports.renderDiagramDsl = renderDiagramDsl;
8294
9063
  exports.resolveLineHeight = resolveLineHeight;
8295
9064
  exports.resolveOutputFormat = resolveOutputFormat;
8296
9065
  exports.routeEdge = routeEdge;
9066
+ exports.runComponentAwareDagreInitialLayout = runComponentAwareDagreInitialLayout;
8297
9067
  exports.runDagreInitialLayout = runDagreInitialLayout;
8298
9068
  exports.simplifyRoute = simplifyRoute2;
8299
9069
  exports.solveDiagram = solveDiagram;