[feat] Add QuadTree spatial data structure for node indexing

- Implements efficient O(log n) spatial queries for large node graphs
- Supports dynamic insertion, removal, and updates
- Configurable depth and capacity parameters
- Comprehensive test suite covering performance scenarios
- Benchmark tools for performance validation
- Designed for viewport culling optimization in 100+ node workflows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
bymyself
2025-07-03 17:54:23 -07:00
parent 122170fc0d
commit 0de3b8a864
3 changed files with 703 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { QuadTree, type Bounds } from './QuadTree'
describe('QuadTree', () => {
let quadTree: QuadTree<string>
const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 }
beforeEach(() => {
quadTree = new QuadTree<string>(worldBounds, {
maxDepth: 4,
maxItemsPerNode: 4
})
})
describe('insertion', () => {
it('should insert items within bounds', () => {
const success = quadTree.insert('node1',
{ x: 100, y: 100, width: 50, height: 50 },
'node1'
)
expect(success).toBe(true)
expect(quadTree.size).toBe(1)
})
it('should reject items outside bounds', () => {
const success = quadTree.insert('node1',
{ x: -100, y: -100, width: 50, height: 50 },
'node1'
)
expect(success).toBe(false)
expect(quadTree.size).toBe(0)
})
it('should handle duplicate IDs by replacing', () => {
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'data1')
quadTree.insert('node1', { x: 200, y: 200, width: 50, height: 50 }, 'data2')
expect(quadTree.size).toBe(1)
const results = quadTree.query({ x: 150, y: 150, width: 100, height: 100 })
expect(results).toContain('data2')
expect(results).not.toContain('data1')
})
})
describe('querying', () => {
beforeEach(() => {
// Insert test nodes in a grid pattern
for (let x = 0; x < 10; x++) {
for (let y = 0; y < 10; y++) {
const id = `node_${x}_${y}`
quadTree.insert(id, {
x: x * 100,
y: y * 100,
width: 50,
height: 50
}, id)
}
}
})
it('should find nodes within query bounds', () => {
const results = quadTree.query({ x: 0, y: 0, width: 250, height: 250 })
expect(results.length).toBe(9) // 3x3 grid
})
it('should return empty array for out-of-bounds query', () => {
const results = quadTree.query({ x: 2000, y: 2000, width: 100, height: 100 })
expect(results.length).toBe(0)
})
it('should handle partial overlaps', () => {
const results = quadTree.query({ x: 25, y: 25, width: 100, height: 100 })
expect(results.length).toBe(4) // 2x2 grid due to overlap
})
it('should handle large query areas efficiently', () => {
const startTime = performance.now()
const results = quadTree.query({ x: 0, y: 0, width: 1000, height: 1000 })
const queryTime = performance.now() - startTime
expect(results.length).toBe(100) // All nodes
expect(queryTime).toBeLessThan(5) // Should be fast
})
})
describe('removal', () => {
it('should remove existing items', () => {
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'node1')
expect(quadTree.size).toBe(1)
const success = quadTree.remove('node1')
expect(success).toBe(true)
expect(quadTree.size).toBe(0)
})
it('should handle removal of non-existent items', () => {
const success = quadTree.remove('nonexistent')
expect(success).toBe(false)
})
})
describe('updating', () => {
it('should update item position', () => {
quadTree.insert('node1', { x: 100, y: 100, width: 50, height: 50 }, 'node1')
const success = quadTree.update('node1', { x: 200, y: 200, width: 50, height: 50 })
expect(success).toBe(true)
// Should not find at old position
const oldResults = quadTree.query({ x: 75, y: 75, width: 100, height: 100 })
expect(oldResults).not.toContain('node1')
// Should find at new position
const newResults = quadTree.query({ x: 175, y: 175, width: 100, height: 100 })
expect(newResults).toContain('node1')
})
})
describe('subdivision', () => {
it('should subdivide when exceeding max items', () => {
// Insert 5 items (max is 4) to trigger subdivision
for (let i = 0; i < 5; i++) {
quadTree.insert(`node${i}`, {
x: i * 10,
y: i * 10,
width: 5,
height: 5
}, `node${i}`)
}
expect(quadTree.size).toBe(5)
// Verify all items can still be found
const allResults = quadTree.query(worldBounds)
expect(allResults.length).toBe(5)
})
})
describe('performance', () => {
it('should handle 1000 nodes efficiently', () => {
const insertStart = performance.now()
// Insert 1000 nodes
for (let i = 0; i < 1000; i++) {
const x = Math.random() * 900
const y = Math.random() * 900
quadTree.insert(`node${i}`, {
x,
y,
width: 50,
height: 50
}, `node${i}`)
}
const insertTime = performance.now() - insertStart
expect(insertTime).toBeLessThan(50) // Should be fast
// Query performance
const queryStart = performance.now()
const results = quadTree.query({ x: 400, y: 400, width: 200, height: 200 })
const queryTime = performance.now() - queryStart
expect(queryTime).toBeLessThan(2) // Queries should be very fast
expect(results.length).toBeGreaterThan(0)
expect(results.length).toBeLessThan(1000) // Should cull most nodes
})
})
describe('edge cases', () => {
it('should handle zero-sized bounds', () => {
const success = quadTree.insert('point', { x: 100, y: 100, width: 0, height: 0 }, 'point')
expect(success).toBe(true)
const results = quadTree.query({ x: 99, y: 99, width: 2, height: 2 })
expect(results).toContain('point')
})
it('should handle items spanning multiple quadrants', () => {
const success = quadTree.insert('large', {
x: 400,
y: 400,
width: 200,
height: 200
}, 'large')
expect(success).toBe(true)
// Should be found when querying any overlapping quadrant
const topLeft = quadTree.query({ x: 0, y: 0, width: 500, height: 500 })
const bottomRight = quadTree.query({ x: 500, y: 500, width: 500, height: 500 })
expect(topLeft).toContain('large')
expect(bottomRight).toContain('large')
})
})
})

