Skip to content

feat(library): improve automatic edge routing - #826

Merged
FelixTJDietrich merged 124 commits into
mainfrom
feat/orthogonal-edge-routing
Jul 24, 2026
Merged

feat(library): improve automatic edge routing#826
FelixTJDietrich merged 124 commits into
mainfrom
feat/orthogonal-edge-routing

Conversation

@FelixTJDietrich

@FelixTJDietrich FelixTJDietrich commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Automatic edges now coordinate endpoint sides, connection points, and orthogonal paths to produce cleaner diagrams with fewer avoidable bends, overlaps, and crossings. Pinned endpoints and hand-placed bends remain authoritative, while nearby automatic edges can still rebalance around them.

The change also makes routing responsive and consistent across the product:

  • the first solve and diagrams with fewer than 32 edges remain synchronous; later dense solves use a backpressured Web Worker that retains only the newest unserialized snapshot and adapts its preview cadence to observed throughput;
  • drag previews project or reuse accepted geometry while the latest exact result is computed, and stale Worker results cannot replace settled geometry;
  • line jumps follow the routes actually on screen, so bridge arcs stay attached to their edge during a drag and at the preview-to-settled handoff instead of floating over routes that already moved;
  • replacing the model increments a routing epoch that discards any in-flight solve for the previous model, so a fresh diagram can never briefly flash the old one's routes;
  • on load and on model replacement, edges stay unmounted until the first exact generation is measured, removing the brief straight-then-relayout flash;
  • releasing a drag prioritizes the newest exact snapshot, then transitions from the preview to the accepted route unless reduced motion is requested;
  • SVG export waits for accepted routing geometry instead of a fixed delay;
  • required-interface sockets, package bounds, shared junctions, and legacy diagram imports use the same geometry contracts;
  • node and edge action toolbars use the existing React Flow toolbar primitives with accessible, full-size controls;
  • the five bundled design-pattern templates start from reviewed automatic layouts;
  • server-side consumers can normalize diagrams through the DOM-free @tumaet/apollon/model entry.

Release note

Library: Create cleaner diagrams with automatic edges that balance connection points, reduce collisions with nodes and nearby edges, and stay responsive while you drag, without losing pinned endpoints, hand-placed bends, aligned component interfaces, or compatibility with older diagrams; embedding apps can also normalize diagrams in server-side code through the new @tumaet/apollon/model entry.

Web app: Start every bundled design-pattern template in a clean, visually balanced editing state, with stable shared hierarchy trunks and automatic routing preserved for associations.

Implementation notes

Routing is a bounded, deterministic pipeline rather than one global optimization:

Stage Responsibility
Node-local coordination Propose sides and balanced connection-point ordering
Joint endpoint/path search Choose endpoint candidates and an obstacle/edge-aware orthogonal path
Route-set repair Try bounded ordering variants for interacting edges and keep strict improvements
Rendering Preserve manual topology, project live previews, and publish accepted geometry

Routing authority remains explicit:

  • no custom anchors or points: automatic routing owns the edge;
  • sourceAnchor or targetAnchor: that endpoint remains pinned while generated geometry adapts;
  • non-empty points: the authored bend topology remains authoritative.

Search work is capped to protect interaction latency. If a joint search exceeds its bound, the solver retries the selected endpoint pair on a smaller graph and then uses deterministic obstacle-derived fallbacks ranked by hard crossings first. This is a deliberate degradation path, not a claim that every pathological diagram has a globally optimal route.

For dense interactive diagrams, the main thread owns one latest snapshot and the Worker owns at most one in-flight solve. Serialization is deferred to a later task and occurs only when the Worker can accept work, avoiding both an obsolete request queue and main-thread cloning on every pointer frame. Preview cadence adapts between 40 and 160 ms from measured round-trip time. Independent input, dispatch, and acceptance revisions verify that release settles the latest authored state.

The /model export has its own documentation, Node 22 smoke test, complete runtime-graph check, declaration isolation check, and 8 kB size budget. Its current normalization chunk is 4.73 kB compressed and does not pull React or XYFlow into server code.

Bundle budgets pass, but the main entry and routing Worker are intentionally close to their limits: 116.97/118 kB and 38.38/40 kB respectively. The export entry is 413.41/700 kB.

