diff --git a/src/utils/spatial/QuadTree.test.ts b/src/utils/spatial/QuadTree.test.ts new file mode 100644 index 000000000..7f3100aac --- /dev/null +++ b/src/utils/spatial/QuadTree.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { QuadTree, type Bounds } from './QuadTree' + +describe('QuadTree', () => { + let quadTree: QuadTree + const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 } + + beforeEach(() => { + quadTree = new QuadTree(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') + }) + }) +}) \ No newline at end of file diff --git a/src/utils/spatial/QuadTree.ts b/src/utils/spatial/QuadTree.ts new file mode 100644 index 000000000..bd01b0999 --- /dev/null +++ b/src/utils/spatial/QuadTree.ts @@ -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 { + id: string + bounds: Bounds + data: T +} + +interface QuadTreeOptions { + maxDepth?: number + maxItemsPerNode?: number + minNodeSize?: number +} + +class QuadNode { + private bounds: Bounds + private depth: number + private maxDepth: number + private maxItems: number + private items: QuadTreeItem[] = [] + private children: QuadNode[] | 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): 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): 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[] = []): QuadTreeItem[] { + // 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( + { x, y, width: halfWidth, height: halfHeight }, + this.depth + 1, + this.maxDepth, + this.maxItems + ), + // Top-right + new QuadNode( + { x: x + halfWidth, y, width: halfWidth, height: halfHeight }, + this.depth + 1, + this.maxDepth, + this.maxItems + ), + // Bottom-left + new QuadNode( + { x, y: y + halfHeight, width: halfWidth, height: halfHeight }, + this.depth + 1, + this.maxDepth, + this.maxItems + ), + // Bottom-right + new QuadNode( + { 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 { + private root: QuadNode + private itemMap: Map> = new Map() + private options: Required + + constructor(bounds: Bounds, options: QuadTreeOptions = {}) { + this.options = { + maxDepth: options.maxDepth ?? 5, + maxItemsPerNode: options.maxItemsPerNode ?? 4, + minNodeSize: options.minNodeSize ?? 50 + } + + this.root = new QuadNode( + bounds, + 0, + this.options.maxDepth, + this.options.maxItemsPerNode + ) + } + + insert(id: string, bounds: Bounds, data: T): boolean { + const item: QuadTreeItem = { 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( + 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() + } + } +} \ No newline at end of file diff --git a/src/utils/spatial/QuadTreeBenchmark.ts b/src/utils/spatial/QuadTreeBenchmark.ts new file mode 100644 index 000000000..80e2b1d04 --- /dev/null +++ b/src/utils/spatial/QuadTreeBenchmark.ts @@ -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, 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(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(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 + } +} \ No newline at end of file