View File

@@ -0,0 +1,290 @@
/**
* QuadTree implementation for spatial indexing of nodes
* Optimized for viewport culling in large node graphs
*/
export interface Bounds {
x: number
y: number
width: number
height: number
}
export interface QuadTreeItem<T> {
id: string
bounds: Bounds
data: T
}
interface QuadTreeOptions {
maxDepth?: number
maxItemsPerNode?: number
minNodeSize?: number
}
class QuadNode<T> {
private bounds: Bounds
private depth: number
private maxDepth: number
private maxItems: number
private items: QuadTreeItem<T>[] = []
private children: QuadNode<T>[] | null = null
private divided = false
constructor(
bounds: Bounds,
depth: number = 0,
maxDepth: number = 5,
maxItems: number = 4
) {
this.bounds = bounds
this.depth = depth
this.maxDepth = maxDepth
this.maxItems = maxItems
}
insert(item: QuadTreeItem<T>): boolean {
// Check if item is within bounds
if (!this.contains(item.bounds)) {
return false
}
// If we have space and haven't divided, add to this node
if (this.items.length < this.maxItems && !this.divided) {
this.items.push(item)
return true
}
// If we haven't reached max depth, subdivide
if (!this.divided && this.depth < this.maxDepth) {
this.subdivide()
}
// If divided, insert into children
if (this.divided && this.children) {
for (const child of this.children) {
if (child.insert(item)) {
return true
}
}
}
// If we can't subdivide further, add to this node anyway
this.items.push(item)
return true
}
remove(item: QuadTreeItem<T>): boolean {
const index = this.items.findIndex(i => i.id === item.id)
if (index !== -1) {
this.items.splice(index, 1)
return true
}
if (this.divided && this.children) {
for (const child of this.children) {
if (child.remove(item)) {
return true
}
}
}
return false
}
query(searchBounds: Bounds, found: QuadTreeItem<T>[] = []): QuadTreeItem<T>[] {
// Check if search area intersects with this node
if (!this.intersects(searchBounds)) {
return found
}
// Add items in this node that intersect with search bounds
for (const item of this.items) {
if (this.boundsIntersect(item.bounds, searchBounds)) {
found.push(item)
}
}
// Recursively search children
if (this.divided && this.children) {
for (const child of this.children) {
child.query(searchBounds, found)
}
}
return found
}
private subdivide() {
const { x, y, width, height } = this.bounds
const halfWidth = width / 2
const halfHeight = height / 2
this.children = [
// Top-left
new QuadNode<T>(
{ x, y, width: halfWidth, height: halfHeight },
this.depth + 1,
this.maxDepth,
this.maxItems
),
// Top-right
new QuadNode<T>(
{ x: x + halfWidth, y, width: halfWidth, height: halfHeight },
this.depth + 1,
this.maxDepth,
this.maxItems
),
// Bottom-left
new QuadNode<T>(
{ x, y: y + halfHeight, width: halfWidth, height: halfHeight },
this.depth + 1,
this.maxDepth,
this.maxItems
),
// Bottom-right
new QuadNode<T>(
{ x: x + halfWidth, y: y + halfHeight, width: halfWidth, height: halfHeight },
this.depth + 1,
this.maxDepth,
this.maxItems
)
]
this.divided = true
// Redistribute existing items to children
const itemsToRedistribute = [...this.items]
this.items = []
for (const item of itemsToRedistribute) {
let inserted = false
for (const child of this.children) {
if (child.insert(item)) {
inserted = true
break
}
}
// Keep in parent if it doesn't fit in any child
if (!inserted) {
this.items.push(item)
}
}
}
private contains(itemBounds: Bounds): boolean {
return (
itemBounds.x >= this.bounds.x &&
itemBounds.y >= this.bounds.y &&
itemBounds.x + itemBounds.width <= this.bounds.x + this.bounds.width &&
itemBounds.y + itemBounds.height <= this.bounds.y + this.bounds.height
)
}
private intersects(searchBounds: Bounds): boolean {
return this.boundsIntersect(this.bounds, searchBounds)
}
private boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
// Debug helper to get tree structure
getDebugInfo(): any {
return {
bounds: this.bounds,
depth: this.depth,
itemCount: this.items.length,
divided: this.divided,
children: this.children?.map(child => child.getDebugInfo())
}
}
}
export class QuadTree<T> {
private root: QuadNode<T>
private itemMap: Map<string, QuadTreeItem<T>> = new Map()
private options: Required<QuadTreeOptions>
constructor(bounds: Bounds, options: QuadTreeOptions = {}) {
this.options = {
maxDepth: options.maxDepth ?? 5,
maxItemsPerNode: options.maxItemsPerNode ?? 4,
minNodeSize: options.minNodeSize ?? 50
}
this.root = new QuadNode<T>(
bounds,
0,
this.options.maxDepth,
this.options.maxItemsPerNode
)
}
insert(id: string, bounds: Bounds, data: T): boolean {
const item: QuadTreeItem<T> = { id, bounds, data }
// Remove old item if it exists
if (this.itemMap.has(id)) {
this.remove(id)
}
const success = this.root.insert(item)
if (success) {
this.itemMap.set(id, item)
}
return success
}
remove(id: string): boolean {
const item = this.itemMap.get(id)
if (!item) return false
const success = this.root.remove(item)
if (success) {
this.itemMap.delete(id)
}
return success
}
update(id: string, newBounds: Bounds): boolean {
const item = this.itemMap.get(id)
if (!item) return false
// Remove and re-insert with new bounds
const data = item.data
this.remove(id)
return this.insert(id, newBounds, data)
}
query(searchBounds: Bounds): T[] {
const items = this.root.query(searchBounds)
return items.map(item => item.data)
}
clear() {
this.root = new QuadNode<T>(
this.root['bounds'],
0,
this.options.maxDepth,
this.options.maxItemsPerNode
)
this.itemMap.clear()
}
get size(): number {
return this.itemMap.size
}
getDebugInfo() {
return {
size: this.size,
tree: this.root.getDebugInfo()
}
}
}