Steps for testing

  1. Create several automatic edges on one node side and confirm their connection points spread into balanced gaps.
  2. Pin one endpoint, then move its node and nearby nodes; the pin should stay fixed while the generated route adapts.
  3. Add and drag a manual bend; its topology should remain authoritative without a jump on release.
  4. Exercise a diagram with at least 32 edges and drag continuously; incident edges should follow immediately, other routes should update during the gesture, and the newest exact route should settle after release.
  5. Repeat the dense drag with reduced motion enabled; the accepted route should appear without a settlement animation.
  6. Open component, deployment, and package diagrams; verify interface sockets and package connections meet the visible shapes correctly.
  7. Import representative v3 and early-v4 diagrams, create each bundled design-pattern template, and export a diagram as SVG.
  8. In Node, import importDiagram from @tumaet/apollon/model and normalize a saved model without browser globals.

Validation completed on 60bfaf92:

  • pnpm lint && pnpm format:check && pnpm build && pnpm test
  • library tests: 1,724 passed, 1 skipped
  • production-built edge-routing performance suite: Chromium 4/4 and Firefox 4/4
  • repeated continuous-drag freshness run: Chromium 3/3
  • focused Worker and execution-equivalence tests: 25/25
  • pnpm knip
  • pnpm dlx publint from library/
  • pnpm --filter @tumaet/apollon size
  • node scripts/check-model-entry.mjs
  • node scripts/check-doc-snippets.mjs (39 checked examples)

Screenshots / screencasts

Flowchart routing:

Flowchart routing

Component routing with required-interface sockets:

Component routing

Bridge template:

Bridge template

Checklist

  • Linked to a related issue — not applicable; no issue was identified
  • Added user-voice changesets for the library and web app
  • PR title's feat type matches the primary user-visible change
  • Tests added or updated
  • Ran pnpm lint && pnpm format:check && pnpm build && pnpm test locally — green
  • Documentation updated
  • Screenshots attached

FelixTJDietrich and others added 30 commits July 15, 2026 11:46
…eckpoint)

Working-state checkpoint of the orthogonal edge-routing system before the
central single-pass solver refactor. Captures the A* orthogonal router,
sparse Hanan visibility graph, per-edge routing hooks, the ephemeral
edge-geometry store + layout-effect convergence cascade, obstacle/neighbour
context, drag-preview accuracy, and the full test harness (unit, routing
oracle, visual, perf).

Also includes this session's React 19 / React Compiler fixes:
- fix a stale-closure (`padding`) in the endpoint-drag callback that silently
  deopted the entire useStepPathEdge hook under the React Compiler
- replace three content-digest useMemo + `eslint-disable exhaustive-deps`
  with a `useStableValue` ref-compare hook (no per-render string allocation,
  compiler-preservable, lint-clean)

Verified: library unit 1339, webapp unit 263, routing oracle 13/13,
visual 40/40, perf 2/2 all green. Two pre-existing E2E failures remain and
are unrelated to this checkpoint (edge-fresh-bend:200 bend persistence,
edge-parallel-overlap:30 same-pair overlap by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the default-route computation (straight-path attempt then orthogonal
router) out of useStepPathEdge's computedPoints memo into a pure, side-effect
free `routeStepEdge` in utils/geometry/edgeRoute.ts. Same inputs, same output —
verified by library unit 1339 and routing oracle 13/13.

This is the single routing primitive the per-edge hook and the forthcoming
central edge-geometry solver will share, so the drag preview and the committed
edge run the exact same function and cannot diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add computeAllEdgeGeometry — a pure single-pass solver that routes EVERY edge
in one call, reproducing today's per-edge pipeline:
- resolveEdgeEndpoints: React Flow's own getEdgePosition on nodeLookup (the same
  function EdgeWrapper uses) for the base endpoint, then freeform-anchor
  override, rounding, and connection-padding — mirroring useStepPathEdge, using
  getPositionOnCanvas (not internals.positionAbsolute) for freeform/straight
  parity on nested nodes
- ordered ascending-id walk: because an edge yields only to lower-id neighbours
  (a strict DAG), one pass reproduces the multi-frame publish->re-route cascade's
  fixed point with no re-renders
- straight-hook edge types emit a plain two-point line; step edges route via the
  shared routeStepEdge primitive; manual bend points merged as before
- live-override input so an interactive drag preview is visible to all edges

Adds @xyflow/system (pinned to the react-locked 0.0.78, catalog) to reach
getEdgePosition, which @xyflow/react does not re-export. Single source of truth
for per-type routing behaviour in edges/edgeRoutingBehavior.ts.

