@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
@@ -1,5 +1,17 @@
1
+ import { proposeWorkflowPlacement } from './workflow-placement-repair.mjs';
2
+ import { createSpatialGrid } from '../shared/spatial-grid.mjs';
3
+ import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
1
4
  import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
2
- import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
+ import { NODE_CARD_ATTR, nodeCardStyle } from '../shared/node-card-style.mjs';
6
+ import {
7
+ animateAttr,
8
+ focusEdgeAttrs,
9
+ focusNodeAttrs,
10
+ focusNodeTitle,
11
+ svgAccessibleText,
12
+ svgRootAttrs,
13
+ validateCrossCollectionContracts,
14
+ } from '../shared/cli.mjs';
3
15
  import {
4
16
  throwDiagnosticError,
5
17
  throwDiagnosticProblems,
@@ -13,15 +25,18 @@ import {
13
25
  resolveLegend,
14
26
  renderLegend as renderResolvedLegend,
15
27
  } from '../shared/legend.mjs';
16
- import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
17
- import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
28
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
29
+ import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
18
30
  import { translateMessage as i18nText } from '../shared/i18n.mjs';
31
+ import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX, minimumReadableSourceTextPx, predictedFixedWidthOverflow } from '../shared/desktop-readability.mjs';
19
32
  import {
20
33
  createMappedWorkflowCandidate,
21
34
  intrinsicWorkflow,
22
35
  planningWorkflow,
36
+ preservesWorkflowAbsoluteGeometry,
23
37
  } from './workflow-migration-geometry.mjs';
