Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/lib/components/FlowCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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 };

Expand Down Expand Up @@ -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 = [];
Expand All @@ -248,7 +261,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;
Expand Down Expand Up @@ -382,6 +395,8 @@
// Combined nodes for SvelteFlow
let nodes = $state<Node[]>([]);
let edges = $state<Edge[]>([]);
// 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)
Expand Down Expand Up @@ -640,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
Expand Down
29 changes: 20 additions & 9 deletions src/lib/routing/gridBuilder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -71,33 +82,33 @@ 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<string, Set<string>> = new Map();
/** Spatial hash: numeric bucket key -> set of node IDs with obstacles in that bucket */
private spatialHash: Map<number, Set<string>> = new Map();

constructor(context?: RoutingContext) {
if (context) {
this.initFromContext(context);
}
}

/** 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);
const maxBy = Math.floor(obs.maxGy / SPATIAL_BUCKET_SIZE);

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;
Expand Down
Loading