dsh-archify-projects 0.0.0-stage → 0.1.0

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 (148) hide show
  1. package/README.md +204 -2
  2. package/cordis.patch.yml +99 -0
  3. package/dist/client.js +2261 -0
  4. package/dist/client.js.map +1 -0
  5. package/docs/CONTAINER.md +197 -0
  6. package/docs/DESIGN.md +905 -0
  7. package/docs/HANDOVER.md +391 -0
  8. package/docs/PUBLISH.md +107 -0
  9. package/icon.svg +12 -0
  10. package/lib/host/archify-skill.js +116 -0
  11. package/lib/host/archify-skill.js.map +1 -0
  12. package/lib/host/config.js +95 -0
  13. package/lib/host/config.js.map +1 -0
  14. package/lib/host/context.js +28 -0
  15. package/lib/host/context.js.map +1 -0
  16. package/lib/host/git.js +185 -0
  17. package/lib/host/git.js.map +1 -0
  18. package/lib/host/http.js +184 -0
  19. package/lib/host/http.js.map +1 -0
  20. package/lib/host/index.js +90 -0
  21. package/lib/host/index.js.map +1 -0
  22. package/lib/host/policy.js +124 -0
  23. package/lib/host/policy.js.map +1 -0
  24. package/lib/host/routes.js +667 -0
  25. package/lib/host/routes.js.map +1 -0
  26. package/lib/host/runner.js +913 -0
  27. package/lib/host/runner.js.map +1 -0
  28. package/lib/host/store.js +370 -0
  29. package/lib/host/store.js.map +1 -0
  30. package/lib/host/workspace.js +65 -0
  31. package/lib/host/workspace.js.map +1 -0
  32. package/lib/shared/format.js +138 -0
  33. package/lib/shared/format.js.map +1 -0
  34. package/lib/shared/prompt.js +257 -0
  35. package/lib/shared/prompt.js.map +1 -0
  36. package/lib/shared/protocol.js +31 -0
  37. package/lib/shared/protocol.js.map +1 -0
  38. package/lib/shared/types.js +173 -0
  39. package/lib/shared/types.js.map +1 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +98 -4
  43. package/skills/archify/LICENSE +22 -0
  44. package/skills/archify/SKILL.md +109 -0
  45. package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
  46. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  47. package/skills/archify/assets/template.html +13709 -0
  48. package/skills/archify/bin/archify.mjs +6936 -0
  49. package/skills/archify/bin/delivery-update.mjs +91 -0
  50. package/skills/archify/bin/finalize.mjs +895 -0
  51. package/skills/archify/bin/open-artifact.mjs +146 -0
  52. package/skills/archify/bin/preview.mjs +969 -0
  53. package/skills/archify/bin/recover-output.mjs +41 -0
  54. package/skills/archify/bin/visual-check.mjs +2667 -0
  55. package/skills/archify/brand-marks/README.md +31 -0
  56. package/skills/archify/brand-marks/catalog.json +131 -0
  57. package/skills/archify/delta/architecture-delta.mjs +1313 -0
  58. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  59. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  60. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  61. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  62. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  63. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  64. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  65. package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
  66. package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
  67. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  68. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  69. package/skills/archify/examples/incident-response.workflow.json +59 -0
  70. package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
  71. package/skills/archify/examples/locales/de.json +425 -0
  72. package/skills/archify/examples/locales/es.json +440 -0
  73. package/skills/archify/examples/locales/fr.json +425 -0
  74. package/skills/archify/examples/locales/fr.partial.json +22 -0
  75. package/skills/archify/examples/locales/it.json +425 -0
  76. package/skills/archify/examples/locales/ja.json +425 -0
  77. package/skills/archify/examples/locales/ko.json +440 -0
  78. package/skills/archify/examples/locales/pt.json +425 -0
  79. package/skills/archify/examples/locales/ru.json +425 -0
  80. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  81. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  82. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  83. package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
  84. package/skills/archify/examples/web-app-rendered.html +13808 -0
  85. package/skills/archify/examples/web-app.architecture.json +41 -0
  86. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
  87. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  88. package/skills/archify/package.json +17 -0
  89. package/skills/archify/recipes/scenarios.mjs +430 -0
  90. package/skills/archify/references/architecture-layout-repair.md +18 -0
  91. package/skills/archify/references/authoring-contract.md +419 -0
  92. package/skills/archify/references/authoring-defaults.md +41 -0
  93. package/skills/archify/references/brand-marks.md +82 -0
  94. package/skills/archify/references/delivery-contract.md +588 -0
  95. package/skills/archify/references/repository-authoring.md +105 -0
  96. package/skills/archify/references/update-awareness.md +12 -0
  97. package/skills/archify/references/viewer-runtime.md +35 -0
  98. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  99. package/skills/archify/renderers/architecture/labels.mjs +215 -0
  100. package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
  101. package/skills/archify/renderers/architecture/routing.mjs +1157 -0
  102. package/skills/archify/renderers/dataflow/README.md +105 -0
  103. package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
  104. package/skills/archify/renderers/lifecycle/README.md +171 -0
  105. package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
  106. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
  107. package/skills/archify/renderers/sequence/README.md +129 -0
  108. package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
  109. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  110. package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
  111. package/skills/archify/renderers/shared/cli.mjs +451 -0
  112. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  113. package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
  114. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  115. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  116. package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
  117. package/skills/archify/renderers/shared/geometry.mjs +1941 -0
  118. package/skills/archify/renderers/shared/i18n.mjs +603 -0
  119. package/skills/archify/renderers/shared/layout-report.mjs +42 -0
  120. package/skills/archify/renderers/shared/legend.mjs +217 -0
  121. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  122. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  123. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  124. package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
  125. package/skills/archify/renderers/shared/repository-location.mjs +59 -0
  126. package/skills/archify/renderers/shared/route-quality.mjs +614 -0
  127. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  128. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  129. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  130. package/skills/archify/renderers/shared/utils.mjs +254 -0
  131. package/skills/archify/renderers/shared/validator.mjs +86 -0
  132. package/skills/archify/renderers/workflow/README.md +301 -0
  133. package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
  134. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
  135. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  136. package/skills/archify/schemas/README.md +256 -0
  137. package/skills/archify/schemas/architecture.schema.json +152 -0
  138. package/skills/archify/schemas/common.schema.json +181 -0
  139. package/skills/archify/schemas/dataflow.schema.json +254 -0
  140. package/skills/archify/schemas/lifecycle.schema.json +280 -0
  141. package/skills/archify/schemas/sequence.schema.json +234 -0
  142. package/skills/archify/schemas/workflow.schema.json +439 -0
  143. package/skills/archify/scripts/check-render-output.mjs +1388 -0
  144. package/skills/archify/scripts/check-update.mjs +1710 -0
  145. package/skills/archify/scripts/delivery-update-child.mjs +23 -0
  146. package/skills/archify/scripts/render-examples.mjs +26 -0
  147. package/skills/archify/scripts/update-contract.mjs +182 -0
  148. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,171 @@
