From 4154db5e0b9b4359d124a80b59b3fa1c4e140260 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Feb 2026 20:25:05 +0100 Subject: [PATCH 1/5] Rewrite A* MinHeap with O(1) index map and numeric state keys --- src/lib/routing/pathfinder.ts | 172 ++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 59 deletions(-) diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 19090b80..955cb203 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -1,5 +1,10 @@ /** * A* pathfinding with turn penalty and no 180-degree turns + * + * Performance notes: + * - MinHeap uses a Map sidecar for O(1) membership/update lookups + * - Closed set and forced-walkable set use numeric keys to avoid string GC + * - Direction is encoded as 0-3 integer for fast hashing */ import type { Position } from '$lib/types/common'; @@ -24,14 +29,31 @@ const EXIT_PATH_LENGTH = 3; /** Maximum iterations before giving up (prevents infinite search) */ const MAX_ITERATIONS = 10000; -/** Neighbor offsets with their directions */ -const NEIGHBORS: Array<{ dx: number; dy: number; dir: Direction }> = [ - { dx: 1, dy: 0, dir: 'right' }, - { dx: -1, dy: 0, dir: 'left' }, - { dx: 0, dy: 1, dir: 'down' }, - { dx: 0, dy: -1, dir: 'up' } +/** Direction to integer index for numeric hashing */ +const DIR_INDEX: Record = { right: 0, left: 1, down: 2, up: 3 }; + +/** Neighbor offsets with their directions and precomputed direction index */ +const NEIGHBORS: Array<{ dx: number; dy: number; dir: Direction; dirIdx: number }> = [ + { dx: 1, dy: 0, dir: 'right', dirIdx: 0 }, + { dx: -1, dy: 0, dir: 'left', dirIdx: 1 }, + { dx: 0, dy: 1, dir: 'down', dirIdx: 2 }, + { dx: 0, dy: -1, dir: 'up', dirIdx: 3 } ]; +/** + * Encode (x, y, dirIdx) into a single number for use as Map key. + * Uses a large multiplier to avoid collisions in the expected coordinate range. + * Grid coordinates are typically -500..+500, so 20_000 provides ample space. + */ +function encodeState(x: number, y: number, dirIdx: number): number { + return ((x + 10_000) * 20_001 + (y + 10_000)) * 4 + dirIdx; +} + +/** Encode (x, y) into a single number for walkability sets */ +function encodeCell(x: number, y: number): number { + return (x + 10_000) * 20_001 + (y + 10_000); +} + /** Priority queue node for A* with direction tracking */ interface AStarNode { x: number; @@ -41,22 +63,35 @@ interface AStarNode { f: number; // Total cost (g + h) parent: AStarNode | null; direction: Direction; // Actual direction we arrived from + dirIdx: number; // Numeric direction index (0-3) + stateKey: number; // Precomputed encodeState key + heapIdx: number; // Current index in the heap array (maintained by MinHeap) } -/** Simple binary min-heap for A* open set */ +/** + * Binary min-heap with O(1) membership test and O(log n) update. + * Uses a Map sidecar for fast lookups. + * Each node stores its current heap index so bubbleUp/bubbleDown can + * update the sidecar without re-scanning. + */ class MinHeap { private heap: AStarNode[] = []; + private index: Map = new Map(); push(node: AStarNode): void { + node.heapIdx = this.heap.length; this.heap.push(node); - this.bubbleUp(this.heap.length - 1); + this.index.set(node.stateKey, node); + this.bubbleUp(node.heapIdx); } pop(): AStarNode | undefined { if (this.heap.length === 0) return undefined; const min = this.heap[0]; + this.index.delete(min.stateKey); const last = this.heap.pop()!; if (this.heap.length > 0) { + last.heapIdx = 0; this.heap[0] = last; this.bubbleDown(0); } @@ -67,50 +102,59 @@ class MinHeap { return this.heap.length; } - // Find and update node if better path found - updateIfBetter(x: number, y: number, dir: Direction, newG: number, parent: AStarNode): boolean { - for (let i = 0; i < this.heap.length; i++) { - const n = this.heap[i]; - if (n.x === x && n.y === y && n.direction === dir) { - if (newG < n.g) { - n.g = newG; - n.f = newG + n.h; - n.parent = parent; - this.bubbleUp(i); - return true; - } - return false; - } + /** O(1) lookup + O(log n) re-heap if better */ + updateIfBetter(stateKey: number, newG: number, newH: number, parent: AStarNode): boolean { + const existing = this.index.get(stateKey); + if (!existing) return false; // Not in open set + if (newG < existing.g) { + existing.g = newG; + existing.f = newG + existing.h; + existing.parent = parent; + this.bubbleUp(existing.heapIdx); + return true; } - return false; // Not found + return false; // Existing path is better or equal } - has(x: number, y: number, dir: Direction): boolean { - return this.heap.some(n => n.x === x && n.y === y && n.direction === dir); + /** O(1) membership test */ + has(stateKey: number): boolean { + return this.index.has(stateKey); } private bubbleUp(i: number): void { + const heap = this.heap; while (i > 0) { - const parent = Math.floor((i - 1) / 2); - if (this.heap[i].f >= this.heap[parent].f) break; - [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; - i = parent; + const parentIdx = (i - 1) >> 1; + if (heap[i].f >= heap[parentIdx].f) break; + this.swap(i, parentIdx); + i = parentIdx; } } private bubbleDown(i: number): void { - const n = this.heap.length; + const heap = this.heap; + const n = heap.length; while (true) { const left = 2 * i + 1; const right = 2 * i + 2; let smallest = i; - if (left < n && this.heap[left].f < this.heap[smallest].f) smallest = left; - if (right < n && this.heap[right].f < this.heap[smallest].f) smallest = right; + if (left < n && heap[left].f < heap[smallest].f) smallest = left; + if (right < n && heap[right].f < heap[smallest].f) smallest = right; if (smallest === i) break; - [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; + this.swap(i, smallest); i = smallest; } } + + private swap(a: number, b: number): void { + const heap = this.heap; + const nodeA = heap[a]; + const nodeB = heap[b]; + heap[a] = nodeB; + heap[b] = nodeA; + nodeA.heapIdx = b; + nodeB.heapIdx = a; + } } /** Result from pathfinding */ @@ -144,41 +188,50 @@ export function findPathWithTurnPenalty( const endGx = worldToGrid(end.x - offset.x); const endGy = worldToGrid(end.y - offset.y); - // Cells that are forced walkable (start, end, exit path) - const forcedWalkable = new Set(); - forcedWalkable.add(`${startGx},${startGy}`); - forcedWalkable.add(`${endGx},${endGy}`); + // Precompute offset in grid units for usedCells lookup + const offsetGx = worldToGrid(offset.x); + const offsetGy = worldToGrid(offset.y); + + // Cells that are forced walkable (start, end, exit path) — numeric keys + const forcedWalkable = new Set(); + forcedWalkable.add(encodeCell(startGx, startGy)); + forcedWalkable.add(encodeCell(endGx, endGy)); // Force first few cells in initial direction walkable (exit path from port) const initVec = NEIGHBORS.find((n) => n.dir === initialDir); if (initVec) { for (let i = 1; i <= EXIT_PATH_LENGTH; i++) { - forcedWalkable.add(`${startGx + initVec.dx * i},${startGy + initVec.dy * i}`); + forcedWalkable.add(encodeCell(startGx + initVec.dx * i, startGy + initVec.dy * i)); } } // Helper to check walkability (sparse grid + forced walkable) const isWalkable = (gx: number, gy: number): boolean => { - const key = `${gx},${gy}`; - if (forcedWalkable.has(key)) return true; + if (forcedWalkable.has(encodeCell(gx, gy))) return true; return grid.isWalkableAt(gx, gy); }; - // Initialize open (min-heap) and closed sets + // Initialize open (min-heap) and closed set (numeric keys) const openSet = new MinHeap(); - const closedSet = new Set(); + const closedSet = new Set(); + + const initialDirIdx = DIR_INDEX[initialDir]; + const startStateKey = encodeState(startGx, startGy, initialDirIdx); + const startH = manhattanDistance(startGx, startGy, endGx, endGy); // Create start node const startNode: AStarNode = { x: startGx, y: startGy, g: 0, - h: manhattanDistance(startGx, startGy, endGx, endGy), - f: 0, + h: startH, + f: startH, parent: null, - direction: initialDir + direction: initialDir, + dirIdx: initialDirIdx, + stateKey: startStateKey, + heapIdx: 0 }; - startNode.f = startNode.g + startNode.h; openSet.push(startNode); let iterations = 0; @@ -192,16 +245,15 @@ export function findPathWithTurnPenalty( } // Skip if already processed with this direction - const closedKey = `${current.x},${current.y},${current.direction}`; - if (closedSet.has(closedKey)) continue; - closedSet.add(closedKey); + if (closedSet.has(current.stateKey)) continue; + closedSet.add(current.stateKey); // Get the direction we must NOT go (opposite = 180-degree turn) const blockedDir = OPPOSITE_DIRECTION[current.direction]; const isStartNode = current.parent === null; // Explore neighbors - for (const { dx, dy, dir } of NEIGHBORS) { + for (const { dx, dy, dir, dirIdx } of NEIGHBORS) { if (dir === blockedDir) continue; if (isStartNode && dir !== initialDir) continue; @@ -212,8 +264,8 @@ export function findPathWithTurnPenalty( if (!isWalkable(nx, ny)) continue; // Skip if already closed with this direction - const neighborClosedKey = `${nx},${ny},${dir}`; - if (closedSet.has(neighborClosedKey)) continue; + const neighborStateKey = encodeState(nx, ny, dirIdx); + if (closedSet.has(neighborStateKey)) continue; // Calculate movement cost let moveCost = 1; @@ -221,8 +273,8 @@ export function findPathWithTurnPenalty( // Add penalty for cells used by other paths if (usedCells) { - const worldGx = nx + worldToGrid(offset.x); - const worldGy = ny + worldToGrid(offset.y); + const worldGx = nx + offsetGx; + const worldGy = ny + offsetGy; const existingDirs = usedCells.get(`${worldGx},${worldGy}`); if (existingDirs) { const isHorizontal = dir === 'left' || dir === 'right'; @@ -235,9 +287,9 @@ export function findPathWithTurnPenalty( const tentativeG = current.g + moveCost; - // Try to update existing node or add new one - if (!openSet.updateIfBetter(nx, ny, dir, tentativeG, current)) { - if (!openSet.has(nx, ny, dir)) { + // Try to update existing node in open set, or add new one + if (!openSet.updateIfBetter(neighborStateKey, tentativeG, 0, current)) { + if (!openSet.has(neighborStateKey)) { const h = manhattanDistance(nx, ny, endGx, endGy); openSet.push({ x: nx, @@ -246,7 +298,10 @@ export function findPathWithTurnPenalty( h, f: tentativeG + h, parent: current, - direction: dir + direction: dir, + dirIdx, + stateKey: neighborStateKey, + heapIdx: 0 }); } } @@ -288,4 +343,3 @@ function reconstructPath(endNode: AStarNode, offset: Position): Position[] { return path; } - From 2bad1bbb8e458cd2cfdf7098d4ec7bc12af1f2f7 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Feb 2026 20:26:30 +0100 Subject: [PATCH 2/5] Two-pass routing: sync fast pass + async overlap refinement --- src/lib/stores/routing.ts | 128 +++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index d7dd396b..7e732b1a 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -53,6 +53,9 @@ function hashRouteInputs( return `${sourcePos.x},${sourcePos.y}|${targetPos.x},${targetPos.y}|${sourceDir}|${targetDir}|${wpHash}`; } +/** Number of routes to calculate per async batch before yielding to browser */ +const ASYNC_BATCH_SIZE = 8; + interface RoutingState { /** Cached routes by connection ID */ routes: Map; @@ -71,6 +74,9 @@ const state = writable({ routeInputHashes: new Map() }); +/** Generation counter — incremented on each recalculateAllRoutes call to cancel stale async work */ +let routingGeneration = 0; + /** * Routing store - manages route calculations and caching */ @@ -259,7 +265,13 @@ export const routingStore = { }, /** - * Recalculate all routes with path overlap avoidance + * Recalculate all routes using a two-pass strategy: + * Pass 1 (sync): Fast route for every connection WITHOUT overlap avoidance. + * Routes appear on screen immediately. + * Pass 2 (async): Refine routes WITH overlap avoidance, yielding to the + * browser every ASYNC_BATCH_SIZE routes so the UI stays responsive. + * Cancelled automatically if a newer recalculation starts. + * * @param connections - All connections to route * @param getPortInfo - Function to get port world position and direction */ @@ -269,6 +281,9 @@ export const routingStore = { ): void { const $state = get(state); + // Bump generation — any in-flight async pass with an older generation will bail out + const generation = ++routingGeneration; + // Memoize port info lookups for this batch (used during sorting and routing) const portInfoCache = new Map(); const getPortInfoCached = (nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null => { @@ -279,13 +294,12 @@ export const routingStore = { return portInfoCache.get(key)!; }; - // Start with existing routes to preserve them if recalculation fails + // ── Pass 1: fast sync routing (no overlap avoidance) ───────────── + const routes = new Map($state.routes); const routeInputHashes = new Map(); - const usedCells = new Map>(); - // Sort connections by Manhattan distance (longest first) - // Longer routes are less likely to block shorter ones + // Prepare sorted + grouped connections (shared by both passes) const sortedConnections = [...connections].sort((a, b) => { const aSource = getPortInfoCached(a.sourceNodeId, a.sourcePortIndex, true); const aTarget = getPortInfoCached(a.targetNodeId, a.targetPortIndex, false); @@ -311,20 +325,82 @@ export const routingStore = { bySourcePort.set(key, group); } - // Process each source port group + // Flat ordered list of connections for pass 2 batching + const orderedConnections: Connection[] = []; + for (const [, groupConns] of bySourcePort) { + orderedConnections.push(...groupConns); + } + + // Pass 1: calculate every route without usedCells (fast) + for (const conn of orderedConnections) { + const sourceInfo = getPortInfoCached(conn.sourceNodeId, conn.sourcePortIndex, true); + const targetInfo = getPortInfoCached(conn.targetNodeId, conn.targetPortIndex, false); + if (!sourceInfo || !targetInfo) continue; + + const userWaypoints = getUserWaypoints(conn.waypoints); + const result = computeRoute( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + $state.grid, + userWaypoints + // no usedCells — fast path + ); + routes.set(conn.id, result); + routeInputHashes.set(conn.id, hashRouteInputs( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + userWaypoints + )); + } + + state.update((s) => ({ ...s, routes, routeInputHashes })); + + // ── Pass 2: async overlap-aware refinement ─────────────────────── + // Only needed when there are enough connections to overlap + if (orderedConnections.length > 1) { + this._refineRoutesAsync( + generation, + orderedConnections, + bySourcePort, + getPortInfoCached, + routeInputHashes + ); + } + }, + + /** + * Async pass 2 — recalculate routes with overlap avoidance in batches. + * Yields to the browser every ASYNC_BATCH_SIZE routes. + * Bails out if routingGeneration has advanced (newer routing started). + */ + async _refineRoutesAsync( + generation: number, + orderedConnections: Connection[], + bySourcePort: Map, + getPortInfoCached: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null, + routeInputHashes: Map + ): Promise { + const usedCells = new Map>(); + const refinedRoutes = new Map(); + let processed = 0; + for (const [, groupConns] of bySourcePort) { const groupCells: Map>[] = []; - // Calculate routes for all connections from this source port for (const conn of groupConns) { + // Bail out if a newer recalculation has been triggered + if (generation !== routingGeneration) return; + + const $state = get(state); const sourceInfo = getPortInfoCached(conn.sourceNodeId, conn.sourcePortIndex, true); const targetInfo = getPortInfoCached(conn.targetNodeId, conn.targetPortIndex, false); - if (!sourceInfo || !targetInfo) continue; - // Extract user waypoints from connection const userWaypoints = getUserWaypoints(conn.waypoints); - const result = computeRoute( sourceInfo.position, targetInfo.position, @@ -334,21 +410,19 @@ export const routingStore = { userWaypoints, usedCells ); - routes.set(conn.id, result); + refinedRoutes.set(conn.id, result); - // Store hash for future change detection - routeInputHashes.set(conn.id, hashRouteInputs( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - userWaypoints - )); - - // Collect cells for this path (add to usedCells after processing whole group) if (result.path.length > 0) { groupCells.push(getPathCells(result.path, 2)); } + + processed++; + + // Yield to browser every ASYNC_BATCH_SIZE routes + if (processed % ASYNC_BATCH_SIZE === 0) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + if (generation !== routingGeneration) return; + } } // Add all cells from this group to usedCells for subsequent groups @@ -362,7 +436,17 @@ export const routingStore = { } } - state.update((s) => ({ ...s, routes, routeInputHashes })); + // Final check before committing + if (generation !== routingGeneration) return; + + // Merge refined routes into current state + state.update((s) => { + const routes = new Map(s.routes); + for (const [id, route] of refinedRoutes) { + routes.set(id, route); + } + return { ...s, routes, routeInputHashes: new Map(routeInputHashes) }; + }); }, /** From 66a26000f30b243cfd2bf047d6ae6d89e4e3e8f6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Feb 2026 20:27:06 +0100 Subject: [PATCH 3/5] Use Map for O(1) node lookup in getPortInfo --- src/lib/components/FlowCanvas.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index a77b285a..dd43c266 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -248,7 +248,7 @@ // Returns handle tip position (accounting for handle offset from block edge) // For inputs, also accounts for arrowhead so stub starts within arrow function getPortInfo(nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null { - const node = nodes.find(n => n.id === nodeId); + const node = nodeMap.get(nodeId); if (!node) return null; const nodeData = node.data as NodeInstance; @@ -382,6 +382,8 @@ // Combined nodes for SvelteFlow let nodes = $state([]); let edges = $state([]); + // O(1) node lookup map — kept in sync with nodes array via $effect + let nodeMap = $derived(new Map(nodes.map(n => [n.id, n]))); // Merge block, event, and annotation nodes when any changes // Preserve position and selection from SvelteFlow's current state (except during undo/redo) From f33ea23033ae073a9d9033568b2cbb5ec78568fb Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Feb 2026 20:27:59 +0100 Subject: [PATCH 4/5] Use numeric bucket keys in spatial hash grid --- src/lib/routing/gridBuilder.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index 9cb82c94..8d7675fb 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -1,6 +1,9 @@ /** * Sparse grid for pathfinding - stores only obstacles, computes walkability on demand * Supports incremental updates for efficient node dragging + * + * Performance: spatial hash uses numeric bucket keys to avoid string GC + * in the hot isWalkableAt path (called thousands of times per A* run). */ import type { RoutingContext, Bounds, PortStub } from './types'; @@ -10,6 +13,14 @@ import { GRID_SIZE, ROUTING_MARGIN } from './constants'; /** Size of spatial hash buckets (in grid cells) */ const SPATIAL_BUCKET_SIZE = 10; +/** + * Encode bucket coordinates into a single number. + * Bucket coords are small (typically -50..+50), so 10_000 offset is plenty. + */ +function encodeBucket(bx: number, by: number): number { + return (bx + 10_000) * 20_001 + (by + 10_000); +} + /** * Convert world coordinates to grid coordinates * Since everything is grid-aligned, this is a simple division @@ -57,7 +68,7 @@ function boundsToObstacle(bounds: Bounds, offsetX: number, offsetY: number): Gri /** * Sparse grid that computes walkability on-demand from obstacle list - * No matrix storage - O(obstacles) memory instead of O(width × height) + * No matrix storage - O(obstacles) memory instead of O(width x height) * Supports incremental updates - O(1) to update a single node * Effectively unbounded - only obstacles block movement */ @@ -71,8 +82,8 @@ export class SparseGrid { /** Port stub obstacles (rebuilt when stubs change) */ private portStubObstacles: GridObstacle[] = []; - /** Spatial hash: bucket key -> set of node IDs with obstacles in that bucket */ - private spatialHash: Map> = new Map(); + /** Spatial hash: numeric bucket key -> set of node IDs with obstacles in that bucket */ + private spatialHash: Map> = new Map(); constructor(context?: RoutingContext) { if (context) { @@ -80,16 +91,16 @@ export class SparseGrid { } } - /** Get bucket key for a grid coordinate */ - private getBucketKey(gx: number, gy: number): string { + /** Get numeric bucket key for a grid coordinate */ + private getBucketKey(gx: number, gy: number): number { const bx = Math.floor(gx / SPATIAL_BUCKET_SIZE); const by = Math.floor(gy / SPATIAL_BUCKET_SIZE); - return `${bx},${by}`; + return encodeBucket(bx, by); } /** Get all bucket keys that an obstacle overlaps */ - private getObstacleBuckets(obs: GridObstacle): string[] { - const keys: string[] = []; + private getObstacleBuckets(obs: GridObstacle): number[] { + const keys: number[] = []; const minBx = Math.floor(obs.minGx / SPATIAL_BUCKET_SIZE); const maxBx = Math.floor(obs.maxGx / SPATIAL_BUCKET_SIZE); const minBy = Math.floor(obs.minGy / SPATIAL_BUCKET_SIZE); @@ -97,7 +108,7 @@ export class SparseGrid { for (let bx = minBx; bx <= maxBx; bx++) { for (let by = minBy; by <= maxBy; by++) { - keys.push(`${bx},${by}`); + keys.push(encodeBucket(bx, by)); } } return keys; From 1d5e471b97f11058ddeceae5535033f40513e690 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Feb 2026 20:55:26 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Fix=20O(n=C2=B2)=20path=20reconstruction,?= =?UTF-8?q?=20optimize=20getPathCells,=20debounce=20routing=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/FlowCanvas.svelte | 21 +++++++++-- src/lib/routing/pathfinder.ts | 4 +- src/lib/routing/routeCalculator.ts | 56 ++++++++++++++-------------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index dd43c266..f4c3d68e 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -54,7 +54,17 @@ colorMode = theme; }); - + // Debounced routing context update — coalesces rapid connection changes + // (e.g. paste, undo, bulk delete) into a single recalculation + let routingContextTimer: ReturnType | null = null; + function scheduleRoutingUpdate() { + if (routingContextTimer !== null) clearTimeout(routingContextTimer); + routingContextTimer = setTimeout(() => { + routingContextTimer = null; + updateRoutingContext(); + }, 0); + } + // Track mouse position for waypoint placement let mousePosition = { x: 0, y: 0 }; @@ -238,7 +248,10 @@ // Cleanup function - will add subscriptions as they're defined const cleanups: (() => void)[] = [unsubscribeTheme, unsubscribeNodeUpdates, unsubscribeClearSelection, unsubscribeNudge, unsubscribeSelectNode]; - onDestroy(() => cleanups.forEach(fn => fn())); + onDestroy(() => { + cleanups.forEach(fn => fn()); + if (routingContextTimer !== null) clearTimeout(routingContextTimer); + }); function clearPendingUpdates() { pendingNodeUpdates = []; @@ -642,8 +655,8 @@ return edge; }); // Recalculate routes when connections change - // Use setTimeout to ensure nodes are updated first - setTimeout(() => updateRoutingContext(), 0); + // Debounced to coalesce rapid changes (paste, undo, bulk operations) + scheduleRoutingUpdate(); })); // Track last snapped positions during drag for discrete routing updates diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 955cb203..01dab2e9 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -328,18 +328,20 @@ function manhattanDistance(x1: number, y1: number, x2: number, y2: number): numb /** * Reconstruct path from A* result + * Uses push + reverse instead of unshift to avoid O(n²) array shifting */ function reconstructPath(endNode: AStarNode, offset: Position): Position[] { const path: Position[] = []; let current: AStarNode | null = endNode; while (current !== null) { - path.unshift({ + path.push({ x: gridToWorld(current.x) + offset.x, y: gridToWorld(current.y) + offset.y }); current = current.parent; } + path.reverse(); return path; } diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index e9d1d92c..83410b66 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -87,7 +87,11 @@ export function calculateSimpleRoute( /** * Extract grid cells used by a path with direction info (for overlap/crossing avoidance) - * @param path - Path positions in world coordinates + * + * Optimized for simplified paths (corner-only): each segment is axis-aligned, + * so we compute the grid cell range directly instead of stepping every cell. + * + * @param path - Path positions in world coordinates (simplified = corners only) * @param skipStart - Number of cells to skip at start (for shared source ports) * @returns Map of cell key to set of directions traveled through that cell */ @@ -96,14 +100,12 @@ export function getPathCells(path: Position[], skipStart = 2): Map Math.abs(dy)) { dir = dx > 0 ? 'right' : 'left'; @@ -111,26 +113,26 @@ export function getPathCells(path: Position[], skipStart = 2): Map 0 ? 'down' : 'up'; } - if (dist < 1) { - cellCount++; - if (cellCount > skipStart) { - const key = `${worldToGrid(start.x)},${worldToGrid(start.y)}`; - if (!cells.has(key)) cells.set(key, new Set()); - cells.get(key)!.add(dir); - } - continue; - } - - const steps = Math.ceil(dist / GRID_SIZE); - for (let s = 0; s <= steps; s++) { - cellCount++; - if (cellCount > skipStart) { - const t = s / steps; - const x = start.x + dx * t; - const y = start.y + dy * t; - const key = `${worldToGrid(x)},${worldToGrid(y)}`; - if (!cells.has(key)) cells.set(key, new Set()); - cells.get(key)!.add(dir); + // Compute grid range for this axis-aligned segment + const gx1 = worldToGrid(segStart.x); + const gy1 = worldToGrid(segStart.y); + const gx2 = worldToGrid(segEnd.x); + const gy2 = worldToGrid(segEnd.y); + + const minGx = Math.min(gx1, gx2); + const maxGx = Math.max(gx1, gx2); + const minGy = Math.min(gy1, gy2); + const maxGy = Math.max(gy1, gy2); + + // Iterate over the grid range (one axis is constant for orthogonal segments) + for (let gx = minGx; gx <= maxGx; gx++) { + for (let gy = minGy; gy <= maxGy; gy++) { + cellCount++; + if (cellCount > skipStart) { + const key = `${gx},${gy}`; + if (!cells.has(key)) cells.set(key, new Set()); + cells.get(key)!.add(dir); + } } } }