@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 +198 -10
- package/dist/index.js +107 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +107 -30
- package/dist/index.mjs.map +1 -1
- package/dist/proposals.d.ts.map +1 -1
- package/package.json +1 -1
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,
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
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 #
|
|
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
|
|
1535
|
-
if (!
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
const
|
|
1541
|
-
const
|
|
1542
|
-
const
|
|
1543
|
-
const
|
|
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({
|
|
1553
|
+
arr.push({ weight, isFlow, flowSide, perpSide, orderCoord: nbrOrderC, rankCoord: nbrRankC, beyond });
|
|
1547
1554
|
votesByHandle.set(handleId, arr);
|
|
1548
1555
|
}
|
|
1549
|
-
const
|
|
1550
|
-
const
|
|
1551
|
-
|
|
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
|
-
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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 (
|
|
1638
|
+
if (!changed) return null;
|
|
1563
1639
|
const proposed = { ...node, handles: newHandles };
|
|
1564
|
-
const
|
|
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
|
|
1647
|
+
changes: sideChanges,
|
|
1648
|
+
reason
|
|
1572
1649
|
};
|
|
1573
1650
|
}
|
|
1574
1651
|
function rotateHandles(handles, rot) {
|