@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.js CHANGED
@@ -76,6 +76,17 @@ function intersectsAabb(a, b) {
76
76
  validateBox(b, "b");
77
77
  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;
78
78
  }
79
+ function overlapArea(first, second) {
80
+ const x = Math.max(
81
+ 0,
82
+ Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x)
83
+ );
84
+ const y = Math.max(
85
+ 0,
86
+ Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y)
87
+ );
88
+ return x * y;
89
+ }
79
90
  function validateMargin(value, label) {
80
91
  validateFinite(value, label);
81
92
  if (value < 0) {
@@ -88,6 +99,72 @@ function validateFinite(value, label) {
88
99
  }
89
100
  }
90
101
 
102
+ // src/geometry/spatial-index.ts
103
+ function createBoxSpatialIndex(entries, cellSize = 128) {
104
+ const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : 128;
105
+ const boxes = /* @__PURE__ */ new Map();
106
+ const mutableCells = /* @__PURE__ */ new Map();
107
+ for (const entry of entries) {
108
+ boxes.set(entry.id, { ...entry.box });
109
+ for (const key of cellKeysForBox(entry.box, normalizedCellSize)) {
110
+ const ids = mutableCells.get(key) ?? [];
111
+ ids.push(entry.id);
112
+ mutableCells.set(key, ids);
113
+ }
114
+ }
115
+ const cells = /* @__PURE__ */ new Map();
116
+ for (const [key, ids] of mutableCells) {
117
+ cells.set(key, [...new Set(ids)].sort());
118
+ }
119
+ return { cellSize: normalizedCellSize, entries: boxes, cells };
120
+ }
121
+ function queryBoxSpatialIndex(index, box) {
122
+ const ids = /* @__PURE__ */ new Set();
123
+ for (const key of cellKeysForBox(box, index.cellSize)) {
124
+ for (const id of index.cells.get(key) ?? []) {
125
+ ids.add(id);
126
+ }
127
+ }
128
+ return [...ids].sort().flatMap((id) => {
129
+ const candidate = index.entries.get(id);
130
+ return candidate !== void 0 && intersectsAabb(candidate, box) ? [{ id, box: candidate }] : [];
131
+ });
132
+ }
133
+ function querySegmentSpatialIndex(index, start, end) {
134
+ return queryBoxSpatialIndex(index, segmentBox(start, end));
135
+ }
136
+ function expandBoxForQuery(box, margin) {
137
+ return {
138
+ x: box.x - margin,
139
+ y: box.y - margin,
140
+ width: box.width + margin * 2,
141
+ height: box.height + margin * 2
142
+ };
143
+ }
144
+ function cellKeysForBox(box, cellSize) {
145
+ const minCol = Math.floor(box.x / cellSize);
146
+ const maxCol = Math.floor((box.x + Math.max(1, box.width)) / cellSize);
147
+ const minRow = Math.floor(box.y / cellSize);
148
+ const maxRow = Math.floor((box.y + Math.max(1, box.height)) / cellSize);
149
+ const keys = [];
150
+ for (let col = minCol; col <= maxCol; col += 1) {
151
+ for (let row = minRow; row <= maxRow; row += 1) {
152
+ keys.push(`${col}:${row}`);
153
+ }
154
+ }
155
+ return keys;
156
+ }
157
+ function segmentBox(start, end) {
158
+ const x = Math.min(start.x, end.x);
159
+ const y = Math.min(start.y, end.y);
160
+ return {
161
+ x,
162
+ y,
163
+ width: Math.max(1, Math.abs(start.x - end.x)),
164
+ height: Math.max(1, Math.abs(start.y - end.y))
165
+ };
166
+ }
167
+
91
168
  // src/constraints/solver.ts
92
169
  function applyLayoutConstraints(input) {
93
170
  const diagnostics = [];
@@ -119,7 +196,12 @@ function applyLayoutConstraints(input) {
119
196
  dedupReplayDiagnostics(diagnostics, diagBefore);
120
197
  }
121
198
  removeResolvedConstraintDiagnostics(input.constraints, boxes, diagnostics);
122
- reportOverlaps(boxes, diagnostics, containmentOverlapKeys(input.constraints));
199
+ reportOverlaps(
200
+ boxes,
201
+ diagnostics,
202
+ containmentOverlapKeys(input.constraints),
203
+ locks
204
+ );
123
205
  reportIntraContainerOverflow(input, boxes, diagnostics);
124
206
  return { boxes, locks, diagnostics };
125
207
  }
@@ -285,14 +367,19 @@ function applyContainment(constraints, boxes, locks, diagnostics, reportOverflow
285
367
  if (samePosition(child, next)) {
286
368
  continue;
287
369
  }
288
- if (locks.has(childId)) {
370
+ const lock = locks.get(childId);
371
+ if (lock !== void 0) {
289
372
  if (!reportOverflow) {
290
373
  diagnostics.push({
291
374
  severity: "warning",
292
375
  code: "constraints.locked-target-not-moved",
293
376
  message: `Locked child ${childId} was not moved into containment.`,
294
377
  path: ["constraints", constraint.id ?? constraint.containerId],
295
- detail: { nodeId: childId, containerId: constraint.containerId }
378
+ detail: {
379
+ nodeId: childId,
380
+ containerId: constraint.containerId,
381
+ lockSource: lock.source
382
+ }
296
383
  });
297
384
  if (!isInside(child, content)) {
298
385
  diagnostics.push({
@@ -407,18 +494,29 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
407
494
  const secondaryAxis = axis === "x" ? "y" : "x";
408
495
  const ignoredPairs = containmentOverlapKeys(input.constraints);
409
496
  const ids = [...boxes.keys()].sort();
497
+ const index = createBoxSpatialIndex(
498
+ ids.flatMap((id) => {
499
+ const box = boxes.get(id);
500
+ return box === void 0 ? [] : [{ id, box }];
501
+ }),
502
+ spacing
503
+ );
410
504
  for (let pass = 0; pass < 2; pass += 1) {
411
505
  for (const firstId of ids) {
412
- for (const secondId of ids) {
506
+ const first = boxes.get(firstId);
507
+ if (first === void 0) {
508
+ continue;
509
+ }
510
+ const candidateIds = queryBoxSpatialIndex(index, first).map((candidate) => candidate.id).filter((id) => id > firstId).sort();
511
+ for (const secondId of candidateIds) {
413
512
  if (firstId >= secondId) {
414
513
  continue;
415
514
  }
416
515
  if (ignoredPairs.has(overlapKey(firstId, secondId))) {
417
516
  continue;
418
517
  }
419
- const first = boxes.get(firstId);
420
518
  const second = boxes.get(secondId);
421
- if (first === void 0 || second === void 0 || !intersectsAabb(first, second)) {
519
+ if (second === void 0 || !intersectsAabb(first, second)) {
422
520
  continue;
423
521
  }
424
522
  const firstLocked = locks.has(firstId);
@@ -442,7 +540,7 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
442
540
  }
443
541
  }
444
542
  }
445
- reportOverlaps(boxes, diagnostics, ignoredPairs);
543
+ reportOverlaps(boxes, diagnostics, ignoredPairs, locks);
446
544
  }
447
545
  function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
448
546
  for (let i = diagnostics.length - 1; i >= 0; i -= 1) {
@@ -498,29 +596,56 @@ function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
498
596
  }
499
597
  }
500
598
  }
501
- function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set()) {
599
+ function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set(), locks = /* @__PURE__ */ new Map()) {
502
600
  const ids = [...boxes.keys()].sort();
503
601
  const reported = new Set(
504
602
  diagnostics.filter(
505
- (diagnostic) => diagnostic.code === "constraints.overlap.unresolved"
603
+ (diagnostic) => diagnostic.code === "constraints.overlap.unresolved" || diagnostic.code === "constraints.overlap.locked-conflict"
506
604
  ).map((diagnostic) => {
507
605
  const firstId = diagnostic.detail?.firstId;
508
606
  const secondId = diagnostic.detail?.secondId;
509
607
  return typeof firstId === "string" && typeof secondId === "string" ? overlapKey(firstId, secondId) : void 0;
510
608
  }).filter((key) => key !== void 0)
511
609
  );
610
+ const index = createBoxSpatialIndex(
611
+ ids.flatMap((id) => {
612
+ const box = boxes.get(id);
613
+ return box === void 0 ? [] : [{ id, box }];
614
+ }),
615
+ 40
616
+ );
512
617
  for (const firstId of ids) {
513
- for (const secondId of ids) {
514
- if (firstId >= secondId) {
515
- continue;
516
- }
618
+ const first = boxes.get(firstId);
619
+ if (first === void 0) {
620
+ continue;
621
+ }
622
+ const candidateIds = queryBoxSpatialIndex(index, first).map((candidate) => candidate.id).filter((id) => id > firstId).sort();
623
+ for (const secondId of candidateIds) {
517
624
  const key = overlapKey(firstId, secondId);
518
625
  if (reported.has(key) || ignoredPairs.has(key)) {
519
626
  continue;
520
627
  }
521
- const first = boxes.get(firstId);
522
628
  const second = boxes.get(secondId);
523
- if (first !== void 0 && second !== void 0 && intersectsAabb(first, second)) {
629
+ if (second !== void 0 && intersectsAabb(first, second)) {
630
+ const firstLock = locks.get(firstId);
631
+ const secondLock = locks.get(secondId);
632
+ if (firstLock !== void 0 && secondLock !== void 0) {
633
+ diagnostics.push({
634
+ severity: "warning",
635
+ code: "constraints.overlap.locked-conflict",
636
+ message: `Locked boxes ${firstId} (${firstLock.source}) and ${secondId} (${secondLock.source}) overlap and cannot be repaired.`,
637
+ path: ["boxes"],
638
+ detail: {
639
+ firstId,
640
+ secondId,
641
+ firstLockSource: firstLock.source,
642
+ secondLockSource: secondLock.source,
643
+ overlapArea: overlapArea(first, second)
644
+ }
645
+ });
646
+ reported.add(key);
647
+ continue;
648
+ }
524
649
  diagnostics.push({
525
650
  severity: "warning",
526
651
  code: "constraints.overlap.unresolved",
@@ -676,12 +801,17 @@ function setUnlockedBox(id, next, boxes, locks, diagnostics, constraint) {
676
801
  return;
677
802
  }
678
803
  if (locks.has(id) && !samePosition(current, next)) {
804
+ const lock = locks.get(id);
679
805
  diagnostics.push({
680
806
  severity: "warning",
681
807
  code: "constraints.locked-target-not-moved",
682
808
  message: `Locked target ${id} was not moved by ${constraint.kind}.`,
683
809
  path: ["constraints", constraint.id ?? id],
684
- detail: { nodeId: id, constraintKind: constraint.kind }
810
+ detail: {
811
+ nodeId: id,
812
+ constraintKind: constraint.kind,
813
+ ...lock === void 0 ? {} : { lockSource: lock.source }
814
+ }
685
815
  });
686
816
  return;
687
817
  }
@@ -850,7 +980,28 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
850
980
  if (distributable.length < 2) {
851
981
  continue;
852
982
  }
983
+ const spread = typeof input.distributeContainedChildren === "string";
984
+ let effectiveGap = minGap;
853
985
  let pos = content[axis];
986
+ if (spread) {
987
+ let totalChildSpan = 0;
988
+ for (const child of distributable) {
989
+ totalChildSpan += child.box[mainSize];
990
+ }
991
+ let reservedSpan = 0;
992
+ const contentEnd = content[axis] + content[mainSize];
993
+ for (const r of reserved) {
994
+ const rStart = Math.max(r.start, content[axis]);
995
+ const rEnd = Math.min(r.end, contentEnd);
996
+ if (rEnd > rStart) {
997
+ reservedSpan += rEnd - rStart + minGap;
998
+ }
999
+ }
1000
+ const remaining = content[mainSize] - totalChildSpan - reservedSpan - minGap * (distributable.length - 1);
1001
+ if (remaining > 0) {
1002
+ effectiveGap = minGap + remaining / (distributable.length - 1);
1003
+ }
1004
+ }
854
1005
  for (const child of distributable) {
855
1006
  pos = advancePastReserved(pos, child.box[mainSize], reserved, minGap);
856
1007
  const crossPos = content[crossAxis] + Math.max(0, (content[crossSize] - child.box[crossSize]) / 2);
@@ -869,7 +1020,7 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
869
1020
  }
870
1021
  boxes.set(child.id, clamped);
871
1022
  locks.delete(child.id);
872
- pos = clamped[axis] + clamped[mainSize] + minGap;
1023
+ pos = clamped[axis] + clamped[mainSize] + effectiveGap;
873
1024
  }
874
1025
  diagnostics.push({
875
1026
  severity: "info",
@@ -1625,6 +1776,7 @@ function normalizeDiagramDsl(dslValue, options = {}) {
1625
1776
  const measurer = options.textMeasurer ?? createDefaultTextMeasurer();
1626
1777
  const routeKind = dsl.routing?.kind ?? "orthogonal";
1627
1778
  const portShifting = normalizePortShifting(dsl.routing?.portShifting);
1779
+ const initialLayout = dsl.layout?.mode;
1628
1780
  const primaryReadingDirection = dsl.layout?.primaryReadingDirection;
1629
1781
  const matrices = normalizeMatrices(dsl);
1630
1782
  const tables = normalizeTables(dsl);
@@ -1645,6 +1797,7 @@ function normalizeDiagramDsl(dslValue, options = {}) {
1645
1797
  ...dsl.frame === void 0 ? {} : { frame: normalizeFrame(dsl.frame) },
1646
1798
  metadata: {
1647
1799
  routeKind,
1800
+ ...initialLayout === void 0 ? {} : { initialLayout },
1648
1801
  ...primaryReadingDirection === void 0 ? {} : { primaryReadingDirection },
1649
1802
  ...portShifting === void 0 ? {} : { portShifting }
1650
1803
  }
@@ -2194,6 +2347,7 @@ function point(value) {
2194
2347
  return { x: value.x, y: value.y };
2195
2348
  }
2196
2349
  var directionSchema = z.enum(["TB", "LR", "BT", "RL"]);
2350
+ var layoutModeSchema = z.enum(["dagre", "positions"]);
2197
2351
  var routeKindSchema = z.enum(["orthogonal", "straight", "obstacle-avoiding"]);
2198
2352
  var outputFormatSchema = z.enum(["svg", "excalidraw"]);
2199
2353
  var edgeStrokeStyleSchema = z.enum(["solid", "dashed"]);
@@ -2504,6 +2658,7 @@ var diagramDslSchema = z.object({
2504
2658
  direction: directionSchema.optional(),
2505
2659
  layout: z.object({
2506
2660
  direction: directionSchema.optional(),
2661
+ mode: layoutModeSchema.optional(),
2507
2662
  primaryReadingDirection: primaryReadingDirectionSchema.optional()
2508
2663
  }).optional(),
2509
2664
  routing: z.object({
@@ -2840,13 +2995,22 @@ function exportExcalidraw(diagram, options = {}) {
2840
2995
  appState: {
2841
2996
  name: options.title ?? diagram.title ?? diagram.id,
2842
2997
  viewBackgroundColor: "#ffffff",
2843
- gridSize: null
2998
+ gridSize: null,
2999
+ ...options.viewportPadding === void 0 ? {} : viewportAppState(diagram.bounds, options.viewportPadding)
2844
3000
  },
2845
3001
  files: {}
2846
3002
  };
2847
3003
  return `${JSON.stringify(scene, null, 2)}
2848
3004
  `;
2849
3005
  }
3006
+ function viewportAppState(bounds, padding) {
3007
+ const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
3008
+ return {
3009
+ scrollX: finite(-bounds.x + safePadding),
3010
+ scrollY: finite(-bounds.y + safePadding),
3011
+ zoom: { value: 1 }
3012
+ };
3013
+ }
2850
3014
  function renderGroup(group) {
2851
3015
  return {
2852
3016
  ...baseElement(`group:${group.id}`, "rectangle", group.box),
@@ -3170,6 +3334,9 @@ function exportSvg(diagram, options = {}) {
3170
3334
  return `${[
3171
3335
  `<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="${formatBoxViewBox(diagram.bounds)}">`,
3172
3336
  ...title === void 0 ? [] : [` <title>${escapeXml(title)}</title>`],
3337
+ ...options.viewportPadding === void 0 ? [] : [
3338
+ ` <metadata data-dge-viewport="${escapeAttribute(viewportMetadata(diagram.bounds, options.viewportPadding))}"></metadata>`
3339
+ ],
3173
3340
  ` <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"/>`,
3174
3341
  ...diagram.frame === void 0 ? [] : [indent(renderFrame(diagram.frame, annotations))],
3175
3342
  ...(diagram.swimlanes ?? []).flatMap(
@@ -3203,6 +3370,16 @@ function exportSvg(diagram, options = {}) {
3203
3370
  ].join("\n")}
3204
3371
  `;
3205
3372
  }
3373
+ function viewportMetadata(bounds, padding) {
3374
+ const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
3375
+ return JSON.stringify({
3376
+ x: bounds.x - safePadding,
3377
+ y: bounds.y - safePadding,
3378
+ width: bounds.width + safePadding * 2,
3379
+ height: bounds.height + safePadding * 2,
3380
+ padding: safePadding
3381
+ });
3382
+ }
3206
3383
  function renderGroup2(group) {
3207
3384
  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"/>`;
3208
3385
  }
@@ -3874,9 +4051,49 @@ function indentLines(values) {
3874
4051
  return values.map(indent);
3875
4052
  }
3876
4053
 
4054
+ // src/solver/pipeline/pipeline.ts
4055
+ var LayoutPipeline = class {
4056
+ phases = [];
4057
+ addPhase(phase) {
4058
+ this.phases.push(phase);
4059
+ return this;
4060
+ }
4061
+ addBefore(refName, phase) {
4062
+ const idx = this.phases.findIndex((p) => p.name === refName);
4063
+ if (idx === -1) throw new Error(`Phase "${refName}" not found`);
4064
+ this.phases.splice(idx, 0, phase);
4065
+ return this;
4066
+ }
4067
+ addAfter(refName, phase) {
4068
+ const idx = this.phases.findIndex((p) => p.name === refName);
4069
+ if (idx === -1) throw new Error(`Phase "${refName}" not found`);
4070
+ this.phases.splice(idx + 1, 0, phase);
4071
+ return this;
4072
+ }
4073
+ replacePhase(name, phase) {
4074
+ const idx = this.phases.findIndex((p) => p.name === name);
4075
+ if (idx === -1) throw new Error(`Phase "${name}" not found`);
4076
+ this.phases[idx] = phase;
4077
+ return this;
4078
+ }
4079
+ run(state) {
4080
+ for (const phase of this.phases) {
4081
+ const before = state.diagnostics.length;
4082
+ const start = performance.now();
4083
+ phase.run(state);
4084
+ state.phaseTrace.push({
4085
+ phase: phase.name,
4086
+ durationMs: performance.now() - start,
4087
+ diagnosticsAdded: state.diagnostics.length - before
4088
+ });
4089
+ }
4090
+ }
4091
+ };
4092
+
3877
4093
  // src/ir/diagnostics.ts
3878
4094
  var DELIVERABILITY_DIAGNOSTIC_CODES = /* @__PURE__ */ new Set([
3879
4095
  "constraints.locked-target-not-moved",
4096
+ "constraints.overlap.locked-conflict",
3880
4097
  "routing.evidence.crossing_forbidden",
3881
4098
  "routing.obstacle.unavoidable",
3882
4099
  "route_obstacle_fallback",
@@ -3888,6 +4105,7 @@ var DEFAULT_OPTIONS = {
3888
4105
  edgesep: 40,
3889
4106
  marginx: 0,
3890
4107
  marginy: 0,
4108
+ componentGap: 160,
3891
4109
  ranker: "network-simplex"
3892
4110
  };
3893
4111
  function runDagreInitialLayout(input) {
@@ -3976,9 +4194,116 @@ function runDagreInitialLayout(input) {
3976
4194
  }
3977
4195
  return { boxes, diagnostics };
3978
4196
  }
4197
+ function runComponentAwareDagreInitialLayout(input) {
4198
+ const options = { ...DEFAULT_OPTIONS, ...input.options };
4199
+ const diagnostics = reportMissingEdgeReferences(input);
4200
+ const validNodeIds = new Set(input.nodes.map((node) => node.id));
4201
+ const validEdges = input.edges.filter(
4202
+ (edge) => validNodeIds.has(edge.sourceId) && validNodeIds.has(edge.targetId)
4203
+ );
4204
+ const components = connectedComponents(input.nodes, validEdges);
4205
+ if (components.length <= 1) {
4206
+ const layout2 = runDagreInitialLayout({ ...input, edges: validEdges });
4207
+ return {
4208
+ boxes: layout2.boxes,
4209
+ diagnostics: [...diagnostics, ...layout2.diagnostics]
4210
+ };
4211
+ }
4212
+ const boxes = /* @__PURE__ */ new Map();
4213
+ let cursor = 0;
4214
+ for (const component of components) {
4215
+ const componentNodeIds = new Set(component.map((node) => node.id));
4216
+ const componentLayout = runDagreInitialLayout({
4217
+ ...input,
4218
+ nodes: component,
4219
+ edges: validEdges.filter(
4220
+ (edge) => componentNodeIds.has(edge.sourceId) && componentNodeIds.has(edge.targetId)
4221
+ )
4222
+ });
4223
+ diagnostics.push(...componentLayout.diagnostics);
4224
+ if (componentLayout.boxes.size === 0) {
4225
+ continue;
4226
+ }
4227
+ const bounds = unionBoxes([...componentLayout.boxes.values()]);
4228
+ const axis = input.direction === "LR" || input.direction === "RL" ? "x" : "y";
4229
+ const dx = axis === "x" ? cursor - bounds.x : -bounds.x;
4230
+ const dy = axis === "y" ? cursor - bounds.y : -bounds.y;
4231
+ for (const [id, box] of componentLayout.boxes) {
4232
+ boxes.set(id, { ...box, x: box.x + dx, y: box.y + dy });
4233
+ }
4234
+ cursor += (axis === "x" ? bounds.width : bounds.height) + options.componentGap;
4235
+ }
4236
+ return { boxes, diagnostics };
4237
+ }
4238
+ function reportMissingEdgeReferences(input) {
4239
+ const validNodeIds = new Set(input.nodes.map((node) => node.id));
4240
+ return input.edges.flatMap((edge) => {
4241
+ if (validNodeIds.has(edge.sourceId) && validNodeIds.has(edge.targetId)) {
4242
+ return [];
4243
+ }
4244
+ return [
4245
+ {
4246
+ severity: "error",
4247
+ code: "layout.edge-reference.missing",
4248
+ message: `Edge ${edge.id} references a missing layout node.`,
4249
+ path: ["edges", edge.id],
4250
+ detail: {
4251
+ edgeId: edge.id,
4252
+ sourceId: edge.sourceId,
4253
+ targetId: edge.targetId
4254
+ }
4255
+ }
4256
+ ];
4257
+ });
4258
+ }
3979
4259
  function isValidDimension(value) {
3980
4260
  return Number.isFinite(value) && value >= 0;
3981
4261
  }
4262
+ function connectedComponents(nodes, edges) {
4263
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
4264
+ const adjacency = new Map(nodes.map((node) => [node.id, /* @__PURE__ */ new Set()]));
4265
+ for (const edge of edges) {
4266
+ if (!nodeById.has(edge.sourceId) || !nodeById.has(edge.targetId)) {
4267
+ continue;
4268
+ }
4269
+ adjacency.get(edge.sourceId)?.add(edge.targetId);
4270
+ adjacency.get(edge.targetId)?.add(edge.sourceId);
4271
+ }
4272
+ const visited = /* @__PURE__ */ new Set();
4273
+ const components = [];
4274
+ for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) {
4275
+ if (visited.has(node.id)) {
4276
+ continue;
4277
+ }
4278
+ const ids = [];
4279
+ const stack = [node.id];
4280
+ visited.add(node.id);
4281
+ while (stack.length > 0) {
4282
+ const id = stack.pop();
4283
+ if (id === void 0) {
4284
+ continue;
4285
+ }
4286
+ ids.push(id);
4287
+ for (const neighbor of [...adjacency.get(id) ?? []].sort().reverse()) {
4288
+ if (!visited.has(neighbor)) {
4289
+ visited.add(neighbor);
4290
+ stack.push(neighbor);
4291
+ }
4292
+ }
4293
+ }
4294
+ components.push(
4295
+ ids.sort().flatMap((id) => {
4296
+ const componentNode = nodeById.get(id);
4297
+ return componentNode === void 0 ? [] : [componentNode];
4298
+ })
4299
+ );
4300
+ }
4301
+ return components.sort((a, b) => {
4302
+ const left = a[0]?.id ?? "";
4303
+ const right = b[0]?.id ?? "";
4304
+ return left.localeCompare(right);
4305
+ });
4306
+ }
3982
4307
 
3983
4308
  // src/routing/astar.ts
3984
4309
  function findObstacleFreePath(source, target, obstacles, options = {}, diagnostics) {
@@ -4021,7 +4346,7 @@ function collectXs(source, target, obstacles, margin) {
4021
4346
  for (const obs of obstacles) {
4022
4347
  raw.push(obs.x - margin - 2, obs.x + obs.width + margin + 2);
4023
4348
  }
4024
- const deduped = dedupSorted(raw);
4349
+ const deduped = insertChannelMidpoints(dedupSorted(raw));
4025
4350
  for (const v of [source.x, target.x]) {
4026
4351
  if (!deduped.includes(v)) {
4027
4352
  deduped.push(v);
@@ -4034,7 +4359,7 @@ function collectYs(source, target, obstacles, margin) {
4034
4359
  for (const obs of obstacles) {
4035
4360
  raw.push(obs.y - margin - 2, obs.y + obs.height + margin + 2);
4036
4361
  }
4037
- const deduped = dedupSorted(raw);
4362
+ const deduped = insertChannelMidpoints(dedupSorted(raw));
4038
4363
  for (const v of [source.y, target.y]) {
4039
4364
  if (!deduped.includes(v)) {
4040
4365
  deduped.push(v);
@@ -4053,6 +4378,19 @@ function dedupSorted(values) {
4053
4378
  }
4054
4379
  return result;
4055
4380
  }
4381
+ function insertChannelMidpoints(sorted, minGap = 8) {
4382
+ const result = [];
4383
+ for (let i = 0; i < sorted.length - 1; i++) {
4384
+ const a = sorted[i];
4385
+ const b = sorted[i + 1];
4386
+ result.push(a);
4387
+ if (b - a > minGap) {
4388
+ result.push((a + b) / 2);
4389
+ }
4390
+ }
4391
+ result.push(sorted[sorted.length - 1]);
4392
+ return result.sort((a, b) => a - b);
4393
+ }
4056
4394
  function buildGraph(xs, ys) {
4057
4395
  const nodes = [];
4058
4396
  const nodeIndex = /* @__PURE__ */ new Map();
@@ -4215,10 +4553,36 @@ function areCollinear(a, b, c) {
4215
4553
  }
4216
4554
 
4217
4555
  // src/routing/routes.ts
4556
+ function checkBacktracking(points, source, target, diagnostics) {
4557
+ if (points.length < 2) return;
4558
+ const direct = Math.hypot(target.x - source.x, target.y - source.y);
4559
+ if (direct <= 0) return;
4560
+ let routeLen = 0;
4561
+ for (let i = 0; i < points.length - 1; i++) {
4562
+ const a = points[i];
4563
+ const b = points[i + 1];
4564
+ routeLen += Math.hypot(b.x - a.x, b.y - a.y);
4565
+ }
4566
+ const threshold = 10;
4567
+ if (routeLen > direct * threshold) {
4568
+ diagnostics.push({
4569
+ severity: "warning",
4570
+ code: "routing.backtracking_excessive",
4571
+ message: `Route length ${Math.round(routeLen)} px exceeds ${threshold}\xD7 direct distance ${Math.round(direct)} px.`,
4572
+ detail: {
4573
+ routeLength: Math.round(routeLen),
4574
+ directDistance: Math.round(direct),
4575
+ threshold
4576
+ }
4577
+ });
4578
+ }
4579
+ }
4218
4580
  function routeEdge(input) {
4219
4581
  const diagnostics = [];
4220
4582
  const softObstacles = input.obstacles ?? [];
4221
4583
  const hardObstacles = input.hardObstacles ?? [];
4584
+ const softObstacleIndex = input.obstacleIndex ?? createBoxSpatialIndex(indexedBoxes(softObstacles));
4585
+ const hardObstacleIndex = input.hardObstacleIndex ?? createBoxSpatialIndex(indexedBoxes(hardObstacles));
4222
4586
  const maxAttempts = input.maxRoutingAttempts ?? 5;
4223
4587
  const defaultAnchors = defaultAnchorsForGeometry(
4224
4588
  input.source.box,
@@ -4240,9 +4604,11 @@ function routeEdge(input) {
4240
4604
  [source, target],
4241
4605
  softObstacles,
4242
4606
  hardObstacles,
4243
- diagnostics
4607
+ diagnostics,
4608
+ softObstacleIndex,
4609
+ hardObstacleIndex
4244
4610
  );
4245
- if (routeCrossesBoxes(points, hardObstacles)) {
4611
+ if (routeCrossesBoxes(points, hardObstacles, hardObstacleIndex)) {
4246
4612
  diagnostics.push({
4247
4613
  severity: "error",
4248
4614
  code: "routing.evidence.crossing_forbidden",
@@ -4250,7 +4616,7 @@ function routeEdge(input) {
4250
4616
  });
4251
4617
  return { points, diagnostics };
4252
4618
  }
4253
- if (routeCrossesBoxes(points, softObstacles)) {
4619
+ if (routeCrossesBoxes(points, softObstacles, softObstacleIndex)) {
4254
4620
  diagnostics.push({
4255
4621
  severity: "warning",
4256
4622
  code: "routing.obstacle.unavoidable",
@@ -4289,9 +4655,16 @@ function routeEdge(input) {
4289
4655
  path,
4290
4656
  softObstacles,
4291
4657
  hardObstacles,
4292
- diagnostics
4658
+ diagnostics,
4659
+ softObstacleIndex,
4660
+ hardObstacleIndex
4293
4661
  );
4294
- if (!routeIntersectsObstacles(finalized, softObstacles) && !routeIntersectsObstacles(finalized, hardObstacles)) {
4662
+ if (!routeIntersectsObstacles(
4663
+ finalized,
4664
+ softObstacles,
4665
+ softObstacleIndex
4666
+ ) && !routeIntersectsObstacles(finalized, hardObstacles, hardObstacleIndex)) {
4667
+ checkBacktracking(finalized, source, target, diagnostics);
4295
4668
  return { points: finalized, diagnostics };
4296
4669
  }
4297
4670
  }
@@ -4331,23 +4704,41 @@ function routeEdge(input) {
4331
4704
  }
4332
4705
  );
4333
4706
  for (const candidate of candidateRoutes) {
4334
- if (!routeIntersectsObstacles(candidate.points, softObstacles) && !routeIntersectsObstacles(candidate.points, hardObstacles) && !routeIntersectsEndpointInteriors(
4707
+ if (!routeIntersectsObstacles(candidate.points, softObstacles) && !routeIntersectsObstacles(
4708
+ candidate.points,
4709
+ softObstacles,
4710
+ softObstacleIndex
4711
+ ) && !routeIntersectsObstacles(
4712
+ candidate.points,
4713
+ hardObstacles,
4714
+ hardObstacleIndex
4715
+ ) && !routeIntersectsEndpointInteriors(
4335
4716
  candidate.points,
4336
4717
  candidate.endpointObstacles
4337
4718
  )) {
4338
- return {
4339
- points: finalizeRoute(
4340
- candidate.points,
4341
- softObstacles,
4342
- hardObstacles,
4343
- diagnostics
4344
- ),
4719
+ const finalizedClean = finalizeRoute(
4720
+ candidate.points,
4721
+ softObstacles,
4722
+ hardObstacles,
4723
+ diagnostics,
4724
+ softObstacleIndex,
4725
+ hardObstacleIndex
4726
+ );
4727
+ checkBacktracking(
4728
+ finalizedClean,
4729
+ candidate.points[0],
4730
+ candidate.points[candidate.points.length - 1],
4345
4731
  diagnostics
4346
- };
4732
+ );
4733
+ return { points: finalizedClean, diagnostics };
4347
4734
  }
4348
4735
  }
4349
4736
  const hardClearCandidate = candidateRoutes.find(
4350
- (candidate) => !routeIntersectsObstacles(candidate.points, hardObstacles) && !routeIntersectsEndpointInteriors(
4737
+ (candidate) => !routeIntersectsObstacles(
4738
+ candidate.points,
4739
+ hardObstacles,
4740
+ hardObstacleIndex
4741
+ ) && !routeIntersectsEndpointInteriors(
4351
4742
  candidate.points,
4352
4743
  candidate.endpointObstacles
4353
4744
  )
@@ -4498,13 +4889,21 @@ function routeEdge(input) {
4498
4889
  diagnostics
4499
4890
  };
4500
4891
  }
4501
- function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
4892
+ function finalizeRoute(points, softObstacles, hardObstacles, diagnostics, softObstacleIndex, hardObstacleIndex) {
4502
4893
  const simplified = simplifyRoute2(points);
4503
4894
  if (simplified.length >= 3) {
4504
4895
  return simplified;
4505
4896
  }
4506
- const crossesHardObstacles = routeCrossesBoxes(simplified, hardObstacles);
4507
- const crossesSoftObstacles = routeCrossesBoxes(simplified, softObstacles);
4897
+ const crossesHardObstacles = routeCrossesBoxes(
4898
+ simplified,
4899
+ hardObstacles,
4900
+ hardObstacleIndex
4901
+ );
4902
+ const crossesSoftObstacles = routeCrossesBoxes(
4903
+ simplified,
4904
+ softObstacles,
4905
+ softObstacleIndex
4906
+ );
4508
4907
  if (!crossesHardObstacles && !crossesSoftObstacles) {
4509
4908
  return simplified;
4510
4909
  }
@@ -4512,8 +4911,16 @@ function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
4512
4911
  ...softObstacles,
4513
4912
  ...hardObstacles
4514
4913
  ]);
4515
- const expandedCrossesHard = routeCrossesBoxes(expanded, hardObstacles);
4516
- const expandedCrossesSoft = routeCrossesBoxes(expanded, softObstacles);
4914
+ const expandedCrossesHard = routeCrossesBoxes(
4915
+ expanded,
4916
+ hardObstacles,
4917
+ hardObstacleIndex
4918
+ );
4919
+ const expandedCrossesSoft = routeCrossesBoxes(
4920
+ expanded,
4921
+ softObstacles,
4922
+ softObstacleIndex
4923
+ );
4517
4924
  if (expandedCrossesHard || expandedCrossesSoft) {
4518
4925
  diagnostics.push({
4519
4926
  severity: expandedCrossesHard ? "error" : "warning",
@@ -4955,15 +5362,20 @@ function sortedUniqueLanes(lanes, midpoint) {
4955
5362
  return distance === 0 ? left - right : distance;
4956
5363
  });
4957
5364
  }
4958
- function routeIntersectsObstacles(points, obstacles) {
4959
- for (let index = 0; index < points.length - 1; index += 1) {
4960
- const a = points[index];
4961
- const b = points[index + 1];
5365
+ function routeIntersectsObstacles(points, obstacles, spatialIndex) {
5366
+ for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) {
5367
+ const a = points[pointIndex];
5368
+ const b = points[pointIndex + 1];
4962
5369
  if (a === void 0 || b === void 0) {
4963
5370
  continue;
4964
5371
  }
4965
- const segment = segmentBox(a, b);
4966
- for (const obstacle of obstacles) {
5372
+ const segment = segmentBox2(a, b);
5373
+ for (const obstacle of candidateBoxesForSegment(
5374
+ obstacles,
5375
+ a,
5376
+ b,
5377
+ spatialIndex
5378
+ )) {
4967
5379
  validateBox(obstacle);
4968
5380
  if (intersectsAabb(segment, obstacle)) {
4969
5381
  return true;
@@ -4979,7 +5391,7 @@ function routeIntersectsEndpointInteriors(points, endpointInteriors) {
4979
5391
  if (a === void 0 || b === void 0) {
4980
5392
  continue;
4981
5393
  }
4982
- const segment = segmentBox(a, b);
5394
+ const segment = segmentBox2(a, b);
4983
5395
  for (const endpointInterior of endpointInteriors) {
4984
5396
  validateBox(endpointInterior);
4985
5397
  if (intersectsAabb(segment, endpointInterior)) {
@@ -4989,14 +5401,19 @@ function routeIntersectsEndpointInteriors(points, endpointInteriors) {
4989
5401
  }
4990
5402
  return false;
4991
5403
  }
4992
- function routeCrossesBoxes(points, obstacles) {
4993
- for (let index = 0; index < points.length - 1; index += 1) {
4994
- const a = points[index];
4995
- const b = points[index + 1];
5404
+ function routeCrossesBoxes(points, obstacles, spatialIndex) {
5405
+ for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) {
5406
+ const a = points[pointIndex];
5407
+ const b = points[pointIndex + 1];
4996
5408
  if (a === void 0 || b === void 0) {
4997
5409
  continue;
4998
5410
  }
4999
- for (const obstacle of obstacles) {
5411
+ for (const obstacle of candidateBoxesForSegment(
5412
+ obstacles,
5413
+ a,
5414
+ b,
5415
+ spatialIndex
5416
+ )) {
5000
5417
  validateBox(obstacle);
5001
5418
  if (segmentIntersectsBox(a, b, obstacle)) {
5002
5419
  return true;
@@ -5005,6 +5422,12 @@ function routeCrossesBoxes(points, obstacles) {
5005
5422
  }
5006
5423
  return false;
5007
5424
  }
5425
+ function candidateBoxesForSegment(obstacles, start, end, index) {
5426
+ return index === void 0 ? obstacles : querySegmentSpatialIndex(index, start, end).map((entry) => entry.box);
5427
+ }
5428
+ function indexedBoxes(obstacles) {
5429
+ return obstacles.map((box, index) => ({ id: `obstacle:${index}`, box }));
5430
+ }
5008
5431
  function segmentIntersectsBox(start, end, box) {
5009
5432
  const left = box.x;
5010
5433
  const right = box.x + box.width;
@@ -5038,7 +5461,7 @@ function segmentIntersectsBoxEdge(start, end, x1, y1, x2, y2) {
5038
5461
  const u = ((x1 - start.x) * (end.y - start.y) - (y1 - start.y) * (end.x - start.x)) / denominator;
5039
5462
  return t > 0 && t < 1 && u > 0 && u < 1;
5040
5463
  }
5041
- function segmentBox(a, b) {
5464
+ function segmentBox2(a, b) {
5042
5465
  const minX = Math.min(a.x, b.x);
5043
5466
  const minY = Math.min(a.y, b.y);
5044
5467
  return {
@@ -5052,6 +5475,217 @@ function areCollinear2(a, b, c) {
5052
5475
  return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
5053
5476
  }
5054
5477
 
5478
+ // src/solver/pipeline/quality.ts
5479
+ function scoreLayoutQuality(nodes, edges) {
5480
+ const diagnostics = [];
5481
+ const metrics = [];
5482
+ const overlapCount = countNodeOverlaps(nodes);
5483
+ const overlapScore = Math.max(0, 20 - overlapCount * 5);
5484
+ metrics.push({
5485
+ kind: "node-overlap",
5486
+ value: overlapCount,
5487
+ label: `${overlapCount} overlaps`
5488
+ });
5489
+ if (overlapCount > 0) {
5490
+ diagnostics.push({
5491
+ severity: "warning",
5492
+ code: "quality.node_overlap",
5493
+ message: `${overlapCount} node pair(s) overlap.`,
5494
+ detail: { overlapCount }
5495
+ });
5496
+ }
5497
+ const crossingCount = countEdgeCrossings(edges);
5498
+ const crossingScore = Math.max(0, 20 - crossingCount * 2);
5499
+ metrics.push({
5500
+ kind: "edge-crossing",
5501
+ value: crossingCount,
5502
+ label: `${crossingCount} crossings`
5503
+ });
5504
+ if (crossingCount > 0) {
5505
+ diagnostics.push({
5506
+ severity: "warning",
5507
+ code: "quality.edge_crossing",
5508
+ message: `${crossingCount} edge segment pair(s) cross.`,
5509
+ detail: { crossingCount }
5510
+ });
5511
+ }
5512
+ const totalBends = countTotalBends(edges);
5513
+ const bendScore = Math.max(0, 20 - totalBends * 0.5);
5514
+ metrics.push({
5515
+ kind: "bend-count",
5516
+ value: totalBends,
5517
+ label: `${totalBends} bends`
5518
+ });
5519
+ const backtrackCount = countBacktrackingEdges(edges);
5520
+ const backtrackScore = Math.max(0, 20 - backtrackCount * 5);
5521
+ metrics.push({
5522
+ kind: "route-backtrack",
5523
+ value: backtrackCount,
5524
+ label: `${backtrackCount} backtracking`
5525
+ });
5526
+ if (backtrackCount > 0) {
5527
+ diagnostics.push({
5528
+ severity: "warning",
5529
+ code: "quality.route_backtrack",
5530
+ message: `${backtrackCount} edge(s) are excessively long (>3\xD7 direct).`,
5531
+ detail: { backtrackCount }
5532
+ });
5533
+ }
5534
+ const labelCollisions = countLabelCollisions(nodes, edges);
5535
+ const labelScore = Math.max(0, 20 - labelCollisions * 3);
5536
+ metrics.push({
5537
+ kind: "label-collision",
5538
+ value: labelCollisions,
5539
+ label: `${labelCollisions} label collisions`
5540
+ });
5541
+ const score = Math.max(
5542
+ 0,
5543
+ Math.min(
5544
+ 100,
5545
+ overlapScore + crossingScore + bendScore + backtrackScore + labelScore
5546
+ )
5547
+ );
5548
+ return { metrics, score, diagnostics };
5549
+ }
5550
+ function countNodeOverlaps(nodes) {
5551
+ let count = 0;
5552
+ for (let i = 0; i < nodes.length; i++) {
5553
+ for (let j = i + 1; j < nodes.length; j++) {
5554
+ if (intersectsAabb(nodes[i].box, nodes[j].box)) {
5555
+ count++;
5556
+ }
5557
+ }
5558
+ }
5559
+ return count;
5560
+ }
5561
+ function countEdgeCrossings(edges) {
5562
+ let count = 0;
5563
+ for (let i = 0; i < edges.length; i++) {
5564
+ const aPts = edges[i].points;
5565
+ for (let j = i + 1; j < edges.length; j++) {
5566
+ const bPts = edges[j].points;
5567
+ for (let ai = 0; ai < aPts.length - 1; ai++) {
5568
+ for (let bi = 0; bi < bPts.length - 1; bi++) {
5569
+ if (segmentsIntersect(
5570
+ aPts[ai],
5571
+ aPts[ai + 1],
5572
+ bPts[bi],
5573
+ bPts[bi + 1]
5574
+ )) {
5575
+ count++;
5576
+ }
5577
+ }
5578
+ }
5579
+ }
5580
+ }
5581
+ return count;
5582
+ }
5583
+ function segmentsIntersect(a, b, c, d) {
5584
+ const d1 = cross(c, d, a);
5585
+ const d2 = cross(c, d, b);
5586
+ const d3 = cross(a, b, c);
5587
+ const d4 = cross(a, b, d);
5588
+ return (d1 > 0 && d2 < 0 || d1 < 0 && d2 > 0) && (d3 > 0 && d4 < 0 || d3 < 0 && d4 > 0);
5589
+ }
5590
+ function cross(o, a, b) {
5591
+ return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
5592
+ }
5593
+ function countTotalBends(edges) {
5594
+ const sign = (n) => n > 0 ? 1 : n < 0 ? -1 : 0;
5595
+ let bends = 0;
5596
+ for (const e of edges) {
5597
+ if (e.points.length < 3) continue;
5598
+ for (let i = 1; i < e.points.length - 1; i++) {
5599
+ const prev = e.points[i - 1];
5600
+ const curr = e.points[i];
5601
+ const next = e.points[i + 1];
5602
+ const dx1 = curr.x - prev.x;
5603
+ const dy1 = curr.y - prev.y;
5604
+ const dx2 = next.x - curr.x;
5605
+ const dy2 = next.y - curr.y;
5606
+ if (sign(dx1) !== sign(dx2) || sign(dy1) !== sign(dy2)) {
5607
+ bends++;
5608
+ }
5609
+ }
5610
+ }
5611
+ return bends;
5612
+ }
5613
+ function countBacktrackingEdges(edges) {
5614
+ let count = 0;
5615
+ for (const e of edges) {
5616
+ if (e.points.length < 2) continue;
5617
+ const first = e.points[0];
5618
+ const last = e.points[e.points.length - 1];
5619
+ const direct = Math.hypot(last.x - first.x, last.y - first.y);
5620
+ if (direct <= 0) continue;
5621
+ let routeLen = 0;
5622
+ for (let i = 0; i < e.points.length - 1; i++) {
5623
+ routeLen += Math.hypot(
5624
+ e.points[i + 1].x - e.points[i].x,
5625
+ e.points[i + 1].y - e.points[i].y
5626
+ );
5627
+ }
5628
+ if (routeLen > direct * 3) count++;
5629
+ }
5630
+ return count;
5631
+ }
5632
+ function countLabelCollisions(nodes, edges) {
5633
+ let count = 0;
5634
+ const nodeBoxes = /* @__PURE__ */ new Map();
5635
+ for (const n of nodes) {
5636
+ nodeBoxes.set(n.id, n.box);
5637
+ if (n.label?.text !== void 0) {
5638
+ const lw = n.label.text.length * 8;
5639
+ const labelBox = {
5640
+ x: n.box.x + n.box.width / 2 - lw / 2,
5641
+ y: n.box.y - 8,
5642
+ width: lw,
5643
+ height: 14
5644
+ };
5645
+ for (const [id, box] of nodeBoxes) {
5646
+ if (id === n.id) continue;
5647
+ if (intersectsAabb(labelBox, box)) count++;
5648
+ }
5649
+ for (const e of edges) {
5650
+ for (let i = 0; i < e.points.length - 1; i++) {
5651
+ if (segmentIntersectsBox2(e.points[i], e.points[i + 1], labelBox)) {
5652
+ count++;
5653
+ break;
5654
+ }
5655
+ }
5656
+ }
5657
+ }
5658
+ }
5659
+ return count;
5660
+ }
5661
+ function segmentIntersectsBox2(start, end, box) {
5662
+ const left = box.x;
5663
+ const right = box.x + box.width;
5664
+ const top = box.y;
5665
+ const bottom = box.y + box.height;
5666
+ 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)
5667
+ return true;
5668
+ if (start.x === end.x) {
5669
+ return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
5670
+ }
5671
+ if (start.y === end.y) {
5672
+ return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
5673
+ }
5674
+ 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);
5675
+ }
5676
+ function rangesOverlap3(a, b, min, max) {
5677
+ const lo = Math.min(a, b);
5678
+ const hi = Math.max(a, b);
5679
+ return hi > min && lo < max;
5680
+ }
5681
+ function edgeIntersect(start, end, x1, y1, x2, y2) {
5682
+ const denom = (end.x - start.x) * (y2 - y1) - (end.y - start.y) * (x2 - x1);
5683
+ if (denom === 0) return false;
5684
+ const t = ((start.x - x1) * (y2 - y1) - (start.y - y1) * (x2 - x1)) / denom;
5685
+ const u = ((start.x - x1) * (end.y - start.y) - (start.y - y1) * (end.x - start.x)) / denom;
5686
+ return t > 0 && t < 1 && u > 0 && u < 1;
5687
+ }
5688
+
5055
5689
  // src/solver/solve.ts
5056
5690
  var DEFAULT_MATRIX_CELL_SIZE2 = { width: 120, height: 36 };
5057
5691
  var DEFAULT_TABLE_CELL_SIZE2 = { width: 128, height: 34 };
@@ -5110,17 +5744,16 @@ function solveDiagram(diagram, options = {}) {
5110
5744
  (swimlane) => enhanceSwimlaneCjkTypography(swimlane, cjkTypography, diagnostics)
5111
5745
  );
5112
5746
  const constraints = stableByConstraintId(diagram.constraints);
5113
- const layout2 = runDagreInitialLayout({
5747
+ const initialLayoutMode = options.initialLayout ?? "dagre";
5748
+ const layout2 = runInitialLayout({
5749
+ mode: initialLayoutMode,
5750
+ componentAware: options.maxStackDepth === void 0,
5114
5751
  direction: diagram.direction,
5115
- nodes: styledNodes.map((node) => ({ id: node.id, size: node.size })),
5116
- edges: styledEdges.map((edge) => ({
5117
- id: edge.id,
5118
- sourceId: edge.source.nodeId,
5119
- targetId: edge.target.nodeId
5120
- }))
5752
+ nodes: styledNodes,
5753
+ edges: styledEdges
5121
5754
  });
5122
5755
  diagnostics.push(...layout2.diagnostics);
5123
- const initialNodeBoxes = wrapVerticalStackIfNeeded(
5756
+ const initialNodeBoxes = initialLayoutMode === "positions" ? layout2.boxes : wrapVerticalStackIfNeeded(
5124
5757
  layout2.boxes,
5125
5758
  styledNodes,
5126
5759
  styledEdges,
@@ -5380,6 +6013,84 @@ function solveDiagram(diagram, options = {}) {
5380
6013
  function solveDiagramSafe(diagram, options = {}) {
5381
6014
  return solveDiagram(diagram, { ...options, prefitLabelSize: true });
5382
6015
  }
6016
+ function runInitialLayout(input) {
6017
+ if (input.mode === "positions") {
6018
+ return runPositionSeededInitialLayout(input);
6019
+ }
6020
+ const runAutoLayout = input.componentAware ? runComponentAwareDagreInitialLayout : runDagreInitialLayout;
6021
+ return runAutoLayout({
6022
+ direction: input.direction,
6023
+ nodes: input.nodes.map((node) => ({ id: node.id, size: node.size })),
6024
+ edges: input.edges.map((edge) => ({
6025
+ id: edge.id,
6026
+ sourceId: edge.source.nodeId,
6027
+ targetId: edge.target.nodeId
6028
+ }))
6029
+ });
6030
+ }
6031
+ function runPositionSeededInitialLayout(input) {
6032
+ const diagnostics = [];
6033
+ const boxes = /* @__PURE__ */ new Map();
6034
+ const autoNodes = [];
6035
+ for (const node of input.nodes) {
6036
+ if (!isValidInitialDimension(node.size.width) || !isValidInitialDimension(node.size.height)) {
6037
+ diagnostics.push({
6038
+ severity: "error",
6039
+ code: "layout.node-size.invalid",
6040
+ message: `Node ${node.id} has invalid layout dimensions.`,
6041
+ path: ["nodes", node.id, "size"],
6042
+ detail: { nodeId: node.id }
6043
+ });
6044
+ continue;
6045
+ }
6046
+ if (node.position === void 0) {
6047
+ autoNodes.push(node);
6048
+ continue;
6049
+ }
6050
+ if (!isFiniteInitialPoint(node.position)) {
6051
+ diagnostics.push({
6052
+ severity: "error",
6053
+ code: "layout.node-position.invalid",
6054
+ message: `Node ${node.id} has an invalid seeded position.`,
6055
+ path: ["nodes", node.id, "position"],
6056
+ detail: { nodeId: node.id }
6057
+ });
6058
+ continue;
6059
+ }
6060
+ boxes.set(node.id, {
6061
+ x: node.position.x,
6062
+ y: node.position.y,
6063
+ width: node.size.width,
6064
+ height: node.size.height
6065
+ });
6066
+ }
6067
+ if (autoNodes.length === 0) {
6068
+ return { boxes, diagnostics };
6069
+ }
6070
+ const autoNodeIds = new Set(autoNodes.map((node) => node.id));
6071
+ const autoLayout = runComponentAwareDagreInitialLayout({
6072
+ direction: input.direction,
6073
+ nodes: autoNodes.map((node) => ({ id: node.id, size: node.size })),
6074
+ edges: input.edges.filter(
6075
+ (edge) => autoNodeIds.has(edge.source.nodeId) && autoNodeIds.has(edge.target.nodeId)
6076
+ ).map((edge) => ({
6077
+ id: edge.id,
6078
+ sourceId: edge.source.nodeId,
6079
+ targetId: edge.target.nodeId
6080
+ }))
6081
+ });
6082
+ diagnostics.push(...autoLayout.diagnostics);
6083
+ for (const [id, box] of autoLayout.boxes) {
6084
+ boxes.set(id, box);
6085
+ }
6086
+ return { boxes, diagnostics };
6087
+ }
6088
+ function isValidInitialDimension(value) {
6089
+ return Number.isFinite(value) && value >= 0;
6090
+ }
6091
+ function isFiniteInitialPoint(point2) {
6092
+ return Number.isFinite(point2.x) && Number.isFinite(point2.y);
6093
+ }
5383
6094
  function prefitNodeLabelSize(node, options, diagnostics) {
5384
6095
  if (node.label === void 0) {
5385
6096
  return node;
@@ -7131,6 +7842,10 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7131
7842
  const coordinatedNodeById = new Map(
7132
7843
  coordinatedNodes.map((node) => [node.id, node])
7133
7844
  );
7845
+ const nodeObstacleIndex = createBoxSpatialIndex(
7846
+ obstacles.map((box, index) => ({ id: `node-obstacle:${index}`, box })),
7847
+ options.routingGutter ?? 160
7848
+ );
7134
7849
  for (const edge of edges) {
7135
7850
  const source = nodes.get(edge.source.nodeId);
7136
7851
  const target = nodes.get(edge.target.nodeId);
@@ -7151,6 +7866,14 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7151
7866
  const sourcePort = coordinatedNodeById.get(edge.source.nodeId)?.ports?.find((port) => port.id === edge.source.portId);
7152
7867
  const targetPort = coordinatedNodeById.get(edge.target.nodeId)?.ports?.find((port) => port.id === edge.target.portId);
7153
7868
  const routeTextObstacles = textObstacles.filter((annotation) => !isEdgeConnectedTextAnnotation(edge, annotation)).map((annotation) => annotation.box);
7869
+ const corridor = edgeCorridorBox(
7870
+ source.box,
7871
+ target.box,
7872
+ options.routingGutter ?? 160
7873
+ );
7874
+ const routeNodeObstacles = queryBoxSpatialIndex(nodeObstacleIndex, corridor).map((entry) => entry.box).filter(
7875
+ (obstacle) => !sameBox(obstacle, source.obstacleBox) && !sameBox(obstacle, target.obstacleBox)
7876
+ );
7154
7877
  const route = routeEdge({
7155
7878
  kind: options.routeKind ?? "orthogonal",
7156
7879
  direction,
@@ -7159,9 +7882,7 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7159
7882
  ...edge.source.anchor === void 0 ? {} : { sourceAnchor: edge.source.anchor },
7160
7883
  ...edge.target.anchor === void 0 ? {} : { targetAnchor: edge.target.anchor },
7161
7884
  obstacles: [
7162
- ...obstacles.filter(
7163
- (obstacle) => obstacle !== source.obstacleBox && obstacle !== target.obstacleBox
7164
- ),
7885
+ ...routeNodeObstacles,
7165
7886
  ...softObstacles,
7166
7887
  ...groupObstaclesForEdge(edge, groups, options.obstacleMargin ?? 0),
7167
7888
  ...routeTextObstacles
@@ -7182,6 +7903,19 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
7182
7903
  }
7183
7904
  return coordinated;
7184
7905
  }
7906
+ function edgeCorridorBox(source, target, margin) {
7907
+ const minX = Math.min(source.x, target.x);
7908
+ const minY = Math.min(source.y, target.y);
7909
+ const maxX = Math.max(source.x + source.width, target.x + target.width);
7910
+ const maxY = Math.max(source.y + source.height, target.y + target.height);
7911
+ return expandBoxForQuery(
7912
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
7913
+ margin
7914
+ );
7915
+ }
7916
+ function sameBox(first, second) {
7917
+ return first.x === second.x && first.y === second.y && first.width === second.width && first.height === second.height;
7918
+ }
7185
7919
  function isEdgeConnectedTextAnnotation(edge, annotation) {
7186
7920
  switch (annotation.surfaceKind) {
7187
7921
  case "edge-label":
@@ -7639,13 +8373,13 @@ function routeIntersectsTextBox(points, box) {
7639
8373
  if (start === void 0 || end === void 0) {
7640
8374
  continue;
7641
8375
  }
7642
- if (segmentIntersectsBox2(start, end, box)) {
8376
+ if (segmentIntersectsBox3(start, end, box)) {
7643
8377
  return true;
7644
8378
  }
7645
8379
  }
7646
8380
  return false;
7647
8381
  }
7648
- function segmentIntersectsBox2(start, end, box) {
8382
+ function segmentIntersectsBox3(start, end, box) {
7649
8383
  const left = box.x;
7650
8384
  const right = box.x + box.width;
7651
8385
  const top = box.y;
@@ -7654,17 +8388,17 @@ function segmentIntersectsBox2(start, end, box) {
7654
8388
  return true;
7655
8389
  }
7656
8390
  if (start.x === end.x) {
7657
- return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
8391
+ return start.x > left && start.x < right && rangesOverlap4(start.y, end.y, top, bottom);
7658
8392
  }
7659
8393
  if (start.y === end.y) {
7660
- return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
8394
+ return start.y > top && start.y < bottom && rangesOverlap4(start.x, end.x, left, right);
7661
8395
  }
7662
8396
  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);
7663
8397
  }
7664
8398
  function pointInsideBox2(point2, box) {
7665
8399
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
7666
8400
  }
7667
- function rangesOverlap3(a, b, min, max) {
8401
+ function rangesOverlap4(a, b, min, max) {
7668
8402
  const low = Math.min(a, b);
7669
8403
  const high = Math.max(a, b);
7670
8404
  return high > min && low < max;
@@ -8027,6 +8761,30 @@ function groupReferenceMissing(groupId, referenceKind, id) {
8027
8761
  detail: id === void 0 ? { groupId } : { groupId, id }
8028
8762
  };
8029
8763
  }
8764
+ function createDefaultPipeline() {
8765
+ return new LayoutPipeline().addPhase({
8766
+ name: "solve-diagram",
8767
+ run(state) {
8768
+ const result = solveDiagram(state.diagram, state.options);
8769
+ state.diagnostics.push(...result.diagnostics);
8770
+ state.bounds = result.bounds;
8771
+ state.degraded = result.degraded ?? false;
8772
+ state.coordinatedNodes = result.nodes;
8773
+ state.coordinatedEdges = result.edges;
8774
+ }
8775
+ }).addPhase({
8776
+ name: "quality-score",
8777
+ run(state) {
8778
+ if (!state.options.qualityScore) return;
8779
+ const report = scoreLayoutQuality(
8780
+ state.coordinatedNodes,
8781
+ state.coordinatedEdges
8782
+ );
8783
+ state.qualityReport = report;
8784
+ state.diagnostics.push(...report.diagnostics);
8785
+ }
8786
+ });
8787
+ }
8030
8788
 
8031
8789
  // src/dsl/render.ts
8032
8790
  function resolveOutputFormat(cliFormat, dslFormat) {
@@ -8070,6 +8828,7 @@ function renderDiagramDsl(source, options = {}) {
8070
8828
  return { diagnostics };
8071
8829
  }
8072
8830
  const solved = solveDiagram(normalized.diagram, {
8831
+ ...solveInitialLayoutOption(normalized.diagram.metadata?.initialLayout),
8073
8832
  routeKind: normalized.diagram.metadata?.routeKind === "straight" ? "straight" : normalized.diagram.metadata?.routeKind === "obstacle-avoiding" ? "obstacle-avoiding" : "orthogonal",
8074
8833
  ...solvePortShiftingOption(normalized.diagram.metadata?.portShifting),
8075
8834
  ...options.textMeasurer === void 0 ? {} : { textMeasurer: options.textMeasurer }
@@ -8111,6 +8870,9 @@ function renderDiagramDsl(source, options = {}) {
8111
8870
  function toSolveDiagnostic(diagnostic) {
8112
8871
  return { ...diagnostic, layer: "solve" };
8113
8872
  }
8873
+ function solveInitialLayoutOption(value) {
8874
+ return value === "positions" ? { initialLayout: "positions" } : {};
8875
+ }
8114
8876
  function solvePortShiftingOption(value) {
8115
8877
  if (!isJsonObject(value)) {
8116
8878
  return {};
@@ -8260,6 +9022,6 @@ function isPointLikeRecord(value) {
8260
9022
  return isPlainObject(value) && typeof value.x === "number" && typeof value.y === "number";
8261
9023
  }
8262
9024
 
8263
- export { DEFAULT_CANONICAL_PRECISION, DEFAULT_DSL_MAX_BYTES, DELIVERABILITY_DIAGNOSTIC_CODES, DeterministicTextMeasurer, LabelFitter, PretextTextMeasurer, applyLayoutConstraints, assertFiniteNonNegative, assertFinitePositive, boxCenter, canonicalize, computeArrowhead, computeContainerGeometry, computeShapeGeometry, createDefaultTextMeasurer, expandBox, exportExcalidraw, exportSvg, fitLabel, getEdgePort, installNodeCanvasRuntime, intersectsAabb, isPretextRuntimeAvailable, normalizeDiagramDsl, normalizeInsets, parseDiagramDsl, parseEdgeShorthand, renderDiagramDsl, resolveLineHeight, resolveOutputFormat, routeEdge, runDagreInitialLayout, simplifyRoute2 as simplifyRoute, solveDiagram, solveDiagramSafe, sortDslDiagnostics, stringifyCanonical, toCanvasFont, unionBoxes, validateBox, validateTextStyle };
9025
+ export { DEFAULT_CANONICAL_PRECISION, DEFAULT_DSL_MAX_BYTES, DELIVERABILITY_DIAGNOSTIC_CODES, DeterministicTextMeasurer, LabelFitter, LayoutPipeline, PretextTextMeasurer, applyLayoutConstraints, assertFiniteNonNegative, assertFinitePositive, boxCenter, canonicalize, computeArrowhead, computeContainerGeometry, computeShapeGeometry, createBoxSpatialIndex, createDefaultPipeline, createDefaultTextMeasurer, expandBox, expandBoxForQuery, exportExcalidraw, exportSvg, fitLabel, getEdgePort, installNodeCanvasRuntime, intersectsAabb, isPretextRuntimeAvailable, normalizeDiagramDsl, normalizeInsets, overlapArea, parseDiagramDsl, parseEdgeShorthand, queryBoxSpatialIndex, querySegmentSpatialIndex, renderDiagramDsl, resolveLineHeight, resolveOutputFormat, routeEdge, runComponentAwareDagreInitialLayout, runDagreInitialLayout, simplifyRoute2 as simplifyRoute, solveDiagram, solveDiagramSafe, sortDslDiagnostics, stringifyCanonical, toCanvasFont, unionBoxes, validateBox, validateTextStyle };
8264
9026
  //# sourceMappingURL=index.js.map
8265
9027
  //# sourceMappingURL=index.js.map