Not wired into any component yet (flag-gated by absence of a caller), so runtime
is unchanged: library unit 1344 (5 new solver tests), build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add EdgeSolverParityProbe (dev/e2e only, opt-in via window flag) that runs
computeAllEdgeGeometry over the live React Flow store and compares each edge's
solver route against the polyline the edge actually published to
edgeGeometryStore.geometryById — the ground truth of today's per-edge routing.
No per-edge instrumentation needed.

New e2e spec edge-solver-parity.spec.ts drives the 12 real-diagram oracle
fixtures and asserts zero mismatches. Result: 12/12 byte-identical — the
single-pass ascending-id DAG walk reproduces the multi-frame per-edge publish
cascade exactly, on real measured diagrams (which jsdom can't verify). This is
the gate that had to be green before wiring the solver into rendering.

Probe is inert unless a test sets window.__apollonEnableSolverParity, so it adds
zero cost to the rest of the suite; oracle 13/13 unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ting flag

Route every edge in one synchronous, pre-paint pass (EdgeGeometrySolver)
committed to the shared geometry store, and have the edge hooks read that
route back when `edgeRouting === "central"` instead of routing themselves.
Default stays "per-edge"; central is opt-in via ApollonOptions.edgeRouting
(and the local editor's ?edgeRouting= query for tests).

The solver's layout effect keys on a node-geometry signature that folds in
handleBounds, not just position/size: getEdgePosition derives endpoints from
measured handle rects, which populate a frame after the node body measures,
so a signature without them would solve once with null endpoints (empty
routes) and never re-run. Computing in the layout effect — not a useMemo —
is deliberate: the React Compiler infers a memo's deps from what its body
reads and would strip the unread nodeGeometryKey trigger, caching the first
pre-measurement solve forever; effect dep arrays are honored verbatim.

Proven byte-identical to per-edge across all 12 oracle fixtures
(edge-central-parity.spec.ts), with the static solver parity gate and the
per-edge regression suite still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al mode

While an edge's bend or endpoint is being dragged, the per-edge path publishes
its in-progress renderPoints into the geometry store so neighbours dodge the
preview in real time. Central mode had no equivalent, so neighbours only
reacted on release. Add a `liveEdgeOverride` to the metadata store that the
dragged edge publishes from a layout effect keyed on its `dragPreviewPoints`
(set by both bend and endpoint drags, cleared on release/unmount). The solver
reads it and feeds it as `liveOverride`, routing every other edge around the
live polyline — pre-paint, matching the per-edge timing.

Proven by edge-central-drag-parity.spec.ts: an identical bend gesture replayed
in both modes lands on byte-identical persisted points and rendered paths for
every edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flip the default `edgeRouting` from `per-edge` to `central`. Every consumer
now gets the single synchronous pre-paint solve instead of the multi-frame
per-edge publish→re-route cascade. `per-edge` stays available as an explicit
kill switch (ApollonOptions.edgeRouting: "per-edge").

Validated with central as the default across the entire visual suite — all 18
diagram-type canvas snapshots and all 18 SVG-export snapshots match
byte-for-byte (Class, Object, Activity, UseCase, Communication, Component,
Deployment, PetriNet, ReachabilityGraph, SyntaxTree, Flowchart, BPMN, SFC, and
the design-pattern templates) — plus the edge routing, parity, drag-parity and
interaction e2e suites. The full-suite edge-routing failure set is identical to
per-edge's (only two pre-existing, mode-independent failures).

The shadow parity gate is pinned to `per-edge` so it keeps comparing the solver
against the real cascade rather than against its own committed output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shadow parity probe and the three central/per-edge comparison specs were
migration scaffolding: they proved the central solver reproduces per-edge
routing byte-for-byte. That is now locked in — central is the default and the
visual suite (all diagram types) plus the edge routing/interaction e2e suites
assert its output directly. Remove EdgeSolverParityProbe and its App mount, and
the edge-solver-parity / edge-central-parity / edge-central-drag-parity specs,
which all require a live per-edge engine and would break as it is removed.

First step of the staged per-edge cascade removal; central rendering unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With central routing the sole engine, the edge geometry registry is written in
one pass by the solver (setAllGeometry). Remove the per-edge machinery that
predated it: usePublishEdgeGeometry (each edge publishing its own polyline in a
layout effect), and the store's publishEdgeGeometry / removeEdgeGeometry plus
the per-frame publish budget that bounded the resulting re-render cascade.
geometryById is now solver-populated only; setAllGeometry prunes deleted edges,
so the unmount-removal path is unnecessary. Consumers that READ the map — line
jumps, label neighbour-avoidance, the reconnect preview's obstacle routing —
are unchanged.

Second step of the staged per-edge cascade removal. Line-jump, routing,
reconnect and full visual suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip the legacy per-edge routing branch out of useStepPathEdge. It no longer
runs useEdgeRoutingContext (an obstacle + neighbour scan that, under central
routing, executed every render only to have its result discarded) or the
computedPoints A* search; activePoints is now just the solver's route for this
edge (already manual-merged), falling back to a straight endpoint line until
the first pre-paint solve lands. The drag handlers, manual-point persistence
effect and label layout are unchanged — they read activePoints, which is now
solver-sourced.

The `enableStraightPath` prop is retained on the hook's props (many edge
components still pass it) but deprecated and unread: the solver derives
straight-path behaviour from edge type (STRAIGHT_PATH_STEP_EDGE_TYPES).

Third step of the staged removal. Comprehensive edge-interaction, routing,
reconnect and both visual suites (canvas + SVG export) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…engine

The per-edge cascade is gone, so the routing selector has nothing to select.
Drop `edgeRouting` / `EdgeRoutingMode` / `setEdgeRouting` from the metadata
store, the `edgeRouting` option from ApollonOptions, its wiring in
apollon-editor, and the `isCentralRouting` reads in useStepPathEdge. The solver
now runs unconditionally (no `edgeRouting === "central"` guard), and the live
drag-override effect publishes whenever a drag is in progress. The webapp's
`?edgeRouting=` test hook and the canvas helper's option are removed too.

`useEdgeRoutingContext` stays: it is the routing context for the live reconnect
preview (ReconnectConnectionLine), now feeding it the same obstacles and
neighbour polylines the central solver sees. Its doc is updated to say so.

Final step of the staged removal. Library + webapp typecheck clean; solver unit
tests, the edge routing/interaction/reconnect/line-jump e2e suites, both visual
suites (canvas + SVG export), and the no-React-error editor-load guard green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tPath dead prop

Review round 1, wave 1 (correctness + dead prop). From a 7-agent principal-engineer audit:

Solver correctness:
- Wire `previous` into the solve (read via the store's getState so it isn't a
  re-trigger). The "never paint a guess" guarantee was inert — its caller never
  passed `previous`, so a momentarily-unmeasured node dropped its edge and
  flickered to a straight fallback. Now the last route is held, as documented.
- Fold `hidden` into the re-solve trigger: hidden nodes drop out of the obstacle
  set (obstacles.ts), but toggling visibility changed neither position nor size,
  so the solver never re-ran and edges routed around a stale obstacle set.
- Build a nodeById Map once instead of two `nodes.find` per edge (was O(edges x nodes)).
- Delete the provably-dead `otherId >= edge.id` neighbour guard (the ascending-id
  walk already guarantees only lower-id routes are present) and the
  enableStraightPath round-trip through resolveEdgeEndpoints; un-export it.

Dead prop:
- Remove `enableStraightPath` from useStepPathEdge's props and all 10 edge
  components (plus the Class/Sfc derivation). It was unread — the solver derives
  straight-shot eligibility from edge type — and worse, ClassDiagramEdge still
  honoured a per-config `enableStraightPath:false` the solver silently overrode.
  One source of truth (STRAIGHT_PATH_STEP_EDGE_TYPES) now.

Also trims the solver's comment theatre. Full unit suite (1344), visual (all
diagram types) and edge routing/interaction e2e green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move ObstacleRect to obstacles.ts (its producer) and delete the three
getGridPx/getNodeClearancePx/getMinNodeClearancePx wrappers. The router
now reads CANVAS/EDGES at call-time directly, matching obstacles.ts —
the wrappers only ever forwarded a single constant and existed to mark a
call-time-read discipline the leaf modules already follow.