24
38
  import {
39
+ joinRoutePoints,
25
40
  asArray,
26
41
  isFinitePoint,
27
42
  rectsOverlap,
@@ -34,17 +49,20 @@ import {
34
49
  cleanBorderRunProblems,
35
50
  cleanRouteRhythmProblems,
36
51
  cleanLabelRouteClearanceProblems,
52
+ cleanLabelCanvasContainmentProblems,
37
53
  collectAmbiguousCorridors,
54
+ collectArrowheadCollisions,
38
55
  collectLabelRouteClearance,
39
56
  collectBorderRuns,
40
57
  forwardCollinearAnalysisSegments,
41
58
  sourceSegmentIndexAtPoint,
59
+ polylineBendContacts,
42
60
  suggestLabelObstacleFix,
43
61
  suggestLabelPairFix,
44
62
  anchor,
45
63
  automaticPortSpread,
46
- defaultFromSide,
47
- defaultToSide,
64
+ legacyDefaultFromSide as defaultFromSide,
65
+ legacyDefaultToSide as defaultToSide,
48
66
  chosenSide,
49
67
  normalizeRoutePoints,
50
68
  routeHonorsEndpointSides,
@@ -54,16 +72,22 @@ import {
54
72
  componentFill,
55
73
  componentText,
56
74
  arrowClassMap,
57
- variantAccent
75
+ variantAccent,
76
+ edgeLabelAccent,
77
+ roundedPath
58
78
  } from '../shared/geometry.mjs';
59
79
 
60
80
  const LEGACY_COLUMN_CENTERS = Object.freeze([88, 220, 300, 430, 500, 625]);
61
81
  const READABLE_CANDIDATE_COST_PRIORITY = Object.freeze([
62
- 'automaticForwardReversePx',
63
82
  'properCrossingCount',
83
+ 'unrelatedBendContactCount',
64
84
  'sharedCorridorPx',
85
+ 'automaticForwardReversePx',
65
86
  'labelRouteClearanceDeficit',
87
+ 'laterArrivalBlockCount',
88
+ 'oppositeParallelDeficit',
66
89
  'interiorPreferred28Deficit',
90
+ 'excessiveVerticalTravelPx',
67
91
  'bendCount',
68
92
  'stretchMilli',
69
93
  'canvasGrowthPx',
@@ -79,6 +103,14 @@ const GROUP_LABEL_MASK_ASCENT = 10;
79
103
  const GROUP_LABEL_MASK_H = 14;
80
104
  const GROUP_NODE_INSET = 4;
81
105
 
106
+ const HARD_ROUTE_RHYTHM = Object.freeze({ direct: 28, endpoint: 8, interior: 16 });
107
+
108
+ function hardRouteSegmentMinimum(points, index) {
109
+ if (points.length === 2) return HARD_ROUTE_RHYTHM.direct;
110
+ return index === 0 || index === points.length - 2
111
+ ? HARD_ROUTE_RHYTHM.endpoint : HARD_ROUTE_RHYTHM.interior;
112
+ }
113
+
82
114
  class WorkflowLayoutFeedback extends Error {
83
115
  constructor(request) {
84
116
  super(`Workflow layout requires ${request.kind} feedback.`);
@@ -103,8 +135,50 @@ function createLegacyLayout() {
103
135
  };
104
136
  }
105
137
 
138
+ function hasAbsoluteWorkflowPins(workflow) {
139
+ return asArray(workflow.edges).some((edge) => (
140
+ Array.isArray(edge.via)
141
+ || Array.isArray(edge.labelAt)
142
+ || edge.channelX !== undefined
143
+ || edge.channelY !== undefined
144
+ ));
145
+ }
146
+
147
+ function hasVerticalStack(workflow) {
148
+ const offsetsByLaneAndColumn = new Map();
149
+ for (const node of asArray(workflow.nodes)) {
150
+ if (!Number.isInteger(node.col)) continue;
151
+ const key = `${node.lane}\u0000${node.col}`;
152
+ const offsets = offsetsByLaneAndColumn.get(key) || new Set();
153
+ offsets.add(Number(node.yOffset) || 0);
154
+ if (offsets.size > 1) return true;
155
+ offsetsByLaneAndColumn.set(key, offsets);
156
+ }
157
+ return false;
158
+ }
159
+
160
+ function usesIndependentLaneMeasurement(workflow) {
161
+ return hasVerticalStack(workflow)
162
+ && !workflow.meta?.viewBox
163
+ && !hasAbsoluteWorkflowPins(workflow);
164
+ }
165
+
166
+ // A readable-v2 node without an authored width grows to fit its label, up to
167
+ // 200px; longer labels still fail the label check. A sublabel or tag that
168
+ // would not fit even at 6px also grows the node to fit it at its preferred 8px
169
+ // or 7px. Keyed by the node object so the authored document is never rewritten.
170
+ const automaticNodeWidths = new WeakMap();
171
+ function automaticNodeWidth(node) {
172
+ const labelWidth = Math.max(92, Math.ceil((textUnits(node.label) * 6.8 - 6) / 4) * 4);
173
+ const secondaryWidths = [[node.sublabel, 8], [node.tag, 7]]
174
+ .filter(([text]) => text && minimumNodeTextWidth(text, 6) > availableNodeTextWidth(labelWidth))
175
+ .map(([text, size]) => Math.ceil((minimumNodeTextWidth(text, size) + labelWidth - availableNodeTextWidth(labelWidth)) / 4) * 4);
176
+ const fitted = Math.max(labelWidth, ...secondaryWidths);
177
+ return fitted > 92 ? Math.min(200, fitted) : null;
178
+ }
179
+
106
180
  function authoredNodeWidth(node) {
107
- return Number.isFinite(node?.width) ? node.width : 92;
181
+ return Number.isFinite(node?.width) ? node.width : automaticNodeWidths.get(node) ?? 92;
108
182
  }
109
183
 
110
184
  function nodeWidthContributor(node) {
@@ -168,6 +242,7 @@ function createReadableLayout(workflow, layoutFeedback = {}) {
168
242
  const widthContributors = new Set();
169
243
  const heightContributors = new Set();
170
244
  const nodes = asArray(workflow.nodes);
245
+ const nodeClearance = preservesWorkflowAbsoluteGeometry(workflow) ? 8 : 32;
171
246
  const nodesById = new Map(nodes.map((node) => [node.id, node]));
172
247
 
173
248
  for (let col = 0; col < columnCount - 1; col += 1) {
@@ -192,10 +267,12 @@ function createReadableLayout(workflow, layoutFeedback = {}) {
192
267
  if (!verticalIntervalsOverlap(leftNode, rightNode, 8)) continue;
193
268
  const fromNode = leftNode.col < rightNode.col ? leftNode : rightNode;
194
269
  const toNode = fromNode === leftNode ? rightNode : leftNode;
270
+ // Unpinned drafts reserve a route corridor; absolute assertions keep
271
+ // their existing rank plan, including migration planning projections.
195
272
  constraints.push({
196
273
  from: fromNode.col,
197
274
  to: toNode.col,
198
- minimum: authoredNodeWidth(fromNode) / 2 + 8 + authoredNodeWidth(toNode) / 2,
275
+ minimum: authoredNodeWidth(fromNode) / 2 + nodeClearance + authoredNodeWidth(toNode) / 2,
199
276
  contributors: [
200
277
  `rank ${fromNode.col}→${toNode.col} node width clearance`,
201
278
  nodeWidthContributor(fromNode),
@@ -472,27 +549,45 @@ function createReadableLayout(workflow, layoutFeedback = {}) {
472
549
  widthContributors.add(`lane ${widestLaneLabel.lane.id || widestLaneLabel.lane.label} label width`);
473
550
  }
474
551
  }
475
- let maxVerticalExtent = 0;
476
- const verticalExtentContributors = new Set();
477
- for (const node of nodes) {
478
- const yOffset = Number(node.yOffset) || 0;
479
- const extent = authoredNodeHeight(node) / 2 + Math.abs(yOffset);
480
- const contributor = `node ${node.id} height ${authoredNodeHeight(node)}px${yOffset ? ` with yOffset ${yOffset}px` : ''}`;
481
- if (extent > maxVerticalExtent + 0.0001) {
482
- maxVerticalExtent = extent;
483
- verticalExtentContributors.clear();
484
- verticalExtentContributors.add(contributor);
485
- } else if (Math.abs(extent - maxVerticalExtent) <= 0.0001) {
486
- verticalExtentContributors.add(contributor);
487
- }
488
- }
489
- const baseContentH = Math.max(74, Math.ceil(maxVerticalExtent * 2 + 8));
490
- const laneH = 30 + baseContentH;
552
+ const verticalExtent = (laneId) => {
553
+ let maximum = 0;
554
+ const contributors = new Set();
555
+ for (const node of nodes) {
556
+ if (laneId !== undefined && node.lane !== laneId) continue;
557
+ const yOffset = Number(node.yOffset) || 0;
558
+ const extent = authoredNodeHeight(node) / 2 + Math.abs(yOffset);
559
+ const contributor = `node ${node.id} height ${authoredNodeHeight(node)}px${yOffset ? ` with yOffset ${yOffset}px` : ''}`;
560
+ if (extent > maximum + 0.0001) {
561
+ maximum = extent;
562
+ contributors.clear();
563
+ contributors.add(contributor);
564
+ } else if (Math.abs(extent - maximum) <= 0.0001) {
565
+ contributors.add(contributor);
566
+ }
567
+ }
568
+ return { maximum, contributors };
569
+ };
570
+ const sharedVerticalExtent = verticalExtent();
571
+ const laneH = 30 + Math.max(74, Math.ceil(sharedVerticalExtent.maximum * 2 + 8));
572
+ const independentLaneMeasurement = usesIndependentLaneMeasurement(workflow);
573
+ const laneBaseHeights = asArray(workflow.lanes).map((lane) => {
574
+ if (!independentLaneMeasurement) return laneH;
575
+ const ownVerticalExtent = verticalExtent(lane.id);
576
+ const height = 30 + Math.max(74, Math.ceil(ownVerticalExtent.maximum * 2 + 8));
577
+ if (height > 104) {
578
+ for (const contributor of ownVerticalExtent.contributors) heightContributors.add(contributor);
579
+ }
580
+ return height;
581
+ });
582
+ if (!independentLaneMeasurement && laneH > 104) {
583
+ for (const contributor of sharedVerticalExtent.contributors) heightContributors.add(contributor);
584
+ }
491
585
  const groupsByLane = new Map();
492
586
  for (const group of asArray(workflow.groups)) {
493
587
  groupsByLane.set(group.lane, [...(groupsByLane.get(group.lane) || []), group]);
494
588
  }
495
- const groupLaneReserves = asArray(workflow.lanes).map((lane) => {
589
+ const groupLaneReserves = asArray(workflow.lanes).map((lane, laneIndex) => {
590
+ const baseContentH = laneBaseHeights[laneIndex] - 30;
496
591
  let header = 0;
497
592
  let footer = 0;
498
593
  for (const group of groupsByLane.get(lane.id) || []) {
@@ -523,7 +618,9 @@ function createReadableLayout(workflow, layoutFeedback = {}) {
523
618
  });
524
619
  const groupHeaderHeights = groupLaneReserves.map(({ header }) => header);
525
620
  const groupFooterHeights = groupLaneReserves.map(({ footer }) => footer);
526
- const laneHeights = groupLaneReserves.map(({ header, footer }) => laneH + header + footer);
621
+ const laneHeights = groupLaneReserves.map(({ header, footer }, index) => (
622
+ laneBaseHeights[index] + header + footer
623
+ ));
527
624
  const laneGap = Math.max(20, Math.ceil(layoutFeedback.laneGapMin || 0));
528
625
  for (const [index, reserve] of groupHeaderHeights.entries()) {
529
626
  if (!reserve) continue;
@@ -535,9 +632,6 @@ function createReadableLayout(workflow, layoutFeedback = {}) {
535
632
  const lane = asArray(workflow.lanes)[index];
536
633
  heightContributors.add(`lane ${lane.id || lane.label} group frame containment ${reserve}px`);
537
634
  }
538
- if (laneH > 104) {
539
- for (const contributor of verticalExtentContributors) heightContributors.add(contributor);
540
- }
541
635
  if (laneGap > 20) {
542
636
  for (const contributor of asArray(layoutFeedback.laneGapContributors)) {
543
637
  heightContributors.add(contributor);
@@ -601,22 +695,31 @@ function cloneWorkflow(value) {
601
695
  return JSON.parse(JSON.stringify(value));
602
696
  }
603
697
 
604
- function canonicalReadableWorkflow(workflow) {
698
+ function compareWorkflowEdges(left, right) {
699
+ return stableCompare(left.id, right.id)
700
+ || stableCompare(left.from, right.from)
701
+ || stableCompare(left.to, right.to)
702
+ || stableCompare(left.label, right.label)
703
+ || stableCompare(left.route, right.route)
704
+ || stableCompare(stableValueKey(left), stableValueKey(right));
705
+ }
706
+
707
+ function canonicalReadableWorkflow(workflow, widthOverrides) {
605
708
  if (workflow.schema_version !== 2) return workflow;
709
+ for (const node of asArray(workflow.nodes)) {
710
+ const width = !preservesWorkflowAbsoluteGeometry(workflow)
711
+ && !Number.isFinite(node.width)
712
+ && Math.max(automaticNodeWidth(node) || 0, widthOverrides?.get(node.id) || 0);
713
+ if (width) automaticNodeWidths.set(node, width);
714
+ else automaticNodeWidths.delete(node);
715
+ }
606
716
  const laneOrder = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
607
717
  const nodes = [...asArray(workflow.nodes)].sort((left, right) => (
608
718
  (laneOrder.get(left.lane) ?? Number.MAX_SAFE_INTEGER) - (laneOrder.get(right.lane) ?? Number.MAX_SAFE_INTEGER)
609
719
  || left.col - right.col
610
720
  || stableCompare(left.id, right.id)
611
721
  ));
612
- const edges = [...asArray(workflow.edges)].sort((left, right) => (
613
- stableCompare(left.id, right.id)
614
- || stableCompare(left.from, right.from)
615
- || stableCompare(left.to, right.to)
616
- || stableCompare(left.label, right.label)
617
- || stableCompare(left.route, right.route)
618
- || stableCompare(stableValueKey(left), stableValueKey(right))
619
- ));
722
+ const edges = [...asArray(workflow.edges)].sort(compareWorkflowEdges);
620
723
  const phases = workflow.phases === undefined ? undefined : [...asArray(workflow.phases)].sort((left, right) => (
621
724
  left.fromCol - right.fromCol || left.toCol - right.toCol
622
725
  || stableCompare(left.id, right.id)
@@ -752,11 +855,517 @@ function semanticContractDiagnostics(workflow) {
752
855
  return diagnostics;
753
856
  }
754
857
 
858
+ // Legend footprint and canvas width: one phase of the workflow compile, lifted
859
+ // out of compileWorkflowInternal so the caller reads as a sequence of steps.
860
+ function resolveWorkflowLegendFootprint(workflow, layout) {
861
+ const LEGEND_CATALOG = [
862
+ 'frontend',
863
+ 'backend',
864
+ 'security',
865
+ 'messagebus',
866
+ 'database',
867
+ 'cloud',
868
+ 'external',
869
+ ].map((kind) => ({ kind, label: i18nText(workflow.meta.locale, `legend.workflow.${kind}`) }));
870
+ const presentLegendKinds = new Set(asArray(workflow.nodes).map((node) => node.type));
871
+ const workflowLegendEntries = resolveLegend(
872
+ workflow.meta?.legend,
873
+ LEGEND_CATALOG,
874
+ presentLegendKinds,
875
+ );
876
+ const legendFootprintOptions = { fontSize: 7, itemGap: 7 };
877
+ const oneRowLegendFootprint = legendFootprint(workflowLegendEntries, {
878
+ ...legendFootprintOptions,
879
+ width: Number.MAX_SAFE_INTEGER,
880
+ });
881
+ const minimumCanvasWidth = workflow.schema_version === 2
882
+ ? Math.max(layout.defaultViewBoxWidth, oneRowLegendFootprint.minWidth + 40)
883
+ : layout.defaultViewBoxWidth;
884
+ const legendPackingWidth = Math.max(
885
+ 1,
886
+ (workflow.schema_version === 2
887
+ ? minimumCanvasWidth
888
+ : (workflow.meta?.viewBox?.[0] ?? minimumCanvasWidth)) - 40,
889
+ );
890
+ const packedLegendFootprint = legendFootprint(workflowLegendEntries, {
891
+ ...legendFootprintOptions,
892
+ width: legendPackingWidth,
893
+ });
894
+ const legendExtraHeight = workflow.schema_version === 2
895
+ ? packedLegendFootprint.extraHeight
896
+ : 0;
897
+ return {
898
+ workflowLegendEntries,
899
+ presentLegendKinds,
900
+ legendPackingWidth,
901
+ packedLegendFootprint,
902
+ legendExtraHeight,
903
+ minimumCanvasWidth,
904
+ };
905
+ }
906
+
907
+ // Lane geometry: the canvas height, the lane lookups and the small position
908
+ // helpers every later phase reads. Lifted out of compileWorkflowInternal so the
909
+ // body reads as phases instead of one script.
910
+ function createWorkflowLaneGeometry(workflow, layout, legendExtraHeight, minimumCanvasWidth) {
911
+ // Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend.
912
+ const autoHeight = layout.laneY
913
+ + (layout.laneHeights?.reduce((total, height) => total + height, 0)
914
+ ?? (workflow.lanes?.length || 1) * layout.laneH)
915
+ + ((workflow.lanes?.length || 1) - 1) * layout.laneGap
916
+ + 124
917
+ + legendExtraHeight;
918
+ const initialViewBox = workflow.meta?.viewBox || [minimumCanvasWidth, autoHeight];
919
+
920
+
921
+ const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
922
+ const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label]));
923
+
924
+ function nodeContext(node) {
925
+ const group = asArray(workflow.groups).find((candidate) => (
926
+ candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol
927
+ ));
928
+ const phase = asArray(workflow.phases).find((candidate) => (
929
+ node.col >= candidate.fromCol && node.col <= candidate.toCol
930
+ ));
931
+ return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ')
932
+ || i18nText(workflow.meta.locale, 'node.context.workflow');
933
+ }
934
+
935
+ function laneHeight(idOrIndex) {
936
+ const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
937
+ return layout.laneHeights?.[index] ?? layout.laneH;
938
+ }
939
+
940
+ function laneGroupHeaderH(idOrIndex) {
941
+ const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
942
+ return layout.groupHeaderHeights?.[index] ?? 0;
943
+ }
944
+
945
+ function laneGroupFooterH(idOrIndex) {
946
+ const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
947
+ return layout.groupFooterHeights?.[index] ?? 0;
948
+ }
949
+
950
+ function laneTop(id) {
951
+ const index = laneIndex.get(id);
952
+ const precedingHeight = asArray(workflow.lanes).slice(0, index)
953
+ .reduce((total, _lane, lanePosition) => total + laneHeight(lanePosition), 0);
954
+ return layout.laneY + precedingHeight + index * layout.laneGap;
955
+ }
956
+
957
+ function lastLaneBottom() {
958
+ return layout.laneY
959
+ + asArray(workflow.lanes).reduce((total, _lane, index) => total + laneHeight(index), 0)
960
+ + (workflow.lanes.length - 1) * layout.laneGap;
961
+ }
962
+
963
+ function legendY() {
964
+ return lastLaneBottom() + 44 + legendExtraHeight;
965
+ }
966
+
967
+ return {
968
+ autoHeight,
969
+ initialViewBox,
970
+ laneIndex,
971
+ laneLabels,
972
+ nodeContext,
973
+ laneHeight,
974
+ laneGroupHeaderH,
975
+ laneGroupFooterH,
976
+ laneTop,
977
+ lastLaneBottom,
978
+ legendY,
979
+ };
980
+ }
981
+
982
+ // Node measurement: the measured node map and the text-fit sizes every later
983
+ // phase reads. Kept together so the compiler body reads as phases.
984
+ function measureWorkflowNodes(workflow, layout, laneGeometry) {
985
+ const { laneHeight, laneGroupHeaderH, laneGroupFooterH, laneTop } = laneGeometry;
986
+ function measureNode(node) {
987
+ const width = node.width || (workflow.schema_version === 2 && automaticNodeWidths.get(node)) || layout.nodeW;
988
+ const height = node.height || (node.tag ? 68 : layout.nodeH);
989
+ const cx = layout.colXs[node.col];
990
+ const groupHeaderH = laneGroupHeaderH(node.lane);
991
+ const contentH = laneHeight(node.lane) - layout.laneTitleH
992
+ - groupHeaderH - laneGroupFooterH(node.lane);
993
+ const y = laneTop(node.lane) + layout.laneTitleH + groupHeaderH
994
+ + (contentH - height) / 2 + (node.yOffset || 0);
995
+ return {
996
+ ...node,
997
+ width,
998
+ height,
999
+ x: cx - width / 2,
1000
+ y,
1001
+ cx,
1002
+ cy: y + height / 2
1003
+ };
1004
+ }
1005
+
1006
+ // Font sizes for this renderer's node text; the fitting geometry is shared.
1007
+ const nodeTextFit = {
1008
+ labelPreferred: 11,
1009
+ labelMinimum: 9,
1010
+ sublabelPreferred: 8,
1011
+ sublabelMinimum: 6,
1012
+ tagPreferred: 7,
1013
+ tagMinimum: 6,
1014
+ };
1015
+
1016
+ const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)]));
1017
+
1018
+ return { measureNode, nodeTextFit, nodes };
1019
+ }
1020
+
1021
+ function workflowNodeTextProblems(node, nodeTextFit) {
1022
+ const problems = [];
1023
+ const estLabelW = textUnits(node.label) * 6.8;
1024
+ if (estLabelW > node.width + 6) {
1025
+ problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than component "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
1026
+ }
1027
+ const brandRailProblem = brandTopRailProblem(node, node.width, nodeTextFit.labelMinimum);
1028
+ if (brandRailProblem) problems.push(brandRailProblem);
1029
+ const availableTextW = availableNodeTextWidth(node.width);
1030
+ for (const [field, value, minimum] of [
1031
+ ['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
1032
+ ['Tag', node.tag, nodeTextFit.tagMinimum],
1033
+ ]) {
1034
+ if (!value) continue;
1035
+ const minimumW = minimumNodeTextWidth(value, minimum);
1036
+ if (minimumW > availableTextW) {
1037
+ problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
1038
+ }
1039
+ }
1040
+ return problems;
1041
+ }
1042
+
1043
+ // Step indexes: where each edge and node sits on the main path. Lifted out so
1044
+ // the compiler body keeps only the phases that read them.
1045
+ function createWorkflowStepIndexes(workflow) {
1046
+ const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index]));
1047
+ const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => {
1048
+ const fromStep = mainPathSteps.get(edge.from);
1049
+ const toStep = mainPathSteps.get(edge.to);
1050
+ const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null;
1051
+ return [edge, mainStep ?? asArray(workflow.mainPath).length + index];
1052
+ }));
1053
+
1054
+ function nodeStep(node) {
1055
+ return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id);
1056
+ }
1057
+
1058
+ return { mainPathSteps, edgeSteps, nodeStep };
1059
+ }
1060
+
1061
+ // Legacy capacity repair: the v1 column-capacity gate and the three fix
1062
+ // verifiers it uses. They share the measured nodes, the layout and the fix
1063
+ // acceptor, so they are built together and the body only calls the gate.
1064
+ function createLegacyCapacityRepair({
1065
+ workflow,
1066
+ nodes,
1067
+ layout,
1068
+ discoverFixes,
1069
+ resolvedQualityProfile,
1070
+ authoredQualityProfile,
1071
+ nodeTextFit,
1072
+ acceptsFix,
1073
+ }) {
1074
+ function verifiedLegacyAlternative(edge, from, to, requiredClearance) {
1075
+ const occupied = [...nodes.values()].filter((node) => node.lane === to.lane && node.id !== to.id);
1076
+ const candidates = layout.colXs.map((center, col) => ({ center, col }))
1077
+ .filter(({ col }) => col !== to.col)
1078
+ .sort((a, b) => Math.abs(a.col - to.col) - Math.abs(b.col - to.col) || a.col - b.col);
1079
+ for (const candidate of candidates) {
1080
+ const candidateRect = { ...to, col: candidate.col, cx: candidate.center, x: candidate.center - to.width / 2 };
1081
+ if (occupied.some((node) => rectsOverlap(candidateRect, node, 8))) continue;
1082
+ const centerDistance = Math.abs(candidate.center - from.cx);
1083
+ const signedClearance = centerDistance - from.width / 2 - to.width / 2;
1084
+ if (signedClearance < requiredClearance) continue;
1085
+ if (acceptsFix((document) => {
1086
+ document.nodes.find((node) => node.id === to.id).col = candidate.col;
1087
+ })) return candidate.col;
1088
+ }
1089
+ return null;
1090
+ }
1091
+
1092
+ function readableMigrationProvidesCapacity(from, to, requiredClearance) {
1093
+ const readable = createReadableLayout({ ...workflow, schema_version: 2 });
1094
+ const centerDistance = Math.abs(readable.colXs[to.col] - readable.colXs[from.col]);
1095
+ if (centerDistance - from.width / 2 - to.width / 2 < requiredClearance) return false;
1096
+ if (!discoverFixes) return false;
1097
+
1098
+ return withDiagnosticRecordingSuppressed(() => {
1099
+ const migrationQualityProfile = authoredQualityProfile;
1100
+ let planned = compileWorkflowWithFeedback({
1101
+ workflow: intrinsicWorkflow(workflow),
1102
+ qualityProfile: migrationQualityProfile,
1103
+ discoverFixes: false,
1104
+ });
1105
+ if (!planned.ok) {
1106
+ planned = compileWorkflowWithFeedback({
1107
+ workflow: planningWorkflow(workflow),
1108
+ qualityProfile: migrationQualityProfile,
1109
+ discoverFixes: false,
1110
+ });
1111
+ }
1112
+ if (!planned.ok || !Array.isArray(planned.receipt?.columns)) return false;
1113
+
1114
+ let candidate;
1115
+ try {
1116
+ candidate = createMappedWorkflowCandidate(
1117
+ workflow,
1118
+ LEGACY_COLUMN_CENTERS,
1119
+ planned.receipt.columns,
1120
+ ).document;
1121
+ } catch {
1122
+ return false;
1123
+ }
1124
+ let compiled = compileWorkflowWithFeedback({
1125
+ workflow: candidate,
1126
+ qualityProfile: migrationQualityProfile,
1127
+ discoverFixes: false,
1128
+ });
1129
+ const requiredViewBox = compiled.diagnostics?.length
1130
+ && compiled.diagnostics.every(({ code }) => code === 'workflow/viewbox-capacity')
1131
+ ? compiled.diagnostics.find(({ evidence }) => Array.isArray(evidence?.requiredViewBox))
1132
+ ?.evidence.requiredViewBox
1133
+ : null;
1134
+ if (!compiled.ok && Array.isArray(candidate.meta?.viewBox) && requiredViewBox) {
1135
+ candidate.meta.viewBox = [
1136
+ Math.max(candidate.meta.viewBox[0], requiredViewBox[0]),
1137
+ Math.max(candidate.meta.viewBox[1], requiredViewBox[1]),
1138
+ ];
1139
+ compiled = compileWorkflowWithFeedback({
1140
+ workflow: candidate,
1141
+ qualityProfile: migrationQualityProfile,
1142
+ discoverFixes: false,
1143
+ });
1144
+ }
1145
+ return compiled.ok;
1146
+ });
1147
+ }
1148
+
1149
+ function verifiedReducedWidths(from, to, requiredClearance) {
1150
+ const widthBudget = 2 * (Math.abs(to.cx - from.cx) - requiredClearance);
1151
+ if (widthBudget < 64) return null;
1152
+ const widths = [from.width, to.width];
1153
+ let excess = widths[0] + widths[1] - widthBudget;
1154
+ for (const index of widths[0] >= widths[1] ? [0, 1] : [1, 0]) {
1155
+ const reduction = Math.min(excess, widths[index] - 32);
1156
+ widths[index] -= reduction;
1157
+ excess -= reduction;
1158
+ }
1159
+ if (excess > 0.0001) return null;
1160
+ const candidates = [from, to];
1161
+ const labelsFit = candidates.every((node, index) => (
1162
+ textUnits(node.label) * 6.8 <= widths[index] + 6
1163
+ && (!node.sublabel || minimumNodeTextWidth(node.sublabel, nodeTextFit.sublabelMinimum) <= availableNodeTextWidth(widths[index]))
1164
+ && (!node.tag || minimumNodeTextWidth(node.tag, nodeTextFit.tagMinimum) <= availableNodeTextWidth(widths[index]))
1165
+ ));
1166
+ if (!labelsFit) return null;
1167
+ const serializedWidths = widths.map((width) => Math.floor((width + 1e-9) * 100) / 100);
1168
+ const signedClearance = Math.abs(to.cx - from.cx)
1169
+ - serializedWidths[0] / 2 - serializedWidths[1] / 2;
1170
+ if (signedClearance + 0.0001 < requiredClearance) return null;
1171
+ const accepted = acceptsFix((document) => {
1172
+ document.nodes.find((node) => node.id === from.id).width = serializedWidths[0];
1173
+ document.nodes.find((node) => node.id === to.id).width = serializedWidths[1];
1174
+ });
1175
+ return accepted ? serializedWidths : null;
1176
+ }
1177
+
1178
+ function enforceLegacyColumnCapacity() {
1179
+ if (workflow.schema_version !== 1) return;
1180
+ for (const edge of workflow.edges) {
1181
+ const from = nodes.get(edge.from);
1182
+ const to = nodes.get(edge.to);
1183
+ if (!from || !to || from.lane !== to.lane || from.col === to.col) continue;
1184
+ if (!verticalIntervalsOverlap(from, to, 8)) continue;
1185
+ const centerDistance = Math.abs(to.cx - from.cx);
1186
+ const actualSignedClearance = centerDistance - from.width / 2 - to.width / 2;
1187
+ const direct = !edge.via && ['auto', 'straight'].includes(edge.route || 'auto')
1188
+ && Math.abs(from.cy - to.cy) < 0.0001;
1189
+ const requiredDirectClearance = direct ? 28 : 8;
1190
+ if (actualSignedClearance >= requiredDirectClearance) continue;
1191
+ const alternative = verifiedLegacyAlternative(edge, from, to, requiredDirectClearance);
1192
+ const reducedWidths = verifiedReducedWidths(from, to, requiredDirectClearance);
1193
+ const capacity = actualSignedClearance < 0
1194
+ ? `overlap by ${Math.abs(Math.round(actualSignedClearance))}px`
1195
+ : `leave only ${Math.round(actualSignedClearance)}px of direct clearance`;
1196
+ const message = `Workflow columns ${from.col}→${to.col} place nodes "${from.id}" and "${to.id}" so they ${capacity} under the fixed-v1 layout.`;
1197
+ const supportedFixes = [];
1198
+ if (readableMigrationProvidesCapacity(from, to, requiredDirectClearance)) {
1199
+ supportedFixes.push('migrate this workflow to schema_version 2');
1200
+ }
1201
+ if (alternative !== null) supportedFixes.push(`move node "${to.id}" to verified free column ${alternative}`);
1202
+ if (reducedWidths) {
1203
+ supportedFixes.push(`set node widths "${from.id}"=${Math.round(reducedWidths[0] * 100) / 100}px and "${to.id}"=${Math.round(reducedWidths[1] * 100) / 100}px`);
1204
+ }
1205
+ throwDiagnosticError(message, [{
1206
+ code: 'workflow/column-capacity',
1207
+ severity: 'error',
1208
+ message,
1209
+ subject: {
1210
+ diagramType: 'workflow',
1211
+ edge: edge.id ?? null,
1212
+ from: edge.from,
1213
+ to: edge.to,
1214
+ fromCol: from.col,
1215
+ toCol: to.col,
1216
+ },
1217
+ evidence: {
1218
+ centerDistancePx: centerDistance,
1219
+ nodeWidthsPx: [from.width, to.width],
1220
+ actualSignedClearancePx: actualSignedClearance,
1221
+ requiredDirectClearancePx: requiredDirectClearance,
1222
+ },
1223
+ supportedFixes,
1224
+ suppresses: [
1225
+ 'workflow/short-edge',
1226
+ 'clean-flow/endpoint-side-direction',
1227
+ 'workflow/label-node-overlap',
1228
+ ],
1229
+ }]);
1230
+ }
1231
+ }
1232
+ return { enforceLegacyColumnCapacity };
1233
+ }
1234
+
1235
+ // Automatic side selection: which sides an automatic one-bend route leaves and
1236
+ // enters. Uses the shared side cache and the readable candidate set, so both are
1237
+ // passed in rather than captured.
1238
+ function createAutomaticSideSelection({
1239
+ workflow,
1240
+ readableSideCache,
1241
+ readableAutomaticCandidateSet,
1242
+ compareCost,
1243
+ oneBendCrossLaneVia,
1244
+ }) {
1245
+ function legacyAutomaticOneBendSides(edge, from, to) {
1246
+ const automaticRoute = !edge.via && (!edge.route || edge.route === 'auto');
1247
+ const automaticFrom = !edge.fromSide || edge.fromSide === 'auto';
1248
+ const automaticTo = !edge.toSide || edge.toSide === 'auto';
1249
+ if (!automaticRoute || !automaticFrom || !automaticTo || from.lane === to.lane) return null;
1250
+ if (from.cx === to.cx || from.cy === to.cy) return null;
1251
+ const verticalFrom = to.cy < from.cy ? 'top' : 'bottom';
1252
+ const horizontalTo = to.cx < from.cx ? 'right' : 'left';
1253
+ const horizontalFrom = to.cx < from.cx ? 'left' : 'right';
1254
+ const verticalTo = to.cy < from.cy ? 'bottom' : 'top';
1255
+ const candidates = [
1256
+ { fromSide: verticalFrom, toSide: horizontalTo },
1257
+ { fromSide: horizontalFrom, toSide: verticalTo },
1258
+ ];
1259
+
1260
+ return candidates.find(({ fromSide, toSide }) => {
1261
+ const start = anchor(from, fromSide);
1262
+ const end = anchor(to, toSide);
1263
+ return oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
1264
+ }) || null;
1265
+ }
1266
+
1267
+ function readableAutomaticSides(edge, from, to) {
1268
+ const automaticRoute = !edge.via
1269
+ && edge.channelX === undefined
1270
+ && edge.channelY === undefined
1271
+ && (!edge.route || edge.route === 'auto');
1272
+ const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
1273
+ const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
1274
+ if (!automaticRoute || (authoredFrom && authoredTo)) return null;
1275
+ if (readableSideCache.has(edge)) return readableSideCache.get(edge);
1276
+
1277
+ const preferred = [];
1278
+ const legacyPreferred = legacyAutomaticOneBendSides(edge, from, to);
1279
+ if (legacyPreferred) preferred.push(legacyPreferred);
1280
+ preferred.push({
1281
+ fromSide: authoredFrom || defaultFromSide(from, to),
1282
+ toSide: authoredTo || defaultToSide(from, to),
1283
+ });
1284
+ const sideOrder = ['right', 'bottom', 'left', 'top'];
1285
+ for (const fromSide of authoredFrom ? [authoredFrom] : sideOrder) {
1286
+ for (const toSide of authoredTo ? [authoredTo] : sideOrder) {
1287
+ preferred.push({ fromSide, toSide });
1288
+ }
1289
+ }
1290
+
1291
+ const seen = new Set();
1292
+ const sidePairs = [];
1293
+ for (const candidate of preferred) {
1294
+ if (authoredFrom && candidate.fromSide !== authoredFrom) continue;
1295
+ if (authoredTo && candidate.toSide !== authoredTo) continue;
1296
+ const key = `${candidate.fromSide}:${candidate.toSide}`;
1297
+ if (seen.has(key)) continue;
1298
+ seen.add(key);
1299
+ sidePairs.push(candidate);
1300
+ }
1301
+
1302
+ const naturalFromSide = authoredFrom || defaultFromSide(from, to);
1303
+ const naturalToSide = authoredTo || defaultToSide(from, to);
1304
+ const planFor = (candidate, pairOrdinal) => {
1305
+ const start = anchor(from, candidate.fromSide);
1306
+ const end = anchor(to, candidate.toSide);
1307
+ return {
1308
+ start,
1309
+ end,
1310
+ planned: readableAutomaticCandidateSet(
1311
+ edge,
1312
+ from,
1313
+ to,
1314
+ start,
1315
+ end,
1316
+ candidate.fromSide,
1317
+ candidate.toSide,
1318
+ {
1319
+ ordinalOffset: pairOrdinal * 9,
1320
+ naturalFromSide,
1321
+ naturalToSide,
1322
+ },
1323
+ ),
1324
+ };
1325
+ };
1326
+
1327
+ const primary = sidePairs[0];
1328
+ if (primary) {
1329
+ const { planned } = planFor(primary, 0);
1330
+ if (planned.candidates.length) {
1331
+ readableSideCache.set(edge, primary);
1332
+ return primary;
1333
+ }
1334
+ }
1335
+
1336
+ const candidates = [];
1337
+ for (const [pairOrdinal, candidate] of sidePairs.entries()) {
1338
+ const { planned } = planFor(candidate, pairOrdinal);
1339
+ candidates.push(...planned.candidates.map((route) => ({ ...route, ...candidate })));
1340
+ }
1341
+ candidates.sort((left, right) => compareCost(left.cost, right.cost));
1342
+ if (candidates.length) {
1343
+ const selected = {
1344
+ fromSide: candidates[0].fromSide,
1345
+ toSide: candidates[0].toSide,
1346
+ };
1347
+ readableSideCache.set(edge, selected);
1348
+ return selected;
1349
+ }
1350
+ readableSideCache.set(edge, null);
1351
+ return null;
1352
+ }
1353
+
1354
+ function automaticOneBendSides(edge, from, to) {
1355
+ return workflow.schema_version === 2
1356
+ ? readableAutomaticSides(edge, from, to)
1357
+ : legacyAutomaticOneBendSides(edge, from, to);
1358
+ }
1359
+ return { automaticOneBendSides };
1360
+ }
1361
+
755
1362
  function compileWorkflowInternal({
756
1363
  workflow: inputWorkflow,
757
1364
  qualityProfile,
1365
+ sourceEvidence,
758
1366
  discoverFixes = true,
759
1367
  layoutFeedback = {},
1368
+ widthOverrides,
760
1369
  } = {}) {
761
1370
  if (!inputWorkflow || typeof inputWorkflow !== 'object' || Array.isArray(inputWorkflow)) {
762
1371
  const diagnostics = [{
@@ -777,6 +1386,7 @@ function compileWorkflowInternal({
777
1386
  let inputDiagnostics = [];
778
1387
  try {
779
1388
  validateSchema('workflow', qualityResolvedWorkflow);
1389
+ validateCrossCollectionContracts('workflow', qualityResolvedWorkflow);
780
1390
  } catch (error) {
781
1391
  inputDiagnostics = Array.isArray(error?.archifyDiagnostics)
782
1392
  ? error.archifyDiagnostics.map((diagnostic) => ({
@@ -798,7 +1408,7 @@ function compileWorkflowInternal({
798
1408
  inputDiagnostics,
799
1409
  );
800
1410
  }
801
- const workflow = canonicalReadableWorkflow(qualityResolvedWorkflow);
1411
+ const workflow = canonicalReadableWorkflow(qualityResolvedWorkflow, widthOverrides);
802
1412
  const semanticDiagnostics = semanticContractDiagnostics(workflow);
803
1413
  if (semanticDiagnostics.length) {
804
1414
  return compilerFailure(
@@ -815,98 +1425,31 @@ function compileWorkflowInternal({
815
1425
  ? createReadableLayout(workflow, layoutFeedback)
816
1426
  : createLegacyLayout();
817
1427
 
818
- const LEGEND_CATALOG = [
819
- 'frontend',
820
- 'backend',
821
- 'security',
822
- 'messagebus',
823
- 'database',
824
- 'cloud',
825
- 'external',
826
- ].map((kind) => ({ kind, label: i18nText(workflow.meta.locale, `legend.workflow.${kind}`) }));
827
- const presentLegendKinds = new Set(asArray(workflow.nodes).map((node) => node.type));
828
- const workflowLegendEntries = resolveLegend(
829
- workflow.meta?.legend,
830
- LEGEND_CATALOG,
1428
+ const {
1429
+ workflowLegendEntries,
831
1430
  presentLegendKinds,
832
- );
833
- const legendFootprintOptions = { fontSize: 7, itemGap: 7 };
834
- const oneRowLegendFootprint = legendFootprint(workflowLegendEntries, {
835
- ...legendFootprintOptions,
836
- width: Number.MAX_SAFE_INTEGER,
837
- });
838
- const minimumCanvasWidth = workflow.schema_version === 2
839
- ? Math.max(layout.defaultViewBoxWidth, oneRowLegendFootprint.minWidth + 40)
840
- : layout.defaultViewBoxWidth;
841
- const legendPackingWidth = Math.max(
842
- 1,
843
- (workflow.schema_version === 2
844
- ? minimumCanvasWidth
845
- : (workflow.meta?.viewBox?.[0] ?? minimumCanvasWidth)) - 40,
846
- );
847
- const packedLegendFootprint = legendFootprint(workflowLegendEntries, {
848
- ...legendFootprintOptions,
849
- width: legendPackingWidth,
850
- });
851
- const legendExtraHeight = workflow.schema_version === 2
852
- ? packedLegendFootprint.extraHeight
853
- : 0;
854
-
855
- // Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend.
856
- const autoHeight = layout.laneY
857
- + (layout.laneHeights?.reduce((total, height) => total + height, 0)
858
- ?? (workflow.lanes?.length || 1) * layout.laneH)
859
- + ((workflow.lanes?.length || 1) - 1) * layout.laneGap
860
- + 124
861
- + legendExtraHeight;
862
- let viewBox = workflow.meta?.viewBox || [minimumCanvasWidth, autoHeight];
1431
+ legendPackingWidth,
1432
+ packedLegendFootprint,
1433
+ legendExtraHeight,
1434
+ minimumCanvasWidth,
1435
+ } = resolveWorkflowLegendFootprint(workflow, layout);
1436
+
1437
+ const laneGeometry = createWorkflowLaneGeometry(workflow, layout, legendExtraHeight, minimumCanvasWidth);
1438
+ const {
1439
+ autoHeight,
1440
+ laneIndex,
1441
+ laneLabels,
1442
+ nodeContext,
1443
+ laneHeight,
1444
+ laneGroupHeaderH,
1445
+ laneGroupFooterH,
1446
+ laneTop,
1447
+ lastLaneBottom,
1448
+ legendY,
1449
+ } = laneGeometry;
1450
+ let viewBox = laneGeometry.initialViewBox;
863
1451
  let requiredViewBox = [...viewBox];
864
-
865
- const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
866
- const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label]));
867
-
868
- function nodeContext(node) {
869
- const group = asArray(workflow.groups).find((candidate) => (
870
- candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol
871
- ));
872
- const phase = asArray(workflow.phases).find((candidate) => (
873
- node.col >= candidate.fromCol && node.col <= candidate.toCol
874
- ));
875
- return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ')
876
- || i18nText(workflow.meta.locale, 'node.context.workflow');
877
- }
878
-
879
- function laneHeight(idOrIndex) {
880
- const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
881
- return layout.laneHeights?.[index] ?? layout.laneH;
882
- }
883
-
884
- function laneGroupHeaderH(idOrIndex) {
885
- const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
886
- return layout.groupHeaderHeights?.[index] ?? 0;
887
- }
888
-
889
- function laneGroupFooterH(idOrIndex) {
890
- const index = typeof idOrIndex === 'number' ? idOrIndex : laneIndex.get(idOrIndex);
891
- return layout.groupFooterHeights?.[index] ?? 0;
892
- }
893
-
894
- function laneTop(id) {
895
- const index = laneIndex.get(id);
896
- const precedingHeight = asArray(workflow.lanes).slice(0, index)
897
- .reduce((total, _lane, lanePosition) => total + laneHeight(lanePosition), 0);
898
- return layout.laneY + precedingHeight + index * layout.laneGap;
899
- }
900
-
901
- function lastLaneBottom() {
902
- return layout.laneY
903
- + asArray(workflow.lanes).reduce((total, _lane, index) => total + laneHeight(index), 0)
904
- + (workflow.lanes.length - 1) * layout.laneGap;
905
- }
906
-
907
- function legendY() {
908
- return lastLaneBottom() + 44 + legendExtraHeight;
909
- }
1452
+ let measuredPlanReady = false;
910
1453
 
911
1454
  function workflowLegendLayout(obstacles = []) {
912
1455
  return {
@@ -938,37 +1481,23 @@ function workflowLegendRects() {
938
1481
  ];
939
1482
  }
940
1483
 
941
- function measureNode(node) {
942
- const width = node.width || layout.nodeW;
943
- const height = node.height || (node.tag ? 68 : layout.nodeH);
944
- const cx = layout.colXs[node.col];
945
- const groupHeaderH = laneGroupHeaderH(node.lane);
946
- const contentH = laneHeight(node.lane) - layout.laneTitleH
947
- - groupHeaderH - laneGroupFooterH(node.lane);
948
- const y = laneTop(node.lane) + layout.laneTitleH + groupHeaderH
949
- + (contentH - height) / 2 + (node.yOffset || 0);
950
- return {
951
- ...node,
952
- width,
953
- height,
954
- x: cx - width / 2,
955
- y,
956
- cx,
957
- cy: y + height / 2
958
- };
959
- }
1484
+ const { measureNode, nodeTextFit, nodes } = measureWorkflowNodes(workflow, layout, laneGeometry);
960
1485
 
961
- // Font sizes for this renderer's node text; the fitting geometry is shared.
962
- const nodeTextFit = {
963
- labelPreferred: 11,
964
- labelMinimum: 9,
965
- sublabelPreferred: 8,
966
- sublabelMinimum: 6,
967
- tagPreferred: 7,
968
- tagMinimum: 6,
969
- };
1486
+ // Obstacles of the routing search are queried through a uniform grid instead of
1487
+ // scanned: a candidate can only fail an obstacle its own box can reach.
1488
+ const OBSTACLE_CELL = 160;
1489
+
1490
+ const obstacleGrid = createSpatialGrid(OBSTACLE_CELL);
1491
+ let rightmostNodeEdge = 0;
1492
+ for (const node of nodes.values()) {
1493
+ obstacleGrid.insert(rectToBounds(node), { kind: 'node', node, bounds: rectToBounds(node) });
1494
+ rightmostNodeEdge = Math.max(rightmostNodeEdge, node.x + node.width);
1495
+ }
1496
+ let rightmostRoutedEdge = Number.NEGATIVE_INFINITY;
970
1497
 
971
- const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)]));
1498
+ // Routed endpoints per node, so the port search reads only the routes that touch
1499
+ // the node instead of walking every routed path.
1500
+ const nodeRoutes = new Map();
972
1501
 
973
1502
  function workflowCompositionFrames() {
974
1503
  const frames = [];
@@ -1045,187 +1574,69 @@ function workflowSceneLabelObstacles() {
1045
1574
  return obstacles;
1046
1575
  }
1047
1576
 
1048
- const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index]));
1049
- const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => {
1050
- const fromStep = mainPathSteps.get(edge.from);
1051
- const toStep = mainPathSteps.get(edge.to);
1052
- const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null;
1053
- return [edge, mainStep ?? asArray(workflow.mainPath).length + index];
1054
- }));
1055
-
1056
- function nodeStep(node) {
1057
- return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id);
1058
- }
1577
+ const { mainPathSteps, edgeSteps, nodeStep } = createWorkflowStepIndexes(workflow);
1578
+ const edgeIndexByEdge = new Map(asArray(workflow.edges).map((edge, index) => [edge, index]));
1059
1579
 
1580
+ // Several rejected routes can propose the same document edit. Reuse its
1581
+ // full verification within this compilation, with a bounded local cache.
1582
+ const fixResults = new Map();
1060
1583
  function acceptsFix(mutator) {
1061
1584
  if (!discoverFixes) return false;
1062
1585
  const candidate = cloneWorkflow(workflow);
1586
+ // Migration projections carry geometry provenance outside their JSON.
1587
+ // Keep that metadata without making ordinary authored pins sticky.
1588
+ for (const symbol of Object.getOwnPropertySymbols(workflow)) {
1589
+ candidate[symbol] = workflow[symbol];
1590
+ }
1063
1591
  mutator(candidate);
1064
- return withDiagnosticRecordingSuppressed(() => compileWorkflowWithFeedback({
1592
+ const key = JSON.stringify(candidate);
1593
+ if (fixResults.has(key)) return fixResults.get(key);
1594
+ const accepted = withDiagnosticRecordingSuppressed(() => compileWorkflowWithFeedback({
1065
1595
  workflow: candidate,
1066
1596
  qualityProfile: resolvedQualityProfile,
1067
1597
  discoverFixes: false,
1068
1598
  }).ok);
1599
+ if (fixResults.size < 128) fixResults.set(key, accepted);
1600
+ return accepted;
1069
1601
  }
1070
1602
 
1071
- function verifiedLegacyAlternative(edge, from, to, requiredClearance) {
1072
- const occupied = [...nodes.values()].filter((node) => node.lane === to.lane && node.id !== to.id);
1073
- const candidates = layout.colXs.map((center, col) => ({ center, col }))
1074
- .filter(({ col }) => col !== to.col)
1075
- .sort((a, b) => Math.abs(a.col - to.col) - Math.abs(b.col - to.col) || a.col - b.col);
1076
- for (const candidate of candidates) {
1077
- const candidateRect = { ...to, col: candidate.col, cx: candidate.center, x: candidate.center - to.width / 2 };
1078
- if (occupied.some((node) => rectsOverlap(candidateRect, node, 8))) continue;
1079
- const centerDistance = Math.abs(candidate.center - from.cx);
1080
- const signedClearance = centerDistance - from.width / 2 - to.width / 2;
1081
- if (signedClearance < requiredClearance) continue;
1082
- if (acceptsFix((document) => {
1083
- document.nodes.find((node) => node.id === to.id).col = candidate.col;
1084
- })) return candidate.col;
1603
+ function repointReferences(document, unknownId, nodeId) {
1604
+ for (const entry of asArray(document.edges)) {
1605
+ if (entry.from === unknownId) entry.from = nodeId;
1606
+ if (entry.to === unknownId) entry.to = nodeId;
1607
+ }
1608
+ if (Array.isArray(document.mainPath)) {
1609
+ document.mainPath = document.mainPath.map((id) => (id === unknownId ? nodeId : id));
1085
1610
  }
1086
- return null;
1087
1611
  }
1088
1612
 
1089
- function readableMigrationProvidesCapacity(from, to, requiredClearance) {
1090
- const readable = createReadableLayout({ ...workflow, schema_version: 2 });
1091
- const centerDistance = Math.abs(readable.colXs[to.col] - readable.colXs[from.col]);
1092
- if (centerDistance - from.width / 2 - to.width / 2 < requiredClearance) return false;
1093
- if (!discoverFixes) return false;
1094
-
1095
- return withDiagnosticRecordingSuppressed(() => {
1096
- const migrationQualityProfile = authoredQualityProfile;
1097
- let planned = compileWorkflowWithFeedback({
1098
- workflow: intrinsicWorkflow(workflow),
1099
- qualityProfile: migrationQualityProfile,
1100
- discoverFixes: false,
1101
- });
1102
- if (!planned.ok) {
1103
- planned = compileWorkflowWithFeedback({
1104
- workflow: planningWorkflow(workflow),
1105
- qualityProfile: migrationQualityProfile,
1106
- discoverFixes: false,
1107
- });
1108
- }
1109
- if (!planned.ok || !Array.isArray(planned.receipt?.columns)) return false;
1110
-
1111
- let candidate;
1112
- try {
1113
- candidate = createMappedWorkflowCandidate(
1114
- workflow,
1115
- LEGACY_COLUMN_CENTERS,
1116
- planned.receipt.columns,
1117
- ).document;
1118
- } catch {
1119
- return false;
1613
+ // repointReferences verifies repointing every reference together; suggestions
1614
+ // must list that same complete edit set so applying them verbatim is what was
1615
+ // verified. Indexes follow the emitter conventions: edges use the
1616
+ // sourceIndexes space, mainPath the canonical workflow's.
1617
+ function unknownIdReferenceFixes(unknownId, nodeId) {
1618
+ const fixes = [];
1619
+ asArray(qualityResolvedWorkflow.edges).forEach((entry, index) => {
1620
+ for (const field of ['from', 'to']) {
1621
+ if (entry[field] === unknownId) fixes.push(`set /edges/${index}/${field} to verified node id "${nodeId}"`);
1120
1622
  }
1121
- let compiled = compileWorkflowWithFeedback({
1122
- workflow: candidate,
1123
- qualityProfile: migrationQualityProfile,
1124
- discoverFixes: false,
1125
- });
1126
- const requiredViewBox = compiled.diagnostics?.length
1127
- && compiled.diagnostics.every(({ code }) => code === 'workflow/viewbox-capacity')
1128
- ? compiled.diagnostics.find(({ evidence }) => Array.isArray(evidence?.requiredViewBox))
1129
- ?.evidence.requiredViewBox
1130
- : null;
1131
- if (!compiled.ok && Array.isArray(candidate.meta?.viewBox) && requiredViewBox) {
1132
- candidate.meta.viewBox = [
1133
- Math.max(candidate.meta.viewBox[0], requiredViewBox[0]),
1134
- Math.max(candidate.meta.viewBox[1], requiredViewBox[1]),
1135
- ];
1136
- compiled = compileWorkflowWithFeedback({
1137
- workflow: candidate,
1138
- qualityProfile: migrationQualityProfile,
1139
- discoverFixes: false,
1140
- });
1141
- }
1142
- return compiled.ok;
1143
1623
  });
1144
- }
1145
-
1146
- function verifiedReducedWidths(from, to, requiredClearance) {
1147
- const widthBudget = 2 * (Math.abs(to.cx - from.cx) - requiredClearance);
1148
- if (widthBudget < 64) return null;
1149
- const widths = [from.width, to.width];
1150
- let excess = widths[0] + widths[1] - widthBudget;
1151
- for (const index of widths[0] >= widths[1] ? [0, 1] : [1, 0]) {
1152
- const reduction = Math.min(excess, widths[index] - 32);
1153
- widths[index] -= reduction;
1154
- excess -= reduction;
1155
- }
1156
- if (excess > 0.0001) return null;
1157
- const candidates = [from, to];
1158
- const labelsFit = candidates.every((node, index) => (
1159
- textUnits(node.label) * 6.8 <= widths[index] + 6
1160
- && (!node.sublabel || minimumNodeTextWidth(node.sublabel, nodeTextFit.sublabelMinimum) <= availableNodeTextWidth(widths[index]))
1161
- && (!node.tag || minimumNodeTextWidth(node.tag, nodeTextFit.tagMinimum) <= availableNodeTextWidth(widths[index]))
1162
- ));
1163
- if (!labelsFit) return null;
1164
- const serializedWidths = widths.map((width) => Math.floor((width + 1e-9) * 100) / 100);
1165
- const signedClearance = Math.abs(to.cx - from.cx)
1166
- - serializedWidths[0] / 2 - serializedWidths[1] / 2;
1167
- if (signedClearance + 0.0001 < requiredClearance) return null;
1168
- const accepted = acceptsFix((document) => {
1169
- document.nodes.find((node) => node.id === from.id).width = serializedWidths[0];
1170
- document.nodes.find((node) => node.id === to.id).width = serializedWidths[1];
1171
- });
1172
- return accepted ? serializedWidths : null;
1173
- }
1174
-
1175
- function enforceLegacyColumnCapacity() {
1176
- if (workflow.schema_version !== 1) return;
1177
- for (const edge of workflow.edges) {
1178
- const from = nodes.get(edge.from);
1179
- const to = nodes.get(edge.to);
1180
- if (!from || !to || from.lane !== to.lane || from.col === to.col) continue;
1181
- if (!verticalIntervalsOverlap(from, to, 8)) continue;
1182
- const centerDistance = Math.abs(to.cx - from.cx);
1183
- const actualSignedClearance = centerDistance - from.width / 2 - to.width / 2;
1184
- const direct = !edge.via && ['auto', 'straight'].includes(edge.route || 'auto')
1185
- && Math.abs(from.cy - to.cy) < 0.0001;
1186
- const requiredDirectClearance = direct ? 28 : 8;
1187
- if (actualSignedClearance >= requiredDirectClearance) continue;
1188
- const alternative = verifiedLegacyAlternative(edge, from, to, requiredDirectClearance);
1189
- const reducedWidths = verifiedReducedWidths(from, to, requiredDirectClearance);
1190
- const capacity = actualSignedClearance < 0
1191
- ? `overlap by ${Math.abs(Math.round(actualSignedClearance))}px`
1192
- : `leave only ${Math.round(actualSignedClearance)}px of direct clearance`;
1193
- const message = `Workflow columns ${from.col}→${to.col} place nodes "${from.id}" and "${to.id}" so they ${capacity} under the fixed-v1 layout.`;
1194
- const supportedFixes = [];
1195
- if (readableMigrationProvidesCapacity(from, to, requiredDirectClearance)) {
1196
- supportedFixes.push('migrate this workflow to schema_version 2');
1197
- }
1198
- if (alternative !== null) supportedFixes.push(`move node "${to.id}" to verified free column ${alternative}`);
1199
- if (reducedWidths) {
1200
- supportedFixes.push(`set node widths "${from.id}"=${Math.round(reducedWidths[0] * 100) / 100}px and "${to.id}"=${Math.round(reducedWidths[1] * 100) / 100}px`);
1201
- }
1202
- throwDiagnosticError(message, [{
1203
- code: 'workflow/column-capacity',
1204
- severity: 'error',
1205
- message,
1206
- subject: {
1207
- diagramType: 'workflow',
1208
- edge: edge.id ?? null,
1209
- from: edge.from,
1210
- to: edge.to,
1211
- fromCol: from.col,
1212
- toCol: to.col,
1213
- },
1214
- evidence: {
1215
- centerDistancePx: centerDistance,
1216
- nodeWidthsPx: [from.width, to.width],
1217
- actualSignedClearancePx: actualSignedClearance,
1218
- requiredDirectClearancePx: requiredDirectClearance,
1219
- },
1220
- supportedFixes,
1221
- suppresses: [
1222
- 'workflow/short-edge',
1223
- 'clean-flow/endpoint-side-direction',
1224
- 'workflow/label-node-overlap',
1225
- ],
1226
- }]);
1227
- }
1228
- }
1624
+ asArray(workflow.mainPath).forEach((id, index) => {
1625
+ if (id === unknownId) fixes.push(`set /mainPath/${index} to verified node id "${nodeId}"`);
1626
+ });
1627
+ return fixes;
1628
+ }
1629
+
1630
+ const { enforceLegacyColumnCapacity } = createLegacyCapacityRepair({
1631
+ workflow,
1632
+ nodes,
1633
+ layout,
1634
+ discoverFixes,
1635
+ resolvedQualityProfile,
1636
+ authoredQualityProfile,
1637
+ nodeTextFit,
1638
+ acceptsFix,
1639
+ });
1229
1640
 
1230
1641
  function verifiedEdgeFix(edge, message, mutator) {
1231
1642
  const edgeIndex = workflow.edges.indexOf(edge);
@@ -1535,13 +1946,26 @@ function throwReadableLabelRoutePinConflict(hit, routePoints = null) {
1535
1946
  const routePinned = hasAuthoredRouteAssertions(routeEdge);
1536
1947
  if (!labelPinned && !routePinned) return false;
1537
1948
  const alternatives = verifiedLabelRoutePinAlternatives(labelEdge, routeEdge);
1949
+ const labelNudges = labelPinned && discoverFixes ? verifiedLabelAtAlternatives(labelEdge) : [];
1950
+ // A passing replacement proves the label pin is causal even if its removal
1951
+ // recreates the collision under the ordinary automatic reservation policy.
1952
+ const conflictingRefs = labelNudges.length
1953
+ && !alternatives.conflictingRefs.some(({ edge, field }) => edge === labelEdge && field === 'labelAt')
1954
+ ? [{ edge: labelEdge, edgeIndex: workflow.edges.indexOf(labelEdge), field: 'labelAt' }, ...alternatives.conflictingRefs]
1955
+ : alternatives.conflictingRefs;
1956
+ const supportedFixes = [
1957
+ ...labelNudges,
1958
+ ...alternatives.repairs.filter(({ removalSet }) => (
1959
+ !labelNudges.length || removalSet.length !== 1 || removalSet[0].field !== 'labelAt'
1960
+ )).map(({ message }) => message),
1961
+ ];
1538
1962
  const actualRoutePoints = routePoints
1539
1963
  || pathCache.get(routeEdge)?.points
1540
1964
  || pathFor(routeEdge).points;
1541
- const diagnosticEdge = alternatives.conflictingRefs[0]?.edge
1965
+ const diagnosticEdge = conflictingRefs[0]?.edge
1542
1966
  || (labelPinned ? labelEdge : routeEdge);
1543
1967
  throwExplicitPinConflict(diagnosticEdge, 'explicit label-route clearance', {
1544
- conflictingPins: alternatives.conflictingPins,
1968
+ conflictingPins: conflictingRefs.map(({ edge, field }) => authoredPinEvidence(edge, field)),
1545
1969
  ...(labelPinned ? { labelAt: [...labelEdge.labelAt] } : {}),
1546
1970
  labelRect: {
1547
1971
  x: hit.rect.x,
@@ -1559,9 +1983,7 @@ function throwReadableLabelRoutePinConflict(hit, routePoints = null) {
1559
1983
  routeSegment: { from: [...hit.start], to: [...hit.end] },
1560
1984
  clearancePx: Math.round(hit.clearance * 10) / 10,
1561
1985
  minimumPx: hit.threshold,
1562
- }, [
1563
- ...verifiedRepairsWithLabelNudges(alternatives),
1564
- ]);
1986
+ }, supportedFixes);
1565
1987
  return true;
1566
1988
  }
1567
1989
 
@@ -1842,7 +2264,7 @@ function corridorTopologyMatches(points, axis, coordinate) {
1842
2264
  const via = axis === 'x'
1843
2265
  ? [[coordinate, start[1]], [coordinate, end[1]]]
1844
2266
  : [[start[0], coordinate], [end[0], coordinate]];
1845
- const expected = normalizeRoutePoints([start, ...via, end]);
2267
+ const expected = joinRoutePoints(start, via, end);
1846
2268
  const actualPattern = routeSegments(collapsed).map(({ orientation }) => orientation);
1847
2269
  const expectedPattern = routeSegments(expected).map(({ orientation }) => orientation);
1848
2270
  return actualPattern.length === expectedPattern.length
@@ -1911,8 +2333,89 @@ function routeContainsChannelPin(points, field, value) {
1911
2333
  });
1912
2334
  }
1913
2335
 
2336
+ function clearStraightAutomaticPlan(edge) {
2337
+ if (!independentAutomaticRoute(edge) || edge.from === edge.to) return null;
2338
+ // A return must not reserve the shared facing corridor before its main
2339
+ // relationship. It can still use a clear straight route in ordinary routing.
2340
+ if (edge.role !== 'main' && workflow.edges.some(other => (
2341
+ other.role === 'main' && other.from === edge.to && other.to === edge.from
2342
+ && !pathCache.has(other)
2343
+ ))) return null;
2344
+ // Relative label pins are not absolute, but they still participate in
2345
+ // placed-label conflict evidence. Reserving a straight path for them first
2346
+ // can change which edge owns the corridor and scramble supportedFixes.
2347
+ if (edge.labelDx !== undefined || edge.labelDy !== undefined || edge.labelSegment !== undefined) {
2348
+ return null;
2349
+ }
2350
+ const from = nodes.get(edge.from);
2351
+ const to = nodes.get(edge.to);
2352
+ const start = anchor(from, defaultFromSide(from, to));
2353
+ const end = anchor(to, defaultToSide(from, to));
2354
+ const aligned = Math.abs(start[0] - end[0]) < 0.0001 || Math.abs(start[1] - end[1]) < 0.0001;
2355
+ if (!aligned || !routeClearsUnrelatedNodes(edge, [start, end])) return null;
2356
+ // Facing centres are only a cheap filter. Port selection, labels and route
2357
+ // costs can still select a bent route; reserving that route first would let
2358
+ // a long branch displace a shorter one. Preview the ordinary planner and
2359
+ // reserve only its selected straight route, without registering obstacles.
2360
+ const hadSides = readableSideCache.has(edge);
2361
+ const previousSides = readableSideCache.get(edge);
2362
+ try {
2363
+ const plan = withDiagnosticRecordingSuppressed(() => readableAutomaticRoute(
2364
+ edge, from, to, edgeSides(edge), automaticPorts.get(edge),
2365
+ ));
2366
+ return plan.points.length === 2 ? plan : null;
2367
+ } catch (error) {
2368
+ // A preview does not own layout feedback or failure diagnostics. Let the
2369
+ // ordinary routing order establish them with its complete preceding scene.
2370
+ if (error instanceof WorkflowLayoutFeedback || Array.isArray(error?.archifyDiagnostics)) return null;
2371
+ throw error;
2372
+ } finally {
2373
+ if (hadSides) readableSideCache.set(edge, previousSides);
2374
+ else readableSideCache.delete(edge);
2375
+ }
2376
+ }
2377
+
1914
2378
  function validateReadablePinnedGeometry() {
1915
2379
  if (workflow.schema_version !== 2) return;
2380
+ // Reserve absolute geometry at contested nodes before automatic routing,
2381
+ // independently of IDs. Unrelated routes retain their diagnostic ordering.
2382
+ for (const edge of workflow.edges) {
2383
+ if (!hasAbsoluteRoutePins(edge) || !nodes.has(edge.from) || !nodes.has(edge.to)) continue;
2384
+ if (!workflow.edges.some(other => !hasAbsoluteRoutePins(other)
2385
+ && [edge.from, edge.to].some(id => id === other.from || id === other.to))) continue;
2386
+ validateReadableRouteControls(edge);
2387
+ pathFor(edge);
2388
+ }
2389
+ // Reserve clear straight automatic routes before ID-ordered bending edges so
2390
+ // a later edge cannot steal a corridor that already has one good straight path.
2391
+ // Fix probes use the same reservation policy as public compilation. A
2392
+ // verified labelAt replacement retains causal label-pin evidence even when
2393
+ // deleting that pin lets automatic routing recreate its collision.
2394
+ // Skip when any edge carries a relative/absolute label pin: straight-first
2395
+ // would reorder corridors and scramble placed-label conflict evidence.
2396
+ const hasLabelPins = workflow.edges.some((edge) => (
2397
+ edge.labelAt !== undefined
2398
+ || edge.labelDx !== undefined
2399
+ || edge.labelDy !== undefined
2400
+ || edge.labelSegment !== undefined
2401
+ ));
2402
+ if (!hasLabelPins) {
2403
+ for (const edge of workflow.edges) {
2404
+ if (!nodes.has(edge.from) || !nodes.has(edge.to)) continue;
2405
+ const plan = clearStraightAutomaticPlan(edge);
2406
+ if (!plan) continue;
2407
+ validateReadableRouteControls(edge);
2408
+ pathFor(edge, plan);
2409
+ }
2410
+ }
2411
+ // Routing order feedback: an automatic edge that an earlier automatic edge
2412
+ // forced into a shared corridor or crossing is planned first on a retry.
2413
+ for (const index of asArray(layoutFeedback.routeFirst)) {
2414
+ // Diagnostics name source indexes; the canonical edge order differs.
2415
+ const edge = workflow.edges.find((candidate) => sourceIndexes.edges.get(candidate) === index);
2416
+ if (!edge || !nodes.has(edge.from) || !nodes.has(edge.to) || !independentAutomaticRoute(edge)) continue;
2417
+ pathFor(edge);
2418
+ }
1916
2419
  for (const edge of workflow.edges) {
1917
2420
  if (!nodes.has(edge.from) || !nodes.has(edge.to)) continue;
1918
2421
  validateReadableRouteControls(edge);
@@ -2188,6 +2691,8 @@ function validateReadablePinnedGeometry() {
2188
2691
  }
2189
2692
 
2190
2693
  function validateWorkflow() {
2694
+ // Report edges by their authored index; the canonical order is internal.
2695
+ const authoredEdges = asArray(qualityResolvedWorkflow.edges);
2191
2696
  const problems = [];
2192
2697
  if (workflow.schema_version !== 1 && workflow.schema_version !== 2) {
2193
2698
  problems.push('Workflow files must set "schema_version" to 1 or 2.');
@@ -2253,26 +2758,10 @@ function validateWorkflow() {
2253
2758
  continue;
2254
2759
  }
2255
2760
  if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
2256
- problems.push(`Node "${node.id}" produced non-finite coordinates - check col, width, height, and yOffset are numbers.`);
2761
+ problems.push(`Node "${node.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
2257
2762
  continue;
2258
2763
  }
2259
- const estLabelW = textUnits(node.label) * 6.8;
2260
- if (estLabelW > node.width + 6) {
2261
- problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) - shorten the label or increase node.width.`);
2262
- }
2263
- const brandRailProblem = brandTopRailProblem(node, node.width, nodeTextFit.labelMinimum);
2264
- if (brandRailProblem) problems.push(brandRailProblem);
2265
- const availableTextW = availableNodeTextWidth(node.width);
2266
- for (const [field, value, minimum] of [
2267
- ['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
2268
- ['Tag', node.tag, nodeTextFit.tagMinimum],
2269
- ]) {
2270
- if (!value) continue;
2271
- const minimumW = minimumNodeTextWidth(value, minimum);
2272
- if (minimumW > availableTextW) {
2273
- problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or increase node.width.`);
2274
- }
2275
- }
2764
+ problems.push(...workflowNodeTextProblems(node, nodeTextFit));
2276
2765
 
2277
2766
  const top = laneTop(node.lane);
2278
2767
  const contentTop = top + layout.laneTitleH + laneGroupHeaderH(node.lane);
@@ -2299,7 +2788,7 @@ function validateWorkflow() {
2299
2788
  const estLabelW = textUnits(phase.label) * 5.6;
2300
2789
  const width = phaseSpan(phase).width;
2301
2790
  if (estLabelW > width + 8) {
2302
- problems.push(`Phase label "${phase.label}" (~${Math.round(estLabelW)}px) is wider than its ${Math.round(width)}px span - shorten the label or widen the phase range.`);
2791
+ problems.push(`Phase label "${phase.label}" (~${Math.round(estLabelW)}px) is wider than its ${Math.round(width)}px span — shorten the label or widen the phase range.`);
2303
2792
  }
2304
2793
  }
2305
2794
  phaseRanges.sort((a, b) => a.fromCol - b.fromCol || a.toCol - b.toCol);
@@ -2308,7 +2797,7 @@ function validateWorkflow() {
2308
2797
  const earlier = phaseRanges[i];
2309
2798
  const later = phaseRanges[j];
2310
2799
  if (later.fromCol > earlier.toCol) break;
2311
- problems.push(`Phase "${later.id}" (${later.fromCol}..${later.toCol}) overlaps phase "${earlier.id}" (${earlier.fromCol}..${earlier.toCol}) - start at col ${earlier.toCol + 1} or later, or end the earlier phase at col ${later.fromCol - 1}.`);
2800
+ problems.push(`Phase "${later.id}" (${later.fromCol}..${later.toCol}) overlaps phase "${earlier.id}" (${earlier.fromCol}..${earlier.toCol}) — start at col ${earlier.toCol + 1} or later, or end the earlier phase at col ${later.fromCol - 1}.`);
2312
2801
  }
2313
2802
  }
2314
2803
 
@@ -2326,7 +2815,7 @@ function validateWorkflow() {
2326
2815
  }
2327
2816
  const contained = [...nodes.values()].some((node) => node.lane === group.lane && node.col >= group.fromCol && node.col <= group.toCol);
2328
2817
  if (!contained) {
2329
- problems.push(`Group "${group.id}" does not contain any nodes - align its lane/columns with the parallel or branch work it frames.`);
2818
+ problems.push(`Group "${group.id}" does not contain any nodes — align its lane/columns with the parallel or branch work it frames.`);
2330
2819
  }
2331
2820
  }
2332
2821
 
@@ -2338,7 +2827,7 @@ function validateWorkflow() {
2338
2827
  for (let i = 0; i < laneNodes.length; i += 1) {
2339
2828
  for (let j = i + 1; j < laneNodes.length; j += 1) {
2340
2829
  if (rectsOverlap(laneNodes[i], laneNodes[j], 8)) {
2341
- problems.push(`Nodes "${laneNodes[i].id}" and "${laneNodes[j].id}" are less than 8px apart in lane "${lane}" - move one to another col, adjust yOffset, or reduce width/height.`);
2830
+ problems.push(`Nodes "${laneNodes[i].id}" and "${laneNodes[j].id}" are less than 8px apart in lane "${lane}" — move one to another col, adjust yOffset, or reduce width/height.`);
2342
2831
  }
2343
2832
  }
2344
2833
  }
@@ -2353,14 +2842,14 @@ function validateWorkflow() {
2353
2842
  const [start, end] = routed.points;
2354
2843
  const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1]);
2355
2844
  if (segmentLength < 28) {
2356
- problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) - move the nodes farther apart or use a verified orthogonal route with readable clearance.`);
2845
+ problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) — move the nodes farther apart or use a verified orthogonal route with readable clearance.`);
2357
2846
  }
2358
2847
  }
2359
2848
  }
2360
2849
  }
2361
2850
 
2362
2851
  problems.push(...cleanEndpointSideProblems({
2363
- relations: workflow.edges,
2852
+ relations: authoredEdges,
2364
2853
  endpointIds: new Set(nodes.keys()),
2365
2854
  pathFor,
2366
2855
  diagramType: 'workflow',
@@ -2370,7 +2859,7 @@ function validateWorkflow() {
2370
2859
  routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
2371
2860
  }));
2372
2861
  problems.push(...cleanFlowProblems({
2373
- relations: workflow.edges,
2862
+ relations: authoredEdges,
2374
2863
  obstacles: nodes.values(),
2375
2864
  pathFor,
2376
2865
  diagramType: 'workflow',
@@ -2379,7 +2868,7 @@ function validateWorkflow() {
2379
2868
  routeHint: 'adjust fromSide/toSide, set route/via or channel coordinates, or move the node to a clearer lane/column'
2380
2869
  }));
2381
2870
  problems.push(...cleanCrossingProblems({
2382
- relations: workflow.edges,
2871
+ relations: authoredEdges,
2383
2872
  endpointIds: new Set(nodes.keys()),
2384
2873
  pathFor,
2385
2874
  diagramType: 'workflow',
@@ -2387,20 +2876,44 @@ function validateWorkflow() {
2387
2876
  profile: workflow.meta?.quality_profile,
2388
2877
  profileIsAuthoritative: true,
2389
2878
  mergeForwardCollinearWaypoints: workflow.schema_version === 2,
2879
+ includeSharedEndpoints: () => workflow.schema_version === 2,
2880
+ warnInStandard: workflow.schema_version === 2,
2881
+ ...(workflow.schema_version === 2 ? { onDiagnostic: diagnostic => workflowDiagnostics.push(diagnostic) } : {}),
2390
2882
  routeHint: 'adjust route/via, bias, or channel coordinates so the edges use separate lane corridors'
2391
2883
  }));
2392
2884
  problems.push(...cleanAmbiguousCorridorProblems({
2393
- relations: workflow.edges,
2885
+ relations: authoredEdges,
2394
2886
  endpointIds: new Set(nodes.keys()),
2395
2887
  pathFor,
2396
2888
  diagramType: 'workflow',
2397
2889
  relationCollection: 'edges',
2398
2890
  profile: workflow.meta?.quality_profile,
2399
2891
  profileIsAuthoritative: true,
2892
+ includeSharedEndpoints: () => workflow.schema_version === 2,
2893
+ allowShortWorkflowTrunks: workflow.schema_version === 2,
2894
+ onDiagnostic: (diagnostic) => workflowDiagnostics.push(diagnostic),
2400
2895
  routeHint: 'adjust route/via, bias, or channel coordinates so unrelated edges do not visually merge'
2401
2896
  }));
2897
+ if (workflow.schema_version === 2) {
2898
+ const routedRelations = workflow.edges.map((relation, relationIndex) => ({
2899
+ relation: { ...relation, width: relation.width || (relation.variant === 'emphasis' ? 1.8 : 1.4) },
2900
+ relationIndex, points: pathFor(relation).points,
2901
+ }));
2902
+ for (const hit of collectArrowheadCollisions({ routedRelations, allowShortWorkflowTrunks: true })) {
2903
+ const severity = resolvedQualityProfile === 'showcase' ? 'error' : 'warning';
2904
+ const name = entry => entry.relation.id || `${entry.relation.from}->${entry.relation.to}`;
2905
+ const message = `[composition/arrowhead-collision] workflow arrows "${name(hit.left)}" and "${name(hit.right)}" overlap at their destination (clearance ${hit.distance}px; minimum ${hit.minimum}px).`;
2906
+ workflowDiagnostics.push({
2907
+ code: 'composition/arrowhead-collision', severity, message,
2908
+ subject: { diagramType: 'workflow', edge: name(hit.left), from: hit.left.relation.from, to: hit.left.relation.to },
2909
+ evidence: { otherEdge: name(hit.right), distancePx: hit.distance, minimumPx: hit.minimum },
2910
+ supportedFixes: ['use separate destination sides or ports with clearance for both arrow markers'],
2911
+ });
2912
+ if (severity === 'error') problems.push(message);
2913
+ }
2914
+ }
2402
2915
  problems.push(...cleanBorderRunProblems({
2403
- relations: workflow.edges,
2916
+ relations: authoredEdges,
2404
2917
  endpointIds: new Set(nodes.keys()),
2405
2918
  frames: workflowCompositionFrames(),
2406
2919
  pathFor,
@@ -2411,7 +2924,7 @@ function validateWorkflow() {
2411
2924
  routeHint: 'adjust route/via, bias, or channel coordinates so the edge crosses the lane or group perpendicularly instead of following its border'
2412
2925
  }));
2413
2926
  problems.push(...cleanRouteRhythmProblems({
2414
- relations: workflow.edges,
2927
+ relations: authoredEdges,
2415
2928
  endpointIds: new Set(nodes.keys()),
2416
2929
  pathFor,
2417
2930
  diagramType: 'workflow',
@@ -2422,9 +2935,31 @@ function validateWorkflow() {
2422
2935
  }));
2423
2936
 
2424
2937
  if (Array.isArray(workflow.mainPath)) {
2425
- for (const id of workflow.mainPath) {
2938
+ for (let stepIndex = 0; stepIndex < workflow.mainPath.length; stepIndex += 1) {
2939
+ const id = workflow.mainPath[stepIndex];
2426
2940
  if (!nodes.has(id)) {
2427
- problems.push(`mainPath references unknown node "${id}".`);
2941
+ const message = `mainPath references unknown node "${id}".`;
2942
+ problems.push(message);
2943
+ const candidates = [...nodes.keys()].sort(stableCompare);
2944
+ workflowDiagnostics.push({
2945
+ code: 'workflow/unknown-endpoint',
2946
+ severity: 'error',
2947
+ message,
2948
+ subject: {
2949
+ diagramType: 'workflow',
2950
+ mainPath: id,
2951
+ path: `/mainPath/${stepIndex}`,
2952
+ },
2953
+ evidence: {
2954
+ unknownNodeId: id,
2955
+ availableNodeIds: candidates,
2956
+ fixVerification: 'each candidate was verified by applying every listed repoint together and recompiling',
2957
+ },
2958
+ supportedFixes: candidates
2959
+ .filter((nodeId) => acceptsFix((document) => repointReferences(document, id, nodeId)))
2960
+ .slice(0, 3)
2961
+ .flatMap((nodeId) => unknownIdReferenceFixes(id, nodeId)),
2962
+ });
2428
2963
  }
2429
2964
  }
2430
2965
  for (let i = 0; i < workflow.mainPath.length - 1; i += 1) {
@@ -2435,10 +2970,10 @@ function validateWorkflow() {
2435
2970
  if (!from || !to) continue;
2436
2971
  const linked = workflow.edges.some((edge) => edge.from === fromId && edge.to === toId);
2437
2972
  if (!linked) {
2438
- problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge - add the edge or remove the pair from mainPath.`);
2973
+ problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge — add the edge or remove the pair from mainPath.`);
2439
2974
  }
2440
2975
  if (to.col < from.col) {
2441
- problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} - use a return edge outside mainPath for loops.`);
2976
+ problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} — use a return edge outside mainPath for loops.`);
2442
2977
  }
2443
2978
  }
2444
2979
  }
@@ -2451,19 +2986,19 @@ function validateWorkflow() {
2451
2986
  for (const rect of labelRects) {
2452
2987
  for (const node of nodes.values()) {
2453
2988
  if (rectsOverlap(rect, node, -2)) {
2454
- problems.push(`Label "${rect.label}" overlaps node "${node.id}" - adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`);
2989
+ problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node', viewBox, nodes.values())}`);
2455
2990
  }
2456
2991
  }
2457
2992
  }
2458
2993
  for (let i = 0; i < labelRects.length; i += 1) {
2459
2994
  for (let j = i + 1; j < labelRects.length; j += 1) {
2460
2995
  if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
2461
- problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap - adjust labelDx/labelDy/labelSegment or route one relationship through a separate corridor.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
2996
+ problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy/labelSegment or route one relationship through a separate corridor.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
2462
2997
  }
2463
2998
  }
2464
2999
  }
2465
3000
  problems.push(...cleanLabelRouteClearanceProblems({
2466
- relations: workflow.edges,
3001
+ relations: authoredEdges,
2467
3002
  labels: labelRects,
2468
3003
  endpointIds: new Set(nodes.keys()),
2469
3004
  pathFor,
@@ -2475,16 +3010,27 @@ function validateWorkflow() {
2475
3010
 
2476
3011
  if (workflow.schema_version === 1) {
2477
3012
  if (viewBox[0] < layout.laneX + layout.laneW + 16) {
2478
- problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes - set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`);
3013
+ problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes — set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`);
2479
3014
  }
2480
3015
  if (legendY() + 18 > viewBox[1]) {
2481
- problems.push(`Legend exceeds viewBox height ${viewBox[1]} - set meta.viewBox[1] to at least ${legendY() + 18}.`);
3016
+ problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`);
2482
3017
  }
3018
+ // v1 only; see collectLabelCanvasOverflow in shared/geometry.mjs.
3019
+ problems.push(...cleanLabelCanvasContainmentProblems({
3020
+ labels: labelRects,
3021
+ viewBox,
3022
+ diagramType: 'workflow',
3023
+ relationCollection: 'edges',
3024
+ profile: workflow.meta?.quality_profile,
3025
+ profileIsAuthoritative: true,
3026
+ }));
2483
3027
  }
2484
3028
 
3029
+ measuredPlanReady = hasCompleteMeasuredPlan();
2485
3030
  if (problems.length) {
2486
3031
  throwDiagnosticProblems('Workflow layout validation failed', problems, {
2487
3032
  subject: { diagramType: 'workflow' },
3033
+ diagnostics: workflowDiagnostics,
2488
3034
  });
2489
3035
  }
2490
3036
  }
@@ -2566,14 +3112,10 @@ function validateReadableInputsBeforeRouting() {
2566
3112
  for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
2567
3113
  if (nodes.has(edge[field])) continue;
2568
3114
  const message = `Workflow edge "${workflowEdgeName(edge)}" references unknown ${endpoint} "${edge[field]}".`;
2569
- const canonicalEdgeIndex = workflow.edges.indexOf(edge);
2570
- const supportedFixes = availableNodeIds.flatMap((nodeId) => (
2571
- acceptsFix((document) => {
2572
- document.edges[canonicalEdgeIndex][field] = nodeId;
2573
- })
2574
- ? [`set /edges/${edgeIndex}/${field} to verified node id "${nodeId}"`]
2575
- : []
2576
- ));
3115
+ const supportedFixes = availableNodeIds
3116
+ .filter((nodeId) => acceptsFix((document) => repointReferences(document, edge[field], nodeId)))
3117
+ .slice(0, 3)
3118
+ .flatMap((nodeId) => unknownIdReferenceFixes(edge[field], nodeId));
2577
3119
  fail({
2578
3120
  code: 'workflow/unknown-edge-endpoint',
2579
3121
  severity: 'error',
@@ -2589,6 +3131,7 @@ function validateReadableInputsBeforeRouting() {
2589
3131
  endpoint,
2590
3132
  unknownNodeId: edge[field],
2591
3133
  availableNodeIds,
3134
+ fixVerification: 'each candidate was verified by applying every listed repoint together and recompiling',
2592
3135
  },
2593
3136
  supportedFixes,
2594
3137
  });
@@ -2664,6 +3207,10 @@ function validateReadableInputsBeforeRouting() {
2664
3207
  }
2665
3208
  byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]);
2666
3209
  }
3210
+ // Every overlapping pair is reported at once. Failing on the first pair
3211
+ // made an author who stacked two groups of nodes fix one, rerun, and only
3212
+ // then learn about the other; the draft already contained both.
3213
+ const overlaps = [];
2667
3214
  for (const [lane, laneNodes] of byLane) {
2668
3215
  for (let left = 0; left < laneNodes.length; left += 1) {
2669
3216
  for (let right = left + 1; right < laneNodes.length; right += 1) {
@@ -2683,7 +3230,7 @@ function validateReadableInputsBeforeRouting() {
2683
3230
  : [];
2684
3231
  });
2685
3232
  const message = `Workflow nodes "${leftNode.id}" and "${rightNode.id}" are less than 8px apart in lane "${lane}".`;
2686
- fail({
3233
+ overlaps.push({
2687
3234
  code: 'workflow/node-overlap',
2688
3235
  severity: 'error',
2689
3236
  message,
@@ -2702,6 +3249,11 @@ function validateReadableInputsBeforeRouting() {
2702
3249
  }
2703
3250
  }
2704
3251
  }
3252
+ if (overlaps.length) {
3253
+ throwDiagnosticError(overlaps.length === 1
3254
+ ? overlaps[0].message
3255
+ : `Workflow node overlap:\n- ${overlaps.map((entry) => entry.message).join('\n- ')}`, overlaps);
3256
+ }
2705
3257
  }
2706
3258
 
2707
3259
  function gapYBetween(fromLane, toLane, bias = 0.5) {
@@ -2748,10 +3300,38 @@ function sameLaneAutoVia(start, end) {
2748
3300
  return [[midX, start[1]], [midX, end[1]]];
2749
3301
  }
2750
3302
 
3303
+ function routeBounds(points) {
3304
+ let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
3305
+ for (const point of points) {
3306
+ if (point[0] < minX) minX = point[0];
3307
+ if (point[0] > maxX) maxX = point[0];
3308
+ if (point[1] < minY) minY = point[1];
3309
+ if (point[1] > maxY) maxY = point[1];
3310
+ }
3311
+ return { minX, minY, maxX, maxY };
3312
+ }
3313
+
3314
+ function rectToBounds(rect) {
3315
+ return { minX: rect.x, minY: rect.y, maxX: rect.x + rect.width, maxY: rect.y + rect.height };
3316
+ }
3317
+
3318
+ function boundsOverlap(a, b, margin) {
3319
+ return a.minX <= b.maxX + margin && b.minX <= a.maxX + margin
3320
+ && a.minY <= b.maxY + margin && b.minY <= a.maxY + margin;
3321
+ }
3322
+
2751
3323
  function routeClearsUnrelatedNodes(edge, points, clearance = 2) {
2752
3324
  const endpointIds = new Set([edge.from, edge.to]);
2753
- for (const node of nodes.values()) {
3325
+ const routeExtent = routeBounds(points);
3326
+ const queryBox = {
3327
+ minX: routeExtent.minX - clearance, minY: routeExtent.minY - clearance,
3328
+ maxX: routeExtent.maxX + clearance, maxY: routeExtent.maxY + clearance,
3329
+ };
3330
+ for (const item of obstacleGrid.query(queryBox)) {
3331
+ if (item.kind !== 'node') continue;
3332
+ const node = item.node;
2754
3333
  if (endpointIds.has(node.id)) continue;
3334
+ if (!boundsOverlap(routeExtent, rectToBounds(node), clearance)) continue;
2755
3335
  for (let index = 0; index < points.length - 1; index += 1) {
2756
3336
  if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, node, clearance)) {
2757
3337
  return false;
@@ -2809,121 +3389,15 @@ function oneBendCrossLaneVia(edge, start, end, fromSide, toSide) {
2809
3389
 
2810
3390
  const pathCache = new Map();
2811
3391
  const readableSideCache = new Map();
2812
-
2813
- function legacyAutomaticOneBendSides(edge, from, to) {
2814
- const automaticRoute = !edge.via && (!edge.route || edge.route === 'auto');
2815
- const automaticFrom = !edge.fromSide || edge.fromSide === 'auto';
2816
- const automaticTo = !edge.toSide || edge.toSide === 'auto';
2817
- if (!automaticRoute || !automaticFrom || !automaticTo || from.lane === to.lane) return null;
2818
- if (from.cx === to.cx || from.cy === to.cy) return null;
2819
- const verticalFrom = to.cy < from.cy ? 'top' : 'bottom';
2820
- const horizontalTo = to.cx < from.cx ? 'right' : 'left';
2821
- const horizontalFrom = to.cx < from.cx ? 'left' : 'right';
2822
- const verticalTo = to.cy < from.cy ? 'bottom' : 'top';
2823
- const candidates = [
2824
- { fromSide: verticalFrom, toSide: horizontalTo },
2825
- { fromSide: horizontalFrom, toSide: verticalTo },
2826
- ];
2827
-
2828
- return candidates.find(({ fromSide, toSide }) => {
2829
- const start = anchor(from, fromSide);
2830
- const end = anchor(to, toSide);
2831
- return oneBendCrossLaneVia(edge, start, end, fromSide, toSide);
2832
- }) || null;
2833
- }
2834
-
2835
- function readableAutomaticSides(edge, from, to) {
2836
- const automaticRoute = !edge.via
2837
- && edge.channelX === undefined
2838
- && edge.channelY === undefined
2839
- && (!edge.route || edge.route === 'auto');
2840
- const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
2841
- const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
2842
- if (!automaticRoute || (authoredFrom && authoredTo)) return null;
2843
- if (readableSideCache.has(edge)) return readableSideCache.get(edge);
2844
-
2845
- const preferred = [];
2846
- const legacyPreferred = legacyAutomaticOneBendSides(edge, from, to);
2847
- if (legacyPreferred) preferred.push(legacyPreferred);
2848
- preferred.push({
2849
- fromSide: authoredFrom || defaultFromSide(from, to),
2850
- toSide: authoredTo || defaultToSide(from, to),
2851
- });
2852
- const sideOrder = ['right', 'bottom', 'left', 'top'];
2853
- for (const fromSide of authoredFrom ? [authoredFrom] : sideOrder) {
2854
- for (const toSide of authoredTo ? [authoredTo] : sideOrder) {
2855
- preferred.push({ fromSide, toSide });
2856
- }
2857
- }
2858
-
2859
- const seen = new Set();
2860
- const sidePairs = [];
2861
- for (const candidate of preferred) {
2862
- if (authoredFrom && candidate.fromSide !== authoredFrom) continue;
2863
- if (authoredTo && candidate.toSide !== authoredTo) continue;
2864
- const key = `${candidate.fromSide}:${candidate.toSide}`;
2865
- if (seen.has(key)) continue;
2866
- seen.add(key);
2867
- sidePairs.push(candidate);
2868
- }
2869
-
2870
- const naturalFromSide = authoredFrom || defaultFromSide(from, to);
2871
- const naturalToSide = authoredTo || defaultToSide(from, to);
2872
- const planFor = (candidate, pairOrdinal) => {
2873
- const start = anchor(from, candidate.fromSide);
2874
- const end = anchor(to, candidate.toSide);
2875
- return {
2876
- start,
2877
- end,
2878
- planned: readableAutomaticCandidateSet(
2879
- edge,
2880
- from,
2881
- to,
2882
- start,
2883
- end,
2884
- candidate.fromSide,
2885
- candidate.toSide,
2886
- {
2887
- ordinalOffset: pairOrdinal * 9,
2888
- naturalFromSide,
2889
- naturalToSide,
2890
- },
2891
- ),
2892
- };
2893
- };
2894
-
2895
- const primary = sidePairs[0];
2896
- if (primary) {
2897
- const { planned } = planFor(primary, 0);
2898
- if (planned.candidates.length) {
2899
- readableSideCache.set(edge, primary);
2900
- return primary;
2901
- }
2902
- }
2903
-
2904
- const candidates = [];
2905
- for (const [pairOrdinal, candidate] of sidePairs.entries()) {
2906
- const { planned } = planFor(candidate, pairOrdinal);
2907
- candidates.push(...planned.candidates.map((route) => ({ ...route, ...candidate })));
2908
- }
2909
- candidates.sort((left, right) => compareCost(left.cost, right.cost));
2910
- if (candidates.length) {
2911
- const selected = {
2912
- fromSide: candidates[0].fromSide,
2913
- toSide: candidates[0].toSide,
2914
- };
2915
- readableSideCache.set(edge, selected);
2916
- return selected;
2917
- }
2918
- readableSideCache.set(edge, null);
2919
- return null;
2920
- }
2921
-
2922
- function automaticOneBendSides(edge, from, to) {
2923
- return workflow.schema_version === 2
2924
- ? readableAutomaticSides(edge, from, to)
2925
- : legacyAutomaticOneBendSides(edge, from, to);
2926
- }
3392
+ const workflowDiagnostics = [];
3393
+
3394
+ const { automaticOneBendSides } = createAutomaticSideSelection({
3395
+ workflow,
3396
+ readableSideCache,
3397
+ readableAutomaticCandidateSet,
3398
+ compareCost,
3399
+ oneBendCrossLaneVia,
3400
+ });
2927
3401
 
2928
3402
  const OUTWARD_SIDE_VECTOR = Object.freeze({
2929
3403
  left: [-1, 0],
@@ -2960,25 +3434,32 @@ function routeClearsEndpointNodes(points, from, to) {
2960
3434
 
2961
3435
  function routeMeetsHardRhythm(points) {
2962
3436
  if (points.length === 2) {
2963
- return Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]) + 0.0001 >= 28;
3437
+ return Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]) + 0.0001 >= hardRouteSegmentMinimum(points, 0);
2964
3438
  }
2965
- return points.slice(0, -1).every((point, index) => {
3439
+ for (let index = 0; index < points.length - 1; index += 1) {
3440
+ const point = points[index];
2966
3441
  const length = Math.abs(points[index + 1][0] - point[0]) + Math.abs(points[index + 1][1] - point[1]);
2967
- const endpoint = index === 0 || index === points.length - 2;
2968
- return length + 0.0001 >= (endpoint ? 8 : 16);
2969
- });
3442
+ if (!(length + 0.0001 >= hardRouteSegmentMinimum(points, index))) return false;
3443
+ }
3444
+ return true;
2970
3445
  }
2971
3446
 
2972
3447
  function routeLabelClearsNodes(edge, points) {
2973
3448
  if (!edge.label || edge.labelAt) return true;
2974
3449
  const [lx, ly] = workflowEdgeLabelPoint(edge, points);
2975
- const rect = {
2976
- x: lx - workflowLabelWidth(edge.label) / 2,
2977
- y: ly - 10,
2978
- width: workflowLabelWidth(edge.label),
2979
- height: 14,
3450
+ const width = workflowLabelWidth(edge.label);
3451
+ const rect = { x: lx - width / 2, y: ly - 10, width, height: 14 };
3452
+ // The comparison shrinks the rect by two pixels, so only nodes inside the
3453
+ // shrunk box can fail it.
3454
+ const queryBox = {
3455
+ minX: rect.x + 2, minY: rect.y + 2,
3456
+ maxX: rect.x + rect.width - 2, maxY: rect.y + rect.height - 2,
2980
3457
  };
2981
- return [...nodes.values()].every((node) => !rectsOverlap(rect, node, -2));
3458
+ for (const item of obstacleGrid.query(queryBox)) {
3459
+ if (item.kind !== 'node') continue;
3460
+ if (rectsOverlap(rect, item.node, -2)) return false;
3461
+ }
3462
+ return true;
2982
3463
  }
2983
3464
 
2984
3465
  function candidateLabelRect(edge, points) {
@@ -2990,25 +3471,41 @@ function candidateLabelRect(edge, points) {
2990
3471
 
2991
3472
  function labelRouteClearanceDeficit(edge, points, threshold = 8) {
2992
3473
  const candidateLabel = candidateLabelRect(edge, points);
3474
+ const candidateExtent = routeBounds(points);
3475
+ if (candidateLabel) {
3476
+ const labelExtent = rectToBounds(candidateLabel);
3477
+ candidateExtent.minX = Math.min(candidateExtent.minX, labelExtent.minX);
3478
+ candidateExtent.minY = Math.min(candidateExtent.minY, labelExtent.minY);
3479
+ candidateExtent.maxX = Math.max(candidateExtent.maxX, labelExtent.maxX);
3480
+ candidateExtent.maxY = Math.max(candidateExtent.maxY, labelExtent.maxY);
3481
+ }
3482
+ const queryBox = {
3483
+ minX: candidateExtent.minX - threshold, minY: candidateExtent.minY - threshold,
3484
+ maxX: candidateExtent.maxX + threshold, maxY: candidateExtent.maxY + threshold,
3485
+ };
3486
+ // Accumulate in the order the routes were registered, which is the order the
3487
+ // previous full scan used, so the floating-point sum is identical.
3488
+ const items = obstacleGrid.query(queryBox)
3489
+ .filter((item) => item.kind === 'route' || item.kind === 'label')
3490
+ .sort((left, right) => left.sequence - right.sequence || (left.kind === right.kind ? 0 : left.kind === 'route' ? -1 : 1));
2993
3491
  let deficit = 0;
2994
- for (const [otherEdge, routed] of pathCache) {
2995
- const otherIndex = workflow.edges.indexOf(otherEdge);
2996
- const otherLabel = labelRectFor(otherEdge, otherIndex);
2997
- if (candidateLabel) {
2998
- for (let index = 0; index < routed.points.length - 1; index += 1) {
3492
+ for (const item of items) {
3493
+ if (item.kind === 'route') {
3494
+ if (!candidateLabel) continue;
3495
+ const otherPoints = item.routed.points;
3496
+ for (let index = 0; index < otherPoints.length - 1; index += 1) {
2999
3497
  const clearance = segmentRectClearance({
3000
- start: routed.points[index],
3001
- end: routed.points[index + 1],
3498
+ start: otherPoints[index],
3499
+ end: otherPoints[index + 1],
3002
3500
  }, candidateLabel);
3003
3501
  if (clearance != null) deficit += Math.max(0, threshold - clearance);
3004
3502
  }
3005
- }
3006
- if (otherLabel) {
3503
+ } else if (candidateLabel || !edge.label) {
3007
3504
  for (let index = 0; index < points.length - 1; index += 1) {
3008
3505
  const clearance = segmentRectClearance({
3009
3506
  start: points[index],
3010
3507
  end: points[index + 1],
3011
- }, otherLabel);
3508
+ }, item.rect);
3012
3509
  if (clearance != null) deficit += Math.max(0, threshold - clearance);
3013
3510
  }
3014
3511
  }
@@ -3016,28 +3513,85 @@ function labelRouteClearanceDeficit(edge, points, threshold = 8) {
3016
3513
  return deficit;
3017
3514
  }
3018
3515
 
3019
- function routeClearsPlacedLabels(edge, points) {
3516
+ function placedLabelConflictEvidence(edge, candidateLabel, item, conflictType, points, segmentIndex, clearance) {
3517
+ const edgeEvidence = (relation) => ({
3518
+ edge: relation.id ?? null, from: relation.from, to: relation.to,
3519
+ path: `/edges/${sourceIndexes.edges.get(relation)}`,
3520
+ });
3521
+ const labelEvidence = (relation, rect) => ({
3522
+ label: relation.label,
3523
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
3524
+ });
3525
+ return {
3526
+ conflictType,
3527
+ candidateEdge: edgeEvidence(edge),
3528
+ otherEdge: edgeEvidence(item.edge),
3529
+ ...(candidateLabel ? { candidateLabel: labelEvidence(edge, candidateLabel) } : {}),
3530
+ ...(item.kind === 'label' ? { otherLabel: labelEvidence(item.edge, item.rect) } : {}),
3531
+ ...(segmentIndex !== undefined ? {
3532
+ segment: {
3533
+ edge: conflictType === 'route-label' ? 'candidate' : 'other',
3534
+ index: segmentIndex, from: [...points[segmentIndex]], to: [...points[segmentIndex + 1]],
3535
+ },
3536
+ measuredClearancePx: clearance, requiredClearancePx: 4,
3537
+ } : {
3538
+ rectangleMarginPx: -2,
3539
+ overlapPx: {
3540
+ x: Math.min(candidateLabel.x + candidateLabel.width, item.rect.x + item.rect.width) - Math.max(candidateLabel.x, item.rect.x),
3541
+ y: Math.min(candidateLabel.y + candidateLabel.height, item.rect.y + item.rect.height) - Math.max(candidateLabel.y, item.rect.y),
3542
+ },
3543
+ }),
3544
+ };
3545
+ }
3546
+
3547
+ function routeClearsPlacedLabels(edge, points, evidence = null) {
3020
3548
  const candidateLabel = candidateLabelRect(edge, points);
3021
- for (const [otherEdge, routed] of pathCache) {
3022
- const otherIndex = workflow.edges.indexOf(otherEdge);
3023
- const otherLabel = labelRectFor(otherEdge, otherIndex);
3024
- if (candidateLabel && otherLabel && rectsOverlap(candidateLabel, otherLabel, -2)) return false;
3025
- if (candidateLabel) {
3026
- for (let index = 0; index < routed.points.length - 1; index += 1) {
3027
- const clearance = segmentRectClearance({
3028
- start: routed.points[index],
3029
- end: routed.points[index + 1],
3030
- }, candidateLabel);
3031
- if (clearance != null && clearance + 0.0001 < 4) return false;
3549
+ const candidateExtent = routeBounds(points);
3550
+ const queryBox = {
3551
+ minX: candidateExtent.minX - 4, minY: candidateExtent.minY - 4,
3552
+ maxX: candidateExtent.maxX + 4, maxY: candidateExtent.maxY + 4,
3553
+ };
3554
+ if (candidateLabel) {
3555
+ queryBox.minX = Math.min(queryBox.minX, candidateLabel.x - 4);
3556
+ queryBox.minY = Math.min(queryBox.minY, candidateLabel.y - 4);
3557
+ queryBox.maxX = Math.max(queryBox.maxX, candidateLabel.x + candidateLabel.width + 4);
3558
+ queryBox.maxY = Math.max(queryBox.maxY, candidateLabel.y + candidateLabel.height + 4);
3559
+ }
3560
+ for (const item of obstacleGrid.query(queryBox)) {
3561
+ // A label may sit far from the route it annotates, so both boxes are queried.
3562
+ if (item.kind === 'label') {
3563
+ if (candidateLabel && rectsOverlap(candidateLabel, item.rect, -2)) {
3564
+ if (evidence) Object.assign(evidence, placedLabelConflictEvidence(edge, candidateLabel, item, 'label-label'));
3565
+ return false;
3032
3566
  }
3033
- }
3034
- if (otherLabel) {
3567
+ const rect = item.rect;
3568
+ // Either axis further than the minimum clearance means no segment can reach it.
3569
+ if (Math.max(rect.x - candidateExtent.maxX, candidateExtent.minX - (rect.x + rect.width)) >= 4
3570
+ || Math.max(rect.y - candidateExtent.maxY, candidateExtent.minY - (rect.y + rect.height)) >= 4) continue;
3035
3571
  for (let index = 0; index < points.length - 1; index += 1) {
3036
3572
  const clearance = segmentRectClearance({
3037
3573
  start: points[index],
3038
3574
  end: points[index + 1],
3039
- }, otherLabel);
3040
- if (clearance != null && clearance + 0.0001 < 4) return false;
3575
+ }, rect);
3576
+ if (clearance != null && clearance + 0.0001 < 4) {
3577
+ if (evidence) Object.assign(evidence, placedLabelConflictEvidence(edge, candidateLabel, item, 'route-label', points, index, clearance));
3578
+ return false;
3579
+ }
3580
+ }
3581
+ } else if (item.kind === 'route' && candidateLabel) {
3582
+ const otherBounds = item.bounds;
3583
+ if (Math.max(candidateLabel.x - otherBounds.maxX, otherBounds.minX - (candidateLabel.x + candidateLabel.width)) >= 4
3584
+ || Math.max(candidateLabel.y - otherBounds.maxY, otherBounds.minY - (candidateLabel.y + candidateLabel.height)) >= 4) continue;
3585
+ const otherPoints = item.routed.points;
3586
+ for (let index = 0; index < otherPoints.length - 1; index += 1) {
3587
+ const clearance = segmentRectClearance({
3588
+ start: otherPoints[index],
3589
+ end: otherPoints[index + 1],
3590
+ }, candidateLabel);
3591
+ if (clearance != null && clearance + 0.0001 < 4) {
3592
+ if (evidence) Object.assign(evidence, placedLabelConflictEvidence(edge, candidateLabel, item, 'label-route', otherPoints, index, clearance));
3593
+ return false;
3594
+ }
3041
3595
  }
3042
3596
  }
3043
3597
  }
@@ -3057,23 +3611,59 @@ function routeClearsLegend(edge, points) {
3057
3611
  return true;
3058
3612
  }
3059
3613
 
3614
+ // Scene label obstacles are a pure function of the document and the layout.
3615
+ let cachedSceneLabelObstacles = null;
3616
+ function sceneLabelObstacles() {
3617
+ if (!cachedSceneLabelObstacles) {
3618
+ cachedSceneLabelObstacles = workflowSceneLabelObstacles();
3619
+ for (const obstacle of cachedSceneLabelObstacles) {
3620
+ obstacleGrid.insert(rectToBounds(obstacle), { kind: 'scene', obstacle });
3621
+ }
3622
+ }
3623
+ return cachedSceneLabelObstacles;
3624
+ }
3625
+
3060
3626
  function routeClearsSceneLabelObstacles(edge, points) {
3627
+ // Asking for the cached list also publishes it to the grid.
3628
+ sceneLabelObstacles();
3061
3629
  const label = candidateLabelRect(edge, points);
3062
- for (const obstacle of workflowSceneLabelObstacles()) {
3630
+ const extent = routeBounds(points);
3631
+ const queryBox = {
3632
+ minX: extent.minX, minY: extent.minY, maxX: extent.maxX, maxY: extent.maxY,
3633
+ };
3634
+ if (label) {
3635
+ queryBox.minX = Math.min(queryBox.minX, label.x);
3636
+ queryBox.minY = Math.min(queryBox.minY, label.y);
3637
+ queryBox.maxX = Math.max(queryBox.maxX, label.x + label.width);
3638
+ queryBox.maxY = Math.max(queryBox.maxY, label.y + label.height);
3639
+ }
3640
+ for (const item of obstacleGrid.query(queryBox)) {
3641
+ if (item.kind !== 'scene') continue;
3063
3642
  for (let index = 0; index < points.length - 1; index += 1) {
3064
- if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, obstacle)) {
3643
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, item.obstacle)) {
3065
3644
  return false;
3066
3645
  }
3067
3646
  }
3068
- if (label && rectsOverlap(label, obstacle)) return false;
3647
+ if (label && rectsOverlap(label, item.obstacle)) return false;
3069
3648
  }
3070
3649
  return true;
3071
3650
  }
3072
3651
 
3652
+ // Composition frames are pure as well; only a frame that reaches the candidate
3653
+ // can share a border line with it.
3654
+ let cachedCompositionFrames = null;
3655
+ function compositionFrames() {
3656
+ if (!cachedCompositionFrames) cachedCompositionFrames = workflowCompositionFrames();
3657
+ return cachedCompositionFrames;
3658
+ }
3659
+
3073
3660
  function routeClearsFrameBorders(points) {
3661
+ const extent = routeBounds(points);
3662
+ const nearFrames = compositionFrames().filter((frame) => boundsOverlap(rectToBounds(frame), extent, 0));
3663
+ if (!nearFrames.length) return true;
3074
3664
  return collectBorderRuns({
3075
3665
  routedRelations: [{ points }],
3076
- frames: workflowCompositionFrames(),
3666
+ frames: nearFrames,
3077
3667
  }).length === 0;
3078
3668
  }
3079
3669
 
@@ -3092,19 +3682,69 @@ function routeFitsCanvasOrigin(edge, points) {
3092
3682
  return routeExtentCoordinates(edge, points).every(([x, y]) => x >= 0 && y >= 0);
3093
3683
  }
3094
3684
 
3685
+ // One ordered contract for acceptance and the first rejection. Cheap and
3686
+ // selective checks run before expensive clearance work. Direct calls need no
3687
+ // dispatch table, callbacks or context objects, and never collect evidence.
3688
+ function readableCandidateRejection(edge, points, from, to, fromSide, toSide) {
3689
+ if (points.length < 2) return 'route endpoints';
3690
+ if (!orthogonalRoute(points)) return 'orthogonal non-zero segments';
3691
+ if (!routeMeetsHardRhythm(points)) return 'readable segment rhythm';
3692
+ if (!routeHonorsEndpointSides(points, fromSide, toSide)) return 'perpendicular endpoint-side direction';
3693
+ if (!routeClearsEndpointNodes(points, from, to)) return 'node clearance';
3694
+ if (!routeLabelClearsNodes(edge, points)) return 'edge-label node clearance';
3695
+ if (!routeClearsSceneLabelObstacles(edge, points)) return 'lane/phase/group label clearance';
3696
+ if (!routeClearsUnrelatedNodes(edge, points)) return 'node clearance';
3697
+ if (!routeClearsPlacedLabels(edge, points)) return 'placed edge-label clearance';
3698
+ if (!routeFitsCanvasOrigin(edge, points)) return 'canvas origin';
3699
+ if (!routeClearsFrameBorders(points)) return 'structural-frame border clearance';
3700
+ if (!routeClearsLegend(edge, points)) return 'legend clearance';
3701
+ return null;
3702
+ }
3703
+
3095
3704
  function readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide) {
3096
- return points.length >= 2
3097
- && orthogonalRoute(points)
3098
- && routeHonorsEndpointSides(points, fromSide, toSide)
3099
- && routeMeetsHardRhythm(points)
3100
- && routeClearsEndpointNodes(points, from, to)
3101
- && routeClearsUnrelatedNodes(edge, points)
3102
- && routeLabelClearsNodes(edge, points)
3103
- && routeClearsPlacedLabels(edge, points)
3104
- && routeClearsLegend(edge, points)
3105
- && routeClearsSceneLabelObstacles(edge, points)
3106
- && routeClearsFrameBorders(points)
3107
- && routeFitsCanvasOrigin(edge, points);
3705
+ return readableCandidateRejection(edge, points, from, to, fromSide, toSide) === null;
3706
+ }
3707
+
3708
+ // Inspect only a rejected preset. Keep successful routing and automatic
3709
+ // candidate enumeration on the short-circuit feasibility path above.
3710
+ function readablePresetRejection(edge, points, from, to, fromSide, toSide) {
3711
+ const invariant = readableCandidateRejection(edge, points, from, to, fromSide, toSide)
3712
+ ?? (!routeMatchesPresetFamily(edge.route, points, from, to) ? 'route preset compatibility' : undefined);
3713
+ if (invariant === 'edge-label node clearance') {
3714
+ // A label move cannot repair a preset whose stroke also crosses a node.
3715
+ // Keep the fast feasibility order, but report the obstructed route first.
3716
+ const collision = firstRouteNodeCollision(edge, points);
3717
+ if (collision) return { invariant: 'node clearance', ...collision };
3718
+ }
3719
+ if (invariant === 'placed edge-label clearance') {
3720
+ const evidence = {};
3721
+ routeClearsPlacedLabels(edge, points, evidence);
3722
+ return { invariant, ...evidence };
3723
+ }
3724
+ if (invariant === 'node clearance') {
3725
+ return { invariant, ...firstRouteNodeCollision(edge, points) };
3726
+ }
3727
+ if (invariant === 'readable segment rhythm') {
3728
+ const segmentIndex = points.slice(0, -1).findIndex((point, index) => {
3729
+ const minimum = hardRouteSegmentMinimum(points, index);
3730
+ return Math.hypot(points[index + 1][0] - point[0], points[index + 1][1] - point[1]) + 0.0001 < minimum;
3731
+ });
3732
+ return {
3733
+ invariant,
3734
+ segmentIndex,
3735
+ actualSegmentPx: Math.hypot(
3736
+ points[segmentIndex + 1][0] - points[segmentIndex][0],
3737
+ points[segmentIndex + 1][1] - points[segmentIndex][1],
3738
+ ),
3739
+ requiredSegmentPx: hardRouteSegmentMinimum(points, segmentIndex),
3740
+ };
3741
+ }
3742
+ if (invariant === 'edge-label node clearance') {
3743
+ const labelRect = candidateLabelRect(edge, points);
3744
+ const obstacle = [...nodes.values()].find((node) => rectsOverlap(labelRect, node, -2));
3745
+ return { invariant, labelRect, obstacleNode: obstacle?.id };
3746
+ }
3747
+ return { invariant };
3108
3748
  }
3109
3749
 
3110
3750
  function corridorViaY(start, end, fromSide, toSide, y) {
@@ -3119,19 +3759,6 @@ function corridorViaX(start, end, fromSide, toSide, x) {
3119
3759
  return [startStub, [x, startStub[1]], [x, endStub[1]], endStub];
3120
3760
  }
3121
3761
 
3122
- function axisOverlapLength(a, b, c, d) {
3123
- const horizontal = Math.abs(a[1] - b[1]) <= 0.0001
3124
- && Math.abs(c[1] - d[1]) <= 0.0001
3125
- && Math.abs(a[1] - c[1]) <= 0.0001;
3126
- const vertical = Math.abs(a[0] - b[0]) <= 0.0001
3127
- && Math.abs(c[0] - d[0]) <= 0.0001
3128
- && Math.abs(a[0] - c[0]) <= 0.0001;
3129
- if (!horizontal && !vertical) return 0;
3130
- const axis = horizontal ? 0 : 1;
3131
- return Math.max(0, Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]))
3132
- - Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis])));
3133
- }
3134
-
3135
3762
  function properAxisCrossing(a, b, c, d) {
3136
3763
  const firstHorizontal = Math.abs(a[1] - b[1]) <= 0.0001;
3137
3764
  const secondHorizontal = Math.abs(c[1] - d[1]) <= 0.0001;
@@ -3146,23 +3773,86 @@ function properAxisCrossing(a, b, c, d) {
3146
3773
  && y < Math.max(vertical[0][1], vertical[1][1]) - 0.0001;
3147
3774
  }
3148
3775
 
3776
+ function isReadableAutomaticRoute(edge) {
3777
+ return workflow.schema_version === 2
3778
+ && !Array.isArray(edge.via)
3779
+ && edge.channelX === undefined
3780
+ && edge.channelY === undefined
3781
+ && (!edge.route || edge.route === 'auto');
3782
+ }
3783
+
3784
+ function independentAutomaticRoute(edge) {
3785
+ return workflow.schema_version === 2
3786
+ && !edge.via && edge.channelX === undefined && edge.channelY === undefined
3787
+ && !edge.labelAt
3788
+ && (!edge.route || edge.route === 'auto')
3789
+ && (!edge.fromSide || edge.fromSide === 'auto')
3790
+ && (!edge.toSide || edge.toSide === 'auto');
3791
+ }
3792
+
3149
3793
  function routeInteractionMetrics(edge, points) {
3150
3794
  let properCrossingCount = 0;
3795
+ let unrelatedBendContactCount = 0;
3151
3796
  let sharedCorridorPx = 0;
3152
- for (const [otherEdge, routed] of pathCache) {
3153
- if ([edge.from, edge.to].some((id) => id === otherEdge.from || id === otherEdge.to)) continue;
3797
+ let oppositeParallelDeficit = 0;
3798
+ const extent = routeBounds(points);
3799
+ const nearbyRoutes = obstacleGrid.query({
3800
+ minX: extent.minX - 8, minY: extent.minY - 8,
3801
+ maxX: extent.maxX + 8, maxY: extent.maxY + 8,
3802
+ })
3803
+ .filter((item) => item.kind === 'route')
3804
+ .sort((left, right) => left.sequence - right.sequence);
3805
+ for (const item of nearbyRoutes) {
3806
+ const otherEdge = item.edge;
3807
+ const routed = item.routed;
3808
+ const sharedEndpoint = [edge.from, edge.to].some((id) => id === otherEdge.from || id === otherEdge.to);
3809
+ if (sharedEndpoint && workflow.schema_version !== 2) continue;
3810
+ if (!sharedEndpoint && isReadableAutomaticRoute(edge)) {
3811
+ unrelatedBendContactCount += polylineBendContacts(points, routed.points).length;
3812
+ }
3813
+ if (isReadableAutomaticRoute(edge)) {
3814
+ oppositeParallelDeficit += longCounterflowDeficit(points, routed.points);
3815
+ }
3816
+ sharedCorridorPx += collectAmbiguousCorridors({
3817
+ routedRelations: [{ relation: edge, points }, { relation: otherEdge, points: routed.points }],
3818
+ includeSharedEndpoints: () => workflow.schema_version === 2,
3819
+ allowShortWorkflowTrunks: workflow.schema_version === 2,
3820
+ }).reduce((total, hit) => total + hit.overlapLength, 0);
3154
3821
  for (let left = 0; left < points.length - 1; left += 1) {
3155
3822
  for (let right = 0; right < routed.points.length - 1; right += 1) {
3156
3823
  if (properAxisCrossing(points[left], points[left + 1], routed.points[right], routed.points[right + 1])) {
3157
3824
  properCrossingCount += 1;
3158
3825
  }
3159
- sharedCorridorPx += axisOverlapLength(
3160
- points[left], points[left + 1], routed.points[right], routed.points[right + 1],
3161
- );
3162
3826
  }
3163
3827
  }
3164
3828
  }
3165
- return { properCrossingCount, sharedCorridorPx };
3829
+ return { properCrossingCount, unrelatedBendContactCount, sharedCorridorPx, oppositeParallelDeficit };
3830
+ }
3831
+
3832
+ // Prefer a clear existing track for long, close counterflow. This is only a
3833
+ // candidate preference: same-direction trunks and short endpoint runs stay
3834
+ // under the existing route contracts, and authored geometry is never edited.
3835
+ function longCounterflowDeficit(points, otherPoints) {
3836
+ const left = normalizeRoutePoints(points);
3837
+ const right = normalizeRoutePoints(otherPoints);
3838
+ let worst = 0;
3839
+ for (let a = 1; a < left.length; a += 1) {
3840
+ for (let b = 1; b < right.length; b += 1) {
3841
+ const horizontal = Math.abs(left[a][1] - left[a - 1][1]) <= 0.0001
3842
+ && Math.abs(right[b][1] - right[b - 1][1]) <= 0.0001;
3843
+ const vertical = Math.abs(left[a][0] - left[a - 1][0]) <= 0.0001
3844
+ && Math.abs(right[b][0] - right[b - 1][0]) <= 0.0001;
3845
+ if (!horizontal && !vertical) continue;
3846
+ const axis = horizontal ? 0 : 1;
3847
+ if ((left[a][axis] - left[a - 1][axis]) * (right[b][axis] - right[b - 1][axis]) >= 0) continue;
3848
+ const gap = Math.abs(left[a][1 - axis] - right[b][1 - axis]);
3849
+ if (gap <= 0.0001 || gap >= 8) continue;
3850
+ const overlap = Math.min(Math.max(left[a][axis], left[a - 1][axis]), Math.max(right[b][axis], right[b - 1][axis]))
3851
+ - Math.max(Math.min(left[a][axis], left[a - 1][axis]), Math.min(right[b][axis], right[b - 1][axis]));
3852
+ worst = Math.max(worst, Math.max(0, overlap - 24) * (8 - gap));
3853
+ }
3854
+ }
3855
+ return worst;
3166
3856
  }
3167
3857
 
3168
3858
  function automaticForwardReversePx(edge, points) {
@@ -3174,6 +3864,47 @@ function automaticForwardReversePx(edge, points) {
3174
3864
  ), 0);
3175
3865
  }
3176
3866
 
3867
+ // Reserve only a measured, feasible horizontal arrival, not a future whole
3868
+ // route. This preference applies to automatic same-column upward rails only.
3869
+ const laterArrivalCache = new Map();
3870
+ function laterArrivalBlockCount(edge, points) {
3871
+ if (!independentAutomaticRoute(edge)
3872
+ || ['labelDx', 'labelDy', 'labelSegment'].some(field => edge[field] !== undefined)) return 0;
3873
+ const from = nodes.get(edge.from), to = nodes.get(edge.to);
3874
+ if (from.col !== to.col || to.cy >= from.cy) return 0;
3875
+ let cached = laterArrivalCache.get(edge);
3876
+ if (!cached || cached.routeCount !== pathCache.size) {
3877
+ const arrivals = [];
3878
+ const eligible = workflow.edges.filter(incoming => {
3879
+ if (incoming === edge || pathCache.has(incoming) || !independentAutomaticRoute(incoming)
3880
+ || ['labelDx', 'labelDy', 'labelSegment'].some(field => incoming[field] !== undefined)) return false;
3881
+ const source = nodes.get(incoming.from), target = nodes.get(incoming.to);
3882
+ return source && target && target.col === from.col && source.col !== target.col
3883
+ && target.cy > to.cy && target.cy < from.cy;
3884
+ });
3885
+ // Skip crowded scenes entirely: no order-dependent first-eight choice,
3886
+ // and at most eight prospective feasibility probes for this rail/scene.
3887
+ for (const incoming of eligible.length <= 8 ? eligible : []) {
3888
+ const source = nodes.get(incoming.from), target = nodes.get(incoming.to);
3889
+ const fromSide = source.cx < target.cx ? 'right' : 'left';
3890
+ const toSide = source.cx < target.cx ? 'left' : 'right';
3891
+ const start = anchor(source, fromSide), end = anchor(target, toSide);
3892
+ const prospective = joinRoutePoints(start,
3893
+ corridorViaX(start, end, fromSide, toSide, (start[0] + end[0]) / 2), end);
3894
+ if (!readableCandidateIsFeasible(incoming, prospective, source, target, fromSide, toSide)) continue;
3895
+ const interaction = routeInteractionMetrics(incoming, prospective);
3896
+ if (interaction.properCrossingCount || interaction.unrelatedBendContactCount || interaction.sharedCorridorPx
3897
+ || labelRouteClearanceDeficit(incoming, prospective)) continue;
3898
+ arrivals.push([outwardStub(end, toSide), end]);
3899
+ }
3900
+ cached = { routeCount: pathCache.size, arrivals };
3901
+ laterArrivalCache.set(edge, cached);
3902
+ }
3903
+ return cached.arrivals.filter(([a, b]) => points.slice(1, -2).some((start, index) => (
3904
+ properAxisCrossing(start, points[index + 2], a, b)
3905
+ ))).length;
3906
+ }
3907
+
3177
3908
  function readableCandidateCost(
3178
3909
  edge,
3179
3910
  points,
@@ -3189,12 +3920,20 @@ function readableCandidateCost(
3189
3920
  const directLength = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
3190
3921
  const interiorPreferred28Deficit = segmentLengths.slice(1, -1)
3191
3922
  .reduce((total, length) => total + Math.max(0, 28 - length), 0);
3192
- const xs = points.map(([x]) => x);
3193
- const ys = points.map(([, y]) => y);
3194
- const canvasGrowthPx = Math.max(0, -Math.min(...xs))
3195
- + Math.max(0, Math.max(...xs) - minimumCanvasWidth)
3196
- + Math.max(0, -Math.min(...ys))
3197
- + Math.max(0, Math.max(...ys) - autoHeight);
3923
+ let minX = Infinity;
3924
+ let maxX = -Infinity;
3925
+ let minY = Infinity;
3926
+ let maxY = -Infinity;
3927
+ for (const [x, y] of points) {
3928
+ minX = Math.min(minX, x);
3929
+ maxX = Math.max(maxX, x);
3930
+ minY = Math.min(minY, y);
3931
+ maxY = Math.max(maxY, y);
3932
+ }
3933
+ const canvasGrowthPx = Math.max(0, -minX)
3934
+ + Math.max(0, maxX - minimumCanvasWidth)
3935
+ + Math.max(0, -minY)
3936
+ + Math.max(0, maxY - autoHeight);
3198
3937
  const from = nodes.get(edge.from);
3199
3938
  const to = nodes.get(edge.to);
3200
3939
  const naturalStart = anchor(from, naturalFromSide);
@@ -3208,9 +3947,16 @@ function readableCandidateCost(
3208
3947
  return {
3209
3948
  automaticForwardReversePx: automaticForwardReversePx(edge, points),
3210
3949
  properCrossingCount: interaction.properCrossingCount,
3950
+ unrelatedBendContactCount: interaction.unrelatedBendContactCount,
3211
3951
  sharedCorridorPx: interaction.sharedCorridorPx,
3952
+ laterArrivalBlockCount: laterArrivalBlockCount(edge, points),
3212
3953
  labelRouteClearanceDeficit: labelRouteClearanceDeficit(edge, points),
3954
+ oppositeParallelDeficit: interaction.oppositeParallelDeficit,
3213
3955
  interiorPreferred28Deficit,
3956
+ // Keep bend count first for ordinary routes; avoid a many-fold outer trip
3957
+ // solely to save a corner when the endpoints share a column.
3958
+ excessiveVerticalTravelPx: independentAutomaticRoute(edge) && from.col === to.col && from.cy !== to.cy
3959
+ ? Math.max(0, routeLength - 2 * Math.abs(to.cy - from.cy)) : 0,
3214
3960
  bendCount: Math.max(0, points.length - 2),
3215
3961
  stretchMilli: Math.round((directLength > 0 ? routeLength / directLength : 1) * 1000),
3216
3962
  canvasGrowthPx,
@@ -3264,11 +4010,20 @@ function readableAutomaticCandidateSet(
3264
4010
  { family: 'outside-right', via: corridorViaX(start, end, fromSide, toSide, outsideRight) },
3265
4011
  { family: 'top-corridor', via: corridorViaY(start, end, fromSide, toSide, topY) },
3266
4012
  { family: 'bottom-corridor', via: corridorViaY(start, end, fromSide, toSide, bottomY) },
3267
- ];
4013
+ // Offset tracks let several routes through one gap run side by side.
4014
+ ...[-12, 12, -24, 24].flatMap((offset) => [
4015
+ { family: 'lane-gap-corridor', via: corridorViaY(start, end, fromSide, toSide, laneGapY + offset) },
4016
+ { family: 'column-gap-corridor', via: corridorViaX(start, end, fromSide, toSide, midX + offset) },
4017
+ ]),
4018
+ ].map((candidate) => ({
4019
+ ...candidate,
4020
+ // Failed-route diagnostics also inspect rejected candidates. Keep their
4021
+ // complete geometry before filtering for feasible routes.
4022
+ points: joinRoutePoints(start, candidate.via, end),
4023
+ }));
3268
4024
  const candidates = rawCandidates.map((candidate, ordinal) => ({
3269
4025
  ...candidate,
3270
4026
  ordinal: ordinalOffset + ordinal,
3271
- points: normalizeRoutePoints([start, ...candidate.via, end]),
3272
4027
  })).filter(({ points }) => (
3273
4028
  readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)
3274
4029
  )).map((candidate) => ({
@@ -3301,58 +4056,44 @@ function readableAutomaticVia(edge, from, to, start, end, fromSide, toSide) {
3301
4056
  const currentPoints = normalizeRoutePoints([start, ...outsideRightCandidate.via, end]);
3302
4057
  const labelRect = candidateLabelRect(edge, currentPoints);
3303
4058
  let outsideRightMinX = outsideRight;
3304
- for (const node of nodes.values()) {
3305
- if (!labelRect || !rectsOverlap(labelRect, node, -2)) continue;
3306
- const rightwardLabelDeficit = node.x + node.width - 2 - labelRect.x;
3307
- if (rightwardLabelDeficit > 0) {
3308
- outsideRightMinX = Math.max(
3309
- outsideRightMinX,
3310
- outsideRight + rightwardLabelDeficit * 2,
3311
- );
3312
- }
3313
- }
3314
- for (const [otherEdge, routed] of pathCache) {
3315
- const otherIndex = workflow.edges.indexOf(otherEdge);
3316
- const otherLabel = labelRectFor(otherEdge, otherIndex);
3317
- if (labelRect && otherLabel && rectsOverlap(labelRect, otherLabel, -2)) {
3318
- const rightwardLabelDeficit = otherLabel.x + otherLabel.width - 2 - labelRect.x;
3319
- if (rightwardLabelDeficit > 0) {
3320
- outsideRightMinX = Math.max(
3321
- outsideRightMinX,
3322
- outsideRight + rightwardLabelDeficit * 2,
3323
- );
3324
- }
3325
- }
3326
- if (!labelRect) continue;
3327
- for (let index = 0; index < routed.points.length - 1; index += 1) {
3328
- const segment = {
3329
- start: routed.points[index],
3330
- end: routed.points[index + 1],
3331
- };
3332
- const clearance = segmentRectClearance(segment, labelRect);
3333
- if (clearance == null || clearance + 0.0001 >= 4) continue;
3334
- const rightwardLabelDeficit = Math.max(segment.start[0], segment.end[0])
3335
- + 4 - labelRect.x;
3336
- if (rightwardLabelDeficit > 0) {
3337
- outsideRightMinX = Math.max(
3338
- outsideRightMinX,
3339
- outsideRight + rightwardLabelDeficit * 2,
3340
- );
4059
+ if (labelRect) {
4060
+ // Only obstacles whose box can reach the label rect can push the corridor.
4061
+ const labelQuery = {
4062
+ minX: labelRect.x - 4, minY: labelRect.y - 4,
4063
+ maxX: labelRect.x + labelRect.width + 4, maxY: labelRect.y + labelRect.height + 4,
4064
+ };
4065
+ for (const item of obstacleGrid.query(labelQuery)) {
4066
+ if (item.kind === 'node') {
4067
+ if (!rectsOverlap(labelRect, item.node, -2)) continue;
4068
+ const rightwardLabelDeficit = item.node.x + item.node.width - 2 - labelRect.x;
4069
+ if (rightwardLabelDeficit > 0) {
4070
+ outsideRightMinX = Math.max(outsideRightMinX, outsideRight + rightwardLabelDeficit * 2);
4071
+ }
4072
+ } else if (item.kind === 'label') {
4073
+ if (!rectsOverlap(labelRect, item.rect, -2)) continue;
4074
+ const rightwardLabelDeficit = item.rect.x + item.rect.width - 2 - labelRect.x;
4075
+ if (rightwardLabelDeficit > 0) {
4076
+ outsideRightMinX = Math.max(outsideRightMinX, outsideRight + rightwardLabelDeficit * 2);
4077
+ }
4078
+ } else if (item.kind === 'route') {
4079
+ const otherPoints = item.routed.points;
4080
+ for (let index = 0; index < otherPoints.length - 1; index += 1) {
4081
+ const clearance = segmentRectClearance({
4082
+ start: otherPoints[index],
4083
+ end: otherPoints[index + 1],
4084
+ }, labelRect);
4085
+ if (clearance == null || clearance + 0.0001 >= 4) continue;
4086
+ const rightwardLabelDeficit = Math.max(otherPoints[index][0], otherPoints[index + 1][0])
4087
+ + 4 - labelRect.x;
4088
+ if (rightwardLabelDeficit > 0) {
4089
+ outsideRightMinX = Math.max(outsideRightMinX, outsideRight + rightwardLabelDeficit * 2);
4090
+ }
4091
+ }
3341
4092
  }
3342
4093
  }
3343
4094
  }
3344
4095
  outsideRightMinX = Math.ceil(outsideRightMinX * 1000) / 1000;
3345
- let rightmostPlacedX = outsideRight;
3346
- for (const node of nodes.values()) {
3347
- rightmostPlacedX = Math.max(rightmostPlacedX, node.x + node.width);
3348
- }
3349
- for (const [otherEdge, routed] of pathCache) {
3350
- for (const [x] of routed.points) rightmostPlacedX = Math.max(rightmostPlacedX, x);
3351
- const otherLabel = labelRectFor(otherEdge, workflow.edges.indexOf(otherEdge));
3352
- if (otherLabel) {
3353
- rightmostPlacedX = Math.max(rightmostPlacedX, otherLabel.x + otherLabel.width);
3354
- }
3355
- }
4096
+ const rightmostPlacedX = Math.max(outsideRight, rightmostNodeEdge, rightmostRoutedEdge);
3356
4097
  let probeGrowth = Math.max(
3357
4098
  32,
3358
4099
  labelRect?.width ?? 0,
@@ -3496,15 +4237,33 @@ function readablePresetVia(edge, from, to, start, end, fromSide, toSide) {
3496
4237
  default:
3497
4238
  return readableAutomaticVia(edge, from, to, start, end, fromSide, toSide);
3498
4239
  }
3499
- const points = normalizeRoutePoints([start, ...via, end]);
4240
+ const points = joinRoutePoints(start, via, end);
3500
4241
  if (readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)
3501
4242
  && routeMatchesPresetFamily(preset, points, from, to)) {
3502
4243
  return points.slice(1, -1);
3503
4244
  }
3504
- const message = `Workflow edge "${workflowEdgeName(edge)}" cannot satisfy route preset "${preset}" under readable-v2 constraints (minimum 8px endpoint stubs, 16px interior turns, and 28px direct clearance).`;
4245
+ const rejection = readablePresetRejection(edge, points, from, to, fromSide, toSide);
4246
+ const obstacle = rejection.obstacleNode
4247
+ ? `: ${rejection.segmentIndex === undefined ? 'its label' : `segment ${rejection.segmentIndex}`} intersects node "${rejection.obstacleNode}"`
4248
+ : '';
4249
+ const message = `Workflow edge "${workflowEdgeName(edge)}" cannot satisfy route preset "${preset}": candidate violates ${rejection.invariant}${obstacle}.`;
3505
4250
  const edgeIndex = workflow.edges.indexOf(edge);
3506
4251
  const edgeName = workflowEdgeName(edge);
3507
4252
  const supportedFixes = [];
4253
+ if (discoverFixes && rejection.invariant === 'edge-label node clearance' && edge.label
4254
+ && ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].every((field) => edge[field] === undefined)) {
4255
+ for (let segment = 0; segment < Math.min(points.length - 1, 3); segment += 1) {
4256
+ const fix = verifiedEdgeFix(
4257
+ edge,
4258
+ `set labelSegment on edge "${edgeName}" to ${segment}`,
4259
+ (candidate) => { candidate.labelSegment = segment; },
4260
+ );
4261
+ if (fix) {
4262
+ supportedFixes.push(fix);
4263
+ break;
4264
+ }
4265
+ }
4266
+ }
3508
4267
  for (const candidatePreset of ['straight', 'drop', 'outside-right', 'return-left', 'bottom-channel', 'up-channel']) {
3509
4268
  if (candidatePreset === preset) continue;
3510
4269
  if (acceptsFix((document) => {
@@ -3530,13 +4289,14 @@ function readablePresetVia(edge, from, to, start, end, fromSide, toSide) {
3530
4289
  route: preset,
3531
4290
  },
3532
4291
  evidence: {
4292
+ ...rejection,
3533
4293
  attemptedCandidateFamily: preset,
3534
4294
  points,
3535
4295
  fromSide,
3536
4296
  toSide,
3537
- requiredEndpointStubPx: 8,
3538
- requiredInteriorSegmentPx: 16,
3539
- requiredDirectClearancePx: 28,
4297
+ requiredEndpointStubPx: HARD_ROUTE_RHYTHM.endpoint,
4298
+ requiredInteriorSegmentPx: HARD_ROUTE_RHYTHM.interior,
4299
+ requiredDirectClearancePx: HARD_ROUTE_RHYTHM.direct,
3540
4300
  },
3541
4301
  supportedFixes,
3542
4302
  }]);
@@ -3617,6 +4377,26 @@ function workflowEdgeLabelPoint(edge, points) {
3617
4377
  if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
3618
4378
  return point;
3619
4379
  }
4380
+ if (!edge.labelAt && !Number.isInteger(edge.labelSegment)
4381
+ && edge.labelDx === undefined && edge.labelDy === undefined
4382
+ && points.length === 2 && points[0][0] === points[1][0]) {
4383
+ // Center implicit v2 vertical labels in the corridor. Authored offsets
4384
+ // retain their existing source-relative anchor, including explicit zero.
4385
+ const x = points[0][0];
4386
+ let labelSide = 0;
4387
+ if (layoutFeedback.fanInLabelClearance && !preservesWorkflowAbsoluteGeometry(workflow)) {
4388
+ // On a failed-route retry, leave the shared arrival side open. The
4389
+ // label stays beside its own straight edge, with an 8px mask gap;
4390
+ // normal node, route, label and frame checks still decide feasibility.
4391
+ const incoming = workflow.edges.filter(other => other !== edge && other.to === edge.to)
4392
+ .map(other => nodes.get(other.from)?.cx)
4393
+ .filter(value => Number.isFinite(value) && Math.abs(value - x) > 4);
4394
+ if (incoming.length && incoming.every(value => value < x)) labelSide = 1;
4395
+ else if (incoming.length && incoming.every(value => value > x)) labelSide = -1;
4396
+ }
4397
+ return [x + labelSide * (workflowLabelWidth(edge.label) / 2 + 8),
4398
+ (points[0][1] + points[1][1]) / 2];
4399
+ }
3620
4400
  if (edge.labelAt || Number.isInteger(edge.labelSegment) || points.length <= 2) {
3621
4401
  return labelPoint(edge, points);
3622
4402
  }
@@ -3632,10 +4412,36 @@ function workflowEdgeLabelPoint(edge, points) {
3632
4412
  || right.length - left.length
3633
4413
  || left.index - right.index
3634
4414
  ));
3635
- const labelSegment = segments[0]?.index ?? 0;
3636
- const point = labelPoint({ ...edge, labelSegment }, points);
3637
- if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
3638
- return point;
4415
+ const pointOn = (labelSegment) => {
4416
+ const point = labelPoint({ ...edge, labelSegment }, points);
4417
+ if (points[labelSegment][0] === points[labelSegment + 1][0]) point[1] += 10;
4418
+ return point;
4419
+ };
4420
+ // The longest horizontal run is the preferred home, but a run shorter than
4421
+ // its label pushes the label onto a node, and the planner then detours the
4422
+ // whole edge to manufacture a longer run. When the preferred run is that
4423
+ // short, use the next run that keeps the label clear of every node and
4424
+ // inside one lane, off the lane dividers, before giving up on it.
4425
+ const width = workflowLabelWidth(edge.label);
4426
+ const clearOfNodes = ([lx, ly]) => {
4427
+ const rect = { x: lx - width / 2, y: ly - 10, width, height: 14 };
4428
+ return ![...nodes.values()].some((node) => rectsOverlap(rect, node, -2));
4429
+ };
4430
+ const insideOneLane = ([lx, ly]) => lx - width / 2 >= layout.laneX + 4
4431
+ && lx + width / 2 <= layout.laneX + layout.laneW - 4
4432
+ && asArray(workflow.lanes).some((lane, index) => {
4433
+ const top = laneTop(lane.id);
4434
+ return ly - 10 >= top + 4 && ly + 4 <= top + laneHeight(index) - 4;
4435
+ });
4436
+ const preferred = pointOn(segments[0]?.index ?? 0);
4437
+ if (!edge.label || !segments[0]?.horizontal || segments[0].length >= width + 8
4438
+ || clearOfNodes(preferred)) return preferred;
4439
+ for (const segment of segments.slice(1)) {
4440
+ if (segment.length < 28) continue;
4441
+ const point = pointOn(segment.index);
4442
+ if (clearOfNodes(point) && insideOneLane(point)) return point;
4443
+ }
4444
+ return preferred;
3639
4445
  }
3640
4446
 
3641
4447
  function edgeSides(edge) {
@@ -3661,6 +4467,229 @@ const automaticPorts = automaticPortSpread(workflow.edges, nodes, {
3661
4467
  sideFor: (edge, endpoint) => edgeSides(edge)[endpoint === 'source' ? 'fromSide' : 'toSide'],
3662
4468
  });
3663
4469
 
4470
+ function automaticPortCandidates(edge, node, side, preferred, counterpart) {
4471
+ if (!independentAutomaticRoute(edge)) return [preferred];
4472
+ const verticalSide = side === 'left' || side === 'right';
4473
+ const axis = verticalSide ? 1 : 0;
4474
+ const center = anchor(node, side);
4475
+ const occupied = [];
4476
+ for (const [other, routed] of nodeRoutes.get(node.id) ?? []) {
4477
+ for (const endpoint of ['from', 'to']) {
4478
+ if (other[endpoint] !== node.id) continue;
4479
+ const point = endpoint === 'from' ? routed.points[0] : routed.points.at(-1);
4480
+ const ownWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4);
4481
+ const otherWidth = other.width || (other.variant === 'emphasis' ? 1.8 : 1.4);
4482
+ if (Math.abs(point[1 - axis] - center[1 - axis]) < 0.0001) {
4483
+ occupied.push({ coordinate: point[axis], clearance: Math.max(16, 3.5 * (ownWidth + otherWidth)) });
4484
+ }
4485
+ }
4486
+ }
4487
+ const clear = (point) => occupied.every(({ coordinate, clearance }) => Math.abs(coordinate - point[axis]) >= clearance - 0.0001);
4488
+ if (clear(preferred)) return [preferred];
4489
+ const candidates = [];
4490
+ const extent = verticalSide ? node.height : node.width;
4491
+ const direction = (verticalSide ? counterpart.cy : counterpart.cx) >= center[axis] ? 1 : -1;
4492
+ for (let offset = 0; offset <= extent / 2 - 16; offset += 12) {
4493
+ for (const sign of [direction, -direction]) {
4494
+ const point = [...center];
4495
+ point[axis] += sign * offset;
4496
+ if (clear(point) && !candidates.some((candidate) => candidate[axis] === point[axis])) candidates.push(point);
4497
+ }
4498
+ if (candidates.length >= 2) return candidates.slice(0, 2);
4499
+ }
4500
+ return candidates.length ? candidates : [preferred];
4501
+ }
4502
+
4503
+ function automaticEndpointRepairs(edge, selected, from, to, naturalFromSide, naturalToSide, narrowSlots = false) {
4504
+ if (!independentAutomaticRoute(edge)
4505
+ || ['labelDx', 'labelDy', 'labelSegment', 'bias'].some(field => edge[field] !== undefined)
4506
+ || selected.cost.properCrossingCount !== 0
4507
+ || (!selected.cost.sharedCorridorPx && !selected.cost.unrelatedBendContactCount)
4508
+ || selected.points.length < 3) return [];
4509
+ const corridorHits = (points) => [...pathCache].flatMap(([relation, routed]) => (
4510
+ collectAmbiguousCorridors({
4511
+ routedRelations: [{ relation: edge, points }, { relation, points: routed.points }],
4512
+ includeSharedEndpoints: () => true,
4513
+ allowShortWorkflowTrunks: true,
4514
+ })
4515
+ ));
4516
+ const originalHits = corridorHits(selected.points);
4517
+ const originalNeighbours = new Set(originalHits.map(hit => hit.right.relation));
4518
+ const contacts = selected.cost.unrelatedBendContactCount ? [...pathCache].flatMap(([other, routed]) => (
4519
+ [edge.from, edge.to].some(id => id === other.from || id === other.to)
4520
+ ? [] : polylineBendContacts(selected.points, routed.points)
4521
+ )) : [];
4522
+ const strokeWidth = relation => relation.width || (relation.variant === 'emphasis' ? 1.8 : 1.4);
4523
+ const arrowRelation = relation => ({ ...relation, width: strokeWidth(relation) });
4524
+ const ownArrow = arrowRelation(edge);
4525
+ const incoming = [...(nodeRoutes.get(edge.to) ?? [])].map(([relation, routed]) => ({
4526
+ relation: arrowRelation(relation), points: routed.points,
4527
+ }));
4528
+ const repairs = [];
4529
+ for (const endpoint of ['from', 'to']) {
4530
+ const first = endpoint === 'from';
4531
+ const endpointIndex = first ? 0 : selected.points.length - 1;
4532
+ const segmentIndex = first ? 0 : selected.points.length - 2;
4533
+ const corner = selected.points[first ? 1 : selected.points.length - 2];
4534
+ const touchingCorner = contacts.some(point => Math.hypot(point[0] - corner[0], point[1] - corner[1]) <= 0.0001);
4535
+ if (!touchingCorner && !originalHits.some(hit => hit.leftSegment === segmentIndex)) continue;
4536
+ const node = first ? from : to;
4537
+ const side = first ? selected.fromSide : selected.toSide;
4538
+ const axis = side === 'left' || side === 'right' ? 1 : 0;
4539
+ const minimum = (axis ? node.y : node.x) + 16;
4540
+ const maximum = (axis ? node.y + node.height : node.x + node.width) - 16;
4541
+ // Keep the side, its perpendicular stub, and the existing corner gutter.
4542
+ // Small slides can separate source strokes or a source from an arrow even
4543
+ // when the normal 12px port-spread step cannot fit on a 52px node side.
4544
+ const offsets = [-8, 8, -10, 10];
4545
+ if (narrowSlots) {
4546
+ const ownFootprint = (first ? 0.5 : 3.5) * strokeWidth(edge);
4547
+ let slots = [[minimum, maximum]];
4548
+ for (const [relation, routed] of nodeRoutes.get(node.id) ?? []) {
4549
+ for (const otherEndpoint of ['from', 'to']) {
4550
+ if (relation[otherEndpoint] !== node.id) continue;
4551
+ const point = otherEndpoint === 'from' ? routed.points[0] : routed.points.at(-1);
4552
+ if (Math.abs(point[1 - axis] - selected.points[endpointIndex][1 - axis]) > 0.0001) continue;
4553
+ const radius = ownFootprint + (otherEndpoint === 'from' ? 0.5 : 3.5) * strokeWidth(relation);
4554
+ const low = point[axis] - radius, high = point[axis] + radius;
4555
+ slots = slots.flatMap(([start, end]) => high <= start || low >= end
4556
+ ? [[start, end]]
4557
+ : [[start, Math.min(end, low)], [Math.max(start, high), end]].filter(([a, b]) => b > a));
4558
+ }
4559
+ }
4560
+ offsets.push(...slots.map(([start, end]) => (start + end) / 2 - selected.points[endpointIndex][axis])
4561
+ .sort((a, b) => Math.abs(a) - Math.abs(b) || a - b).slice(0, 4));
4562
+ }
4563
+ for (const offset of offsets) {
4564
+ const points = selected.points.map(point => [...point]);
4565
+ points[endpointIndex][axis] += offset;
4566
+ points[first ? 1 : points.length - 2][axis] += offset;
4567
+ if (points[endpointIndex][axis] < minimum || points[endpointIndex][axis] > maximum) continue;
4568
+ const endpointFootprint = (first ? 0.5 : 3.5) * strokeWidth(edge);
4569
+ const clearPort = [...(nodeRoutes.get(node.id) ?? [])].every(([relation, routed]) => (
4570
+ ['from', 'to'].every(otherEndpoint => {
4571
+ if (relation[otherEndpoint] !== node.id) return true;
4572
+ const point = otherEndpoint === 'from' ? routed.points[0] : routed.points.at(-1);
4573
+ if (Math.abs(point[1 - axis] - points[endpointIndex][1 - axis]) > 0.0001) return true;
4574
+ const otherFootprint = (otherEndpoint === 'from' ? 0.5 : 3.5) * strokeWidth(relation);
4575
+ return Math.abs(point[axis] - points[endpointIndex][axis]) + 0.0001 >= endpointFootprint + otherFootprint;
4576
+ })
4577
+ ));
4578
+ if (!clearPort) continue;
4579
+ if (!readableCandidateIsFeasible(edge, points, from, to, selected.fromSide, selected.toSide)) continue;
4580
+ const cost = readableCandidateCost(edge, points, selected.cost.stableCandidateOrdinal, naturalFromSide, naturalToSide);
4581
+ if (cost.properCrossingCount !== 0
4582
+ || cost.unrelatedBendContactCount > selected.cost.unrelatedBendContactCount
4583
+ || cost.sharedCorridorPx > selected.cost.sharedCorridorPx
4584
+ || (cost.sharedCorridorPx === selected.cost.sharedCorridorPx
4585
+ && cost.unrelatedBendContactCount === selected.cost.unrelatedBendContactCount)) continue;
4586
+ if (corridorHits(points).some(hit => !originalNeighbours.has(hit.right.relation))) continue;
4587
+ if (collectArrowheadCollisions({
4588
+ routedRelations: [{ relation: ownArrow, points }, ...incoming],
4589
+ allowShortWorkflowTrunks: true,
4590
+ }).some(hit => hit.left.relation === ownArrow || hit.right.relation === ownArrow)) continue;
4591
+ repairs.push({ ...selected, points, cost });
4592
+ }
4593
+ }
4594
+ return repairs;
4595
+ }
4596
+
4597
+ // The fixed center/offset tracks can miss the open space beside a column.
4598
+ // Only expand a severe vertical detour: try at most 24 nearby node-side tracks,
4599
+ // preserving the existing feasibility, port and interaction checks.
4600
+ function readableInternalDetourPlans(edge, selected, from, to, naturalFromSide, naturalToSide) {
4601
+ if (!independentAutomaticRoute(edge) || from.col !== to.col) return [];
4602
+ const length = selected.points.slice(1).reduce((sum, point, index) => sum
4603
+ + Math.abs(point[0] - selected.points[index][0]) + Math.abs(point[1] - selected.points[index][1]), 0);
4604
+ const direct = Math.abs(to.cx - from.cx) + Math.abs(to.cy - from.cy);
4605
+ if (length <= direct * 2 || length - direct <= 64) return [];
4606
+ const down = to.cy > from.cy;
4607
+ const fromSide = down ? 'bottom' : 'top';
4608
+ const toSide = down ? 'top' : 'bottom';
4609
+ const starts = automaticPortCandidates(edge, from, fromSide, anchor(from, fromSide), to);
4610
+ const ends = automaticPortCandidates(edge, to, toSide, anchor(to, toSide), from);
4611
+ const midX = (from.cx + to.cx) / 2;
4612
+ const lowY = Math.min(from.cy, to.cy);
4613
+ const highY = Math.max(from.cy, to.cy);
4614
+ const tracks = [...new Set([...nodes.values()]
4615
+ .filter(node => node.y <= highY && node.y + node.height >= lowY)
4616
+ .flatMap(node => [node.x - 8, node.x + node.width + 8]))]
4617
+ .filter(x => x >= layout.laneX + 4 && x <= layout.laneX + layout.laneW - 4)
4618
+ .sort((a, b) => Math.abs(a - midX) - Math.abs(b - midX) || a - b).slice(0, 24);
4619
+ const plans = [];
4620
+ for (const start of starts) for (const end of ends) for (const x of tracks) {
4621
+ const points = joinRoutePoints(start, corridorViaX(start, end, fromSide, toSide, x), end);
4622
+ if (!readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)) continue;
4623
+ plans.push({
4624
+ points, fromSide, toSide, family: 'internal-detour',
4625
+ cost: readableCandidateCost(edge, points, 10000 + plans.length, naturalFromSide, naturalToSide),
4626
+ });
4627
+ }
4628
+ return plans;
4629
+ }
4630
+
4631
+ // Fixed offset tracks can miss a free corridor around several occupied rows.
4632
+ // Search only after the cheaper automatic plans still collide; pins retain
4633
+ // their existing paths. Four side pairs and explicit grid budgets bound work.
4634
+ function readableFreeSpacePlans(edge, selected, from, to, naturalFromSide, naturalToSide) {
4635
+ if (!independentAutomaticRoute(edge) || from.lane === to.lane
4636
+ || ['labelDx', 'labelDy', 'labelSegment', 'bias'].some(field => edge[field] !== undefined)
4637
+ || (!selected.cost.sharedCorridorPx && !selected.cost.properCrossingCount
4638
+ && !selected.cost.unrelatedBendContactCount)) return [];
4639
+ const obstacles = [];
4640
+ const add = (x, y, width, height, padding) => obstacles.push({
4641
+ x: x - padding, y: y - padding, width: width + padding * 2, height: height + padding * 2,
4642
+ });
4643
+ for (const node of nodes.values()) add(node.x, node.y, node.width, node.height, 8);
4644
+ sceneLabelObstacles();
4645
+ for (const item of obstacleGrid.query({ minX: 0, minY: 0,
4646
+ maxX: layout.laneX + layout.laneW + 64, maxY: autoHeight + 64 })) {
4647
+ if (item.kind === 'label' || item.kind === 'scene') {
4648
+ const rect = item.rect ?? item.obstacle;
4649
+ add(rect.x, rect.y, rect.width, rect.height, 4);
4650
+ } else if (item.kind === 'route' && item.edge !== edge) {
4651
+ for (let index = 1; index < item.routed.points.length; index += 1) {
4652
+ const a = item.routed.points[index - 1], b = item.routed.points[index];
4653
+ add(Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), 8);
4654
+ }
4655
+ }
4656
+ }
4657
+ const down = to.cy > from.cy;
4658
+ const pairs = [
4659
+ { fromSide: down ? 'bottom' : 'top', toSide: down ? 'top' : 'bottom' },
4660
+ { fromSide: 'left', toSide: 'left' },
4661
+ { fromSide: 'right', toSide: 'right' },
4662
+ { fromSide: selected.fromSide, toSide: selected.toSide },
4663
+ ];
4664
+ const plans = [];
4665
+ const seen = new Set();
4666
+ for (const [ordinal, { fromSide, toSide }] of pairs.entries()) {
4667
+ const key = `${fromSide}:${toSide}`;
4668
+ if (seen.has(key)) continue;
4669
+ seen.add(key);
4670
+ const start = automaticPortCandidates(edge, from, fromSide, anchor(from, fromSide), to)[0];
4671
+ const end = automaticPortCandidates(edge, to, toSide, anchor(to, toSide), from)[0];
4672
+ const candidate = shortestOrthogonalGridRoute({
4673
+ start, end, points: selected.points, obstacles, fromSide, toSide,
4674
+ clearance: 0, endpointStubPx: 16, bendPenaltyPx: 24,
4675
+ maximumObstacleCount: 200, maximumGridNodes: 25600,
4676
+ // Keep nearby coordinate candidates: coalescing can erase a whole gap.
4677
+ // Workflow's feasibility check still rejects short or reversing segments.
4678
+ minimumSegmentPx: 0,
4679
+ accept(points) {
4680
+ if (!readableCandidateIsFeasible(edge, points, from, to, fromSide, toSide)) return false;
4681
+ const cost = readableCandidateCost(edge, points, 20000 + ordinal, naturalFromSide, naturalToSide);
4682
+ return !cost.properCrossingCount && !cost.unrelatedBendContactCount
4683
+ && !cost.sharedCorridorPx && !cost.labelRouteClearanceDeficit;
4684
+ },
4685
+ });
4686
+ if (!candidate) continue;
4687
+ plans.push({ points: candidate.points, fromSide, toSide, family: 'free-space',
4688
+ cost: readableCandidateCost(edge, candidate.points, 20000 + ordinal, naturalFromSide, naturalToSide) });
4689
+ }
4690
+ return plans;
4691
+ }
4692
+
3664
4693
  function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3665
4694
  const authoredFrom = edge.fromSide && edge.fromSide !== 'auto' ? edge.fromSide : null;
3666
4695
  const authoredTo = edge.toSide && edge.toSide !== 'auto' ? edge.toSide : null;
@@ -3678,6 +4707,22 @@ function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3678
4707
  const plans = [];
3679
4708
  const feedback = [];
3680
4709
  let firstFailure = null;
4710
+ // Spreading ports for provisional sides can offset an otherwise clear
4711
+ // facing pair. Keep the aligned route in the same feasibility/cost contest.
4712
+ // Error and return roles do not imply a detour when that corridor is clear.
4713
+ const alignedStart = automaticPortCandidates(edge, from, naturalFromSide, anchor(from, naturalFromSide), to)[0];
4714
+ const alignedEnd = automaticPortCandidates(edge, to, naturalToSide, anchor(to, naturalToSide), from)[0];
4715
+ const alignedPoints = [alignedStart, alignedEnd];
4716
+ if (!edge.fromSide && !edge.toSide && !edge.labelAt
4717
+ && readableCandidateIsFeasible(edge, alignedPoints, from, to, naturalFromSide, naturalToSide)) {
4718
+ plans.push({
4719
+ family: 'aligned-facing',
4720
+ points: alignedPoints,
4721
+ fromSide: naturalFromSide,
4722
+ toSide: naturalToSide,
4723
+ cost: readableCandidateCost(edge, alignedPoints, -1, naturalFromSide, naturalToSide),
4724
+ });
4725
+ }
3681
4726
  for (const candidateSides of sidePairs) {
3682
4727
  if (authoredFrom && candidateSides.fromSide !== authoredFrom) continue;
3683
4728
  if (authoredTo && candidateSides.toSide !== authoredTo) continue;
@@ -3686,12 +4731,16 @@ function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3686
4731
  const pairOrdinal = seen.size;
3687
4732
  seen.add(key);
3688
4733
  const primary = pairOrdinal === 0;
3689
- const start = primaryPorts?.from && primary
4734
+ const preferredStart = primaryPorts?.from && primary
3690
4735
  ? primaryPorts.from
3691
4736
  : anchor(from, candidateSides.fromSide);
3692
- const end = primaryPorts?.to && primary
4737
+ const preferredEnd = primaryPorts?.to && primary
3693
4738
  ? primaryPorts.to
3694
4739
  : anchor(to, candidateSides.toSide);
4740
+ const starts = automaticPortCandidates(edge, from, candidateSides.fromSide, preferredStart, to);
4741
+ const ends = automaticPortCandidates(edge, to, candidateSides.toSide, preferredEnd, from);
4742
+ const [start] = starts;
4743
+ const [end] = ends;
3695
4744
  const planned = readableAutomaticCandidateSet(
3696
4745
  edge,
3697
4746
  from,
@@ -3710,6 +4759,35 @@ function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3710
4759
  ...candidate,
3711
4760
  ...candidateSides,
3712
4761
  })));
4762
+ let portOrdinal = 0;
4763
+ for (const alternativeStart of starts) {
4764
+ for (const alternativeEnd of ends) {
4765
+ if (alternativeStart === start && alternativeEnd === end) continue;
4766
+ portOrdinal += 1;
4767
+ const alternative = readableAutomaticCandidateSet(edge, from, to, alternativeStart, alternativeEnd,
4768
+ candidateSides.fromSide, candidateSides.toSide, {
4769
+ ordinalOffset: pairOrdinal * 9 + portOrdinal * 144,
4770
+ naturalFromSide,
4771
+ naturalToSide,
4772
+ });
4773
+ plans.push(...alternative.candidates.map((candidate) => ({ ...candidate, ...candidateSides })));
4774
+ if (!alternative.candidates.length) {
4775
+ try {
4776
+ const via = withDiagnosticRecordingSuppressed(() => readableAutomaticVia(
4777
+ edge, from, to, alternativeStart, alternativeEnd, candidateSides.fromSide, candidateSides.toSide,
4778
+ ));
4779
+ const points = joinRoutePoints(alternativeStart, via, alternativeEnd);
4780
+ plans.push({
4781
+ points, ...candidateSides,
4782
+ cost: readableCandidateCost(edge, points, pairOrdinal * 9 + portOrdinal * 144 + 6, naturalFromSide, naturalToSide),
4783
+ });
4784
+ } catch (error) {
4785
+ if (error instanceof WorkflowLayoutFeedback) feedback.push({ error, pairOrdinal });
4786
+ else if (!firstFailure) firstFailure = error;
4787
+ }
4788
+ }
4789
+ }
4790
+ }
3713
4791
  if (planned.candidates.length) continue;
3714
4792
  try {
3715
4793
  const expandedVia = withDiagnosticRecordingSuppressed(() => readableAutomaticVia(
@@ -3721,7 +4799,7 @@ function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3721
4799
  candidateSides.fromSide,
3722
4800
  candidateSides.toSide,
3723
4801
  ));
3724
- const expandedPoints = normalizeRoutePoints([start, ...expandedVia, end]);
4802
+ const expandedPoints = joinRoutePoints(start, expandedVia, end);
3725
4803
  const outsideRightOrdinal = planned.rawCandidates.findIndex(({ family }) => (
3726
4804
  family === 'outside-right'
3727
4805
  ));
@@ -3750,6 +4828,56 @@ function readableAutomaticRoute(edge, from, to, primarySides, primaryPorts) {
3750
4828
 
3751
4829
  plans.sort((left, right) => compareCost(left.cost, right.cost));
3752
4830
  if (plans.length) {
4831
+ // Before paying for a long outside detour, try the existing bounded port
4832
+ // slides on up to four otherwise viable plans with a false bend junction.
4833
+ // Pins never enter this repair; every slide keeps route and label checks.
4834
+ const touchingPlans = plans.filter(plan => plan.cost.unrelatedBendContactCount
4835
+ && !plan.cost.properCrossingCount).slice(0, 4);
4836
+ for (const plan of touchingPlans) {
4837
+ plans.push(...automaticEndpointRepairs(edge, plan, from, to, naturalFromSide, naturalToSide));
4838
+ }
4839
+ plans.sort((left, right) => compareCost(left.cost, right.cost));
4840
+ plans.push(...readableInternalDetourPlans(edge, plans[0], from, to, naturalFromSide, naturalToSide));
4841
+ plans.sort((a, b) => compareCost(a.cost, b.cost));
4842
+ const endpointRepairs = automaticEndpointRepairs(edge, plans[0], from, to, naturalFromSide, naturalToSide);
4843
+ const repairSeeds = plans[0].cost.sharedCorridorPx > 0
4844
+ ? plans.filter((plan, index) => plans.findIndex(other => (
4845
+ other.fromSide === plan.fromSide && other.toSide === plan.toSide
4846
+ && String(other.points[0]) === String(plan.points[0])
4847
+ && String(other.points.at(-1)) === String(plan.points.at(-1))
4848
+ )) === index).slice(0, 8) : [];
4849
+ // A crowded fan-out can exhaust the fixed corridor coordinates even with
4850
+ // distinct ports. Repair only interior segments of this automatic route;
4851
+ // One pass over at most eight seeds and four offsets bounds the search.
4852
+ for (const selected of repairSeeds) {
4853
+ if (selected.cost.sharedCorridorPx === 0) continue;
4854
+ const alternatives = [];
4855
+ for (let segment = 1; segment < selected.points.length - 2; segment += 1) {
4856
+ const a = selected.points[segment], b = selected.points[segment + 1];
4857
+ const axis = a[0] === b[0] ? 0 : 1;
4858
+ for (const offset of [-16, 16, -32, 32]) {
4859
+ const points = selected.points.map(point => [...point]);
4860
+ points[segment][axis] += offset;
4861
+ points[segment + 1][axis] += offset;
4862
+ if (!readableCandidateIsFeasible(edge, points, from, to, selected.fromSide, selected.toSide)) continue;
4863
+ const cost = readableCandidateCost(edge, points, selected.cost.stableCandidateOrdinal, naturalFromSide, naturalToSide);
4864
+ if (compareCost(cost, selected.cost) < 0) alternatives.push({ ...selected, points, cost });
4865
+ }
4866
+ }
4867
+ alternatives.sort((a, b) => compareCost(a.cost, b.cost));
4868
+ if (alternatives.length) plans.push(alternatives[0]);
4869
+ }
4870
+ plans.push(...endpointRepairs);
4871
+ plans.sort((a, b) => compareCost(a.cost, b.cost));
4872
+ // A corner repair can expose a usable port slot that the original route
4873
+ // could not reach. Try at most four measured gaps per endpoint, retaining
4874
+ // stroke/arrow footprints and every route/label feasibility check.
4875
+ if (plans[0].cost.sharedCorridorPx > 0) {
4876
+ plans.push(...automaticEndpointRepairs(edge, plans[0], from, to, naturalFromSide, naturalToSide, true));
4877
+ plans.sort((a, b) => compareCost(a.cost, b.cost));
4878
+ }
4879
+ plans.push(...readableFreeSpacePlans(edge, plans[0], from, to, naturalFromSide, naturalToSide));
4880
+ plans.sort((a, b) => compareCost(a.cost, b.cost));
3753
4881
  const selected = plans[0];
3754
4882
  return {
3755
4883
  points: selected.points,
@@ -3928,7 +5056,31 @@ function readableControlledRoute(edge, from, to) {
3928
5056
  };
3929
5057
  }
3930
5058
 
3931
- function pathFor(edge) {
5059
+ // Registering a routed path also publishes it (and its label box) to the grid.
5060
+ let obstacleSequence = 0;
5061
+ function registerRouted(edge, routed) {
5062
+ pathCache.set(edge, routed);
5063
+ const sequence = obstacleSequence++;
5064
+ const routeExtent = routeBounds(routed.points);
5065
+ obstacleGrid.insert(routeExtent, { kind: 'route', edge, routed, sequence, bounds: routeExtent });
5066
+ for (const [x] of routed.points) rightmostRoutedEdge = Math.max(rightmostRoutedEdge, x);
5067
+ for (const end of ['from', 'to']) {
5068
+ const nodeId = edge[end];
5069
+ if (nodeId === undefined || nodeId === null) continue;
5070
+ let byEdge = nodeRoutes.get(nodeId);
5071
+ if (!byEdge) { byEdge = new Map(); nodeRoutes.set(nodeId, byEdge); }
5072
+ byEdge.set(edge, routed);
5073
+ }
5074
+ const index = edgeIndexByEdge.get(edge);
5075
+ const label = index === undefined ? null : labelRectFor(edge, index);
5076
+ if (label) {
5077
+ obstacleGrid.insert(rectToBounds(label), { kind: 'label', edge, rect: label, sequence });
5078
+ rightmostRoutedEdge = Math.max(rightmostRoutedEdge, label.x + label.width);
5079
+ }
5080
+ return routed;
5081
+ }
5082
+
5083
+ function pathFor(edge, automaticPlan = null) {
3932
5084
  if (pathCache.has(edge)) return pathCache.get(edge);
3933
5085
  const from = nodes.get(edge.from);
3934
5086
  const to = nodes.get(edge.to);
@@ -3940,19 +5092,13 @@ function pathFor(edge) {
3940
5092
  toSide: planned.toSide,
3941
5093
  });
3942
5094
  const routed = { d: polylinePath(planned.points), points: planned.points };
3943
- pathCache.set(edge, routed);
3944
- return routed;
5095
+ return registerRouted(edge, routed);
3945
5096
  }
3946
5097
  }
3947
5098
  const ports = automaticPorts.get(edge);
3948
5099
  const { fromSide, toSide } = edgeSides(edge);
3949
- const readableAutomatic = workflow.schema_version === 2
3950
- && !Array.isArray(edge.via)
3951
- && edge.channelX === undefined
3952
- && edge.channelY === undefined
3953
- && (!edge.route || edge.route === 'auto');
3954
- if (readableAutomatic) {
3955
- const planned = readableAutomaticRoute(
5100
+ if (isReadableAutomaticRoute(edge)) {
5101
+ const planned = automaticPlan || readableAutomaticRoute(
3956
5102
  edge,
3957
5103
  from,
3958
5104
  to,
@@ -3964,8 +5110,7 @@ function pathFor(edge) {
3964
5110
  toSide: planned.toSide,
3965
5111
  });
3966
5112
  const routed = { d: polylinePath(planned.points), points: planned.points };
3967
- pathCache.set(edge, routed);
3968
- return routed;
5113
+ return registerRouted(edge, routed);
3969
5114
  }
3970
5115
  const start = ports?.from || anchor(from, fromSide);
3971
5116
  const end = ports?.to || anchor(to, toSide);
@@ -3977,15 +5122,21 @@ function pathFor(edge) {
3977
5122
  ? normalizeRoutePoints(authoredPoints)
3978
5123
  : authoredPoints;
3979
5124
  const routed = { d: polylinePath(points), points };
3980
- pathCache.set(edge, routed);
3981
- return routed;
5125
+ return registerRouted(edge, routed);
3982
5126
  }
3983
5127
 
5128
+ // The label rectangle follows from the routed path, which is fixed once it is
5129
+ // cached; keying on that object means a re-routed edge gets a fresh rectangle.
5130
+ const LABEL_RECT_CACHE = new WeakMap();
5131
+
3984
5132
  function labelRectFor(edge, relationIndex) {
3985
5133
  if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) return null;
3986
- const [lx, ly] = workflowEdgeLabelPoint(edge, pathFor(edge).points);
5134
+ const routed = pathFor(edge);
5135
+ const cached = LABEL_RECT_CACHE.get(routed);
5136
+ if (cached) return cached;
5137
+ const [lx, ly] = workflowEdgeLabelPoint(edge, routed.points);
3987
5138
  const width = workflowLabelWidth(edge.label);
3988
- return {
5139
+ const rect = {
3989
5140
  relation: edge,
3990
5141
  relationIndex,
3991
5142
  label: edge.label,
@@ -3996,6 +5147,8 @@ function labelRectFor(edge, relationIndex) {
3996
5147
  lx,
3997
5148
  ly,
3998
5149
  };
5150
+ LABEL_RECT_CACHE.set(routed, rect);
5151
+ return rect;
3999
5152
  }
4000
5153
 
4001
5154
  function measuredContentBounds() {
@@ -4035,8 +5188,9 @@ function measuredContentBounds() {
4035
5188
  for (const node of nodes.values()) includeRect(node, `node ${node.id}`);
4036
5189
  for (const [index, edge] of workflow.edges.entries()) {
4037
5190
  if (!nodes.has(edge.from) || !nodes.has(edge.to)) continue;
4038
- for (const point of pathFor(edge).points) includePoint(point, `edge ${edge.id || index}`);
4039
- const label = labelRectFor(edge, index);
5191
+ const routed = pathCache.get(edge);
5192
+ for (const point of routed.points) includePoint(point, `edge ${edge.id || index}`);
5193
+ const label = LABEL_RECT_CACHE.get(routed);
4040
5194
  if (label) includeRect(label, `edge ${edge.id || index} label mask`);
4041
5195
  }
4042
5196
  for (const phase of asArray(workflow.phases)) {
@@ -4085,24 +5239,28 @@ function measuredContentBounds() {
4085
5239
  };
4086
5240
  }
4087
5241
 
5242
+ function measuredPlanFootprint() {
5243
+ const bounds = measuredContentBounds();
5244
+ return {
5245
+ bounds,
5246
+ requiredViewBox: [
5247
+ Math.max(minimumCanvasWidth, Math.ceil(bounds.right + 16)),
5248
+ Math.max(autoHeight, Math.ceil(bounds.bottom + 18)),
5249
+ ],
5250
+ };
5251
+ }
5252
+
4088
5253
  function finalizeReadableViewBox() {
4089
5254
  if (workflow.schema_version !== 2) {
4090
5255
  requiredViewBox = [...viewBox];
4091
5256
  return;
4092
5257
  }
4093
- const bounds = measuredContentBounds();
4094
- requiredViewBox = [
4095
- Math.max(minimumCanvasWidth, Math.ceil(bounds.right + 16)),
4096
- Math.max(autoHeight, Math.ceil(bounds.bottom + 18)),
4097
- ];
5258
+ const footprint = measuredPlanFootprint();
5259
+ const { bounds } = footprint;
5260
+ requiredViewBox = footprint.requiredViewBox;
4098
5261
  const outsideOrigin = bounds.left < 0 || bounds.top < 0;
4099
5262
  if (outsideOrigin) {
4100
- const hasAbsolutePins = workflow.edges.some((edge) => (
4101
- Array.isArray(edge.via)
4102
- || Array.isArray(edge.labelAt)
4103
- || edge.channelX !== undefined
4104
- || edge.channelY !== undefined
4105
- ));
5263
+ const hasAbsolutePins = hasAbsoluteWorkflowPins(workflow);
4106
5264
  const message = `Workflow geometry extends above or left of the viewBox origin (${Math.round(bounds.left)}, ${Math.round(bounds.top)}).`;
4107
5265
  throwDiagnosticError(message, [{
4108
5266
  code: hasAbsolutePins ? 'workflow/explicit-pin-conflict' : 'workflow/solver-budget-exhausted',
@@ -4158,7 +5316,7 @@ function renderLane(lane, index) {
4158
5316
  const exception = lane.variant === 'exception'
4159
5317
  ? `\n <rect data-graph-role="structural-frame" data-composition-frame-kind="exception-lane" data-composition-frame-id="lane-${index}-exception" x="${layout.laneX + 6}" y="${y + 6}" width="${layout.laneW - 12}" height="${height - 12}" rx="8" class="c-security-group" stroke-width="1"/>`
4160
5318
  : '';
4161
- const labelClass = lane.variant === 'exception' ? 't-security' : 't-dim';
5319
+ const labelClass = lane.variant === 'exception' ? 't-security' : 't-muted';
4162
5320
  const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0');
4163
5321
  return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-${index}" x="${layout.laneX}" y="${y}" width="${layout.laneW}" height="${height}" rx="10" class="c-lane" stroke-width="1"/>${exception}
4164
5322
  <text x="${layout.laneX + 14}" y="${y + 22}" class="${labelClass}" font-size="10" font-weight="600">${prefix} / ${esc(lane.label)}</text>`;
@@ -4168,9 +5326,14 @@ function renderPhase(phase) {
4168
5326
  const span = phaseSpan(phase);
4169
5327
  const accent = variantAccent(phase.variant);
4170
5328
  const [lineClass] = arrowClassMap[phase.variant || 'default'] || arrowClassMap.default;
4171
- return ` <line x1="${span.x}" y1="35" x2="${span.x + span.width}" y2="35" class="${lineClass}" stroke-width="1.1"/>
4172
- <rect x="${span.x}" y="27" width="${span.width}" height="16" rx="4" class="c-mask"/>
4173
- <text x="${span.cx}" y="39" class="${accent}" font-size="8" font-weight="600" text-anchor="middle">${esc(phase.label)}</text>`;
5329
+ // A hairline spans the phase's columns; only the label is masked, so the
5330
+ // span itself stays visible.
5331
+ const labelW = Math.min(span.width - 12, workflowLabelWidth(phase.label) + 10);
5332
+ const x1 = span.x + 8;
5333
+ const x2 = span.x + span.width - 8;
5334
+ return ` <path d="M ${x1} 35 H ${x2}" class="${lineClass} phase-rule" stroke-width="1" stroke-linecap="round" fill="none"/>
5335
+ <rect x="${span.cx - labelW / 2}" y="27" width="${labelW}" height="16" rx="8" class="c-mask phase-chip"/>
5336
+ <text x="${span.cx}" y="38.5" class="${accent}" font-size="8" font-weight="600" text-anchor="middle" letter-spacing=".04em">${esc(phase.label)}</text>`;
4174
5337
  }
4175
5338
 
4176
5339
  function renderGroup(group, index) {
@@ -4187,36 +5350,103 @@ function renderGroup(group, index) {
4187
5350
  <text x="${span.x + 10}" y="${labelY}" class="${textClass}" font-size="7" font-weight="600">${esc(group.label)}</text>`;
4188
5351
  }
4189
5352
 
5353
+ // Automatic showcase canvases stop sublabel shrinking at the desktop floor.
5354
+ // Standard and authored canvases retain the established text-fitting contract;
5355
+ // the preferred font remains the maximum supported by the fixed text rows.
5356
+ function readableSublabelMinimum() {
5357
+ const minimum = nodeTextFit.sublabelMinimum;
5358
+ if (workflow.schema_version !== 2 || resolvedQualityProfile !== 'showcase'
5359
+ || workflow.meta?.viewBox) return minimum;
5360
+ return Math.max(minimum, Math.ceil(minimumReadableSourceTextPx(viewBox[0]) * 10) / 10);
5361
+ }
5362
+
5363
+ function validateReadableNodeText() {
5364
+ const diagnostics = [];
5365
+ const minimum = readableSublabelMinimum();
5366
+ for (const node of nodes.values()) {
5367
+ if (!node.sublabel) continue;
5368
+ const maximumCanvasWidth = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH
5369
+ * nodeTextFit.sublabelPreferred / MIN_PROJECTED_NODE_TEXT_PX);
5370
+ const fontBudgetExceeded = minimum > nodeTextFit.sublabelPreferred;
5371
+ const minimumW = minimumNodeTextWidth(node.sublabel, minimum);
5372
+ if (!fontBudgetExceeded && minimumW <= availableNodeTextWidth(node.width)) continue;
5373
+ const message = fontBudgetExceeded
5374
+ ? `Sublabel "${node.sublabel}" needs a ${minimum}px minimum that stays readable on this ${viewBox[0]}px canvas, above the supported ${nodeTextFit.sublabelPreferred}px text row — compact the column spacing or node widths so the canvas is at most ${maximumCanvasWidth}px wide; preserve the sublabel's role or protocol.`
5375
+ : `Sublabel "${node.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px minimum that stays readable on this ${viewBox[0]}px canvas, but node "${node.id}" provides ${availableNodeTextWidth(node.width)}px — shorten the sublabel while preserving its role or protocol.`;
5376
+ const authoredNode = workflow.nodes.find((authored) => authored.id === node.id);
5377
+ diagnostics.push({
5378
+ code: 'workflow/sublabel-readability',
5379
+ severity: 'error',
5380
+ message,
5381
+ subject: {
5382
+ diagramType: 'workflow', node: node.id,
5383
+ path: `/nodes/${sourceIndexes.nodes.get(authoredNode)}/sublabel`,
5384
+ },
5385
+ evidence: {
5386
+ requiredFontPx: minimum,
5387
+ maximumSlotFontPx: nodeTextFit.sublabelPreferred,
5388
+ canvasWidthPx: viewBox[0],
5389
+ maximumCanvasWidthPx: maximumCanvasWidth,
5390
+ requiredTextWidthPx: minimumW,
5391
+ availableTextWidthPx: availableNodeTextWidth(node.width),
5392
+ },
5393
+ supportedFixes: fontBudgetExceeded
5394
+ ? [`compact column spacing or node widths until the implicit canvas is at most ${maximumCanvasWidth}px wide, preserving every node and relationship`]
5395
+ : [`shorten node "${node.id}" sublabel while preserving its role or protocol; move supplementary facts into a card`],
5396
+ });
5397
+ }
5398
+ if (diagnostics.length) throwDiagnosticError('Workflow layout validation failed', diagnostics);
5399
+ }
5400
+
4190
5401
  function renderNode(node) {
4191
5402
  const fill = componentFill[node.type] || 'c-external';
4192
5403
  const accent = componentText[node.type] || 't-muted';
4193
5404
  const hasSub = node.sublabel != null && node.sublabel !== '';
4194
5405
  const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), nodeTextFit.labelPreferred, nodeTextFit.labelMinimum);
4195
- const sublabelFontSize = hasSub
4196
- ? fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)
4197
- : nodeTextFit.sublabelPreferred;
5406
+ const sublabelMinimum = Math.min(nodeTextFit.sublabelPreferred, readableSublabelMinimum());
5407
+ const sublabelFontSize = fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, sublabelMinimum);
5408
+ const tagFontSize = fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum);
5409
+ const textRows = [{ text: node.label, font: labelFontSize, y: 21 }];
5410
+ if (hasSub) textRows.push({ text: node.sublabel, font: sublabelFontSize, y: 38 });
5411
+ if (node.tag) textRows.push({ text: node.tag, font: tagFontSize, y: node.height - 12 });
5412
+ const labelLayout = nodeLabelLayout({ width: node.width, height: node.height, rows: textRows,
5413
+ brand: Boolean(brandMarkFor(node)), source: Boolean(sourceEvidence?.nodes?.[node.id]?.length) });
4198
5414
  const sub = hasSub
4199
- ? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 38}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
5415
+ ? `\n <text data-detail="context" x="${node.cx}" y="${node.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
4200
5416
  : '';
4201
5417
  const tag = node.tag
4202
- ? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 12}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" text-anchor="middle">${esc(node.tag)}</text>`
5418
+ ? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(node.tag)}</text>`
4203
5419
  : '';
4204
5420
  const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
4205
5421
  const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context: nodeContext(node), ...brandMetadataFor(node) };
4206
5422
  return ` <g ${focusNodeAttrs(node.id, node.label, passport, workflow.meta.locale)}>
4207
5423
  ${focusNodeTitle(node.label, passport)}
4208
- <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
4209
- <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(workflow.meta, 'node', nodeStep(node))} stroke-width="1.5"/>
4210
- ${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
4211
- <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
5424
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" class="c-mask"/>
5425
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" class="${fill}"${animateAttr(workflow.meta, 'node', nodeStep(node))} stroke-width="1.5"/>
5426
+ ${renderSemanticSigil(node.type, { icon: node.icon, x: node.x + 6, y: node.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}
5427
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.x + labelLayout.x}" y="${node.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
4212
5428
  </g>`;
4213
5429
  }
4214
5430
 
5431
+ // Rounded corners must leave every visible straight segment above the
5432
+ // artifact check's 8px floor, including short arrow attachment stubs.
5433
+ function roundedWorkflowPath(points) {
5434
+ let radius = 8;
5435
+ for (let index = 1; index < points.length; index += 1) {
5436
+ const length = Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);
5437
+ const corners = (index > 1 ? 1 : 0) + (index < points.length - 1 ? 1 : 0);
5438
+ if (corners) radius = Math.min(radius, Math.max(0, (length - 8) / corners));
5439
+ }
5440
+ return roundedPath(points, radius);
5441
+ }
5442
+
4215
5443
  function renderEdgePath(edge, index) {
4216
5444
  const [cls, marker] = arrowClassMap[edge.variant || 'default'] || arrowClassMap.default;
4217
5445
  const routed = pathFor(edge);
4218
5446
  const strokeWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4);
4219
- return ` <path ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(workflow.meta, 'edge', edgeSteps.get(edge))} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
5447
+ const routing = independentAutomaticRoute(edge) ? ' data-composition-routing="workflow-v2-auto"' : '';
5448
+ const role = workflow.schema_version === 2 ? ` data-edge-role="${esc(edge.role || '')}"` : '';
5449
+ return ` <path ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)}${routing}${role} data-composition-points="${routePointsValue(routed.points)}" d="${roundedWorkflowPath(routed.points)}" class="${cls}"${animateAttr(workflow.meta, 'edge', edgeSteps.get(edge))} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
4220
5450
  }
4221
5451
 
4222
5452
  function renderEdgeLabel(edge, index) {
@@ -4226,7 +5456,7 @@ function renderEdgeLabel(edge, index) {
4226
5456
  const labelW = workflowLabelWidth(edge.label);
4227
5457
  return ` <g data-detail="context" ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)}>
4228
5458
  <rect x="${lx - labelW / 2}" y="${ly - 10}" width="${labelW}" height="14" rx="3" class="c-mask"/>
4229
- <text x="${lx}" y="${ly}" class="${variantAccent(edge.variant, { dashed: 't-database' })}" font-size="8" text-anchor="middle">${esc(edge.label)}</text>
5459
+ <text x="${lx}" y="${ly}" class="${!edge.variant || edge.variant === 'default' ? 't-muted' : edgeLabelAccent(edge.variant)}" font-size="8" text-anchor="middle">${esc(edge.label)}</text>
4230
5460
  </g>`;
4231
5461
  }
4232
5462
 
@@ -4246,9 +5476,19 @@ function renderLegend() {
4246
5476
  }
4247
5477
 
4248
5478
  function renderSvg() {
4249
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(workflow.meta, 'workflow diagram')}>
5479
+ // A renderer-sized canvas that would overflow the desktop page at full
5480
+ // width reads at page width with vertical scroll, like a tall stack.
5481
+ const readerFit = workflow.schema_version === 2
5482
+ && !workflow.meta?.viewBox
5483
+ && ((hasVerticalStack(workflow) && asArray(layout.laneHeights).some((height) => height > 104))
5484
+ || predictedFixedWidthOverflow({ viewBoxWidth: viewBox[0], viewBoxHeight: viewBox[1], diagramType: 'workflow' }))
5485
+ ? ' data-reader-fit="width-first"'
5486
+ : '';
5487
+ const contract = workflow.schema_version === 2 ? ' data-layout-contract="readable-v2"' : '';
5488
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit}${contract} ${svgRootAttrs(workflow.meta, resolvedQualityProfile)} ${NODE_CARD_ATTR}>
4250
5489
  ${svgAccessibleText(workflow.meta, 'workflow')}
4251
5490
  ${renderDefinitions()}
5491
+ ${nodeCardStyle()}
4252
5492
 
4253
5493
  <!-- Background Grid -->
4254
5494
  <rect width="100%" height="100%" fill="url(#grid)" />
@@ -4277,44 +5517,129 @@ ${renderLegend()}
4277
5517
  }
4278
5518
 
4279
5519
 
5520
+ function hasCompleteMeasuredPlan() {
5521
+ if (nodes.size !== workflow.nodes.length || pathCache.size !== workflow.edges.length
5522
+ || !layout.colXs.every(Number.isFinite) || !viewBox.every(Number.isFinite)) return false;
5523
+ for (const node of nodes.values()) {
5524
+ if (!isFinitePoint(node.x, node.y, node.width, node.height)
5525
+ || node.width <= 0 || node.height <= 0) return false;
5526
+ }
5527
+ for (const edge of workflow.edges) {
5528
+ if (!nodes.has(edge.from) || !nodes.has(edge.to)) return false;
5529
+ const routed = pathCache.get(edge);
5530
+ if (!Array.isArray(routed?.points) || routed.points.length < 2
5531
+ || !routed.points.every((point) => Array.isArray(point)
5532
+ && point.length === 2 && point.every(Number.isFinite))) return false;
5533
+ if (!edge.label) continue;
5534
+ const rect = LABEL_RECT_CACHE.get(routed);
5535
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height, rect.lx, rect.ly)
5536
+ || rect.width <= 0 || rect.height <= 0) return false;
5537
+ }
5538
+ return true;
5539
+ }
5540
+
5541
+ function measuredPlanReceipt(diagnostics, requiredFrame) {
5542
+ return {
5543
+ contract: layout.contract,
5544
+ viewBox: [...viewBox],
5545
+ requiredViewBox: [...requiredFrame],
5546
+ columns: [...layout.colXs],
5547
+ nodes: [...nodes.values()].map((node) => ({
5548
+ id: node.id,
5549
+ lane: node.lane,
5550
+ col: node.col,
5551
+ x: node.x,
5552
+ y: node.y,
5553
+ width: node.width,
5554
+ height: node.height,
5555
+ })),
5556
+ edges: workflow.edges.map((edge) => ({
5557
+ id: edge.id ?? null,
5558
+ from: edge.from,
5559
+ to: edge.to,
5560
+ points: pathCache.get(edge).points.map((point) => [...point]),
5561
+ })),
5562
+ labels: workflow.edges.flatMap((edge) => {
5563
+ if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) return [];
5564
+ const { lx: x, ly: y } = LABEL_RECT_CACHE.get(pathCache.get(edge));
5565
+ return [{ edge: edge.id ?? null, label: edge.label, x, y, width: workflowLabelWidth(edge.label), height: 14 }];
5566
+ }),
5567
+ diagnostics,
5568
+ };
5569
+ }
5570
+
5571
+ function absoluteGeometrySizingDiagnostic() {
5572
+ if (workflow.schema_version !== 2 || !preservesWorkflowAbsoluteGeometry(workflow)) return null;
5573
+ const affectedNodes = workflow.nodes.flatMap((authored) => {
5574
+ if (Number.isFinite(authored.width)) return [];
5575
+ const node = nodes.get(authored.id);
5576
+ const automaticWidth = automaticNodeWidth(authored);
5577
+ if (!node || !automaticWidth || automaticWidth <= node.width) return [];
5578
+ const problems = workflowNodeTextProblems(node, nodeTextFit);
5579
+ // Report only intrinsic text failures that automatic sizing would resolve.
5580
+ // Final-canvas readability still belongs to its later measured check.
5581
+ if (!problems.length || workflowNodeTextProblems({ ...node, width: automaticWidth }, nodeTextFit).length) return [];
5582
+ return [{
5583
+ node: node.id,
5584
+ path: `/nodes/${sourceIndexes.nodes.get(authored)}/width`,
5585
+ currentWidthPx: node.width,
5586
+ automaticWidthPx: automaticWidth,
5587
+ problems,
5588
+ }];
5589
+ });
5590
+ if (!affectedNodes.length) return null;
5591
+ const absoluteConstraints = [
5592
+ ...(workflow.meta?.viewBox ? [{ path: '/meta/viewBox', value: [...workflow.meta.viewBox] }] : []),
5593
+ ...workflow.edges.flatMap((edge) => ['via', 'labelAt', 'channelX', 'channelY']
5594
+ .filter((field) => edge[field] !== undefined)
5595
+ .map((field) => ({ path: `/edges/${sourceIndexes.edges.get(edge)}/${field}`, value: edge[field] }))),
5596
+ ];
5597
+ const message = `Preserving absolute workflow geometry disables automatic node widening: ${affectedNodes.length} node(s) with omitted widths also have text-fit problems. Explicit widths can preserve their full text, but changing widths moves endpoints, so route coordinates must be remeasured. The reported automatic widths resolve these intrinsic text checks only; they are not a verified whole-diagram repair.`;
5598
+ return {
5599
+ code: 'workflow/absolute-geometry-sizing',
5600
+ severity: 'warning',
5601
+ message,
5602
+ subject: { diagramType: 'workflow' },
5603
+ evidence: { absoluteConstraints, affectedNodes },
5604
+ supportedFixes: [],
5605
+ };
5606
+ }
5607
+
4280
5608
  try {
4281
5609
  validateReadableInputsBeforeRouting();
4282
5610
  validateReadablePinnedGeometry();
4283
5611
  validateWorkflow();
4284
5612
  finalizeReadableViewBox();
5613
+ validateReadableNodeText();
5614
+ // A rendering failure is not a measured validation rejection.
5615
+ measuredPlanReady = false;
4285
5616
  const svg = renderSvg();
4286
- const receipt = {
4287
- contract: layout.contract,
4288
- viewBox: [...viewBox],
4289
- requiredViewBox: [...requiredViewBox],
4290
- columns: [...layout.colXs],
4291
- nodes: [...nodes.values()].map((node) => ({
4292
- id: node.id,
4293
- lane: node.lane,
4294
- col: node.col,
4295
- x: node.x,
4296
- y: node.y,
4297
- width: node.width,
4298
- height: node.height,
4299
- })),
4300
- edges: workflow.edges.map((edge) => ({
4301
- id: edge.id ?? null,
4302
- from: edge.from,
4303
- to: edge.to,
4304
- points: pathFor(edge).points.map((point) => [...point]),
4305
- })),
4306
- labels: workflow.edges.flatMap((edge) => {
4307
- if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) return [];
4308
- const [x, y] = workflowEdgeLabelPoint(edge, pathFor(edge).points);
4309
- return [{ edge: edge.id ?? null, label: edge.label, x, y, width: workflowLabelWidth(edge.label), height: 14 }];
4310
- }),
4311
- diagnostics: [],
4312
- };
5617
+ const receipt = measuredPlanReceipt(workflowDiagnostics, requiredViewBox);
4313
5618
  return { ok: true, svg, receipt };
4314
5619
  } catch (error) {
4315
5620
  if (!Array.isArray(error?.archifyDiagnostics)) throw error;
4316
5621
  const diagnostics = error.archifyDiagnostics.map((diagnostic) => ({ ...diagnostic }));
4317
- return compilerFailure(layout.contract, diagnostics, error.message);
5622
+ // A route failure can hide the sizing side effect of a newly added pin.
5623
+ // Supplement that failure without altering acceptance, geometry or fixes.
5624
+ const sizing = !measuredPlanReady && diagnostics.some(({ code }) => [
5625
+ 'workflow/explicit-pin-conflict', 'workflow/route-preset-conflict', 'workflow/solver-budget-exhausted',
5626
+ ].includes(code)) ? absoluteGeometrySizingDiagnostic() : null;
5627
+ if (sizing) diagnostics.push(sizing);
5628
+ const failure = compilerFailure(layout.contract, diagnostics, sizing ? `${error.message}\n${sizing.message}` : error.message);
5629
+ if (measuredPlanReady) {
5630
+ try {
5631
+ const footprint = measuredPlanFootprint();
5632
+ if (footprint.requiredViewBox.every(Number.isFinite)) {
5633
+ failure.receipt = {
5634
+ ...measuredPlanReceipt(diagnostics, footprint.requiredViewBox),
5635
+ ok: false,
5636
+ };
5637
+ }
5638
+ } catch {
5639
+ // Supplemental evidence must never replace the causal diagnostics.
5640
+ }
5641
+ }
5642
+ return failure;
4318
5643
  }
4319
5644
  }
4320
5645
 
@@ -4339,15 +5664,94 @@ function feedbackFailure(request) {
4339
5664
  return compilerFailure('readable-v2', diagnostics, message);
4340
5665
  }
4341
5666
 
4342
- function compileWorkflowWithFeedback({ workflow, qualityProfile, discoverFixes = true } = {}) {
5667
+ const ROUTE_ORDER_CODES = new Set(['composition/ambiguous-corridor', 'composition/proper-crossing']);
5668
+ const MAX_ROUTE_ORDER_RETRIES = 3;
5669
+
5670
+ // Automatic routes are planned in canonical order, each against those already
5671
+ // placed, so an earlier edge can take the only clear corridor of a later one
5672
+ // (a long cross-lane branch dropping between two same-lane neighbours). When
5673
+ // the readable result fails only because two automatic edges share a corridor
5674
+ // or cross, plan the later edge first and keep the retry only if it removes
5675
+ // errors; the document itself is never changed.
5676
+ function errorCount(result) {
5677
+ return result.ok ? 0 : result.diagnostics.filter((diagnostic) => diagnostic.severity !== 'warning').length;
5678
+ }
5679
+ function routeOrderRetryIndex(result, layoutFeedback, workflow) {
5680
+ if (result.ok || !Array.isArray(result.diagnostics)) return null;
5681
+ const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity !== 'warning');
5682
+ if (!errors.length || !errors.every((diagnostic) => ROUTE_ORDER_CODES.has(diagnostic.code))) return null;
5683
+ const tried = new Set(asArray(layoutFeedback.routeFirst));
5684
+ for (const diagnostic of errors) {
5685
+ const indexes = [diagnostic.subject?.index, diagnostic.evidence?.otherRelationship?.index]
5686
+ .filter(Number.isInteger);
5687
+ if (indexes.length !== 2) continue;
5688
+ // Diagnostic indexes identify the authored array, not routing priority.
5689
+ const later = indexes.sort((a, b) => compareWorkflowEdges(workflow.edges[a], workflow.edges[b])).at(-1);
5690
+ if (!tried.has(later)) return later;
5691
+ }
5692
+ return null;
5693
+ }
5694
+
5695
+ function canRepairAutomaticSublabels(result, { workflow, qualityProfile }) {
5696
+ if (workflow.schema_version !== 2
5697
+ || (qualityProfile || workflow.meta?.quality_profile) !== 'showcase'
5698
+ || preservesWorkflowAbsoluteGeometry(workflow)
5699
+ || result.receipt?.contract !== 'readable-v2'
5700
+ || !Array.isArray(result.receipt.nodes)) return false;
5701
+ const errors = result.diagnostics.filter(({ severity }) => severity !== 'warning');
5702
+ return errors.length > 0 && errors.every(({ code, subject, evidence }) => {
5703
+ if (code !== 'workflow/sublabel-readability') return false;
5704
+ const node = workflow.nodes.find(({ id }) => id === subject.node);
5705
+ const measured = result.receipt.nodes.find(({ id }) => id === subject.node);
5706
+ if (!node?.sublabel || Number.isFinite(node.width) || !measured) return false;
5707
+ const minimum = evidence.requiredFontPx;
5708
+ if (!Number.isFinite(minimum) || minimum > Math.min(8, evidence.maximumSlotFontPx)) return false;
5709
+ const padding = measured.width - availableNodeTextWidth(measured.width);
5710
+ const width = Math.ceil((minimumNodeTextWidth(node.sublabel, minimum) + padding) / 4) * 4;
5711
+ return width > measured.width && width <= 200;
5712
+ });
5713
+ }
5714
+
5715
+ function compileWithRouteOrderFeedback(options) {
5716
+ const first = compileWorkflowInternal(options);
5717
+ if (options.workflow?.schema_version !== 2) return first;
5718
+ let best = first;
5719
+ let current = first;
5720
+ let layoutFeedback = options.layoutFeedback;
5721
+ for (let retry = 0; retry < MAX_ROUTE_ORDER_RETRIES && errorCount(best) > 0; retry += 1) {
5722
+ const index = routeOrderRetryIndex(current, layoutFeedback, options.workflow);
5723
+ if (index === null) break;
5724
+ layoutFeedback = {
5725
+ ...layoutFeedback,
5726
+ fanInLabelClearance: true,
5727
+ routeFirst: [...asArray(layoutFeedback.routeFirst), index],
5728
+ };
5729
+ try {
5730
+ current = compileWorkflowInternal({ ...options, layoutFeedback });
5731
+ } catch (error) {
5732
+ if (error instanceof WorkflowLayoutFeedback) break;
5733
+ throw error;
5734
+ }
5735
+ // Removing a crossing may expose several text-width errors on a wider
5736
+ // canvas. Let the existing bounded sizing loop finish that automatic plan;
5737
+ // counting errors alone would discard it before sizing could run.
5738
+ if (errorCount(current) < errorCount(best)
5739
+ || canRepairAutomaticSublabels(current, options)) best = current;
5740
+ }
5741
+ return best;
5742
+ }
5743
+
5744
+ function compileWithLayoutFeedback({ workflow, qualityProfile, sourceEvidence, discoverFixes, widthOverrides }) {
4343
5745
  let layoutFeedback = {};
4344
5746
  for (let attempt = 0; attempt <= MAX_READABLE_LAYOUT_FEEDBACK_ROUNDS; attempt += 1) {
4345
5747
  try {
4346
- return compileWorkflowInternal({
5748
+ return compileWithRouteOrderFeedback({
4347
5749
  workflow,
4348
5750
  qualityProfile,
5751
+ sourceEvidence,
4349
5752
  discoverFixes,
4350
5753
  layoutFeedback,
5754
+ widthOverrides,
4351
5755
  });
4352
5756
  } catch (error) {
4353
5757
  if (!(error instanceof WorkflowLayoutFeedback)) throw error;
@@ -4395,6 +5799,122 @@ function compileWorkflowWithFeedback({ workflow, qualityProfile, discoverFixes =
4395
5799
  throw new Error('unreachable readable-v2 layout feedback state');
4396
5800
  }
4397
5801
 
4398
- export function compileWorkflow({ workflow, qualityProfile } = {}) {
4399
- return compileWorkflowWithFeedback({ workflow, qualityProfile });
5802
+ function compileWorkflowWithFeedback({ workflow, qualityProfile, sourceEvidence, discoverFixes = true } = {}) {
5803
+ const options = { workflow, qualityProfile, sourceEvidence, discoverFixes };
5804
+ let result = compileWithLayoutFeedback(options);
5805
+ // Keep successful output and authored geometry exactly as before. Check the
5806
+ // original migration policy before cloning, since its Symbol is not JSON.
5807
+ if (result.ok || workflow?.schema_version !== 2
5808
+ || (qualityProfile || workflow.meta?.quality_profile) !== 'showcase'
5809
+ || preservesWorkflowAbsoluteGeometry(workflow)) return result;
5810
+
5811
+ let repairWorkflow;
5812
+ const widthOverrides = new Map();
5813
+ let minimumTick = 60;
5814
+ // Each of the 6.1..8.0 minimum-font ticks can trigger at most one batch.
5815
+ // Widths are fixed throughout a route/rank solver epoch; every new epoch
5816
+ // starts with empty geometry feedback and measures its own final canvas.
5817
+ // Only a complete measured receipt carries nodes; early diagnostic-only
5818
+ // failures must never start sizing feedback.
5819
+ while (!result.ok && result.receipt?.contract === 'readable-v2'
5820
+ && Array.isArray(result.receipt.nodes)) {
5821
+ const errors = result.diagnostics.filter(({ severity }) => severity !== 'warning');
5822
+ if (!errors.length || errors.some(({ code }) => code !== 'workflow/sublabel-readability')) break;
5823
+ const minimum = Math.max(...errors.map(({ evidence }) => evidence.requiredFontPx));
5824
+ const maximum = Math.min(...errors.map(({ evidence }) => evidence.maximumSlotFontPx));
5825
+ if (!Number.isFinite(minimum) || !Number.isFinite(maximum) || minimum > maximum) break;
5826
+ const tick = Math.round(minimum * 10);
5827
+ if (tick <= minimumTick || tick > 80) break;
5828
+ minimumTick = tick;
5829
+
5830
+ const measuredWidths = new Map(result.receipt.nodes.map(({ id, width }) => [id, width]));
5831
+ let changed = false;
5832
+ for (const node of workflow.nodes) {
5833
+ if (Number.isFinite(node.width) || !node.sublabel) continue;
5834
+ const currentWidth = measuredWidths.get(node.id);
5835
+ const needed = minimumNodeTextWidth(node.sublabel, minimum);
5836
+ if (needed <= availableNodeTextWidth(currentWidth)) continue;
5837
+ const padding = currentWidth - availableNodeTextWidth(currentWidth);
5838
+ const width = Math.min(200, Math.ceil((needed + padding) / 4) * 4);
5839
+ if (width <= currentWidth) continue;
5840
+ widthOverrides.set(node.id, width);
5841
+ changed = true;
5842
+ }
5843
+ if (!changed) break;
5844
+ repairWorkflow ||= cloneWorkflow(workflow);
5845
+ result = compileWithLayoutFeedback({ ...options, workflow: repairWorkflow, widthOverrides });
5846
+ }
5847
+ return result;
5848
+ }
5849
+
5850
+ function addVerifiedCrossingRepair(result, options) {
5851
+ const { workflow } = options;
5852
+ if (result.ok || workflow?.schema_version !== 2
5853
+ || preservesWorkflowAbsoluteGeometry(workflow)) return result;
5854
+ const errors = result.diagnostics.filter(({ severity }) => severity !== 'warning');
5855
+ if (!errors.length || errors.some(({ code }) => code !== 'composition/proper-crossing')) return result;
5856
+ const diagnostic = errors[0];
5857
+ const indexes = [diagnostic.subject?.index, diagnostic.evidence?.otherRelationship?.index];
5858
+ if (!indexes.every(index => Number.isInteger(index) && workflow.edges[index])) return result;
5859
+ const groups = indexes.map(index => ['fromSide', 'toSide', 'route']
5860
+ .filter(field => workflow.edges[index][field] && workflow.edges[index][field] !== 'auto')
5861
+ .map(field => ({ index, field })));
5862
+ // Try each complete set of optional side/preset constraints, then the pair.
5863
+ // At most three full compiler probes; do not search every field combination.
5864
+ const seen = new Set();
5865
+ for (const removals of [...groups, groups.flat()]) {
5866
+ const key = JSON.stringify(removals);
5867
+ if (!removals.length || seen.has(key)) continue;
5868
+ seen.add(key);
5869
+ const candidate = cloneWorkflow(workflow);
5870
+ for (const symbol of Object.getOwnPropertySymbols(workflow)) candidate[symbol] = workflow[symbol];
5871
+ for (const { index, field } of removals) delete candidate.edges[index][field];
5872
+ const verified = withDiagnosticRecordingSuppressed(() => compileWorkflowWithFeedback({
5873
+ ...options, workflow: candidate, discoverFixes: false,
5874
+ }));
5875
+ if (!verified.ok) continue;
5876
+ const changes = removals.map(({ index, field }) => ({ op: 'remove', path: `/edges/${index}/${field}` }));
5877
+ const fix = `if these endpoint sides and route presets are optional, remove ${changes.map(({ path }) => path).join(', ')} together; this complete workflow passes compilation with those edits (browser verification still required)`;
5878
+ diagnostic.evidence = {
5879
+ ...diagnostic.evidence,
5880
+ verifiedRepair: { scope: 'complete-workflow-compile', changes },
5881
+ };
5882
+ diagnostic.supportedFixes = [fix, ...diagnostic.supportedFixes];
5883
+ return result;
5884
+ }
5885
+ return result;
5886
+ }
5887
+
5888
+ function addVerifiedPlacementRepair(result, options) {
5889
+ const { workflow, qualityProfile } = options;
5890
+ if (result.ok || workflow?.schema_version !== 2
5891
+ || (qualityProfile || workflow.meta?.quality_profile) !== 'showcase'
5892
+ || preservesWorkflowAbsoluteGeometry(workflow)) return result;
5893
+ const errors = result.diagnostics.filter(({ severity }) => severity !== 'warning');
5894
+ if (!errors.length || errors.some(({ code, evidence }) =>
5895
+ !ROUTE_ORDER_CODES.has(code) || evidence?.verifiedRepair)) return result;
5896
+ const proposal = proposeWorkflowPlacement(workflow);
5897
+ if (!proposal.eligible || !proposal.changes.length) return result;
5898
+ const verified = withDiagnosticRecordingSuppressed(() => compileWorkflowWithFeedback({
5899
+ ...options, workflow: proposal.proposed, discoverFixes: false,
5900
+ }));
5901
+ if (!verified.ok) return result;
5902
+ const diagnostic = errors[0];
5903
+ diagnostic.evidence = {
5904
+ ...diagnostic.evidence,
5905
+ verifiedRepair: {
5906
+ scope: 'complete-workflow-compile', kind: 'optional-node-placement',
5907
+ changes: proposal.changes,
5908
+ },
5909
+ };
5910
+ diagnostic.supportedFixes = [
5911
+ 'if node columns and row offsets are flexible, apply the complete optional-node-placement changes in evidence.verifiedRepair together; this workflow then passes compilation with every node, relationship, lane, condition and mainPath preserved (browser verification and visual review still required)',
5912
+ ...diagnostic.supportedFixes,
5913
+ ];
5914
+ return result;
5915
+ }
5916
+
5917
+ export function compileWorkflow({ workflow, qualityProfile, sourceEvidence } = {}) {
5918
+ const options = { workflow, qualityProfile, sourceEvidence };
5919
+ return addVerifiedPlacementRepair(addVerifiedCrossingRepair(compileWorkflowWithFeedback(options), options), options);
4400
5920
  }