@stacksjs/defaults 0.75.360 → 0.75.362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-payments/SKILL.md +28 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ide/vscode/package.json +1 -1
  209. package/package.json +2 -2
@@ -0,0 +1,1605 @@
1
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
2
+ import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
3
+ import { throwDiagnosticProblems, withIsolatedDiagnosticRecording, rendererFailure } from '../shared/diagnostics.mjs';
4
+ import { resolveLegend, renderLegend as renderResolvedLegend, legendFootprint } from '../shared/legend.mjs';
5
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
6
+ import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
7
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
8
+ import { DECLARED_WIDE_READER_RATIO, minimumReadableSourceTextPx, predictedFixedWidthOverflow } from '../shared/desktop-readability.mjs';
9
+ import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
10
+ import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
11
+ import { repairLabelRoutes } from './joint-label-routing.mjs';
12
+ import { FLOW_LABEL_HEIGHT, flowLabelSize, wrapBlockedFlowLabels } from './label-capacity.mjs';
13
+ import {
14
+ asArray,
15
+ isFinitePoint,
16
+ rectsOverlap,
17
+ cleanEndpointSideProblems,
18
+ cleanFlowProblems,
19
+ cleanCrossingProblems,
20
+ collectAmbiguousCorridors,
21
+ collectBorderRuns,
22
+ cleanAmbiguousCorridorProblems,
23
+ cleanBorderRunProblems,
24
+ frameBorderSegments,
25
+ cleanRouteRhythmProblems,
26
+ collectRouteRhythmIssues,
27
+ cleanLabelRouteClearanceProblems,
28
+ cleanLabelCanvasContainmentProblems,
29
+ suggestLabelObstacleFix,
30
+ suggestLabelPairFix,
31
+ anchor,
32
+ automaticPortSpread,
33
+ alignAutomaticFacingPorts,
34
+ automaticPortRhythmBridge,
35
+ segmentIntersectsRect,
36
+ segmentRectClearanceWithin,
37
+ routeHonorsEndpointSides,
38
+ normalizeRoutePoints,
39
+ legacyDefaultFromSide as defaultFromSide,
40
+ legacyDefaultToSide as defaultToSide,
41
+ chosenSide,
42
+ polylinePath,
43
+ routePointsValue,
44
+ authoredStraightRouteAttrs,
45
+ labelPoint as legacyLabelPoint,
46
+ componentFill,
47
+ componentText,
48
+ arrowClassMap,
49
+ edgeLabelAccent,
50
+ properSegmentIntersection
51
+ } from '../shared/geometry.mjs';
52
+
53
+ const nodeTextFit = {
54
+ sublabelPreferred: 7,
55
+ sublabelMinimum: 6,
56
+ tagPreferred: 7,
57
+ tagMinimum: 6,
58
+ };
59
+
60
+ // All geometry is local to an attempt. File publication and source loading
61
+ // stay in the CLI renderer and happen once, after a complete valid SVG exists.
62
+ export function compileDataflow({ dataflow, sourceEvidence, qualityProfile: requestedQualityProfile }) {
63
+ function build(rebalancePorts) {
64
+ const layout = {
65
+ stageY: 46,
66
+ stageH: 36,
67
+ stageBottomPad: 74,
68
+ leftX: 100,
69
+ colGap: 215,
70
+ stageW: 168,
71
+ nodeW: 112,
72
+ nodeH: 58,
73
+ rowYs: [128, 242, 356, 470, 584],
74
+ labelH: FLOW_LABEL_HEIGHT
75
+ };
76
+ // Mirrors the authored minimum in schemas/dataflow.schema.json: a repair that
77
+ // would need a node shorter than the schema allows cannot be offered.
78
+ const minimumNodeHeight = 36;
79
+
80
+ // Explicit geometry preserves established node sizing, typography, height,
81
+ // routing and label placement. An omitted canvas width still fits its content
82
+ // in either profile, including authored node widths.
83
+ const qualityProfile = requestedQualityProfile;
84
+ const automaticShowcase = qualityProfile === 'showcase'
85
+ && dataflow.meta?.viewBox === undefined
86
+ && !asArray(dataflow.nodes).some(node => node.width !== undefined);
87
+ const stageRight = stageX(asArray(dataflow.stages).length - 1) + layout.stageW / 2;
88
+ const nodeRights = asArray(dataflow.nodes).map(node =>
89
+ stageX(node.stage) + (node.width || layout.nodeW) / 2).filter(Number.isFinite);
90
+ const pinnedLabelRights = asArray(dataflow.flows).filter(flow => flow.label && Array.isArray(flow.labelAt) && isFinitePoint(...flow.labelAt))
91
+ .map(flow => flow.labelAt[0] + flowLabelSize(flow).width / 2);
92
+ // Automatic width fits stages/nodes with 24px right pad. The old 940px floor
93
+ // left a blank third of the canvas on 2–3 stage drafts; five default stages
94
+ // still measure ~1068 and stay unchanged. Height starts at 720 and is refit
95
+ // by heightForLabels for automatic showcase.
96
+ const contentWidth = Math.ceil(Math.max(stageRight, ...nodeRights, ...pinnedLabelRights, 0) + 24);
97
+ const viewBox = dataflow.meta?.viewBox || [
98
+ Math.max(480, contentWidth),
99
+ 720,
100
+ ];
101
+ const contextFontMinimum = automaticShowcase
102
+ ? Math.max(6, Math.ceil(minimumReadableSourceTextPx(viewBox[0]) * 10) / 10)
103
+ : 6;
104
+
105
+ function stageX(index) {
106
+ return layout.leftX + index * layout.colGap;
107
+ }
108
+
109
+ function stageFrame(stage, index) {
110
+ return {
111
+ id: index,
112
+ label: stage.label,
113
+ kind: 'stage',
114
+ x: stageX(index) - layout.stageW / 2,
115
+ y: layout.stageY,
116
+ width: layout.stageW,
117
+ height: viewBox[1] - layout.stageY - layout.stageBottomPad,
118
+ radius: 10,
119
+ };
120
+ }
121
+
122
+ function measureNode(node) {
123
+ const cx = stageX(node.stage);
124
+ // Expand only unpinned showcase nodes, within their existing stage budget.
125
+ // Very long text still gets an actionable error instead of an unbounded
126
+ // canvas/typography feedback loop.
127
+ const contextWidth = Math.max(0, ...[node.sublabel, node.tag].map(text =>
128
+ text ? minimumNodeTextWidth(text, Math.max(7, contextFontMinimum)) + 8 : 0));
129
+ const width = node.width || (automaticShowcase
130
+ ? Math.max(layout.nodeW, Math.min(layout.stageW,
131
+ (cx - 24) * 2, (viewBox[0] - 24 - cx) * 2, Math.ceil(contextWidth)))
132
+ : layout.nodeW);
133
+ const height = node.height || layout.nodeH;
134
+ const y = layout.rowYs[node.row] + (node.yOffset || 0);
135
+ return {
136
+ ...node,
137
+ width,
138
+ height,
139
+ cx,
140
+ cy: y + height / 2,
141
+ x: cx - width / 2,
142
+ y
143
+ };
144
+ }
145
+
146
+ const nodes = new Map(asArray(dataflow.nodes).map((node) => [node.id, measureNode(node)]));
147
+ // Move one outgoing arrow off a crowded mixed side when its
148
+ // perpendicular exit has capacity and reaches the target with a clear corner.
149
+ const hasPinnedFlowGeometry = flow => flow.via || (flow.route && flow.route !== 'auto')
150
+ || ['fromSide', 'toSide', 'channelX', 'channelY', 'labelAt', 'labelDx', 'labelDy', 'labelSegment']
151
+ .some(key => flow[key] !== undefined);
152
+ // Arrowheads are seven stroke-width units tall. Reserve two units of
153
+ // clear ink within the automatic spread's existing 18px spacing limit.
154
+ const portSpan = items => 32 + Math.max(0, items.length - 1) * Math.min(18,
155
+ Math.ceil(Math.max(0, ...items.map(item => item.width * (item.incoming ? 7 : 1)))) + 2);
156
+
157
+ const automaticSourceSides = new Map();
158
+ if (automaticShowcase && rebalancePorts) {
159
+ const flows = asArray(dataflow.flows);
160
+ for (const node of nodes.values()) {
161
+ const authored = dataflow.nodes.find(item => item.id === node.id);
162
+ if (authored.height !== undefined || authored.yOffset !== undefined) continue;
163
+ const incident = flows.filter(flow => flow.from === node.id || flow.to === node.id);
164
+ if (incident.some(hasPinnedFlowGeometry)) continue;
165
+ const sides = { left: [], right: [], top: [], bottom: [] };
166
+ for (const flow of incident) {
167
+ const from = nodes.get(flow.from), to = nodes.get(flow.to);
168
+ if (!from || !to) continue;
169
+ const incoming = flow.to === node.id;
170
+ const side = incoming ? defaultToSide(from, to)
171
+ : automaticSourceSides.get(flow) || defaultFromSide(from, to);
172
+ sides[side].push({ flow, incoming, width: flowStrokeWidth(flow) });
173
+ }
174
+ const choices = [];
175
+ for (const side of ['left', 'right']) {
176
+ const items = sides[side];
177
+ if (!items.some(item => item.incoming) || portSpan(items) <= node.height) continue;
178
+ for (const item of items.filter(item => !item.incoming)) {
179
+ const target = nodes.get(item.flow.to), dy = target.cy - node.cy;
180
+ if (Math.abs(dy) < (node.height + target.height) / 2 + 16) continue;
181
+ const alternate = dy > 0 ? 'bottom' : 'top';
182
+ if (portSpan([...sides[alternate], item]) > node.width) continue;
183
+ const toSide = defaultToSide(node, target);
184
+ if (toSide !== 'left' && toSide !== 'right') continue;
185
+ const start = anchor(node, alternate), end = anchor(target, toSide);
186
+ const points = normalizeRoutePoints([start, [start[0], end[1]], end]);
187
+ if (!routeHonorsEndpointSides(points, alternate, toSide)) continue;
188
+ if ([...nodes.values()].some(other => points.slice(1).some((point, index) =>
189
+ segmentIntersectsRect({ start: points[index], end: point }, other, 2)
190
+ && !((other.id === node.id && index === 0)
191
+ || (other.id === target.id && index === points.length - 2))))) continue;
192
+ choices.push({ flow: item.flow, alternate,
193
+ distance: Math.abs(start[0] - end[0]) + Math.abs(start[1] - end[1]) });
194
+ }
195
+ }
196
+ choices.sort((a, b) => a.distance - b.distance);
197
+ if (choices.length) automaticSourceSides.set(choices[0].flow, choices[0].alternate);
198
+ }
199
+ }
200
+
201
+ if (rebalancePorts && !automaticSourceSides.size) return null;
202
+
203
+ // Seven incoming arrows cannot fit on a 58px box after the corner gutters.
204
+ // Use spare row space around its centre, so clear same-row outputs stay
205
+ // straight. Explicit geometry and incident route/label pins stay authoritative.
206
+ if (automaticShowcase) {
207
+ const flows = asArray(dataflow.flows);
208
+ const originalBoxes = [...nodes.values()].map(node => ({ ...node }));
209
+ const rowBudget = Math.min(...layout.rowYs.slice(1).map((y, index) => y - layout.rowYs[index])) - 10;
210
+ for (const authored of asArray(dataflow.nodes)) {
211
+ if (authored.height !== undefined || authored.yOffset !== undefined) continue;
212
+ const node = nodes.get(authored.id);
213
+ const incident = flows.filter(flow => flow.from === node.id || flow.to === node.id);
214
+ if (incident.some(hasPinnedFlowGeometry)) continue;
215
+ const sides = { left: [], right: [] };
216
+ for (const flow of incident) {
217
+ if (!nodes.has(flow.from) || !nodes.has(flow.to)) continue;
218
+ const { fromSide, toSide } = flowSides(flow);
219
+ if (flow.from === node.id) sides[fromSide]?.push({ incoming: false, width: flowStrokeWidth(flow) });
220
+ if (flow.to === node.id) sides[toSide]?.push({ incoming: true, width: flowStrokeWidth(flow) });
221
+ }
222
+ const desired = Math.max(node.height, ...Object.values(sides)
223
+ .filter(items => items.some(item => item.incoming)).map(portSpan));
224
+ const neighbours = originalBoxes.filter(other => other.id !== node.id
225
+ && other.x < node.x + node.width + 10 && other.x + other.width + 10 > node.x);
226
+ const height = Math.min(desired, rowBudget,
227
+ 2 * (node.cy - (layout.stageY + layout.stageH + 22)),
228
+ ...neighbours.map(other => {
229
+ const distance = Math.abs(other.cy - node.cy);
230
+ // Split spare space with another growing box, and also respect a
231
+ // taller authored neighbour. Use original bounds for stable ordering.
232
+ return Math.min(distance - 10, 2 * distance - other.height - 20);
233
+ }));
234
+ if (height > node.height) {
235
+ node.autoHeightInset = (height - node.height) / 2;
236
+ node.y -= node.autoHeightInset;
237
+ node.height = height;
238
+ }
239
+ }
240
+ }
241
+ const nodeSteps = new Map();
242
+ for (const [index, flow] of asArray(dataflow.flows).entries()) {
243
+ if (!nodeSteps.has(flow.from)) nodeSteps.set(flow.from, index);
244
+ if (!nodeSteps.has(flow.to)) nodeSteps.set(flow.to, index + 1);
245
+ }
246
+ for (const [index, node] of asArray(dataflow.nodes).entries()) {
247
+ if (!nodeSteps.has(node.id)) nodeSteps.set(node.id, index);
248
+ }
249
+
250
+ function validateDataflow() {
251
+ const problems = [];
252
+ const diagnostics = [];
253
+ if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
254
+
255
+ const stageCount = asArray(dataflow.stages).length;
256
+ for (const node of nodes.values()) {
257
+ if (typeof node.stage !== 'number' || node.stage < 0 || node.stage >= stageCount) {
258
+ problems.push(`Node "${node.id}" uses invalid stage ${node.stage} — valid stages are 0..${stageCount - 1}.`);
259
+ }
260
+ if (typeof node.row !== 'number' || node.row < 0 || node.row >= layout.rowYs.length) {
261
+ problems.push(`Node "${node.id}" uses invalid row ${node.row} — valid rows are 0..${layout.rowYs.length - 1}.`);
262
+ }
263
+ if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
264
+ problems.push(`Node "${node.id}" produced non-finite coordinates — check stage, row, width, height, and yOffset are numbers.`);
265
+ continue;
266
+ }
267
+ if (node.x < 24 || node.x + node.width > viewBox[0] - 24) {
268
+ problems.push(`Node "${node.id}" exceeds the horizontal bounds of the viewBox — reduce node.width or increase meta.viewBox[0].`);
269
+ }
270
+ const diagramAreaTop = layout.stageY + layout.stageH + 22;
271
+ const diagramAreaBottom = viewBox[1] - layout.stageBottomPad;
272
+ // y is the node's top edge, so the top bound moves only with yOffset —
273
+ // height cannot repair it. Both bounds are reported independently, because
274
+ // a node tall enough to cross the whole area violates both at once.
275
+ if (node.y < diagramAreaTop) {
276
+ const message = `Node "${node.id}" starts above the readable diagram area — keep y at or above ${diagramAreaTop}.`;
277
+ diagnostics.push({
278
+ code: 'dataflow/node-above-area',
279
+ severity: 'error',
280
+ message,
281
+ subject: { diagramType: 'dataflow', node: node.id },
282
+ evidence: { y: node.y, areaTop: diagramAreaTop, yOffset: node.yOffset || 0 },
283
+ supportedFixes: [
284
+ `raise or remove the negative yOffset on node "${node.id}"`,
285
+ ],
286
+ });
287
+ problems.push(message);
288
+ }
289
+ if (node.y + node.height > diagramAreaBottom) {
290
+ const nodeBottom = node.y + node.height;
291
+ const requiredViewBoxHeight = nodeBottom + layout.stageBottomPad;
292
+ // Only offer a repair that can clear this bound on its own. Lowering
293
+ // yOffset moves the whole node, which reaches the bound only while the
294
+ // node is short enough to fit between them at all; reducing height moves
295
+ // only the bottom edge, and only reaches the bound while a node short
296
+ // enough to do it is still legal to author.
297
+ const supportedFixes = [];
298
+ const roomForNode = diagramAreaBottom - diagramAreaTop;
299
+ // The height that clears the bound at the node's current y. The room
300
+ // between the bounds is the cap only while the node can still move; once
301
+ // it sits below the area, the cap is what remains below it.
302
+ const heightCapAtY = Math.min(roomForNode, diagramAreaBottom - node.y);
303
+ if (node.height <= roomForNode) {
304
+ supportedFixes.push(`lower yOffset on node "${node.id}"`);
305
+ }
306
+ if (heightCapAtY >= minimumNodeHeight) {
307
+ supportedFixes.push(`reduce node "${node.id}" height to at most ${heightCapAtY}`);
308
+ }
309
+ // A dataflow canvas with an authored meta.viewBox gets no readable-scroll
310
+ // exception, so the raised canvas is offered as a standalone repair only
311
+ // while the delivered page still fits the target viewport — the same
312
+ // page-fit math the checker applies for composition/viewport-height. The
313
+ // raise is bounded above too: growing the height lowers the canvas ratio,
314
+ // and under the wide threshold the Reader can no longer narrow the page,
315
+ // so advice that stopped at the minimum let an author overshoot into a
316
+ // certain visual-check failure.
317
+ const raisedOverflow = predictedFixedWidthOverflow({
318
+ viewBoxWidth: viewBox[0],
319
+ viewBoxHeight: requiredViewBoxHeight,
320
+ readerFit: null,
321
+ diagramType: null,
322
+ });
323
+ const raisedCanvasFits = raisedOverflow === null;
324
+ let maximumViewBoxHeight = Math.floor(viewBox[0] / DECLARED_WIDE_READER_RATIO);
325
+ // The ratio boundary can land between integers; step down until the same
326
+ // predicate the checker uses accepts the height.
327
+ while (maximumViewBoxHeight > 1 && predictedFixedWidthOverflow({
328
+ viewBoxWidth: viewBox[0],
329
+ viewBoxHeight: maximumViewBoxHeight,
330
+ readerFit: null,
331
+ diagramType: null,
332
+ }) !== null) {
333
+ maximumViewBoxHeight -= 1;
334
+ }
335
+ const raiseTarget = maximumViewBoxHeight > requiredViewBoxHeight
336
+ ? `to at least ${requiredViewBoxHeight} and at most ${maximumViewBoxHeight}`
337
+ : `to ${requiredViewBoxHeight}`;
338
+ if (raisedCanvasFits) {
339
+ supportedFixes.push(`raise meta.viewBox[1] ${raiseTarget}, then rerun deliver and visual-check`);
340
+ }
341
+ // The bound belongs to y + height, so name the y that satisfies it. The
342
+ // unadjusted "keep y between 104 and areaBottom" let an author follow the
343
+ // message exactly and still fail.
344
+ const extent = node.height <= roomForNode
345
+ ? `keep y within [${diagramAreaTop}, ${diagramAreaBottom - node.height}] so that y + height does not pass ${diagramAreaBottom}`
346
+ : heightCapAtY >= minimumNodeHeight
347
+ ? `it is taller than the area from y = ${diagramAreaTop} to y = ${diagramAreaBottom}, so no yOffset can fit it — reduce node height to at most ${heightCapAtY} and keep y within [${diagramAreaTop}, ${diagramAreaBottom - heightCapAtY}] so that y + height does not pass ${diagramAreaBottom}`
348
+ : `it is taller than the area from y = ${diagramAreaTop} to y = ${diagramAreaBottom} and sits too low for any legal height, so no single change clears this bound — reduce node height to at most ${roomForNode} and move it up so that y + height does not pass ${diagramAreaBottom}`;
349
+ const raiseClause = raisedCanvasFits
350
+ ? ` Raising meta.viewBox[1] ${raiseTarget} also fits it and still fits the target viewport.`
351
+ : ` Raising meta.viewBox[1] to ${requiredViewBoxHeight} would fit the node, but the page then reaches at least ~${raisedOverflow.pageHeightPx}px in the 1440x900 viewport, so the raise cannot stand on its own — compact the node instead.`;
352
+ const message = `Node "${node.id}" exceeds the readable diagram area — ${extent}.${raiseClause}`;
353
+ diagnostics.push({
354
+ code: 'dataflow/node-below-area',
355
+ severity: 'error',
356
+ message,
357
+ subject: { diagramType: 'dataflow', node: node.id },
358
+ evidence: {
359
+ y: node.y,
360
+ height: node.height,
361
+ areaBottom: diagramAreaBottom,
362
+ requiredViewBoxHeight,
363
+ requiredViewBoxFitsViewport: raisedCanvasFits,
364
+ maximumViewBoxHeight,
365
+ ...(raisedCanvasFits ? {} : { requiredViewBoxPageHeightPx: raisedOverflow.pageHeightPx }),
366
+ viewBoxHeight: viewBox[1],
367
+ },
368
+ supportedFixes,
369
+ });
370
+ problems.push(message);
371
+ }
372
+ const estLabelW = textUnits(node.label) * 6.2;
373
+ if (estLabelW > node.width + 6) {
374
+ 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.`);
375
+ }
376
+ const brandRailProblem = brandTopRailProblem(node, node.width, 8);
377
+ if (brandRailProblem) problems.push(brandRailProblem);
378
+ // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
379
+ // handles the ordinary case, this rejects what it cannot rescue.
380
+ const availableTextW = availableNodeTextWidth(node.width);
381
+ for (const [field, value, minimum] of [
382
+ ['Sublabel', node.sublabel, contextFontMinimum],
383
+ ['Tag', node.tag, contextFontMinimum],
384
+ ]) {
385
+ if (!value) continue;
386
+ const minimumW = minimumNodeTextWidth(value, minimum);
387
+ if (minimumW > availableTextW) {
388
+ 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.`);
389
+ }
390
+ }
391
+ }
392
+
393
+ const nodeList = asArray(dataflow.nodes);
394
+ for (let i = 0; i < nodeList.length; i += 1) {
395
+ for (let j = i + 1; j < nodeList.length; j += 1) {
396
+ const a = nodes.get(nodeList[i].id);
397
+ const b = nodes.get(nodeList[j].id);
398
+ if (rectsOverlap(a, b, 10)) {
399
+ problems.push(`Nodes "${a.id}" and "${b.id}" are less than 10px apart — move one to another stage/row or adjust yOffset.`);
400
+ }
401
+ }
402
+ }
403
+
404
+ const flowList = asArray(dataflow.flows);
405
+ const stageById = new Map(asArray(dataflow.nodes).map((node) => [node.id, node.stage]));
406
+ for (const flow of flowList) {
407
+ const flowIndex = flowList.indexOf(flow);
408
+ for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
409
+ if (nodes.has(flow[field])) continue;
410
+ const problem = `Flow "${flow.label || flow[field]}" references unknown ${endpoint} "${flow[field]}".`;
411
+ const anchorStage = stageById.get(flow[field === 'from' ? 'to' : 'from']);
412
+ const anchorId = flow[field === 'from' ? 'to' : 'from'];
413
+ const stageDelta = (id) => (anchorStage === undefined || stageById.get(id) === undefined
414
+ ? 0
415
+ : field === 'to'
416
+ ? stageById.get(id) - anchorStage
417
+ : anchorStage - stageById.get(id));
418
+ const candidates = [...stageById.keys()]
419
+ .filter((id) => id !== anchorId)
420
+ .sort((a, b) => {
421
+ const da = stageDelta(a);
422
+ const db = stageDelta(b);
423
+ const forwardDiff = (db > 0) - (da > 0);
424
+ return forwardDiff !== 0 ? forwardDiff : da - db;
425
+ });
426
+ diagnostics.push({
427
+ code: 'dataflow/unknown-endpoint', severity: 'error', message: problem,
428
+ subject: {
429
+ diagramType: 'dataflow',
430
+ flow: flow.id ?? null,
431
+ path: `/flows/${flowIndex}/${field}`,
432
+ from: flow.from,
433
+ to: flow.to,
434
+ },
435
+ evidence: { endpoint, unknownNodeId: flow[field], availableNodeIds: candidates },
436
+ supportedFixes: candidates.slice(0, 3).map((id) => `set /flows/${flowIndex}/${field} to verified node id "${id}"`),
437
+ });
438
+ problems.push(problem);
439
+ }
440
+ if (!flow.label) problems.push(`Flow "${flow.from}" -> "${flow.to}" must include a short data label.`);
441
+ if (nodes.has(flow.from) && nodes.has(flow.to)) {
442
+ const routed = pathFor(flow);
443
+ const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
444
+ const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
445
+ if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) — route it through a channel or spread its nodes.`);
446
+ if (Array.isArray(flow.via)) {
447
+ for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
448
+ const segmentStart = routed.points[segmentIndex];
449
+ const segmentEnd = routed.points[segmentIndex + 1];
450
+ const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
451
+ && Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
452
+ if (!isDiagonal) continue;
453
+ const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
454
+ problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) — align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ problems.push(...cleanEndpointSideProblems({
461
+ relations: dataflow.flows,
462
+ endpointIds: new Set(nodes.keys()),
463
+ pathFor,
464
+ diagramType: 'dataflow',
465
+ relationCollection: 'flows',
466
+ fromSideFor: (flow) => flowSides(flow).fromSide,
467
+ toSideFor: (flow) => flowSides(flow).toSide,
468
+ routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
469
+ }));
470
+ problems.push(...cleanFlowProblems({
471
+ relations: dataflow.flows,
472
+ obstacles: nodes.values(),
473
+ pathFor,
474
+ diagramType: 'dataflow',
475
+ relationCollection: 'flows',
476
+ obstacleKind: 'node',
477
+ routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
478
+ }));
479
+ problems.push(...cleanCrossingProblems({
480
+ relations: dataflow.flows,
481
+ endpointIds: new Set(nodes.keys()),
482
+ pathFor,
483
+ diagramType: 'dataflow',
484
+ relationCollection: 'flows',
485
+ profile: dataflow.meta?.quality_profile,
486
+ crossingResolved: (left, right) => resolvedCrossoverFlows.has(left) && resolvedCrossoverFlows.has(right),
487
+ routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
488
+ }));
489
+ problems.push(...cleanAmbiguousCorridorProblems({
490
+ relations: dataflow.flows,
491
+ endpointIds: new Set(nodes.keys()),
492
+ pathFor,
493
+ diagramType: 'dataflow',
494
+ relationCollection: 'flows',
495
+ profile: dataflow.meta?.quality_profile,
496
+ routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
497
+ }));
498
+ problems.push(...cleanBorderRunProblems({
499
+ relations: dataflow.flows,
500
+ endpointIds: new Set(nodes.keys()),
501
+ frames: compositionFrames,
502
+ pathFor,
503
+ diagramType: 'dataflow',
504
+ relationCollection: 'flows',
505
+ profile: dataflow.meta?.quality_profile,
506
+ routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
507
+ }));
508
+ problems.push(...cleanRouteRhythmProblems({
509
+ relations: dataflow.flows,
510
+ endpointIds: new Set(nodes.keys()),
511
+ pathFor,
512
+ diagramType: 'dataflow',
513
+ relationCollection: 'flows',
514
+ profile: dataflow.meta?.quality_profile,
515
+ routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
516
+ }));
517
+
518
+ const labelRects = resolvedLabelRects;
519
+ for (const rect of labelRects) {
520
+ for (const node of nodes.values()) {
521
+ if (rectsOverlap(rect, node, -2)) {
522
+ 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())}`);
523
+ }
524
+ }
525
+ }
526
+ for (let i = 0; i < labelRects.length; i += 1) {
527
+ for (let j = i + 1; j < labelRects.length; j += 1) {
528
+ if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
529
+ problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
530
+ }
531
+ }
532
+ }
533
+ problems.push(...cleanLabelRouteClearanceProblems({
534
+ relations: dataflow.flows,
535
+ labels: labelRects,
536
+ endpointIds: new Set(nodes.keys()),
537
+ pathFor,
538
+ diagramType: 'dataflow',
539
+ relationCollection: 'flows',
540
+ profile: dataflow.meta?.quality_profile,
541
+ routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
542
+ }));
543
+ problems.push(...cleanLabelCanvasContainmentProblems({
544
+ labels: labelRects,
545
+ viewBox,
546
+ diagramType: 'dataflow',
547
+ relationCollection: 'flows',
548
+ profile: dataflow.meta?.quality_profile,
549
+ }));
550
+
551
+ const lastStageX = stageX(asArray(dataflow.stages).length - 1);
552
+ if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
553
+ problems.push(`Stages exceed viewBox width — set meta.viewBox[0] to at least ${Math.ceil(lastStageX + layout.stageW / 2 + 24)}.`);
554
+ }
555
+
556
+ if (problems.length) {
557
+ throwDiagnosticProblems('Data-flow layout validation failed', problems, {
558
+ subject: { diagramType: 'dataflow' },
559
+ diagnostics,
560
+ });
561
+ }
562
+ }
563
+
564
+ function supportsAutomaticCrossover(flow) {
565
+ return automaticShowcase && !flow.via && (!flow.route || flow.route === 'auto')
566
+ && flow.channelX === undefined && flow.channelY === undefined;
567
+ }
568
+
569
+ function routeVia(flow, from, to, start, end) {
570
+ if (flow.via) return flow.via;
571
+ switch (flow.route || 'auto') {
572
+ case 'straight':
573
+ return [];
574
+ case 'vertical-channel': {
575
+ const x = flow.channelX ?? start[0] + (end[0] > start[0] ? 44 : -44);
576
+ return [[x, start[1]], [x, end[1]]];
577
+ }
578
+ case 'bottom-channel': {
579
+ const y = flow.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 26;
580
+ return [[start[0], y], [end[0], y]];
581
+ }
582
+ case 'top-channel': {
583
+ const y = flow.channelY ?? Math.min(from.y, to.y) - 24;
584
+ return [[start[0], y], [end[0], y]];
585
+ }
586
+ case 'auto':
587
+ default: {
588
+ const { fromSide, toSide } = flowSides(flow);
589
+ const fromVertical = fromSide === 'top' || fromSide === 'bottom';
590
+ const toVertical = toSide === 'top' || toSide === 'bottom';
591
+ const acrossDelta = fromVertical && toVertical
592
+ ? Math.abs(start[0] - end[0]) : Math.abs(start[1] - end[1]);
593
+ if (automaticShowcase && acrossDelta > 0.0001 && acrossDelta < 16) {
594
+ // Spread ports can differ by only 7px/14px. Reuse the bounded bridge
595
+ // rather than emit a midpoint turn below our own rhythm floor.
596
+ const bridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
597
+ accept: points => ![...nodes.values()].some(node => (
598
+ node.id !== from.id && node.id !== to.id && points.slice(1).some((point, index) =>
599
+ segmentIntersectsRect({ start: points[index], end: point }, node, 2))
600
+ )),
601
+ });
602
+ if (bridge) return bridge.slice(1, -1);
603
+ }
604
+ if (fromVertical !== toVertical) {
605
+ // Perpendicular sides meet at one corner when both endpoints face it;
606
+ // otherwise each leaves through a short stub before the corner.
607
+ const outward = { left: [-24, 0], right: [24, 0], top: [0, -24], bottom: [0, 24] };
608
+ const stub = (point, side) => [point[0] + outward[side][0], point[1] + outward[side][1]];
609
+ const startStub = stub(start, fromSide);
610
+ const endStub = stub(end, toSide);
611
+ const route = [
612
+ [fromVertical ? [start[0], end[1]] : [end[0], start[1]]],
613
+ [startStub, [startStub[0], endStub[1]], endStub],
614
+ [startStub, [endStub[0], startStub[1]], endStub],
615
+ ].find(candidate => {
616
+ const points = normalizeRoutePoints([start, ...candidate, end]);
617
+ return routeHonorsEndpointSides(points, fromSide, toSide)
618
+ && ![...nodes.values()].some(node => points.slice(1).some((point, index) => (
619
+ segmentIntersectsRect({ start: points[index], end: point }, node, 2)
620
+ && !((node.id === from.id && index === 0) || (node.id === to.id && index === points.length - 2)))));
621
+ });
622
+ if (route) return route;
623
+ }
624
+ if (automaticShowcase && fromVertical && toVertical) {
625
+ if (Math.abs(start[0] - end[0]) < 0.0001) return [];
626
+ const midY = (start[1] + end[1]) / 2;
627
+ return [[start[0], midY], [end[0], midY]];
628
+ }
629
+ if (Math.abs(start[1] - end[1]) < 4) return [];
630
+ const midX = start[0] + (end[0] - start[0]) / 2;
631
+ // Across several stages the midpoint can fall inside a middle stage's
632
+ // node; then turn in the clear inter-stage gap nearest to it.
633
+ const gapXs = asArray(dataflow.stages).slice(1).map((_, index) => (stageX(index) + stageX(index + 1)) / 2)
634
+ .filter((x) => x > Math.min(start[0], end[0]) && x < Math.max(start[0], end[0]))
635
+ .sort((left, right) => Math.abs(left - midX) - Math.abs(right - midX));
636
+ const turnX = [midX, ...gapXs].find((x) => {
637
+ const points = [start, [x, start[1]], [x, end[1]], end];
638
+ return ![...nodes.values()].some((node) => node.id !== from.id && node.id !== to.id
639
+ && points.slice(1).some((point, index) => segmentIntersectsRect({ start: points[index], end: point }, node, 2)));
640
+ }) ?? midX;
641
+ return [[turnX, start[1]], [turnX, end[1]]];
642
+ }
643
+ }
644
+ }
645
+
646
+ const pathCache = new Map();
647
+
648
+ function flowSides(flow) {
649
+ const from = nodes.get(flow.from);
650
+ const to = nodes.get(flow.to);
651
+ return {
652
+ fromSide: chosenSide(flow.fromSide, automaticSourceSides.get(flow) || defaultFromSide(from, to)),
653
+ toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
654
+ };
655
+ }
656
+
657
+ // The shared default of 14px packs two 10px arrowheads on top of each other.
658
+ // Raise the ceiling so a side that has room spreads them; a short side still
659
+ // clamps to usable/(n-1) and keeps the previous layout.
660
+ const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
661
+ sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
662
+ maxSpacing: 18,
663
+ });
664
+
665
+ function pathFor(flow) {
666
+ if (pathCache.has(flow)) return pathCache.get(flow);
667
+ const from = nodes.get(flow.from);
668
+ const to = nodes.get(flow.to);
669
+ const ports = automaticPorts.get(flow);
670
+ const { fromSide, toSide } = flowSides(flow);
671
+ let start = ports?.from || anchor(from, fromSide);
672
+ let end = ports?.to || anchor(to, toSide);
673
+ if (automaticShowcase && flow.labelDx === undefined && flow.labelDy === undefined && flow.labelSegment === undefined) {
674
+ const aligned = alignAutomaticFacingPorts(flow, from, to, start, end, fromSide, toSide, ports, {
675
+ accept: points => {
676
+ if ([...nodes.values()].some(node => node.id !== from.id && node.id !== to.id
677
+ && segmentIntersectsRect({ start: points[0], end: points[1] }, node, 2))) return false;
678
+ // A pinned route still occupies its endpoint. Check both ends: on a
679
+ // short crowded side, keeping the bridge also preserves space for
680
+ // labels between neighbouring branches.
681
+ for (const [rect, side, point] of [
682
+ [from, fromSide, points[0]], [to, toSide, points[1]],
683
+ ]) {
684
+ for (const other of asArray(dataflow.flows)) {
685
+ if (other === flow || !nodes.has(other.from) || !nodes.has(other.to)) continue;
686
+ for (const field of ['from', 'to']) {
687
+ if (other[field] !== rect.id || flowSides(other)[field === 'from' ? 'fromSide' : 'toSide'] !== side) continue;
688
+ const occupied = automaticPorts.get(other)?.[field] || anchor(rect, side);
689
+ if (Math.hypot(point[0] - occupied[0], point[1] - occupied[1]) < 18 - 0.0001) return false;
690
+ }
691
+ }
692
+ }
693
+ return true;
694
+ },
695
+ });
696
+ start = aligned.start;
697
+ end = aligned.end;
698
+ }
699
+ // Drop consecutive duplicate points so a purely vertical (or horizontal)
700
+ // auto-route never emits a zero-length final segment — SVG derives
701
+ // marker-end orientation from the last segment, and a degenerate segment
702
+ // leaves the arrowhead angle undefined (see #169).
703
+ const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
704
+ const points = [];
705
+ for (const p of rawPoints) {
706
+ const prev = points.at(-1);
707
+ if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
708
+ points.push(p);
709
+ }
710
+ }
711
+ // Guard against an all-degenerate route (e.g. start === end): keep both
712
+ // endpoints so the path is still well-formed even if the marker is hidden.
713
+ if (points.length < 2) points.push(end);
714
+ const routed = { d: polylinePath(points), points };
715
+ pathCache.set(flow, routed);
716
+ return routed;
717
+ }
718
+
719
+ const resolvedCrossoverFlows = new Set();
720
+ const routable = asArray(dataflow.flows).filter(flow => nodes.has(flow.from) && nodes.has(flow.to));
721
+ const segmentsOf = points => points.slice(1).map((end, index) => ({ start: points[index], end }));
722
+ const related = (left, right) => [left.from, left.to].some(id => id === right.from || id === right.to);
723
+ const crosses = (left, right) => segmentsOf(pathFor(left).points).some(a => segmentsOf(pathFor(right).points).some(b => (
724
+ properSegmentIntersection(a.start, a.end, b.start, b.end))));
725
+ const unrelated = flow => routable.filter(other => other !== flow && !related(flow, other));
726
+ const length = points => segmentsOf(points).reduce((sum, { start, end }) => sum + Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]), 0);
727
+ const DETOUR_NODE_GAP = 12;
728
+ const ENDPOINT_STUB = 24;
729
+ function intrudesNode(flow, points) {
730
+ return [...nodes.values()].some(node => segmentsOf(points).some((segment, index) => (
731
+ segmentIntersectsRect(segment, node, 2)
732
+ && !((node.id === flow.from && index === 0) || (node.id === flow.to && index === points.length - 2))
733
+ )));
734
+ }
735
+ function sharesCorridor(flow, points, deferred = new Set()) {
736
+ return unrelated(flow).some(other => {
737
+ const occupied = deferred.has(other)
738
+ ? endpointStubs(other).map(({ start, end }) => [start, end])
739
+ : [pathFor(other).points];
740
+ return occupied.some(otherPoints => collectAmbiguousCorridors({
741
+ routedRelations: [{ relation: flow, points }, { relation: other, points: otherPoints }],
742
+ }).length > 0);
743
+ });
744
+ }
745
+ function endpointStubs(flow) {
746
+ const points = pathFor(flow).points;
747
+ const { fromSide, toSide } = flowSides(flow);
748
+ const directions = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
749
+ return [[points[0], fromSide], [points.at(-1), toSide]].map(([start, side]) => ({
750
+ start, end: start.map((value, axis) => value + directions[side][axis] * ENDPOINT_STUB),
751
+ }));
752
+ }
753
+ function crossingFlows(flow, points) {
754
+ return unrelated(flow).filter(other => segmentsOf(points).some(a => segmentsOf(pathFor(other).points).some(b => (
755
+ properSegmentIntersection(a.start, a.end, b.start, b.end)))));
756
+ }
757
+ function readingCost(flow, points) {
758
+ return length(points) + Math.max(0, points.length - 2) * 48 + crossingFlows(flow, points).length * 160;
759
+ }
760
+
761
+ // Preset paths can cross unrelated flows. Prefer a short clear detour, but
762
+ // give resolved crossings the same finite reading cost as Architecture rather
763
+ // than forcing a perimeter excursion. Crossings with authored routes still
764
+ // need a clear detour; only actual automatic/automatic crosses get halos.
765
+ if (automaticShowcase) {
766
+ // The stage bottoms are fitted below the final routes and labels later.
767
+ // Reserve the fixed tops/sides now, extending sides past the search area.
768
+ const routingBottom = Math.max(viewBox[1], ...[...nodes.values()].map(node => node.y + node.height),
769
+ ...routable.flatMap(flow => pathFor(flow).points.map(point => point[1]))) + DETOUR_NODE_GAP + ENDPOINT_STUB;
770
+ const routingFrames = asArray(dataflow.stages).map((stage, index) => ({
771
+ ...stageFrame(stage, index), height: routingBottom - layout.stageY,
772
+ }));
773
+ const stageBorders = routingFrames.flatMap(frame => frameBorderSegments(frame).filter(border => border.side !== 'bottom'));
774
+ // Check nearby simple corridors before accepting a grid detour. Coordinate
775
+ // thinning can discard an open two-bend corridor beside an occupied one.
776
+ // Bound these candidates; the grid still handles routes around obstacles.
777
+ function compactCorridors(flow) {
778
+ const { fromSide, toSide } = flowSides(flow);
779
+ const axis = fromSide === 'right' && toSide === 'left' ? 0
780
+ : fromSide === 'left' && toSide === 'right' ? 0
781
+ : fromSide === 'bottom' && toSide === 'top' ? 1
782
+ : fromSide === 'top' && toSide === 'bottom' ? 1 : -1;
783
+ if (axis < 0) return [];
784
+ const [start, end] = [pathFor(flow).points[0], pathFor(flow).points.at(-1)];
785
+ const low = Math.min(start[axis], end[axis]) + ENDPOINT_STUB;
786
+ const high = Math.max(start[axis], end[axis]) - ENDPOINT_STUB;
787
+ if (low > high) return [];
788
+ const middle = (low + high) / 2;
789
+ const coordinates = new Set([low, middle, high]);
790
+ const others = routable.filter(other => other !== flow);
791
+ for (const other of others) {
792
+ for (const segment of segmentsOf(pathFor(other).points)) {
793
+ if (segment.start[axis] === segment.end[axis]) {
794
+ coordinates.add(segment.start[axis] - 8);
795
+ coordinates.add(segment.start[axis] + 8);
796
+ }
797
+ }
798
+ }
799
+ return [...coordinates].filter(value => value >= low && value <= high)
800
+ .sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle)).slice(0, 16)
801
+ .map(value => {
802
+ const a = [...start], b = [...end];
803
+ a[axis] = b[axis] = value;
804
+ return normalizeRoutePoints([start, a, b, end]);
805
+ }).filter(points => routeHonorsEndpointSides(points, fromSide, toSide)
806
+ && !intrudesNode(flow, points)
807
+ && !collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
808
+ && !collectBorderRuns({ routedRelations: [{ points }], frames: routingFrames })
809
+ .some(hit => hit.side !== 'bottom')
810
+ && crossingFlows(flow, points).every(supportsAutomaticCrossover)
811
+ && !others.some(other => collectAmbiguousCorridors({
812
+ routedRelations: [{ relation: flow, points }, { relation: other, points: pathFor(other).points }],
813
+ includeSharedEndpoints: () => true,
814
+ }).length));
815
+ }
816
+ function gridDetour(flow, allowAvoidedCrossings = false, deferred = new Set()) {
817
+ const { points } = pathFor(flow);
818
+ const { fromSide, toSide } = flowSides(flow);
819
+ const others = unrelated(flow);
820
+ const options = {
821
+ start: points[0],
822
+ end: points.at(-1),
823
+ points: [points[0], points.at(-1)],
824
+ // Keep a detour a readable gap away from the nodes it passes; only its
825
+ // own endpoints are held to the grid's 2-unit clearance.
826
+ obstacles: [...nodes.values()].map(node => (node.id === flow.from || node.id === flow.to ? node : {
827
+ x: node.x - DETOUR_NODE_GAP, y: node.y - DETOUR_NODE_GAP,
828
+ width: node.width + DETOUR_NODE_GAP * 2, height: node.height + DETOUR_NODE_GAP * 2,
829
+ })),
830
+ fromSide,
831
+ toSide,
832
+ clearance: 2,
833
+ maximumObstacleCount: 80,
834
+ endpointStubPx: ENDPOINT_STUB,
835
+ maximumGridNodes: 4096,
836
+ avoidedSegments: others.flatMap(other => deferred.has(other) ? endpointStubs(other) : segmentsOf(pathFor(other).points)),
837
+ allowAvoidedCrossings,
838
+ minimumAvoidedOverlapPx: 8,
839
+ routeSeparationPx: 8,
840
+ minimumSegmentPx: 16,
841
+ bendPenaltyPx: 48,
842
+ };
843
+ let detour = shortestOrthogonalGridRoute(options);
844
+ // Frame constraints add coordinates to the grid. Keep an already-clear
845
+ // route stable; only retry if this route actually borrows a frame side.
846
+ if (detour && collectBorderRuns({ routedRelations: [{ points: detour.points }], frames: routingFrames })
847
+ .some(hit => hit.side !== 'bottom')) {
848
+ detour = shortestOrthogonalGridRoute({ ...options, borderSegments: stageBorders });
849
+ }
850
+ // The grid preserves both endpoint coordinates, even when their axes
851
+ // are closer than its coalescing distance. Check the resulting route
852
+ // before a cheaper micro jog can replace a readable port bridge.
853
+ const normalized = detour && normalizeRoutePoints(detour.points);
854
+ return normalized && !collectRouteRhythmIssues({ routedRelations: [{ points: normalized }] }).length
855
+ ? normalized : null;
856
+ }
857
+ function detourFor(flow) {
858
+ const { points } = pathFor(flow);
859
+ const normalized = gridDetour(flow);
860
+ if (!normalized) return null;
861
+ // Reuse Architecture's finite reading cost: 48px per bend, 160px per
862
+ // resolved crossing. A small clear detour still wins over a crossover.
863
+ const currentCost = readingCost(flow, points);
864
+ const detourCost = length(normalized) + Math.max(0, normalized.length - 2) * 48;
865
+ // A halo resolves an edge/edge crossing, never an edge through a node.
866
+ // Keep the clear detour even when the obstructed preset costs less.
867
+ const canResolve = supportsAutomaticCrossover(flow) && crossingFlows(flow, points).every(supportsAutomaticCrossover)
868
+ && !sharesCorridor(flow, points) && !intrudesNode(flow, points);
869
+ if (canResolve && detourCost >= currentCost) return null;
870
+ return { flow, routed: { d: polylinePath(normalized), points: normalized }, cost: detourCost - currentCost };
871
+ }
872
+ // A skipped stage/row or shared corridor can obstruct a preset without a
873
+ // proper crossing. Repair those routes first; otherwise the crossing pass either
874
+ // misses them or treats every neighbouring edge as an impenetrable wall.
875
+ // Both bounded searches keep node clearance and separate corridors. Only
876
+ // automatic/automatic crosses can use the shorter, halo-resolved option.
877
+ // Repairing a later preset can free a corridor for an earlier one. An
878
+ // already-obstructed automatic preset is not a settled corridor: two such
879
+ // presets can cover each other's endpoint stubs. Defer those presets while
880
+ // repairing one route, but reserve their endpoint stubs so they can still
881
+ // leave and enter their nodes. Plan the others around that repaired route.
882
+ // Supplied routes and routes already clear of nodes remain obstacles.
883
+ // Node intrusions only decrease; stop as soon as no route improves. Final
884
+ // validation still checks every route, including any unrepaired preset.
885
+ for (let pass = 0; pass < routable.length; pass += 1) {
886
+ let repaired = false;
887
+ for (const flow of routable) {
888
+ if (!supportsAutomaticCrossover(flow)) continue;
889
+ const current = pathFor(flow).points;
890
+ if (!intrudesNode(flow, current) && !sharesCorridor(flow, current)) continue;
891
+ const { fromSide, toSide } = flowSides(flow);
892
+ const candidatesFor = deferred => [gridDetour(flow, false, deferred), gridDetour(flow, true, deferred)].filter(points => points
893
+ && routeHonorsEndpointSides(points, fromSide, toSide) && !intrudesNode(flow, points)
894
+ && !sharesCorridor(flow, points, deferred) && crossingFlows(flow, points).every(supportsAutomaticCrossover));
895
+ let candidates = candidatesFor(new Set());
896
+ if (!candidates.length) {
897
+ const deferred = new Set(unrelated(flow).filter(other => supportsAutomaticCrossover(other)
898
+ && intrudesNode(other, pathFor(other).points)));
899
+ if (deferred.size) candidates = candidatesFor(deferred);
900
+ }
901
+ candidates.push(...compactCorridors(flow));
902
+ candidates.sort((left, right) => readingCost(flow, left) - readingCost(flow, right));
903
+ if (candidates.length && JSON.stringify(candidates[0]) !== JSON.stringify(current)) {
904
+ pathCache.set(flow, { d: polylinePath(candidates[0]), points: candidates[0] });
905
+ repaired = true;
906
+ }
907
+ }
908
+ if (!repaired) break;
909
+ }
910
+ // Resolve the most useful clear detour first. Remaining automatic crosses
911
+ // get explicit halos; supplied geometry retains its crossing diagnostic.
912
+ for (let pass = 0; pass < routable.length; pass += 1) {
913
+ const crossing = routable.filter(flow => supportsAutomaticCrossover(flow) && unrelated(flow).some(other => crosses(flow, other)));
914
+ const options = crossing.map(detourFor).filter(Boolean).sort((left, right) => left.cost - right.cost);
915
+ const before = routable.filter(flow => unrelated(flow).some(other => crosses(flow, other))).length;
916
+ const chosen = options.find(({ flow, routed }) => {
917
+ const previous = pathCache.get(flow);
918
+ pathCache.set(flow, routed);
919
+ const after = routable.filter(other => unrelated(other).some(next => crosses(other, next))).length;
920
+ if (after < before) return true;
921
+ pathCache.set(flow, previous);
922
+ return false;
923
+ });
924
+ if (!chosen) break;
925
+ }
926
+ }
927
+
928
+ const intersectionCount = (points, otherPoints) => segmentsOf(points).reduce((count, a) => count
929
+ + segmentsOf(otherPoints).filter(b => properSegmentIntersection(a.start, a.end, b.start, b.end)).length, 0);
930
+ const foldsBack = points => {
931
+ const segments = segmentsOf(points);
932
+ return segments.some((segment, index) => segments.slice(index + 2).some(other => segmentIntersectsRect(segment, {
933
+ x: Math.min(other.start[0], other.end[0]), y: Math.min(other.start[1], other.end[1]),
934
+ width: Math.abs(other.end[0] - other.start[0]), height: Math.abs(other.end[1] - other.start[1]),
935
+ })));
936
+ };
937
+
938
+ // Route detours can reverse the direction used to sort a shared node's ports.
939
+ // Reorder the existing slots by the first turn of the settled route, only when
940
+ // the whole automatic group becomes shorter without adding any intersection.
941
+ if (automaticShowcase) {
942
+ const portFrames = asArray(dataflow.stages).map(stageFrame);
943
+ const groups = new Map();
944
+ for (const flow of routable) for (const field of ['from', 'to']) {
945
+ const side = flowSides(flow)[field === 'from' ? 'fromSide' : 'toSide'];
946
+ const key = `${flow[field]}\0${side}`;
947
+ if (!groups.has(key)) groups.set(key, []);
948
+ groups.get(key).push({ flow, field, side });
949
+ }
950
+ const pinned = flow => !supportsAutomaticCrossover(flow)
951
+ || ['fromSide', 'toSide', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined);
952
+ let probes = 0;
953
+ for (const group of groups.values()) {
954
+ if (probes >= 16) break;
955
+ if (group.length < 2 || group.length > 8
956
+ || group.some(({ flow, field }) => pinned(flow) || !automaticPorts.get(flow)?.[field])) continue;
957
+ const axis = ['left', 'right'].includes(group[0].side) ? 1 : 0;
958
+ const currentRoutes = group.map(item => ({ ...item, current: normalizeRoutePoints(pathFor(item.flow).points) }));
959
+ if (currentRoutes.some(({ current }) => current.length < 2
960
+ || current.some(point => !point.every(Number.isFinite)))) continue;
961
+ const items = currentRoutes.map(({ current, ...item }) => {
962
+ const outward = item.field === 'from' ? current : [...current].reverse();
963
+ return { ...item, current, outward, coordinate: outward[0][axis],
964
+ turn: outward.find(point => Math.abs(point[axis] - outward[0][axis]) > 0.0001)?.[axis] ?? outward[0][axis] };
965
+ });
966
+ const slots = items.map(item => item.coordinate).sort((a, b) => a - b);
967
+ const ordered = [...items].sort((a, b) => a.turn - b.turn || a.coordinate - b.coordinate);
968
+ if (ordered.every((item, index) => item.coordinate === slots[index])) continue;
969
+ probes++;
970
+ const proposal = new Map();
971
+ let straightened = false;
972
+ for (const [index, item] of ordered.entries()) {
973
+ const outward = item.outward.map(point => [...point]);
974
+ outward[0][axis] = slots[index];
975
+ if (outward.length > 2) outward[1][axis] = slots[index];
976
+ let points = item.field === 'from' ? outward : outward.reverse();
977
+ const { fromSide, toSide } = flowSides(item.flow);
978
+ const direct = [points[0], points.at(-1)];
979
+ if (routeHonorsEndpointSides(direct, fromSide, toSide)
980
+ && (direct[0][0] === direct[1][0] || direct[0][1] === direct[1][1])) points = direct;
981
+ points = normalizeRoutePoints(points);
982
+ if (points.length === 2 && item.current.length > 2) straightened = true;
983
+ proposal.set(item.flow, points);
984
+ }
985
+ if (!straightened || items.some(({ flow, current }) => {
986
+ const points = proposal.get(flow), { fromSide, toSide } = flowSides(flow);
987
+ return points.length > current.length || length(points) > length(current) + 0.0001 || foldsBack(points)
988
+ || !routeHonorsEndpointSides(points, fromSide, toSide) || intrudesNode(flow, points)
989
+ || collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
990
+ || collectBorderRuns({ routedRelations: [{ points }], frames: portFrames }).length
991
+ || points.some(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1] - layout.stageBottomPad)
992
+ || routable.some(other => other !== flow && (
993
+ intersectionCount(points, proposal.get(other) || pathFor(other).points)
994
+ > intersectionCount(current, pathFor(other).points)
995
+ || collectAmbiguousCorridors({
996
+ routedRelations: [{ relation: flow, points }, { relation: other, points: proposal.get(other) || pathFor(other).points }],
997
+ includeSharedEndpoints: () => true,
998
+ }).length));
999
+ })) continue;
1000
+ for (const { flow, field } of items) {
1001
+ const points = proposal.get(flow);
1002
+ pathCache.set(flow, { points, d: polylinePath(points) });
1003
+ automaticPorts.get(flow)[field] = field === 'from' ? points[0] : points.at(-1);
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ // The legacy two-point anchor is relative to the source's y coordinate.
1009
+ // On an automatic vertical flow that places the label inside the source.
1010
+ // Start in the inter-node corridor; explicit offsets retain their old anchor.
1011
+ function labelPoint(flow, points) {
1012
+ if (automaticShowcase && !flow.classification && points.length === 2 && points[0][0] === points[1][0]
1013
+ && !['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) {
1014
+ const anchor = [points[0][0], (points[0][1] + points[1][1]) / 2 - 10];
1015
+ const { width, height } = flowLabelSize(flow);
1016
+ const rect = { x: anchor[0] - width / 2, y: anchor[1] - 11, width, height };
1017
+ // Sibling flows share a fixed source-side corridor. Keep the old search
1018
+ // anchor if a sibling occupies the midpoint; interior routes from other
1019
+ // nodes can still move during the later label/corridor repair.
1020
+ if (routable.every(other => other === flow || other.from !== flow.from || segmentsOf(pathFor(other).points)
1021
+ .every(segment => segmentRectClearanceWithin(segment, rect, 4) + 0.0001 >= 4))) return anchor;
1022
+ }
1023
+ return legacyLabelPoint(flow, points);
1024
+ }
1025
+
1026
+ function extraLabelLineMasksRoute(rect, points) {
1027
+ if (rect.height <= layout.labelH) return false;
1028
+ const segments = segmentsOf(points);
1029
+ return segments.some(segment => segmentIntersectsRect(segment, rect))
1030
+ && !segments.some(segment => segmentIntersectsRect(segment, { ...rect, height: layout.labelH }));
1031
+ }
1032
+
1033
+ const resolvedLabelPoints = new Map();
1034
+ const twoLineOcclusions = new Set();
1035
+ const initialLabelRects = asArray(dataflow.flows).flatMap((flow, relationIndex) => {
1036
+ if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) return [];
1037
+ const [lx, ly] = labelPoint(flow, pathFor(flow).points);
1038
+ const { width, height } = flowLabelSize(flow);
1039
+ // The shared placer uses a 10px baseline inset; Dataflow masks use 11px.
1040
+ // Adapt the baseline so it tests exactly the rectangle we later render.
1041
+ const rect = { relation: flow, relationIndex, label: flow.label,
1042
+ x: lx - width / 2, y: ly - 11, width, height, lx, ly: ly - 1 };
1043
+ // Only repair the additional footprint of an unpinned second line. A
1044
+ // single-line plate may intentionally interrupt its own route, especially
1045
+ // on a vertical segment; that established placement remains authoritative.
1046
+ if (flow.classification && !['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) {
1047
+ if (extraLabelLineMasksRoute(rect, pathFor(flow).points)) {
1048
+ twoLineOcclusions.add(flow);
1049
+ }
1050
+ }
1051
+ return [rect];
1052
+ });
1053
+ // Routing depends on node geometry and canvas width, so its actual footprint
1054
+ // can determine height without another copy of routeVia's preset rules.
1055
+ function heightForLabels(labels) {
1056
+ const geometryBottom = Math.max(0,
1057
+ ...[...nodes.values()].map(node => node.y + node.height),
1058
+ ...routable.flatMap(flow => pathFor(flow).points.map(point => point[1])));
1059
+ const contentBottom = Math.max(geometryBottom, ...labels.map(rect => rect.y + rect.height));
1060
+ return Math.max(360, Math.ceil(contentBottom + 24 + layout.stageBottomPad));
1061
+ }
1062
+ function fitAutomaticWidthToLabels(labels) {
1063
+ if (dataflow.meta?.viewBox) return;
1064
+ const labelRight = Math.max(0, ...labels.map(rect => rect.x + rect.width));
1065
+ viewBox[0] = Math.max(viewBox[0], Math.ceil(labelRight + 24));
1066
+ }
1067
+ // Give the shared placer room for complete initial plates before its search;
1068
+ // width growth here does not recompute the measured nodes or cached routes.
1069
+ fitAutomaticWidthToLabels(initialLabelRects);
1070
+ if (automaticShowcase) viewBox[1] = heightForLabels(initialLabelRects)
1071
+ + Math.max(0, ...initialLabelRects.map(rect => rect.height - layout.labelH));
1072
+ const compositionFrames = asArray(dataflow.stages).map(stageFrame);
1073
+
1074
+ const resolvedLabelRects = (automaticShowcase ? placeAutomaticLabels({
1075
+ labels: initialLabelRects,
1076
+ routes: asArray(dataflow.flows).flatMap((flow, relationIndex) => (
1077
+ nodes.has(flow.from) && nodes.has(flow.to)
1078
+ ? [{ relationIndex, points: pathFor(flow).points }] : []
1079
+ )),
1080
+ components: [...nodes.values()],
1081
+ titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1082
+ viewBox,
1083
+ placementBottom: viewBox[1] - layout.stageBottomPad,
1084
+ maxOwnRouteDistance: 36,
1085
+ }) : initialLabelRects).map(rect => {
1086
+ // Dataflow has compact 16px plates. Permit one clearance gutter beyond
1087
+ // two plate heights (36px), which covers a label immediately above a node,
1088
+ // while rejecting a collision-free island far from the labelled route.
1089
+ const ownPoints = pathFor(rect.relation).points;
1090
+ const nearby = ownPoints.slice(1).some((point, index) =>
1091
+ segmentRectClearanceWithin({ start: ownPoints[index], end: point }, rect, 36) <= 36);
1092
+ const original = initialLabelRects.find(label => label.relation === rect.relation);
1093
+ const kept = nearby ? rect : original;
1094
+ const resolved = { ...kept, ly: kept.ly + 1 };
1095
+ if (automaticShowcase) resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1096
+ return resolved;
1097
+ });
1098
+
1099
+ // The ordinary shared placer allows a plate to interrupt its own connector.
1100
+ // For the second-line defect above, keep the complete plate beside the route
1101
+ // instead. This local search freezes all other labels and authored geometry,
1102
+ // and uses the same node/title/label/other-route checks as ordinary placement.
1103
+ for (const [index, rect] of resolvedLabelRects.entries()) {
1104
+ if (!twoLineOcclusions.has(rect.relation)) continue;
1105
+ const ownPoints = pathFor(rect.relation).points;
1106
+ if (!ownPoints.slice(1).some((end, segmentIndex) => segmentIntersectsRect({ start: ownPoints[segmentIndex], end }, rect))) continue;
1107
+ const original = initialLabelRects.find(label => label.relation === rect.relation);
1108
+ const extraHeight = original.height - layout.labelH;
1109
+ const preferred = { ...original, y: original.y - extraHeight, ly: original.ly - extraHeight };
1110
+ const ownRouteBands = ownPoints.slice(1).map((end, segmentIndex) => {
1111
+ const start = ownPoints[segmentIndex];
1112
+ return {
1113
+ x: Math.min(start[0], end[0]) - 2, y: Math.min(start[1], end[1]) - 2,
1114
+ width: Math.abs(end[0] - start[0]) + 4, height: Math.abs(end[1] - start[1]) + 4,
1115
+ };
1116
+ });
1117
+ const labels = resolvedLabelRects.map((other, otherIndex) => otherIndex === index ? preferred : {
1118
+ ...other, ly: other.ly - 1,
1119
+ relation: { ...other.relation, labelAt: [other.lx, other.ly] },
1120
+ });
1121
+ const routes = asArray(dataflow.flows).flatMap((flow, relationIndex) => (
1122
+ nodes.has(flow.from) && nodes.has(flow.to) ? [{ relationIndex, points: pathFor(flow).points }] : []
1123
+ ));
1124
+ const titles = compositionFrames.map(frame => ({ ...frame, height: layout.stageH }));
1125
+ const replacement = placeAutomaticLabels({
1126
+ labels, routes,
1127
+ components: [...nodes.values(), ...ownRouteBands],
1128
+ titles,
1129
+ viewBox,
1130
+ placementBottom: viewBox[1] - layout.stageBottomPad,
1131
+ keepFallbackNearRoute: true,
1132
+ maxOwnRouteDistance: 36,
1133
+ })[index];
1134
+ // An exhausted shared search returns its supplied preferred rect. That is
1135
+ // not proof of a safe replacement: preserve the previously accepted label
1136
+ // if the complete footprint cannot fit near its connector without collisions.
1137
+ const clear = replacement.x >= 0 && replacement.y >= 0
1138
+ && replacement.x + replacement.width <= viewBox[0]
1139
+ && replacement.y + replacement.height <= viewBox[1] - layout.stageBottomPad
1140
+ && ![...nodes.values(), ...titles].some(obstacle => rectsOverlap(replacement, obstacle, 2))
1141
+ && !resolvedLabelRects.some((other, otherIndex) => otherIndex !== index && rectsOverlap(replacement, other, 2))
1142
+ && routes.every(route => route.points.slice(1).every((end, segmentIndex) => (
1143
+ segmentRectClearanceWithin({ start: route.points[segmentIndex], end }, replacement, 4) >= 4
1144
+ )))
1145
+ && ownPoints.slice(1).some((end, segmentIndex) => (
1146
+ segmentRectClearanceWithin({ start: ownPoints[segmentIndex], end }, replacement, 36) <= 36
1147
+ ));
1148
+ if (!clear) continue;
1149
+ const resolved = { ...replacement, ly: replacement.ly + 1 };
1150
+ resolvedLabelRects[index] = resolved;
1151
+ resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1152
+ }
1153
+
1154
+ // Labels may occupy the only useful part of an inter-stage corridor after
1155
+ // ordinary routing has finished. Try shifting one interior segment past the
1156
+ // actual plate, then re-place only the labels that this move obstructs. Valid
1157
+ // labels keep their positions. Endpoint geometry and authored controls remain
1158
+ // authoritative, and every accepted route clears all final foreign labels.
1159
+ if (automaticShowcase) {
1160
+ const titles = compositionFrames.map(frame => ({ ...frame, height: layout.stageH }));
1161
+ const labelGap = 4;
1162
+ // Bound the complete candidate checks, including rejected geometry, across
1163
+ // the diagram. Each accepted move clears one route's label collisions.
1164
+ let attempts = 0;
1165
+ const nearOwnRoute = (rect, points) => segmentsOf(points)
1166
+ .some(segment => segmentRectClearanceWithin(segment, rect, 36) <= 36);
1167
+ const masksLabel = (points, rect) => segmentsOf(points)
1168
+ .some(segment => segmentRectClearanceWithin(segment, rect, labelGap) + 0.0001 < labelGap);
1169
+ const overlapsNode = rect => [...nodes.values()].some(node => rectsOverlap(rect, node, -2));
1170
+ // A label left inside a node does not mask the route occupying its usable
1171
+ // slot. Probe a nearby node-clear slot with the other labels fixed. These
1172
+ // are search targets only: accept a corridor move after placing every label
1173
+ // against the real routes, including resolving the original node overlap.
1174
+ let slotProbes = 0;
1175
+ const blockedLabelSlots = resolvedLabelRects.flatMap((rect, index) => {
1176
+ if (slotProbes >= 16 || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined)
1177
+ || !overlapsNode(rect)) return [];
1178
+ slotProbes++;
1179
+ const labels = resolvedLabelRects.map((other, otherIndex) => ({ ...other, ly: other.ly - 1,
1180
+ relation: otherIndex === index ? other.relation : { ...other.relation, labelAt: [other.lx, other.ly] } }));
1181
+ const probe = placeAutomaticLabels({ labels,
1182
+ routes: [{ relationIndex: rect.relationIndex, points: pathFor(rect.relation).points }],
1183
+ components: [...nodes.values()], titles, viewBox,
1184
+ placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 })[index];
1185
+ return probe !== labels[index] ? [probe] : [];
1186
+ });
1187
+ for (const flow of routable) {
1188
+ if (attempts >= 16) break;
1189
+ if (!supportsAutomaticCrossover(flow)
1190
+ || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) continue;
1191
+ const current = normalizeRoutePoints(pathFor(flow).points);
1192
+ const pendingSlots = blockedLabelSlots.filter(slot => overlapsNode(
1193
+ resolvedLabelRects.find(rect => rect.relation === slot.relation)));
1194
+ const blocked = [...resolvedLabelRects, ...pendingSlots]
1195
+ .filter(rect => rect.relation !== flow && masksLabel(current, rect));
1196
+ if (!blocked.length) continue;
1197
+ const candidates = new Map();
1198
+ for (const rect of blocked) {
1199
+ for (let index = 1; index < current.length - 2; index++) {
1200
+ const [start, end] = [current[index], current[index + 1]];
1201
+ if (segmentRectClearanceWithin({ start, end }, rect, labelGap) + 0.0001 >= labelGap) continue;
1202
+ const axis = start[0] === end[0] ? 0 : start[1] === end[1] ? 1 : -1;
1203
+ if (axis === -1) continue;
1204
+ const [low, size] = axis === 0 ? [rect.x, rect.width] : [rect.y, rect.height];
1205
+ for (const coordinate of [low - labelGap, low + size + labelGap]) {
1206
+ const points = current.map(point => [...point]);
1207
+ points[index][axis] = points[index + 1][axis] = coordinate;
1208
+ // Do not reverse either neighbouring segment into a doubled-back
1209
+ // path. A zero-length neighbour can be removed by normalization.
1210
+ if ([index - 1, index + 1].some(k => [0, 1].reduce((dot, axis) => dot
1211
+ + (current[k + 1][axis] - current[k][axis]) * (points[k + 1][axis] - points[k][axis]), 0) < 0)) continue;
1212
+ const normalized = normalizeRoutePoints(points);
1213
+ candidates.set(JSON.stringify(normalized), normalized);
1214
+ }
1215
+ }
1216
+ }
1217
+ const { fromSide, toSide } = flowSides(flow);
1218
+ const oldCrossings = new Map(unrelated(flow).map(other => [other, intersectionCount(current, pathFor(other).points)]));
1219
+ const oldCost = readingCost(flow, current);
1220
+ const ranked = [...candidates.values()].map(points => ({ points, cost: readingCost(flow, points) }))
1221
+ .sort((a, b) => a.cost - b.cost);
1222
+ for (const { points, cost } of ranked) {
1223
+ if (attempts >= 16) break;
1224
+ attempts++;
1225
+ // At most two bend-costs of extra distance; never add bends, crossings,
1226
+ // self-overlaps, shared corridors or a new route through a node.
1227
+ if (points.length > current.length || cost > oldCost + 96 || foldsBack(points)
1228
+ || !routeHonorsEndpointSides(points, fromSide, toSide)
1229
+ || points.some(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1] - layout.stageBottomPad)
1230
+ || intrudesNode(flow, points) || sharesCorridor(flow, points)
1231
+ || collectRouteRhythmIssues({ routedRelations: [{ points }] }).length
1232
+ || collectBorderRuns({ routedRelations: [{ points }], frames: compositionFrames }).length
1233
+ || [...oldCrossings].some(([other, count]) => {
1234
+ const nextCount = intersectionCount(points, pathFor(other).points);
1235
+ return nextCount > count || nextCount > 0 && !supportsAutomaticCrossover(other);
1236
+ })) continue;
1237
+ const labels = resolvedLabelRects.map(rect => ({ ...rect, ly: rect.ly - 1 }));
1238
+ const routes = asArray(dataflow.flows).flatMap((other, relationIndex) => (
1239
+ nodes.has(other.from) && nodes.has(other.to)
1240
+ ? [{ relationIndex, points: other === flow ? points : pathFor(other).points }] : []
1241
+ ));
1242
+ const placed = placeAutomaticLabels({ labels, routes, components: [...nodes.values()], titles,
1243
+ viewBox, placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 });
1244
+ if (placed.some(rect => rect.relation !== flow && masksLabel(points, rect))) continue;
1245
+ if (blocked.some(slot => pendingSlots.includes(slot) && overlapsNode(
1246
+ placed.find(rect => rect.relation === slot.relation)))) continue;
1247
+ if (placed.some((rect, index) => {
1248
+ const oldPoints = pathFor(rect.relation).points;
1249
+ const ownPoints = rect.relation === flow ? points : oldPoints;
1250
+ return ((rect.relation === flow || rect !== labels[index]) && !nearOwnRoute(rect, ownPoints))
1251
+ || (extraLabelLineMasksRoute(rect, ownPoints) && !extraLabelLineMasksRoute(labels[index], oldPoints));
1252
+ })) continue;
1253
+ pathCache.set(flow, { points, d: polylinePath(points) });
1254
+ for (const [index, rect] of placed.entries()) {
1255
+ const resolved = { ...rect, ly: rect.ly + 1 };
1256
+ resolvedLabelRects[index] = resolved;
1257
+ resolvedLabelPoints.set(rect.relation, [resolved.lx, resolved.ly]);
1258
+ }
1259
+ break;
1260
+ }
1261
+ }
1262
+ // Once labels and routes are settled, align only the free endpoint of a
1263
+ // short port bridge. Keep other routes and labels fixed so a local straight
1264
+ // line cannot trigger a longer detour or hide a neighbouring label.
1265
+ let alignmentProbes = 0;
1266
+ for (const flow of routable) {
1267
+ if (alignmentProbes >= 16) break;
1268
+ if (!supportsAutomaticCrossover(flow)
1269
+ || ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => flow[key] !== undefined)) continue;
1270
+ const current = normalizeRoutePoints(pathFor(flow).points);
1271
+ if (current.length <= 2) continue;
1272
+ const index = resolvedLabelRects.findIndex(rect => rect.relation === flow);
1273
+ if (index < 0) continue;
1274
+ const from = nodes.get(flow.from), to = nodes.get(flow.to);
1275
+ const { fromSide, toSide } = flowSides(flow);
1276
+ let placedLabel;
1277
+ const aligned = alignAutomaticFacingPorts(flow, from, to, current[0], current.at(-1), fromSide, toSide, automaticPorts.get(flow), {
1278
+ accept: points => {
1279
+ if (alignmentProbes >= 16 || intrudesNode(flow, points)
1280
+ || unrelated(flow).some(other => intersectionCount(points, pathFor(other).points)
1281
+ > intersectionCount(current, pathFor(other).points))
1282
+ || resolvedLabelRects.some(rect => rect.relation !== flow && masksLabel(points, rect))) return false;
1283
+ for (const [node, side, point, original] of [
1284
+ [from, fromSide, points[0], current[0]], [to, toSide, points[1], current.at(-1)],
1285
+ ]) {
1286
+ if (Math.hypot(point[0] - original[0], point[1] - original[1]) < 0.0001) continue;
1287
+ for (const other of routable) for (const field of ['from', 'to']) {
1288
+ if (other === flow || other[field] !== node.id || flowSides(other)[field === 'from' ? 'fromSide' : 'toSide'] !== side) continue;
1289
+ const occupied = field === 'from' ? pathFor(other).points[0] : pathFor(other).points.at(-1);
1290
+ if (Math.hypot(point[0] - occupied[0], point[1] - occupied[1]) < 18 - 0.0001) return false;
1291
+ }
1292
+ }
1293
+ if (routable.some(other => other !== flow && collectAmbiguousCorridors({
1294
+ routedRelations: [{ relation: flow, points }, { relation: other, points: pathFor(other).points }],
1295
+ includeSharedEndpoints: () => true,
1296
+ }).length)) return false;
1297
+ alignmentProbes++;
1298
+ const [lx, ly] = labelPoint(flow, points), rect = resolvedLabelRects[index];
1299
+ const labels = resolvedLabelRects.map((other, i) => i === index
1300
+ ? { ...other, x: lx - other.width / 2, y: ly - 11, lx, ly: ly - 1 }
1301
+ : { ...other, ly: other.ly - 1, relation: { ...other.relation, labelAt: [other.lx, other.ly] } });
1302
+ const routes = routable.map(other => ({ relationIndex: asArray(dataflow.flows).indexOf(other), points: other === flow ? points : pathFor(other).points }));
1303
+ const candidate = placeAutomaticLabels({ labels, routes, components: [...nodes.values()], titles,
1304
+ viewBox, placementBottom: viewBox[1] - layout.stageBottomPad, maxOwnRouteDistance: 36 })[index];
1305
+ if (candidate.x < 0 || candidate.y < 0 || candidate.x + candidate.width > viewBox[0]
1306
+ || candidate.y + candidate.height > viewBox[1] - layout.stageBottomPad
1307
+ || [...nodes.values(), ...titles].some(obstacle => rectsOverlap(candidate, obstacle, 2))
1308
+ || resolvedLabelRects.some((other, i) => i !== index && rectsOverlap(candidate, other, 2))
1309
+ || routes.some(route => route.relationIndex !== rect.relationIndex && masksLabel(route.points, candidate))
1310
+ || !nearOwnRoute(candidate, points)
1311
+ || (extraLabelLineMasksRoute(candidate, points) && !extraLabelLineMasksRoute(rect, current))) return false;
1312
+ placedLabel = { ...candidate, ly: candidate.ly + 1 };
1313
+ return true;
1314
+ },
1315
+ });
1316
+ if (!placedLabel) continue;
1317
+ const points = [aligned.start, aligned.end];
1318
+ pathCache.set(flow, { points, d: polylinePath(points) });
1319
+ resolvedLabelRects[index] = placedLabel;
1320
+ resolvedLabelPoints.set(flow, [placedLabel.lx, placedLabel.ly]);
1321
+ }
1322
+ for (const [index, left] of routable.entries()) {
1323
+ for (const right of routable.slice(index + 1)) {
1324
+ if (!related(left, right) && supportsAutomaticCrossover(left) && supportsAutomaticCrossover(right) && crosses(left, right)) {
1325
+ resolvedCrossoverFlows.add(left);
1326
+ resolvedCrossoverFlows.add(right);
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ // Only after routes settle, try two complete rows for an unresolved wide
1333
+ // label. Keep the plate immediately beside its own arrow, with every other
1334
+ // route and label fixed; an empty island above a node is not a useful slot.
1335
+ if (automaticShowcase) {
1336
+ const wrapped = wrapBlockedFlowLabels({ labels: resolvedLabelRects,
1337
+ routes: routable.map(flow => ({ relationIndex: asArray(dataflow.flows).indexOf(flow), points: pathFor(flow).points })),
1338
+ components: [...nodes.values()], titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1339
+ viewBox, placementBottom: viewBox[1] - layout.stageBottomPad });
1340
+ for (const [index, rect] of wrapped.entries()) {
1341
+ if (rect === resolvedLabelRects[index]) continue;
1342
+ resolvedLabelRects[index] = rect;
1343
+ resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
1344
+ }
1345
+ }
1346
+
1347
+ // A shared placement search may retain an unpinned original plate when none
1348
+ // of its candidates fit the compact width. Include every final rendered plate
1349
+ // without moving nodes, routes or labels; an authored canvas remains fixed.
1350
+ fitAutomaticWidthToLabels(resolvedLabelRects);
1351
+ // A bounded label move may also extend below the initial route footprint.
1352
+ if (automaticShowcase) {
1353
+ viewBox[1] = heightForLabels(resolvedLabelRects);
1354
+ for (const frame of compositionFrames) frame.height = viewBox[1] - layout.stageY - layout.stageBottomPad;
1355
+ }
1356
+
1357
+ // Resolve a remaining label and its occupied corridors as one proposal.
1358
+ // Earlier valid placements and authored geometry remain authoritative.
1359
+ if (automaticShowcase) {
1360
+ const proposal = repairLabelRoutes({ labels: resolvedLabelRects,
1361
+ routes: routable.map(flow => ({ relation: flow, relationIndex: dataflow.flows.indexOf(flow), points: pathFor(flow).points })),
1362
+ nodes: [...nodes.values()], titles: compositionFrames.map(frame => ({ ...frame, height: layout.stageH })),
1363
+ frames: compositionFrames, viewBox, placementBottom: viewBox[1] - layout.stageBottomPad,
1364
+ sideFor: flowSides });
1365
+ if (proposal.labels !== resolvedLabelRects) {
1366
+ for (const route of proposal.routes) pathCache.set(route.relation, { points: route.points, d: polylinePath(route.points) });
1367
+ for (const [index, rect] of proposal.labels.entries()) {
1368
+ resolvedLabelRects[index] = rect;
1369
+ resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
1370
+ }
1371
+ viewBox[1] = heightForLabels(resolvedLabelRects);
1372
+ for (const frame of compositionFrames) frame.height = viewBox[1] - layout.stageY - layout.stageBottomPad;
1373
+ resolvedCrossoverFlows.clear();
1374
+ for (const [index, left] of routable.entries()) for (const right of routable.slice(index + 1)) {
1375
+ if (!related(left, right) && supportsAutomaticCrossover(left) && supportsAutomaticCrossover(right) && crosses(left, right)) {
1376
+ resolvedCrossoverFlows.add(left); resolvedCrossoverFlows.add(right);
1377
+ }
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // Header measurement follows 276970789's #257, including the ordinal.
1383
+ // Long titles wrap at the same legible floor instead of becoming invalid input.
1384
+ function stageHeaderText(stage, index) {
1385
+ return `${String(index + 1).padStart(2, '0')} / ${stage.label}`;
1386
+ }
1387
+
1388
+ function renderStageHeader(stage, index, cx) {
1389
+ const text = stageHeaderText(stage, index);
1390
+ const font = fittedNodeFontSize(text, layout.stageW, 9, 7);
1391
+ const available = availableNodeTextWidth(layout.stageW);
1392
+ const open = `<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="${font}" font-weight="600" text-anchor="middle">`;
1393
+ if (minimumNodeTextWidth(text, font) <= available) return `${open}${esc(text)}</text>`;
1394
+ const lines = [];
1395
+ let line = '';
1396
+ // Prefer word boundaries; split oversized words and CJK by grapheme, without
1397
+ // dropping whitespace or splitting a combining character/emoji sequence.
1398
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
1399
+ for (const word of text.match(/\s+|\S+/gu) || []) {
1400
+ if (line && minimumNodeTextWidth(line + word, font) > available) {
1401
+ lines.push(line); line = '';
1402
+ }
1403
+ for (const { segment } of segmenter.segment(word)) {
1404
+ if (line && minimumNodeTextWidth(line + segment, font) > available) {
1405
+ lines.push(line); line = '';
1406
+ }
1407
+ line += segment;
1408
+ }
1409
+ }
1410
+ if (line) lines.push(line);
1411
+ // Explicit node geometry is authoritative. Do not turn an old horizontal
1412
+ // overflow into a new collision with nodes when the header area is packed.
1413
+ const firstNodeY = Math.min(...[...nodes.values()].filter(node => node.stage === index).map(node => node.y),
1414
+ viewBox[1] - layout.stageBottomPad);
1415
+ const lastLineBottom = layout.stageY + 22 + (lines.length - 1) * (font + 4) + font * 0.3;
1416
+ if (lastLineBottom > firstNodeY - 4) return `${open}${esc(text)}</text>`;
1417
+ const content = lines.length === 1 ? esc(text) : lines.map((line, i) =>
1418
+ `<tspan x="${cx}" dy="${i ? font + 4 : 0}">${esc(line)}</tspan>`).join('');
1419
+ return `${open}${content}</text>`;
1420
+ }
1421
+
1422
+ function renderStage(stage, index) {
1423
+ const frame = compositionFrames[index];
1424
+ const cx = stageX(index);
1425
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
1426
+ ${renderStageHeader(stage, index, cx)}`;
1427
+ }
1428
+
1429
+ function renderNode(node) {
1430
+ const fill = componentFill[node.type] || 'c-external';
1431
+ const accent = componentText[node.type] || 't-muted';
1432
+ const hasSub = node.sublabel != null && node.sublabel !== '';
1433
+ const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
1434
+ const sublabelFontSize = fittedNodeFontSize(node.sublabel, node.width, Math.max(nodeTextFit.sublabelPreferred, contextFontMinimum), contextFontMinimum);
1435
+ const tagFontSize = fittedNodeFontSize(node.tag, node.width, Math.max(nodeTextFit.tagPreferred, contextFontMinimum), contextFontMinimum);
1436
+ const inset = node.autoHeightInset || 0;
1437
+ const textRows = [{ text: node.label, font: labelFontSize, y: 21 + inset }];
1438
+ if (hasSub) textRows.push({ text: node.sublabel, font: sublabelFontSize, y: 37 + inset });
1439
+ if (node.tag) textRows.push({ text: node.tag, font: tagFontSize, y: node.height - 11 - inset });
1440
+ const labelLayout = nodeLabelLayout({ width: node.width, height: node.height, rows: textRows,
1441
+ brand: Boolean(brandMarkFor(node)), source: Boolean(sourceEvidence?.nodes?.[node.id]?.length) });
1442
+ const sub = hasSub
1443
+ ? `\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>`
1444
+ : '';
1445
+ const tag = node.tag
1446
+ ? `\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>`
1447
+ : '';
1448
+ const stage = asArray(dataflow.stages)[node.stage];
1449
+ const context = stage
1450
+ ? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
1451
+ : i18nText(dataflow.meta.locale, 'node.context.dataflow');
1452
+ const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
1453
+ const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
1454
+ return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
1455
+ ${focusNodeTitle(node.label, passport)}
1456
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
1457
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
1458
+ ${renderSemanticSigil(node.type, { icon: node.icon, x: node.x + 6, y: node.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}
1459
+ <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}
1460
+ </g>`;
1461
+ }
1462
+
1463
+ function flowStrokeWidth(flow) {
1464
+ return flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
1465
+ }
1466
+
1467
+ function renderFlowPath(flow, index) {
1468
+ const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
1469
+ const routed = pathFor(flow);
1470
+ const strokeWidth = flowStrokeWidth(flow);
1471
+ const automatic = resolvedCrossoverFlows.has(flow);
1472
+ const edge = ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}"${automatic ? ' data-composition-crossover="halo"' : ''}${authoredStraightRouteAttrs(flow, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1473
+ if (!automatic) return edge;
1474
+ return ` <g data-graph-role="automatic-crossover" style="--step:${index}">
1475
+ <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>
1476
+ ${edge}
1477
+ </g>`;
1478
+ }
1479
+
1480
+ function renderFlowLabel(flow, index) {
1481
+ const routed = pathFor(flow);
1482
+ const [lx, ly] = resolvedLabelPoints.get(flow) || labelPoint(flow, routed.points);
1483
+ const rect = resolvedLabelRects.find(rect => rect.relation === flow);
1484
+ const { width: labelW, height: labelH } = rect || flowLabelSize(flow);
1485
+ const lines = rect?.lines;
1486
+ const text = lines ? lines.map((line, i) => `<tspan x="${lx}" dy="${i ? 11 : 0}">${esc(line)}</tspan>`).join('') : esc(flow.label);
1487
+ const classification = flow.classification
1488
+ ? `\n <text data-detail="fine" x="${lx}" y="${ly + 11 * (lines?.length || 1)}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
1489
+ : '';
1490
+ return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
1491
+ <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
1492
+ <text x="${lx}" y="${ly}" class="${edgeLabelAccent(flow.variant)}" font-size="8" text-anchor="middle">${text}</text>${classification}
1493
+ </g>`;
1494
+ }
1495
+
1496
+ const LEGEND_CATALOG = [
1497
+ { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
1498
+ { kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
1499
+ { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
1500
+ { kind: 'database' },
1501
+ { kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
1502
+ ].map((entry) => ({
1503
+ ...entry,
1504
+ label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
1505
+ }));
1506
+
1507
+ function renderLegend() {
1508
+ const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
1509
+ if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
1510
+ const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
1511
+ return renderResolvedLegend({
1512
+ entries,
1513
+ locale: dataflow.meta.locale,
1514
+ layout: {
1515
+ x: 40,
1516
+ baselineY: viewBox[1] - 36,
1517
+ width: viewBox[0] - 80,
1518
+ minTitleY: viewBox[1] - 66,
1519
+ unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
1520
+ diagramType: 'dataflow',
1521
+ },
1522
+ renderSwatch: (entry) => entry.kind === 'database'
1523
+ ? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
1524
+ : `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
1525
+ });
1526
+ }
1527
+
1528
+ function ensureAutoLegendFits() {
1529
+ // Narrow auto canvases can wrap the legend onto extra rows. Stage frames
1530
+ // fill the canvas height, so growing height alone does not free a legend
1531
+ // band — widen just enough for a one-row pack instead.
1532
+ if (dataflow.meta?.viewBox) return;
1533
+ const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
1534
+ if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
1535
+ const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
1536
+ if (!entries.length) return;
1537
+ if (legendFootprint(entries, { width: Math.max(1, viewBox[0] - 80) }).extraHeight <= 0) return;
1538
+ let width = viewBox[0];
1539
+ while (width < 1600
1540
+ && legendFootprint(entries, { width: Math.max(1, width - 80) }).extraHeight > 0) {
1541
+ width += 40;
1542
+ }
1543
+ viewBox[0] = width;
1544
+ }
1545
+
1546
+ function renderSvg() {
1547
+ ensureAutoLegendFits();
1548
+ // Same default-canvas contract as lifecycle: 940x720 is below the 1.55 wide
1549
+ // ratio, so without intrinsic-height the desktop Reader can neither narrow
1550
+ // nor scroll it and every default dataflow fails the browser gate.
1551
+ const readerFit = dataflow.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
1552
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(dataflow.meta)} data-motion-choreography="stream">
1553
+ ${svgAccessibleText(dataflow.meta, 'dataflow')}
1554
+ ${renderDefinitions()}
1555
+
1556
+ <!-- Background Grid -->
1557
+ <rect width="100%" height="100%" fill="url(#grid)" />
1558
+
1559
+ <!-- Data Stages -->
1560
+ ${dataflow.stages.map(renderStage).join('\n\n')}
1561
+
1562
+ <!-- Flow paths -->
1563
+ ${asArray(dataflow.flows).map(renderFlowPath).join('\n')}
1564
+
1565
+ <!-- Nodes -->
1566
+ ${[...nodes.values()].map(renderNode).join('\n\n')}
1567
+
1568
+ <!-- Flow labels -->
1569
+ ${asArray(dataflow.flows).map(renderFlowLabel).join('\n')}
1570
+
1571
+ <!-- Legend -->
1572
+ ${renderLegend()}
1573
+ </svg>`;
1574
+ }
1575
+ validateDataflow();
1576
+ return renderSvg();
1577
+ }
1578
+
1579
+ function attempt(rebalancePorts) {
1580
+ return withIsolatedDiagnosticRecording(() => {
1581
+ try { return build(rebalancePorts); }
1582
+ catch (error) {
1583
+ if (error?.archifyDiagnostics) error.archifyDiagnostics = rendererFailure(error).diagnostics;
1584
+ throw error;
1585
+ }
1586
+ });
1587
+ }
1588
+
1589
+ try {
1590
+ return attempt(false);
1591
+ } catch (initial) {
1592
+ const layoutFailure = error => error?.archifyDiagnostics?.length
1593
+ && error.message.startsWith('Data-flow layout validation failed:');
1594
+ if (!layoutFailure(initial)) throw initial;
1595
+ try {
1596
+ const recovered = attempt(true);
1597
+ if (recovered !== null) return recovered;
1598
+ } catch (error) {
1599
+ // A failed geometry proposal leaves the original actionable diagnosis.
1600
+ // Unexpected implementation errors still escape with their own stack.
1601
+ if (!layoutFailure(error)) throw error;
1602
+ }
1603
+ throw initial;
1604
+ }
1605
+ }