Removes the sole edgeUtils import from orthogonalRouter, cutting the
cycle this branch introduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- getBendableSegments: drop the two never-read _sourcePosition/
  _targetPosition params (and the 6 call-site args + hook deps). Bend
  placement stopped depending on endpoint side; the params were vestigial.
- bendHandles: delete an orphaned JSDoc left above getStubExit.
- obstacles: move the 'nodes to route around' doc onto getEdgeObstacles,
  where it belongs (it had drifted above the NodeIndex type).
- edgeGeometrySolver: un-export mergeManualPoints (internal-only).
- orthogonalRouter: rename simplifyColinear -> simplifyCollinear.

Behavior-preserving; 1344 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim war-stories, restatement, duplicated essays and rhetorical filler
from the orthogonal-routing code; keep every load-bearing invariant
(cost-model reasoning, geometry rationale, units, determinism/soundness
guarantees, the circular-import call-time-read discipline). Collapse the
React-Compiler deopt essay that useStableValue and useEdgeRoutingContext
told twice down to one canonical copy.

Comments only — no code, JSX, deps, string literals or directives changed.
tsc + 1344 unit tests + 23 edge E2E all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… test

- routingGauntlet: the fuzz test's channel-CENTRING assertion demanded a
  guarantee the router only best-effort meets (in wide asymmetric channels
  it can settle a few grid cells off-centre), so it reddened CI at random.
  Downgrade centring to a documented KNOWN GAP; keep every SAFETY invariant
  (never through a node, on-grid, >= min-clearance) fuzzed hard and unseeded.
  Remove the now-dead channel-detection machinery in the clearance helper.
