@nodius/layouting 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,8 +36,11 @@ connected by typed edges, with parent/child grouping and side-attached values.
36
36
  - [API](#api)
37
37
  - [Types](#types)
38
38
  - [Algorithm internals](#algorithm-internals)
39
+ - [The phases in depth](#the-phases-in-depth)
40
+ - [Which knob affects which phase](#which-knob-affects-which-phase)
39
41
  - [Debugging](#debugging)
40
42
  - [Playground](#playground)
43
+ - [⚙ Optimizations panel](#-optimizations-panel)
41
44
  - [Development](#development)
42
45
 
43
46
  ---
@@ -542,12 +545,27 @@ Mechanism:
542
545
 
543
546
  1. The engine lays out the graph **once** with the input handles to discover
544
547
  where every node actually ends up.
545
- 2. For each node with at least one connected handle, it groups handles by id,
546
- sums per-handle votes for each side (weighted by `1 / distance` so close
547
- neighbors win), and picks the strongest side per handle.
548
- 3. If at least one handle would change side, it bundles all the moves into
549
- one `RelocateHandlesProposal` for that node with a `changes` map you can
550
- inspect to see exactly what would move.
548
+ 2. For each node with at least one connected handle, each handle is assigned a
549
+ side using a **flow-aware** rule rather than raw center-to-center direction:
550
+ - A neighbor in a **different rank band** (a layout predecessor / successor)
551
+ gets a handle on the **flow axis** top/bottom in TB, left/right in LR
552
+ *even when it is offset sideways*. This is what keeps a hub's children on
553
+ the bottom edge instead of fanning chaotically onto its flanks.
554
+ - A neighbor that **overlaps the same rank band** (a true sidecar / sibling)
555
+ gets a handle on the **perpendicular axis** (the flank facing it).
556
+ - **Lopsided correction:** when a node's flow neighbors all sit on one side
557
+ of its order-center (a one-directional fan), the nearest one stays on the
558
+ flow edge and farther outliers move to the flank — they would otherwise
559
+ crowd and cross. A *balanced* fan (children on both sides) stays entirely
560
+ on the flow edge.
561
+ Handles serving several edges still vote per side, weighted by `1 / distance`
562
+ so the closest neighbor wins.
563
+ 3. When two or more handles end up on the **same side**, their offsets are
564
+ permuted to match the order of their neighbors along that edge — so the
565
+ edges leaving a shared side never cross each other.
566
+ 4. If any handle would change side or offset, it bundles all the moves into one
567
+ `RelocateHandlesProposal` for that node — with a `changes` map you can
568
+ inspect to see exactly which sides would move.
551
569
 
552
570
  #### Example: a multi-handle hub
553
571
 
@@ -1064,9 +1082,12 @@ The engine is a modified **Sugiyama algorithm** with these phases (in order):
1064
1082
  - **Rotate pass**: scan input nodes; emit `RotateProposal` when the whole
1065
1083
  node faces the wrong axis for the chosen direction.
1066
1084
  - **Relocate pass**: run a preview layout, then emit
1067
- `RelocateHandlesProposal` per node with each handle's optimal side
1068
- (computed from the preview's actual neighbor positions). The
1069
- application accepts/rejects each proposal via `onProposal`.
1085
+ `RelocateHandlesProposal` per node with each handle's optimal side. Side
1086
+ selection is **flow-aware** (flow-axis for cross-rank neighbors,
1087
+ perpendicular for same-rank sidecars, with a lopsided-fan correction) and
1088
+ handles sharing a side are **offset-ordered** to match their neighbors so
1089
+ edges don't cross. The application accepts/rejects each proposal via
1090
+ `onProposal`.
1070
1091
  2. **Compound resolution** — group nodes by `parentId`; recursively lay out
1071
1092
  each compound's children bottom-up so the compound's bounding box is
1072
1093
  known before it appears at the parent level.
@@ -1088,6 +1109,112 @@ The engine is a modified **Sugiyama algorithm** with these phases (in order):
1088
1109
  10. **Component packing** — disjoint components packed along the order axis;
1089
1110
  compound groups travel as a single block.
1090
1111
 
1112
+ ### The phases in depth
1113
+
1114
+ Each phase is a small, independently testable module under `src/algorithms/`.
1115
+ Understanding what each one does explains *why* the optimization knobs in the
1116
+ playground behave the way they do.
1117
+
1118
+ #### Cycle breaking (`cycle-breaking.ts`)
1119
+
1120
+ A layered layout requires a DAG, but real graphs have feedback loops (retry
1121
+ edges, `Check → Process` in the `Cycle Example`). A DFS visits nodes ordered by
1122
+ out-degree minus in-degree (sources first), and any edge pointing back at a
1123
+ *gray* (in-progress) node is a **back edge** — it gets reversed so the graph
1124
+ becomes acyclic. The reversal is remembered (`reversed: true`) so edge routing
1125
+ can draw the arrow in its original direction. Cost: `O(V + E)`, negligible.
1126
+
1127
+ #### Layer assignment (`layer-assignment.ts`)
1128
+
1129
+ Two passes. **Pass A** runs a longest-path on *control* edges only, between
1130
+ non-value nodes — this fixes the execution "rail" (`Start` at layer 0, each
1131
+ successor one layer deeper). **Pass B** pulls every value onto the **median**
1132
+ layer of its non-value neighbors, so a `CONFIG` constant snaps next to the node
1133
+ that consumes it instead of extending the rail. Then long control edges get
1134
+ **dummy nodes** inserted (one per layer crossed) so the next phases only ever
1135
+ reason about adjacent layers. Dummies dominate the internal node count — a
1136
+ 100-node graph can become ~665 internal nodes, which is why the heavy phases
1137
+ scale with *dummies*, not your input size.
1138
+
1139
+ #### Crossing minimization (`crossing-minimization.ts`) — the hot phase
1140
+
1141
+ This is the **barycenter heuristic**: repeatedly sweep down then up the layers,
1142
+ and within each layer reorder nodes by the average position of their neighbors
1143
+ in the adjacent layer. After each sweep, a **transpose** pass greedily swaps
1144
+ adjacent pairs whenever the swap reduces crossings. Crossings are counted with
1145
+ **merge-sort inversion counting** — `O(E log V)` instead of the naive `O(E²)`.
1146
+ The loop stops early when it reaches zero crossings or stops improving.
1147
+
1148
+ This phase is **40–67 % of total time**, which is exactly why the playground
1149
+ exposes:
1150
+
1151
+ - `crossingMinimizationIterations` — how many sweeps (more = fewer crossings,
1152
+ diminishing returns);
1153
+ - `skipTranspose` — drop the transpose pass entirely (the single biggest cost).
1154
+
1155
+ The engine already auto-scales iterations down and disables transpose for very
1156
+ large graphs; the knobs let you push further.
1157
+
1158
+ #### Coordinate assignment (`coordinate-assignment.ts`) — the other hot phase
1159
+
1160
+ Two sub-steps. First, each layer is placed along the **rank axis** (Y for TB,
1161
+ X for LR) at a cumulative offset. Then the **order axis** position is optimized
1162
+ iteratively: each node is pulled toward the **median** of its connected
1163
+ neighbors' centers, then a forward + backward pass enforces minimum spacing so
1164
+ nodes never overlap. `coordinateOptimizationIterations` controls how many
1165
+ align/repair sweeps run — more iterations = straighter edges, ~20–50 % of total
1166
+ time.
1167
+
1168
+ #### Sidecar value placement (`value-placement.ts`)
1169
+
1170
+ Values were excluded from the rail; here they're re-attached. Each value's
1171
+ *dominant consumer* (the non-value neighbor it shares the most edges with) is
1172
+ found, values are split onto the consumer's two flanks based on which side the
1173
+ connecting handle sits, then stacked outward by handle offset. Cheap: `< 1 %`.
1174
+
1175
+ #### Edge routing (`edge-routing.ts`)
1176
+
1177
+ Dummy chains are collapsed back into single logical edges, then each edge is
1178
+ drawn as an **orthogonal path**: exit the source handle along its facing
1179
+ direction, fold through the dummy waypoints at midpoints, enter the target
1180
+ handle. Collinear points are cleaned up. `edgeMargin` controls how far the path
1181
+ travels straight out of a handle before turning.
1182
+
1183
+ #### Component packing (`component-packing.ts`)
1184
+
1185
+ A union-find groups nodes into connected components (compound children stay with
1186
+ their parent), each component's bounding box is computed, and the boxes are laid
1187
+ side-by-side along the order axis largest-first — turning a long ribbon into a
1188
+ roughly square canvas. Toggle it off with `packComponents: false` (or the
1189
+ playground checkbox) to see each component laid out independently at the origin.
1190
+
1191
+ ### Which knob affects which phase
1192
+
1193
+ Everything you can change in the playground's **⚙ Optimizations** panel (and via
1194
+ `LayoutOptions`) maps directly onto one of the phases above:
1195
+
1196
+ | Knob / option | Phase affected | Effect |
1197
+ |----------------------------------------|---------------------------------|-----------------------------------------------------|
1198
+ | `quality: 'draft' \| 'balanced' \| 'high'` | crossing + coordinate | Bulk preset: scales both iteration counts and toggles transpose. |
1199
+ | `crossingMinimizationIterations` | crossing minimization | More sweeps → fewer crossings, more time. |
1200
+ | `skipTranspose` | crossing minimization | Drops the transpose pass → big speedup, a few more crossings. |
1201
+ | `coordinateOptimizationIterations` | coordinate assignment | More sweeps → straighter edges, more time. |
1202
+ | `nodeSpacing` / `layerSpacing` | coordinate assignment | Minimum gaps along order / rank axes. |
1203
+ | `edgeMargin` | edge routing | Straight run-out length before a path turns. |
1204
+ | `packComponents` | component packing | On/off side-by-side packing of disjoint subgraphs. |
1205
+ | `compoundPadding` | compound resolution | Inner padding of compound bounding boxes. |
1206
+ | `edgeWeights` / `kind` | layer assignment | Control vs data classification (rail vs sidecar). |
1207
+ | `onProposal` | proposals (pre-pass) | Accept/reject rotate + relocate handle suggestions. |
1208
+
1209
+ The `quality` preset is just a convenient bundle — the table below shows exactly
1210
+ what each preset sets, and any explicit option overrides it:
1211
+
1212
+ | preset | crossing iters | coordinate iters | transpose |
1213
+ |-------------|---------------:|-----------------:|-----------|
1214
+ | `draft` | 6 | 2 | skipped |
1215
+ | `balanced` | 24 | 8 | on |
1216
+ | `high` | 48 | 16 | on |
1217
+
1091
1218
  ### Why the rail is laid out without values
1092
1219
 
1093
1220
  Including values in the rail's layer assignment would inflate the layer's
@@ -1190,6 +1317,38 @@ Toolbar toggles:
1190
1317
  `Pub/Sub Broker`, or `Wrong Handles Everywhere` to see how bad the layout
1191
1318
  looks without it; toggle it back on to watch every handle snap into place.
1192
1319
 
1320
+ ### ⚙ Optimizations panel
1321
+
1322
+ Click **⚙ Optimizations** in the toolbar to open the optimization controls.
1323
+ These let you exercise each speed/quality knob live and see the effect on both
1324
+ the rendered layout and the timing read-out:
1325
+
1326
+ | Control | What it does |
1327
+ |------------------------|------------------------------------------------------------------------------|
1328
+ | **Quality** | `draft` / `balanced` (default) / `high` preset. Re-layouts on change so you can compare the visual quality and the timing. |
1329
+ | **Skip transpose** | Force-skips the per-layer transpose pass (the single largest cost in balanced mode) independently of the preset. |
1330
+ | **Crossing iter** | Numeric override for crossing-minimization iterations. Empty = use the preset's value. |
1331
+ | **Coord iter** | Numeric override for coordinate-optimization iterations. Empty = use the preset's value. |
1332
+ | **⏱ Compare presets** | Runs `draft`, `balanced`, and `high` on the current input (rep count auto-scaled to graph size) and prints a best/mean timing table with the ratio vs `balanced`. |
1333
+ | **active-opts badge** | A live read-out of the effective options, e.g. `quality=balanced · skipTranspose · xIter=4 · cIter=1`. |
1334
+
1335
+ **What to try:**
1336
+
1337
+ - Load a small example (`Simple Chain`) → all presets read ~0 ms; the layout
1338
+ is dominated by fixed setup, so the preset barely matters.
1339
+ - Build or paste a larger graph (a few hundred nodes) and hit **⏱ Compare
1340
+ presets** → `draft` is typically ~0.7–0.8× the balanced time, `high` ~1.2–1.5×.
1341
+ - Switch **Quality** between `draft` and `high` on a dense example and watch the
1342
+ number of edge crossings change while the timing tracks the preset.
1343
+
1344
+ Every numeric override wins over the preset, and **Skip transpose** stacks on
1345
+ top of any preset — so `quality=draft + skipTranspose` is the fastest possible
1346
+ configuration, while `quality=high` with explicit high iteration counts is the
1347
+ slowest / highest quality.
1348
+
1349
+ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the full benchmark tables and
1350
+ Web Worker / WASM / WebGPU notes.
1351
+
1193
1352
  ---
1194
1353
 
1195
1354
  ## Development
@@ -1197,7 +1356,7 @@ Toolbar toggles:
1197
1356
  ```bash
1198
1357
  npm install
1199
1358
 
1200
- npm test # 93 tests cover compound, sidecars, rotate + relocate proposals, perf, cycles…
1359
+ npm test # 99 tests cover compound, sidecars, rotate + relocate proposals, quality presets, perf, cycles…
1201
1360
  npm run test:watch
1202
1361
  npm run build # tsup bundle + tsc declaration files
1203
1362
 
@@ -1217,6 +1376,35 @@ npm run dev # vite on http://localhost:6501
1217
1376
  acceptance, partial accept, value-specific rotation suggestions.
1218
1377
  - `tests/diag-values.test.ts` — `printLayout` smoke test on the Floating
1219
1378
  Values scenario.
1379
+ - `tests/quality-presets.test.ts` — `quality` presets: `balanced` equals the
1380
+ default, `draft`/`high` produce valid non-overlapping layouts, explicit
1381
+ iteration counts override the preset, and `draft` is measurably faster.
1382
+
1383
+ ### Snapshot regression harness
1384
+
1385
+ `scripts/snapshot.ts` captures a golden master of `layout()` output for the
1386
+ whole example corpus in all four directions plus random DAGs up to 500 nodes,
1387
+ in **two modes** — `plain` (no proposals) and `proposal` (auto-rotate accepted,
1388
+ `onProposal: p => p.proposed`, like the playground default) — for 182 snapshots
1389
+ total. The plain set guards that performance work keeps the **`balanced`**
1390
+ output bit-identical; the proposal set guards the rotate + relocate handle
1391
+ placement:
1392
+
1393
+ ```bash
1394
+ npx tsx scripts/snapshot.ts capture # write baseline/ (plain + proposal)
1395
+ npx tsx scripts/snapshot.ts verify # diff current output vs baseline/
1396
+ ```
1397
+
1398
+ Two companion scripts judge handle-placement *quality* (a non-circular metric:
1399
+ edge crossings, edges through nodes, handles facing away from their edge):
1400
+
1401
+ ```bash
1402
+ npx tsx scripts/quality-metric.ts # per-example metric, proposal mode
1403
+ npx tsx scripts/quality-compare.ts # none vs rotate vs relocate
1404
+ ```
1405
+
1406
+ `scripts/profile.ts` breaks down per-phase timing and
1407
+ `scripts/benchmark-presets.ts` compares the three presets across graph sizes.
1220
1408
 
1221
1409
  ## License
1222
1410
 
package/dist/index.js CHANGED
@@ -1503,17 +1503,23 @@ function applyRelocateProposals(input, preview, options) {
1503
1503
  const previewIndex = new Map(preview.nodes.map((n) => [n.id, n]));
1504
1504
  const inputIndex = new Map(input.nodes.map((n) => [n.id, n]));
1505
1505
  const newNodes = input.nodes.map((node) => {
1506
- const proposal = computeRelocateProposal(node, input.edges, inputIndex, previewIndex);
1506
+ const proposal = computeRelocateProposal(node, input.edges, inputIndex, previewIndex, options.direction);
1507
1507
  if (!proposal) return node;
1508
1508
  const accepted = options.onProposal(proposal);
1509
1509
  return accepted ?? node;
1510
1510
  });
1511
1511
  return { nodes: newNodes, edges: input.edges };
1512
1512
  }
1513
- function computeRelocateProposal(node, edges, inputIndex, previewIndex) {
1513
+ function computeRelocateProposal(node, edges, inputIndex, previewIndex, direction) {
1514
1514
  const selfPreview = previewIndex.get(node.id);
1515
1515
  if (!selfPreview) return null;
1516
1516
  if (node.handles.length === 0) return null;
1517
+ const isHorizontal = direction === "LR" || direction === "RL";
1518
+ const rankMin = isHorizontal ? selfPreview.x : selfPreview.y;
1519
+ const rankMax = isHorizontal ? selfPreview.x + selfPreview.width : selfPreview.y + selfPreview.height;
1520
+ const orderMin = isHorizontal ? selfPreview.y : selfPreview.x;
1521
+ const orderMax = isHorizontal ? selfPreview.y + selfPreview.height : selfPreview.x + selfPreview.width;
1522
+ const orderCenter = (orderMin + orderMax) / 2;
1517
1523
  const usedHandleIds = /* @__PURE__ */ new Set();
1518
1524
  for (const e of edges) {
1519
1525
  if (e.from === node.id) usedHandleIds.add(e.fromHandle);
@@ -1521,54 +1527,125 @@ function computeRelocateProposal(node, edges, inputIndex, previewIndex) {
1521
1527
  }
1522
1528
  if (usedHandleIds.size === 0) return null;
1523
1529
  const votesByHandle = /* @__PURE__ */ new Map();
1524
- const selfCenter = {
1525
- x: selfPreview.x + selfPreview.width / 2,
1526
- y: selfPreview.y + selfPreview.height / 2
1527
- };
1528
1530
  for (const e of edges) {
1529
1531
  const isFrom = e.from === node.id;
1530
1532
  const isTo = e.to === node.id;
1531
1533
  if (!isFrom && !isTo) continue;
1532
1534
  const handleId = isFrom ? e.fromHandle : e.toHandle;
1533
1535
  const neighborId = isFrom ? e.to : e.from;
1534
- const neighborPreview = previewIndex.get(neighborId);
1535
- if (!neighborPreview) continue;
1536
- const neighborCenter = {
1537
- x: neighborPreview.x + neighborPreview.width / 2,
1538
- y: neighborPreview.y + neighborPreview.height / 2
1539
- };
1540
- const dx = neighborCenter.x - selfCenter.x;
1541
- const dy = neighborCenter.y - selfCenter.y;
1542
- const side = Math.abs(dx) >= Math.abs(dy) ? dx >= 0 ? "right" : "left" : dy >= 0 ? "bottom" : "top";
1543
- const dist = Math.hypot(dx, dy);
1536
+ const np = previewIndex.get(neighborId);
1537
+ if (!np) continue;
1538
+ const nbrRankMin = isHorizontal ? np.x : np.y;
1539
+ const nbrRankMax = isHorizontal ? np.x + np.width : np.y + np.height;
1540
+ const overlap = Math.min(rankMax, nbrRankMax) - Math.max(rankMin, nbrRankMin);
1541
+ const isFlow = overlap <= 0;
1542
+ const nbrOrderC = isHorizontal ? np.y + np.height / 2 : np.x + np.width / 2;
1543
+ const nbrRankC = isHorizontal ? np.x + np.width / 2 : np.y + np.height / 2;
1544
+ const selfRankC = isHorizontal ? selfPreview.x + selfPreview.width / 2 : selfPreview.y + selfPreview.height / 2;
1545
+ const nbrIsBefore = nbrRankMax <= rankMin || overlap <= 0 && nbrRankC < selfRankC;
1546
+ const flowSide = isHorizontal ? nbrIsBefore ? "left" : "right" : nbrIsBefore ? "top" : "bottom";
1547
+ const d = nbrOrderC - orderCenter;
1548
+ const perpSide = isHorizontal ? d >= 0 ? "bottom" : "top" : d >= 0 ? "right" : "left";
1549
+ const beyond = nbrOrderC > orderMax ? 1 : nbrOrderC < orderMin ? -1 : 0;
1550
+ const dist = Math.hypot(nbrRankC - selfRankC, nbrOrderC - orderCenter);
1544
1551
  const weight = dist > 0 ? 1 / dist : 1;
1545
1552
  const arr = votesByHandle.get(handleId) ?? [];
1546
- arr.push({ side, weight });
1553
+ arr.push({ weight, isFlow, flowSide, perpSide, orderCoord: nbrOrderC, rankCoord: nbrRankC, beyond });
1547
1554
  votesByHandle.set(handleId, arr);
1548
1555
  }
1549
- const changes = {};
1550
- const newHandles = node.handles.map((h) => {
1551
- const votes = votesByHandle.get(h.id);
1552
- if (!votes || votes.length === 0) return h;
1556
+ const resolved = /* @__PURE__ */ new Map();
1557
+ for (const [handleId, votes] of votesByHandle) {
1558
+ if (votes.length === 0) continue;
1553
1559
  const tallies = { top: 0, right: 0, bottom: 0, left: 0 };
1554
- for (const v of votes) tallies[v.side] += v.weight;
1555
- const best = Object.entries(tallies).reduce((acc, cur) => cur[1] > acc[1] ? cur : acc, ["top", -Infinity])[0];
1556
- if (best !== h.position) {
1557
- changes[h.id] = { from: h.position, to: best };
1558
- return { ...h, position: best };
1560
+ let dom = votes[0];
1561
+ for (const v of votes) {
1562
+ tallies[v.isFlow ? v.flowSide : v.perpSide] += v.weight;
1563
+ if (v.weight > dom.weight) dom = v;
1564
+ }
1565
+ const side = Object.entries(tallies).reduce((acc, cur) => cur[1] > acc[1] ? cur : acc, ["top", -Infinity])[0];
1566
+ resolved.set(handleId, {
1567
+ side,
1568
+ isFlow: dom.isFlow,
1569
+ flowSide: dom.flowSide,
1570
+ perpSide: dom.perpSide,
1571
+ orderCoord: dom.orderCoord,
1572
+ rankCoord: dom.rankCoord,
1573
+ beyond: dom.beyond
1574
+ });
1575
+ }
1576
+ for (const flowSide of ["top", "bottom", "left", "right"]) {
1577
+ const group = [...resolved.entries()].filter(([, r]) => r.isFlow && r.side === flowSide);
1578
+ if (group.length < 2) continue;
1579
+ let hasLeft = false, hasRight = false;
1580
+ for (const [, r] of group) {
1581
+ if (r.orderCoord < orderCenter) hasLeft = true;
1582
+ else if (r.orderCoord > orderCenter) hasRight = true;
1583
+ }
1584
+ if (hasLeft && hasRight) continue;
1585
+ const distOf = (oc) => oc > orderMax ? oc - orderMax : orderMin - oc;
1586
+ const distinctCoords = [...new Set(group.filter(([, r]) => r.beyond !== 0).map(([, r]) => r.orderCoord))].sort((a, b) => distOf(a) - distOf(b));
1587
+ if (distinctCoords.length >= 2) {
1588
+ const keep = distinctCoords[0];
1589
+ for (const [, r] of group) {
1590
+ if (r.beyond !== 0 && r.orderCoord !== keep) r.side = r.perpSide;
1591
+ }
1592
+ }
1593
+ }
1594
+ const sideChanges = {};
1595
+ const newSideById = /* @__PURE__ */ new Map();
1596
+ for (const h of node.handles) {
1597
+ const r = resolved.get(h.id);
1598
+ if (!r) continue;
1599
+ newSideById.set(h.id, r.side);
1600
+ if (r.side !== h.position) sideChanges[h.id] = { from: h.position, to: r.side };
1601
+ }
1602
+ const newOffsetById = /* @__PURE__ */ new Map();
1603
+ const bySide = /* @__PURE__ */ new Map();
1604
+ for (const [hid, r] of resolved) {
1605
+ const arr = bySide.get(r.side) ?? [];
1606
+ arr.push(hid);
1607
+ bySide.set(r.side, arr);
1608
+ }
1609
+ for (const [side, handleIds] of bySide) {
1610
+ if (handleIds.length < 2) continue;
1611
+ const offsets = handleIds.map((hid) => node.handles.find((h) => h.id === hid).offset ?? 0.5).sort((a, b) => a - b);
1612
+ const horizontalSide = side === "top" || side === "bottom";
1613
+ const sortKey = (hid) => {
1614
+ const r = resolved.get(hid);
1615
+ if (isHorizontal) {
1616
+ return horizontalSide ? r.rankCoord : r.orderCoord;
1617
+ }
1618
+ return horizontalSide ? r.orderCoord : r.rankCoord;
1619
+ };
1620
+ const sortedHandles = [...handleIds].sort((a, b) => {
1621
+ const ra = sortKey(a), rb = sortKey(b);
1622
+ return ra - rb || (a < b ? -1 : a > b ? 1 : 0);
1623
+ });
1624
+ for (let i = 0; i < sortedHandles.length; i++) {
1625
+ newOffsetById.set(sortedHandles[i], offsets[i]);
1626
+ }
1627
+ }
1628
+ let changed = false;
1629
+ const newHandles = node.handles.map((h) => {
1630
+ const side = newSideById.get(h.id) ?? h.position;
1631
+ const offset = newOffsetById.has(h.id) ? newOffsetById.get(h.id) : h.offset ?? 0.5;
1632
+ if (side !== h.position || offset !== (h.offset ?? 0.5)) {
1633
+ changed = true;
1634
+ return { ...h, position: side, offset };
1559
1635
  }
1560
1636
  return h;
1561
1637
  });
1562
- if (Object.keys(changes).length === 0) return null;
1638
+ if (!changed) return null;
1563
1639
  const proposed = { ...node, handles: newHandles };
1564
- const summary = Object.entries(changes).map(([id, c]) => `${id}: ${c.from}\u2192${c.to}`).join(", ");
1640
+ const sideSummary = Object.entries(sideChanges).map(([id, c]) => `${id}: ${c.from}\u2192${c.to}`).join(", ");
1641
+ const reason = sideSummary ? `${Object.keys(sideChanges).length} handle(s) repositioned toward their neighbor (${sideSummary})` : `handles on a shared side reordered to match neighbor order`;
1565
1642
  return {
1566
1643
  type: "relocate-handles",
1567
1644
  nodeId: node.id,
1568
1645
  current: node,
1569
1646
  proposed,
1570
- changes,
1571
- reason: `${Object.keys(changes).length} handle(s) point away from their neighbor (${summary})`
1647
+ changes: sideChanges,
1648
+ reason
1572
1649
  };
1573
1650
  }
1574
1651
  function rotateHandles(handles, rot) {