Skip to content

Commit 629e0f0

Browse files
committed
perf: add node culling spatial index
Adds a position query for "which nodes intersect this rect", backed by a QuadTree that is rebuilt only when layoutVersion changes. Panning over a static graph then costs a tree query rather than a scan of every node. Deliberately does not reuse the shared SpatialIndexManager. That index is built on QUADTREE_CONFIG.DEFAULT_BOUNDS, a fixed +/-10000 box, and QuadTree.insert silently drops anything not fully contained (its return value is discarded, and update() cannot recover an item that was never inserted). Real workflows outgrow that box - a graph built by stamping the default workflow across empty space spans tens of thousands of units on each axis - so most nodes would be missing from query results. This index sizes its root to the graph's own extent instead. Nodes with no layout yet cannot be indexed, so they are reported by every query; callers must mount them in order for them to measure themselves. Unused until the next commit wires it up, so the two can be reverted independently.
1 parent 37e86ce commit 629e0f0

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
4+
import type { CullingIndexEntry } from '@/renderer/core/spatial/nodeCullingIndex'
5+
import { createNodeCullingIndex } from '@/renderer/core/spatial/nodeCullingIndex'
6+
7+
const id = (value: string) => value as NodeId
8+
9+
function box(x: number, y: number, size = 100): Bounds {
10+
return { x, y, width: size, height: size }
11+
}
12+
13+
/** Index over a mutable entry list with an explicitly controlled version. */
14+
function setup(entries: CullingIndexEntry[]) {
15+
const state = { entries, version: 1, builds: 0 }
16+
17+
const index = createNodeCullingIndex({
18+
getVersion: () => state.version,
19+
getEntries: () => {
20+
state.builds++
21+
return state.entries
22+
}
23+
})
24+
25+
return { index, state }
26+
}
27+
28+
describe('createNodeCullingIndex', () => {
29+
it('returns only nodes intersecting the query rect', () => {
30+
const { index } = setup([
31+
{ id: id('near'), bounds: box(0, 0) },
32+
{ id: id('far'), bounds: box(50_000, 50_000) }
33+
])
34+
35+
expect(index.query(box(-10, -10, 500))).toEqual([id('near')])
36+
})
37+
38+
it('indexes nodes far outside the shared quadtree bounds', () => {
39+
// The shared QUADTREE_CONFIG root is a fixed +/-10000 box and silently
40+
// drops anything outside it, so the index must size its own root.
41+
const { index } = setup([
42+
{ id: id('distant'), bounds: box(250_000, -80_000) }
43+
])
44+
45+
expect(index.query(box(249_900, -80_100, 500))).toEqual([id('distant')])
46+
expect(index.size).toBe(1)
47+
})
48+
49+
it('always reports nodes that have no bounds yet', () => {
50+
const { index } = setup([
51+
{ id: id('unmeasured'), bounds: null },
52+
{ id: id('far'), bounds: box(50_000, 50_000) }
53+
])
54+
55+
expect(index.query(box(0, 0, 10))).toEqual([id('unmeasured')])
56+
})
57+
58+
it('reuses the tree while the version is unchanged', () => {
59+
const { index, state } = setup([{ id: id('a'), bounds: box(0, 0) }])
60+
61+
index.query(box(0, 0, 10))
62+
index.query(box(0, 0, 10))
63+
index.query(box(0, 0, 10))
64+
65+
expect(state.builds).toBe(1)
66+
})
67+
68+
it('rebuilds when the version changes', () => {
69+
const { index, state } = setup([{ id: id('a'), bounds: box(0, 0) }])
70+
71+
expect(index.query(box(0, 0, 10))).toEqual([id('a')])
72+
73+
state.entries = [{ id: id('a'), bounds: box(40_000, 40_000) }]
74+
state.version++
75+
76+
expect(index.query(box(0, 0, 10))).toEqual([])
77+
expect(index.query(box(39_950, 39_950, 200))).toEqual([id('a')])
78+
expect(state.builds).toBe(2)
79+
})
80+
81+
it('rebuilds when explicitly invalidated', () => {
82+
const { index, state } = setup([{ id: id('a'), bounds: box(0, 0) }])
83+
84+
index.query(box(0, 0, 10))
85+
86+
// Entry list changed without a version bump.
87+
state.entries = [
88+
{ id: id('a'), bounds: box(0, 0) },
89+
{ id: id('b'), bounds: box(10, 10) }
90+
]
91+
index.invalidate()
92+
93+
expect(index.query(box(0, 0, 200)).sort()).toEqual([id('a'), id('b')])
94+
})
95+
96+
it('handles an empty graph', () => {
97+
const { index } = setup([])
98+
99+
expect(index.query(box(0, 0, 100))).toEqual([])
100+
expect(index.size).toBe(0)
101+
})
102+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Spatial index for viewport culling.
3+
*
4+
* Holds a QuadTree of node bounds that is rebuilt only when the supplied
5+
* version changes, so panning over a static graph costs a tree query rather
6+
* than a full rescan of every node.
7+
*/
8+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
9+
10+
import { QuadTree } from './QuadTree'
11+
12+
/** Slack around the graph extent so nodes on the edge insert cleanly. */
13+
const ROOT_PADDING = 1000
14+
15+
const QUAD_TREE_OPTIONS = {
16+
maxDepth: 8,
17+
maxItemsPerNode: 8
18+
} as const
19+
20+
export interface CullingIndexEntry {
21+
id: NodeId
22+
/** Null while a node has no layout yet. */
23+
bounds: Bounds | null
24+
}
25+
26+
interface NodeCullingIndexOptions {
27+
/**
28+
* Changes whenever any node's position or size changes. Entries must be
29+
* derived from the same source as this version, or the index can cache
30+
* stale bounds indefinitely.
31+
*/
32+
getVersion: () => number
33+
getEntries: () => Iterable<CullingIndexEntry>
34+
}
35+
36+
export function createNodeCullingIndex({
37+
getVersion,
38+
getEntries
39+
}: NodeCullingIndexOptions) {
40+
let tree: QuadTree<NodeId> | null = null
41+
let builtVersion: number | null = null
42+
let built = false
43+
44+
/** Nodes with no bounds yet; they cannot be indexed, so always report them. */
45+
let unpositioned: NodeId[] = []
46+
47+
function rebuild(): void {
48+
const positioned: CullingIndexEntry[] = []
49+
const pending: NodeId[] = []
50+
51+
let minX = Infinity
52+
let minY = Infinity
53+
let maxX = -Infinity
54+
let maxY = -Infinity
55+
56+
for (const entry of getEntries()) {
57+
const { bounds } = entry
58+
if (!bounds) {
59+
pending.push(entry.id)
60+
continue
61+
}
62+
positioned.push(entry)
63+
if (bounds.x < minX) minX = bounds.x
64+
if (bounds.y < minY) minY = bounds.y
65+
if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width
66+
if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height
67+
}
68+
69+
unpositioned = pending
70+
71+
if (positioned.length === 0) {
72+
tree = null
73+
return
74+
}
75+
76+
// Size the root to the graph's own extent. QuadTree.insert drops anything
77+
// not fully contained, and the shared QUADTREE_CONFIG bounds are a fixed
78+
// +/-10000 box that real graphs outgrow.
79+
const root: Bounds = {
80+
x: minX - ROOT_PADDING,
81+
y: minY - ROOT_PADDING,
82+
width: maxX - minX + ROOT_PADDING * 2,
83+
height: maxY - minY + ROOT_PADDING * 2
84+
}
85+
86+
tree = new QuadTree<NodeId>(root, QUAD_TREE_OPTIONS)
87+
for (const { id, bounds } of positioned) {
88+
tree.insert(String(id), bounds!, id)
89+
}
90+
}
91+
92+
function ensureFresh(): void {
93+
const version = getVersion()
94+
if (built && builtVersion === version) return
95+
rebuild()
96+
builtVersion = version
97+
built = true
98+
}
99+
100+
return {
101+
query(bounds: Bounds): NodeId[] {
102+
ensureFresh()
103+
const matches = tree ? tree.query(bounds) : []
104+
return unpositioned.length ? matches.concat(unpositioned) : matches
105+
},
106+
107+
/** Forces a rebuild on next query, for changes the version does not cover. */
108+
invalidate(): void {
109+
built = false
110+
},
111+
112+
get size(): number {
113+
ensureFresh()
114+
return (tree?.size ?? 0) + unpositioned.length
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)