- edgeGeometrySolver: rename a test whose name promised ascending-id ordering
  but whose two non-interacting edge pairs asserted only 'both got routed' —
  now honestly named for the multi-edge smoke check it is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 3rd drag pulled the source-TERMINAL handle (which .first() selects once
the edge is bent) back across the straight line, flattening a shallow bend —
which correctly returns the edge to its auto-route (data.points = []). That
is a distinct gesture, not a snap-back, so the test was asserting the wrong
thing. Drag consistently to reshape instead, with a comment explaining why.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The central solver re-ran every edge's full route (endpoint resolve,
obstacle scan, neighbour gather, A* search) on every drag frame — so an
edge forced to route around a STATIONARY node re-searched 60x/second even
when nothing near it moved.

Cache each edge's routed polyline keyed on a lossless signature of every
input routeStepEdge consumes (endpoints, obstacles, neighbour polylines,
straight-path flag). routeStepEdge is pure, so an unchanged signature means
an unchanged route: reuse the polyline, skip the search. The DAG cascade
stays correct because neighbour polylines are IN the signature — a moved
lower-id edge changes its dependents' signatures. mergeManualPoints is still
applied fresh (manual points are not in the signature).

Per-frame solve, 1280x720 chromium node-drag benchmark:
    nodes  edges   before   after
       50    121   10.8ms   8.4ms
       75    191   17.4ms  10.0ms   (was over 16.7ms/60fps, now under)
      100    261   24.3ms  13.6ms   (-44%; searches/frame 82 -> 21)

Byte-identical output, proven three ways: two new solver unit tests (cached
== uncached; a moved node invalidates only its changed edges), the edge E2E
suite, and pixel-identical visual baselines.

Adds a DEV/VITE_E2E-gated solve-time probe (solveMs/solveMaxMs/solveCount);
DCEs in production (check-no-perf-hooks green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wall-clock profiling harness across 10/25/50/75/100-node fixtures. NOT a CI
gate — wall time is machine-specific; the expansion-count budgets in
edge-routing.spec.ts stay the machine-independent guard. Drags the central
node and prints per-frame solve cost, so the routing solve's scaling (and
any regression in it) is visible locally. Reads the new solve-time probe.

Run: pnpm --filter @tumaet/webapp exec playwright test tests/perf/drag-lag-benchmark.spec.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(E²)

The ascending-id solver walk had each edge linearly scan every already-routed
edge to gather its nearby neighbours — O(edges²) per frame, ~4ms/frame and
growing quadratically at 100 nodes. collectNeighbors ran on every frame even
for cache-hit edges (it builds the route signature), so this was on the hot
drag path.

Bucket each finished route's vertices into a per-solve spatial grid, grown as
the walk proceeds; an edge queries only the cells overlapping its bbox. The
cell is a hint — each candidate is still re-checked against the exact box and
sibling filter, and candidate ids are sorted ascending so neighbour order (and
thus the routed result) is unchanged.

