@stacksjs/defaults 0.75.360 → 0.75.362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-payments/SKILL.md +28 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ide/vscode/package.json +1 -1
  209. package/package.json +2 -2
@@ -0,0 +1,1469 @@
1
+ // Internal architecture router shared by rendering and geometry inspection.
2
+ // Create a new router when measured boxes or connections change: port spreading
3
+ // is computed once and route results are cached for this scene.
4
+
5
+ import {
6
+ segmentIntersectsRect,
7
+ anchor,
8
+ automaticPortSpread,
9
+ alignAutomaticFacingPorts,
10
+ portHasCornerClearance,
11
+ automaticPortRhythmBridge,
12
+ defaultFromSide,
13
+ defaultToSide,
14
+ chosenSide,
15
+ properSegmentIntersection,
16
+ routeHonorsEndpointSides,
17
+ normalizeRoutePoints,
18
+ rectsOverlap,
19
+ roundedPath,
20
+ collectBorderRuns,
21
+ collectRouteRhythmIssues,
22
+ frameBorderSegments,
23
+ } from '../shared/geometry.mjs';
24
+ import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
25
+
26
+ /**
27
+ * Router bound to one set of measured component boxes.
28
+ *
29
+ * @param {Map<string, {x,y,width,height,cx,cy}>} components measured boxes by id
30
+ * @param {Array<object>} connections the connection list to spread ports across
31
+ * @param {object} [options]
32
+ * @param {Array<{x,y,width,height,radius?}>} [options.frames] structural frames
33
+ * (boundaries) whose borders an automatic route may cross but never follow
34
+ * @param {number} [options.interiorSegmentPx] showcase floor for interior segments
35
+ * @param {number} [options.microSegmentPx] floor for any segment
36
+ * @param {(conn: object, points: number[][], context: {routes: number[][][], labels: object[]}) => object|null} [options.labelRectFor]
37
+ * default label rect of a routed relationship given the routes and label
38
+ * rects resolved so far; later automatic routes keep clear of it so a dense
39
+ * fan-out does not leave the label nowhere to go
40
+ * @param {(conn: object, endpoint: 'source'|'target') => string|undefined} [options.sideFor]
41
+ * endpoint side of a relationship whose author did not pin one, for a type
42
+ * that reads its layout on a different axis than architecture's row fan-out;
43
+ * an endpoint this hook leaves undefined keeps the shared inference
44
+ * @param {number} [options.maxPortSpacing] widest spacing the automatic port
45
+ * spread may use on a shared side; a type whose endpoint glyphs are taller
46
+ * than the default raises this so two symbols never draw over each other
47
+ * @param {(context: {conn: object, from: object, to: object, start: number[], end: number[], fromSide: string, toSide: string}) => number[][][]} [options.preferredCandidates]
48
+ * candidate families tried before the shared ones, so a type-owned corridor
49
+ * (a bundled trunk, a dedicated lane) wins over the generic midpoint
50
+ * @param {boolean} [options.crossingFreeGridFirst = false] before the obstacle
51
+ * grid search that may cross earlier routes, try one that treats every
52
+ * unrelated resolved route as a wall. A type whose showcase gate rejects any
53
+ * proper crossing (erd) opts in so a later relationship takes a clear detour
54
+ * instead of the shorter crossing the gate would refuse.
55
+ * @param {number} [options.componentGapPx = 0] when positive, the obstacle
56
+ * grid first keeps a route this far from every component it merely passes
57
+ * (its own endpoints stay at the 2-unit clearance) and falls back to the
58
+ * tight search only when no spaced route is accepted, so a detour does not
59
+ * run along a neighbouring box edge 3 units away from its border.
60
+ * @param {number} [options.minimumTerminalSegmentPx = 0] shortest first and
61
+ * last segment any automatic route may have, applied even with
62
+ * compositionFloors off: a type that rounds its corners when drawing needs
63
+ * the end segment to outlast the corner radius plus the micro-segment floor.
64
+ * @param {boolean} [options.compositionFloors = true] hold every automatic
65
+ * route to the rhythm floors the showcase gate enforces, so the planner never
66
+ * accepts a route the gate will reject. A type that draws a route differently
67
+ * from its logical points (the erd renderer removes the bundled trunk stretch
68
+ * from each branch and draws the bus as one path) turns this off: the floors
69
+ * describe a route drawn exactly as planned, and that type answers for the
70
+ * drawn result at its own gate instead.
71
+ * @param {number} [options.readabilityBottomMargin = 0] extra vertical room for
72
+ * the bounded readability sweep on an intrinsic-height canvas. Width and
73
+ * authored geometry stay fixed; fixed canvases leave this at zero.
74
+ */
75
+ export function createRouter(components, connections = [], {
76
+ frames = [],
77
+ interiorSegmentPx = 16,
78
+ microSegmentPx = 8,
79
+ labelRectFor = null,
80
+ distinctAutomaticPorts = false,
81
+ preferReadableRoutes = false,
82
+ sideFor = null,
83
+ maxPortSpacing = null,
84
+ preferredCandidates = null,
85
+ compositionFloors = true,
86
+ crossingFreeGridFirst = false,
87
+ componentGapPx = 0,
88
+ minimumTerminalSegmentPx = 0,
89
+ readabilityBottomMargin = 0,
90
+ } = {}) {
91
+ const frameBorders = frames.flatMap((frame) => frameBorderSegments(frame));
92
+ const LABEL_CLEARANCE = 4;
93
+ // Labels of already-routed relationships, reserved while planning the rest.
94
+ let reservedLabels = [];
95
+ let honourReservedLabels = true;
96
+ let allowGridSearch = true;
97
+
98
+ function routeClearsReservedLabels(conn, points) {
99
+ if (!honourReservedLabels) return true;
100
+ for (const entry of reservedLabels) {
101
+ if (entry.conn === conn) continue;
102
+ for (let index = 0; index < points.length - 1; index += 1) {
103
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, entry.rect, LABEL_CLEARANCE)) {
104
+ return false;
105
+ }
106
+ }
107
+ }
108
+ return true;
109
+ }
110
+
111
+ // The grid search adds its own 2px component clearance; pre-expand so a
112
+ // reserved label keeps the same 4px clearance the placement pass demands.
113
+ function reservedLabelObstacles(gridClearance) {
114
+ if (!honourReservedLabels) return [];
115
+ const grow = LABEL_CLEARANCE - gridClearance;
116
+ return reservedLabels.map(({ rect }) => ({
117
+ x: rect.x - grow,
118
+ y: rect.y - grow,
119
+ width: rect.width + grow * 2,
120
+ height: rect.height + grow * 2,
121
+ }));
122
+ }
123
+
124
+ // Automatic routes are held to the same composition floors the showcase
125
+ // gate enforces afterwards. Accepting a route here that the gate rejects
126
+ // only hands the author a hand-routing repair the planner could have made.
127
+ function routeMeetsCompositionFloors(points) {
128
+ if (minimumTerminalSegmentPx > 0 && points.length > 2) {
129
+ const span = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
130
+ if (span(points[0], points[1]) < minimumTerminalSegmentPx
131
+ || span(points.at(-2), points.at(-1)) < minimumTerminalSegmentPx) return false;
132
+ }
133
+ if (compositionFloors
134
+ && collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx, microSegmentPx }).length) {
135
+ return false;
136
+ }
137
+ return !frames.length || collectBorderRuns({ routedRelations: [{ points }], frames }).length === 0;
138
+ }
139
+ const planningMetrics = {
140
+ routeCount: 0,
141
+ explicitRouteCount: 0,
142
+ automaticRouteCount: 0,
143
+ gridSearchCount: 0,
144
+ gridRoutedCount: 0,
145
+ gridCandidateNodeCount: 0,
146
+ gridUsableNodeCount: 0,
147
+ gridEdgeCount: 0,
148
+ gridVisitedNodeCount: 0,
149
+ avoidedSegmentCount: 0,
150
+ conflictFallbackCount: 0,
151
+ maximumGridSearchCount: 64,
152
+ gridBudgetExhaustedCount: 0,
153
+ crossoverRoutedCount: 0,
154
+ readabilityCandidateCount: 0,
155
+ readabilityImprovedCount: 0,
156
+ labelRelocationCandidateCount: 0,
157
+ labelRelocationImprovedCount: 0,
158
+ reciprocalCandidateCount: 0,
159
+ reciprocalImprovedCount: 0,
160
+ gridAttempts: [],
161
+ };
162
+
163
+ // ---- Connection routing ------------------------------------------------------
164
+ const gridComponentGaps = componentGapPx > 0 ? [componentGapPx, 0] : [0];
165
+ // The spaced search is a readability preference, so its route must also be
166
+ // clean as drawn: full rhythm floors (even for a type that turns them off)
167
+ // and terminal segments long enough for a rounded corner and an end marker.
168
+ function spacedRouteIsClean(points, gap) {
169
+ if (gap === 0) return true;
170
+ const terminal = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
171
+ return terminal(points[0], points[1]) >= 16 && terminal(points.at(-2), points.at(-1)) >= 16
172
+ && collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx, microSegmentPx }).length === 0;
173
+ }
174
+ function gridComponentObstacles(conn, gap) {
175
+ return [...components.values()].map((component) => (gap === 0 || component.id === conn.from || component.id === conn.to
176
+ ? component
177
+ : { ...component, x: component.x - gap, y: component.y - gap, width: component.width + gap * 2, height: component.height + gap * 2 }));
178
+ }
179
+
180
+ function routeClearsComponents(conn, points, clearance = 2) {
181
+ const endpointIds = new Set([conn.from, conn.to]);
182
+ for (const component of components.values()) {
183
+ if (endpointIds.has(component.id)) continue;
184
+ for (let index = 0; index < points.length - 1; index += 1) {
185
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
186
+ return false;
187
+ }
188
+ }
189
+ }
190
+ return routeClearsReservedLabels(conn, points);
191
+ }
192
+
193
+ function routeClearsEndpointComponents(points, from, to) {
194
+ const lastSegment = points.length - 2;
195
+ for (let index = 0; index <= lastSegment; index += 1) {
196
+ const segment = { start: points[index], end: points[index + 1] };
197
+ if (index > 0 && segmentIntersectsRect(segment, from)) return false;
198
+ if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
199
+ }
200
+ return true;
201
+ }
202
+
203
+ function relationshipsShareEndpoint(left, right) {
204
+ return left.from === right.from
205
+ || left.from === right.to
206
+ || left.to === right.from
207
+ || left.to === right.to;
208
+ }
209
+
210
+ function collinearOverlapLength(leftStart, leftEnd, rightStart, rightEnd) {
211
+ const epsilon = 0.0001;
212
+ if (Math.abs(leftStart[0] - leftEnd[0]) <= epsilon
213
+ && Math.abs(rightStart[0] - rightEnd[0]) <= epsilon
214
+ && Math.abs(leftStart[0] - rightStart[0]) <= epsilon) {
215
+ return Math.max(0,
216
+ Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
217
+ - Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
218
+ }
219
+ if (Math.abs(leftStart[1] - leftEnd[1]) <= epsilon
220
+ && Math.abs(rightStart[1] - rightEnd[1]) <= epsilon
221
+ && Math.abs(leftStart[1] - rightStart[1]) <= epsilon) {
222
+ return Math.max(0,
223
+ Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
224
+ - Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
225
+ }
226
+ return 0;
227
+ }
228
+
229
+ function orthogonalTouchOnResolvedInterior(start, end, resolvedStart, resolvedEnd) {
230
+ const epsilon = 0.0001;
231
+ const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
232
+ const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
233
+ const resolvedHorizontal = Math.abs(resolvedStart[1] - resolvedEnd[1]) <= epsilon;
234
+ const resolvedVertical = Math.abs(resolvedStart[0] - resolvedEnd[0]) <= epsilon;
235
+ if (candidateHorizontal && resolvedVertical) {
236
+ const x = resolvedStart[0];
237
+ const y = start[1];
238
+ return x >= Math.min(start[0], end[0]) - epsilon
239
+ && x <= Math.max(start[0], end[0]) + epsilon
240
+ && y > Math.min(resolvedStart[1], resolvedEnd[1]) + epsilon
241
+ && y < Math.max(resolvedStart[1], resolvedEnd[1]) - epsilon;
242
+ }
243
+ if (candidateVertical && resolvedHorizontal) {
244
+ const x = start[0];
245
+ const y = resolvedStart[1];
246
+ return y >= Math.min(start[1], end[1]) - epsilon
247
+ && y <= Math.max(start[1], end[1]) + epsilon
248
+ && x > Math.min(resolvedStart[0], resolvedEnd[0]) + epsilon
249
+ && x < Math.max(resolvedStart[0], resolvedEnd[0]) - epsilon;
250
+ }
251
+ return false;
252
+ }
253
+
254
+ function unrelatedResolvedRoutes(conn, resolvedRoutes) {
255
+ return resolvedRoutes.filter((entry) => !relationshipsShareEndpoint(conn, entry.conn));
256
+ }
257
+
258
+ function routeConflictsWithResolved(conn, points, resolvedRoutes) {
259
+ const unrelated = unrelatedResolvedRoutes(conn, resolvedRoutes);
260
+ for (const entry of unrelated) {
261
+ for (let left = 0; left < points.length - 1; left += 1) {
262
+ for (let right = 0; right < entry.points.length - 1; right += 1) {
263
+ if (properSegmentIntersection(
264
+ points[left],
265
+ points[left + 1],
266
+ entry.points[right],
267
+ entry.points[right + 1],
268
+ )) return true;
269
+ if (orthogonalTouchOnResolvedInterior(
270
+ points[left],
271
+ points[left + 1],
272
+ entry.points[right],
273
+ entry.points[right + 1],
274
+ )) return true;
275
+ if (collinearOverlapLength(
276
+ points[left],
277
+ points[left + 1],
278
+ entry.points[right],
279
+ entry.points[right + 1],
280
+ ) >= 8) return true;
281
+ }
282
+ }
283
+ }
284
+ return false;
285
+ }
286
+
287
+ function routeOverlapsResolved(conn, points, resolvedRoutes) {
288
+ // A common destination does not make two independently labelled routes a
289
+ // bus. Keep their corridors distinct too; explicit routes bypass planning.
290
+ for (const entry of resolvedRoutes) {
291
+ if (relationshipsShareEndpoint(conn, entry.conn)
292
+ && (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt)) continue;
293
+ for (let left = 0; left < points.length - 1; left += 1) {
294
+ for (let right = 0; right < entry.points.length - 1; right += 1) {
295
+ if (collinearOverlapLength(
296
+ points[left],
297
+ points[left + 1],
298
+ entry.points[right],
299
+ entry.points[right + 1],
300
+ ) >= 8) return true;
301
+ }
302
+ }
303
+ }
304
+ return false;
305
+ }
306
+
307
+ const OUTWARD_SIDE_VECTOR = {
308
+ left: [-1, 0],
309
+ right: [1, 0],
310
+ top: [0, -1],
311
+ bottom: [0, 1],
312
+ };
313
+
314
+ function outwardStub(point, side, distance = 24) {
315
+ const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
316
+ return [point[0] + dx * distance, point[1] + dy * distance];
317
+ }
318
+
319
+ function collinearBacktrack(a, b, c) {
320
+ const first = [b[0] - a[0], b[1] - a[1]];
321
+ const second = [c[0] - b[0], c[1] - b[1]];
322
+ const cross = first[0] * second[1] - first[1] * second[0];
323
+ const dot = first[0] * second[0] + first[1] * second[1];
324
+ return Math.abs(cross) <= 0.0001 && dot < -0.0001;
325
+ }
326
+
327
+ function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
328
+ const startStub = outwardStub(start, fromSide);
329
+ const endStub = outwardStub(end, toSide);
330
+ const rawCandidates = [];
331
+ const minimumBridge = 16;
332
+ const verticalSides = new Set(['top', 'bottom']);
333
+ const horizontalSides = new Set(['left', 'right']);
334
+
335
+ // Port spreading can leave parallel-side anchors only a few pixels apart.
336
+ // Route through a bounded outside channel so we keep both endpoint normals
337
+ // without introducing a tiny, noisy connector between the two stubs.
338
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
339
+ && Math.abs(start[0] - end[0]) < minimumBridge) {
340
+ for (const channelX of [
341
+ Math.max(start[0], end[0]) + minimumBridge,
342
+ Math.min(start[0], end[0]) - minimumBridge,
343
+ ]) {
344
+ rawCandidates.push([
345
+ startStub,
346
+ [channelX, startStub[1]],
347
+ [channelX, endStub[1]],
348
+ endStub,
349
+ ]);
350
+ }
351
+ }
352
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
353
+ && Math.abs(start[1] - end[1]) < minimumBridge) {
354
+ for (const channelY of [
355
+ Math.max(start[1], end[1]) + minimumBridge,
356
+ Math.min(start[1], end[1]) - minimumBridge,
357
+ ]) {
358
+ rawCandidates.push([
359
+ startStub,
360
+ [startStub[0], channelY],
361
+ [endStub[0], channelY],
362
+ endStub,
363
+ ]);
364
+ }
365
+ }
366
+
367
+ rawCandidates.push(
368
+ [startStub, [endStub[0], startStub[1]], endStub],
369
+ [startStub, [startStub[0], endStub[1]], endStub],
370
+ );
371
+ return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
372
+ .filter((points) => points.length >= 2)
373
+ .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
374
+ .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
375
+ .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
376
+ .sort((a, b) => a.length - b.length)
377
+ .map((points) => points.slice(1, -1));
378
+ }
379
+
380
+ const AUTOMATIC_PORT_CORNER_GUTTER = 16;
381
+
382
+ function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
383
+ return alignAutomaticFacingPorts(conn, from, to, start, end, fromSide, toSide, ports, {
384
+ accept: points => routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points),
385
+ });
386
+ }
387
+
388
+ // A reciprocal pair is repaired jointly after planning, so crossings that
389
+ // involve it are not final yet; keep the established first choice there.
390
+ const reciprocal = (conn) => connections.some((other) => other.from === conn.to && other.to === conn.from);
391
+ function siblingCrossings(conn, points, resolvedRoutes) {
392
+ if (reciprocal(conn)) return 0;
393
+ return crossingCount(conn, points, resolvedRoutes.filter((entry) => !reciprocal(entry.conn)));
394
+ }
395
+
396
+ function routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes = []) {
397
+ if (conn.via) return conn.via;
398
+ switch (conn.route || 'auto') {
399
+ case 'straight':
400
+ return [];
401
+ case 'orthogonal-h': {
402
+ const midX = (start[0] + end[0]) / 2;
403
+ return [[midX, start[1]], [midX, end[1]]];
404
+ }
405
+ case 'orthogonal-v': {
406
+ const midY = (start[1] + end[1]) / 2;
407
+ return [[start[0], midY], [end[0], midY]];
408
+ }
409
+ case 'auto':
410
+ default: {
411
+ // A caller may own the corridor for relationships that would otherwise
412
+ // share one channel. Preferred candidates are tried before the shared
413
+ // families, so a declared trunk or lane wins over the generic midpoint;
414
+ // a candidate that violates the endpoint contract or an obstacle is
415
+ // skipped and the historical order below still applies.
416
+ if (preferredCandidates) {
417
+ for (const candidate of preferredCandidates({ conn, from, to, start, end, fromSide, toSide }) || []) {
418
+ const points = [start, ...candidate, end];
419
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
420
+ && routeClearsEndpointComponents(points, from, to)
421
+ && routeClearsComponents(conn, points)
422
+ && routeMeetsCompositionFloors(points)
423
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
424
+ }
425
+ }
426
+
427
+ // Direct line unless the anchors are clearly orthogonal-friendly.
428
+ const deltaX = Math.abs(start[0] - end[0]);
429
+ const deltaY = Math.abs(start[1] - end[1]);
430
+ if (deltaX < 4 || deltaY < 4) {
431
+ const direct = [start, end];
432
+ if (routeHonorsEndpointSides(direct, fromSide, toSide)
433
+ && routeClearsEndpointComponents(direct, from, to)
434
+ && routeClearsComponents(conn, direct)
435
+ && routeMeetsCompositionFloors(direct)
436
+ && !routeConflictsWithResolved(conn, direct, resolvedRoutes)) return [];
437
+ }
438
+
439
+ const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
440
+ accept: (points) => (
441
+ routeClearsEndpointComponents(points, from, to)
442
+ && routeClearsComponents(conn, points)
443
+ && routeMeetsCompositionFloors(points)
444
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
445
+ ),
446
+ });
447
+ if (rhythmBridge) return rhythmBridge.slice(1, -1);
448
+
449
+ // Automatic port spreading can leave otherwise aligned endpoints only a
450
+ // few pixels apart. A midpoint route would split that tiny difference
451
+ // into two unreadable endpoint stubs, so take a bounded outside channel
452
+ // when both anchors sit on parallel component sides.
453
+ const minimumStub = 8;
454
+ const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
455
+ const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
456
+ if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
457
+ const outsideChannels = [
458
+ Math.max(start[0], end[0]) + minimumStub * 2,
459
+ Math.min(start[0], end[0]) - minimumStub * 2,
460
+ ];
461
+ for (const channelX of outsideChannels) {
462
+ const candidate = [[channelX, start[1]], [channelX, end[1]]];
463
+ const points = [start, ...candidate, end];
464
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
465
+ && routeClearsComponents(conn, points)
466
+ && routeMeetsCompositionFloors(points)
467
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
468
+ }
469
+ }
470
+
471
+ const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
472
+ const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
473
+ if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
474
+ const outsideChannels = [
475
+ Math.max(start[1], end[1]) + minimumStub * 2,
476
+ Math.min(start[1], end[1]) - minimumStub * 2,
477
+ ];
478
+ for (const channelY of outsideChannels) {
479
+ const candidate = [[start[0], channelY], [end[0], channelY]];
480
+ const points = [start, ...candidate, end];
481
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
482
+ && routeClearsComponents(conn, points)
483
+ && routeMeetsCompositionFloors(points)
484
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
485
+ }
486
+ }
487
+
488
+ const midX = (start[0] + end[0]) / 2;
489
+ const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
490
+ const midY = (start[1] + end[1]) / 2;
491
+ const verticalFirst = [[start[0], midY], [end[0], midY]];
492
+ const candidates = [horizontalFirst, verticalFirst];
493
+ const sideSafe = candidates.filter((candidate) => (
494
+ routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
495
+ ));
496
+ const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
497
+ const nearParallelPorts = (
498
+ ((fromSide === 'top' || fromSide === 'bottom')
499
+ && (toSide === 'top' || toSide === 'bottom')
500
+ && deltaX < minimumStub * 2)
501
+ || ((fromSide === 'left' || fromSide === 'right')
502
+ && (toSide === 'left' || toSide === 'right')
503
+ && deltaY < minimumStub * 2)
504
+ );
505
+ const ordered = [
506
+ ...(nearParallelPorts ? sideAware : sideSafe),
507
+ ...(nearParallelPorts ? sideSafe : sideAware),
508
+ ];
509
+ let firstFeasible = null;
510
+ for (const candidate of ordered) {
511
+ const points = [start, ...candidate, end];
512
+ if (!(routeClearsEndpointComponents(points, from, to)
513
+ && routeClearsComponents(conn, points)
514
+ && routeMeetsCompositionFloors(points)
515
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
516
+ && (!distinctAutomaticPorts || (!routeOverlapsResolved(conn, points, resolvedRoutes)
517
+ && !siblingCrossings(conn, points, resolvedRoutes))))) continue;
518
+ if (!conn.label || !labelRectFor || labelRectFor(conn, points, {
519
+ routes: resolvedRoutes.map((entry) => entry.points),
520
+ labels: reservedLabels.map((entry) => entry.rect),
521
+ })) return candidate;
522
+ firstFeasible ||= candidate;
523
+ }
524
+ if (firstFeasible) return firstFeasible;
525
+ // Siblings fanning out from one side all want the same midpoint
526
+ // channel. Step outward through the corridor to a free parallel
527
+ // channel before searching.
528
+ if (distinctAutomaticPorts) {
529
+ const channels = (from, to, mid) => {
530
+ const [low, high] = [Math.min(from, to) + 24, Math.max(from, to) - 24];
531
+ const values = [];
532
+ for (let offset = 16; mid - offset >= low || mid + offset <= high; offset += 16) {
533
+ values.push(...[mid + offset, mid - offset].filter((value) => value >= low && value <= high));
534
+ }
535
+ return values;
536
+ };
537
+ for (const candidate of [
538
+ ...channels(start[0], end[0], midX).map((x) => [[x, start[1]], [x, end[1]]]),
539
+ ...channels(start[1], end[1], midY).map((y) => [[start[0], y], [end[0], y]]),
540
+ ]) {
541
+ const points = [start, ...candidate, end];
542
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
543
+ && routeClearsEndpointComponents(points, from, to)
544
+ && routeClearsComponents(conn, points)
545
+ && routeMeetsCompositionFloors(points)
546
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
547
+ && !routeOverlapsResolved(conn, points, resolvedRoutes)
548
+ && !siblingCrossings(conn, points, resolvedRoutes)) return candidate;
549
+ }
550
+ }
551
+
552
+ // Two-bend doglegs are deliberately cheap, but a real architecture can
553
+ // place adjacent components on both of those corridors. Search a
554
+ // bounded obstacle grid before falling back to a route that the Clean
555
+ // Flow gate already knows violates the inferred endpoint directions.
556
+ // This keeps ordinary multi-bend avoidance renderer-owned instead of
557
+ // forcing the author to hand-place via points.
558
+ // Alternative endpoint pairs are cheap probes, not another grid-search
559
+ // budget. A legal short bridge often beats the first grid detour.
560
+ if (!allowGridSearch) return sideSafe[0] || sideAware[0] || horizontalFirst;
561
+ if (planningMetrics.gridSearchCount >= planningMetrics.maximumGridSearchCount) {
562
+ planningMetrics.gridBudgetExhaustedCount += 1;
563
+ planningMetrics.conflictFallbackCount += 1;
564
+ return sideSafe[0] || sideAware[0] || horizontalFirst;
565
+ }
566
+ // First-draft architecture graphs are not always planar at their
567
+ // authored node positions. The renderer gives automatic crossings a
568
+ // visible halo, so the grid owns opaque-node avoidance and rejects
569
+ // ambiguous shared corridors without forcing the model to hand-route
570
+ // a sprawling perimeter detour. Cheap candidates above still prefer a
571
+ // genuinely crossing-free path whenever one is available.
572
+ if (crossingFreeGridFirst) {
573
+ const unrelatedSegments = unrelatedResolvedRoutes(conn, resolvedRoutes)
574
+ .flatMap((entry) => entry.points.slice(1).map((end, index) => ({ start: entry.points[index], end })));
575
+ for (const gap of unrelatedSegments.length ? gridComponentGaps : []) {
576
+ planningMetrics.gridSearchCount += 1;
577
+ const strict = shortestOrthogonalGridRoute({
578
+ start,
579
+ end,
580
+ points: [start, end],
581
+ obstacles: [...gridComponentObstacles(conn, gap), ...reservedLabelObstacles(2)],
582
+ fromSide,
583
+ toSide,
584
+ clearance: 2,
585
+ maximumObstacleCount: 80,
586
+ endpointStubPx: 24,
587
+ maximumGridNodes: 4096,
588
+ avoidedSegments: unrelatedSegments,
589
+ allowAvoidedCrossings: false,
590
+ minimumAvoidedOverlapPx: 8,
591
+ routeSeparationPx: 8,
592
+ minimumSegmentPx: interiorSegmentPx,
593
+ borderSegments: frameBorders,
594
+ bendPenaltyPx: 48,
595
+ });
596
+ if (strict
597
+ && routeClearsEndpointComponents(strict.points, from, to)
598
+ && routeClearsComponents(conn, strict.points)
599
+ && !routeConflictsWithResolved(conn, strict.points, resolvedRoutes)
600
+ && !routeOverlapsResolved(conn, strict.points, resolvedRoutes)
601
+ && routeMeetsCompositionFloors(strict.points)
602
+ && spacedRouteIsClean(strict.points, gap)) {
603
+ planningMetrics.gridRoutedCount += 1;
604
+ return strict.points.slice(1, -1);
605
+ }
606
+ }
607
+ }
608
+ const avoidedSegments = resolvedRoutes
609
+ .filter((entry) => distinctAutomaticPorts && relationshipsShareEndpoint(conn, entry.conn)
610
+ && !hasAuthoredRouteGeometry(entry.conn) && !entry.conn.labelAt && !conn.labelAt)
611
+ .flatMap((entry) => entry.points.slice(1).map((end, index) => ({
612
+ start: entry.points[index], end,
613
+ })));
614
+ for (const gap of gridComponentGaps) {
615
+ const gridMetrics = {};
616
+ planningMetrics.gridSearchCount += 1;
617
+ planningMetrics.avoidedSegmentCount += avoidedSegments.length;
618
+ const searched = shortestOrthogonalGridRoute({
619
+ start,
620
+ end,
621
+ points: [start, end],
622
+ obstacles: [...gridComponentObstacles(conn, gap), ...reservedLabelObstacles(2)],
623
+ fromSide,
624
+ toSide,
625
+ clearance: 2,
626
+ maximumObstacleCount: 80,
627
+ endpointStubPx: 24,
628
+ maximumGridNodes: 4096,
629
+ avoidedSegments,
630
+ allowAvoidedCrossings: true,
631
+ minimumAvoidedOverlapPx: 8,
632
+ routeSeparationPx: 8,
633
+ minimumSegmentPx: interiorSegmentPx,
634
+ borderSegments: frameBorders,
635
+ bendPenaltyPx: 48,
636
+ metrics: gridMetrics,
637
+ });
638
+ planningMetrics.gridCandidateNodeCount += gridMetrics.candidateNodeCount || 0;
639
+ planningMetrics.gridUsableNodeCount += gridMetrics.usableNodeCount || 0;
640
+ planningMetrics.gridEdgeCount += gridMetrics.graphEdgeCount || 0;
641
+ planningMetrics.gridVisitedNodeCount += gridMetrics.visitedNodeCount || 0;
642
+ const clearsEndpoints = searched
643
+ ? routeClearsEndpointComponents(searched.points, from, to) : false;
644
+ const clearsComponents = searched
645
+ ? routeClearsComponents(conn, searched.points) : false;
646
+ const clearsRelationships = searched
647
+ ? !routeConflictsWithResolved(conn, searched.points, resolvedRoutes) : false;
648
+ const clearsSharedCorridors = searched
649
+ ? !routeOverlapsResolved(conn, searched.points, resolvedRoutes) : false;
650
+ const meetsFloors = searched
651
+ ? routeMeetsCompositionFloors(searched.points) && spacedRouteIsClean(searched.points, gap) : false;
652
+ const accepted = Boolean(
653
+ searched && clearsEndpoints && clearsComponents && clearsSharedCorridors && meetsFloors,
654
+ );
655
+ planningMetrics.gridAttempts.push({
656
+ relationship: conn.id || `${conn.from}->${conn.to}`,
657
+ fromSide,
658
+ toSide,
659
+ inputAvoidedSegmentCount: avoidedSegments.length,
660
+ ...gridMetrics,
661
+ accepted,
662
+ ...(!accepted && searched ? {
663
+ rejectedBy: [
664
+ ...(!clearsEndpoints ? ['endpoint-components'] : []),
665
+ ...(!clearsComponents ? ['components'] : []),
666
+ ...(!clearsSharedCorridors ? ['shared-corridor'] : []),
667
+ ...(!meetsFloors ? ['composition-floors'] : []),
668
+ ],
669
+ candidatePoints: searched.points,
670
+ } : {}),
671
+ });
672
+ if (accepted) {
673
+ planningMetrics.gridRoutedCount += 1;
674
+ if (!clearsRelationships) planningMetrics.crossoverRoutedCount += 1;
675
+ return searched.points.slice(1, -1);
676
+ }
677
+ }
678
+
679
+ // Both bounded doglegs are blocked. Keep the best endpoint-safe route
680
+ // when one exists so the universal Clean Flow gate reports the actual
681
+ // obstacle; otherwise preserve the historical deterministic fallback
682
+ // and let the endpoint-direction gate explain the side mismatch.
683
+ planningMetrics.conflictFallbackCount += 1;
684
+ return sideSafe[0] || sideAware[0] || horizontalFirst;
685
+ }
686
+ }
687
+ }
688
+
689
+ // A node's neighbours laid out as one row below (or above) it read as a
690
+ // fan-out: reach them all through the same vertical side. Center-based
691
+ // inference alone sends the outer ones sideways around their siblings.
692
+ const neighbourRects = new Map();
693
+ for (const conn of connections) {
694
+ const from = components.get(conn.from);
695
+ const to = components.get(conn.to);
696
+ if (!from || !to || from === to) continue;
697
+ for (const [node, other] of [[from, to], [to, from]]) {
698
+ neighbourRects.set(node.id, [...(neighbourRects.get(node.id) || []), other]);
699
+ }
700
+ }
701
+ function rowFanOutSides(from, to) {
702
+ if (!preferReadableRoutes) return null;
703
+ const verticalSides = (node, other) => {
704
+ const below = other.y >= node.y + node.height;
705
+ if (!below && other.y + other.height > node.y) return null;
706
+ if (['top', 'bottom'].includes(defaultFromSide(node, other))) return null;
707
+ const rowSibling = (neighbourRects.get(node.id) || []).some((sibling) => sibling !== other
708
+ && Math.abs(sibling.cy - other.cy) < 1
709
+ && defaultFromSide(node, sibling) === (below ? 'bottom' : 'top'));
710
+ // Only when a node of that row blocks a sideways route to one of the
711
+ // neighbours on this side; otherwise side exits stay clear and keep the
712
+ // vertical side free. Decide per side so a row never mixes both styles.
713
+ const blocked = (target) => {
714
+ const [gapStart, gapEnd] = target.cx < node.cx
715
+ ? [target.x + target.width, node.x] : [node.x + node.width, target.x];
716
+ return [...components.values()].some((rect) => rect !== node && rect !== target
717
+ && rect.y < target.y + target.height && rect.y + rect.height > target.y
718
+ && rect.x < gapEnd && rect.x + rect.width > gapStart);
719
+ };
720
+ const sameSide = (neighbourRects.get(node.id) || []).filter((sibling) => Math.abs(sibling.cy - other.cy) < 1
721
+ && defaultFromSide(node, sibling) === defaultFromSide(node, other));
722
+ return rowSibling && sameSide.some(blocked) ? (below ? 'bottom' : 'top') : null;
723
+ };
724
+ const opposite = { top: 'bottom', bottom: 'top' };
725
+ const fromSide = verticalSides(from, to);
726
+ if (fromSide) return { fromSide, toSide: opposite[fromSide] };
727
+ const toSide = verticalSides(to, from);
728
+ return toSide ? { fromSide: opposite[toSide], toSide } : null;
729
+ }
730
+
731
+ const pathCache = new Map();
732
+ const selectedSides = new Map();
733
+ const stroke = (relation) => relation.width || (relation.variant === 'emphasis' ? 1.8 : 1.5);
734
+ const markerSpacing = (left, right) => 3.5 * (stroke(left) + stroke(right));
735
+ const portSpacing = (left, right) => Math.max(14, markerSpacing(left, right) + 3.5);
736
+ function fanOutSide(conn, endpoint) {
737
+ const fanOut = rowFanOutSides(components.get(conn.from), components.get(conn.to));
738
+ return endpoint === 'source' ? fanOut?.fromSide : fanOut?.toSide;
739
+ }
740
+ // A caller may own the endpoint side of a relationship; whatever it leaves
741
+ // unresolved keeps architecture's row fan-out inference.
742
+ function inferredSide(conn, endpoint) {
743
+ return sideFor?.(conn, endpoint) || fanOutSide(conn, endpoint);
744
+ }
745
+ const automaticPorts = automaticPortSpread(connections, components, {
746
+ sideFor: inferredSide,
747
+ ...(maxPortSpacing === null ? {} : { maxSpacing: maxPortSpacing }),
748
+ // Preserve the established initial placement for ordinary markers; only
749
+ // widen groups whose arrowheads cannot fit the legacy 14px slots.
750
+ ...(distinctAutomaticPorts ? { spacingFor: (left, right) =>
751
+ markerSpacing(left, right) > 14 ? portSpacing(left, right) : 14 } : {}),
752
+ });
753
+ const incidentEndpoints = new Map();
754
+ for (const conn of connections) {
755
+ if (!components.has(conn.from) || !components.has(conn.to)) continue;
756
+ for (const [field, sideField] of [['from', 'fromSide'], ['to', 'toSide']]) {
757
+ const entries = incidentEndpoints.get(conn[field]) || [];
758
+ entries.push({ conn, field, sideField });
759
+ incidentEndpoints.set(conn[field], entries);
760
+ }
761
+ }
762
+ function inferredConnectionSides(conn) {
763
+ const from = components.get(conn.from);
764
+ const to = components.get(conn.to);
765
+ return {
766
+ fromSide: chosenSide(conn.fromSide, inferredSide(conn, 'source') || defaultFromSide(from, to)),
767
+ toSide: chosenSide(conn.toSide, inferredSide(conn, 'target') || defaultToSide(from, to)),
768
+ };
769
+ }
770
+
771
+ function connectionSides(conn) {
772
+ if (!routesPlanned && !routesPlanning) planRoutes();
773
+ return selectedSides.get(conn) || inferredConnectionSides(conn);
774
+ }
775
+
776
+ // The same side inference without the planning pass. A caller that must group
777
+ // relationships before any route exists (the erd renderer assigns a shared
778
+ // trunk per fan-in) reads its sides here: connectionSides() plans routes, and
779
+ // planning asks the grouping what the trunks are.
780
+ function inferredSides(conn) {
781
+ return inferredConnectionSides(conn);
782
+ }
783
+
784
+ function connectionEndpointSide(conn, endpoint) {
785
+ const field = endpoint === 'source' ? 'fromSide' : 'toSide';
786
+ if (conn[field] && conn[field] !== 'auto') return conn[field];
787
+ return connectionSides(conn)[field];
788
+ }
789
+
790
+ function hasAuthoredRouteGeometry(conn) {
791
+ return Boolean(
792
+ conn?.via
793
+ || (conn?.route && conn.route !== 'auto')
794
+ || conn?.channelX !== undefined
795
+ || conn?.channelY !== undefined
796
+ );
797
+ }
798
+
799
+ function hasAuthoredLabelPlacement(conn) {
800
+ return ['labelAt', 'labelDx', 'labelDy', 'labelSegment']
801
+ .some((field) => conn?.[field] !== undefined);
802
+ }
803
+
804
+ // A route can change sides after the initial port spread. Reserve slots on
805
+ // the final side as well: falling back to its midpoint can put an arrow
806
+ // between two existing slots, or directly on another incoming arrow.
807
+ function automaticEndpoint(conn, endpoint, rect, side, inferredSide) {
808
+ const initial = side === inferredSide ? automaticPorts.get(conn)?.[endpoint] : null;
809
+ const preferred = initial || anchor(rect, side);
810
+ if (hasAuthoredRouteGeometry(conn) || conn.labelAt) return { point: preferred, spread: Boolean(initial) };
811
+ const axis = side === 'left' || side === 'right' ? 1 : 0;
812
+ const occupied = [];
813
+ for (const { conn: other, field, sideField } of incidentEndpoints.get(rect.id) || []) {
814
+ if (other === conn || hasAuthoredRouteGeometry(other) || other.labelAt) continue;
815
+ const routed = pathCache.get(other);
816
+ const sides = selectedSides.get(other) || inferredConnectionSides(other);
817
+ if (sides[sideField] !== side) continue;
818
+ const point = routed
819
+ ? (field === 'from' ? routed.points[0] : routed.points.at(-1))
820
+ : automaticPorts.get(other)?.[field] || anchor(rect, side);
821
+ occupied.push({ value: point[axis], spacing: portSpacing(conn, other) });
822
+ }
823
+ if (!occupied.length) return { point: preferred, spread: Boolean(initial) };
824
+ // On an alternate side, equally close slots should approach the other
825
+ // node rather than pass its neighbours just because a coordinate is lower.
826
+ // Keep the established spread order on the inferred side unchanged.
827
+ const counterpart = components.get(endpoint === 'from' ? conn.to : conn.from);
828
+ const toward = side === inferredSide ? preferred[axis] : (axis === 1 ? counterpart.cy : counterpart.cx);
829
+ const candidates = [preferred[axis], ...occupied.flatMap(({ value, spacing }) => [value - spacing, value + spacing])]
830
+ .sort((a, b) => Math.abs(a - preferred[axis]) - Math.abs(b - preferred[axis])
831
+ || Math.abs(a - toward) - Math.abs(b - toward) || a - b);
832
+ for (const value of candidates) {
833
+ const point = [...preferred];
834
+ point[axis] = value;
835
+ if (portHasCornerClearance(rect, side, point)
836
+ && occupied.every((other) => Math.abs(value - other.value) >= other.spacing - 0.0001)) {
837
+ return { point, spread: true };
838
+ }
839
+ }
840
+ return { point: preferred, spread: true, crowded: true };
841
+ }
842
+
843
+ function connectionGeometry(conn, sides = inferredConnectionSides(conn)) {
844
+ const from = components.get(conn.from);
845
+ const to = components.get(conn.to);
846
+ const inferred = inferredConnectionSides(conn);
847
+ const { fromSide, toSide } = sides;
848
+ const legacyPorts = fromSide === inferred.fromSide && toSide === inferred.toSide ? automaticPorts.get(conn) : null;
849
+ const source = distinctAutomaticPorts
850
+ ? automaticEndpoint(conn, 'from', from, fromSide, inferred.fromSide)
851
+ : { point: legacyPorts?.from || anchor(from, fromSide), spread: Boolean(legacyPorts?.from) };
852
+ const target = distinctAutomaticPorts
853
+ ? automaticEndpoint(conn, 'to', to, toSide, inferred.toSide)
854
+ : { point: legacyPorts?.to || anchor(to, toSide), spread: Boolean(legacyPorts?.to) };
855
+ const ports = { from: source.spread, to: target.spread };
856
+ const { start, end } = alignFacingPorts(
857
+ conn,
858
+ from,
859
+ to,
860
+ source.point,
861
+ target.point,
862
+ fromSide,
863
+ toSide,
864
+ ports,
865
+ );
866
+ return { from, to, start, end, fromSide, toSide, crowded: source.crowded || target.crowded };
867
+ }
868
+
869
+ function routedForGeometry(conn, resolvedRoutes, geometry) {
870
+ const { from, to, start, end, fromSide, toSide } = geometry;
871
+ const authoredPoints = [
872
+ start,
873
+ ...routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes),
874
+ end,
875
+ ];
876
+ // Explicit waypoints are author-owned geometry. Keep even a collinear
877
+ // waypoint: it can intentionally split a route at a semantic touch point,
878
+ // and preserving it is part of the backwards-compatible authoring contract.
879
+ // Automatic routes remain normalized so the renderer does not emit noisy
880
+ // duplicate turns or zero-length segments.
881
+ const points = hasAuthoredRouteGeometry(conn)
882
+ ? authoredPoints
883
+ : normalizeRoutePoints(authoredPoints);
884
+ return { d: roundedPath(points, 8), points };
885
+ }
886
+
887
+ function cachePath(conn, routed, sides) {
888
+ pathCache.set(conn, routed);
889
+ selectedSides.set(conn, { fromSide: sides.fromSide, toSide: sides.toSide });
890
+ return routed;
891
+ }
892
+
893
+ const SIDE_ORDER = ['right', 'bottom', 'left', 'top'];
894
+ function candidateSidePairs(conn) {
895
+ const inferred = inferredConnectionSides(conn);
896
+ const authoredFrom = conn.fromSide && conn.fromSide !== 'auto' ? conn.fromSide : null;
897
+ const authoredTo = conn.toSide && conn.toSide !== 'auto' ? conn.toSide : null;
898
+ const fromOptions = authoredFrom
899
+ ? [authoredFrom]
900
+ : [inferred.fromSide, ...SIDE_ORDER.filter((side) => side !== inferred.fromSide)];
901
+ const toOptions = authoredTo
902
+ ? [authoredTo]
903
+ : [inferred.toSide, ...SIDE_ORDER.filter((side) => side !== inferred.toSide)];
904
+ return fromOptions.flatMap((fromSide) => toOptions.map((toSide) => ({
905
+ fromSide,
906
+ toSide,
907
+ deviationCount: Number(fromSide !== inferred.fromSide) + Number(toSide !== inferred.toSide),
908
+ }))).sort((left, right) => {
909
+ if (left.deviationCount !== right.deviationCount) {
910
+ return left.deviationCount - right.deviationCount;
911
+ }
912
+ const leftGeometry = connectionGeometry(conn, left);
913
+ const rightGeometry = connectionGeometry(conn, right);
914
+ const leftDistance = Math.abs(leftGeometry.end[0] - leftGeometry.start[0])
915
+ + Math.abs(leftGeometry.end[1] - leftGeometry.start[1]);
916
+ const rightDistance = Math.abs(rightGeometry.end[0] - rightGeometry.start[0])
917
+ + Math.abs(rightGeometry.end[1] - rightGeometry.start[1]);
918
+ return leftDistance - rightDistance;
919
+ });
920
+ }
921
+
922
+ function routeIsClear(conn, routed, geometry, resolvedRoutes) {
923
+ return !geometry.crowded && routed.points.length >= 2
924
+ && routeHonorsEndpointSides(routed.points, geometry.fromSide, geometry.toSide)
925
+ && routeClearsEndpointComponents(routed.points, geometry.from, geometry.to)
926
+ && routeClearsComponents(conn, routed.points)
927
+ && routeMeetsCompositionFloors(routed.points)
928
+ && !routeOverlapsResolved(conn, routed.points, resolvedRoutes);
929
+ }
930
+
931
+ function crossingCount(conn, points, resolvedRoutes) {
932
+ let count = 0;
933
+ for (const entry of resolvedRoutes) {
934
+ if (relationshipsShareEndpoint(conn, entry.conn)
935
+ && (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt)) continue;
936
+ let crosses = false;
937
+ for (let index = 1; index < points.length && !crosses; index += 1) {
938
+ for (let otherIndex = 1; otherIndex < entry.points.length; otherIndex += 1) {
939
+ if (properSegmentIntersection(points[index - 1], points[index], entry.points[otherIndex - 1], entry.points[otherIndex])) {
940
+ crosses = true;
941
+ break;
942
+ }
943
+ }
944
+ }
945
+ if (crosses) count += 1;
946
+ }
947
+ return count;
948
+ }
949
+
950
+ function readabilityCost(conn, routed, resolvedRoutes) {
951
+ const points = routed.points;
952
+ const length = points.slice(1).reduce((total, point, index) => total
953
+ + Math.abs(point[0] - points[index][0]) + Math.abs(point[1] - points[index][1]), 0);
954
+ // A crossover is a reading cost even with a halo. Keep it finite: avoiding
955
+ // one crossing must not justify an arbitrarily long perimeter excursion.
956
+ return length + Math.max(0, points.length - 2) * 48 + crossingCount(conn, points, resolvedRoutes) * 160;
957
+ }
958
+
959
+ function computePath(conn, resolvedRoutes) {
960
+ if (hasAuthoredRouteGeometry(conn)) {
961
+ const sides = inferredConnectionSides(conn);
962
+ return cachePath(conn, routedForGeometry(
963
+ conn,
964
+ resolvedRoutes,
965
+ connectionGeometry(conn, sides),
966
+ ), sides);
967
+ }
968
+
969
+ let firstFallback = null;
970
+ const clearRoute = () => {
971
+ let firstClear = null;
972
+ for (const sides of candidateSidePairs(conn)) {
973
+ const geometry = connectionGeometry(conn, sides);
974
+ const routed = routedForGeometry(conn, resolvedRoutes, geometry);
975
+ if (!firstFallback) firstFallback = { routed, sides };
976
+ if (routeIsClear(conn, routed, geometry, resolvedRoutes)) {
977
+ firstClear = { routed, sides };
978
+ break;
979
+ }
980
+ }
981
+ return firstClear;
982
+ };
983
+ const routeLength = ({ routed }) => routed.points.slice(1)
984
+ .reduce((total, point, index) => total + Math.abs(point[0] - routed.points[index][0]) + Math.abs(point[1] - routed.points[index][1]), 0);
985
+
986
+ // Reserved labels are a preference, not an obstacle. A route that has no
987
+ // corridor around them, or that would have to detour far around them,
988
+ // takes the plain route instead and the label placement pass moves the
989
+ // label; only a modest extra length is worth keeping a label in place.
990
+ honourReservedLabels = true;
991
+ let chosen = clearRoute();
992
+ if (reservedLabels.length) {
993
+ honourReservedLabels = false;
994
+ if (!chosen) chosen = clearRoute();
995
+ else {
996
+ const direct = Math.abs(chosen.routed.points.at(-1)[0] - chosen.routed.points[0][0])
997
+ + Math.abs(chosen.routed.points.at(-1)[1] - chosen.routed.points[0][1]);
998
+ if (routeLength(chosen) > direct * 1.25 + 64) {
999
+ const plain = clearRoute();
1000
+ if (plain && routeLength(plain) * 1.25 + 64 < routeLength(chosen)) chosen = plain;
1001
+ }
1002
+ }
1003
+ honourReservedLabels = true;
1004
+ }
1005
+ if (chosen) return cachePath(conn, chosen.routed, chosen.sides);
1006
+ return cachePath(conn, firstFallback.routed, firstFallback.sides);
1007
+ }
1008
+
1009
+ let routesPlanned = false;
1010
+ let routesPlanning = false;
1011
+ function planRoutes() {
1012
+ if (routesPlanned || routesPlanning) return;
1013
+ routesPlanning = true;
1014
+ const indexed = connections
1015
+ .map((conn, index) => ({ conn, index }))
1016
+ .filter(({ conn }) => components.has(conn.from) && components.has(conn.to));
1017
+ const explicit = indexed.filter(({ conn }) => hasAuthoredRouteGeometry(conn));
1018
+ const automatic = indexed.filter(({ conn }) => !hasAuthoredRouteGeometry(conn))
1019
+ .sort((left, right) => {
1020
+ const leftFrom = components.get(left.conn.from);
1021
+ const leftTo = components.get(left.conn.to);
1022
+ const rightFrom = components.get(right.conn.from);
1023
+ const rightTo = components.get(right.conn.to);
1024
+ const leftDistance = Math.abs(leftTo.cx - leftFrom.cx) + Math.abs(leftTo.cy - leftFrom.cy);
1025
+ const rightDistance = Math.abs(rightTo.cx - rightFrom.cx) + Math.abs(rightTo.cy - rightFrom.cy);
1026
+ return leftDistance - rightDistance || left.index - right.index;
1027
+ });
1028
+ const resolvedRoutes = [];
1029
+ reservedLabels = [];
1030
+ for (const { conn } of [...explicit, ...automatic]) {
1031
+ const routed = computePath(conn, resolvedRoutes);
1032
+ resolvedRoutes.push({ conn, points: routed.points });
1033
+ const rect = labelRectFor?.(conn, routed.points, {
1034
+ routes: resolvedRoutes.map((entry) => entry.points),
1035
+ labels: reservedLabels.map((entry) => entry.rect),
1036
+ });
1037
+ if (rect) reservedLabels.push({ conn, rect });
1038
+ }
1039
+ // Improve against the complete set of routes, not only earlier edges.
1040
+ // A greedy side change during the initial pass can steal a later edge's
1041
+ // corridor. This bounded sweep only changes one route at a time, keeping
1042
+ // every other route and label as an obstacle/reference.
1043
+ if (preferReadableRoutes) {
1044
+ const scenePoints = resolvedRoutes.flatMap((entry) => entry.points);
1045
+ for (const rect of [...components.values(), ...frames, ...reservedLabels.map((entry) => entry.rect)]) {
1046
+ scenePoints.push([rect.x, rect.y], [rect.x + rect.width, rect.y + rect.height]);
1047
+ }
1048
+ const bounds = {
1049
+ left: Math.min(...scenePoints.map(([x]) => x)), right: Math.max(...scenePoints.map(([x]) => x)),
1050
+ top: Math.min(...scenePoints.map(([, y]) => y)),
1051
+ bottom: Math.max(...scenePoints.map(([, y]) => y)),
1052
+ };
1053
+ const withinScene = ([x, y]) => x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
1054
+ const jointlyImproved = new Set();
1055
+ const endpointSlotsClear = (candidateRoutes, others, sidesOf) => {
1056
+ const entries = [...others, ...candidateRoutes];
1057
+ for (const candidate of candidateRoutes) {
1058
+ const sides = sidesOf(candidate.conn);
1059
+ for (const [componentId, side, point] of [
1060
+ [candidate.conn.from, sides.fromSide, candidate.points[0]],
1061
+ [candidate.conn.to, sides.toSide, candidate.points.at(-1)],
1062
+ ]) {
1063
+ const coordinate = side === 'left' || side === 'right' ? 1 : 0;
1064
+ for (const other of entries) {
1065
+ if (other.conn === candidate.conn) continue;
1066
+ const otherSides = sidesOf(other.conn);
1067
+ for (const [otherId, otherSide, otherPoint] of [
1068
+ [other.conn.from, otherSides.fromSide, other.points[0]],
1069
+ [other.conn.to, otherSides.toSide, other.points.at(-1)],
1070
+ ]) {
1071
+ if (componentId === otherId && side === otherSide
1072
+ && Math.abs(point[coordinate] - otherPoint[coordinate])
1073
+ < portSpacing(candidate.conn, other.conn) - 0.0001) return false;
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ return true;
1079
+ };
1080
+ const labelClears = (rect, owner, entries, labels) => {
1081
+ if (!rect) return !owner.label;
1082
+ if (!withinScene([rect.x, rect.y])
1083
+ || !withinScene([rect.x + rect.width, rect.y + rect.height])
1084
+ || labels.some((other) => rectsOverlap(rect, other.rect, 2))) return false;
1085
+ return entries.every((entry) => entry.conn === owner || entry.points.slice(1).every((end, index) =>
1086
+ !segmentIntersectsRect({ start: entry.points[index], end }, rect, LABEL_CLEARANCE)));
1087
+ };
1088
+ function improveReciprocalPairs() {
1089
+ // A one-route sweep cannot repair reciprocal facing edges whose initial
1090
+ // spread groups differ. Try both direct lanes together after final side
1091
+ // geometry is known, leaving every other route and authored port in place.
1092
+ const paired = new Set();
1093
+ for (const first of resolvedRoutes) {
1094
+ const conn = first.conn;
1095
+ if (paired.has(conn) || typeOwned.has(conn) || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)
1096
+ || (conn.fromSide && conn.fromSide !== 'auto')
1097
+ || (conn.toSide && conn.toSide !== 'auto')) continue;
1098
+ const second = resolvedRoutes.find((entry) => entry !== first
1099
+ && entry.conn.from === conn.to && entry.conn.to === conn.from && !typeOwned.has(entry.conn)
1100
+ && !hasAuthoredRouteGeometry(entry.conn) && !hasAuthoredLabelPlacement(entry.conn)
1101
+ && (!entry.conn.fromSide || entry.conn.fromSide === 'auto')
1102
+ && (!entry.conn.toSide || entry.conn.toSide === 'auto'));
1103
+ if (!second) continue;
1104
+ paired.add(conn);
1105
+ paired.add(second.conn);
1106
+ const firstSides = inferredConnectionSides(conn);
1107
+ const secondSides = inferredConnectionSides(second.conn);
1108
+ const horizontal = firstSides.fromSide === 'right' && firstSides.toSide === 'left'
1109
+ && secondSides.fromSide === 'left' && secondSides.toSide === 'right';
1110
+ const horizontalReverse = firstSides.fromSide === 'left' && firstSides.toSide === 'right'
1111
+ && secondSides.fromSide === 'right' && secondSides.toSide === 'left';
1112
+ const vertical = firstSides.fromSide === 'bottom' && firstSides.toSide === 'top'
1113
+ && secondSides.fromSide === 'top' && secondSides.toSide === 'bottom';
1114
+ const verticalReverse = firstSides.fromSide === 'top' && firstSides.toSide === 'bottom'
1115
+ && secondSides.fromSide === 'bottom' && secondSides.toSide === 'top';
1116
+ if (!horizontal && !horizontalReverse && !vertical && !verticalReverse) continue;
1117
+ if (first.points.length <= 2 && second.points.length <= 2) continue;
1118
+ const axis = horizontal || horizontalReverse ? 1 : 0;
1119
+ const others = resolvedRoutes.filter((entry) => entry !== first && entry !== second);
1120
+ const firstGeometry = connectionGeometry(conn, firstSides);
1121
+ const secondGeometry = connectionGeometry(second.conn, secondSides);
1122
+ if (firstGeometry.crowded || secondGeometry.crowded) continue;
1123
+ const previousLabels = reservedLabels;
1124
+ let improved = false;
1125
+ reservedLabels = reservedLabels.filter((entry) => entry.conn !== conn && entry.conn !== second.conn);
1126
+ try {
1127
+ const lanes = (geometry) => [...new Set([geometry.start[axis], geometry.end[axis]])];
1128
+ const direct = (geometry, lane) => {
1129
+ const start = [...geometry.start];
1130
+ const end = [...geometry.end];
1131
+ start[axis] = lane;
1132
+ end[axis] = lane;
1133
+ return { points: [start, end], d: roundedPath([start, end], 8) };
1134
+ };
1135
+ const pairSides = (candidate) => (candidate === conn ? firstSides
1136
+ : candidate === second.conn ? secondSides : selectedSides.get(candidate));
1137
+ let best = null;
1138
+ let bestCost = readabilityCost(conn, first, [...others, second])
1139
+ + readabilityCost(second.conn, second, [...others, first]);
1140
+ for (const firstLane of lanes(firstGeometry)) {
1141
+ for (const secondLane of lanes(secondGeometry)) {
1142
+ planningMetrics.reciprocalCandidateCount += 1;
1143
+ const firstRoute = { conn, ...direct(firstGeometry, firstLane) };
1144
+ const secondRoute = { conn: second.conn, ...direct(secondGeometry, secondLane) };
1145
+ const candidates = [firstRoute, secondRoute];
1146
+ if (!candidates.every((entry) => entry.points.every(withinScene))) continue;
1147
+ if (!endpointSlotsClear(candidates, others, pairSides)) continue;
1148
+ if (!candidates.every((entry, index) => {
1149
+ const geometry = entry.conn === conn ? firstGeometry : secondGeometry;
1150
+ if (!portHasCornerClearance(geometry.from, geometry.fromSide, entry.points[0])
1151
+ || !portHasCornerClearance(geometry.to, geometry.toSide, entry.points.at(-1))) return false;
1152
+ return routeIsClear(entry.conn, entry, geometry, [...others, candidates[1 - index]]);
1153
+ })) continue;
1154
+ const firstRect = labelRectFor?.(conn, firstRoute.points, {
1155
+ routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
1156
+ labels: reservedLabels.map((entry) => entry.rect),
1157
+ });
1158
+ if (!labelClears(firstRect, conn, [...others, secondRoute], reservedLabels)) continue;
1159
+ const secondRect = labelRectFor?.(second.conn, secondRoute.points, {
1160
+ routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
1161
+ labels: [...reservedLabels.map((entry) => entry.rect), ...(firstRect ? [firstRect] : [])],
1162
+ });
1163
+ if (!labelClears(secondRect, second.conn, [...others, firstRoute], [
1164
+ ...reservedLabels, ...(firstRect ? [{ conn, rect: firstRect }] : []),
1165
+ ])) continue;
1166
+ const cost = readabilityCost(conn, firstRoute, [...others, secondRoute])
1167
+ + readabilityCost(second.conn, secondRoute, [...others, firstRoute]);
1168
+ if (cost < bestCost) {
1169
+ best = { firstRoute, secondRoute, firstRect, secondRect };
1170
+ bestCost = cost;
1171
+ }
1172
+ }
1173
+ }
1174
+ if (!best) continue;
1175
+ cachePath(conn, best.firstRoute, firstSides);
1176
+ cachePath(second.conn, best.secondRoute, secondSides);
1177
+ first.points = best.firstRoute.points;
1178
+ second.points = best.secondRoute.points;
1179
+ planningMetrics.reciprocalImprovedCount += 1;
1180
+ jointlyImproved.add(conn);
1181
+ jointlyImproved.add(second.conn);
1182
+ improved = true;
1183
+ reservedLabels = [
1184
+ ...reservedLabels,
1185
+ ...(best.firstRect ? [{ conn, rect: best.firstRect }] : []),
1186
+ ...(best.secondRect ? [{ conn: second.conn, rect: best.secondRect }] : []),
1187
+ ];
1188
+ } finally {
1189
+ if (!improved) reservedLabels = previousLabels;
1190
+ }
1191
+ }
1192
+ }
1193
+ // A type-owned corridor (an erd bundled trunk or dedicated lane) is the
1194
+ // route its renderer draws around; the generic sweep must not trade it
1195
+ // for a shorter lane and leave the bus without its branch.
1196
+ const followsPreferredCorridor = (entry) => {
1197
+ if (!preferredCandidates) return false;
1198
+ const sides = selectedSides.get(entry.conn);
1199
+ if (!sides) return false;
1200
+ const interior = JSON.stringify(entry.points.slice(1, -1));
1201
+ return (preferredCandidates({
1202
+ conn: entry.conn,
1203
+ from: components.get(entry.conn.from),
1204
+ to: components.get(entry.conn.to),
1205
+ start: entry.points[0],
1206
+ end: entry.points.at(-1),
1207
+ fromSide: sides.fromSide,
1208
+ toSide: sides.toSide,
1209
+ }) || []).some((candidate) => JSON.stringify(candidate) === interior);
1210
+ };
1211
+ const typeOwned = new Set(resolvedRoutes.filter(followsPreferredCorridor).map((entry) => entry.conn));
1212
+ function relocatedLabelsFor(conn, routed, others) {
1213
+ // A provisional label may occupy an otherwise clear corridor. Move at
1214
+ // most one automatic label, on its existing route, and accept the pair
1215
+ // only when both labels fit without borrowing other labels' space.
1216
+ const displaced = reservedLabels.filter((label) => label.conn !== conn && routed.points.slice(1).some((end, index) =>
1217
+ segmentIntersectsRect({ start: routed.points[index], end }, label.rect, LABEL_CLEARANCE)));
1218
+ if (displaced.length !== 1) return null;
1219
+ const owner = displaced[0].conn;
1220
+ if (typeOwned.has(owner) || hasAuthoredRouteGeometry(owner) || hasAuthoredLabelPlacement(owner)) return null;
1221
+ const labels = reservedLabels.filter((label) => label.conn !== conn && label.conn !== owner);
1222
+ const routes = [...others, { conn, points: routed.points }];
1223
+ const moved = routes.find((entry) => entry.conn === owner);
1224
+ for (const entry of [moved, { conn, points: routed.points }]) {
1225
+ const rect = labelRectFor?.(entry.conn, entry.points, {
1226
+ routes: routes.map((route) => route.points),
1227
+ labels: labels.map((label) => label.rect),
1228
+ });
1229
+ if (!labelClears(rect, entry.conn, routes, labels)
1230
+ || (rect && [...components.values()].some((component) => rectsOverlap(rect, component, -2)))) return null;
1231
+ if (rect) labels.push({ conn: entry.conn, rect });
1232
+ }
1233
+ return labels;
1234
+ }
1235
+ function improveComplexRoutes(onlyCrossings = false) {
1236
+ for (const entry of resolvedRoutes) {
1237
+ const { conn } = entry;
1238
+ if (jointlyImproved.has(conn) || typeOwned.has(conn) || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)) continue;
1239
+ const others = resolvedRoutes.filter((other) => other !== entry);
1240
+ // Preserve uncomplicated routes and their established inferred sides.
1241
+ // Spend the comparison budget where the complete scene has a reading
1242
+ // cost: a crossover, more than two bends, or no room for its label.
1243
+ const crossingsBefore = crossingCount(conn, entry.points, others);
1244
+ const currentLabel = labelRectFor?.(conn, entry.points, {
1245
+ routes: others.map(other => other.points),
1246
+ labels: reservedLabels.filter(label => label.conn !== conn).map(label => label.rect),
1247
+ });
1248
+ const labelBlocked = Boolean(conn.label && labelRectFor && !currentLabel);
1249
+ if (!labelBlocked && crossingsBefore === 0 && (onlyCrossings || entry.points.length <= 4)) continue;
1250
+ let best = null;
1251
+ let bestCost = labelBlocked ? Infinity : readabilityCost(conn, { points: entry.points }, others);
1252
+ for (const sides of candidateSidePairs(conn)) {
1253
+ const geometry = connectionGeometry(conn, sides);
1254
+ const routed = routedForGeometry(conn, others, geometry);
1255
+ planningMetrics.readabilityCandidateCount += 1;
1256
+ if (!routed.points.every(withinScene) || !routeIsClear(conn, routed, geometry, others)) continue;
1257
+ if (onlyCrossings && crossingCount(conn, routed.points, others) >= crossingsBefore) continue;
1258
+ const cost = readabilityCost(conn, routed, others);
1259
+ if (cost < bestCost) {
1260
+ const rect = labelRectFor?.(conn, routed.points, {
1261
+ routes: [...others.map((other) => other.points), routed.points],
1262
+ labels: reservedLabels.filter((label) => label.conn !== conn).map((label) => label.rect),
1263
+ });
1264
+ // Include the reserved label in this pass's bounds. In
1265
+ // particular, a shorter route must not widen and shrink the graph.
1266
+ if (labelRectFor && !labelClears(rect, conn, others, reservedLabels.filter(label => label.conn !== conn))) continue;
1267
+ if (onlyCrossings && (!endpointSlotsClear([{ conn, points: routed.points }], others,
1268
+ candidate => candidate === conn ? sides : selectedSides.get(candidate))
1269
+ || (rect && [...components.values()].some(component => rectsOverlap(rect, component, -2))))) continue;
1270
+ best = { routed, sides, rect };
1271
+ bestCost = cost;
1272
+ }
1273
+ }
1274
+ if (!best) continue;
1275
+ cachePath(conn, best.routed, best.sides);
1276
+ entry.points = best.routed.points;
1277
+ planningMetrics.readabilityImprovedCount += 1;
1278
+ reservedLabels = reservedLabels.filter((label) => label.conn !== conn);
1279
+ if (best.rect) reservedLabels.push({ conn, rect: best.rect });
1280
+ }
1281
+ }
1282
+ allowGridSearch = false;
1283
+ try {
1284
+ improveReciprocalPairs();
1285
+ improveComplexRoutes();
1286
+ // A bent route between nodes that overlap across the gap separating
1287
+ // them can run straight through facing sides inside that overlap.
1288
+ // Authored sides must already face; every other route, slot and label
1289
+ // stays fixed, and the straight lane must pass the same checks.
1290
+ for (const entry of resolvedRoutes) {
1291
+ const { conn } = entry;
1292
+ if (entry.points.length < 4 || jointlyImproved.has(conn) || typeOwned.has(conn)
1293
+ || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)) continue;
1294
+ const from = components.get(conn.from);
1295
+ const to = components.get(conn.to);
1296
+ const facing = from.x + from.width <= to.x ? { fromSide: 'right', toSide: 'left' }
1297
+ : to.x + to.width <= from.x ? { fromSide: 'left', toSide: 'right' }
1298
+ : from.y + from.height <= to.y ? { fromSide: 'bottom', toSide: 'top' }
1299
+ : to.y + to.height <= from.y ? { fromSide: 'top', toSide: 'bottom' } : null;
1300
+ if (!facing) continue;
1301
+ const axis = facing.fromSide === 'right' || facing.fromSide === 'left' ? 1 : 0;
1302
+ const [origin, size] = axis === 1 ? ['y', 'height'] : ['x', 'width'];
1303
+ const low = Math.max(from[origin], to[origin]) + AUTOMATIC_PORT_CORNER_GUTTER;
1304
+ const high = Math.min(from[origin] + from[size], to[origin] + to[size]) - AUTOMATIC_PORT_CORNER_GUTTER;
1305
+ if (high < low
1306
+ || (conn.fromSide && conn.fromSide !== 'auto' && conn.fromSide !== facing.fromSide)
1307
+ || (conn.toSide && conn.toSide !== 'auto' && conn.toSide !== facing.toSide)) continue;
1308
+ const sides = facing;
1309
+ const geometry = connectionGeometry(conn, sides);
1310
+ const start = geometry.start;
1311
+ const end = geometry.end;
1312
+ const clamp = (value) => Math.min(high, Math.max(low, value));
1313
+ const others = resolvedRoutes.filter((other) => other !== entry);
1314
+ const otherLabels = reservedLabels.filter((label) => label.conn !== conn);
1315
+ const crossingsBefore = crossingCount(conn, entry.points, others);
1316
+ const sidesOf = (candidate) => (candidate === conn ? sides : selectedSides.get(candidate));
1317
+ for (const lane of new Set([start[axis], end[axis], (start[axis] + end[axis]) / 2, (low + high) / 2].map(clamp))) {
1318
+ const points = [[...start], [...end]];
1319
+ points[0][axis] = lane;
1320
+ points[1][axis] = lane;
1321
+ const routed = { conn, points, d: roundedPath(points, 8) };
1322
+ if (!points.every(withinScene)
1323
+ || !endpointSlotsClear([routed], others, sidesOf)
1324
+ || !routeIsClear(conn, routed, geometry, others)
1325
+ || crossingCount(conn, points, others) > crossingsBefore) continue;
1326
+ const rect = labelRectFor?.(conn, points, {
1327
+ routes: [...others.map((other) => other.points), points],
1328
+ labels: otherLabels.map((label) => label.rect),
1329
+ });
1330
+ if (!labelClears(rect, conn, others, otherLabels)) continue;
1331
+ cachePath(conn, { points, d: routed.d }, sides);
1332
+ entry.points = points;
1333
+ planningMetrics.readabilityImprovedCount += 1;
1334
+ reservedLabels = [...otherLabels, ...(rect ? [{ conn, rect }] : [])];
1335
+ break;
1336
+ }
1337
+ }
1338
+ // Finish the established route/straightening plan first. Moving a
1339
+ // label earlier could prevent a later edge from taking its straight
1340
+ // corridor, even though the local candidate was cheaper.
1341
+ for (const entry of resolvedRoutes) {
1342
+ const { conn } = entry;
1343
+ if (jointlyImproved.has(conn) || typeOwned.has(conn) || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)) continue;
1344
+ const others = resolvedRoutes.filter((other) => other !== entry);
1345
+ if (entry.points.length <= 4 && crossingCount(conn, entry.points, others) === 0) continue;
1346
+ let best = null;
1347
+ let bestCost = readabilityCost(conn, entry, others);
1348
+ if (labelRectFor && reservedLabels.some((label) => label.conn !== conn)) {
1349
+ // One additional side sweep, with no grid search or cascading
1350
+ // relayout. Temporary reservations can be ignored only after a
1351
+ // replacement placement is verified against the complete scene.
1352
+ for (const sides of candidateSidePairs(conn)) {
1353
+ const geometry = connectionGeometry(conn, sides);
1354
+ let routed;
1355
+ let clear;
1356
+ honourReservedLabels = false;
1357
+ try {
1358
+ routed = routedForGeometry(conn, others, geometry);
1359
+ clear = routeIsClear(conn, routed, geometry, others);
1360
+ } finally {
1361
+ honourReservedLabels = true;
1362
+ }
1363
+ planningMetrics.labelRelocationCandidateCount += 1;
1364
+ const cost = readabilityCost(conn, routed, others);
1365
+ if (cost >= bestCost || !clear || !routed.points.every(withinScene)
1366
+ || crossingCount(conn, routed.points, others) > crossingCount(conn, entry.points, others)
1367
+ || !endpointSlotsClear([{ conn, points: routed.points }], others,
1368
+ (candidate) => candidate === conn ? sides : selectedSides.get(candidate))) continue;
1369
+ const labels = relocatedLabelsFor(conn, routed, others);
1370
+ if (!labels) continue;
1371
+ best = { routed, sides, labels };
1372
+ bestCost = cost;
1373
+ }
1374
+ }
1375
+ if (!best) continue;
1376
+ cachePath(conn, best.routed, best.sides);
1377
+ entry.points = best.routed.points;
1378
+ reservedLabels = best.labels;
1379
+ planningMetrics.labelRelocationImprovedCount += 1;
1380
+ }
1381
+ // A side's midpoint may be blocked even when a corner port has a
1382
+ // clear one-bend route. Probe bent automatic routes, keeping
1383
+ // all other paths/labels fixed and the existing corner inset intact.
1384
+ // Eight perpendicular side pairs, at most three slots per endpoint:
1385
+ // 72 cheap candidates per route, without another grid search.
1386
+ for (const entry of resolvedRoutes) {
1387
+ const { conn } = entry;
1388
+ if (entry.points.length < 4 || jointlyImproved.has(conn) || typeOwned.has(conn)
1389
+ || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)) continue;
1390
+ const others = resolvedRoutes.filter((other) => other !== entry);
1391
+ const labels = reservedLabels.filter((label) => label.conn !== conn);
1392
+ const crossingsBefore = crossingCount(conn, entry.points, others);
1393
+ let best = null;
1394
+ let bestCost = readabilityCost(conn, entry, others);
1395
+ const slots = (rect, side, preferred) => {
1396
+ const horizontal = side === 'left' || side === 'right';
1397
+ const axis = horizontal ? 1 : 0;
1398
+ const origin = horizontal ? rect.y : rect.x;
1399
+ const size = horizontal ? rect.height : rect.width;
1400
+ const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, size / 2);
1401
+ return [...new Set([preferred[axis], origin + inset, origin + size - inset])].map((value) => {
1402
+ const point = [...preferred];
1403
+ point[axis] = value;
1404
+ return point;
1405
+ }).filter((point) => portHasCornerClearance(rect, side, point));
1406
+ };
1407
+ for (const sides of candidateSidePairs(conn)) {
1408
+ const horizontal = (side) => side === 'left' || side === 'right';
1409
+ if (horizontal(sides.fromSide) === horizontal(sides.toSide)) continue;
1410
+ const geometry = connectionGeometry(conn, sides);
1411
+ for (const start of slots(geometry.from, sides.fromSide, geometry.start)) {
1412
+ for (const end of slots(geometry.to, sides.toSide, geometry.end)) {
1413
+ const corner = horizontal(sides.fromSide) ? [end[0], start[1]] : [start[0], end[1]];
1414
+ const points = normalizeRoutePoints([start, corner, end]);
1415
+ const routed = { points, d: roundedPath(points, 8) };
1416
+ const cost = readabilityCost(conn, routed, others);
1417
+ if (cost >= bestCost || points.length !== 3 || !points.every(withinScene)
1418
+ || !endpointSlotsClear([{ conn, points }], others,
1419
+ (candidate) => candidate === conn ? sides : selectedSides.get(candidate))
1420
+ || !routeIsClear(conn, routed, { ...geometry, start, end, crowded: false }, others)
1421
+ || crossingCount(conn, points, others) > crossingsBefore) continue;
1422
+ const rect = labelRectFor?.(conn, points, {
1423
+ routes: [...others.map((other) => other.points), points],
1424
+ labels: labels.map((label) => label.rect),
1425
+ });
1426
+ if (!labelClears(rect, conn, others, labels)
1427
+ || (rect && [...components.values()].some((component) => rectsOverlap(rect, component, -2)))) continue;
1428
+ best = { routed, sides, rect };
1429
+ bestCost = cost;
1430
+ }
1431
+ }
1432
+ }
1433
+ if (!best) continue;
1434
+ cachePath(conn, best.routed, best.sides);
1435
+ entry.points = best.routed.points;
1436
+ reservedLabels = [...labels, ...(best.rect ? [{ conn, rect: best.rect }] : [])];
1437
+ planningMetrics.readabilityImprovedCount += 1;
1438
+ }
1439
+ // Complete the original bounded plan first. Only a remaining crossing
1440
+ // can justify using an extra lower corridor; an already clear scene
1441
+ // must not grow merely because its labels could move into that space.
1442
+ if (readabilityBottomMargin > 0) {
1443
+ bounds.bottom += readabilityBottomMargin;
1444
+ improveComplexRoutes(true);
1445
+ }
1446
+ } finally {
1447
+ allowGridSearch = true;
1448
+ }
1449
+ }
1450
+ planningMetrics.routeCount = resolvedRoutes.length;
1451
+ planningMetrics.explicitRouteCount = explicit.length;
1452
+ planningMetrics.automaticRouteCount = automatic.length;
1453
+ routesPlanning = false;
1454
+ routesPlanned = true;
1455
+ }
1456
+
1457
+ function pathFor(conn) {
1458
+ planRoutes();
1459
+ if (pathCache.has(conn)) return pathCache.get(conn);
1460
+ return computePath(conn, []);
1461
+ }
1462
+
1463
+ function routingMetrics() {
1464
+ planRoutes();
1465
+ return { ...planningMetrics };
1466
+ }
1467
+
1468
+ return { ports: automaticPorts, pathFor, connectionSides, inferredSides, connectionEndpointSide, routingMetrics };
1469
+ }