1
+ # Lifecycle Renderer
2
+
3
+ Render `diagram_type: "lifecycle"` JSON files into the standard Archify HTML
4
+ template.
5
+
6
+ ```bash
7
+ node archify/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
8
+ ```
9
+
10
+ The renderer validates input against `archify/schemas/lifecycle.schema.json`
11
+ with the bundled standalone validator. No dependency installation is required.
12
+
13
+ If `output.html` is omitted, the renderer uses the required `meta.output` value
14
+ from the JSON file.
15
+
16
+ ## Input
17
+
18
+ Lifecycle JSON files must set:
19
+
20
+ ```json
21
+ {
22
+ "schema_version": 2,
23
+ "diagram_type": "lifecycle",
24
+ "meta": {
25
+ "title": "Deployment Release Lifecycle",
26
+ "output": "deployment-release-lifecycle.html"
27
+ },
28
+ "lanes": [],
29
+ "states": [],
30
+ "transitions": [],
31
+ "cards": []
32
+ }
33
+ ```
34
+
35
+ `schema_version` is `1` or `2`; author new diagrams as `2`. Lane ids `main`
36
+ (required) and `terminal` are reserved in both versions.
37
+
38
+ - **v2** renders one row per populated lane: `main` first, `terminal` last,
39
+ other lanes in `lanes[]` order, each titled in the left gutter. A complete
40
+ example lives at `archify/examples/deployment-release.lifecycle.json`.
41
+ - **v1** keeps the fixed three bands: `main` is the top phase band,
42
+ `terminal` the bottom outcome band, and every other lane shares the middle
43
+ event band, whose header joins their labels with ` + `. A complete example
44
+ lives at `archify/examples/agent-run.lifecycle.json`.
45
+
46
+ The schema lives at:
47
+
48
+ ```text
49
+ archify/schemas/lifecycle.schema.json
50
+ ```
51
+
52
+ ## Legend and state marks
53
+
54
+ State color follows `states[].type`: active and start are cyan, waiting amber,
55
+ decision purple, success green, failure rose, neutral and external slate.
56
+ Structure is drawn, not colored: every `start` state gets a UML initial marker
57
+ (a dot and arrow into its left side), and a state with no outgoing transition
58
+ gets a double border as a final state (in v1, a main state followed by
59
+ another main column is not final, because the implied rail continues).
60
+
61
+ The default legend derives kinds from `states[].type`; the `start` entry shows
62
+ the initial marker, and a non-interactive `final` entry appears when a final
63
+ state exists. Supported `meta.legend.entries` keys, in stable order, are
64
+ `start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, and
65
+ `external`. Labels and visibility may be overridden through the shared legend
66
+ contract; only kinds backed by rendered states receive Semantic Legend
67
+ controls.
68
+
69
+ State decorations share one top rail: the type sigil and `step` on the left,
70
+ the brand mark at the right corner, and the Viewer's runtime source badge just
71
+ left of the brand. Label layout reserves the badge's width whenever the state
72
+ has verified repository sources.
73
+
74
+ ## Layout budget (v2)
75
+
76
+ | Item | Value |
77
+ |------|-------|
78
+ | Columns | `col` 0–4, one x grid shared by every row |
79
+ | Default state | 140×64 (text 11px label, 8px sublabel and tag) |
80
+ | Column gap | 64px, widened until a labelled same-row neighbour transition fits beside its line; all gaps shrink toward 44px when the canvas would exceed the desktop readability budget of the smallest state text |
81
+ | Row gap | at least 120px, opened further for the horizontal tracks its routes need |
82
+ | Canvas | sized from the rows, columns, and measured legend when `meta.viewBox` is omitted; an authored `viewBox` is honored and validated |
83
+
84
+ Transitions without `via`, a channel, or a non-`auto` route use the v2 grid
85
+ router: neighbours in one row connect horizontally (a reciprocal pair runs as
86
+ two parallel lines), rows connect through the facing top/bottom sides with one
87
+ turn in a row gap, a state blocking a straight descent sends the route through
88
+ the empty corridor between columns, and an unlabelled route blocked in an
89
+ outer column loops around the outside of the grid. Each gap assigns tracks in
90
+ the order that minimizes crossings. There is no implied rail; a forward
91
+ transition between two `main` states without a `variant` renders as the
92
+ emphasized primary path. Showcase labels are ranked beside their line, then on
93
+ it, then outward past neighbouring parallels.
94
+
95
+ Explicit `fromSide` / `toSide` values remain authoritative. Pins that match the
96
+ grid router's chosen sides keep its routes and adaptive row gaps. If an automatic
97
+ transition pins a different side, the scene uses the shared side-aware obstacle
98
+ planner, retaining the v2 state grid and shared port spreading.
99
+
100
+ ## Layout budget (v1)
101
+
102
+ | Band | Lane id | Top y | Column centers | Default state |
103
+ |------|---------|-------|----------------|---------------|
104
+ | Phase | `main` (required) | 126 | `col` 0–4 → x = 94, 248, 402, 556, 710 | 118×62 |
105
+ | Event | any other id | 278 | `col` 0–2 → x = 402, 556, 710 | 126×58 |
106
+ | Outcome | `terminal` | 450 | `col` 0–2 → x = 402, 556, 710 | 118×58 |
107
+
108
+ Event and terminal columns are intentionally offset from the main rail:
109
+ event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
110
+ For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
111
+ and 4 respectively.
112
+
113
+ | Constant | Value |
114
+ |----------|-------|
115
+ | viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
116
+ | State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
117
+ | State spacing | ≥10px between any two states — checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
118
+ | Transition length | ≥32px between endpoints |
119
+ | Legend row | final baseline y = height − 36; extra measured rows wrap upward |
120
+
121
+ The primary lifecycle rail runs along the phase band and extends to the
122
+ furthest occupied phase column. Route presets for transitions: `straight`,
123
+ `drop` (bend at `channelY`, defaulting to the vertical midpoint),
124
+ `bottom-channel`, `top-channel`, `right-channel`, `left-channel`, explicit
125
+ `via` points, or the default `auto`. Multi-segment transitions get rounded
126
+ corners; tune them with `cornerRadius` (default 10, `0` for sharp bends).
127
+
128
+ Transition `label` and `note` are independently optional. A non-empty `note`
129
+ renders even when `label` is omitted or empty, using its existing secondary
130
+ text style on a single row and retaining the note's fine-detail visibility.
131
+ With both fields present, the note stays below the label. Notes participate in
132
+ automatic label placement, route-space reservation, and label collision checks;
133
+ the existing `labelAt`, `labelDx`, `labelDy`, and
134
+ `labelSegment` controls also position a note-only text block.
135
+
136
+ ## Design Rules
137
+
138
+ - Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
139
+ - Put the primary lifecycle on one horizontal row using the `main` lane; in v2,
140
+ author each step of it as a transition.
141
+ - In v2, place an interruption, recovery, or exit in the column of the state it
142
+ leaves so its transition drops straight down.
143
+ - Use `step` labels for ordered phases, such as `01`, `02`, and `03`.
144
+ - Use lower lanes only for interruptions, recovery, and terminal exits.
145
+ - Keep transition labels out of the main SVG unless the label is essential;
146
+ prefer node labels, tags, legend entries, and summary cards.
147
+ - Prefer axis-aligned lines and avoid crossings. Terminal exits should drop
148
+ vertically from their source event whenever possible. Explicit `straight`
149
+ routes remain supported; see the [authored routing contract](../../references/authoring-contract.md#executable-geometry-rules).
150
+ - Use `success` for completion, `failure` for failure/terminal exits,
151
+ `waiting` for pauses, and `decision` for quality gates.
152
+
153
+ Schema violations exit non-zero with path-prefixed messages annotated with the
154
+ element's id or label. The renderer additionally fails when it can detect
155
+ layout problems, including a missing `main` lane, duplicate state IDs, unknown
156
+ lanes, unknown transition endpoints, states outside the lifecycle area,
157
+ overlapping states (including across lanes), labels colliding with states or
158
+ other labels, labels wider than their state, unreadably short transitions, or
159
+ transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
160
+ bands remain intentional pass-through containers.
161
+ Text width is estimated CJK-aware: fullwidth glyphs count as two units.
162
+
163
+ Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
164
+ X crossings then fail with `composition/proper-crossing`; default `standard`
165
+ keeps them as artifact-receipt warnings. The final artifact check samples
166
+ rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
167
+ a separate gate warns in `standard` and fails in `showcase` when unrelated
168
+ transitions overlap for at least 8px. Shared semantic endpoints, point touches,
169
+ and shorter overlaps remain valid. Showcase also rejects any route segment
170
+ below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
171
+ stubs remain valid.
@@ -0,0 +1,429 @@
1
+ // Orthogonal router for schema_version 2 lifecycle diagrams.
2
+ //
3
+ // v2 states sit on a fixed grid: one row per lane and a shared column pitch,
4
+ // with empty gaps between rows and between columns. That structure lets
5
+ // every automatic transition use a small, predictable set of shapes instead
6
+ // of a general obstacle search:
7
+ // - neighbours in one row connect with a horizontal line;
8
+ // - other states in one row connect through the gap above (below for the
9
+ // first row);
10
+ // - states in different rows leave through the facing top/bottom side,
11
+ // turn once in a row gap, and enter the target's facing side; when a
12
+ // state blocks the straight descent, the route steps sideways through the
13
+ // empty corridor between two columns.
14
+ // Ports on each side are spread in the order of where their routes head, and
15
+ // the horizontal runs in each gap get their own tracks, ordered to minimize
16
+ // crossings. Reciprocal pairs therefore render as two parallel lines.
17
+
18
+ const PORT_GUTTER = 16;
19
+ const PORT_SPACING = 30;
20
+ const SNAP_LIMIT = 16;
21
+ const TRACK_TOP_CLEARANCE = 18;
22
+ const TRACK_BOTTOM_CLEARANCE = 18;
23
+ const PREFERRED_TRACK_SPACING = 22;
24
+ const MAX_TRACK_SPACING = 24;
25
+ const CORRIDOR_SPACING = 10;
26
+ const EXHAUSTIVE_TRACK_LIMIT = 7;
27
+
28
+ const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
29
+
30
+ function permutations(items) {
31
+ if (items.length <= 1) return [items];
32
+ return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)])
33
+ .map((rest) => [item, ...rest]));
34
+ }
35
+
36
+ export function createLifecycleGridRouter(states, transitions, { rowOf, columnXs }) {
37
+ const byRow = new Map();
38
+ for (const state of states.values()) {
39
+ const row = rowOf(state);
40
+ if (!Number.isInteger(row)) continue;
41
+ if (!byRow.has(row)) byRow.set(row, []);
42
+ byRow.get(row).push(state);
43
+ }
44
+ const rows = [...byRow.keys()].sort((a, b) => a - b);
45
+ const rowTop = new Map(rows.map((row) => [row, Math.min(...byRow.get(row).map((s) => s.y))]));
46
+ const rowBottom = new Map(rows.map((row) => [row, Math.max(...byRow.get(row).map((s) => s.y + s.height))]));
47
+ const nextRow = (row) => rows.find((candidate) => candidate > row);
48
+ const previousRow = (row) => [...rows].reverse().find((candidate) => candidate < row);
49
+
50
+ // Gap g sits below row g. The gap under the last row borders the legend,
51
+ // so it only receives tracks when nothing else is possible.
52
+ function gapBand(row) {
53
+ const below = nextRow(row);
54
+ const top = rowBottom.get(row) + TRACK_TOP_CLEARANCE;
55
+ const bottom = below === undefined ? rowBottom.get(row) + 40 : rowTop.get(below) - TRACK_BOTTOM_CLEARANCE;
56
+ return [top, Math.max(top, bottom)];
57
+ }
58
+
59
+ function blocksVertical(x, fromRow, toRow, exclude) {
60
+ const [low, high] = fromRow < toRow ? [fromRow, toRow] : [toRow, fromRow];
61
+ return [...states.values()].some((state) => {
62
+ if (exclude.has(state.id)) return false;
63
+ const row = rowOf(state);
64
+ return row > low && row < high && x >= state.x - 6 && x <= state.x + state.width + 6;
65
+ });
66
+ }
67
+
68
+ function blocksHorizontal(from, to) {
69
+ const row = rowOf(from);
70
+ const [left, right] = from.cx < to.cx ? [from, to] : [to, from];
71
+ return byRow.get(row).some((state) => state !== left && state !== right
72
+ && state.x < right.x && state.x + state.width > left.x + left.width
73
+ && state.y < Math.max(left.y + left.height, right.y + right.height)
74
+ && state.y + state.height > Math.min(left.y, right.y));
75
+ }
76
+
77
+ // The empty corridors between neighbouring columns.
78
+ const corridorXs = columnXs.slice(1).map((x, index) => (x + columnXs[index]) / 2);
79
+ const allStates = [...states.values()];
80
+ const leftmostX = Math.min(...allStates.map((state) => state.cx));
81
+ const rightmostX = Math.max(...allStates.map((state) => state.cx));
82
+ const gridLeft = Math.min(...allStates.map((state) => state.x));
83
+ const gridRight = Math.max(...allStates.map((state) => state.x + state.width));
84
+ // The initial-state marker occupies a start state's left side.
85
+ function loopBlocked(from, to, side) {
86
+ if (side !== 'left') return false;
87
+ const [low, high] = [Math.min(rowOf(from), rowOf(to)), Math.max(rowOf(from), rowOf(to))];
88
+ return allStates.some((state) => state.type === 'start' && Math.abs(state.cx - from.cx) < 1
89
+ && rowOf(state) >= low && rowOf(state) <= high);
90
+ }
91
+
92
+ // Plan: sides, the gap each horizontal run uses, and where each end heads.
93
+ const plans = new Map();
94
+ for (const transition of transitions) {
95
+ const from = states.get(transition.from);
96
+ const to = states.get(transition.to);
97
+ if (!from || !to || from === to) continue;
98
+ const fromRow = rowOf(from);
99
+ const toRow = rowOf(to);
100
+ if (!Number.isInteger(fromRow) || !Number.isInteger(toRow)) continue;
101
+ const exclude = new Set([from.id, to.id]);
102
+ if (fromRow === toRow) {
103
+ if (!blocksHorizontal(from, to)) {
104
+ const fromSide = to.cx > from.cx ? 'right' : 'left';
105
+ plans.set(transition, { kind: 'horizontal', from, to, fromSide, toSide: opposite[fromSide] });
106
+ } else {
107
+ const gapRow = previousRow(fromRow);
108
+ const useAbove = gapRow !== undefined;
109
+ const side = useAbove ? 'top' : 'bottom';
110
+ plans.set(transition, {
111
+ kind: 'channel', from, to, fromSide: side, toSide: side,
112
+ runs: [{ gap: useAbove ? gapRow : fromRow, legs: useAbove ? ['down', 'down'] : ['up', 'up'] }],
113
+ });
114
+ }
115
+ continue;
116
+ }
117
+ const down = toRow > fromRow;
118
+ const fromSide = down ? 'bottom' : 'top';
119
+ const toSide = down ? 'top' : 'bottom';
120
+ const gapNearTarget = down ? previousRow(toRow) : toRow;
121
+ const gapNearSource = down ? fromRow : previousRow(fromRow);
122
+ const legs = down ? ['up', 'down'] : ['down', 'up'];
123
+ if (!blocksVertical(from.cx, fromRow, toRow, exclude) && Math.abs(from.cx - to.cx) < 1) {
124
+ plans.set(transition, { kind: 'vertical', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
125
+ } else if (!blocksVertical(from.cx, fromRow, toRow, exclude)) {
126
+ plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
127
+ } else if (!blocksVertical(to.cx, fromRow, toRow, exclude)) {
128
+ plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearSource, legs }] });
129
+ } else if (!transition.label && !transition.note
130
+ && Math.abs(from.cx - to.cx) < 1 && (from.cx <= leftmostX || from.cx >= rightmostX)
131
+ && !loopBlocked(from, to, from.cx <= leftmostX ? 'left' : 'right')) {
132
+ // A blocked edge column loops around the outside of the grid, like a
133
+ // bracket, instead of weaving through the rows' interior corridors.
134
+ // The margin has no room for a label, so only unlabeled edges loop.
135
+ const side = from.cx <= leftmostX ? 'left' : 'right';
136
+ plans.set(transition, { kind: 'loop', from, to, fromSide: side, toSide: side, side });
137
+ } else {
138
+ const middle = (from.cx + to.cx) / 2;
139
+ const corridor = corridorXs
140
+ .filter((x) => !blocksVertical(x, fromRow, toRow, new Set()))
141
+ .sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle))[0];
142
+ plans.set(transition, corridor === undefined
143
+ ? { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] }
144
+ : {
145
+ kind: 'corridor', from, to, fromSide, toSide, corridor,
146
+ runs: [{ gap: gapNearSource, legs }, { gap: gapNearTarget, legs }],
147
+ });
148
+ }
149
+ }
150
+
151
+ // Corridor offsets: routes sharing one corridor run side by side.
152
+ const corridorUse = new Map();
153
+ for (const plan of plans.values()) {
154
+ if (plan.kind !== 'corridor') continue;
155
+ const list = corridorUse.get(plan.corridor) || [];
156
+ list.push(plan);
157
+ corridorUse.set(plan.corridor, list);
158
+ }
159
+ for (const [x, list] of corridorUse) {
160
+ list.sort((a, b) => a.from.cx - b.from.cx || a.to.cx - b.to.cx);
161
+ list.forEach((plan, index) => { plan.corridorX = x + (index - (list.length - 1) / 2) * CORRIDOR_SPACING; });
162
+ }
163
+ // Outer loops nest: a longer span sits further out so loops never cross.
164
+ for (const side of ['left', 'right']) {
165
+ const loops = [...plans.values()].filter((plan) => plan.kind === 'loop' && plan.side === side)
166
+ .sort((a, b) => Math.abs(rowOf(a.from) - rowOf(a.to)) - Math.abs(rowOf(b.from) - rowOf(b.to)));
167
+ loops.forEach((plan, index) => {
168
+ plan.loopX = side === 'left' ? gridLeft - 18 - index * CORRIDOR_SPACING : gridRight + 18 + index * CORRIDOR_SPACING;
169
+ });
170
+ }
171
+
172
+ // Where each end heads after leaving its side, used to order the ports.
173
+ function headingFor(plan, end) {
174
+ const self = end === 'source' ? plan.from : plan.to;
175
+ const other = end === 'source' ? plan.to : plan.from;
176
+ if (plan.kind === 'horizontal' || plan.kind === 'loop') return other.cy;
177
+ if (plan.kind === 'corridor') return plan.corridorX;
178
+ return other === self ? self.cx : other.cx;
179
+ }
180
+
181
+ const sideEnds = new Map();
182
+ for (const [transition, plan] of plans) {
183
+ for (const end of ['source', 'target']) {
184
+ const state = end === 'source' ? plan.from : plan.to;
185
+ const side = end === 'source' ? plan.fromSide : plan.toSide;
186
+ const key = `${state.id}:${side}`;
187
+ if (!sideEnds.has(key)) sideEnds.set(key, { state, side, ends: [] });
188
+ // Movers in the positive direction (right/down) take the first slot so
189
+ // both ends of a reciprocal pair line up.
190
+ const positive = plan.kind === 'horizontal'
191
+ ? plan.to.cx > plan.from.cx
192
+ : rowOf(plan.to) > rowOf(plan.from) || (rowOf(plan.to) === rowOf(plan.from) && plan.to.cx > plan.from.cx);
193
+ sideEnds.get(key).ends.push({ transition, end, heading: headingFor(plan, end), positive });
194
+ }
195
+ }
196
+
197
+ const ports = new Map();
198
+ for (const { state, side, ends } of sideEnds.values()) {
199
+ ends.sort((a, b) => a.heading - b.heading || Number(b.positive) - Number(a.positive));
200
+ const horizontalSide = side === 'top' || side === 'bottom';
201
+ const length = horizontalSide ? state.width : state.height;
202
+ const gutter = Math.min(PORT_GUTTER, length / 4);
203
+ const spacing = ends.length > 1 ? Math.min(PORT_SPACING, (length - gutter * 2) / (ends.length - 1)) : 0;
204
+ ends.forEach((entry, index) => {
205
+ const offset = (index - (ends.length - 1) / 2) * spacing;
206
+ const point = horizontalSide
207
+ ? [state.cx + offset, side === 'top' ? state.y : state.y + state.height]
208
+ : [side === 'left' ? state.x : state.x + state.width, state.cy + offset];
209
+ const record = ports.get(entry.transition) || {};
210
+ record[entry.end] = point;
211
+ ports.set(entry.transition, record);
212
+ });
213
+ }
214
+
215
+ // Straight connections whose spread ports landed a few px apart would
216
+ // otherwise need a jog shorter than a readable turn: move one end onto the
217
+ // other's line when that side still has room there.
218
+ function sideRange(state, side) {
219
+ return side === 'top' || side === 'bottom'
220
+ ? [state.x + PORT_GUTTER / 2, state.x + state.width - PORT_GUTTER / 2]
221
+ : [state.y + PORT_GUTTER / 2, state.y + state.height - PORT_GUTTER / 2];
222
+ }
223
+ function portsOnSide(state, side, except) {
224
+ return (sideEnds.get(`${state.id}:${side}`)?.ends || [])
225
+ .filter((entry) => entry.transition !== except)
226
+ .map((entry) => ports.get(entry.transition)[entry.end]);
227
+ }
228
+ for (const [transition, plan] of plans) {
229
+ const record = ports.get(transition);
230
+ const axis = plan.kind === 'horizontal' ? 1 : 0;
231
+ const delta = Math.abs(record.source[axis] - record.target[axis]);
232
+ if (delta < 0.5 || delta >= SNAP_LIMIT || !(plan.kind === 'horizontal' || plan.kind === 'vertical' || plan.kind === 'channel')) continue;
233
+ for (const [end, fixed] of [['target', 'source'], ['source', 'target']]) {
234
+ const state = end === 'source' ? plan.from : plan.to;
235
+ const side = end === 'source' ? plan.fromSide : plan.toSide;
236
+ const value = record[fixed][axis];
237
+ const [low, high] = sideRange(state, side);
238
+ const crowded = portsOnSide(state, side, transition).some((point) => Math.abs(point[axis] - value) < 10);
239
+ if (value >= low && value <= high && !crowded) {
240
+ record[end] = axis ? [record[end][0], value] : [value, record[end][1]];
241
+ break;
242
+ }
243
+ }
244
+ }
245
+
246
+ // Horizontal runs per gap, then tracks ordered to minimize crossings.
247
+ const runsByGap = new Map();
248
+ for (const [transition, plan] of plans) {
249
+ if (plan.kind === 'horizontal' || plan.kind === 'loop') continue;
250
+ const { source, target } = ports.get(transition);
251
+ plan.runs.forEach((run, index) => {
252
+ const x1 = index === 0 ? source[0] : plan.corridorX;
253
+ const x2 = plan.kind === 'corridor' && index === 0 ? plan.corridorX : target[0];
254
+ if (plan.kind !== 'corridor' && Math.abs(x1 - x2) < 0.5) return;
255
+ const entry = { transition, index, x1, x2, legs: run.legs };
256
+ if (!runsByGap.has(run.gap)) runsByGap.set(run.gap, []);
257
+ runsByGap.get(run.gap).push(entry);
258
+ });
259
+ }
260
+
261
+ const trackY = new Map();
262
+ const trackCounts = new Map();
263
+ for (const [gap, runs] of runsByGap) {
264
+ const [top, bottom] = gapBand(gap);
265
+ // Greedy interval colouring keeps unrelated runs on shared tracks only
266
+ // when they do not overlap.
267
+ const sorted = [...runs].sort((a, b) => Math.min(a.x1, a.x2) - Math.min(b.x1, b.x2));
268
+ const classes = [];
269
+ for (const run of sorted) {
270
+ const low = Math.min(run.x1, run.x2) - 8;
271
+ const high = Math.max(run.x1, run.x2) + 8;
272
+ let target = classes.find((members) => members.every((other) => (
273
+ high < Math.min(other.x1, other.x2) - 8 || low > Math.max(other.x1, other.x2) + 8
274
+ )));
275
+ if (!target || runs.length <= EXHAUSTIVE_TRACK_LIMIT) {
276
+ target = [];
277
+ classes.push(target);
278
+ }
279
+ target.push(run);
280
+ }
281
+ const count = classes.length;
282
+ trackCounts.set(gap, count);
283
+ // The renderer sizes each gap for its track count; a fixed canvas that
284
+ // cannot grow compresses the tracks rather than leaving the gap.
285
+ const spacing = count > 1 ? Math.min(MAX_TRACK_SPACING, (bottom - top) / (count - 1)) : 0;
286
+ const center = (top + bottom) / 2;
287
+ const ys = classes.map((_, index) => center + (index - (count - 1) / 2) * spacing);
288
+ const crossings = (order) => {
289
+ const y = new Map();
290
+ order.forEach((members, index) => members.forEach((run) => y.set(run, ys[index])));
291
+ let total = 0;
292
+ for (const a of runs) {
293
+ for (const b of runs) {
294
+ if (a === b || a.transition === b.transition) continue;
295
+ const [low, high] = [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2)];
296
+ for (const [x, leg] of [[b.x1, b.legs[0]], [b.x2, b.legs[1]]]) {
297
+ // An up leg and a down leg on one x overlap when the down leg
298
+ // starts above where the up leg ends: that merges two routes.
299
+ for (const [ax, aLeg] of [[a.x1, a.legs[0]], [a.x2, a.legs[1]]]) {
300
+ if (Math.abs(ax - x) < 1 && aLeg === 'up' && leg === 'down' && y.get(b) < y.get(a)) total += 100;
301
+ }
302
+ if (x <= low + 0.5 || x >= high - 0.5) continue;
303
+ if ((leg === 'up' && y.get(a) < y.get(b)) || (leg === 'down' && y.get(a) > y.get(b))) total += 1;
304
+ }
305
+ }
306
+ }
307
+ return total;
308
+ };
309
+ let best = classes;
310
+ let bestScore = crossings(best);
311
+ if (count <= EXHAUSTIVE_TRACK_LIMIT) {
312
+ for (const order of permutations(classes).slice(1)) {
313
+ const score = crossings(order);
314
+ if (score < bestScore) {
315
+ best = order;
316
+ bestScore = score;
317
+ }
318
+ }
319
+ } else {
320
+ // Too many tracks to enumerate: swap pairs while that still helps.
321
+ for (let improved = true; improved;) {
322
+ improved = false;
323
+ for (let i = 0; i < count && !improved; i += 1) {
324
+ for (let j = i + 1; j < count && !improved; j += 1) {
325
+ const order = [...best];
326
+ [order[i], order[j]] = [order[j], order[i]];
327
+ const score = crossings(order);
328
+ if (score < bestScore) {
329
+ best = order;
330
+ bestScore = score;
331
+ improved = true;
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ best.forEach((members, index) => members.forEach((run) => trackY.set(`${gap}:${run.index}:${transitions.indexOf(run.transition)}`, ys[index])));
338
+ }
339
+
340
+ const pathCache = new Map();
341
+ function pointsFor(transition) {
342
+ const plan = plans.get(transition);
343
+ if (!plan) {
344
+ // Self transitions and unknown endpoints are rejected by validation;
345
+ // a degenerate stub keeps that diagnostic reachable.
346
+ const state = states.get(transition.from) || states.get(transition.to);
347
+ const point = state ? [state.x + state.width, state.cy] : [0, 0];
348
+ return [point, point];
349
+ }
350
+ const { source, target } = ports.get(transition);
351
+ if (plan.kind === 'horizontal') {
352
+ if (Math.abs(source[1] - target[1]) < 0.5) return [source, target];
353
+ const x = (source[0] + target[0]) / 2;
354
+ return [source, [x, source[1]], [x, target[1]], target];
355
+ }
356
+ if (plan.kind === 'loop') return [source, [plan.loopX, source[1]], [plan.loopX, target[1]], target];
357
+ const trackFor = (index) => trackY.get(`${plan.runs[index].gap}:${index}:${transitions.indexOf(transition)}`);
358
+ if (plan.kind === 'corridor') {
359
+ const y1 = trackFor(0);
360
+ const y2 = trackFor(1);
361
+ return [source, [source[0], y1], [plan.corridorX, y1], [plan.corridorX, y2], [target[0], y2], target];
362
+ }
363
+ if (Math.abs(source[0] - target[0]) < 0.5) return [source, target];
364
+ const y = trackFor(0);
365
+ return [source, [source[0], y], [target[0], y], target];
366
+ }
367
+
368
+ // Ports of two routes can still line up across a gap so their vertical
369
+ // runs share one line. Nudge a turning route's end sideways on its side.
370
+ for (const transition of plans.keys()) pathCache.set(transition, pointsFor(transition));
371
+ const verticals = (points) => points.slice(1).flatMap((end, index) => {
372
+ const start = points[index];
373
+ if (Math.abs(start[0] - end[0]) >= 0.5 || Math.abs(start[1] - end[1]) < 0.5) return [];
374
+ return [{ x: start[0], low: Math.min(start[1], end[1]), high: Math.max(start[1], end[1]), index, last: index === points.length - 2 }];
375
+ });
376
+ for (let round = 0; round < 12; round += 1) {
377
+ const entries = [...pathCache.entries()];
378
+ let conflict = null;
379
+ for (let i = 0; i < entries.length && !conflict; i += 1) {
380
+ for (let j = i + 1; j < entries.length && !conflict; j += 1) {
381
+ for (const left of verticals(entries[i][1])) {
382
+ const right = verticals(entries[j][1]).find((other) => Math.abs(other.x - left.x) < 1
383
+ && Math.min(other.high, left.high) - Math.max(other.low, left.low) > 0.5);
384
+ if (right) {
385
+ conflict = [[entries[i][0], left], [entries[j][0], right]];
386
+ break;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ if (!conflict) break;
392
+ let moved = false;
393
+ for (const [transition, segment] of conflict) {
394
+ const plan = plans.get(transition);
395
+ const end = segment.index === 0 ? 'source' : segment.last ? 'target' : null;
396
+ if (!end || pathCache.get(transition).length < 4 || !['channel', 'corridor'].includes(plan.kind)) continue;
397
+ const state = end === 'source' ? plan.from : plan.to;
398
+ const side = end === 'source' ? plan.fromSide : plan.toSide;
399
+ const record = ports.get(transition);
400
+ const [low, high] = sideRange(state, side);
401
+ const x = [12, -12, 20, -20].map((delta) => record[end][0] + delta).find((candidate) => (
402
+ candidate >= low && candidate <= high
403
+ && !portsOnSide(state, side, transition).some((point) => Math.abs(point[0] - candidate) < 10)
404
+ ));
405
+ if (x === undefined) continue;
406
+ record[end] = [x, record[end][1]];
407
+ pathCache.set(transition, pointsFor(transition));
408
+ moved = true;
409
+ break;
410
+ }
411
+ if (!moved) break;
412
+ }
413
+
414
+ return {
415
+ // Height a gap below `row` needs for its tracks at a readable spacing.
416
+ gapHeight(row) {
417
+ const count = trackCounts.get(row) || 0;
418
+ return TRACK_TOP_CLEARANCE + TRACK_BOTTOM_CLEARANCE + Math.max(0, count - 1) * PREFERRED_TRACK_SPACING;
419
+ },
420
+ connectionSides(transition) {
421
+ const plan = plans.get(transition);
422
+ return plan ? { fromSide: plan.fromSide, toSide: plan.toSide } : { fromSide: 'right', toSide: 'right' };
423
+ },
424
+ pathFor(transition) {
425
+ if (!pathCache.has(transition)) pathCache.set(transition, pointsFor(transition));
426
+ return pathCache.get(transition);
427
+ },
428
+ };
429
+ }