Byte-identical output: the memoization equality unit tests, the full unit
suite (1351), and all 18 pixel-exact visual snapshots agree. Drag-lag
benchmark, per-frame avg solve: 100n 13.2→10.3ms (-22%), 75n 10.2→9.5ms;
smaller sizes flat within run noise (the quadratic term never dominated there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The A* router routed to one fixed endpoint. Endpoint-anchor optimization needs
to ask "of these candidate landing points, which yields the cheapest route" —
so generalize routeAroundObstacles into routeAroundObstaclesToTargets, which
seeds every candidate target into the one lattice, runs a single search with a
min-over-targets heuristic (the min of consistent Manhattan heuristics is
consistent, so the closed-state skip stays sound), and returns which target won.

routeAroundObstacles stays as a one-target wrapper, so every existing caller is
unchanged and byte-identical — proven by the full unit suite (1351) and all 18
pixel-exact visual snapshots passing untouched. New tests pin the N>1 contract:
one-target equals the wrapper, the cheaper target wins, the reported index
tracks the winner, and the result is order-stable across candidate reordering.

No behaviour change yet — nothing calls the multi-target entry; the anchor
selector that will is the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endpoint where an edge met a node was pinned to React Flow's centre-to-
centre guess — blind to the obstacles and neighbours the router already avoids.
Now a new pure module (edgeAnchoring.ts) chooses the SIDE and the OFFSET along
it that route to the fewest bends, while a user-dragged (custom) anchor stays
authoritative and is never re-chosen.

How it works, per edge with a free end:
- Generate <=3 candidate sides per end (the facing side + perpendiculars),
  each at the closed-form offset that aligns with the partner (a straight or
  single-bend run), grid-snapped for stability and fanned out by a per-sibling
  lane so parallel edges don't collapse onto one line.
- One multi-target A* per source candidate picks the cheapest target anchor;
  the committed route is then the shared routeStepEdge, so an auto edge and a
  hand-anchored one are drawn by the same primitive and preview == commit.
- Selection is MEMORYLESS — a pure function of the frame's geometry with a
  strict integer/enum tie-break — so the same model renders identically on every
  Yjs peer and after a reload, and a drag can't chatter (no per-client history).
- Cached like every other route, keyed on the selection's determinants, so a
  distant node move re-anchors nothing.

The interaction layer (useStepPathEdge) now reads its endpoints and sides from
the solver's committed route — its first/last points ARE the chosen anchors —
instead of re-deriving them, so bend drags and reconnection act on the geometry
the user actually sees (a stale baseline silently discarded the drag).

Auto anchors are derived every solve and never persisted: no model migration.
Visual snapshots refreshed — the routes are equal-or-cleaner (straightened
links, inheritance arrows fanned across the parent side), verified against the
edge-cleanliness E2E rules (no edge through a node, no new overlaps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The anchor selector picked a target with the multi-target A* but committed a
routeStepEdge redraw, and for straight-capable edge types that redraw's
smooth-step fallback could bend MORE than the A* route — so a bad-looking
perpendicular side occasionally out-scored the facing side, a visible flip
during a node drag. Commit the A* route directly (selection == commit), taking
the straight-capable route only when it is genuinely straight (2 points).

This collapses the wide side-oscillation bands to at most an isolated one-step
blip where two node edges momentarily align — a legitimate local bend optimum,
still fully deterministic across Yjs peers. New solver tests lock it in: clean
facing routes, custom-anchor wins, sibling fan-out, byte-identical re-solves,
and a swept-node sweep that stays ≤2 bends with a bounded run count. Visual
snapshots refreshed (straight-path edges now bend less).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveEdgeEndpoints called React Flow's getEdgePosition on every
invocation, but the auto-anchor selector resolves each candidate with
both ends pinned to a concrete anchor — the base point is then pure
waste. Compute rects and anchors first and derive the base lazily,
only when an end did not resolve to an anchor. Selector candidates and
cache-hit re-resolves (skipBase=true) skip it entirely; the plain
first resolve still computes it as the measured-yet check.

Byte-identical for non-anchor edges: 1361 unit + 40 visual unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks how auto edge anchors are chosen and cached.

Cost model: anchor scoring was strict-lexicographic on bends, so a
straight edge always won even when it forced a near-corner anchor. Make
it a weighed cost — bends·BEND_COST_GU + off-centre — so an edge goes
straight only while the off-centre slide stays small, and bends back to
a centred attachment past that. Straight where possible, unless the
off-centre cost outweighs it.

Selection is now scored on each candidate's IDEAL (obstacle- and
neighbour-free) route. With no obstacles the step router never searches,
so scoring every candidate is free; only the winning anchors pay for one
real obstacle- and neighbour-aware route (routeChosenAnchors, shared so
selection, commit, and cache re-route can never draw a pair differently).

Cache split: because selection is blind to obstacles and neighbours, its
key is purely intrinsic (rects, types, customs, lane) — only the two
nodes moving re-picks the anchor. Obstacles and neighbours move to a
routeSig that gates only a cheap fixed-anchor re-route, and that sig
serialises just the neighbour segments within the router's reach box
(neighborsWithinReach, now shared with the router), so a neighbour
bending outside this edge's corridor no longer invalidates it. Skips
React Flow's getEdgePosition when both ends are anchored.

Together these stop a one-node drag from cascading an anchor re-search
across the graph: edge searches per drag gesture on the 30-node routing
fixture drop from 643 to ~330. Router output is byte-identical (1361
unit + reach-box refactor verified); five SVG-export snapshots change as
edges take straighter, better-centred attachments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto endpoint anchors legitimately re-route more edges per drag — varied
attachments and sibling-lane fan-out ripple a moving node through more
neighbours — and that cost is irreducible under the memoryless-
determinism rule (a route must be a pure function of current geometry,
so keeping a stale one across a geometry change that could alter it is
forbidden, and deciding whether it would alter it is itself a search).
Everything cleanly cacheable already is. Real cost measures ~330 on the
fixture; raise the ceiling to 400 so the gate still fails loudly on a
genuine whole-canvas (~1000) regression while fitting the feature's
honest cost. Rationale documented at the constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-side candidates each aim their anchor at the partner's centre, so for
two nodes that overlap but aren't centre-aligned the two anchors land on
different lines and the edge is forced into a Z — a straight run was
possible and went unfound. Add a straight-aligned candidate pair: both
anchors placed on one line through the nodes' perpendicular overlap
(offset by the sibling lane so parallel edges still fan). The weighed
cost keeps it only while its off-centre stays cheaper than the bend it
saves, so a near-corner straight still yields to a centred single bend.

Offset-but-overlapping node pairs now connect straight (0 bends) instead
of a 2-bend Z; pairs with no overlap correctly keep their single bend.
One SVG-export snapshot improves (Adaptee2 runs straight up into the
Adapter instead of wrapping to its right side).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit (solver): an offset-but-overlapping pair straightens to a bend-free
edge on one shared line; a non-overlapping pair keeps its single bend.

E2E (edge-auto-anchor.spec.ts, new offset fixture): an auto edge routes
straight between offset nodes while persisting NO anchor; it re-optimises
and stays straight when a node moves, still persisting nothing (the pick
is derived, not stored — memoryless); and the live drag preview equals
the committed route byte-for-byte (the same solve produces both).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two coupled defects let an auto edge run drawn-on-the-node close to its
own source or target:

1. routeChosenAnchors committed the raw multi-target A* route, which
   wraps tighter than the smooth-step the rest of the system draws — so
   an auto edge could commit a worse-cornered path than an identical
   hand-anchored one. Route it through the SAME routeStepEdge (smooth-
   step when clear, A* only to detour) every other edge uses.

2. Anchor scoring ignored how close a candidate's own geometry runs to
   its two endpoint nodes, so it would pick an anchor whose only route
   grazes a node's side. Add a hug penalty — per-segment clearance to the
   source and target rects (skipping the stubs that legitimately touch
   them), weighed far above a bend — so a clear, more-bent attachment
   always beats a grazing one.

An edge whose endpoint is pinned to a node's far side now wraps with
clearance instead of hugging the near side (edge-diag-15 in
edge-real-diagram-routing). 1364 unit + 40 visual unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Checkpoint before the unified cost-solver rewrite. Replaces the per-edge
cost anchor stack with a geometric port stage: joint corner-minimising side
assignment (assignSides), per-partner straight lanes + aimed band + bundle
mirror (assignPorts), routed by the existing step/A* router.

Fixes the fork Z-corners (d54), non-straight merges (d53), corner-jammed
anchors (d47-50), parallel-sibling steps (d51), and different-partner
straight lanes (d55). Evidence + design in .context/edge-cost-model/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The original cache and bundle feedback remains resolved, and the focused routing tests, library build, and size gates all pass. However, the replacement connection preview regresses the exact native target-handle handoff for non-central handles, so straight-hook edges can still jump on commit; I reopened the existing thread with details.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The exact native handle is now preserved, but the new native-target gate also claims invalid target candidates and breaks the continuous use-case fallback. The required E2E fails deterministically on both attempts because the committed endpoint is 8.54 px from the snap circle, so native handoff needs validity/connectability gating before this is ready.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback is addressed, and the focused use-case preview/commit plus package geometry regressions pass locally. Nice work.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the shared toolbar follow-up builds cleanly with all seven focused interaction tests passing. Nice work.

@FelixTJDietrich FelixTJDietrich changed the title feat(library): make orthogonal edge routing balanced and responsive feat(library): improve automatic edge routing Jul 23, 2026

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the focused worker execution, DOM-free model entry, size gates, and reduced-motion release regression pass on this head. Nice work.

FelixTJDietrich and others added 2 commits July 24, 2026 14:41
Line jumps read the routes users actually see (preview when present,
otherwise accepted) instead of only the last settled snapshot. Keeping
both sides of a crossing on one generation removes the floating bridge
arcs that appeared while dragging in dense diagrams and at the
preview-to-settled handoff.

Model replacement now increments a routing epoch and clears geometry;
setAllGeometry rejects any Worker result stamped with an obsolete epoch,
so a solve for a previous model can no longer flash stale routes into a
freshly assigned one. The solver also waits for React Flow to measure
every visible node's handle bounds before consuming its first solve, so
the first routes are computed from a complete obstacle field rather than
a partial initial nodeLookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT
On initial load and on model replacement, React Flow edges stayed
unmounted until this model's first accepted holistic generation lands,
and viewport culling is deferred through that first measurement pass so
every node mounts its handles for the router. This removes the brief
straight-then-relayout flash where edges painted a provisional route
before the solver had measured the canvas.

Adds a perf regression proving initial and replacement routes never
paint provisional geometry, and lets the perf probe skip document
encoding so the check stays cheap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the focused routing-generation store suites pass on the first-exact-generation follow-up. Nice work.

FelixTJDietrich and others added 3 commits July 24, 2026 15:28
Copy/paste/duplicate and palette placement each re-minted nested child
ids from a hardcoded key list, and the two lists had already drifted —
copy/paste omitted swimlane `lanes`, palette placement omitted class
`actionRows`. Duplicating an activity swimlane therefore produced two
nodes sharing lane ids, so the public `getElementIdsByTag` returned both
swimlanes for a tag on one lane. Replace both lists with one canonical
structural walk (`remintNestedChildIds`) that re-mints the id of every
`{ id, ... }` child and leaves string/coordinate arrays untouched, so the
placement and copy paths can no longer diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT
waitForSettled resolves when the store accepts routes, but React Flow
paints the edge paths one commit later, so export bounds could clip a
route that bows outside the node-rect union (self-loops, edges routed
around a node). Flush one frame between settle and measurement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT
Post-review polish, no behavior change to the router:
- render the palette cell as a native <button> instead of a
  div[role=button] with a hand-rolled Enter/Space handler; keyboard
  activation is now the browser's, covered by a real E2E.
- drop the redundant data-state on Base UI color swatches (their native
  aria-pressed already drives the selected style) and the dead CSS.
- delete the unused selectNearbyLabelNodeGeometry selector, its two
  tests, and an unused NeighborSegment export.
- fix stale/garbled comments (line-jump source, single-source router
  wrappers), name the edge-toolbar offset constant, and move a
  misplaced test JSDoc.
- replace fixed-timeout settle-waits in the short-edge handle E2E with a
  web-first visibility assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the three cleanup/fix commits leave those invariants intact with green CI. Nice work.

When the color-paint toggle and the tag-picker trigger migrated to bare
Popover.Trigger elements they kept their aria-label but lost the hover
tooltip they had as IconButtons, leaving sighted users without the
discoverability hint their sibling controls still show. Wrap both in the
shared Tooltip, matching the house pattern used across the editor chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the focused tag-picker test passes with the restored paint and tag trigger tooltips. Nice work.

The bridge-integrity test polled its DOM integrity scan every 5ms (~200×/s)
while waiting up to 5s for the Worker's release-exact solve. On a loaded CI
runner that scan storm starved the very Worker message-handling it awaited,
so the release-exact settlement counter did not advance in time and the poll
timed out — while the integrity assertions it guards were never in question.
Poll gently (100/250/500ms) and allow 15s for the Worker round-trip, with a
60s test budget. No change to what the test verifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K2K5q7x9kmnkG3qpFQhwT

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich All previous feedback remains addressed, and the Worker-settlement E2E passes with the gentler polling. The remaining Node 22 failure is an unrelated 504 while downloading libvips. Nice work.

@FelixTJDietrich
FelixTJDietrich merged commit 991d9c1 into main Jul 24, 2026
35 of 37 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in Apollon Development Jul 24, 2026
@FelixTJDietrich
FelixTJDietrich deleted the feat/orthogonal-edge-routing branch July 24, 2026 16:13
@github-actions github-actions Bot mentioned this pull request Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants