feat(library): improve automatic edge routing - #826
Conversation
…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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@FelixTJDietrich All previous feedback is addressed, and the focused use-case preview/commit plus package geometry regressions pass locally. Nice work.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich All previous feedback remains addressed, and the shared toolbar follow-up builds cleanly with all seven focused interaction tests passing. Nice work.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
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
left a comment
There was a problem hiding this comment.
@FelixTJDietrich All previous feedback remains addressed, and the focused routing-generation store suites pass on the first-exact-generation follow-up. Nice work.
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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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.
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:
@tumaet/apollon/modelentry.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/modelentry.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:
Routing authority remains explicit:
sourceAnchorortargetAnchor: that endpoint remains pinned while generated geometry adapts;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
/modelexport 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
importDiagramfrom@tumaet/apollon/modeland normalize a saved model without browser globals.Validation completed on
60bfaf92:pnpm lint && pnpm format:check && pnpm build && pnpm testpnpm knippnpm dlx publintfromlibrary/pnpm --filter @tumaet/apollon sizenode scripts/check-model-entry.mjsnode scripts/check-doc-snippets.mjs(39 checked examples)Screenshots / screencasts
Flowchart routing:
Component routing with required-interface sockets:
Bridge template:
Checklist
feattype matches the primary user-visible changepnpm lint && pnpm format:check && pnpm build && pnpm testlocally — green