diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index b639225..4fb25b2 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -23,10 +23,10 @@ Overview: 3. Frontend calls parse_repo (Tauri IPC) -> cc-tauri first strips any prior parse state so re-parsing is idempotent -> parses each file with tree-sitter (parallel via rayon) in a single tree walk that attributes each raw reference to its innermost enclosing block (top-level imports/refs attributed to the File) and populates each block's children hierarchy -> only top-level blocks are appended to File children, with progress reported as ONE batched ParseEvent::Progress per ~100 files or ~50ms (cumulative counts + last file + that batch's per-file errors) instead of two events per file -> resolves imports first (yielding file-to-file Import edges plus an import map), then resolves references into edges via SymbolTable using a precision ladder (same-file > imported-file > global-unique > ambiguous, dropping references matching more than 5 global symbols — abandoned before the candidates are even cloned) so each edge carries a Resolution confidence -> keeps the full graph (nodes + edges, with adjacency rebuilt) in server-side GraphState and returns an edge-less ParseResult (nodes, root, edge_count, node_edge_kinds connectivity map) serialized straight out of the stored graph. Nodes go over the wire SLIM: every field except `signature`, which only the details panel and hover tooltip read and which they fetch per node via get_node_details. 4. Frontend graphStore converts the ParseResult into a CodeGraph (node tree + nodeEdgeKinds Map, no edges, no signatures) and computes visibility/expansion state. The hideUnconnectedNodes filter (visibilityFilter) runs synchronously from nodeEdgeKinds. handleParseEvent applies one store update per progress batch, so ingestion no longer broadcasts once per file. 5. Canvas derives the effective layout inputs from the zoom-level viewMode (default "module"): module view forces edge kinds to {Import} and treats files as collapsed (saved state preserved but ignored); symbol view uses the user's edge kinds + expansion; an active focus frame restricts visibility/expansion to the fetched focus ids (a node frame's neighborhood or an edge frame's edge detail). Canvas passes the (edge-less) graph + effective state to PixiRenderer. - 6. The layout pipeline has two phases behind one coalescing queue in PixiRenderer, and the store's relayoutPolicy decides which one a given state change needs (full / edges / visibility / nothing -- at most ONE per user action): + 6. The layout pipeline has two phases behind one coalescing queue owned by LayoutOrchestrator (canvas/layout/layoutOrchestrator.ts; PixiRenderer only supplies its effects), and the store's relayoutPolicy decides which one a given state change needs (full / edges / visibility / nothing -- at most ONE per user action): - Positions phase (full): elkLayout builds the ELK node tree, collects the render set (renderIds), fetches per-view direct + aggregated edges via get_subgraph(render_ids, edge_kinds) computed server-side (direct edges carry a Resolution; ambiguous edges may be hidden client-side), then ELK places the nodes and routes the edges and the renderer rebuilds the canvas. Views over 1500 rendered nodes OR over 3000 view edges skip ELK edge routing and use straight-line fallback edges. Triggered by a new graph, expand/collapse, showing nodes, hide-unconnected, view-mode switches, focus changes, and the sidebar's explicit "Apply Layout Changes" button (graphStore.layoutVersion). - Edges phase (cheap): edge-kind and hide-ambiguous toggles re-run only get_subgraph for the SAME render set and rebuild the edges on the cached node positions -- reusing each surviving edge's routed polyline and straight-lining edges that appear anew -- with no ELK run and no camera move (graphStore.edgeVersion). Hiding nodes is cheaper still: the canvas just flips the existing node displays. - While a pass is in flight, further requests collapse into ONE pending rerun with the latest inputs (elkjs cannot be aborted), and stale results are discarded by _layoutRequestId. At draw time the client re-routing pass (obstacle avoidance) is separately budgeted: node boxes are indexed once per redraw in an R-tree and each edge queries only the obstacles near it, candidate crossing-scoring is dropped above 250 rendered edges, and re-routing is skipped entirely above 500 rendered edges or 2000 visible nodes -- so edge redraw time stays bounded instead of growing without limit. Applying a layout performs exactly ONE full edge rebuild. + While a pass is in flight, further requests collapse into ONE pending rerun with the latest inputs (elkjs cannot be aborted), stale results are discarded by the orchestrator's request-id guard, the cheap visibility redraw is gated until the pass lands or fails, and a visibility change that arrived mid-pass is re-applied on top of the fresh displays so a slow layout cannot resurrect hidden nodes. An edge's endpoint ANCHORS (which side of which node box, at what offset) are decided exactly once, at layout time, from the pristine route -- read off the first/last segment's direction rather than guessed from tolerances -- and are then the durable contract every later stage consumes. At draw time all geometry belongs to one explicit route pipeline (layout/edgeRoutePipeline.ts) whose composition function IS the stage order: anchor endpoints -> spread endpoint lanes -> detour around obstacles, each a pure stage returning new records, with a stage that moves an endpoint also emitting its updated anchor. The detour stage is separately budgeted: node boxes are indexed lazily once per redraw in an R-tree and each edge queries only the obstacles near it, candidate crossing-scoring is dropped above 250 rendered edges, and re-routing is skipped entirely above 500 rendered edges or 2000 visible nodes -- so edge redraw time stays bounded instead of growing without limit. edgeDrawing.ts is left with layer management and stroking, and hit-testing measures the polyline that was actually drawn. Applying a layout performs exactly ONE full edge rebuild. 7. Both phases derive per-kind edge counts for the view from that same SubGraph payload; PixiRenderer publishes them to edgeLegendStore once the pass is known to be current, and the bottom-left EdgeLegend overlay renders one row per edge kind (colour, name, count) which doubles as the edge-kind toggle UI. 8. User interactions (hover, select, expand, drag, zoom) update stores and trigger re-renders. Selection is a node SET (`selectedNodeIds`, with `selectedNodeId` as the derived last-selected primary) and doubles as the pinned edge highlight: hovering previews a node's connections, clicking pins that same dim+highlight treatment so it survives unhover, and ctrl/cmd-clicking a second node switches the highlight to the induced subgraph (only edges with both endpoints selected). The pin is re-applied after every base-layer rebuild and invalidated when its nodes leave the graph. Hovering an EDGE additionally emphasises its two endpoint nodes' borders. Edge tooltips read kind + count from the layout edges (aggregated edges carry a collapsed count). Aggregated edges (count > 1) additionally render a world-space "×N" chip at the arc-length midpoint of their routed polyline, at the "detail" LOD only. 9. Selecting a node also drives the right-side details panel: it fetches get_neighborhood(selectedNodeId, 1, ALL kinds) (debounced, with a monotonic stale-request guard), splits those edges into incoming/outgoing around the selected node, groups them per kind, and renders clickable endpoint rows with per-row Focus buttons. @@ -37,8 +37,8 @@ Overview: Features Index: canvas-rendering: - description: Interactive Pixi.js canvas with node rendering, edge drawing, minimap, drag, and LOD-based visibility. Applying a layout performs exactly one full edge rebuild, and the edge re-routing pass is bounded by an R-tree obstacle index plus a routing budget (full > obstacles-only > none) chosen from the rendered-edge and visible-node counts. Edges are hit-tested by distance to their routed polyline, which drives edge hover, double-click-to-drill-in on aggregated edges, and a border emphasis on the hovered edge's two endpoint nodes (so it is visible where an edge lands without tracing it by eye). Node borders come from one emphasis table (selected > hovered-edge endpoint > plain). Aggregated (collapsed-container) edges carry "xN" count chips drawn at the arc-length midpoint of their routed polyline, shown at the "detail" LOD only and dimmed in step with the edge they label. - entry_points: [packages/app/src/canvas/renderers/PixiRenderer.ts, packages/app/src/canvas/Canvas.tsx, packages/app/src/canvas/renderers/edgeDrawing.ts, packages/app/src/canvas/renderers/edgeRoutingBudget.ts, packages/app/src/canvas/layout/obstacleIndex.ts, packages/app/src/canvas/renderers/edgeLabels.ts, packages/app/src/canvas/renderers/nodeEmphasis.ts] + description: Interactive Pixi.js canvas with node rendering, edge drawing, minimap, drag, and LOD-based visibility. Edge GEOMETRY has one owner -- an explicit route pipeline (anchor endpoints -> spread endpoint lanes -> detour around obstacles, each a pure stage) that consumes the anchors decided at layout time and keeps anchors and geometry in agreement; edgeDrawing.ts is left with layer management and stroking. Applying a layout performs exactly one full edge rebuild, and the detour stage is bounded by a lazily built R-tree obstacle index plus a routing budget (full > obstacles-only > none) chosen from the rendered-edge and visible-node counts. Edges are hit-tested by distance to the polyline that was ACTUALLY DRAWN, which drives edge hover, double-click-to-drill-in on aggregated edges, and a border emphasis on the hovered edge's two endpoint nodes (so it is visible where an edge lands without tracing it by eye). Node borders come from one emphasis table (selected > hovered-edge endpoint > plain). Aggregated (collapsed-container) edges carry "xN" count chips drawn at the arc-length midpoint of their routed polyline, shown at the "detail" LOD only and dimmed in step with the edge they label. + entry_points: [packages/app/src/canvas/renderers/PixiRenderer.ts, packages/app/src/canvas/Canvas.tsx, packages/app/src/canvas/renderers/edgeDrawing.ts, packages/app/src/canvas/layout/edgeRoutePipeline.ts, packages/app/src/canvas/layout/routingConstants.ts, packages/app/src/canvas/layout/edgeRoutingBudget.ts, packages/app/src/canvas/layout/obstacleIndex.ts, packages/app/src/canvas/renderers/edgeLabels.ts, packages/app/src/canvas/renderers/nodeEmphasis.ts] depends_on: [graph-layout, palette] doc: docs/features/canvas-rendering.md @@ -70,8 +70,8 @@ Features Index: doc: docs/features/palette.md graph-layout: - description: ELK-based hierarchical graph layout running in a web worker (elk-api + elk-worker.min.js?worker) so layout does not block the UI thread, split into a POSITIONS phase (ELK) and an EDGES phase (get_subgraph fetch + edge rebuild against cached positions) behind a run-latest coalescing queue. A dependency-free trigger policy (relayoutPolicy) classifies every state change as full / edges / visibility / nothing, so one user action costs at most one layout pass. Fetches per-view direct + aggregated edges from the backend (get_subgraph) rather than filtering client-side, feeds them to ELK for routing, and falls back to straight-line edges (also used as the layout guard for views over 1500 rendered nodes or 3000 view edges -- routing cost scales with edges as much as with nodes). - entry_points: [packages/app/src/canvas/layout/elkLayout.ts, packages/app/src/canvas/layout/edgePhase.ts, packages/app/src/canvas/layout/layoutScheduler.ts, packages/app/src/stores/relayoutPolicy.ts, packages/app/src/canvas/renderers/edgeRoutingBudget.ts] + description: ELK-based hierarchical graph layout running in a web worker (elk-api + elk-worker.min.js?worker) so layout does not block the UI thread, split into a POSITIONS phase (ELK) and an EDGES phase (get_subgraph fetch + edge rebuild against cached positions) behind a run-latest coalescing queue. LayoutOrchestrator owns the whole request lifecycle -- the queue, the stale-result guard, the pending gate on the cheap visibility redraw, and the latest-vs-applied visible-set reconciliation -- with every side effect injected, so the renderer keeps only the drawing. A dependency-free trigger policy (relayoutPolicy) classifies every state change as full / edges / visibility / nothing, so one user action costs at most one layout pass. Fetches per-view direct + aggregated edges from the backend (get_subgraph) rather than filtering client-side, feeds them to ELK for routing, and falls back to straight-line edges (also used as the layout guard for views over 1500 rendered nodes or 3000 view edges -- routing cost scales with edges as much as with nodes). Extraction is composition over pure, ELK-free functions (elkExtract.ts) and is where each edge's endpoint ANCHORS are decided exactly once, exactly -- from the first/last segment's direction against the node box -- becoming the durable contract the edges phase and the draw-time route pipeline consume instead of re-deriving. anchorEdgePolyline reports whether it anchored the layout's route or discarded it for a fresh one rather than escalating silently. + entry_points: [packages/app/src/canvas/layout/elkLayout.ts, packages/app/src/canvas/layout/elkExtract.ts, packages/app/src/canvas/layout/edgePhase.ts, packages/app/src/canvas/layout/layoutOrchestrator.ts, packages/app/src/canvas/layout/layoutScheduler.ts, packages/app/src/stores/relayoutPolicy.ts, packages/app/src/canvas/layout/edgeRoutingBudget.ts] depends_on: [graph-model] doc: docs/features/graph-layout.md diff --git a/docs/features/benchmarking.md b/docs/features/benchmarking.md index 4059baa..3c0d24a 100644 --- a/docs/features/benchmarking.md +++ b/docs/features/benchmarking.md @@ -162,8 +162,16 @@ Three scenarios per size: The script degrades gracefully: `obstacleIndex.ts` and `edgeRoutingBudget.ts` are loaded through a `try`/`catch` dynamic import, and their absence turns `shipped_redraw` into "route everything, crossing-aware", which is the older -shipped behaviour. Runtime imports use explicit `.ts` specifiers so the module -chain loads under plain `node` (tsconfig sets `allowImportingTsExtensions`). +shipped behaviour. `edgeRoutingBudget.ts` is probed at BOTH +`src/canvas/layout/` (where it lives) and `src/canvas/renderers/` (where it lived +before the routing pipeline was consolidated), so a run on an older branch still +finds it and stays comparable. Runtime imports use explicit `.ts` specifiers so +the module chain loads under plain `node` (tsconfig sets +`allowImportingTsExtensions`). + +`OBSTACLE_QUERY_MARGIN` is deliberately a LITERAL in the bench rather than an +import from `layout/routingConstants.ts`: the file has to stay byte-comparable +across branches where that module does not exist. ### 5. Runner -- `benchmarks/run_all.sh` diff --git a/docs/features/canvas-rendering.md b/docs/features/canvas-rendering.md index b0766c3..2b8f373 100644 --- a/docs/features/canvas-rendering.md +++ b/docs/features/canvas-rendering.md @@ -32,13 +32,16 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: ### Module Structure -1. **PixiRenderer.ts** (~565 lines) - Orchestrator +1. **PixiRenderer.ts** (~900 lines) - Renderer shell - Owns the Pixi Application, Viewport, and layer containers - Constructor, init, destroy lifecycle - `updateGraph()` (full layout), `updateEdges()` (edge-only phase), - `renderFromLayout()`, `rebuildEdgeDisplays()`, `updateVisibility()` - - `layoutQueue`: a `CoalescingScheduler` that serialises layout - work and collapses a burst of requests into ONE rerun (see graph-layout.md) + `updateVisibility()` — all three delegate straight to `layoutOrchestrator`; + `renderFromLayout()` / `rebuildEdgeDisplays()` are the apply effects it calls + - `layoutOrchestrator`: the `LayoutOrchestrator` owning the coalescing queue, + the stale-result guard, the pending gate and the visible-set reconciliation + (see graph-layout.md). The renderer supplies its effects and reads + `lastLayout` / `currentVisibleNodes` back off it. - `setHoveredNode()`, `setSelection(nodeIds, primaryId)`, `zoomToNode()` - `applyHighlight()` / `rebuildHighlightedEdgeIndices(source)`: resolve and apply the hover-or-pin highlight (connected vs induced subgraph) @@ -51,9 +54,13 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: - `fitViewportToLayout(layout)`: centre + zoom-to-fit, extracted so a layout application can fit BEFORE the edges are built - `hitTestEdge(globalPos)`: nearest rendered edge within a screen-space radius - (`EDGE_HIT_RADIUS_PX`), via `pointToPolylineDistance` over the routed - polylines. Edges are not Pixi interactive objects, so both edge hover and - edge double-click resolve through this one hit test. + (`EDGE_HIT_RADIUS_PX`), via `pointToPolylineDistance` over the polyline that + was ACTUALLY DRAWN (`edgeManager.resolvedPointsFor(index)`, falling back to + the layout polyline for an edge no redraw has touched yet). Lane spreading, + obstacle detours and node drags all move an edge off its layout route, so + testing the layout polyline would hit an invisible line. Edges are not Pixi + interactive objects, so both edge hover and edge double-click resolve + through this one hit test. - Wires up interaction event handlers on node displays, plus viewport-level edge interactions: throttled hover, and a `pointertap` pair (two taps on the SAME edge within `DOUBLE_TAP_MS`) that drills into an AGGREGATED edge @@ -61,7 +68,9 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: hover and active drags short-circuit both, so node interactions win. - Delegates to EdgeDrawingManager, MinimapRenderer, DragManager -2. **edgeDrawing.ts** (~425 lines) - Edge rendering with two-layer architecture +2. **edgeDrawing.ts** (~815 lines) - Layer management and stroking + - Owns WHAT to draw and HOW it looks. It does not own edge GEOMETRY: every + routing decision belongs to `layout/edgeRoutePipeline.ts` (see 2e below). - `EdgeDrawingManager` class: manages edgeData array, nodeToEdgeIndices map, highlightedEdgeIndices - **Two-layer rendering:** - `baseLayer` (Graphics): all edges at normal LOD-based opacity. Rebuilt on layout/visibility/LOD/drag. @@ -71,19 +80,23 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: - `setHighlightActive(active)`: highlight-only update returning true if handled (no full redraw needed). The manager is deliberately ignorant of WHERE the highlight came from -- the caller resolves hover vs pinned selection vs induced subgraph, fills `highlightedEdgeIndices`, and passes a flag. - - `redrawEdgesWithHighlight(...)`: full base+highlight layer rebuild. Its pipeline is: + - `redrawEdgesWithHighlight(...)`: full base+highlight layer rebuild: 1. `snapshotNodeRefs(visibleNodes, getNodeDisplayRef)` -- ONE `NodeDisplayRef` per visible node for the whole redraw (never per edge) - 2. resolve each visible, LOD-passing edge to a polyline (`resolveEdgeDraw`), which - no longer collects obstacles - 3. `spreadEndpointLanes` (both ends) - 4. `resolveEdgeRoutingMode(...)` -> optional routing pass: `buildObstacleIndex` - once, then `obstaclesForEdge` (an rbush query over the edge's own inflated - bbox, `OBSTACLE_QUERY_MARGIN` = 160) + `routePolylineAroundObstacles` per edge - 5. stroke, and collect "×N" chip specs at the arc-length midpoint of the - polyline that was actually drawn + 2. `edgeRouteInput(idx, edge, refs)` per visible, LOD-passing edge: pairs the + layout route + anchors with where the two nodes are on screen right now + 3. ONE call to `routeEdges(inputs, env)` -- all geometry, budget gate + included. `env.obstacles` is a THUNK (`() => buildObstacleIndex(refs)`), + so a redraw the gate downgrades to `none` never builds an index it will + not query + 4. stroke, record each drawn polyline in `resolvedPointsByEdgeIndex`, and + collect "×N" chip specs at the arc-length midpoint of the polyline that + was actually drawn + - `resolvedPointsFor(edgeIndex)`: the polyline that was actually drawn, or + null before the first redraw. This is what `hitTestEdge` measures against. - `buildEdgeData(layout)`: converts LayoutResult edges into EdgeDatum array, - pre-parsing each `color` into `colorInt` so no redraw ever re-parses a hex string + carrying each edge's layout-time anchors through and pre-parsing each + `color` into `colorInt` so no redraw ever re-parses a hex string - `scheduleEdgeRedraw()` / `flushEdgeRedraw()`: animation frame throttling - LOD helper functions: `getLODEdgeOpacity`, `shouldHideEdgeKindAtLOD`, `getLODEdgeWidthMultiplier` - Private drawing primitives: `drawEdgePath`, `drawEdgeStartCap`, `drawEdgeArrowhead` @@ -108,7 +121,49 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: `Text.tint`, not a cloned style) so importing the module outside a DOM never touches Pixi's text machinery. -2c. **edgeRoutingBudget.ts** (~130 lines, pure) - How much routing a view can afford +2e. **layout/edgeRoutePipeline.ts** (~370 lines) - The single owner of edge geometry + + An edge's polyline used to be mutated by half a dozen loosely-coupled stages + across three files, each re-deciding for itself which side of a node the edge + attached to. Now there is ONE composition function whose body IS the stage + order: + + ``` + routeEdges(inputs, env) + anchorEndpoints -> put endpoints on their anchors, at current positions + [budget gate] resolveEdgeRoutingMode over the surviving edge count + spreadEndpointLanes -> fan out the edges sharing one node side + detourAroundObstacles-> push routes clear of node/label boxes (gated) + ``` + + - Every stage is a pure `(edges, ctx) -> edges` returning NEW records, so a + stage's output is exactly what the next stage sees. Nothing is mutated + across a stage boundary. + - **The anchor contract.** Stages CONSUME the anchors decided at layout time + (see graph-layout.md) and derive endpoints from `(box, anchor)` via + `getAnchorPoint`. No stage reads a polyline back to work out which side an + edge attached to. A stage that deliberately moves an endpoint (lane + spreading) emits an UPDATED anchor with it, so anchors and geometry never + disagree — including the two-point case where moving a source lane drags + the far end's row with it. + - **The one fresh decision.** `anchorEndpoints` has three cases: neither box + moved (snap the stored route onto the stored anchors); both moved by the + same delta (translate; anchors are box-relative and survive); moved APART + (the stored anchors describe geometry that no longer exists, so new ones + are decided facing the opposite box and the edge is re-routed). + - `RoutedEdge.origin` (`"anchored" | "rerouted" | "reanchored"`) records which + branch produced the geometry, making `anchorEdgePolyline`'s escalation + observable instead of silent. + - The gate sits BETWEEN stages 1 and 2 because it counts the edges that will + actually be stroked, which is only known once stage 1 has dropped the + undrawable ones. + - `obstaclesForEdge` (an rbush query over the edge's own bbox inflated by + `OBSTACLE_QUERY_MARGIN`, minus its own endpoints' boxes and any box + swallowing an endpoint centre) lives here too. + - Runtime imports use `.ts` specifiers and `ObstacleIndex` is imported + type-only, so the whole module loads under `node --test`. + +2c. **layout/edgeRoutingBudget.ts** (~130 lines, pure) - How much routing a view can afford - `EdgeRoutingMode` = `"full" | "obstacles" | "none"`, resolved by `resolveEdgeRoutingMode({ renderedEdges, visibleNodes, edgesVisible })` - `CROSSING_AWARE_EDGE_LIMIT` (250): above this, detour candidates are no longer @@ -123,6 +178,24 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: the layout-time (ELK) guard, kept here so every routing threshold lives in one file (consumed by `layout/elkLayout.ts` -- see graph-layout.md) - Import-free so it loads under `node --test` + - Lives in `layout/`, not `renderers/`: `layout/elkLayout.ts` consumes it, and + a layout module must not depend on the renderer + +2f. **layout/routingConstants.ts** (~95 lines, pure) - Every routing tolerance in one place + - `POINT_TOLERANCE` (0.5, point equality) < `BOUNDARY_TOLERANCE` (4, boundary + containment) < `NODE_OBSTACLE_MARGIN` (14) < `DETOUR_GUTTER` (28), plus + `NODE_MOVED_EPSILON` (1), `MAX_OBSTACLE_REROUTE_PASSES` (32) and the lead + distances + - `OBSTACLE_QUERY_MARGIN` is DERIVED -- `NODE_OBSTACLE_MARGIN + DETOUR_GUTTER + + OBSTACLE_QUERY_ALLOWANCE` = 160 -- so the relationship cannot drift the way + the prose comment it replaced could + - Two tolerances used to disagree here: the drawing pass had its own 1.5-unit + "is this point on that box" test alongside `edgeGeometry`'s 4, which is how + one stage could put an edge on a side another stage did not believe in + - `edgeGeometry.ts` re-binds the ones in its innermost loops to module-local + consts (a measured ~8% of the whole routing pass: V8 constant-folds a + module-scope `const` but not a live imported binding). `routingConstants.ts` + remains the owner of the values. 2d. **layout/obstacleIndex.ts** (~150 lines) - R-tree over a redraw's obstacle boxes - `ObstacleIndex`: bulk-loaded rbush; `query(bounds, excludeA, excludeB)` and @@ -168,12 +241,6 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: - Import-free so it loads under `node --test`; the pixi side (`redrawNodeBg`, `createNodeDisplay`) reads the style table -8. **Re-export shims** (replace dead code): - - `EdgeRenderer.ts`: re-exports EdgeDrawingManager and related types from edgeDrawing.ts - - `NodeRenderer.ts`: re-exports from nodeCreation.ts and dragManager.ts - - `LabelRenderer.ts`: re-exports updateNodeLabelWrap from dragManager.ts - - `interaction/interactionManager.ts`: re-exports DragManager from dragManager.ts - ### Shared Utilities - **canvas/utils/graphUtils.ts** (~30 lines) @@ -207,9 +274,11 @@ The PixiRenderer (orchestrator) delegates to focused sub-modules: 5. On viewport move, `onViewportChanged()` syncs the viewport state and updates the minimap; it rebuilds edges ONLY when the LOD actually changed (user zooming across an LOD boundary must restyle edges; panning must not). -5b. `updateVisibility(visibleNodes)` skips its edge rebuild while a layout request - is in flight (`_layoutPending`): that rebuild would route the new visible set - against the stale layout and be discarded moments later anyway. +5b. `updateVisibility(visibleNodes)` hands the set to the orchestrator, which + flips the node displays but skips the edge rebuild while a layout request is in + flight: that rebuild would route the new visible set against the stale layout + and be discarded moments later anyway. The set is re-applied on top of the + fresh displays once the layout lands. 6. On hover or selection change, `setHoveredNode()` / `setSelection()` both call `applyHighlight()`, which resolves the highlight source (hover > pinned selection > none; induced at 2+ selected), rebuilds the highlighted edge indices, and calls `edgeManager.setHighlightActive()` -- only the highlight layer is rebuilt, not the base layer 7. On drag, globalpointermove updates node positions, resizes ancestors, schedules edge redraw @@ -239,9 +308,11 @@ Three things bound it now: 1. **Hoisted node scan.** `snapshotNodeRefs` reads each visible node's `NodeDisplayRef` exactly once per redraw; every edge shares the snapshot. 2. **Spatial index.** All obstacle boxes go into one `ObstacleIndex` (rbush) per - redraw, and each edge queries only the boxes intersecting its own polyline - bbox inflated by `OBSTACLE_QUERY_MARGIN` (160 -- comfortably more than the - obstacle inflation of 14 plus the detour gutter of 28). Per-edge cost is + redraw -- built LAZILY, so a redraw the gate downgrades to `none` never pays + for it -- and each edge queries only the boxes intersecting its own polyline + bbox inflated by `OBSTACLE_QUERY_MARGIN` (160, computed as the obstacle + inflation of 14 plus the detour gutter of 28 plus an explicit allowance). + Per-edge cost is O(log N + k) instead of O(N), and the routed result is unchanged: routing the query result and routing the full obstacle list produce identical polylines. 3. **Budget gate.** `resolveEdgeRoutingMode` picks `full` / `obstacles` / `none` @@ -282,8 +353,10 @@ only the symmetric difference of the endpoint set is touched per hover change. | File | Role | Key Exports | |------|------|-------------| | `packages/app/src/canvas/renderers/PixiRenderer.ts` | Orchestrator | `PixiRenderer` class | -| `packages/app/src/canvas/renderers/edgeDrawing.ts` | Edge rendering (two-layer) | `EdgeDrawingManager`, `getLODEdgeOpacity`, etc. | -| `packages/app/src/canvas/renderers/edgeRoutingBudget.ts` | Routing budget/thresholds (pure) | `EdgeRoutingMode`, `resolveEdgeRoutingMode`, `routesAroundObstacles`, `scoresEdgeCrossings`, `shouldSkipLayoutEdgeRouting` | +| `packages/app/src/canvas/renderers/edgeDrawing.ts` | Edge layers + stroking (two-layer) | `EdgeDrawingManager` (incl. `resolvedPointsFor`), `getLODEdgeOpacity`, etc. | +| `packages/app/src/canvas/layout/edgeRoutePipeline.ts` | The draw-time route pipeline (single owner of edge geometry) | `routeEdges`, `anchorEndpoints`, `anchorEdgeRoute`, `spreadEndpointLanes`, `detourAroundObstacles`, `obstaclesForEdge`, `laneOffset`, `EdgeRouteInput`, `RoutedEdge`, `EdgeRouteEnv` | +| `packages/app/src/canvas/layout/routingConstants.ts` | All routing tolerances/margins (pure, derived arithmetic) | `POINT_TOLERANCE`, `BOUNDARY_TOLERANCE`, `NODE_MOVED_EPSILON`, `NODE_OBSTACLE_MARGIN`, `DETOUR_GUTTER`, `OBSTACLE_QUERY_MARGIN` | +| `packages/app/src/canvas/layout/edgeRoutingBudget.ts` | Routing budget/thresholds (pure) | `EdgeRoutingMode`, `resolveEdgeRoutingMode`, `routesAroundObstacles`, `scoresEdgeCrossings`, `shouldSkipLayoutEdgeRouting` | | `packages/app/src/canvas/layout/obstacleIndex.ts` | Per-redraw R-tree of obstacle boxes | `ObstacleIndex`, `obstacleEntry`, `polylineBounds` | | `packages/app/src/canvas/renderers/edgeLabels.ts` | Aggregated-edge "×N" count chips | `polylineArcMidpoint`, `shouldShowCountChip`, `chipAlphaForEdge`, `buildEdgeCountChipLayer` | | `packages/app/src/canvas/renderers/types.ts` | Shared types | `EdgeDatum`, `NodeDisplayRef`, `EDGE_STYLES`, `NodePadding` | @@ -291,10 +364,6 @@ only the symmetric difference of the endpoint set is touched per hover change. | `packages/app/src/canvas/renderers/dragManager.ts` | Drag + resize | `DragManager`, `redrawNodeBg`, `syncDisplayBounds` | | `packages/app/src/canvas/renderers/nodeCreation.ts` | Node factory | `createNodeDisplay`, `NodeDisplay`, `getNodeLayer` | | `packages/app/src/canvas/renderers/nodeEmphasis.ts` | Node border emphasis (pure) | `NodeEmphasis`, `NODE_EMPHASIS_STYLES`, `resolveNodeEmphasis`, `edgeEndpointIds`, `emphasisRedrawIds` | -| `packages/app/src/canvas/renderers/EdgeRenderer.ts` | Re-export shim | Re-exports from edgeDrawing.ts | -| `packages/app/src/canvas/renderers/NodeRenderer.ts` | Re-export shim | Re-exports from nodeCreation.ts + dragManager.ts | -| `packages/app/src/canvas/renderers/LabelRenderer.ts` | Re-export shim | Re-exports from dragManager.ts | -| `packages/app/src/canvas/interaction/interactionManager.ts` | Re-export shim | Re-exports DragManager | | `packages/app/src/canvas/utils/graphUtils.ts` | Shared utils | `buildParentMap`, `getNodeSize` | | `packages/app/src/canvas/Canvas.tsx` | React component | Canvas mount/unmount, store subscriptions | @@ -304,7 +373,9 @@ only the symmetric difference of the endpoint set is touched per hover change. |------|---------------| | `packages/app/tests/edgeRenderer.test.ts` | Edge index building, highlight collection, two-layer invariants, EDGE_STYLES | | `packages/app/tests/nodeRenderer.test.ts` | Node labels, colors, blockKindPrefix, selected-node state machine, color constants | -| `packages/app/tests/edgeGeometry.test.ts` | Edge routing geometry (anchorEdgePolyline, rerouteOrthogonalEdge) | +| `packages/app/tests/edgeGeometry.test.ts` | Edge routing geometry: exact anchor decisions (`edgeAnchorAtBoundary`), the explicit anchored-vs-rerouted result of `anchorEdgePolyline`, `rerouteOrthogonalEdge`, obstacle detours | +| `packages/app/tests/edgeRoutePipeline.test.ts` | Stage order and hand-off, no cross-stage mutation, the anchor contract (survival, lane-offset updates, fresh anchors after a drag), the anchored/rerouted/reanchored origin, and the budget gate (incl. that `none` never builds an obstacle index) | +| `packages/app/tests/routingConstants.test.ts` | The DERIVED constant relationship (`OBSTACLE_QUERY_MARGIN`) and the tolerance ordering | | `packages/app/tests/edgeGeometryBounds.test.ts` | `boundingBox`: coverage, empty input, and 10k boxes without a spread-argument overflow | | `packages/app/tests/obstacleIndex.test.ts` | Query windows, owner exclusion (body + label), locality of results | | `packages/app/tests/edgeRoutingBudget.test.ts` | Routing-mode thresholds, monotonicity, layout-time routing guard | @@ -343,6 +414,25 @@ only the symmetric difference of the endpoint set is touched per hover change. - The polyline arrays stored in `resolvedPointsByEdgeIndex` are the same arrays the base layer drew (no defensive copy): routing always produces fresh arrays and nothing mutates a resolved polyline in place. +- Edge geometry has exactly ONE owner: `layout/edgeRoutePipeline.ts`. + `edgeDrawing.ts` decides what to route and strokes the answer; it computes no + route, moves no endpoint and infers no anchor. +- An edge's anchors are DECIDED once, at layout time, and consumed everywhere + else. No draw-time stage re-derives an anchor from a polyline. The single + exception is a node dragged away from its laid-out position, where the stored + anchor describes geometry that no longer exists. +- Anchors and geometry never disagree: a stage that moves an endpoint emits the + updated anchor with it, and every edge the pipeline emits satisfies + `points[0] === getAnchorPoint(sourceBox, sourceAnchor)` and likewise at the + target end (with >= 2 points). +- Every stage returns new records; nothing is mutated across a stage boundary, + so a stage's input is exactly its predecessor's output. +- Hit testing measures the polyline that was DRAWN, never the layout polyline, + whenever a resolved one exists. Hover, the tooltip and drill-in therefore + always agree with what is on screen. +- Routing tolerances have one source of truth (`layout/routingConstants.ts`); + no stage carries a private tolerance for the same question. `edgeGeometry.ts` + re-binds hot ones as module-local consts for V8, but never restates a value. - Hover and pin share ONE dim path: both go through `setBaseLayerAlpha()`. No second dimming mechanism exists, so base edges and their count chips can never drift out of lockstep. - `EdgeDrawingManager` receives only `highlightActive: boolean` + `highlightedEdgeIndices`; it never learns whether the highlight came from hover, a pin, or an induced subgraph. - Count chips are built inside the same pass that strokes the edges, so they are rebuilt exactly when their layer is (including drag redraws) and add no per-frame work. Chips are world-space children of the edge layer, so they scale with zoom. diff --git a/docs/features/graph-layout.md b/docs/features/graph-layout.md index 39a6e88..d15b28c 100644 --- a/docs/features/graph-layout.md +++ b/docs/features/graph-layout.md @@ -28,10 +28,13 @@ The pipeline has two phases and one trigger policy: - Coalescing concurrent layout requests and discarding stale results. - The relayout trigger policy and the store's trigger counters. +- Deciding each edge's endpoint anchors, once, from the pristine route (see + "The anchor contract"). + ### Not in scope -- What the edges *look like* once positioned, including the client-side - obstacle-avoidance re-routing pass and its draw-time budget (see - canvas-rendering). +- What the edges *look like* once positioned: the draw-time route pipeline + (`layout/edgeRoutePipeline.ts`, which lives under `layout/` because it is + geometry, but is documented in canvas-rendering) and its budget. - Which nodes are visible/expanded in the first place (see zoom-views, visibilityFilter). - Server-side subgraph/aggregation semantics (see server_side_graph_state). @@ -95,18 +98,31 @@ Canvas.tsx [displayVisibleNodes] -> PixiRenderer.updateVisibility(displayVisible) (skipped when a layout request just took that set) -PixiRenderer.layoutQueue (CoalescingScheduler) - schedule(full|edges) -> runs immediately when idle; - otherwise merged into ONE pending rerun - (mergeLayoutRequests: full dominates edges, - adopting the newest edge filters) +LayoutOrchestrator (layout/layoutOrchestrator.ts; owns the whole request lifecycle) + requestFullLayout / requestEdgePhase / setVisibleNodes + -> CoalescingScheduler: runs immediately when idle; + otherwise merged into ONE pending rerun + (mergeLayoutRequests: full dominates edges, adopting the newest + edge filters) run: - full -> layoutGraph(...) -> LayoutResult{nodes, edges, counts, renderIds} - -> renderFromLayout (rebuild node displays, edges, refit viewport) - edges -> layoutEdgePhase(lastLayout, kinds, hideAmbiguous) - -> rebuildEdgeDisplays (edges only; viewport untouched) - both: `_layoutRequestId` guards the result, and edgeKindCounts are published to - edgeLegendStore only after that guard. + full -> effects.runFullLayout = layoutGraph(...) + -> LayoutResult{nodes, edges, counts, renderIds} + -> effects.applyFullLayout = renderFromLayout + (rebuild node displays, edges, refit viewport) + edges -> effects.runEdgePhase = layoutEdgePhase(lastLayout, kinds, hideAmbig) + -> effects.applyEdgeLayout = rebuildEdgeDisplays + (edges only; viewport untouched) + both: the request id guards the result, and edgeKindCounts are published to + edgeLegendStore (effects.publishEdgeKindCounts) only after that guard. + pending gate: set by requestFullLayout, cleared when the layout is applied OR + fails; while set, setVisibleNodes flips the node displays but skips the + edge redraw. + visible-set reconciliation: the newest visible set is remembered, and re-applied + on top of the fresh displays when a layout that predates it lands. + + The renderer supplies every effect (run/apply/publish/report) and keeps only + the drawing; the DECISIONS live in the orchestrator, which is dependency-free + and unit-tested with fakes. elkLayout.layoutGraph buildElkNode tree from (visible ∩ expanded-ancestors) -> renderIds @@ -137,7 +153,7 @@ edgePhase.layoutEdgePhase(previous, kinds, hideAmbiguous) edges are dropped here when the toolbar toggle asks for it. The same payload derives the legend's per-kind counts (`deriveEdgeKindCounts`). 4. **Routing guard.** `shouldSkipLayoutEdgeRouting(elkNodeIds.size, viewEdges.length)` - (from `renderers/edgeRoutingBudget.ts`) decides whether ELK is given any edges + (from `layout/edgeRoutingBudget.ts`) decides whether ELK is given any edges at all. Routing cost is driven by EDGES as much as by nodes, so BOTH counts gate it: - `LAYOUT_EDGE_ROUTING_NODE_LIMIT` = 1500 rendered nodes @@ -151,15 +167,53 @@ edgePhase.layoutEdgePhase(previous, kinds, hideAmbiguous) kind/colour/count. 6. `elk.layout(elkGraph)` runs in a web worker (`elkjs/lib/elk-api` + `elk-worker.min.js?worker`), so layout never blocks the UI thread. -7. `extractLayout` walks the result, accumulating parent offsets into absolute - positions, converts each edge's sections into a `Point[]`, infers source and - target anchors (`inferEdgeAnchor`) and normalises the polyline through - `anchorEdgePolyline`. If no routed edges came back (ELK produced none, or - routing was skipped) it emits a straight-line edge per view edge whose - endpoints are present. +7. `extractLayout` is composition only; the work lives in the pure, ELK-free + `layout/elkExtract.ts`: + - `extractNodePositions(root)` walks the tree once, accumulating parent + offsets into absolute positions (root excluded). + - `extractRoutedEdges(root, positions, viewEdges)` walks it again — an edge's + section coordinates are relative to the node it hangs off, so the walk has + to carry that node's offset — and turns each ELK edge into a `LayoutEdge` + via `routedEdgeFromElk`, returning the counts the DEV debug line reports. + - `routedEdgeFromElk` is where an edge's ANCHORS ARE DECIDED, exactly once + (see "The anchor contract" below), before `anchorEdgePolyline` makes the + geometry agree with them. + + If no routed edges came back (ELK produced none, or routing was skipped) it + emits a straight-line edge per view edge whose endpoints are present. 8. If `elk.layout` throws, `fallbackLayout` lays the visible nodes out on a plain grid with no edges, so the canvas still renders something. +### The anchor contract + +An edge's endpoint anchor (`{side, offset}` into a node box) is decided **once, +at layout time, from pristine geometry** and then carried as a durable field on +`LayoutEdge` and `EdgeDatum`. Nothing downstream re-derives it from a polyline. + +- **Who decides.** `elkExtract.routedEdgeFromElk` for ELK-routed edges (via + `edgeGeometry.edgeAnchorAtBoundary`), and `straightEdges.straightLineEdge` for + fallback connectors — the latter CHOOSES the facing sides, so it states the + anchors outright instead of reading them back. +- **How exactly.** `edgeAnchorAtBoundary` reads the side off the first (or last) + segment's DIRECTION: an orthogonal edge can only leave a box through the side + its first segment points at. Two ordered fallbacks handle non-orthogonal input + — the side the point demonstrably sits on, then the dominant axis of the + departure — and exist for the centre-to-centre connector ELK leaves when it + routes nothing. There is no "which side did the router probably mean" guess + from how near a bend happens to be to a box edge. +- **Who consumes.** `edgeRebuild` (cached edges keep their anchors), and every + draw-time stage in `layout/edgeRoutePipeline.ts`, which derives endpoints from + `(box, anchor)` through `getAnchorPoint` (see canvas-rendering). +- **When a NEW anchor may be decided.** Only when a node has been dragged away + from its laid-out position: the stored anchor describes geometry that no + longer exists, so the pipeline picks a fresh one facing the opposite endpoint + (`inferEdgeAnchorFromPoint`). That is a new decision, not a re-derivation. + +`anchorEdgePolyline` makes the decision it used to hide explicit: it returns +`{ points, rerouted }`, where `rerouted: true` means the incoming route was +DISCARDED and replaced by `rerouteOrthogonalEdge` rather than nudged onto its +anchors. + ### Why the edges phase reuses routed geometry ELK's `layered` algorithm places nodes *from* the edge set, so re-running it for a @@ -181,8 +235,12 @@ back on) get a straight connector until the next full layout — the sidebar's - `packages/app/src/canvas/Canvas.tsx` — derives the effective layout inputs, keeps them in a ref, and runs one effect per trigger counter. - `packages/app/src/canvas/layout/elkLayout.ts` — positions phase - (`layoutGraph`), ELK options, the routing guard call, extraction, - ELK-failure fallback layout. + (`layoutGraph`), ELK options, the routing guard call, the `extractLayout` + composition + DEV logging (`debugLog`), ELK-failure fallback layout. +- `packages/app/src/canvas/layout/elkExtract.ts` — the PURE half of extraction: + `extractNodePositions`, `elkEdgePolyline`, `routedEdgeFromElk`, + `extractRoutedEdges` (+ `ExtractStats`). ELK types are imported type-only and + runtime imports use `.ts` specifiers, so it is testable without the worker. - `packages/app/src/canvas/layout/edgePhase.ts` — edges phase (`layoutEdgePhase`; re-exports `rebuildEdges`). - `packages/app/src/canvas/layout/edgeRebuild.ts` — the pure core of the edges @@ -200,19 +258,40 @@ back on) get a straight connector until the next full layout — the sidebar's `mergeLayoutRequests`; PURE, unit-tested. - `packages/app/src/canvas/layout/layoutScheduler.ts` — `CoalescingScheduler`, the generic run-latest queue; PURE, unit-tested. +- `packages/app/src/canvas/layout/layoutOrchestrator.ts` — `LayoutOrchestrator` + and `LayoutOrchestratorEffects`: the OWNER of layout scheduling and staleness. + Holds the coalescing queue, the request-id stale guard, the pending gate and + the latest-vs-applied visible-set reconciliation; every side effect (run ELK, + touch displays, redraw edges, publish counts, report errors) is injected. + Runtime-imports only `layoutScheduler.ts` + `layoutRequest.ts` (explicit `.ts` + specifiers), so it loads under `node --test` and is tested with fakes. + Public API: `requestFullLayout`, `requestEdgePhase`, `setVisibleNodes`, + `destroy`, and the `lastLayout` / `isLayoutPending` / `visibleNodes` getters. - `packages/app/src/canvas/layout/elkEdgeId.ts` — encodes/decodes the viewEdges index in an ELK edge id (`elkEdgeId`, `elkEdgeIndex`). - `packages/app/src/canvas/layout/edgeGeometry.ts` — orthogonal polyline - geometry: anchors, normalisation, obstacle detours (`anchorEdgePolyline`, + geometry: anchors, normalisation, obstacle detours (`edgeAnchorAtBoundary`, + `anchorOnSide`, `getAnchorPoint`, `anchorEdgePolyline` -> `AnchoredPolyline`, + `normalizeRoutedPolyline`, `ensureDrawablePolyline`, `routePolylineAroundObstacles`, `boundingBox`, `pointToPolylineDistance`). -- `packages/app/src/canvas/renderers/edgeRoutingBudget.ts` — ALL routing +- `packages/app/src/canvas/layout/edgeRoutePipeline.ts` — the DRAW-time route + pipeline (see canvas-rendering); lives here because it is geometry, not + rendering, and because it must stay loadable under `node --test`. +- `packages/app/src/canvas/layout/routingConstants.ts` — every routing tolerance + and margin in one dependency-free module, with derived values expressed as + arithmetic (`OBSTACLE_QUERY_MARGIN` = `NODE_OBSTACLE_MARGIN` + `DETOUR_GUTTER` + + `OBSTACLE_QUERY_ALLOWANCE`). +- `packages/app/src/canvas/layout/edgeRoutingBudget.ts` — ALL routing thresholds, layout-time and draw-time (`shouldSkipLayoutEdgeRouting`, - `LAYOUT_EDGE_ROUTING_NODE_LIMIT`, `LAYOUT_EDGE_ROUTING_EDGE_LIMIT`). + `LAYOUT_EDGE_ROUTING_NODE_LIMIT`, `LAYOUT_EDGE_ROUTING_EDGE_LIMIT`). Moved out + of `renderers/` so the layout phase no longer depends on the renderer. - `packages/app/src/canvas/utils/graphUtils.ts` — node sizing / parent map shared with the renderer (`getNodeSize`, `buildParentMap`). -- `packages/app/src/canvas/renderers/PixiRenderer.ts` — owns the queue, - `updateGraph` / `updateEdges` / `updateVisibility`, the `_layoutRequestId` - stale guard and the edge-legend publish. +- `packages/app/src/canvas/renderers/PixiRenderer.ts` — `updateGraph` / + `updateEdges` / `updateVisibility` delegate straight to the orchestrator; the + renderer supplies its effects (ELK + edge phase calls, display rebuilds, edge + redraws, the `edgeLegendStore` publish) and reads `lastLayout` / + `currentVisibleNodes` back off it for rendering and hit-testing. ## Test Files @@ -220,9 +299,12 @@ back on) get a straight connector until the next full layout — the sidebar's |------|---------------| | `packages/app/tests/relayoutPolicy.test.ts` | The trigger policy table | | `packages/app/tests/layoutScheduler.test.ts` | Run-latest coalescing, error draining | +| `packages/app/tests/layoutOrchestrator.test.ts` | The request lifecycle with fake effects: burst coalescing + merge semantics, the edges-phase no-op and render-set reuse, the visibility gate and the late re-apply, the error path releasing the gate, stale-pass discard | | `packages/app/tests/edgeRebuild.test.ts` | Edge-phase rebuild: polyline reuse + the drawing pass's anchored-polyline contract (seam test) | | `packages/app/tests/elkEdgeId.test.ts` | Edge-id round-tripping and rejection of foreign ids | -| `packages/app/tests/edgeGeometry.test.ts` | Anchor inference, orthogonal rerouting, obstacle detours | +| `packages/app/tests/elkExtract.test.ts` | Position extraction (nested offsets, unsized nodes), the anchor decided per route, ID-based view-edge resolution, the section-less fallback, and the anchor round trip into the draw-time pipeline | +| `packages/app/tests/edgeGeometry.test.ts` | Exact anchor decisions, the explicit anchored-vs-rerouted result, orthogonal rerouting, obstacle detours | +| `packages/app/tests/routingConstants.test.ts` | The derived `OBSTACLE_QUERY_MARGIN` relationship and the tolerance ordering | | `packages/app/tests/edgeGeometryBounds.test.ts` | `boundingBox` over empty, overlapping and very large inputs | | `packages/app/tests/edgeRoutingBudget.test.ts` | The layout-time routing guard (and the draw-time budget) | @@ -235,8 +317,11 @@ back on) get a straight connector until the next full layout — the sidebar's - Only one layout runs at a time; requests arriving during a run collapse into a single rerun carrying the newest inputs. A queued full layout absorbs any queued edge phase. -- `edgeLegendStore` counts are published only after the `_layoutRequestId` stale +- `edgeLegendStore` counts are published only after the orchestrator's stale check, so a superseded pass can never clobber current counts. +- The cheap visibility redraw is gated while a full layout is in flight, and the + gate is released when the layout is applied OR fails — a failed layout must + never leave the visibility path permanently mute. - The edges phase never moves the camera; only a full layout refits the viewport. - A layout that started before a later visibility change re-applies the newest visible set when it lands, so a slow layout cannot resurrect hidden nodes. @@ -250,8 +335,21 @@ back on) get a straight connector until the next full layout — the sidebar's - Every `LayoutEdge` has at least 2 points and endpoints that lie on its source and target boxes (`anchorEdgePolyline`), so downstream code may assume a drawable, anchored polyline. +- `LayoutEdge.sourceAnchor` / `targetAnchor` are DECIDED once, by the layout + phase, and read everywhere else. No consumer re-derives an anchor from a + polyline; the only place a new anchor is decided later is a dragged node, + whose stored anchor describes geometry that no longer exists. +- `getAnchorPoint(box, anchor)` reproduces the polyline's endpoint exactly for + as long as the box has not moved. That is what lets every later stage work + from `(box, anchor)` instead of from the geometry. +- `anchorEdgePolyline` never silently discards a route: `rerouted` says whether + the caller is holding the layout's own geometry or a replacement. +- `elkExtract.ts` and `routingConstants.ts` must stay free of runtime imports + that are not `.ts`-specified pure modules, so the node:test runner can load + them (ELK's own types are imported type-only). - `layoutGraph` never rejects: IPC failure yields an empty edge set, and an ELK failure yields `fallbackLayout`. - `relayoutPolicy.ts`, `layoutScheduler.ts` and `layoutRequest.ts` must stay free of runtime imports from other `src` modules (type-only imports are fine) so the - node:test runner can load them. + node:test runner can load them. `layoutOrchestrator.ts` may runtime-import only + those last two, and only through explicit `.ts` specifiers. diff --git a/packages/app/benchmarks/edgeRouting.bench.ts b/packages/app/benchmarks/edgeRouting.bench.ts index 257fa6a..836d567 100644 --- a/packages/app/benchmarks/edgeRouting.bench.ts +++ b/packages/app/benchmarks/edgeRouting.bench.ts @@ -21,7 +21,7 @@ * via `src/canvas/layout/obstacleIndex.ts`. That module only exists on the * perf branch; where it is missing this scenario is reported as `skipped`. * 3. `shipped_redraw` -- what the branch under test actually does at this size, - * budget gate included. `src/canvas/renderers/edgeRoutingBudget.ts` also only + * budget gate included. `src/canvas/layout/edgeRoutingBudget.ts` also only * exists on the perf branch; without it the scenario falls back to * "route everything, crossing-aware", which is the older shipped behaviour. * @@ -46,7 +46,11 @@ import { type Point, } from "../src/canvas/layout/edgeGeometry.ts"; -/** Mirrors `OBSTACLE_QUERY_MARGIN` in edgeDrawing.ts. */ +/** + * Mirrors `OBSTACLE_QUERY_MARGIN` in `src/canvas/layout/routingConstants.ts`. + * Deliberately a literal, not an import: this file must stay byte-comparable + * across branches where that module does not exist. + */ const OBSTACLE_QUERY_MARGIN = 160; /** No-reference-polylines sentinel, matching the renderer's shared constant. */ @@ -369,9 +373,12 @@ async function main(): Promise { const obstacleMod = await loadOptional( "../src/canvas/layout/obstacleIndex.ts" ); - const budgetMod = await loadOptional( - "../src/canvas/renderers/edgeRoutingBudget.ts" - ); + // The budget module moved renderers/ -> layout/ when the routing pipeline was + // consolidated there; both paths are probed so a run on an older branch still + // finds it and stays comparable. + const budgetMod = + (await loadOptional("../src/canvas/layout/edgeRoutingBudget.ts")) ?? + (await loadOptional("../src/canvas/renderers/edgeRoutingBudget.ts")); process.stderr.write( `[edgeRouting.bench] label=${label} obstacleIndex=${obstacleMod ? "present" : "absent"} ` + diff --git a/packages/app/src/canvas/interaction/interactionManager.ts b/packages/app/src/canvas/interaction/interactionManager.ts deleted file mode 100644 index e88666e..0000000 --- a/packages/app/src/canvas/interaction/interactionManager.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Re-export the canonical drag/interaction manager. - * - * This file previously contained dead code. The real drag handling - * implementation lives in renderers/dragManager.ts. Interaction event - * handlers (pointerdown, globalpointermove, pointerup, pointertap, - * pointerover/out) are wired up by PixiRenderer.addNodeDisplay(). - */ -export { DragManager } from "../renderers/dragManager"; diff --git a/packages/app/src/canvas/layout/edgeGeometry.ts b/packages/app/src/canvas/layout/edgeGeometry.ts index 2d270c1..4e8c243 100644 --- a/packages/app/src/canvas/layout/edgeGeometry.ts +++ b/packages/app/src/canvas/layout/edgeGeometry.ts @@ -1,3 +1,33 @@ +// Explicit .ts specifier keeps this module (and everything that imports it) +// loadable under `node --test` -- see edgeRebuild.ts and its seam test. +import { + BOUNDARY_TOLERANCE as SHARED_BOUNDARY_TOLERANCE, + DETOUR_GUTTER as SHARED_DETOUR_GUTTER, + MAX_LEAD_DISTANCE as SHARED_MAX_LEAD_DISTANCE, + MAX_OBSTACLE_REROUTE_PASSES as SHARED_MAX_OBSTACLE_REROUTE_PASSES, + MIN_LEAD_DISTANCE as SHARED_MIN_LEAD_DISTANCE, + NODE_OBSTACLE_MARGIN as SHARED_NODE_OBSTACLE_MARGIN, + POINT_TOLERANCE as SHARED_POINT_TOLERANCE, +} from "./routingConstants.ts"; + +/** + * The shared routing constants, re-bound as module-local consts. + * + * `routingConstants.ts` owns the VALUES and their rationale; this is purely a + * performance measure and must never diverge from it. These sit in the router's + * innermost loops -- `nearlyEqual` alone runs millions of times per redraw -- + * and V8 constant-folds a module-scope `const` where it will not fold a live + * imported binding. Measured on `benchmarks/edgeRouting.bench.ts`: importing + * them directly costs ~8% of the entire routing pass. + */ +const BOUNDARY_TOLERANCE = SHARED_BOUNDARY_TOLERANCE; +const POINT_TOLERANCE = SHARED_POINT_TOLERANCE; +const MIN_LEAD_DISTANCE = SHARED_MIN_LEAD_DISTANCE; +const MAX_LEAD_DISTANCE = SHARED_MAX_LEAD_DISTANCE; +const DETOUR_GUTTER = SHARED_DETOUR_GUTTER; +const NODE_OBSTACLE_MARGIN = SHARED_NODE_OBSTACLE_MARGIN; +const MAX_OBSTACLE_REROUTE_PASSES = SHARED_MAX_OBSTACLE_REROUTE_PASSES; + export interface Point { x: number; y: number; @@ -17,14 +47,6 @@ export interface EdgeAnchor { offset: number; } -const BOUNDARY_TOLERANCE = 4; -const POINT_TOLERANCE = 0.5; -const MIN_LEAD_DISTANCE = 18; -const MAX_LEAD_DISTANCE = 72; -const DETOUR_GUTTER = 28; -const NODE_OBSTACLE_MARGIN = 14; -const MAX_OBSTACLE_REROUTE_PASSES = 32; - function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -70,89 +92,103 @@ function isVerticalSide(side: EdgeAnchorSide): boolean { return side === "top" || side === "bottom"; } -function inferApproachSide( +/** + * An anchor on `side` whose offset is where `point` sits along that side. + * + * The offset is a pure projection -- no judgement about whether `point` really + * belongs to `side`; the caller has already decided that. + */ +export function anchorOnSide( nodeBox: NodeBox, - boundaryPoint: Point, - adjacentPoint: Point -): EdgeAnchorSide | null { - const dx = adjacentPoint.x - boundaryPoint.x; - const dy = adjacentPoint.y - boundaryPoint.y; - const xWithinNode = withinRange(boundaryPoint.x, nodeBox.x, nodeBox.x + nodeBox.width); - const yWithinNode = withinRange(boundaryPoint.y, nodeBox.y, nodeBox.y + nodeBox.height); - const verticalApproach = - Math.abs(dy) > POINT_TOLERANCE && - (Math.abs(dy) >= Math.abs(dx) || nearlyEqual(boundaryPoint.x, adjacentPoint.x, BOUNDARY_TOLERANCE)); - const horizontalApproach = - Math.abs(dx) > POINT_TOLERANCE && - (Math.abs(dx) > Math.abs(dy) || nearlyEqual(boundaryPoint.y, adjacentPoint.y, BOUNDARY_TOLERANCE)); - - if (verticalApproach && xWithinNode) { - if (dy < 0 && adjacentPoint.y <= nodeBox.y + BOUNDARY_TOLERANCE) { - return "top"; - } - if (dy > 0 && adjacentPoint.y >= nodeBox.y + nodeBox.height - BOUNDARY_TOLERANCE) { - return "bottom"; - } - } - - if (horizontalApproach && yWithinNode) { - if (dx < 0 && adjacentPoint.x <= nodeBox.x + BOUNDARY_TOLERANCE) { - return "left"; - } - if (dx > 0 && adjacentPoint.x >= nodeBox.x + nodeBox.width - BOUNDARY_TOLERANCE) { - return "right"; - } - } - - return null; + side: EdgeAnchorSide, + point: Point +): EdgeAnchor { + return { + side, + offset: isHorizontalSide(side) + ? clamp(point.y - nodeBox.y, 0, nodeBox.height) + : clamp(point.x - nodeBox.x, 0, nodeBox.width), + }; } -function inferAnchorSide( - nodeBox: NodeBox, - boundaryPoint: Point, - adjacentPoint: Point -): EdgeAnchorSide { - const approachSide = inferApproachSide(nodeBox, boundaryPoint, adjacentPoint); - if (approachSide) { - return approachSide; - } +/** The box side `point` lies on, or null when it lies on none of them. */ +function sideContainingPoint(box: NodeBox, point: Point): EdgeAnchorSide | null { + const onLeftRightSpan = withinRange(point.y, box.y, box.y + box.height); + const onTopBottomSpan = withinRange(point.x, box.x, box.x + box.width); - if (Math.abs(boundaryPoint.x - nodeBox.x) <= BOUNDARY_TOLERANCE) { + if (onLeftRightSpan && nearlyEqual(point.x, box.x, BOUNDARY_TOLERANCE)) { return "left"; } - if (Math.abs(boundaryPoint.x - (nodeBox.x + nodeBox.width)) <= BOUNDARY_TOLERANCE) { + if (onLeftRightSpan && nearlyEqual(point.x, box.x + box.width, BOUNDARY_TOLERANCE)) { return "right"; } - if (Math.abs(boundaryPoint.y - nodeBox.y) <= BOUNDARY_TOLERANCE) { + if (onTopBottomSpan && nearlyEqual(point.y, box.y, BOUNDARY_TOLERANCE)) { return "top"; } - if (Math.abs(boundaryPoint.y - (nodeBox.y + nodeBox.height)) <= BOUNDARY_TOLERANCE) { + if (onTopBottomSpan && nearlyEqual(point.y, box.y + box.height, BOUNDARY_TOLERANCE)) { return "bottom"; } - - const dx = adjacentPoint.x - boundaryPoint.x; - const dy = adjacentPoint.y - boundaryPoint.y; - - if (Math.abs(dx) >= Math.abs(dy)) { - return dx >= 0 ? "right" : "left"; - } - return dy >= 0 ? "bottom" : "top"; + return null; } -export function inferEdgeAnchor( +/** + * The anchor an edge endpoint attaches to, decided ONCE at layout time from + * pristine geometry: the node box, the endpoint the layout put on its boundary, + * and the point the polyline heads to next. + * + * This is an exact reading of the geometry, not a guess about it. An orthogonal + * edge can only leave a box through the side its FIRST SEGMENT points at, so + * that direction names the side outright (case 1) -- there is no need to + * reverse-engineer "which side did the router probably mean" from how close the + * next bend happens to be to a box edge. + * + * Cases 2 and 3 exist for geometry that is not orthogonal at all: the + * centre-to-centre polyline `extractLayout` synthesises when ELK hands back an + * edge with no routed sections. + * + * Once decided, the anchor is the DURABLE contract carried on `LayoutEdge` and + * `EdgeDatum`; draw-time stages consume it and never re-derive it from geometry. + */ +export function edgeAnchorAtBoundary( nodeBox: NodeBox, boundaryPoint: Point, adjacentPoint: Point ): EdgeAnchor { - const side = inferAnchorSide(nodeBox, boundaryPoint, adjacentPoint); + const dx = adjacentPoint.x - boundaryPoint.x; + const dy = adjacentPoint.y - boundaryPoint.y; + const departsVertically = nearlyEqual(dx, 0) && !nearlyEqual(dy, 0); + const departsHorizontally = nearlyEqual(dy, 0) && !nearlyEqual(dx, 0); + + // 1. Orthogonal departure: the side the segment points through, provided that + // side spans the anchor point at all. + if ( + departsVertically && + withinRange(boundaryPoint.x, nodeBox.x, nodeBox.x + nodeBox.width) + ) { + return anchorOnSide(nodeBox, dy < 0 ? "top" : "bottom", boundaryPoint); + } + if ( + departsHorizontally && + withinRange(boundaryPoint.y, nodeBox.y, nodeBox.y + nodeBox.height) + ) { + return anchorOnSide(nodeBox, dx < 0 ? "left" : "right", boundaryPoint); + } - return { - side, - offset: - side === "left" || side === "right" - ? clamp(boundaryPoint.y - nodeBox.y, 0, nodeBox.height) - : clamp(boundaryPoint.x - nodeBox.x, 0, nodeBox.width), - }; + // 2. Diagonal departure from a point that IS on the boundary: keep the side + // it sits on. Such a route fails `polylineRespectsAnchorDirections` and is + // rerouted downstream, which is the honest outcome -- relocating the + // endpoint to some other side would be the guess this function avoids. + const sideUnderPoint = sideContainingPoint(nodeBox, boundaryPoint); + if (sideUnderPoint) { + return anchorOnSide(nodeBox, sideUnderPoint, boundaryPoint); + } + + // 3. Not on the boundary at all: the dominant axis of the direction the + // polyline heads in. + if (Math.abs(dx) >= Math.abs(dy)) { + return anchorOnSide(nodeBox, dx >= 0 ? "right" : "left", boundaryPoint); + } + return anchorOnSide(nodeBox, dy >= 0 ? "bottom" : "top", boundaryPoint); } export function inferEdgeAnchorFromPoint( @@ -382,7 +418,16 @@ function eraseOrthogonalLoops(points: Point[]): Point[] { return simplifyOrthogonalPolyline(result); } -function normalizeRoutedPolyline(points: Point[]): Point[] { +/** + * Put a polyline back into the canonical routed form: axis-aligned segments, + * no duplicate or collinear vertices, no self-crossing loops. + * + * Exported because every stage that edits a polyline by hand (endpoint + * anchoring, lane spreading) has to re-normalise afterwards, and calling it by + * name says so -- routing against an empty obstacle list used to be the idiom, + * which read as routing work that was not happening. + */ +export function normalizeRoutedPolyline(points: Point[]): Point[] { return eraseOrthogonalLoops(orthogonalizePolyline(points)); } @@ -637,7 +682,7 @@ export function routePolylineAroundObstacles( * partner both endpoints coincide. Fall back to the original endpoints so * callers can always draw a segment (a zero-length one renders as nothing). */ -function ensureDrawablePolyline(routed: Point[], original: Point[]): Point[] { +export function ensureDrawablePolyline(routed: Point[], original: Point[]): Point[] { if (routed.length >= 2 || original.length < 2) { return routed; } @@ -725,15 +770,38 @@ function alignEndWithAnchor(points: Point[], endPoint: Point, side: EdgeAnchorSi return result; } +/** + * The outcome of anchoring a polyline, with the escalation made visible. + * + * `rerouted` is the load-bearing bit: it says the incoming route was DISCARDED + * and replaced by a freshly synthesised orthogonal one, rather than nudged onto + * its anchors. That used to happen silently inside `anchorEdgePolyline`, so a + * caller had no way to tell whether the geometry it got back still resembled + * what the layout produced. + */ +export interface AnchoredPolyline { + points: Point[]; + rerouted: boolean; +} + +/** + * Snap a polyline's endpoints onto the given anchors. + * + * The happy path aligns the first and last segments onto the anchor points and + * re-normalises. When the result would leave or enter a node from a direction + * its anchor forbids -- a stale bend, a node dragged past its own edge -- the + * route cannot be salvaged by nudging and is replaced wholesale by + * `rerouteOrthogonalEdge`. The return value reports which of the two happened. + */ export function anchorEdgePolyline( points: Point[], sourceBox: NodeBox, targetBox: NodeBox, sourceAnchor: EdgeAnchor, targetAnchor: EdgeAnchor -): Point[] { +): AnchoredPolyline { if (points.length < 2) { - return points.slice(); + return { points: points.slice(), rerouted: false }; } const withStart = alignStartWithAnchor( @@ -750,16 +818,19 @@ export function anchorEdgePolyline( const anchored = normalizeRoutedPolyline(withEnd); if (polylineRespectsAnchorDirections(anchored, sourceAnchor, targetAnchor)) { - return anchored; + return { points: anchored, rerouted: false }; } - return rerouteOrthogonalEdge( - points, - sourceBox, - targetBox, - sourceAnchor, - targetAnchor - ); + return { + points: rerouteOrthogonalEdge( + points, + sourceBox, + targetBox, + sourceAnchor, + targetAnchor + ), + rerouted: true, + }; } function getSideNormal(side: EdgeAnchorSide): Point { diff --git a/packages/app/src/canvas/layout/edgeRoutePipeline.ts b/packages/app/src/canvas/layout/edgeRoutePipeline.ts new file mode 100644 index 0000000..fb2d48c --- /dev/null +++ b/packages/app/src/canvas/layout/edgeRoutePipeline.ts @@ -0,0 +1,548 @@ +/** + * The draw-time edge route pipeline: the single owner of an edge's geometry + * between "the layout produced this polyline" and "the renderer strokes this + * polyline". + * + * Three stages run in a fixed, visible order (see `routeEdges`): + * + * anchorEndpoints -> spreadEndpointLanes -> detourAroundObstacles + * + * Each stage is a pure `(edges, ctx) -> edges` function that returns NEW edge + * records; nothing is mutated across a stage boundary, so a stage's output is + * exactly what the next stage saw as input. + * + * ## The anchor contract + * + * Every edge carries the anchors decided once at layout time + * (`edgeAnchorAtBoundary`). Stages CONSUME those anchors and derive endpoints + * from `(box, anchor)` via `getAnchorPoint`; no stage re-reads a polyline to + * work out which side of a node it came from. A stage that deliberately moves + * an endpoint (lane spreading) emits an UPDATED anchor with it, so anchors and + * geometry never disagree. + * + * The one legitimate place a fresh anchor is DECIDED at draw time is a node + * dragged away from its laid-out position: the anchor the layout chose is about + * geometry that no longer exists, so `anchorEndpoints` picks a new one facing + * the opposite endpoint. That is a new decision, not a re-derivation. + * + * ## Seam contract + * + * Every edge this module emits has at least 2 points, with the first on the + * source box and the last on the target box (see canvas-rendering.md). + * + * Runtime imports use explicit `.ts` specifiers so the module chain loads under + * `node --test`. + */ + +import { + anchorEdgePolyline, + anchorOnSide, + ensureDrawablePolyline, + getAnchorPoint, + inferEdgeAnchorFromPoint, + isHorizontalSide, + normalizeRoutedPolyline, + rerouteOrthogonalEdge, + routePolylineAroundObstacles, + translatePolyline, + type EdgeAnchor, + type EdgeAnchorSide, + type NodeBox, + type Point, +} from "./edgeGeometry.ts"; +import { + resolveEdgeRoutingMode, + routesAroundObstacles, + scoresEdgeCrossings, + type EdgeRoutingMode, +} from "./edgeRoutingBudget.ts"; +import type { ObstacleIndex } from "./obstacleIndex.ts"; +import { + NODE_MOVED_EPSILON, + OBSTACLE_QUERY_MARGIN, +} from "./routingConstants.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * One edge entering the pipeline: its identity, the route and anchors the + * layout phase decided, and where its endpoint nodes are RIGHT NOW. + */ +export interface EdgeRouteInput { + /** Index into the renderer's edge data; carried through so results map back. */ + index: number; + sourceId: string; + targetId: string; + /** The polyline the layout phase produced, in layout coordinates. */ + layoutPoints: Point[]; + /** Anchors decided at layout time, against the LAID-OUT boxes. */ + sourceAnchor: EdgeAnchor; + targetAnchor: EdgeAnchor; + /** Current on-screen boxes (post-drag). */ + sourceBox: NodeBox; + targetBox: NodeBox; + /** How far each box has moved from where it was laid out. */ + sourceDelta: Point; + targetDelta: Point; +} + +/** + * How an edge's endpoints came to be where they are. Purely descriptive, but it + * makes the escalation inside `anchorEdgePolyline` observable instead of silent. + */ +export type EdgeRouteOrigin = + /** The layout's own route, nudged onto its stored anchors. */ + | "anchored" + /** The layout's route was unsalvageable; a fresh orthogonal one replaced it. */ + | "rerouted" + /** Endpoints moved apart, so both anchors were decided anew and re-routed. */ + | "reanchored"; + +/** An edge after a stage: geometry and anchors always agree. */ +export interface RoutedEdge { + index: number; + sourceId: string; + targetId: string; + points: Point[]; + sourceAnchor: EdgeAnchor; + targetAnchor: EdgeAnchor; + sourceBox: NodeBox; + targetBox: NodeBox; + origin: EdgeRouteOrigin; +} + +/** What a redraw offers the pipeline, before the budget gate has spoken. */ +export interface EdgeRouteEnv { + /** Nodes contributing obstacle boxes this redraw; feeds the budget gate. */ + visibleNodeCount: number; + /** False when the current LOD renders edges fully transparent. */ + edgesVisible: boolean; + /** + * The redraw's obstacle boxes, produced ON DEMAND. + * + * A thunk because the budget gate can decide there will be no obstacle + * search at all, and indexing every visible node's boxes for a search that + * never runs is exactly the waste the gate exists to prevent. Returns null + * when the caller has no obstacles to offer. + */ + obstacles: () => ObstacleIndex | null; +} + +/** `EdgeRouteEnv` plus the routing mode the budget gate picked. */ +export interface EdgeRouteContext extends EdgeRouteEnv { + mode: EdgeRoutingMode; +} + +/** The pipeline's output, with the gate's verdict attached for the caller. */ +export interface EdgeRouteResult { + edges: RoutedEdge[]; + mode: EdgeRoutingMode; +} + +/** Shared empty reference list, so the no-scoring path allocates nothing. */ +const NO_REFERENCE_POLYLINES: Point[][] = []; + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +/** + * Run the whole pipeline. The stage order is the body of this function and + * nowhere else. + */ +export function routeEdges( + inputs: readonly EdgeRouteInput[], + env: EdgeRouteEnv +): EdgeRouteResult { + const anchored = anchorEndpoints(inputs, env); + + // The budget gate reads the number of edges that will actually be stroked, + // which is only known once stage 1 has dropped the undrawable ones. + const ctx: EdgeRouteContext = { + ...env, + mode: resolveEdgeRoutingMode({ + renderedEdges: anchored.length, + visibleNodes: env.visibleNodeCount, + edgesVisible: env.edgesVisible, + }), + }; + + const spread = spreadEndpointLanes(anchored, ctx); + const detoured = detourAroundObstacles(spread, ctx); + + return { edges: detoured, mode: ctx.mode }; +} + +// --------------------------------------------------------------------------- +// Stage 1: anchor endpoints +// --------------------------------------------------------------------------- + +/** + * Put every edge's endpoints on its anchors, against the boxes' CURRENT + * positions. + * + * Three cases, in the order they are cheap: + * + * 1. Neither box moved -- the stored anchors still describe the stored route, + * so the route only has to be snapped onto them. + * 2. Both boxes moved by the same delta (a dragged container carrying both + * endpoints) -- the route translates with them and the anchors, being box + * relative, are untouched. + * 3. The boxes moved apart -- the stored anchors describe a geometry that no + * longer exists, so fresh ones are DECIDED facing the opposite box and the + * edge is re-routed from scratch. + * + * Edges that cannot produce a drawable polyline are dropped here; the budget + * gate counts what survives. + */ +export function anchorEndpoints( + inputs: readonly EdgeRouteInput[], + _env: EdgeRouteEnv +): RoutedEdge[] { + const routed: RoutedEdge[] = []; + + for (const input of inputs) { + const anchored = anchorEdgeRoute(input); + if (anchored.points.length >= 2) { + routed.push(anchored); + } + } + + return routed; +} + +/** Stage 1 for a single edge. See `anchorEndpoints` for the three cases. */ +export function anchorEdgeRoute(input: EdgeRouteInput): RoutedEdge { + const sourceMoved = boxMoved(input.sourceDelta); + const targetMoved = boxMoved(input.targetDelta); + + if (!sourceMoved && !targetMoved) { + return anchoredResult(input, input.layoutPoints, input.sourceAnchor, input.targetAnchor); + } + + if (movedTogether(input.sourceDelta, input.targetDelta)) { + const translated = translatePolyline( + input.layoutPoints, + (input.sourceDelta.x + input.targetDelta.x) / 2, + (input.sourceDelta.y + input.targetDelta.y) / 2 + ); + // Anchors are offsets INTO a box, so a box that only moved keeps them. + return anchoredResult(input, translated, input.sourceAnchor, input.targetAnchor); + } + + const sourceAnchor = inferEdgeAnchorFromPoint(input.sourceBox, boxCenter(input.targetBox)); + const targetAnchor = inferEdgeAnchorFromPoint(input.targetBox, boxCenter(input.sourceBox)); + + return { + index: input.index, + sourceId: input.sourceId, + targetId: input.targetId, + points: rerouteOrthogonalEdge( + input.layoutPoints, + input.sourceBox, + input.targetBox, + sourceAnchor, + targetAnchor + ), + sourceAnchor, + targetAnchor, + sourceBox: input.sourceBox, + targetBox: input.targetBox, + origin: "reanchored", + }; +} + +function anchoredResult( + input: EdgeRouteInput, + points: Point[], + sourceAnchor: EdgeAnchor, + targetAnchor: EdgeAnchor +): RoutedEdge { + const anchored = anchorEdgePolyline( + points, + input.sourceBox, + input.targetBox, + sourceAnchor, + targetAnchor + ); + + return { + index: input.index, + sourceId: input.sourceId, + targetId: input.targetId, + points: anchored.points, + sourceAnchor, + targetAnchor, + sourceBox: input.sourceBox, + targetBox: input.targetBox, + origin: anchored.rerouted ? "rerouted" : "anchored", + }; +} + +function boxMoved(delta: Point): boolean { + return Math.abs(delta.x) > NODE_MOVED_EPSILON || Math.abs(delta.y) > NODE_MOVED_EPSILON; +} + +function movedTogether(a: Point, b: Point): boolean { + return ( + Math.abs(a.x - b.x) <= NODE_MOVED_EPSILON && Math.abs(a.y - b.y) <= NODE_MOVED_EPSILON + ); +} + +// --------------------------------------------------------------------------- +// Stage 2: spread endpoint lanes +// --------------------------------------------------------------------------- + +/** + * Fan the edges sharing one side of one node out across that side, so a hub + * node does not collect a dozen edges on the same point. + * + * Grouping reads the STORED anchor side -- the whole point of the anchor + * contract. Each moved endpoint gets a new offset on the same side, and the + * edge's anchor is updated to that offset, so the next stage still sees anchors + * that describe the geometry. + * + * Always runs: it is linear in the edge count and independent of the routing + * budget, which only governs the obstacle search. + */ +export function spreadEndpointLanes( + edges: readonly RoutedEdge[], + _ctx: EdgeRouteContext +): RoutedEdge[] { + // Target ends first, then source ends: a two-point polyline's lane move also + // nudges the far end's cross-coordinate, and doing the source last leaves the + // source anchor authoritative -- the order the renderer has always used. + return spreadOneEnd(spreadOneEnd(edges, true), false); +} + +function spreadOneEnd(edges: readonly RoutedEdge[], atEnd: boolean): RoutedEdge[] { + const groups = new Map(); + + for (const edge of edges) { + const side = atEnd ? edge.targetAnchor.side : edge.sourceAnchor.side; + const nodeId = atEnd ? edge.targetId : edge.sourceId; + const key = `${nodeId}:${side}`; + const group = groups.get(key); + + if (group) { + group.push(edge); + } else { + groups.set(key, [edge]); + } + } + + const moved = new Map(); + + for (const group of groups.values()) { + if (group.length <= 1) { + continue; + } + + const side = atEnd ? group[0].targetAnchor.side : group[0].sourceAnchor.side; + + // Ordered by where the OPPOSITE endpoint sits, so lanes do not cross each + // other on their way out of the node. + group.sort((a, b) => { + const aBox = atEnd ? a.sourceBox : a.targetBox; + const bBox = atEnd ? b.sourceBox : b.targetBox; + return orderValueForSide(aBox, side) - orderValueForSide(bBox, side); + }); + + for (let i = 0; i < group.length; i++) { + const edge = group[i]; + const box = atEnd ? edge.targetBox : edge.sourceBox; + const anchor: EdgeAnchor = { + side, + offset: laneOffset(box, side, i, group.length), + }; + moved.set(edge.index, moveEndpointToLane(edge, anchor, atEnd)); + } + } + + if (moved.size === 0) { + return edges.slice(); + } + + return edges.map((edge) => moved.get(edge.index) ?? edge); +} + +/** + * Move one endpoint onto `anchor` and drag its neighbouring bend across with + * it, then re-normalise. + * + * On a two-point polyline the "neighbouring bend" IS the far endpoint, whose + * cross-coordinate therefore shifts too; `withOffsetsFromGeometry` re-reads + * both offsets afterwards so the far anchor stays truthful. + */ +function moveEndpointToLane( + edge: RoutedEdge, + anchor: EdgeAnchor, + atEnd: boolean +): RoutedEdge { + if (edge.points.length < 2) { + return edge; + } + + const box = atEnd ? edge.targetBox : edge.sourceBox; + const nextPoints = edge.points.map((point) => ({ ...point })); + const endpoint = getAnchorPoint(box, anchor); + const endIndex = atEnd ? nextPoints.length - 1 : 0; + const bendIndex = atEnd ? nextPoints.length - 2 : 1; + + nextPoints[endIndex] = endpoint; + nextPoints[bendIndex] = isHorizontalSide(anchor.side) + ? { ...nextPoints[bendIndex], y: endpoint.y } + : { ...nextPoints[bendIndex], x: endpoint.x }; + + const points = ensureDrawablePolyline(normalizeRoutedPolyline(nextPoints), nextPoints); + + return withOffsetsFromGeometry({ + ...edge, + points, + sourceAnchor: atEnd ? edge.sourceAnchor : anchor, + targetAnchor: atEnd ? anchor : edge.targetAnchor, + }); +} + +/** + * Re-read both anchor offsets off the polyline's endpoints, keeping the sides. + * + * Only ever called right after this stage moved a point along a side it already + * owned, so this is a projection of a known-good side, not an inference of + * which side an edge attached to. + */ +function withOffsetsFromGeometry(edge: RoutedEdge): RoutedEdge { + const first = edge.points[0]; + const last = edge.points[edge.points.length - 1]; + + return { + ...edge, + sourceAnchor: anchorOnSide(edge.sourceBox, edge.sourceAnchor.side, first), + targetAnchor: anchorOnSide(edge.targetBox, edge.targetAnchor.side, last), + }; +} + +function sideLength(box: NodeBox, side: EdgeAnchorSide): number { + return isHorizontalSide(side) ? box.height : box.width; +} + +/** Where lane `index` of `count` sits along `side`, inside a small padding. */ +export function laneOffset( + box: NodeBox, + side: EdgeAnchorSide, + index: number, + count: number +): number { + const length = sideLength(box, side); + if (count <= 1) { + return length / 2; + } + + const padding = Math.min(12, Math.max(4, length / 4)); + const usable = Math.max(1, length - padding * 2); + return padding + (usable * (index + 1)) / (count + 1); +} + +function orderValueForSide(box: NodeBox, side: EdgeAnchorSide): number { + const center = boxCenter(box); + return isHorizontalSide(side) ? center.y : center.x; +} + +// --------------------------------------------------------------------------- +// Stage 3: detour around obstacles +// --------------------------------------------------------------------------- + +/** + * Push each route clear of the node/label boxes it runs through. + * + * The only budget-gated stage: `mode === "none"` returns the routes untouched, + * and only `"full"` scores candidate detours against the edges routed so far + * (that scoring is O(E^2) -- see edgeRoutingBudget.ts). + */ +export function detourAroundObstacles( + edges: readonly RoutedEdge[], + ctx: EdgeRouteContext +): RoutedEdge[] { + if (!routesAroundObstacles(ctx.mode)) { + return edges.slice(); + } + + const index = ctx.obstacles(); + if (!index) { + return edges.slice(); + } + + const scoreCrossings = scoresEdgeCrossings(ctx.mode); + const routedPolylines: Point[][] = []; + const detoured: RoutedEdge[] = []; + + for (const edge of edges) { + const points = routePolylineAroundObstacles( + edge.points, + obstaclesForEdge(index, edge), + scoreCrossings ? routedPolylines : NO_REFERENCE_POLYLINES + ); + if (scoreCrossings) { + routedPolylines.push(points); + } + // Detours only bend the middle of a route; the endpoints, and therefore the + // anchors, are exactly the ones the previous stage settled on. + detoured.push({ ...edge, points }); + } + + return detoured; +} + +/** + * Obstacles one edge actually has to care about: the boxes near its polyline, + * minus its own endpoints' boxes and minus any box that swallows an endpoint + * centre (a collapsed container holding one of the endpoints -- routing around + * it is impossible, and trying produces long useless detours). + */ +export function obstaclesForEdge(index: ObstacleIndex, edge: RoutedEdge): NodeBox[] { + const candidates = index.queryForPolyline( + edge.points, + OBSTACLE_QUERY_MARGIN, + edge.sourceId, + edge.targetId + ); + + if (candidates.length === 0) { + return candidates; + } + + const sourceCenter = boxCenter(edge.sourceBox); + const targetCenter = boxCenter(edge.targetBox); + const obstacles: NodeBox[] = []; + + for (const box of candidates) { + if (boxContainsPoint(box, sourceCenter) || boxContainsPoint(box, targetCenter)) { + continue; + } + obstacles.push(box); + } + + return obstacles; +} + +// --------------------------------------------------------------------------- +// Small shared geometry helpers +// --------------------------------------------------------------------------- + +export function boxCenter(box: NodeBox): Point { + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; +} + +function boxContainsPoint(box: NodeBox, point: Point): boolean { + return ( + point.x >= box.x && + point.x <= box.x + box.width && + point.y >= box.y && + point.y <= box.y + box.height + ); +} diff --git a/packages/app/src/canvas/renderers/edgeRoutingBudget.ts b/packages/app/src/canvas/layout/edgeRoutingBudget.ts similarity index 100% rename from packages/app/src/canvas/renderers/edgeRoutingBudget.ts rename to packages/app/src/canvas/layout/edgeRoutingBudget.ts diff --git a/packages/app/src/canvas/layout/elkExtract.ts b/packages/app/src/canvas/layout/elkExtract.ts new file mode 100644 index 0000000..e5824b5 --- /dev/null +++ b/packages/app/src/canvas/layout/elkExtract.ts @@ -0,0 +1,244 @@ +/** + * The pure half of the ELK positions phase: turning an `ElkNode` tree that came + * back from the worker into node positions and routed `LayoutEdge`s. + * + * Split out of `elkLayout.ts` so it can be exercised without the elkjs worker: + * everything here is a plain function over plain data. `elkLayout` keeps the + * side-effecting parts -- running ELK, the DEV debug log, the straight-line and + * grid fallbacks. + * + * ELK's own types are imported TYPE-ONLY, and runtime imports use explicit `.ts` + * specifiers, so this module loads under `node --test`. + */ + +import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"; +import { + anchorEdgePolyline, + dedupePolylinePoints, + edgeAnchorAtBoundary, + type Point, +} from "./edgeGeometry.ts"; +import { elkEdgeIndex } from "./elkEdgeId.ts"; +import type { LayoutEdge, LayoutNodePosition } from "./layoutTypes"; +import type { ViewEdge } from "./viewEdges"; + +/** Counts the DEV debug log reports about one extraction. */ +export interface ExtractStats { + /** ELK edges seen anywhere in the tree. */ + totalEdgesFound: number; + /** ...of which ELK actually routed (they carry `sections`). */ + edgesWithSections: number; + /** ...of which fell back to a centre-to-centre connector. */ + edgesWithoutSections: number; +} + +export interface RoutedEdgeExtraction { + edges: LayoutEdge[]; + stats: ExtractStats; +} + +/** Absolute position of every node in the tree, root excluded. */ +export function extractNodePositions( + root: ElkNode +): Record { + const positions: Record = {}; + + function walk(node: ElkNode, offsetX: number, offsetY: number): void { + const x = offsetX + (node.x || 0); + const y = offsetY + (node.y || 0); + + if (node.id !== "root") { + positions[node.id] = { + x, + y, + width: node.width || 100, + height: node.height || 40, + }; + } + + if (node.children) { + for (const child of node.children) { + walk(child, x, y); + } + } + } + + walk(root, 0, 0); + return positions; +} + +/** + * One ELK edge's polyline in absolute coordinates. + * + * Section coordinates are relative to the node the edge is STORED on, so the + * caller passes that node's absolute offset. An edge ELK declined to route has + * no sections; it falls back to a centre-to-centre connector, and yields an + * empty polyline when either endpoint is missing a position. + */ +export function elkEdgePolyline( + edge: ElkExtendedEdge, + offsetX: number, + offsetY: number, + positions: Record +): Point[] { + const points: Point[] = []; + + if (edge.sections) { + for (const section of edge.sections) { + points.push({ + x: offsetX + section.startPoint.x, + y: offsetY + section.startPoint.y, + }); + if (section.bendPoints) { + for (const bend of section.bendPoints) { + points.push({ x: offsetX + bend.x, y: offsetY + bend.y }); + } + } + points.push({ + x: offsetX + section.endPoint.x, + y: offsetY + section.endPoint.y, + }); + } + return points; + } + + const sourcePos = positions[edge.sources[0]]; + const targetPos = positions[edge.targets[0]]; + if (sourcePos && targetPos) { + points.push({ + x: sourcePos.x + sourcePos.width / 2, + y: sourcePos.y + sourcePos.height / 2, + }); + points.push({ + x: targetPos.x + targetPos.width / 2, + y: targetPos.y + targetPos.height / 2, + }); + } + + return points; +} + +/** + * Turn one ELK edge into a `LayoutEdge`, or null when it cannot become a + * drawable, anchored polyline. + * + * This is where an edge's anchors are DECIDED, exactly once, from the pristine + * route: `edgeAnchorAtBoundary` reads the side off the first/last segment's + * direction, and `anchorEdgePolyline` then makes the geometry agree with them. + * Everything downstream -- the edges phase, the draw-time route pipeline -- + * consumes the anchors recorded here instead of re-deriving them. + */ +export function routedEdgeFromElk( + edge: ElkExtendedEdge, + offsetX: number, + offsetY: number, + positions: Record, + viewEdges: readonly ViewEdge[] +): LayoutEdge | null { + const sourceId = edge.sources[0]; + const targetId = edge.targets[0]; + + const points = elkEdgePolyline(edge, offsetX, offsetY, positions); + if (points.length < 2) { + return null; + } + + const sourcePos = positions[sourceId]; + const targetPos = positions[targetId]; + if (!sourcePos || !targetPos) { + return null; + } + + const normalizedPoints = dedupePolylinePoints(points); + if (normalizedPoints.length < 2) { + return null; + } + + // Resolve by the index encoded in the edge id, not by endpoint pair: + // parallel edges between the same pair (e.g. per-kind aggregates) must each + // keep their own kind/color/count. + const index = elkEdgeIndex(edge.id); + const info = index !== null ? viewEdges[index] : undefined; + + const sourceAnchor = edgeAnchorAtBoundary( + sourcePos, + normalizedPoints[0], + normalizedPoints[1] + ); + const targetAnchor = edgeAnchorAtBoundary( + targetPos, + normalizedPoints[normalizedPoints.length - 1], + normalizedPoints[normalizedPoints.length - 2] + ); + + return { + source: sourceId, + target: targetId, + color: info?.color ?? "#64748b", + kind: info?.kind ?? null, + count: info?.count ?? 1, + resolution: info?.resolution ?? null, + points: anchorEdgePolyline( + normalizedPoints, + sourcePos, + targetPos, + sourceAnchor, + targetAnchor + ).points, + sourceAnchor, + targetAnchor, + }; +} + +/** + * Every routed edge in the tree, in ELK's own traversal order. + * + * Walks the tree a second time (after `extractNodePositions`) because an edge's + * section coordinates are relative to the node it hangs off, so the walk has to + * carry that node's absolute offset. + */ +export function extractRoutedEdges( + root: ElkNode, + positions: Record, + viewEdges: readonly ViewEdge[] +): RoutedEdgeExtraction { + const edges: LayoutEdge[] = []; + const stats: ExtractStats = { + totalEdgesFound: 0, + edgesWithSections: 0, + edgesWithoutSections: 0, + }; + + function walk(node: ElkNode, offsetX: number, offsetY: number): void { + const x = offsetX + (node.x || 0); + const y = offsetY + (node.y || 0); + + if (node.children) { + for (const child of node.children) { + walk(child, x, y); + } + } + + if (!node.edges) { + return; + } + + stats.totalEdgesFound += node.edges.length; + + for (const edge of node.edges) { + if (edge.sections) { + stats.edgesWithSections++; + } else { + stats.edgesWithoutSections++; + } + + const routed = routedEdgeFromElk(edge, x, y, positions, viewEdges); + if (routed) { + edges.push(routed); + } + } + } + + walk(root, 0, 0); + return { edges, stats }; +} diff --git a/packages/app/src/canvas/layout/elkLayout.ts b/packages/app/src/canvas/layout/elkLayout.ts index fb90fcf..ca377f8 100644 --- a/packages/app/src/canvas/layout/elkLayout.ts +++ b/packages/app/src/canvas/layout/elkLayout.ts @@ -6,26 +6,17 @@ import { unknownEdgeKindCounts, type EdgeKindCounts, } from "../legend/edgeLegendModel"; -import { elkEdgeId, elkEdgeIndex } from "./elkEdgeId"; -import { - anchorEdgePolyline, - dedupePolylinePoints, - inferEdgeAnchor, - type Point, -} from "./edgeGeometry"; +import { elkEdgeId } from "./elkEdgeId"; +import { extractNodePositions, extractRoutedEdges } from "./elkExtract"; import { getNodeSize } from "../utils/graphUtils"; import { fetchViewEdges, type ViewEdge } from "./viewEdges"; import { straightLineEdges } from "./straightEdges"; -import type { - LayoutEdge, - LayoutNodePosition, - LayoutResult, -} from "./layoutTypes"; +import type { LayoutNodePosition, LayoutResult } from "./layoutTypes"; import { LAYOUT_EDGE_ROUTING_EDGE_LIMIT, LAYOUT_EDGE_ROUTING_NODE_LIMIT, shouldSkipLayoutEdgeRouting, -} from "../renderers/edgeRoutingBudget"; +} from "./edgeRoutingBudget"; /** * The POSITIONS phase of the layout pipeline: build the ELK containment tree for @@ -41,6 +32,16 @@ import { const elk = new ELK({ workerFactory: () => new ElkWorker() }); +/** + * One DEV-only debug line. Funnels what used to be a dozen copies of + * `if (import.meta.env.DEV) useDebugStore.getState().addLog(...)`. + */ +function debugLog(message: string): void { + if (import.meta.env.DEV) { + useDebugStore.getState().addLog(message); + } +} + export type { LayoutEdge, LayoutNodePosition, LayoutResult } from "./layoutTypes"; function buildElkNode( @@ -150,8 +151,8 @@ export async function layoutGraph( // by EDGES as much as nodes, so both counts gate it -- a 1400-node view // carrying 20k edges is just as unroutable as a 5000-node one. const skipEdgeRouting = shouldSkipLayoutEdgeRouting(elkNodeIds.size, viewEdges.length); - if (skipEdgeRouting && import.meta.env.DEV) { - useDebugStore.getState().addLog( + if (skipEdgeRouting) { + debugLog( `Large view (${elkNodeIds.size} nodes / ${viewEdges.length} edges exceeds ` + `${LAYOUT_EDGE_ROUTING_NODE_LIMIT} nodes or ${LAYOUT_EDGE_ROUTING_EDGE_LIMIT} edges): ` + `skipping edge routing. ` + @@ -159,11 +160,9 @@ export async function layoutGraph( ); } - if (import.meta.env.DEV) { - useDebugStore.getState().addLog( - `View edges: total=${viewEdges.length}, elkNodes=${elkNodeIds.size}, routing=${!skipEdgeRouting}` - ); - } + debugLog( + `View edges: total=${viewEdges.length}, elkNodes=${elkNodeIds.size}, routing=${!skipEdgeRouting}` + ); // Build ELK edge inputs (omitted entirely when skipping routing). The id // encodes the viewEdges index so extraction can map each routed edge back to @@ -194,18 +193,15 @@ export async function layoutGraph( }; try { - if (import.meta.env.DEV) { - useDebugStore.getState().addLog(`ELK layout starting...`); - } + debugLog(`ELK layout starting...`); const laidOut = await elk.layout(elkGraph); - if (import.meta.env.DEV) { - useDebugStore.getState().addLog(`ELK layout done, extracting...`); - } + debugLog(`ELK layout done, extracting...`); const result = extractLayout(laidOut, viewEdges, edgeKindCounts, renderIds); if (import.meta.env.DEV) { - useDebugStore.getState().addLog(`ELK extracted ${result.edges.length} edges`); + debugLog(`ELK extracted ${result.edges.length} edges`); - // Update debug store + // Update debug store (kept inline: these scans are too expensive to run + // outside a DEV guard). const codeBlocksInGraph = Object.values(graph.nodes).filter(n => n.type === "CodeBlock").length; const filesWithChildren = Object.values(graph.nodes).filter(n => n.type === "File" && n.children.length > 0).length; const expandedFiles = Array.from(expandedNodes).filter(id => graph.nodes[id]?.type === "File").length; @@ -229,9 +225,7 @@ export async function layoutGraph( return result; } catch (err) { console.error("ELK layout failed:", err); - if (import.meta.env.DEV) { - useDebugStore.getState().addLog(`ELK FAILED: ${err}`); - } + debugLog(`ELK FAILED: ${err}`); return fallbackLayout(graph, visibleNodes, edgeKindCounts, renderIds); } } @@ -245,159 +239,41 @@ function emptyLayout(): LayoutResult { }; } +/** + * ELK's answer, turned into a `LayoutResult`. + * + * Composition only: positions, then routed edges, then the straight-line + * fallback for when ELK routed nothing. Each step is a pure function in + * `elkExtract.ts`; what stays here is the DEV logging and the fallback policy. + */ function extractLayout( elkNode: ElkNode, viewEdges: ViewEdge[], edgeKindCounts: EdgeKindCounts, renderIds: string[] ): LayoutResult { - const result: LayoutResult = { nodes: {}, edges: [], edgeKindCounts, renderIds }; + const nodes = extractNodePositions(elkNode); + const { edges, stats } = extractRoutedEdges(elkNode, nodes, viewEdges); - let edgesWithSections = 0; - let edgesWithoutSections = 0; - let totalEdgesFound = 0; - - function processNode(node: ElkNode, offsetX: number, offsetY: number) { - if (node.id !== "root") { - result.nodes[node.id] = { - x: offsetX + (node.x || 0), - y: offsetY + (node.y || 0), - width: node.width || 100, - height: node.height || 40, - }; - } - - const nx = offsetX + (node.x || 0); - const ny = offsetY + (node.y || 0); - - if (node.children) { - for (const child of node.children) { - processNode(child, nx, ny); - } - } - - if (node.edges) { - if (import.meta.env.DEV) { totalEdgesFound += node.edges.length; } - for (const edge of node.edges) { - const sourceId = edge.sources[0]; - const targetId = edge.targets[0]; - - // Resolve by the index encoded in the edge id, not by endpoint pair: - // parallel edges between the same pair (e.g. per-kind aggregates) must - // each keep their own kind/color/count. - const index = elkEdgeIndex(edge.id); - const info = index !== null ? viewEdges[index] : undefined; - const color = info?.color ?? "#64748b"; - const kind = info?.kind ?? null; - const count = info?.count ?? 1; - const resolution = info?.resolution ?? null; - - const points: Point[] = []; - if (edge.sections) { - if (import.meta.env.DEV) { edgesWithSections++; } - for (const section of edge.sections) { - points.push({ - x: nx + section.startPoint.x, - y: ny + section.startPoint.y, - }); - if (section.bendPoints) { - for (const bp of section.bendPoints) { - points.push({ x: nx + bp.x, y: ny + bp.y }); - } - } - points.push({ - x: nx + section.endPoint.x, - y: ny + section.endPoint.y, - }); - } - } else { - if (import.meta.env.DEV) { edgesWithoutSections++; } - // Fallback: draw straight line between node centers - const sourcePos = result.nodes[sourceId]; - const targetPos = result.nodes[targetId]; - if (sourcePos && targetPos) { - points.push({ - x: sourcePos.x + sourcePos.width / 2, - y: sourcePos.y + sourcePos.height / 2, - }); - points.push({ - x: targetPos.x + targetPos.width / 2, - y: targetPos.y + targetPos.height / 2, - }); - } - } - - if (points.length >= 2) { - const sourcePos = result.nodes[sourceId]; - const targetPos = result.nodes[targetId]; - if (!sourcePos || !targetPos) { - continue; - } - - const normalizedPoints = dedupePolylinePoints(points); - if (normalizedPoints.length < 2) { - continue; - } - - const sourceAnchor = inferEdgeAnchor( - sourcePos, - normalizedPoints[0], - normalizedPoints[1] - ); - const targetAnchor = inferEdgeAnchor( - targetPos, - normalizedPoints[normalizedPoints.length - 1], - normalizedPoints[normalizedPoints.length - 2] - ); - - result.edges.push({ - source: sourceId, - target: targetId, - color, - kind, - count, - resolution, - points: anchorEdgePolyline( - normalizedPoints, - sourcePos, - targetPos, - sourceAnchor, - targetAnchor - ), - sourceAnchor, - targetAnchor, - }); - } - } - } - } - - processNode(elkNode, 0, 0); - - if (import.meta.env.DEV) { - useDebugStore.getState().addLog( - `extractLayout: found=${totalEdgesFound}, withSections=${edgesWithSections}, withoutSections=${edgesWithoutSections}, result=${result.edges.length}` - ); - } + debugLog( + `extractLayout: found=${stats.totalEdgesFound}, withSections=${stats.edgesWithSections}, ` + + `withoutSections=${stats.edgesWithoutSections}, result=${edges.length}` + ); // If ELK didn't route edges (either it produced none, or routing was skipped // for a large view), generate straight-line fallback edges from the view edges. - if (result.edges.length === 0 && viewEdges.length > 0) { - if (import.meta.env.DEV) { - useDebugStore.getState().addLog("ELK provided no routed edges, generating straight-line fallback edges"); - } - - const fallbackEdges: LayoutEdge[] = straightLineEdges(result.nodes, viewEdges); - for (const edge of fallbackEdges) { - result.edges.push(edge); - } - - if (import.meta.env.DEV) { - useDebugStore.getState().addLog(`Generated ${result.edges.length} fallback edges`); + if (edges.length === 0 && viewEdges.length > 0) { + debugLog("ELK provided no routed edges, generating straight-line fallback edges"); + // Appended one at a time: this path exists for the very views that skipped + // ELK routing, and a spread of tens of thousands of edges overflows the + // call stack. + for (const edge of straightLineEdges(nodes, viewEdges)) { + edges.push(edge); } + debugLog(`Generated ${edges.length} fallback edges`); } - return result; + return { nodes, edges, edgeKindCounts, renderIds }; } function fallbackLayout( diff --git a/packages/app/src/canvas/layout/layoutOrchestrator.ts b/packages/app/src/canvas/layout/layoutOrchestrator.ts new file mode 100644 index 0000000..78b4d30 --- /dev/null +++ b/packages/app/src/canvas/layout/layoutOrchestrator.ts @@ -0,0 +1,282 @@ +import type { CodeGraph, EdgeKind } from "../../api/types"; +import type { LayoutResult } from "./layoutTypes"; +import { + mergeLayoutRequests, + type EdgeLayoutRequest, + type FullLayoutRequest, + type LayoutRequest, +} from "./layoutRequest.ts"; +import { CoalescingScheduler } from "./layoutScheduler.ts"; + +/** + * The layout-request lifecycle: what to run, what is stale, what to re-apply. + * + * The renderer used to carry this state machine inline, which layered three + * overlapping staleness mechanisms on top of the coalescing queue and made none + * of them testable. They all live here instead: + * + * - the run-latest QUEUE (`CoalescingScheduler` + `mergeLayoutRequests`), so a + * burst of interactions costs one pass with the newest inputs; + * - the request-id STALE GUARD, so a superseded pass can neither publish its + * edge-kind counts nor overwrite a newer layout; + * - the pending GATE, which suppresses the cheap visibility redraw while a + * layout is in flight (routing a new visible set against the outgoing layout + * is wasted work) and is released even when the layout FAILS; + * - the latest-vs-applied VISIBLE SET reconciliation, so a slow layout can + * never resurrect nodes the user has hidden meanwhile. + * + * Everything the machine actually *does* -- run ELK, touch node displays, draw + * edges, publish to stores -- is injected as `LayoutOrchestratorEffects`, so the + * module stays free of runtime imports beyond the two pure layout modules and is + * unit-testable with fakes. + */ + +/** Per-kind view counts carried by a layout result. */ +type EdgeKindCounts = LayoutResult["edgeKindCounts"]; + +/** + * The side effects the orchestrator drives. The renderer supplies the real ones; + * tests supply fakes. + */ +export interface LayoutOrchestratorEffects { + /** + * Resolve once the consumer can accept a layout, reporting whether it still + * can (false after teardown). Every pass awaits this before doing any work. + */ + waitUntilReady(): Promise; + + /** Run the POSITIONS phase (ELK + view-edge fetch). */ + runFullLayout(request: FullLayoutRequest): Promise; + + /** Run the EDGES phase against a previous layout's positions and render set. */ + runEdgePhase( + previous: LayoutResult, + enabledEdgeKinds: Set, + hideAmbiguousEdges: boolean + ): Promise; + + /** + * Adopt the inputs of a full layout that is about to run. Called BEFORE the + * layout, unguarded, because the consumer needs the new graph (and anything + * derived from it) for interaction handling even while the pass is in flight. + */ + adoptFullLayoutInputs(request: FullLayoutRequest): void; + + /** + * Apply a fresh, current full layout: rebuild the node displays at the new + * positions and perform exactly ONE full edge rebuild. + */ + applyFullLayout(layout: LayoutResult, request: FullLayoutRequest): void; + + /** Apply a fresh, current edge-phase layout: edges only, camera untouched. */ + applyEdgeLayout(layout: LayoutResult, request: EdgeLayoutRequest): void; + + /** Flip the already-laid-out node displays to a visible set. Draws no edges. */ + applyVisibleNodes(visibleNodes: Set): void; + + /** Redraw the edges against the displays as they stand. */ + redrawEdges(): void; + + /** Publish the legend's per-kind counts for a pass known to be current. */ + publishEdgeKindCounts(counts: EdgeKindCounts): void; + + /** Report a failed pass. The gate is already released when this is called. */ + reportError(error: unknown): void; +} + +export class LayoutOrchestrator { + private readonly effects: LayoutOrchestratorEffects; + + /** + * Monotonic id of the newest STARTED pass. A completing pass whose id is no + * longer the newest has been superseded and discards its result. + */ + private requestId = 0; + + /** True between requesting a full layout and applying it (or failing). */ + private pending = false; + + /** The result of the newest applied pass; the edges phase's input. */ + private layout: LayoutResult | null = null; + + /** The visible set currently applied to the displays. */ + private appliedVisibleNodes: Set = new Set(); + + /** + * The newest visible set handed to the orchestrator, by either a layout + * request or a visibility update. A layout that started before a later + * visibility change re-applies this on completion. + */ + private latestVisibleNodes: Set = new Set(); + + private destroyed = false; + + /** + * Run-latest layout queue. elkjs cannot be aborted mid-run, so a burst of + * interactions collapses into ONE rerun with the latest inputs instead of a + * serial pile of full layouts (see `layoutScheduler`). + */ + private readonly queue: CoalescingScheduler; + + constructor(effects: LayoutOrchestratorEffects) { + this.effects = effects; + this.queue = new CoalescingScheduler({ + run: (request) => this.runRequest(request), + merge: mergeLayoutRequests, + onError: (error) => { + // Never leave the gate stuck: it suppresses the visibility redraw. + this.pending = false; + this.effects.reportError(error); + }, + }); + } + + /** The newest applied layout, or null before the first full layout lands. */ + get lastLayout(): LayoutResult | null { + return this.layout; + } + + /** True while a full layout is in flight; the visibility redraw is gated. */ + get isLayoutPending(): boolean { + return this.pending; + } + + /** The visible set currently applied to the displays. */ + get visibleNodes(): Set { + return this.appliedVisibleNodes; + } + + /** + * Request the POSITIONS phase: a full layout of the node tree plus the view + * edges for the resulting render set. Coalesced -- calling this repeatedly + * costs one layout, with the newest inputs. + */ + requestFullLayout( + graph: CodeGraph, + expandedNodes: Set, + visibleNodes: Set, + enabledEdgeKinds: Set, + hideAmbiguousEdges: boolean + ): void { + this.latestVisibleNodes = visibleNodes; + // Gates the cheap visibility redraw until this layout lands (or fails): + // routing the new visible set against the outgoing layout is wasted work. + this.pending = true; + this.queue.schedule({ + phase: "full", + graph, + expandedNodes, + visibleNodes, + enabledEdgeKinds, + hideAmbiguousEdges, + }); + } + + /** + * Request the EDGES phase: re-fetch the view edges for the last laid-out + * render set and redraw them on the cached node positions. No-op until a full + * layout has produced positions -- a full layout is either queued behind this + * (and absorbs it) or has not been asked for. + */ + requestEdgePhase( + enabledEdgeKinds: Set, + hideAmbiguousEdges: boolean + ): void { + this.queue.schedule({ + phase: "edges", + enabledEdgeKinds, + hideAmbiguousEdges, + }); + } + + /** + * Change which nodes are visible without a relayout. + * + * While a layout is in flight the edge redraw is skipped: it would route the + * NEW visible set against the OLD (stale) layout, only to be thrown away + * moments later when the layout lands. Callers commonly change visibility and + * layout inputs in the same tick, so this is the normal case. + */ + setVisibleNodes(visibleNodes: Set): void { + this.latestVisibleNodes = visibleNodes; + if (this.appliedVisibleNodes === visibleNodes) return; + this.appliedVisibleNodes = visibleNodes; + + this.effects.applyVisibleNodes(visibleNodes); + + if (this.pending) return; + + this.effects.redrawEdges(); + } + + /** Drop the queued rerun. The in-flight pass discards its own result. */ + destroy(): void { + this.destroyed = true; + this.queue.clearPending(); + } + + /** Perform one queued request. Serialised by `queue`; never re-entrant. */ + private async runRequest(request: LayoutRequest): Promise { + if (!(await this.effects.waitUntilReady())) return; + + const requestId = ++this.requestId; + + if (request.phase === "edges") { + await this.runEdgePhase(request, requestId); + return; + } + await this.runFullLayout(request, requestId); + } + + private async runEdgePhase( + request: EdgeLayoutRequest, + requestId: number + ): Promise { + const previous = this.layout; + if (!previous) return; + + const layout = await this.effects.runEdgePhase( + previous, + request.enabledEdgeKinds, + request.hideAmbiguousEdges + ); + if (this.isStale(requestId)) return; + + this.layout = layout; + this.effects.publishEdgeKindCounts(layout.edgeKindCounts); + this.effects.applyEdgeLayout(layout, request); + } + + private async runFullLayout( + request: FullLayoutRequest, + requestId: number + ): Promise { + this.effects.adoptFullLayoutInputs(request); + + const layout = await this.effects.runFullLayout(request); + if (this.isStale(requestId)) return; + + // Adopt the request's visible set only now, so a visibility toggle made + // while this layout ran keeps applying to the displays on screen. + this.appliedVisibleNodes = request.visibleNodes; + this.layout = layout; + // Published only once the pass is known to be current, so a superseded + // fetch cannot clobber fresh counts. + this.effects.publishEdgeKindCounts(layout.edgeKindCounts); + this.effects.applyFullLayout(layout, request); + // Cleared BEFORE the late-visibility re-apply below, so that redraw is not + // gated by the very layout it follows. + this.pending = false; + + // A visibility toggle that landed while this layout was running is applied + // on top of the fresh displays. + if (this.latestVisibleNodes !== request.visibleNodes) { + this.setVisibleNodes(this.latestVisibleNodes); + } + } + + /** Whether a completing pass has been superseded (or the consumer is gone). */ + private isStale(requestId: number): boolean { + return requestId !== this.requestId || this.destroyed; + } +} diff --git a/packages/app/src/canvas/layout/routingConstants.ts b/packages/app/src/canvas/layout/routingConstants.ts new file mode 100644 index 0000000..47d9d8c --- /dev/null +++ b/packages/app/src/canvas/layout/routingConstants.ts @@ -0,0 +1,97 @@ +/** + * Every tolerance and margin the edge-routing pipeline measures against, in one + * place. + * + * These numbers used to be scattered across `edgeGeometry.ts` (geometry + * tolerances, obstacle margins, pass caps) and `renderers/edgeDrawing.ts` + * (a second, weaker point tolerance and the obstacle query window), where two + * stages of the same pipeline could — and did — disagree about whether a point + * sat on a box. Derived values are expressed as ARITHMETIC over the values they + * derive from, so the relationship cannot drift the way a prose comment can. + * + * Dependency-free, so it loads under `node --test`. + */ + +/** + * When two coordinates count as the SAME coordinate. + * + * Sub-pixel: this is point equality (deduping, "is this segment axis-aligned", + * "does this point lie on that box side"), never a judgement call about + * proximity. + */ +export const POINT_TOLERANCE = 0.5; + +/** + * How far off a node's boundary a point may sit and still count as ON it. + * + * Wider than `POINT_TOLERANCE` because it absorbs the rounding ELK accumulates + * while composing nested container offsets. Only used for containment-style + * questions about a whole box, never for point equality. + */ +export const BOUNDARY_TOLERANCE = 4; + +/** + * How far a node's on-screen container may sit from its laid-out position + * before the redraw treats it as DRAGGED. + * + * A whole layout unit: sub-unit drift is float noise from the layout round + * trip, not a user moving a node, and reacting to it would re-route every edge + * on every redraw. + */ +export const NODE_MOVED_EPSILON = 1; + +/** + * How far a routed edge stays clear of a node box it detours around. + * + * Obstacle boxes are inflated by this before any crossing test, so an edge that + * "just" clears a node still reads as clearing it at normal zoom. + */ +export const NODE_OBSTACLE_MARGIN = 14; + +/** + * How far outside the involved boxes a detour is pushed when it has to leave + * the corridor between the two endpoints entirely. + * + * Big enough that the detour reads as going AROUND the obstacle rather than + * grazing it, small enough that it does not fly off across the canvas. + */ +export const DETOUR_GUTTER = 28; + +/** + * Hard cap on detour passes for a single edge. + * + * Each pass clears the first obstacle crossing it finds and re-normalises, so a + * dense field of obstacles can need many. The cap is what makes the router + * terminate: past it the edge is drawn with whatever crossings remain rather + * than the redraw hanging. + */ +export const MAX_OBSTACLE_REROUTE_PASSES = 32; + +/** + * Slack added on top of the obstacle inflation and the detour gutter when + * deciding which obstacles an edge can possibly care about. + * + * Covers a node's own extent (a tall node is ~72 units) plus room to spare, so + * the query window is generous rather than exact — a missed obstacle is a + * visible routing bug, an extra one costs a crossing test. + */ +export const OBSTACLE_QUERY_ALLOWANCE = 118; + +/** + * How far beyond an edge's own bounding box obstacles are still considered. + * + * The router may leave the corridor between the endpoints: a detour clears an + * obstacle inflated by `NODE_OBSTACLE_MARGIN` and can be pushed a further + * `DETOUR_GUTTER` outside the boxes it goes around, on top of a node's own + * extent (`OBSTACLE_QUERY_ALLOWANCE`). Computed rather than written out so it + * cannot fall behind the values it depends on, while keeping each query to a + * handful of boxes instead of the whole graph. + */ +export const OBSTACLE_QUERY_MARGIN = + NODE_OBSTACLE_MARGIN + DETOUR_GUTTER + OBSTACLE_QUERY_ALLOWANCE; + +/** Shortest lead an edge is given before it may turn away from its anchor. */ +export const MIN_LEAD_DISTANCE = 18; + +/** Longest lead an edge is given before it may turn away from its anchor. */ +export const MAX_LEAD_DISTANCE = 72; diff --git a/packages/app/src/canvas/layout/straightEdges.ts b/packages/app/src/canvas/layout/straightEdges.ts index 15f72e4..ff8ac55 100644 --- a/packages/app/src/canvas/layout/straightEdges.ts +++ b/packages/app/src/canvas/layout/straightEdges.ts @@ -2,7 +2,7 @@ // (see edgeRebuild.ts and its seam test). import { anchorEdgePolyline, - inferEdgeAnchor, + type EdgeAnchor, type Point, } from "./edgeGeometry.ts"; import type { LayoutEdge, LayoutNodePosition } from "./layoutTypes"; @@ -38,6 +38,10 @@ export function straightLineEdge( let startPoint: Point; let endPoint: Point; + // The anchors are not read back off the geometry: this function CHOOSES the + // facing sides, so it states them outright. Mid-side offsets by construction. + let sourceAnchor: EdgeAnchor; + let targetAnchor: EdgeAnchor; if (Math.abs(dx) > Math.abs(dy)) { startPoint = { @@ -48,6 +52,14 @@ export function straightLineEdge( x: dx > 0 ? targetPos.x : targetPos.x + targetPos.width, y: targetCy, }; + sourceAnchor = { + side: dx > 0 ? "right" : "left", + offset: sourcePos.height / 2, + }; + targetAnchor = { + side: dx > 0 ? "left" : "right", + offset: targetPos.height / 2, + }; } else { startPoint = { x: sourceCx, @@ -57,11 +69,16 @@ export function straightLineEdge( x: targetCx, y: dy > 0 ? targetPos.y : targetPos.y + targetPos.height, }; + sourceAnchor = { + side: dy > 0 ? "bottom" : "top", + offset: sourcePos.width / 2, + }; + targetAnchor = { + side: dy > 0 ? "top" : "bottom", + offset: targetPos.width / 2, + }; } - const sourceAnchor = inferEdgeAnchor(sourcePos, startPoint, endPoint); - const targetAnchor = inferEdgeAnchor(targetPos, endPoint, startPoint); - return { source: edge.source, target: edge.target, @@ -75,7 +92,7 @@ export function straightLineEdge( targetPos, sourceAnchor, targetAnchor - ), + ).points, sourceAnchor, targetAnchor, }; diff --git a/packages/app/src/canvas/renderers/EdgeRenderer.ts b/packages/app/src/canvas/renderers/EdgeRenderer.ts deleted file mode 100644 index ac16420..0000000 --- a/packages/app/src/canvas/renderers/EdgeRenderer.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Re-export the canonical EdgeDrawingManager and related types. - * - * This file previously contained dead code. The real edge rendering - * implementation lives in edgeDrawing.ts and uses a two-layer - * architecture (base + highlight) for efficient hover updates. - */ -export { - EdgeDrawingManager, - getLODEdgeOpacity, - shouldHideEdgeKindAtLOD, - getLODEdgeWidthMultiplier, -} from "./edgeDrawing"; - -export type { EdgeDatum, NodeDisplayRef } from "./types"; diff --git a/packages/app/src/canvas/renderers/LabelRenderer.ts b/packages/app/src/canvas/renderers/LabelRenderer.ts deleted file mode 100644 index 5a8f785..0000000 --- a/packages/app/src/canvas/renderers/LabelRenderer.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Re-export the canonical label-related helpers. - * - * This file previously contained dead code. Labels are now managed inline - * by nodeCreation.ts (createNodeDisplay sets up the Text) and - * dragManager.ts (updateNodeLabelWrap adjusts word-wrap width). - * - * LOD-based label visibility is handled directly in PixiRenderer.updateLODVisibility(). - */ -export { updateNodeLabelWrap } from "./dragManager"; diff --git a/packages/app/src/canvas/renderers/NodeRenderer.ts b/packages/app/src/canvas/renderers/NodeRenderer.ts deleted file mode 100644 index d47b07e..0000000 --- a/packages/app/src/canvas/renderers/NodeRenderer.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Re-export the canonical node creation functions and types. - * - * This file previously contained dead code. The real node display - * implementation lives in nodeCreation.ts (factory functions) and - * dragManager.ts (resizing / background redraw helpers). - */ -export { - createNodeDisplay, - getNodeColor, - getNodeLabel, - blockKindPrefix, - getNodeLayer, -} from "./nodeCreation"; - -export type { NodeDisplay } from "./nodeCreation"; - -export { redrawNodeBg, syncDisplayBounds, updateNodeLabelWrap } from "./dragManager"; -export { DragManager } from "./dragManager"; diff --git a/packages/app/src/canvas/renderers/PixiRenderer.ts b/packages/app/src/canvas/renderers/PixiRenderer.ts index f98cd65..cdd416a 100644 --- a/packages/app/src/canvas/renderers/PixiRenderer.ts +++ b/packages/app/src/canvas/renderers/PixiRenderer.ts @@ -4,11 +4,7 @@ import type { CodeGraph, CodeNode, EdgeKind } from "../../api/types"; import { layoutGraph } from "../layout/elkLayout"; import { layoutEdgePhase } from "../layout/edgePhase"; import type { LayoutResult, LayoutNodePosition } from "../layout/layoutTypes"; -import { - mergeLayoutRequests, - type LayoutRequest, -} from "../layout/layoutRequest"; -import { CoalescingScheduler } from "../layout/layoutScheduler"; +import { LayoutOrchestrator } from "../layout/layoutOrchestrator"; import { useGraphStore } from "../../stores/graphStore"; import { EMPTY_SELECTION, @@ -56,7 +52,6 @@ export class PixiRenderer { private hoveredNodeId: string | null = null; /** Endpoints of the edge currently under the pointer (emphasised borders). */ private hoveredEdgeEndpoints: ReadonlySet = new Set(); - private currentEnabledEdgeKinds: Set | null = null; private parentByNodeId = new Map(); private resizeObserver: ResizeObserver; private containerEl: HTMLElement; @@ -64,39 +59,53 @@ export class PixiRenderer { /** Mirror of the store's selection; also the pinned edge highlight. */ private selection: SelectionState = EMPTY_SELECTION; private currentLOD: LODLevel = "detail"; - private lastLayout: LayoutResult | null = null; private currentGraph: CodeGraph | null = null; - /** The visible set currently applied to the node displays. */ - private currentVisibleNodes: Set = new Set(); - /** - * The newest visible set handed to the renderer (by a layout request or a - * visibility update). A layout that started before a later visibility change - * re-applies this on completion, so a slow layout can never resurrect nodes - * the user has since hidden. - */ - private latestVisibleNodes: Set = new Set(); private _viewportDirty = false; private _viewportRafId: number | null = null; - private _layoutRequestId = 0; - /** True between requesting a layout and applying it; suppresses stale redraws. */ - private _layoutPending = false; private _edgeHoverRafId: number | null = null; /** Identity + time of the last tap that landed on an edge (double-click pairing). */ private lastEdgeTapKey: string | null = null; private lastEdgeTapTime = 0; /** - * Run-latest layout queue. elkjs cannot be aborted mid-run, so a burst of - * interactions collapses into ONE rerun with the latest inputs instead of a - * serial pile of full layouts (see `layoutScheduler`). Requests scheduled - * before Pixi finishes initialising simply wait on `initPromise`. + * The layout-request state machine: the run-latest queue, the stale-result + * guard, the pending gate and the visible-set reconciliation all live there + * (see `layout/layoutOrchestrator`). The renderer only supplies the effects. + * Requests scheduled before Pixi finishes initialising wait on `initPromise`. */ - private layoutQueue = new CoalescingScheduler({ - run: (request) => this.runLayoutRequest(request), - merge: mergeLayoutRequests, - onError: (err) => { - // Never leave the pending flag stuck: it gates the visibility redraw. - this._layoutPending = false; + private layoutOrchestrator = new LayoutOrchestrator({ + waitUntilReady: async () => { + await this.initPromise; + return !this.destroyed && this.initialized; + }, + runFullLayout: (request) => + layoutGraph( + request.graph, + request.expandedNodes, + request.visibleNodes, + request.enabledEdgeKinds, + request.hideAmbiguousEdges + ), + runEdgePhase: (previous, enabledEdgeKinds, hideAmbiguousEdges) => + layoutEdgePhase(previous, enabledEdgeKinds, hideAmbiguousEdges), + // Adopted before the layout runs: interaction handling (drag, subtree + // highlight) needs the new graph even while the pass is in flight. + adoptFullLayoutInputs: (request) => { + this.currentGraph = request.graph; + this.parentByNodeId = buildParentMap(request.graph); + }, + applyFullLayout: (layout, request) => + this.renderFromLayout(request.graph, layout, request.expandedNodes), + applyEdgeLayout: (layout) => this.rebuildEdgeDisplays(layout), + applyVisibleNodes: (visibleNodes) => { + for (const [nodeId, display] of this.nodeDisplays) { + display.container.visible = visibleNodes.has(nodeId); + } + }, + redrawEdges: () => this.triggerEdgeRedraw(), + publishEdgeKindCounts: (counts) => + useEdgeLegendStore.getState().setCounts(counts), + reportError: (err) => { console.error("layout request failed:", err); if (import.meta.env.DEV) { useDebugStore.getState().addLog(`layout request FAILED: ${err}`); @@ -104,6 +113,16 @@ export class PixiRenderer { }, }); + /** The newest applied layout. Owned by the orchestrator; read for rendering. */ + private get lastLayout(): LayoutResult | null { + return this.layoutOrchestrator.lastLayout; + } + + /** The visible set currently applied to the node displays. */ + private get currentVisibleNodes(): Set { + return this.layoutOrchestrator.visibleNodes; + } + private initPromise: Promise; private destroyed = false; @@ -372,18 +391,13 @@ export class PixiRenderer { ); } - this.latestVisibleNodes = visibleNodes; - // Gates the cheap visibility redraw until this layout lands (or fails): - // routing the new visible set against the outgoing layout is wasted work. - this._layoutPending = true; - this.layoutQueue.schedule({ - phase: "full", + this.layoutOrchestrator.requestFullLayout( graph, expandedNodes, visibleNodes, enabledEdgeKinds, - hideAmbiguousEdges, - }); + hideAmbiguousEdges + ); } /** @@ -394,11 +408,10 @@ export class PixiRenderer { * is either queued behind this (and absorbs it) or has not been asked for. */ updateEdges(enabledEdgeKinds: Set, hideAmbiguousEdges: boolean) { - this.layoutQueue.schedule({ - phase: "edges", + this.layoutOrchestrator.requestEdgePhase( enabledEdgeKinds, - hideAmbiguousEdges, - }); + hideAmbiguousEdges + ); } /** @@ -410,77 +423,7 @@ export class PixiRenderer { * visibility and layout inputs in the same tick, so this is the normal case. */ updateVisibility(visibleNodes: Set) { - this.latestVisibleNodes = visibleNodes; - if (this.currentVisibleNodes === visibleNodes) return; - this.currentVisibleNodes = visibleNodes; - - for (const [nodeId, display] of this.nodeDisplays) { - display.container.visible = visibleNodes.has(nodeId); - } - - if (this._layoutPending) return; - - this.triggerEdgeRedraw(); - } - - /** - * Perform one queued layout request. Serialised by `layoutQueue`, and its - * result still guarded by `_layoutRequestId` so a superseded pass can never - * publish its edge-kind counts or overwrite a newer layout. - */ - private async runLayoutRequest(request: LayoutRequest): Promise { - await this.initPromise; - if (this.destroyed || !this.initialized) return; - - const requestId = ++this._layoutRequestId; - - if (request.phase === "edges") { - const previous = this.lastLayout; - if (!previous) return; - const layout = await layoutEdgePhase( - previous, - request.enabledEdgeKinds, - request.hideAmbiguousEdges - ); - if (requestId !== this._layoutRequestId || this.destroyed) return; // stale -- discard - this.currentEnabledEdgeKinds = request.enabledEdgeKinds; - this.lastLayout = layout; - useEdgeLegendStore.getState().setCounts(layout.edgeKindCounts); - this.rebuildEdgeDisplays(layout); - return; - } - - const { graph, expandedNodes, visibleNodes } = request; - this.currentGraph = graph; - this.currentEnabledEdgeKinds = request.enabledEdgeKinds; - this.parentByNodeId = buildParentMap(graph); - - const layout = await layoutGraph( - graph, - expandedNodes, - visibleNodes, - request.enabledEdgeKinds, - request.hideAmbiguousEdges - ); - if (requestId !== this._layoutRequestId || this.destroyed) return; // stale -- discard - - // Adopt the request's visible set only now, so a visibility toggle made - // while this layout ran keeps applying to the displays on screen. - this.currentVisibleNodes = visibleNodes; - this.lastLayout = layout; - // Publish per-kind view counts for the legend only once the layout is - // known to be current, so a superseded fetch cannot clobber fresh counts. - useEdgeLegendStore.getState().setCounts(layout.edgeKindCounts); - this.renderFromLayout(graph, layout, expandedNodes, visibleNodes); - // Cleared before the late-visibility re-apply below so that redraw is not - // gated by the very layout it follows. - this._layoutPending = false; - - // A visibility toggle that landed while this layout was running is applied - // on top of the fresh displays. - if (this.latestVisibleNodes !== visibleNodes) { - this.updateVisibility(this.latestVisibleNodes); - } + this.layoutOrchestrator.setVisibleNodes(visibleNodes); } /** @@ -500,8 +443,7 @@ export class PixiRenderer { private renderFromLayout( graph: CodeGraph, layout: LayoutResult, - expandedNodes: Set, - _visibleNodes: Set + expandedNodes: Set ) { if (import.meta.env.DEV) { useDebugStore.getState().addLog( @@ -573,14 +515,21 @@ export class PixiRenderer { * none is within the hit radius. Shared by edge hover and edge double-click so * both resolve to the same edge. The radius is in screen pixels, so it stays * constant as the user zooms. + * + * Measured against the polyline the edge manager actually DREW, not the one + * the layout produced: lane spreading, obstacle detours and node drags each + * move an edge off its layout route, and testing the invisible line makes + * hover, the tooltip and drill-in land on the wrong edge (or on none at all). + * Edges not drawn yet fall back to their layout polyline. */ private hitTestEdge(globalPos: { x: number; y: number }): EdgeDatum | null { const worldPos = this.viewport.toLocal(globalPos); let closestEdge: EdgeDatum | null = null; let closestDist = EDGE_HIT_RADIUS_PX / this.viewport.scale.x; - for (const edge of this.edgeManager.edgeData) { - if (edge.originalPoints.length < 2) continue; - const dist = pointToPolylineDistance(worldPos, edge.originalPoints); + for (const [idx, edge] of this.edgeManager.edgeData.entries()) { + const points = this.edgeManager.resolvedPointsFor(idx) ?? edge.originalPoints; + if (points.length < 2) continue; + const dist = pointToPolylineDistance(worldPos, points); if (dist < closestDist) { closestDist = dist; closestEdge = edge; @@ -933,7 +882,7 @@ export class PixiRenderer { destroy() { this.destroyed = true; - this.layoutQueue.clearPending(); + this.layoutOrchestrator.destroy(); this.edgeManager.destroyEdgeGraphics(); if (this._viewportRafId !== null) { cancelAnimationFrame(this._viewportRafId); diff --git a/packages/app/src/canvas/renderers/edgeDrawing.ts b/packages/app/src/canvas/renderers/edgeDrawing.ts index 5d662ed..35e756e 100644 --- a/packages/app/src/canvas/renderers/edgeDrawing.ts +++ b/packages/app/src/canvas/renderers/edgeDrawing.ts @@ -1,16 +1,12 @@ import { Container, Graphics } from "pixi.js"; import type { EdgeKind } from "../../api/types"; +import type { NodeBox, Point } from "../layout/edgeGeometry"; import { - anchorEdgePolyline, - inferEdgeAnchor, - inferEdgeAnchorFromPoint, - rerouteOrthogonalEdge, - routePolylineAroundObstacles, - translatePolyline, - type EdgeAnchor, - type NodeBox, - type Point, -} from "../layout/edgeGeometry"; + anchorEdgeRoute, + routeEdges, + type EdgeRouteInput, + type RoutedEdge, +} from "../layout/edgeRoutePipeline"; import { ObstacleIndex, obstacleEntry, @@ -24,11 +20,6 @@ import { type EdgeDatum, type NodeDisplayRef, } from "./types"; -import { - resolveEdgeRoutingMode, - routesAroundObstacles, - scoresEdgeCrossings, -} from "./edgeRoutingBudget"; import { buildEdgeCountChipLayer, polylineArcMidpoint, @@ -39,28 +30,6 @@ import { // Re-export types for backwards compatibility with existing imports export type { EdgeDatum, NodeDisplayRef } from "./types"; -/** - * How far beyond an edge's own bounding box obstacles are still considered. - * - * The router may leave the corridor between the endpoints: a detour clears an - * obstacle inflated by `NODE_OBSTACLE_MARGIN` (14) and can be pushed a further - * `DETOUR_GUTTER` (28) outside the boxes it goes around, on top of a node's own - * height. 160 layout units covers that with room to spare while keeping each - * query to a handful of boxes instead of the whole graph. - */ -const OBSTACLE_QUERY_MARGIN = 160; - -/** Shared empty reference list, so the no-scoring path allocates nothing. */ -const NO_REFERENCE_POLYLINES: Point[][] = []; - -interface ResolvedEdgeDraw { - index: number; - edge: EdgeDatum; - points: Point[]; - sourceBox: NodeBox; - targetBox: NodeBox; -} - /** * "#64748b" -> 0x64748b. Done once per edge per LAYOUT (in `buildEdgeData`) * rather than once per edge per REDRAW, which is where it used to sit. @@ -70,26 +39,6 @@ function parseEdgeColor(color: string): number { return Number.isNaN(parsed) ? 0x64748b : parsed; } -function getBoxCenter(box: NodeBox): Point { - return { - x: box.x + box.width / 2, - y: box.y + box.height / 2, - }; -} - -function nearlyEqual(a: number, b: number, tolerance = 1.5): boolean { - return Math.abs(a - b) <= tolerance; -} - -function boxContainsPoint(box: NodeBox, point: Point): boolean { - return ( - point.x >= box.x && - point.x <= box.x + box.width && - point.y >= box.y && - point.y <= box.y + box.height - ); -} - function nodeRefToBox(ref: NodeDisplayRef): NodeBox { return { x: ref.containerX, @@ -125,162 +74,6 @@ function nodeRefToLabelObstacle(ref: NodeDisplayRef): NodeBox | null { }; } -function inferPointSide(box: NodeBox, point: Point): EdgeAnchor["side"] | null { - if (nearlyEqual(point.x, box.x)) return "left"; - if (nearlyEqual(point.x, box.x + box.width)) return "right"; - if (nearlyEqual(point.y, box.y)) return "top"; - if (nearlyEqual(point.y, box.y + box.height)) return "bottom"; - return null; -} - -function sideLength(box: NodeBox, side: EdgeAnchor["side"]): number { - return side === "left" || side === "right" ? box.height : box.width; -} - -function pointOnSide(box: NodeBox, side: EdgeAnchor["side"], offset: number): Point { - const clampedOffset = Math.max(0, Math.min(sideLength(box, side), offset)); - - switch (side) { - case "left": - return { x: box.x, y: box.y + clampedOffset }; - case "right": - return { x: box.x + box.width, y: box.y + clampedOffset }; - case "top": - return { x: box.x + clampedOffset, y: box.y }; - case "bottom": - return { x: box.x + clampedOffset, y: box.y + box.height }; - } -} - -function laneOffset(box: NodeBox, side: EdgeAnchor["side"], index: number, count: number): number { - const length = sideLength(box, side); - if (count <= 1) { - return length / 2; - } - - const padding = Math.min(12, Math.max(4, length / 4)); - const usable = Math.max(1, length - padding * 2); - return padding + (usable * (index + 1)) / (count + 1); -} - -function orderValueForSide(box: NodeBox, side: EdgeAnchor["side"]): number { - const center = getBoxCenter(box); - return side === "left" || side === "right" ? center.y : center.x; -} - -function moveEndpointToLane( - points: Point[], - box: NodeBox, - side: EdgeAnchor["side"], - offset: number, - atEnd: boolean -): Point[] { - if (points.length < 2) { - return points; - } - - const nextPoints = points.map((point) => ({ ...point })); - const endpoint = pointOnSide(box, side, offset); - - if (atEnd) { - const prevIndex = nextPoints.length - 2; - nextPoints[nextPoints.length - 1] = endpoint; - - if (side === "left" || side === "right") { - nextPoints[prevIndex] = { ...nextPoints[prevIndex], y: endpoint.y }; - } else { - nextPoints[prevIndex] = { ...nextPoints[prevIndex], x: endpoint.x }; - } - } else { - nextPoints[0] = endpoint; - - if (side === "left" || side === "right") { - nextPoints[1] = { ...nextPoints[1], y: endpoint.y }; - } else { - nextPoints[1] = { ...nextPoints[1], x: endpoint.x }; - } - } - - return routePolylineAroundObstacles(nextPoints, []); -} - -function spreadEndpointLanes(draws: ResolvedEdgeDraw[], atEnd: boolean): void { - const groups = new Map(); - - for (const draw of draws) { - const box = atEnd ? draw.targetBox : draw.sourceBox; - const point = atEnd ? draw.points[draw.points.length - 1] : draw.points[0]; - const side = inferPointSide(box, point); - - if (!side) { - continue; - } - - const nodeId = atEnd ? draw.edge.target : draw.edge.source; - const key = `${nodeId}:${side}`; - const group = groups.get(key); - - if (group) { - group.push(draw); - } else { - groups.set(key, [draw]); - } - } - - for (const group of groups.values()) { - if (group.length <= 1) { - continue; - } - - const side = inferPointSide( - atEnd ? group[0].targetBox : group[0].sourceBox, - atEnd ? group[0].points[group[0].points.length - 1] : group[0].points[0] - ); - if (!side) { - continue; - } - - group.sort((a, b) => { - const aBox = atEnd ? a.sourceBox : a.targetBox; - const bBox = atEnd ? b.sourceBox : b.targetBox; - return orderValueForSide(aBox, side) - orderValueForSide(bBox, side); - }); - - for (let i = 0; i < group.length; i++) { - const draw = group[i]; - const box = atEnd ? draw.targetBox : draw.sourceBox; - draw.points = moveEndpointToLane( - draw.points, - box, - side, - laneOffset(box, side, i, group.length), - atEnd - ); - } - } -} - -function inferAnchorsFromPolyline( - points: Point[], - sourceBox: NodeBox, - targetBox: NodeBox -): { sourceAnchor: EdgeAnchor; targetAnchor: EdgeAnchor } { - const sourceAnchor = - points.length >= 2 - ? inferEdgeAnchor(sourceBox, points[0], points[1]) - : inferEdgeAnchorFromPoint(sourceBox, getBoxCenter(targetBox)); - const targetAnchor = - points.length >= 2 - ? inferEdgeAnchor( - targetBox, - points[points.length - 1], - points[points.length - 2] - ) - : inferEdgeAnchorFromPoint(targetBox, getBoxCenter(sourceBox)); - - return { sourceAnchor, targetAnchor }; -} - /** * Snapshot every visible node's display ref ONCE per redraw. * @@ -326,115 +119,54 @@ function buildObstacleIndex(refs: ReadonlyMap): Obstacle } /** - * Obstacles one edge actually has to care about: the boxes near its polyline, - * minus its own endpoints' boxes and minus any box that swallows an endpoint - * centre (a collapsed container holding one of the endpoints -- routing around - * it is impossible, and trying produces long useless detours). - */ -function obstaclesForEdge(index: ObstacleIndex, draw: ResolvedEdgeDraw): NodeBox[] { - const candidates = index.queryForPolyline( - draw.points, - OBSTACLE_QUERY_MARGIN, - draw.edge.source, - draw.edge.target - ); - - if (candidates.length === 0) { - return candidates; - } - - const sourceCenter = getBoxCenter(draw.sourceBox); - const targetCenter = getBoxCenter(draw.targetBox); - const obstacles: NodeBox[] = []; - - for (const box of candidates) { - if (boxContainsPoint(box, sourceCenter) || boxContainsPoint(box, targetCenter)) { - continue; - } - obstacles.push(box); - } - - return obstacles; -} - -/** - * Resolves edge routing points for a single edge given current node positions. - * Extracted so both base and highlight layers can share this logic. + * Adapt one edge datum plus the current node refs into a pipeline input. * - * Obstacle avoidance is NOT done here -- it is a separate, budget-gated pass - * over the resolved draws (see `redrawEdgesWithHighlight`). + * All this does is pair the layout-time route and anchors with where the two + * nodes are on screen right now. Returns null when either endpoint is not + * displayed or the layout gave us nothing drawable -- the pipeline never sees + * an edge it could not route. */ -function resolveEdgeDraw( +function edgeRouteInput( index: number, edge: EdgeDatum, refs: ReadonlyMap -): ResolvedEdgeDraw | null { +): EdgeRouteInput | null { const sourceRef = refs.get(edge.source); const targetRef = refs.get(edge.target); if (!sourceRef || !targetRef || edge.originalPoints.length < 2) return null; - const sourceBox = { - x: sourceRef.containerX, - y: sourceRef.containerY, - width: sourceRef.layoutWidth, - height: sourceRef.layoutHeight, - }; - const targetBox = { - x: targetRef.containerX, - y: targetRef.containerY, - width: targetRef.layoutWidth, - height: targetRef.layoutHeight, + return { + index, + sourceId: edge.source, + targetId: edge.target, + layoutPoints: edge.originalPoints, + sourceAnchor: edge.sourceAnchor, + targetAnchor: edge.targetAnchor, + sourceBox: nodeRefToBox(sourceRef), + targetBox: nodeRefToBox(targetRef), + sourceDelta: { + x: sourceRef.containerX - sourceRef.layoutX, + y: sourceRef.containerY - sourceRef.layoutY, + }, + targetDelta: { + x: targetRef.containerX - targetRef.layoutX, + y: targetRef.containerY - targetRef.layoutY, + }, }; - const sourceDx = sourceRef.containerX - sourceRef.layoutX; - const sourceDy = sourceRef.containerY - sourceRef.layoutY; - const targetDx = targetRef.containerX - targetRef.layoutX; - const targetDy = targetRef.containerY - targetRef.layoutY; - const sourceMoved = Math.abs(sourceDx) > 1 || Math.abs(sourceDy) > 1; - const targetMoved = Math.abs(targetDx) > 1 || Math.abs(targetDy) > 1; - - let points: Point[]; - if (!sourceMoved && !targetMoved) { - const anchors = inferAnchorsFromPolyline(edge.originalPoints, sourceBox, targetBox); - points = anchorEdgePolyline( - edge.originalPoints, - sourceBox, - targetBox, - anchors.sourceAnchor, - anchors.targetAnchor - ); - } else if ( - Math.abs(sourceDx - targetDx) <= 1 && - Math.abs(sourceDy - targetDy) <= 1 - ) { - const translatedPoints = translatePolyline( - edge.originalPoints, - (sourceDx + targetDx) / 2, - (sourceDy + targetDy) / 2 - ); - const anchors = inferAnchorsFromPolyline(translatedPoints, sourceBox, targetBox); - points = anchorEdgePolyline( - translatedPoints, - sourceBox, - targetBox, - anchors.sourceAnchor, - anchors.targetAnchor - ); - } else { - const sourceAnchor = inferEdgeAnchorFromPoint(sourceBox, getBoxCenter(targetBox)); - const targetAnchor = inferEdgeAnchorFromPoint(targetBox, getBoxCenter(sourceBox)); - points = rerouteOrthogonalEdge( - edge.originalPoints, - sourceBox, - targetBox, - sourceAnchor, - targetAnchor - ); - } +} + +/** Stage 1 of the route pipeline for one edge, or null when it is undrawable. */ +function anchorEdgeRouteFor( + index: number, + edge: EdgeDatum, + refs: ReadonlyMap +): RoutedEdge | null { + const input = edgeRouteInput(index, edge, refs); + if (!input) return null; - return points.length >= 2 - ? { index, edge, points, sourceBox, targetBox } - : null; + const routed = anchorEdgeRoute(input); + return routed.points.length >= 2 ? routed : null; } /** @@ -486,7 +218,11 @@ function renderSingleEdge( } /** - * Manages all edge-related rendering using a two-layer architecture: + * Layer management and stroking for edges. Edge GEOMETRY is not decided here: + * this class collects what to draw, hands it to `layout/edgeRoutePipeline`, and + * strokes whatever comes back. + * + * Two-layer architecture: * * - **baseLayer**: contains ALL edges drawn at normal LOD-based opacity. * Rebuilt on layout change, visibility change, LOD change, or drag. @@ -598,7 +334,7 @@ export class EdgeDrawingManager { const nodeRefs = snapshotNodeRefs(currentVisibleNodes, getNodeDisplayRef); this._lastNodeRefs = nodeRefs; - const resolvedDraws: ResolvedEdgeDraw[] = []; + const routeInputs: EdgeRouteInput[] = []; for (const [idx, edge] of this.edgeData.entries()) { // Skip edges where either endpoint is not visible @@ -611,51 +347,35 @@ export class EdgeDrawingManager { continue; } - const draw = resolveEdgeDraw(idx, edge, nodeRefs); - if (!draw) continue; + const input = edgeRouteInput(idx, edge, nodeRefs); + if (!input) continue; - resolvedDraws.push(draw); + routeInputs.push(input); } - spreadEndpointLanes(resolvedDraws, true); - spreadEndpointLanes(resolvedDraws, false); - const lodOpacityMultiplier = getLODEdgeOpacity(currentLOD); - // Budget gate: how much routing this redraw can afford. Above the - // thresholds the ELK/straight polyline is drawn as-is instead of running an - // effectively unbounded detour search per edge. - const routingMode = resolveEdgeRoutingMode({ - renderedEdges: resolvedDraws.length, - visibleNodes: nodeRefs.size, + // All geometry decisions -- anchoring, lane spreading, obstacle detours, + // and the budget gate that decides how much of that runs -- belong to the + // route pipeline. This class only decides WHAT to route and then strokes + // the answer. + const { edges: routedEdges } = routeEdges(routeInputs, { + visibleNodeCount: nodeRefs.size, edgesVisible: lodOpacityMultiplier > 0, + // Indexed lazily: a redraw the budget gate downgrades to "none" never + // pays for an obstacle index it will not query. + obstacles: () => buildObstacleIndex(nodeRefs), }); - if (routesAroundObstacles(routingMode)) { - const obstacleIndex = buildObstacleIndex(nodeRefs); - const scoreCrossings = scoresEdgeCrossings(routingMode); - const routedPolylines: Point[][] = []; - - for (const draw of resolvedDraws) { - draw.points = routePolylineAroundObstacles( - draw.points, - obstaclesForEdge(obstacleIndex, draw), - scoreCrossings ? routedPolylines : NO_REFERENCE_POLYLINES - ); - if (scoreCrossings) { - routedPolylines.push(draw.points); - } - } - } - const gfx = new Graphics(); const chipSpecs: EdgeCountChipSpec[] = []; - for (const draw of resolvedDraws) { - const { edge, points } = draw; - // `points` is freshly built by the resolve/route pass and never mutated - // in place afterwards, so the highlight layer can share the same array. - this.resolvedPointsByEdgeIndex.set(draw.index, points); + for (const routed of routedEdges) { + const edge = this.edgeData[routed.index]; + const points = routed.points; + // `points` is freshly built by the route pipeline and never mutated in + // place afterwards, so the highlight layer can share the same array. + this.resolvedPointsByEdgeIndex.set(routed.index, points); const style = edge.kind ? EDGE_STYLES[edge.kind] : DEFAULT_EDGE_STYLE; const color = edge.colorInt; @@ -697,6 +417,19 @@ export class EdgeDrawingManager { } } + /** + * The polyline that was actually DRAWN for `edgeIndex`, or null when this + * edge has not been through a redraw yet (no layers built, or it was filtered + * out by visibility/LOD). + * + * Hit testing reads this rather than the layout's polyline: after lane + * spreading, obstacle detours or a node drag, the two are different lines, + * and the user can only point at the one on screen. + */ + resolvedPointsFor(edgeIndex: number): Point[] | null { + return this.resolvedPointsByEdgeIndex.get(edgeIndex) ?? null; + } + /** Keep the base edges and their count chips at the same opacity. */ private setBaseLayerAlpha(alpha: number): void { if (this.baseLayer) { @@ -761,8 +494,13 @@ export class EdgeDrawingManager { continue; } - const points = this.resolvedPointsByEdgeIndex.get(idx) ?? - resolveEdgeDraw(idx, edge, nodeRefs)?.points; + // Normally the base redraw already resolved this edge. The fallback is + // for an edge the base pass filtered out (hidden at the current LOD) that + // the highlight still wants: anchor it on its own, without the lane and + // detour stages, which are group decisions the base pass owns. + const points = + this.resolvedPointsByEdgeIndex.get(idx) ?? + anchorEdgeRouteFor(idx, edge, nodeRefs)?.points; if (!points) continue; const style = edge.kind ? EDGE_STYLES[edge.kind] : DEFAULT_EDGE_STYLE; diff --git a/packages/app/tests/edgeGeometry.test.ts b/packages/app/tests/edgeGeometry.test.ts index d619593..da4b42c 100644 --- a/packages/app/tests/edgeGeometry.test.ts +++ b/packages/app/tests/edgeGeometry.test.ts @@ -3,8 +3,8 @@ import test from "node:test"; import { anchorEdgePolyline, + edgeAnchorAtBoundary, getAnchorPoint, - inferEdgeAnchor, inferEdgeAnchorFromPoint, rerouteOrthogonalEdge, routePolylineAroundObstacles, @@ -162,7 +162,7 @@ test("rerouteOrthogonalEdge detours same-side vertical anchors on the same colum }); test("anchorEdgePolyline reroutes when endpoint anchoring would approach from the wrong side", () => { - const points = anchorEdgePolyline( + const anchored = anchorEdgePolyline( [ { x: 0, y: 20 }, { x: -80, y: 20 }, @@ -174,13 +174,15 @@ test("anchorEdgePolyline reroutes when endpoint anchoring would approach from th anchor("left") ); - assertLeavesFromSide(points, "left"); - assertApproachesFromSide(points, "left"); - assert.ok(points.some((point) => point.y !== 20)); + // The escalation is reported, not silent: this route was DISCARDED. + assert.equal(anchored.rerouted, true); + assertLeavesFromSide(anchored.points, "left"); + assertApproachesFromSide(anchored.points, "left"); + assert.ok(anchored.points.some((point) => point.y !== 20)); }); test("anchorEdgePolyline erases loops from stale bends after a node crosses them", () => { - const points = anchorEdgePolyline( + const anchored = anchorEdgePolyline( [ { x: 40, y: 20 }, { x: 160, y: 20 }, @@ -195,7 +197,9 @@ test("anchorEdgePolyline erases loops from stale bends after a node crosses them { side: "left", offset: 20 } ); - assert.deepEqual(points, [ + // Salvaged in place, so no reroute was needed. + assert.equal(anchored.rerouted, false); + assert.deepEqual(anchored.points, [ { x: 40, y: 20 }, { x: 200, y: 20 }, ]); @@ -301,10 +305,13 @@ test("routePolylineAroundObstacles converts diagonal fallback segments before av assertPolylineAvoidsBox(points, obstacle); }); -test("inferEdgeAnchor prefers top or bottom for vertical approaches at a side boundary", () => { +// Ported from the deleted tolerance-based `inferEdgeAnchor`: the assertions are +// unchanged, because the exact rule ("an orthogonal edge leaves through the side +// its first segment points at") answers these cases the same way. +test("edgeAnchorAtBoundary prefers top or bottom for vertical departures at a side boundary", () => { const box: NodeBox = { x: 100, y: 100, width: 80, height: 40 }; - const fromAbove = inferEdgeAnchor( + const fromAbove = edgeAnchorAtBoundary( box, { x: 100, y: 120 }, { x: 100, y: 40 } @@ -312,7 +319,7 @@ test("inferEdgeAnchor prefers top or bottom for vertical approaches at a side bo assert.equal(fromAbove.side, "top"); assert.deepEqual(getAnchorPoint(box, fromAbove), { x: 100, y: 100 }); - const fromBelow = inferEdgeAnchor( + const fromBelow = edgeAnchorAtBoundary( box, { x: 100, y: 120 }, { x: 100, y: 180 } @@ -321,10 +328,10 @@ test("inferEdgeAnchor prefers top or bottom for vertical approaches at a side bo assert.deepEqual(getAnchorPoint(box, fromBelow), { x: 100, y: 140 }); }); -test("inferEdgeAnchor keeps side anchors for horizontal approaches", () => { +test("edgeAnchorAtBoundary keeps side anchors for horizontal departures", () => { const box: NodeBox = { x: 100, y: 100, width: 80, height: 40 }; - const fromLeft = inferEdgeAnchor( + const fromLeft = edgeAnchorAtBoundary( box, { x: 100, y: 120 }, { x: 40, y: 120 } @@ -332,7 +339,7 @@ test("inferEdgeAnchor keeps side anchors for horizontal approaches", () => { assert.equal(fromLeft.side, "left"); assert.deepEqual(getAnchorPoint(box, fromLeft), { x: 100, y: 120 }); - const fromRight = inferEdgeAnchor( + const fromRight = edgeAnchorAtBoundary( box, { x: 180, y: 120 }, { x: 240, y: 120 } @@ -341,6 +348,31 @@ test("inferEdgeAnchor keeps side anchors for horizontal approaches", () => { assert.deepEqual(getAnchorPoint(box, fromRight), { x: 180, y: 120 }); }); +test("edgeAnchorAtBoundary keeps the side a diagonal departure sits on", () => { + const box: NodeBox = { x: 100, y: 100, width: 80, height: 40 }; + + // A straight-line connector leaves the right edge heading down-right: no + // orthogonal departure to read, but the point unambiguously sits on "right". + const diagonal = edgeAnchorAtBoundary(box, { x: 180, y: 120 }, { x: 400, y: 300 }); + assert.equal(diagonal.side, "right"); + assert.deepEqual(getAnchorPoint(box, diagonal), { x: 180, y: 120 }); +}); + +test("edgeAnchorAtBoundary falls back to the dominant axis for a centre-to-centre polyline", () => { + const box: NodeBox = { x: 100, y: 100, width: 80, height: 40 }; + const center = { x: 140, y: 120 }; + + // The connector ELK's section-less fallback produces starts at the node + // CENTRE, which lies on no side at all. + const toTheRight = edgeAnchorAtBoundary(box, center, { x: 600, y: 140 }); + assert.equal(toTheRight.side, "right"); + assert.deepEqual(getAnchorPoint(box, toTheRight), { x: 180, y: 120 }); + + const below = edgeAnchorAtBoundary(box, center, { x: 150, y: 600 }); + assert.equal(below.side, "bottom"); + assert.deepEqual(getAnchorPoint(box, below), { x: 140, y: 140 }); +}); + test("inferEdgeAnchorFromPoint chooses vertical anchors when another node is above or below", () => { const box: NodeBox = { x: 100, y: 100, width: 80, height: 40 }; diff --git a/packages/app/tests/edgeRoutePipeline.test.ts b/packages/app/tests/edgeRoutePipeline.test.ts new file mode 100644 index 0000000..1193af6 --- /dev/null +++ b/packages/app/tests/edgeRoutePipeline.test.ts @@ -0,0 +1,418 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + anchorEndpoints, + detourAroundObstacles, + laneOffset, + routeEdges, + spreadEndpointLanes, + type EdgeRouteContext, + type EdgeRouteEnv, + type EdgeRouteInput, + type RoutedEdge, +} from "../src/canvas/layout/edgeRoutePipeline.ts"; +import { resolveEdgeRoutingMode } from "../src/canvas/layout/edgeRoutingBudget.ts"; +import { + getAnchorPoint, + type EdgeAnchor, + type NodeBox, + type Point, +} from "../src/canvas/layout/edgeGeometry.ts"; +import { + ObstacleIndex, + obstacleEntry, +} from "../src/canvas/layout/obstacleIndex.ts"; + +/** + * The draw-time route pipeline: stage order, the anchor contract each stage has + * to keep, and the budget gate that decides how much of it runs. + */ + +// --------------------------------------------------------------------------- +// Fixture: a hub node with two outgoing edges and one obstacle in the way, so +// all three stages have something to do. +// --------------------------------------------------------------------------- + +const HUB: NodeBox = { x: 0, y: 0, width: 80, height: 40 }; +const RIGHT_NEAR: NodeBox = { x: 400, y: 0, width: 80, height: 40 }; +const RIGHT_FAR: NodeBox = { x: 400, y: 120, width: 80, height: 40 }; +const BLOCKER: NodeBox = { x: 200, y: 0, width: 80, height: 40 }; + +const NO_MOVE: Point = { x: 0, y: 0 }; + +function rightAnchor(offset = 20): EdgeAnchor { + return { side: "right", offset }; +} + +function leftAnchor(offset = 20): EdgeAnchor { + return { side: "left", offset }; +} + +function hubEdges(): EdgeRouteInput[] { + return [ + { + index: 0, + sourceId: "hub", + targetId: "near", + layoutPoints: [ + { x: 80, y: 20 }, + { x: 400, y: 20 }, + ], + sourceAnchor: rightAnchor(), + targetAnchor: leftAnchor(), + sourceBox: HUB, + targetBox: RIGHT_NEAR, + sourceDelta: NO_MOVE, + targetDelta: NO_MOVE, + }, + { + index: 1, + sourceId: "hub", + targetId: "far", + layoutPoints: [ + { x: 80, y: 20 }, + { x: 340, y: 20 }, + { x: 340, y: 140 }, + { x: 400, y: 140 }, + ], + sourceAnchor: rightAnchor(), + targetAnchor: leftAnchor(), + sourceBox: HUB, + targetBox: RIGHT_FAR, + sourceDelta: NO_MOVE, + targetDelta: NO_MOVE, + }, + ]; +} + +function obstacleEnv(): EdgeRouteEnv { + return { + visibleNodeCount: 4, + edgesVisible: true, + obstacles: () => new ObstacleIndex([obstacleEntry("blocker", BLOCKER)]), + }; +} + +function contextFor(env: EdgeRouteEnv, renderedEdges: number): EdgeRouteContext { + return { + ...env, + mode: resolveEdgeRoutingMode({ + renderedEdges, + visibleNodes: env.visibleNodeCount, + edgesVisible: env.edgesVisible, + }), + }; +} + +function endpointsOf(edge: RoutedEdge): { start: Point; end: Point } { + return { + start: edge.points[0], + end: edge.points[edge.points.length - 1], + }; +} + +/** Every edge the pipeline emits must satisfy the drawing pass's contract. */ +function assertSeamContract(edges: readonly RoutedEdge[]): void { + for (const edge of edges) { + assert.ok(edge.points.length >= 2, "every edge must be drawable"); + const { start, end } = endpointsOf(edge); + assert.deepEqual( + start, + getAnchorPoint(edge.sourceBox, edge.sourceAnchor), + "start point must sit on the source anchor" + ); + assert.deepEqual( + end, + getAnchorPoint(edge.targetBox, edge.targetAnchor), + "end point must sit on the target anchor" + ); + } +} + +// --------------------------------------------------------------------------- +// Stage order and hand-off +// --------------------------------------------------------------------------- + +test("routeEdges is exactly anchor -> spread lanes -> detour, in that order", () => { + const env = obstacleEnv(); + const inputs = hubEdges(); + + const anchored = anchorEndpoints(inputs, env); + const ctx = contextFor(env, anchored.length); + const spread = spreadEndpointLanes(anchored, ctx); + const detoured = detourAroundObstacles(spread, ctx); + + const composed = routeEdges(hubEdges(), obstacleEnv()); + + assert.deepEqual(composed.edges, detoured); + assert.equal(composed.mode, ctx.mode); +}); + +test("each stage's output is what the next stage sees", () => { + const env = obstacleEnv(); + const anchored = anchorEndpoints(hubEdges(), env); + const ctx = contextFor(env, anchored.length); + const spread = spreadEndpointLanes(anchored, ctx); + const detoured = detourAroundObstacles(spread, ctx); + + // Stage 1 anchored both edges on the hub's single right-side anchor point... + assert.deepEqual(anchored[0].points[0], { x: 80, y: 20 }); + assert.deepEqual(anchored[1].points[0], { x: 80, y: 20 }); + + // ...stage 2 moved them apart, so it acted on stage 1's geometry... + assert.notDeepEqual(spread[0].points[0], anchored[0].points[0]); + assert.notDeepEqual(spread[1].points[0], anchored[1].points[0]); + + // ...and stage 3 detoured the lane-spread route, not the anchored one: the + // start point it kept is the one stage 2 produced. + assert.deepEqual(detoured[0].points[0], spread[0].points[0]); + assert.ok( + detoured[0].points.length > spread[0].points.length, + "the blocker should have forced extra bends" + ); +}); + +test("stages return new records instead of mutating their input", () => { + const inputs = hubEdges(); + const layoutPointsBefore = inputs.map((input) => + input.layoutPoints.map((p) => ({ ...p })) + ); + const anchorsBefore = inputs.map((input) => ({ + source: { ...input.sourceAnchor }, + target: { ...input.targetAnchor }, + })); + + const env = obstacleEnv(); + const anchored = anchorEndpoints(inputs, env); + const ctx = contextFor(env, anchored.length); + const anchoredSnapshot = anchored.map((edge) => ({ + points: edge.points.map((p) => ({ ...p })), + sourceAnchor: { ...edge.sourceAnchor }, + })); + + const spread = spreadEndpointLanes(anchored, ctx); + detourAroundObstacles(spread, ctx); + + for (let i = 0; i < inputs.length; i++) { + assert.deepEqual(inputs[i].layoutPoints, layoutPointsBefore[i]); + assert.deepEqual(inputs[i].sourceAnchor, anchorsBefore[i].source); + assert.deepEqual(inputs[i].targetAnchor, anchorsBefore[i].target); + assert.deepEqual(anchored[i].points, anchoredSnapshot[i].points); + assert.deepEqual(anchored[i].sourceAnchor, anchoredSnapshot[i].sourceAnchor); + } +}); + +// --------------------------------------------------------------------------- +// The anchor contract +// --------------------------------------------------------------------------- + +test("stored anchors survive a redraw of unmoved nodes untouched", () => { + const [edge] = hubEdges(); + const routed = routeEdges([edge], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + const single = routed.edges[0]; + assert.equal(single.origin, "anchored"); + // No stage re-derived them from the polyline: they are the same anchors the + // layout phase decided, carried through byte for byte. + assert.deepEqual(single.sourceAnchor, edge.sourceAnchor); + assert.deepEqual(single.targetAnchor, edge.targetAnchor); + assertSeamContract(routed.edges); +}); + +test("lane spreading moves an endpoint AND its anchor together", () => { + const routed = routeEdges(hubEdges(), { + visibleNodeCount: 3, + edgesVisible: true, + obstacles: () => null, + }); + + const near = routed.edges.find((edge) => edge.index === 0)!; + const far = routed.edges.find((edge) => edge.index === 1)!; + + // Both still leave the hub's right side, but on their own lanes... + assert.equal(near.sourceAnchor.side, "right"); + assert.equal(far.sourceAnchor.side, "right"); + assert.notEqual(near.sourceAnchor.offset, far.sourceAnchor.offset); + + // ...at exactly the offsets the lane model prescribes, ordered by where the + // opposite endpoint sits. + assert.equal(near.sourceAnchor.offset, laneOffset(HUB, "right", 0, 2)); + assert.equal(far.sourceAnchor.offset, laneOffset(HUB, "right", 1, 2)); + + // The far ends land on DIFFERENT nodes, so each is a lane of one. `far` has + // interior bends to absorb the source move and keeps its layout anchor... + assert.deepEqual(far.targetAnchor, leftAnchor()); + + // ...while `near` is a straight two-point run, where moving the source lane + // necessarily drags the far end's row with it. The anchor FOLLOWS that move + // rather than going stale, which is the invariant under test. + assert.equal(near.targetAnchor.side, "left"); + assert.equal(near.targetAnchor.offset, near.sourceAnchor.offset); + + // ...and geometry and anchors still agree, which is the whole contract. + assertSeamContract(routed.edges); +}); + +test("nodes dragged apart get freshly DECIDED anchors, not re-derived ones", () => { + const [edge] = hubEdges(); + // The target has been dragged far below its laid-out position; the anchor the + // layout picked ("left") describes geometry that no longer exists. + const dragged: EdgeRouteInput = { + ...edge, + targetBox: { x: 20, y: 400, width: 80, height: 40 }, + targetDelta: { x: -380, y: 400 }, + }; + + const routed = routeEdges([dragged], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + const result = routed.edges[0]; + assert.equal(result.origin, "reanchored"); + assert.equal(result.sourceAnchor.side, "bottom"); + assert.equal(result.targetAnchor.side, "top"); + assertSeamContract(routed.edges); +}); + +test("a container drag that moves both endpoints keeps the stored anchors", () => { + const [edge] = hubEdges(); + const delta = { x: 60, y: 25 }; + const shifted: EdgeRouteInput = { + ...edge, + sourceBox: { ...HUB, x: HUB.x + delta.x, y: HUB.y + delta.y }, + targetBox: { ...RIGHT_NEAR, x: RIGHT_NEAR.x + delta.x, y: RIGHT_NEAR.y + delta.y }, + sourceDelta: delta, + targetDelta: delta, + }; + + const routed = routeEdges([shifted], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + const result = routed.edges[0]; + assert.equal(result.origin, "anchored"); + // Anchors are offsets INTO a box, so a box that only moved keeps them. + assert.deepEqual(result.sourceAnchor, edge.sourceAnchor); + assert.deepEqual(result.targetAnchor, edge.targetAnchor); + assert.deepEqual(result.points[0], { x: 140, y: 45 }); + assertSeamContract(routed.edges); +}); + +// --------------------------------------------------------------------------- +// The anchored-vs-rerouted decision +// --------------------------------------------------------------------------- + +test("an unsalvageable route is reported as rerouted, not silently replaced", () => { + // The stored route leaves the hub's right side heading LEFT, which its anchor + // forbids: anchoring cannot nudge this into shape. + const broken: EdgeRouteInput = { + index: 0, + sourceId: "hub", + targetId: "near", + layoutPoints: [ + { x: 80, y: 20 }, + { x: -60, y: 20 }, + { x: 400, y: 20 }, + ], + sourceAnchor: rightAnchor(), + targetAnchor: { side: "right", offset: 20 }, + sourceBox: HUB, + targetBox: RIGHT_NEAR, + sourceDelta: NO_MOVE, + targetDelta: NO_MOVE, + }; + + const routed = routeEdges([broken], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + assert.equal(routed.edges[0].origin, "rerouted"); + assertSeamContract(routed.edges); +}); + +test("a salvageable route keeps the layout's geometry and says so", () => { + const [edge] = hubEdges(); + const routed = routeEdges([edge], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + assert.equal(routed.edges[0].origin, "anchored"); + assert.deepEqual(routed.edges[0].points, edge.layoutPoints); +}); + +// --------------------------------------------------------------------------- +// The budget gate +// --------------------------------------------------------------------------- + +test("the detour stage is the only budget-gated one", () => { + const env = obstacleEnv(); + const anchored = anchorEndpoints(hubEdges(), env); + const spread = spreadEndpointLanes(anchored, { ...env, mode: "none" }); + + // Lanes are spread regardless of the mode... + assert.notDeepEqual(spread[0].points[0], anchored[0].points[0]); + + // ...but "none" leaves the geometry exactly where stage 2 left it. + const skipped = detourAroundObstacles(spread, { ...env, mode: "none" }); + assert.deepEqual(skipped, spread); +}); + +test("an invisible-edge LOD never pays for the obstacle index", () => { + let indexBuilds = 0; + const routed = routeEdges(hubEdges(), { + visibleNodeCount: 4, + edgesVisible: false, + obstacles: () => { + indexBuilds++; + return new ObstacleIndex([obstacleEntry("blocker", BLOCKER)]); + }, + }); + + assert.equal(routed.mode, "none"); + assert.equal(indexBuilds, 0, "the gate must decide before the index is built"); +}); + +test("a routed redraw detours around the obstacles it is given", () => { + const routed = routeEdges(hubEdges(), obstacleEnv()); + + assert.equal(routed.mode, "full"); + const near = routed.edges.find((edge) => edge.index === 0)!; + assert.ok( + near.points.some( + (point) => point.y < BLOCKER.y || point.y > BLOCKER.y + BLOCKER.height + ), + `expected a detour clear of the blocker: ${JSON.stringify(near.points)}` + ); + // Detours bend the middle; the endpoints stay on the anchors stage 2 settled. + assertSeamContract(routed.edges); +}); + +test("an edge with no drawable layout route is dropped before the gate counts", () => { + const degenerate: EdgeRouteInput = { + ...hubEdges()[0], + index: 7, + layoutPoints: [{ x: 80, y: 20 }], + }; + + const routed = routeEdges([degenerate], { + visibleNodeCount: 2, + edgesVisible: true, + obstacles: () => null, + }); + + assert.equal(routed.edges.length, 0); + assert.equal(routed.mode, "none", "no rendered edges means no routing to budget"); +}); diff --git a/packages/app/tests/edgeRoutingBudget.test.ts b/packages/app/tests/edgeRoutingBudget.test.ts index 0d08530..e3911a6 100644 --- a/packages/app/tests/edgeRoutingBudget.test.ts +++ b/packages/app/tests/edgeRoutingBudget.test.ts @@ -12,7 +12,7 @@ import { scoresEdgeCrossings, shouldSkipLayoutEdgeRouting, type EdgeRoutingMode, -} from "../src/canvas/renderers/edgeRoutingBudget.ts"; +} from "../src/canvas/layout/edgeRoutingBudget.ts"; /** Cheapest first; used to assert the mode never gets MORE expensive as work grows. */ const MODE_COST: Record = { diff --git a/packages/app/tests/elkExtract.test.ts b/packages/app/tests/elkExtract.test.ts new file mode 100644 index 0000000..758c5cb --- /dev/null +++ b/packages/app/tests/elkExtract.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ElkNode } from "elkjs/lib/elk-api"; +import { + extractNodePositions, + extractRoutedEdges, +} from "../src/canvas/layout/elkExtract.ts"; +import { routeEdges } from "../src/canvas/layout/edgeRoutePipeline.ts"; +import { getAnchorPoint } from "../src/canvas/layout/edgeGeometry.ts"; +import type { ViewEdge } from "../src/canvas/layout/viewEdges"; + +/** + * The pure half of ELK extraction, and the anchor round trip it opens: an + * anchor is decided ONCE here, from pristine geometry, and every later stage + * consumes it rather than reading it back off a polyline. + */ + +const VIEW_EDGES: ViewEdge[] = [ + { + source: "a", + target: "b", + color: "#ff0000", + kind: "Import", + count: 3, + resolution: null, + }, + { + source: "a", + target: "c", + color: "#00ff00", + kind: "FunctionCall", + count: 1, + resolution: "SameFile", + }, +]; + +/** Two side-by-side nodes plus one below, with ELK-shaped routed sections. */ +function elkTree(): ElkNode { + return { + id: "root", + x: 0, + y: 0, + children: [ + { id: "a", x: 0, y: 0, width: 80, height: 40 }, + { id: "b", x: 200, y: 0, width: 80, height: 40 }, + { id: "c", x: 0, y: 200, width: 80, height: 40 }, + ], + edges: [ + { + id: "edge-0", + sources: ["a"], + targets: ["b"], + sections: [ + { + id: "s0", + startPoint: { x: 80, y: 20 }, + endPoint: { x: 200, y: 20 }, + }, + ], + }, + { + id: "edge-1", + sources: ["a"], + targets: ["c"], + sections: [ + { + id: "s1", + startPoint: { x: 40, y: 40 }, + bendPoints: [{ x: 40, y: 120 }], + endPoint: { x: 40, y: 200 }, + }, + ], + }, + ], + }; +} + +test("extractNodePositions accumulates nested container offsets and skips root", () => { + const nested: ElkNode = { + id: "root", + x: 0, + y: 0, + children: [ + { + id: "dir", + x: 10, + y: 20, + width: 300, + height: 200, + children: [{ id: "file", x: 15, y: 30, width: 80, height: 40 }], + }, + ], + }; + + const positions = extractNodePositions(nested); + + assert.equal(positions["root"], undefined); + assert.deepEqual(positions["dir"], { x: 10, y: 20, width: 300, height: 200 }); + assert.deepEqual(positions["file"], { x: 25, y: 50, width: 80, height: 40 }); +}); + +test("extractNodePositions defaults a node ELK left unsized", () => { + const positions = extractNodePositions({ + id: "root", + children: [{ id: "bare" }], + }); + + assert.deepEqual(positions["bare"], { x: 0, y: 0, width: 100, height: 40 }); +}); + +test("extraction decides each anchor from the route's own first segment", () => { + const positions = extractNodePositions(elkTree()); + const { edges, stats } = extractRoutedEdges(elkTree(), positions, VIEW_EDGES); + + assert.equal(stats.totalEdgesFound, 2); + assert.equal(stats.edgesWithSections, 2); + assert.equal(stats.edgesWithoutSections, 0); + assert.equal(edges.length, 2); + + // a -> b leaves a's right edge heading right and enters b's left edge. + assert.deepEqual(edges[0].sourceAnchor, { side: "right", offset: 20 }); + assert.deepEqual(edges[0].targetAnchor, { side: "left", offset: 20 }); + + // a -> c drops out of a's bottom edge and enters c's top edge. + assert.deepEqual(edges[1].sourceAnchor, { side: "bottom", offset: 40 }); + assert.deepEqual(edges[1].targetAnchor, { side: "top", offset: 40 }); +}); + +test("an extracted edge's endpoints sit exactly on its anchors", () => { + const positions = extractNodePositions(elkTree()); + const { edges } = extractRoutedEdges(elkTree(), positions, VIEW_EDGES); + + for (const edge of edges) { + assert.ok(edge.points.length >= 2); + assert.deepEqual( + edge.points[0], + getAnchorPoint(positions[edge.source], edge.sourceAnchor) + ); + assert.deepEqual( + edge.points[edge.points.length - 1], + getAnchorPoint(positions[edge.target], edge.targetAnchor) + ); + } +}); + +test("an ELK edge is styled from the view edge its ID encodes, not its endpoints", () => { + const positions = extractNodePositions(elkTree()); + const { edges } = extractRoutedEdges(elkTree(), positions, VIEW_EDGES); + + assert.equal(edges[0].color, "#ff0000"); + assert.equal(edges[0].kind, "Import"); + assert.equal(edges[0].count, 3); + assert.equal(edges[1].kind, "FunctionCall"); + assert.equal(edges[1].resolution, "SameFile"); +}); + +test("an ELK edge with no sections falls back to a centre-to-centre connector", () => { + const tree: ElkNode = { + id: "root", + children: [ + { id: "a", x: 0, y: 0, width: 80, height: 40 }, + { id: "b", x: 400, y: 0, width: 80, height: 40 }, + ], + edges: [{ id: "edge-0", sources: ["a"], targets: ["b"] }], + }; + + const positions = extractNodePositions(tree); + const { edges, stats } = extractRoutedEdges(tree, positions, VIEW_EDGES); + + assert.equal(stats.edgesWithoutSections, 1); + assert.equal(edges.length, 1); + // The centres lie on no side at all, so the anchors come from the dominant + // axis of the direction the connector heads in. + assert.deepEqual(edges[0].sourceAnchor, { side: "right", offset: 20 }); + assert.deepEqual(edges[0].targetAnchor, { side: "left", offset: 20 }); +}); + +test("an anchor decided at extract time is what the draw-time pipeline consumes", () => { + const positions = extractNodePositions(elkTree()); + const { edges } = extractRoutedEdges(elkTree(), positions, VIEW_EDGES); + + // Exactly what EdgeDrawingManager hands the pipeline for an undisturbed view: + // the layout's points and anchors, against the same boxes. + const routed = routeEdges( + edges.map((edge, index) => ({ + index, + sourceId: edge.source, + targetId: edge.target, + layoutPoints: edge.points, + sourceAnchor: edge.sourceAnchor, + targetAnchor: edge.targetAnchor, + sourceBox: positions[edge.source], + targetBox: positions[edge.target], + sourceDelta: { x: 0, y: 0 }, + targetDelta: { x: 0, y: 0 }, + })), + { visibleNodeCount: 3, edgesVisible: true, obstacles: () => null } + ); + + assert.equal(routed.edges.length, edges.length); + for (const drawn of routed.edges) { + const layoutEdge = edges[drawn.index]; + assert.equal(drawn.origin, "anchored", "a settled view should need no reroute"); + assert.deepEqual(drawn.sourceAnchor, layoutEdge.sourceAnchor); + assert.deepEqual(drawn.targetAnchor, layoutEdge.targetAnchor); + assert.deepEqual(drawn.points, layoutEdge.points); + } +}); diff --git a/packages/app/tests/layoutOrchestrator.test.ts b/packages/app/tests/layoutOrchestrator.test.ts new file mode 100644 index 0000000..f86c204 --- /dev/null +++ b/packages/app/tests/layoutOrchestrator.test.ts @@ -0,0 +1,493 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + LayoutOrchestrator, + type LayoutOrchestratorEffects, +} from "../src/canvas/layout/layoutOrchestrator.ts"; +import type { + EdgeLayoutRequest, + FullLayoutRequest, +} from "../src/canvas/layout/layoutRequest.ts"; +import type { LayoutResult } from "../src/canvas/layout/layoutTypes.ts"; +import type { CodeGraph, EdgeKind } from "../src/api/types.ts"; + +/** Let every queued microtask (and timer callback) settle. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +/** A deferred promise, so tests control exactly when a "layout" finishes. */ +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // The orchestrator observes the rejection itself; this only stops node from + // flagging it as unhandled in the tick before it does. + promise.catch(() => {}); + return { promise, resolve, reject }; +} + +function graphNamed(name: string): CodeGraph { + return { + root: name, + edgeCount: 0, + nodeEdgeKinds: new Map(), + nodes: { + [name]: { type: "Directory", id: name, name, path: "", children: [] }, + }, + }; +} + +/** + * Which layout a published `edgeKindCounts` object came from. The counts are + * opaque to the orchestrator, so the fake identifies them by their owner. + */ +const countsOwner = new WeakMap(); + +function layoutNamed(name: string): LayoutResult { + const edgeKindCounts: LayoutResult["edgeKindCounts"] = { + Import: null, + FunctionCall: null, + MethodCall: null, + TypeReference: null, + Inheritance: null, + TraitImpl: null, + VariableUsage: null, + }; + countsOwner.set(edgeKindCounts, name); + return { nodes: {}, edges: [], edgeKindCounts, renderIds: [name] }; +} + +/** A set label, so the event log reads as the inputs the test supplied. */ +function label(nodes: Set): string { + return Array.from(nodes).sort().join("|") || "-"; +} + +function kindLabel(kinds: Set): string { + return Array.from(kinds).sort().join("|") || "-"; +} + +interface Harness { + orchestrator: LayoutOrchestrator; + /** Ordered log of every effect the orchestrator drove. */ + events: string[]; + fullRequests: FullLayoutRequest[]; + /** The edge-phase requests that were actually APPLIED. */ + appliedEdgeRequests: EdgeLayoutRequest[]; + /** The `previous` layout each edge phase was handed. */ + edgePrevious: LayoutResult[]; + fullGates: Deferred[]; + edgeGates: Deferred[]; + /** Names of the layouts whose counts reached the legend. */ + published: string[]; + errors: unknown[]; + setReady(ready: boolean): void; +} + +function harness(): Harness { + const events: string[] = []; + const fullRequests: FullLayoutRequest[] = []; + const appliedEdgeRequests: EdgeLayoutRequest[] = []; + const edgePrevious: LayoutResult[] = []; + const fullGates: Deferred[] = []; + const edgeGates: Deferred[] = []; + const published: string[] = []; + const errors: unknown[] = []; + let ready = true; + + const effects: LayoutOrchestratorEffects = { + waitUntilReady: async () => ready, + runFullLayout: (request) => { + fullRequests.push(request); + events.push( + `run:full(${request.graph.root},vis=${label(request.visibleNodes)},kinds=${kindLabel(request.enabledEdgeKinds)},amb=${request.hideAmbiguousEdges})` + ); + const gate = deferred(); + fullGates.push(gate); + return gate.promise; + }, + runEdgePhase: (previous, enabledEdgeKinds, hideAmbiguousEdges) => { + edgePrevious.push(previous); + events.push( + `run:edges(prev=${previous.renderIds.join(",")},kinds=${kindLabel(enabledEdgeKinds)},amb=${hideAmbiguousEdges})` + ); + const gate = deferred(); + edgeGates.push(gate); + return gate.promise; + }, + adoptFullLayoutInputs: (request) => { + events.push(`adopt(${request.graph.root})`); + }, + applyFullLayout: (layout, request) => { + events.push( + `apply:full(${layout.renderIds.join(",")},vis=${label(request.visibleNodes)})` + ); + }, + applyEdgeLayout: (layout, request) => { + appliedEdgeRequests.push(request); + events.push(`apply:edges(${layout.renderIds.join(",")})`); + }, + applyVisibleNodes: (visibleNodes) => { + events.push(`apply:visible(${label(visibleNodes)})`); + }, + redrawEdges: () => { + events.push("redraw"); + }, + publishEdgeKindCounts: (counts) => { + published.push(countsOwner.get(counts) ?? "?"); + events.push("publish"); + }, + reportError: (error) => { + errors.push(error); + events.push("error"); + }, + }; + + return { + orchestrator: new LayoutOrchestrator(effects), + events, + fullRequests, + appliedEdgeRequests, + edgePrevious, + fullGates, + edgeGates, + published, + errors, + setReady: (next: boolean) => { + ready = next; + }, + }; +} + +const ALL_NODES = new Set(["a", "b", "c"]); + +function requestFull( + h: Harness, + graphName: string, + visible: Set, + kinds: EdgeKind[] = ["Import"], + hideAmbiguous = false +): void { + h.orchestrator.requestFullLayout( + graphNamed(graphName), + new Set(), + visible, + new Set(kinds), + hideAmbiguous + ); +} + +describe("LayoutOrchestrator coalescing", () => { + it("collapses a burst of full layouts into ONE rerun with the newest inputs", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + assert.deepEqual(h.fullRequests.map((r) => r.graph.root), ["g1"]); + + // Three more arrive while g1 is still solving. + requestFull(h, "g2", ALL_NODES); + requestFull(h, "g3", ALL_NODES); + requestFull(h, "g4", ALL_NODES); + await flush(); + assert.deepEqual( + h.fullRequests.map((r) => r.graph.root), + ["g1"], + "queued requests must not run in parallel" + ); + + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + // g2 and g3 were coalesced away; only the newest inputs reran. + assert.deepEqual(h.fullRequests.map((r) => r.graph.root), ["g1", "g4"]); + }); + + it("folds a queued edge phase into the queued full layout, keeping the newest filters", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES, ["Import"]); + await flush(); + + requestFull(h, "g2", ALL_NODES, ["Import"], false); + h.orchestrator.requestEdgePhase( + new Set(["Import", "TypeReference"]), + true + ); + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + assert.equal(h.fullRequests.length, 2); + const second = h.fullRequests[1]!; + assert.equal(second.graph.root, "g2", "the full layout dominates"); + assert.deepEqual( + Array.from(second.enabledEdgeKinds).sort(), + ["Import", "TypeReference"], + "but adopts the newer edge filters" + ); + assert.equal(second.hideAmbiguousEdges, true); + assert.equal(h.edgePrevious.length, 0, "the edge phase never ran on its own"); + }); + + it("keeps only the newest filters across a burst of edge phases", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + h.orchestrator.requestEdgePhase(new Set(["Import"]), false); + await flush(); + h.orchestrator.requestEdgePhase(new Set(["MethodCall"]), false); + h.orchestrator.requestEdgePhase(new Set(["Inheritance"]), true); + h.edgeGates[0]!.resolve(layoutNamed("L2")); + await flush(); + + assert.equal(h.edgePrevious.length, 2); + assert.ok( + h.events.includes("run:edges(prev=L2,kinds=Inheritance,amb=true)"), + `expected the newest filters to rerun, got ${h.events.join(" ")}` + ); + }); +}); + +describe("LayoutOrchestrator edges phase", () => { + it("is a no-op before any full layout has produced positions", async () => { + const h = harness(); + + h.orchestrator.requestEdgePhase(new Set(["Import"]), false); + await flush(); + + assert.deepEqual(h.edgePrevious, []); + assert.deepEqual(h.events, [], "nothing to run, nothing to publish"); + assert.equal(h.orchestrator.lastLayout, null); + }); + + it("reuses the previous layout's render set, then adopts its own result", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + const first = layoutNamed("L1"); + h.fullGates[0]!.resolve(first); + await flush(); + assert.equal(h.orchestrator.lastLayout, first); + + h.orchestrator.requestEdgePhase(new Set(["MethodCall"]), true); + await flush(); + assert.deepEqual(h.edgePrevious, [first], "the edge phase runs against L1"); + + const second = layoutNamed("L2"); + h.edgeGates[0]!.resolve(second); + await flush(); + + assert.equal(h.orchestrator.lastLayout, second); + assert.deepEqual(h.published, ["L1", "L2"]); + assert.deepEqual( + h.appliedEdgeRequests.map((r) => r.hideAmbiguousEdges), + [true] + ); + + // A second edge phase reuses the result of the first. + h.orchestrator.requestEdgePhase(new Set(["Import"]), false); + await flush(); + assert.deepEqual(h.edgePrevious, [first, second]); + }); +}); + +describe("LayoutOrchestrator visibility reconciliation", () => { + it("applies a fresh layout with exactly one edge rebuild and no late re-apply", async () => { + const h = harness(); + const visible = new Set(["a", "b"]); + + requestFull(h, "g1", visible); + await flush(); + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + assert.deepEqual(h.events, [ + "adopt(g1)", + "run:full(g1,vis=a|b,kinds=Import,amb=false)", + "publish", + "apply:full(L1,vis=a|b)", + ]); + assert.equal( + h.events.filter((e) => e === "redraw").length, + 0, + "applying a layout must not trigger an extra edge redraw" + ); + assert.equal(h.orchestrator.visibleNodes, visible); + assert.equal(h.orchestrator.isLayoutPending, false); + }); + + it("re-applies a visibility change that landed while the layout was running", async () => { + const h = harness(); + const laidOut = new Set(["a", "b", "c"]); + const narrowed = new Set(["a"]); + + requestFull(h, "g1", laidOut); + await flush(); + + // The user hides two nodes while ELK is still solving. + h.orchestrator.setVisibleNodes(narrowed); + assert.ok( + h.events.includes("apply:visible(a)"), + "the display flip is immediate" + ); + assert.equal( + h.events.filter((e) => e === "redraw").length, + 0, + "but the edge redraw is gated while the layout is in flight" + ); + + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + assert.deepEqual(h.events, [ + "adopt(g1)", + "run:full(g1,vis=a|b|c,kinds=Import,amb=false)", + "apply:visible(a)", + "publish", + "apply:full(L1,vis=a|b|c)", + // The gate is cleared BEFORE this re-apply, so its redraw is not gated. + "apply:visible(a)", + "redraw", + ]); + assert.equal( + h.orchestrator.visibleNodes, + narrowed, + "a slow layout must not resurrect nodes hidden meanwhile" + ); + }); + + it("redraws edges immediately for a visibility change with no layout in flight", () => { + const h = harness(); + const first = new Set(["a"]); + + h.orchestrator.setVisibleNodes(first); + assert.deepEqual(h.events, ["apply:visible(a)", "redraw"]); + + // The same set identity is a no-op: nothing changed on screen. + h.orchestrator.setVisibleNodes(first); + assert.deepEqual(h.events, ["apply:visible(a)", "redraw"]); + }); +}); + +describe("LayoutOrchestrator failure and teardown", () => { + it("releases the pending gate when the layout FAILS", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + assert.equal(h.orchestrator.isLayoutPending, true); + + h.fullGates[0]!.reject(new Error("elk exploded")); + await flush(); + + assert.equal(h.errors.length, 1); + assert.match(String(h.errors[0]), /elk exploded/); + assert.equal( + h.orchestrator.isLayoutPending, + false, + "a stuck gate would silently kill every later visibility redraw" + ); + assert.equal(h.orchestrator.lastLayout, null, "a failed pass adopts nothing"); + assert.deepEqual(h.published, []); + + // Proof the gate really is open: the cheap path draws again. + h.orchestrator.setVisibleNodes(new Set(["a"])); + assert.ok(h.events.includes("redraw")); + }); + + it("keeps draining the queue after a failed pass", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + requestFull(h, "g2", ALL_NODES); + h.fullGates[0]!.reject(new Error("boom")); + await flush(); + + assert.deepEqual(h.fullRequests.map((r) => r.graph.root), ["g1", "g2"]); + assert.equal(h.errors.length, 1); + // The error handler clears the gate unconditionally, so the queued rerun + // runs ungated. Cosmetic (an extra redraw at worst), and pre-existing. + assert.equal(h.orchestrator.isLayoutPending, false); + }); + + it("discards a superseded (torn-down) pass: no counts published, no layout adopted", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + + // Teardown supersedes the in-flight pass. The queue serialises runs, so + // this is the reachable half of the staleness guard; the request id is the + // second line of defence for the same rule. + h.orchestrator.destroy(); + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + assert.equal(h.orchestrator.lastLayout, null); + assert.deepEqual(h.published, [], "stale counts must not be published"); + assert.deepEqual(h.events, [ + "adopt(g1)", + "run:full(g1,vis=a|b|c,kinds=Import,amb=false)", + ]); + }); + + it("discards a superseded edge phase without overwriting the newer layout", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + const applied = layoutNamed("L1"); + h.fullGates[0]!.resolve(applied); + await flush(); + + h.orchestrator.requestEdgePhase(new Set(["Import"]), false); + await flush(); + h.orchestrator.destroy(); + h.edgeGates[0]!.resolve(layoutNamed("L2")); + await flush(); + + assert.equal(h.orchestrator.lastLayout, applied, "L2 was stale -- discarded"); + assert.deepEqual(h.published, ["L1"]); + assert.deepEqual(h.appliedEdgeRequests, []); + }); + + it("drops the queued rerun on teardown", async () => { + const h = harness(); + + requestFull(h, "g1", ALL_NODES); + await flush(); + requestFull(h, "g2", ALL_NODES); + h.orchestrator.destroy(); + h.fullGates[0]!.resolve(layoutNamed("L1")); + await flush(); + + assert.deepEqual(h.fullRequests.map((r) => r.graph.root), ["g1"]); + }); + + it("runs nothing while the consumer is not ready", async () => { + const h = harness(); + h.setReady(false); + + requestFull(h, "g1", ALL_NODES); + await flush(); + + assert.deepEqual(h.events, []); + assert.equal(h.orchestrator.lastLayout, null); + }); +}); diff --git a/packages/app/tests/routingConstants.test.ts b/packages/app/tests/routingConstants.test.ts new file mode 100644 index 0000000..7185fa7 --- /dev/null +++ b/packages/app/tests/routingConstants.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BOUNDARY_TOLERANCE, + DETOUR_GUTTER, + MAX_LEAD_DISTANCE, + MAX_OBSTACLE_REROUTE_PASSES, + MIN_LEAD_DISTANCE, + NODE_MOVED_EPSILON, + NODE_OBSTACLE_MARGIN, + OBSTACLE_QUERY_ALLOWANCE, + OBSTACLE_QUERY_MARGIN, + POINT_TOLERANCE, +} from "../src/canvas/layout/routingConstants.ts"; + +/** + * The routing constants are one module so the pipeline's stages cannot disagree + * about them, and the DERIVED ones are arithmetic so they cannot drift apart. + * These tests pin the relationships, not just the numbers. + */ + +test("OBSTACLE_QUERY_MARGIN is derived, not written out", () => { + assert.equal( + OBSTACLE_QUERY_MARGIN, + NODE_OBSTACLE_MARGIN + DETOUR_GUTTER + OBSTACLE_QUERY_ALLOWANCE + ); +}); + +test("the obstacle query window covers everything a detour can reach", () => { + // A detour clears an obstacle inflated by NODE_OBSTACLE_MARGIN and may be + // pushed a further DETOUR_GUTTER outside it. Anything the router can reach but + // the query cannot see is an obstacle it will happily route straight through. + assert.ok( + OBSTACLE_QUERY_MARGIN > NODE_OBSTACLE_MARGIN + DETOUR_GUTTER, + "query window must exceed the inflation plus the gutter" + ); + assert.ok(OBSTACLE_QUERY_ALLOWANCE > 0, "the slack must be a positive allowance"); +}); + +test("OBSTACLE_QUERY_MARGIN still evaluates to the shipped 160", () => { + // Guards the arithmetic itself: the parts may be re-explained, but changing + // the query window is a routing-behaviour change and should be deliberate. + assert.equal(OBSTACLE_QUERY_MARGIN, 160); +}); + +test("the tolerances are ordered from point equality up to routing clearances", () => { + assert.ok( + POINT_TOLERANCE < BOUNDARY_TOLERANCE, + "point equality must be tighter than boundary containment" + ); + assert.ok( + BOUNDARY_TOLERANCE < NODE_OBSTACLE_MARGIN, + "boundary containment must be tighter than obstacle clearance" + ); + assert.ok( + NODE_OBSTACLE_MARGIN < DETOUR_GUTTER, + "an edge pushed outside a box must clear it by more than it hugs it" + ); +}); + +test("a node counts as dragged only past a whole layout unit", () => { + // Sub-unit drift is float noise from the layout round trip; reacting to it + // would re-route every edge on every redraw. + assert.ok(NODE_MOVED_EPSILON > POINT_TOLERANCE); + assert.ok(NODE_MOVED_EPSILON < NODE_OBSTACLE_MARGIN); +}); + +test("lead distances form a usable range and the reroute cap terminates", () => { + assert.ok(MIN_LEAD_DISTANCE > 0); + assert.ok(MAX_LEAD_DISTANCE > MIN_LEAD_DISTANCE); + assert.ok(Number.isInteger(MAX_OBSTACLE_REROUTE_PASSES)); + assert.ok(MAX_OBSTACLE_REROUTE_PASSES > 0, "the router must be bounded"); +}); diff --git a/packages/app/tests/vizIntegrationSeam.test.ts b/packages/app/tests/vizIntegrationSeam.test.ts new file mode 100644 index 0000000..d0a33d0 --- /dev/null +++ b/packages/app/tests/vizIntegrationSeam.test.ts @@ -0,0 +1,272 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + LayoutOrchestrator, + type LayoutOrchestratorEffects, +} from "../src/canvas/layout/layoutOrchestrator.ts"; +import type { + LayoutNodePosition, + LayoutResult, +} from "../src/canvas/layout/layoutTypes.ts"; +import type { ViewEdge } from "../src/canvas/layout/viewEdges.ts"; +import { rebuildEdges } from "../src/canvas/layout/edgeRebuild.ts"; +import { straightLineEdge } from "../src/canvas/layout/straightEdges.ts"; +import { + routeEdges, + type EdgeRouteInput, +} from "../src/canvas/layout/edgeRoutePipeline.ts"; +import { getAnchorPoint } from "../src/canvas/layout/edgeGeometry.ts"; +import { unknownEdgeKindCounts } from "../src/canvas/legend/edgeLegendModel.ts"; +import type { CodeGraph, EdgeKind } from "../src/api/types.ts"; + +// Integration seam test between the layout ORCHESTRATOR (which decides which +// phase runs against which previous result) and the edge GEOMETRY chain (which +// decides what those results contain). Each side is unit-tested with fakes on +// its own; this file wires the real geometry functions in as the orchestrator's +// effects and pins the composed behavior: +// +// 1. The edges phase always receives the NEWEST applied layout as `previous` +// -- including a previous EDGE phase's result, not just the last full +// layout. If the orchestrator failed to adopt an edge-phase result as +// lastLayout, the second rerun below would rebuild from stale input and +// the identity assertions would fail. +// 2. Routed polylines survive edge-phase reruns through that adoption +// (rebuildEdges cache-reuses by source/target/kind against `previous`). +// 3. Everything the chain emits still satisfies the anchor contract after +// the full draw-time pipeline: >= 2 points, endpoints sitting exactly on +// getAnchorPoint(box, anchor) -- even for endpoints that lane spreading +// moved (whose anchors must move WITH them). + +/** Let the orchestrator's queued microtasks settle. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const nodes: Record = { + a: { x: 0, y: 0, width: 100, height: 40 }, + b: { x: 300, y: 0, width: 100, height: 40 }, + c: { x: 0, y: 200, width: 100, height: 40 }, +}; + +// Both edges land on b's LEFT side, so lane spreading has a group to fan out. +const AB: ViewEdge = { + source: "a", + target: "b", + color: "#ef4444", + kind: "FunctionCall", + count: 1, + resolution: "Direct", +}; +const CB: ViewEdge = { + source: "c", + target: "b", + color: "#3b82f6", + kind: "Import", + count: 1, + resolution: "Direct", +}; + +function viewEdgesFor(kinds: Set): ViewEdge[] { + return [AB, CB].filter((edge) => edge.kind !== null && kinds.has(edge.kind)); +} + +const graph = { nodes: {}, root: "a", edgeCount: 2 } as unknown as CodeGraph; + +const FUNCTION_CALL_ONLY = new Set(["FunctionCall"]); +const BOTH_KINDS = new Set(["FunctionCall", "Import"]); + +interface Harness { + orchestrator: LayoutOrchestrator; + fullLayout: LayoutResult; + /** The `previous` argument each edge-phase run received, in order. */ + edgePhasePrevious: LayoutResult[]; + /** Each edge-phase result the orchestrator APPLIED, in order. */ + appliedEdgeLayouts: LayoutResult[]; + errors: unknown[]; +} + +/** An orchestrator whose effects run the REAL edge-geometry chain. */ +function makeHarness(): Harness { + // The full layout carries only the FunctionCall edge, straight-lined by the + // real layout-time anchor decision. + const abEdge = straightLineEdge(nodes, AB); + assert.ok(abEdge, "fixture: straightLineEdge must produce the a->b edge"); + const fullLayout: LayoutResult = { + nodes, + edges: [abEdge], + edgeKindCounts: unknownEdgeKindCounts(), + renderIds: ["a", "b", "c"], + }; + + const edgePhasePrevious: LayoutResult[] = []; + const appliedEdgeLayouts: LayoutResult[] = []; + const errors: unknown[] = []; + + const effects: LayoutOrchestratorEffects = { + waitUntilReady: async () => true, + runFullLayout: async () => fullLayout, + runEdgePhase: async (previous, enabledEdgeKinds) => { + edgePhasePrevious.push(previous); + return { + nodes: previous.nodes, + edges: rebuildEdges(previous, viewEdgesFor(enabledEdgeKinds)), + edgeKindCounts: unknownEdgeKindCounts(), + renderIds: previous.renderIds, + }; + }, + adoptFullLayoutInputs: () => {}, + applyFullLayout: () => {}, + applyEdgeLayout: (layout) => { + appliedEdgeLayouts.push(layout); + }, + applyVisibleNodes: () => {}, + redrawEdges: () => {}, + publishEdgeKindCounts: () => {}, + reportError: (error) => { + errors.push(error); + }, + }; + + return { + orchestrator: new LayoutOrchestrator(effects), + fullLayout, + edgePhasePrevious, + appliedEdgeLayouts, + errors, + }; +} + +/** Feed a rebuilt layout's edges to the draw-time pipeline, undragged. */ +function routeLayoutEdges(layout: LayoutResult) { + const inputs: EdgeRouteInput[] = layout.edges.map((edge, index) => ({ + index, + sourceId: edge.source, + targetId: edge.target, + layoutPoints: edge.points, + sourceAnchor: edge.sourceAnchor, + targetAnchor: edge.targetAnchor, + sourceBox: layout.nodes[edge.source], + targetBox: layout.nodes[edge.target], + sourceDelta: { x: 0, y: 0 }, + targetDelta: { x: 0, y: 0 }, + })); + + return routeEdges(inputs, { + visibleNodeCount: Object.keys(layout.nodes).length, + edgesVisible: true, + obstacles: () => null, + }); +} + +function assertPointsNearlyEqual( + actual: { x: number; y: number }, + expected: { x: number; y: number }, + label: string +): void { + const tolerance = 0.5; // normalizeRoutedPolyline's point-merge tolerance + assert.ok( + Math.abs(actual.x - expected.x) <= tolerance && + Math.abs(actual.y - expected.y) <= tolerance, + `${label}: (${actual.x}, ${actual.y}) != (${expected.x}, ${expected.y})` + ); +} + +describe("orchestrator x edge-geometry seam", () => { + it("feeds each edge phase the newest applied layout and reuses routes through it", async () => { + const h = makeHarness(); + + h.orchestrator.requestFullLayout( + graph, + new Set(), + new Set(["a", "b", "c"]), + FUNCTION_CALL_ONLY, + false + ); + await flush(); + assert.equal(h.orchestrator.lastLayout, h.fullLayout); + + // Edge phase 1: Import toggled on. Must rebuild against the FULL layout. + h.orchestrator.requestEdgePhase(BOTH_KINDS, false); + await flush(); + assert.equal(h.edgePhasePrevious.length, 1); + assert.equal(h.edgePhasePrevious[0], h.fullLayout); + const phase1 = h.appliedEdgeLayouts[0]; + assert.ok(phase1, "edge phase 1 must be applied"); + + const phase1Ab = phase1.edges.find((e) => e.source === "a"); + const phase1Cb = phase1.edges.find((e) => e.source === "c"); + assert.ok(phase1Ab && phase1Cb, "phase 1 must carry both edges"); + // The surviving edge kept its routed polyline; the new one was built fresh + // (straight-line fallback, orthogonalized -- a diagonal gains bends). + assert.deepEqual(phase1Ab.points, h.fullLayout.edges[0].points); + assert.ok(phase1Cb.points.length >= 2); + + // Edge phase 2, same kinds. Must rebuild against PHASE 1's result -- this + // is the adoption seam: the orchestrator has to have taken the edge-phase + // result as its lastLayout, or reuse breaks silently. + h.orchestrator.requestEdgePhase(BOTH_KINDS, false); + await flush(); + assert.equal(h.edgePhasePrevious.length, 2); + assert.equal(h.edgePhasePrevious[1], phase1); + + const phase2 = h.appliedEdgeLayouts[1]; + assert.ok(phase2, "edge phase 2 must be applied"); + const phase2Ab = phase2.edges.find((e) => e.source === "a"); + const phase2Cb = phase2.edges.find((e) => e.source === "c"); + assert.ok(phase2Ab && phase2Cb, "phase 2 must carry both edges"); + // Both edges now cache-hit against phase 1's geometry. + assert.deepEqual(phase2Ab.points, phase1Ab.points); + assert.deepEqual(phase2Cb.points, phase1Cb.points); + + assert.deepEqual(h.errors, []); + }); + + it("keeps anchors and geometry in agreement through the draw-time pipeline", async () => { + const h = makeHarness(); + + h.orchestrator.requestFullLayout( + graph, + new Set(), + new Set(["a", "b", "c"]), + FUNCTION_CALL_ONLY, + false + ); + await flush(); + h.orchestrator.requestEdgePhase(BOTH_KINDS, false); + await flush(); + + const phase1 = h.appliedEdgeLayouts[0]; + assert.ok(phase1); + const { edges: routed } = routeLayoutEdges(phase1); + assert.equal(routed.length, 2); + + for (const edge of routed) { + // The drawing pass's contract: drawable, endpoints ON the boxes... + assert.ok(edge.points.length >= 2); + // ...and the emitted anchors describe the emitted geometry exactly, + // including endpoints that lane spreading moved. + assertPointsNearlyEqual( + edge.points[0], + getAnchorPoint(edge.sourceBox, edge.sourceAnchor), + `${edge.sourceId}->${edge.targetId} source` + ); + assertPointsNearlyEqual( + edge.points[edge.points.length - 1], + getAnchorPoint(edge.targetBox, edge.targetAnchor), + `${edge.sourceId}->${edge.targetId} target` + ); + } + + // Both edges land on b's left side, so lane spreading MUST have separated + // their offsets (and moved their anchors with them). + const [abRouted, cbRouted] = [ + routed.find((e) => e.sourceId === "a"), + routed.find((e) => e.sourceId === "c"), + ]; + assert.ok(abRouted && cbRouted); + assert.equal(abRouted.targetAnchor.side, "left"); + assert.equal(cbRouted.targetAnchor.side, "left"); + assert.notEqual(abRouted.targetAnchor.offset, cbRouted.targetAnchor.offset); + }); +});