View File

@@ -0,0 +1,218 @@
/**
* Performance benchmark for QuadTree vs linear culling
* Measures query performance at different node counts and zoom levels
*/
import { QuadTree, type Bounds } from './QuadTree'
export interface BenchmarkResult {
nodeCount: number
queryCount: number
linearTime: number
quadTreeTime: number
speedup: number
culledPercentage: number
}
export interface NodeData {
id: string
bounds: Bounds
}
export class QuadTreeBenchmark {
private worldBounds: Bounds = { x: -5000, y: -5000, width: 10000, height: 10000 }
// Generate random nodes with realistic clustering
generateNodes(count: number): NodeData[] {
const nodes: NodeData[] = []
for (let i = 0; i < count; i++) {
// 70% clustered, 30% scattered
const isClustered = Math.random() < 0.7
let x: number, y: number
if (isClustered) {
// Pick a cluster center
const clusterX = (Math.random() - 0.5) * 8000
const clusterY = (Math.random() - 0.5) * 8000
// Add node near cluster with gaussian distribution
x = clusterX + (Math.random() - 0.5) * 500
y = clusterY + (Math.random() - 0.5) * 500
} else {
// Scattered randomly
x = (Math.random() - 0.5) * 9000
y = (Math.random() - 0.5) * 9000
}
nodes.push({
id: `node_${i}`,
bounds: {
x,
y,
width: 200 + Math.random() * 100,
height: 100 + Math.random() * 50
}
})
}
return nodes
}
// Linear viewport culling (baseline)
linearCulling(nodes: NodeData[], viewport: Bounds): string[] {
const visible: string[] = []
for (const node of nodes) {
if (this.boundsIntersect(node.bounds, viewport)) {
visible.push(node.id)
}
}
return visible
}
// QuadTree viewport culling
quadTreeCulling(quadTree: QuadTree<string>, viewport: Bounds): string[] {
return quadTree.query(viewport)
}
// Check if two bounds intersect
private boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
// Run benchmark for specific configuration
runBenchmark(
nodeCount: number,
viewportSize: { width: number; height: number },
queryCount: number = 100
): BenchmarkResult {
// Generate nodes
const nodes = this.generateNodes(nodeCount)
// Build QuadTree
const quadTree = new QuadTree<string>(this.worldBounds, {
maxDepth: Math.ceil(Math.log2(nodeCount / 4)),
maxItemsPerNode: 4
})
for (const node of nodes) {
quadTree.insert(node.id, node.bounds, node.id)
}
// Generate random viewports for testing
const viewports: Bounds[] = []
for (let i = 0; i < queryCount; i++) {
const x = (Math.random() - 0.5) * (this.worldBounds.width - viewportSize.width)
const y = (Math.random() - 0.5) * (this.worldBounds.height - viewportSize.height)
viewports.push({
x,
y,
width: viewportSize.width,
height: viewportSize.height
})
}
// Benchmark linear culling
const linearStart = performance.now()
let linearVisibleTotal = 0
for (const viewport of viewports) {
const visible = this.linearCulling(nodes, viewport)
linearVisibleTotal += visible.length
}
const linearTime = performance.now() - linearStart
// Benchmark QuadTree culling
const quadTreeStart = performance.now()
let quadTreeVisibleTotal = 0
for (const viewport of viewports) {
const visible = this.quadTreeCulling(quadTree, viewport)
quadTreeVisibleTotal += visible.length
}
const quadTreeTime = performance.now() - quadTreeStart
// Calculate metrics
const avgVisible = linearVisibleTotal / queryCount
const culledPercentage = ((nodeCount - avgVisible) / nodeCount) * 100
return {
nodeCount,
queryCount,
linearTime,
quadTreeTime,
speedup: linearTime / quadTreeTime,
culledPercentage
}
}
// Run comprehensive benchmark suite
runBenchmarkSuite(): BenchmarkResult[] {
const nodeCounts = [50, 100, 200, 500, 1000, 2000, 5000]
const viewportSizes = [
{ width: 1920, height: 1080 }, // Full HD
{ width: 800, height: 600 }, // Zoomed in
{ width: 4000, height: 3000 } // Zoomed out
]
const results: BenchmarkResult[] = []
for (const nodeCount of nodeCounts) {
for (const viewportSize of viewportSizes) {
const result = this.runBenchmark(nodeCount, viewportSize)
results.push(result)
console.log(
`Nodes: ${nodeCount}, ` +
`Viewport: ${viewportSize.width}x${viewportSize.height}, ` +
`Linear: ${result.linearTime.toFixed(2)}ms, ` +
`QuadTree: ${result.quadTreeTime.toFixed(2)}ms, ` +
`Speedup: ${result.speedup.toFixed(2)}x, ` +
`Culled: ${result.culledPercentage.toFixed(1)}%`
)
}
}
return results
}
// Find optimal maxDepth for given node count
findOptimalDepth(nodeCount: number): number {
const nodes = this.generateNodes(nodeCount)
const viewport = { x: 0, y: 0, width: 1920, height: 1080 }
let bestDepth = 1
let bestTime = Infinity
for (let depth = 1; depth <= 10; depth++) {
const quadTree = new QuadTree<string>(this.worldBounds, {
maxDepth: depth,
maxItemsPerNode: 4
})
// Build tree
for (const node of nodes) {
quadTree.insert(node.id, node.bounds, node.id)
}
// Measure query time
const start = performance.now()
for (let i = 0; i < 100; i++) {
quadTree.query(viewport)
}
const time = performance.now() - start
if (time < bestTime) {
bestTime = time
bestDepth = depth
}
}
return bestDepth
}
}