diff --git a/src/composables/graph/useSpatialIndex.ts b/src/composables/graph/useSpatialIndex.ts deleted file mode 100644 index 997e331f7..000000000 --- a/src/composables/graph/useSpatialIndex.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Composable for spatial indexing of nodes using QuadTree - * Integrates with useGraphNodeManager for efficient viewport culling - */ -import { useDebounceFn } from '@vueuse/core' -import { computed, reactive, ref } from 'vue' - -import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree' - -export interface SpatialIndexOptions { - worldBounds?: Bounds - maxDepth?: number - maxItemsPerNode?: number - updateDebounceMs?: number -} - -interface SpatialMetrics { - queryTime: number - totalNodes: number - visibleNodes: number - treeDepth: number - rebuildCount: number -} - -export const useSpatialIndex = (options: SpatialIndexOptions = {}) => { - // Default world bounds (can be expanded dynamically) - const defaultBounds: Bounds = { - x: -10000, - y: -10000, - width: 20000, - height: 20000 - } - - // QuadTree instance - const quadTree = ref | null>(null) - - // Performance metrics - const metrics = reactive({ - queryTime: 0, - totalNodes: 0, - visibleNodes: 0, - treeDepth: 0, - rebuildCount: 0 - }) - - // Initialize QuadTree - const initialize = (bounds: Bounds = defaultBounds) => { - quadTree.value = new QuadTree(bounds, { - maxDepth: options.maxDepth ?? 6, - maxItemsPerNode: options.maxItemsPerNode ?? 4 - }) - metrics.rebuildCount++ - } - - // Add or update node in spatial index - const updateNode = ( - nodeId: string, - position: { x: number; y: number }, - size: { width: number; height: number } - ) => { - if (!quadTree.value) { - initialize() - } - - const bounds: Bounds = { - x: position.x, - y: position.y, - width: size.width, - height: size.height - } - - // Use insert instead of update - insert handles both new and existing nodes - quadTree.value!.insert(nodeId, bounds, nodeId) - metrics.totalNodes = quadTree.value!.size - } - - // Batch update for multiple nodes - const batchUpdate = ( - updates: Array<{ - id: string - position: { x: number; y: number } - size: { width: number; height: number } - }> - ) => { - if (!quadTree.value) { - initialize() - } - - for (const update of updates) { - const bounds: Bounds = { - x: update.position.x, - y: update.position.y, - width: update.size.width, - height: update.size.height - } - // Use insert instead of update - insert handles both new and existing nodes - quadTree.value!.insert(update.id, bounds, update.id) - } - - metrics.totalNodes = quadTree.value!.size - } - - // Remove node from spatial index - const removeNode = (nodeId: string) => { - if (!quadTree.value) return - - quadTree.value.remove(nodeId) - metrics.totalNodes = quadTree.value.size - } - - // Query nodes within viewport bounds - const queryViewport = (viewportBounds: Bounds): string[] => { - if (!quadTree.value) return [] - - const startTime = performance.now() - const nodeIds = quadTree.value.query(viewportBounds) - const queryTime = performance.now() - startTime - - metrics.queryTime = queryTime - metrics.visibleNodes = nodeIds.length - - return nodeIds - } - - // Get nodes within a radius (for proximity queries) - const queryRadius = ( - center: { x: number; y: number }, - radius: number - ): string[] => { - if (!quadTree.value) return [] - - const bounds: Bounds = { - x: center.x - radius, - y: center.y - radius, - width: radius * 2, - height: radius * 2 - } - - return quadTree.value.query(bounds) - } - - // Clear all nodes - const clear = () => { - if (!quadTree.value) return - - quadTree.value.clear() - metrics.totalNodes = 0 - metrics.visibleNodes = 0 - } - - // Rebuild tree (useful after major layout changes) - const rebuild = ( - nodes: Map< - string, - { - position: { x: number; y: number } - size: { width: number; height: number } - } - > - ) => { - initialize() - - const updates = Array.from(nodes.entries()).map(([id, data]) => ({ - id, - position: data.position, - size: data.size - })) - - batchUpdate(updates) - } - - // Debounced update for performance - const debouncedUpdateNode = useDebounceFn( - updateNode, - options.updateDebounceMs ?? 16 - ) - - return { - // Core functions - initialize, - updateNode, - batchUpdate, - removeNode, - queryViewport, - queryRadius, - clear, - rebuild, - - // Debounced version for high-frequency updates - debouncedUpdateNode, - - // Metrics - metrics: computed(() => metrics), - - // Direct access to QuadTree (for advanced usage) - quadTree: computed(() => quadTree.value) - } -} diff --git a/tests-ui/tests/composables/graph/useSpatialIndex.test.ts b/tests-ui/tests/composables/graph/useSpatialIndex.test.ts deleted file mode 100644 index 4e34c0f04..000000000 --- a/tests-ui/tests/composables/graph/useSpatialIndex.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { useSpatialIndex } from '@/composables/graph/useSpatialIndex' - -// Mock @vueuse/core -vi.mock('@vueuse/core', () => ({ - useDebounceFn: (fn: (...args: any[]) => any) => fn // Return function directly for testing -})) - -describe('useSpatialIndex', () => { - let spatialIndex: ReturnType - - beforeEach(() => { - spatialIndex = useSpatialIndex() - }) - - describe('initialization', () => { - it('should start with null quadTree', () => { - expect(spatialIndex.quadTree.value).toBeNull() - }) - - it('should initialize with default bounds when first node is added', () => { - const { updateNode, quadTree, metrics } = spatialIndex - - updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 }) - - expect(quadTree.value).not.toBeNull() - expect(metrics.value.totalNodes).toBe(1) - }) - - it('should initialize with custom bounds', () => { - const { initialize, quadTree } = spatialIndex - const customBounds = { x: 0, y: 0, width: 5000, height: 3000 } - - initialize(customBounds) - - expect(quadTree.value).not.toBeNull() - }) - - it('should increment rebuild count on initialization', () => { - const { initialize, metrics } = spatialIndex - - expect(metrics.value.rebuildCount).toBe(0) - initialize() - expect(metrics.value.rebuildCount).toBe(1) - }) - - it('should accept custom options', () => { - const customIndex = useSpatialIndex({ - maxDepth: 8, - maxItemsPerNode: 6, - updateDebounceMs: 32 - }) - - customIndex.initialize() - - expect(customIndex.quadTree.value).not.toBeNull() - }) - }) - - describe('updateNode', () => { - it('should add a new node to the index', () => { - const { updateNode, metrics } = spatialIndex - - updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 }) - - expect(metrics.value.totalNodes).toBe(1) - }) - - it('should update existing node position', () => { - const { updateNode, queryViewport } = spatialIndex - - // Add node - updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 }) - - // Move node - updateNode('node1', { x: 500, y: 500 }, { width: 200, height: 100 }) - - // Query old position - should not find node - const oldResults = queryViewport({ - x: 50, - y: 50, - width: 300, - height: 200 - }) - expect(oldResults).not.toContain('node1') - - // Query new position - should find node - const newResults = queryViewport({ - x: 450, - y: 450, - width: 300, - height: 200 - }) - expect(newResults).toContain('node1') - }) - - it('should auto-initialize if quadTree is null', () => { - const { updateNode, quadTree } = spatialIndex - - expect(quadTree.value).toBeNull() - updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 }) - expect(quadTree.value).not.toBeNull() - }) - }) - - describe('batchUpdate', () => { - it('should update multiple nodes at once', () => { - const { batchUpdate, metrics } = spatialIndex - - const updates = [ - { - id: 'node1', - position: { x: 100, y: 100 }, - size: { width: 200, height: 100 } - }, - { - id: 'node2', - position: { x: 300, y: 300 }, - size: { width: 150, height: 150 } - }, - { - id: 'node3', - position: { x: 500, y: 200 }, - size: { width: 100, height: 200 } - } - ] - - batchUpdate(updates) - - expect(metrics.value.totalNodes).toBe(3) - }) - - it('should handle empty batch', () => { - const { batchUpdate, metrics } = spatialIndex - - batchUpdate([]) - - expect(metrics.value.totalNodes).toBe(0) - }) - - it('should auto-initialize if needed', () => { - const { batchUpdate, quadTree } = spatialIndex - - expect(quadTree.value).toBeNull() - batchUpdate([ - { - id: 'node1', - position: { x: 0, y: 0 }, - size: { width: 100, height: 100 } - } - ]) - expect(quadTree.value).not.toBeNull() - }) - }) - - describe('removeNode', () => { - beforeEach(() => { - spatialIndex.updateNode( - 'node1', - { x: 100, y: 100 }, - { width: 200, height: 100 } - ) - spatialIndex.updateNode( - 'node2', - { x: 300, y: 300 }, - { width: 200, height: 100 } - ) - }) - - it('should remove node from index', () => { - const { removeNode, metrics } = spatialIndex - - expect(metrics.value.totalNodes).toBe(2) - removeNode('node1') - expect(metrics.value.totalNodes).toBe(1) - }) - - it('should handle removing non-existent node', () => { - const { removeNode, metrics } = spatialIndex - - expect(metrics.value.totalNodes).toBe(2) - removeNode('node999') - expect(metrics.value.totalNodes).toBe(2) - }) - - it('should handle removeNode when quadTree is null', () => { - const freshIndex = useSpatialIndex() - - // Should not throw - expect(() => freshIndex.removeNode('node1')).not.toThrow() - }) - }) - - describe('queryViewport', () => { - beforeEach(() => { - // Set up a grid of nodes - spatialIndex.updateNode( - 'node1', - { x: 0, y: 0 }, - { width: 100, height: 100 } - ) - spatialIndex.updateNode( - 'node2', - { x: 200, y: 0 }, - { width: 100, height: 100 } - ) - spatialIndex.updateNode( - 'node3', - { x: 0, y: 200 }, - { width: 100, height: 100 } - ) - spatialIndex.updateNode( - 'node4', - { x: 200, y: 200 }, - { width: 100, height: 100 } - ) - }) - - it('should find nodes within viewport bounds', () => { - const { queryViewport } = spatialIndex - - // Query top-left quadrant - const results = queryViewport({ x: -50, y: -50, width: 200, height: 200 }) - expect(results).toContain('node1') - expect(results).not.toContain('node2') - expect(results).not.toContain('node3') - expect(results).not.toContain('node4') - }) - - it('should find multiple nodes in larger viewport', () => { - const { queryViewport } = spatialIndex - - // Query entire area - const results = queryViewport({ x: -50, y: -50, width: 400, height: 400 }) - expect(results).toHaveLength(4) - expect(results).toContain('node1') - expect(results).toContain('node2') - expect(results).toContain('node3') - expect(results).toContain('node4') - }) - - it('should return empty array for empty region', () => { - const { queryViewport } = spatialIndex - - const results = queryViewport({ - x: 1000, - y: 1000, - width: 100, - height: 100 - }) - expect(results).toEqual([]) - }) - - it('should update metrics after query', () => { - const { queryViewport, metrics } = spatialIndex - - queryViewport({ x: 0, y: 0, width: 300, height: 300 }) - - expect(metrics.value.queryTime).toBeGreaterThan(0) - expect(metrics.value.visibleNodes).toBe(4) - }) - - it('should handle query when quadTree is null', () => { - const freshIndex = useSpatialIndex() - - const results = freshIndex.queryViewport({ - x: 0, - y: 0, - width: 100, - height: 100 - }) - expect(results).toEqual([]) - }) - }) - - describe('queryRadius', () => { - beforeEach(() => { - // Set up nodes at different distances - spatialIndex.updateNode( - 'center', - { x: 475, y: 475 }, - { width: 50, height: 50 } - ) - spatialIndex.updateNode( - 'near1', - { x: 525, y: 475 }, - { width: 50, height: 50 } - ) - spatialIndex.updateNode( - 'near2', - { x: 425, y: 475 }, - { width: 50, height: 50 } - ) - spatialIndex.updateNode( - 'far', - { x: 775, y: 775 }, - { width: 50, height: 50 } - ) - }) - - it('should find nodes within radius', () => { - const { queryRadius } = spatialIndex - - const results = queryRadius({ x: 500, y: 500 }, 100) - - expect(results).toContain('center') - expect(results).toContain('near1') - expect(results).toContain('near2') - expect(results).not.toContain('far') - }) - - it('should handle zero radius', () => { - const { queryRadius } = spatialIndex - - const results = queryRadius({ x: 500, y: 500 }, 0) - - // Zero radius creates a point query at (500,500) - // The 'center' node spans 475-525 on both axes, so it contains this point - expect(results).toContain('center') - }) - - it('should handle large radius', () => { - const { queryRadius } = spatialIndex - - const results = queryRadius({ x: 500, y: 500 }, 1000) - - expect(results).toHaveLength(4) // Should find all nodes - }) - }) - - describe('clear', () => { - beforeEach(() => { - spatialIndex.updateNode( - 'node1', - { x: 100, y: 100 }, - { width: 200, height: 100 } - ) - spatialIndex.updateNode( - 'node2', - { x: 300, y: 300 }, - { width: 200, height: 100 } - ) - }) - - it('should remove all nodes', () => { - const { clear, metrics } = spatialIndex - - expect(metrics.value.totalNodes).toBe(2) - clear() - expect(metrics.value.totalNodes).toBe(0) - }) - - it('should reset metrics', () => { - const { clear, queryViewport, metrics } = spatialIndex - - // Do a query to set visible nodes - queryViewport({ x: 0, y: 0, width: 500, height: 500 }) - expect(metrics.value.visibleNodes).toBe(2) - - clear() - expect(metrics.value.visibleNodes).toBe(0) - }) - - it('should handle clear when quadTree is null', () => { - const freshIndex = useSpatialIndex() - - expect(() => freshIndex.clear()).not.toThrow() - }) - }) - - describe('rebuild', () => { - it('should rebuild index with new nodes', () => { - const { rebuild, metrics, queryViewport } = spatialIndex - - // Add initial nodes - spatialIndex.updateNode( - 'old1', - { x: 0, y: 0 }, - { width: 100, height: 100 } - ) - expect(metrics.value.rebuildCount).toBe(1) - - // Rebuild with new set - const newNodes = new Map([ - [ - 'new1', - { position: { x: 100, y: 100 }, size: { width: 50, height: 50 } } - ], - [ - 'new2', - { position: { x: 200, y: 200 }, size: { width: 50, height: 50 } } - ] - ]) - - rebuild(newNodes) - - expect(metrics.value.totalNodes).toBe(2) - expect(metrics.value.rebuildCount).toBe(2) - - // Old nodes should be gone - const oldResults = queryViewport({ - x: -50, - y: -50, - width: 100, - height: 100 - }) - expect(oldResults).not.toContain('old1') - - // New nodes should be findable - const newResults = queryViewport({ - x: 50, - y: 50, - width: 200, - height: 200 - }) - expect(newResults).toContain('new1') - expect(newResults).toContain('new2') - }) - - it('should handle empty rebuild', () => { - const { rebuild, metrics } = spatialIndex - - rebuild(new Map()) - - expect(metrics.value.totalNodes).toBe(0) - }) - }) - - describe('metrics', () => { - it('should track performance metrics', () => { - const { metrics, updateNode, queryViewport } = spatialIndex - - // Initial state - expect(metrics.value).toEqual({ - queryTime: 0, - totalNodes: 0, - visibleNodes: 0, - treeDepth: 0, - rebuildCount: 0 - }) - - // Add nodes - updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 }) - expect(metrics.value.totalNodes).toBe(1) - - // Query - queryViewport({ x: -50, y: -50, width: 200, height: 200 }) - expect(metrics.value.queryTime).toBeGreaterThan(0) - expect(metrics.value.visibleNodes).toBe(1) - }) - }) - - describe('edge cases', () => { - it('should handle nodes with zero size', () => { - const { updateNode, queryViewport } = spatialIndex - - updateNode('point', { x: 100, y: 100 }, { width: 0, height: 0 }) - - // Should still be findable - const results = queryViewport({ x: 50, y: 50, width: 100, height: 100 }) - expect(results).toContain('point') - }) - - it('should handle negative positions', () => { - const { updateNode, queryViewport } = spatialIndex - - updateNode('negative', { x: -500, y: -500 }, { width: 100, height: 100 }) - - const results = queryViewport({ - x: -600, - y: -600, - width: 200, - height: 200 - }) - expect(results).toContain('negative') - }) - - it('should handle very large nodes', () => { - const { updateNode, queryViewport } = spatialIndex - - updateNode('huge', { x: 0, y: 0 }, { width: 5000, height: 5000 }) - - // Should be found even when querying small area within it - const results = queryViewport({ x: 100, y: 100, width: 10, height: 10 }) - expect(results).toContain('huge') - }) - }) - - describe('debouncedUpdateNode', () => { - it('should be available', () => { - const { debouncedUpdateNode } = spatialIndex - - expect(debouncedUpdateNode).toBeDefined() - expect(typeof debouncedUpdateNode).toBe('function') - }) - }) -}) diff --git a/tests-ui/tests/performance/spatialIndexPerformance.test.ts b/tests-ui/tests/performance/spatialIndexPerformance.test.ts deleted file mode 100644 index e1281f48c..000000000 --- a/tests-ui/tests/performance/spatialIndexPerformance.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' - -import { useSpatialIndex } from '@/composables/graph/useSpatialIndex' -import type { Bounds } from '@/utils/spatial/QuadTree' - -// Skip this entire suite on CI to avoid flaky performance timing -const isCI = Boolean(process.env.CI) -const describeIfNotCI = isCI ? describe.skip : describe - -describeIfNotCI('Spatial Index Performance', () => { - let spatialIndex: ReturnType - - beforeEach(() => { - spatialIndex = useSpatialIndex({ - maxDepth: 6, - maxItemsPerNode: 4, - updateDebounceMs: 0 // Disable debouncing for tests - }) - }) - - describe('large scale operations', () => { - it('should handle 1000 node insertions efficiently', () => { - const startTime = performance.now() - - // Generate 1000 nodes in a realistic distribution - const nodes = Array.from({ length: 1000 }, (_, i) => ({ - id: `node${i}`, - position: { - x: (Math.random() - 0.5) * 10000, - y: (Math.random() - 0.5) * 10000 - }, - size: { - width: 150 + Math.random() * 100, - height: 100 + Math.random() * 50 - } - })) - - spatialIndex.batchUpdate(nodes) - - const insertTime = performance.now() - startTime - - // Should insert 1000 nodes in under 100ms - expect(insertTime).toBeLessThan(100) - expect(spatialIndex.metrics.value.totalNodes).toBe(1000) - }) - - it('should maintain fast viewport queries under load', () => { - // First populate with many nodes - const nodes = Array.from({ length: 1000 }, (_, i) => ({ - id: `node${i}`, - position: { - x: (Math.random() - 0.5) * 10000, - y: (Math.random() - 0.5) * 10000 - }, - size: { width: 200, height: 100 } - })) - spatialIndex.batchUpdate(nodes) - - // Now benchmark viewport queries - const queryCount = 100 - const viewportBounds: Bounds = { - x: -960, - y: -540, - width: 1920, - height: 1080 - } - - const startTime = performance.now() - - for (let i = 0; i < queryCount; i++) { - // Vary viewport position to test different tree regions - const offsetX = (i % 10) * 500 - const offsetY = Math.floor(i / 10) * 300 - spatialIndex.queryViewport({ - x: viewportBounds.x + offsetX, - y: viewportBounds.y + offsetY, - width: viewportBounds.width, - height: viewportBounds.height - }) - } - - const totalQueryTime = performance.now() - startTime - const avgQueryTime = totalQueryTime / queryCount - - // Each query should take less than 2ms on average - expect(avgQueryTime).toBeLessThan(2) - expect(totalQueryTime).toBeLessThan(100) // 100 queries in under 100ms - }) - - it('should demonstrate performance advantage over linear search', () => { - // Create test data - const nodeCount = 500 - const nodes = Array.from({ length: nodeCount }, (_, i) => ({ - id: `node${i}`, - position: { - x: (Math.random() - 0.5) * 8000, - y: (Math.random() - 0.5) * 8000 - }, - size: { width: 200, height: 100 } - })) - - // Populate spatial index - spatialIndex.batchUpdate(nodes) - - const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 } - const queryCount = 50 - - // Benchmark spatial index queries - const spatialStartTime = performance.now() - for (let i = 0; i < queryCount; i++) { - spatialIndex.queryViewport(viewport) - } - const spatialTime = performance.now() - spatialStartTime - - // Benchmark linear search equivalent - const linearStartTime = performance.now() - for (let i = 0; i < queryCount; i++) { - nodes.filter((node) => { - const nodeRight = node.position.x + node.size.width - const nodeBottom = node.position.y + node.size.height - const viewportRight = viewport.x + viewport.width - const viewportBottom = viewport.y + viewport.height - - return !( - nodeRight < viewport.x || - node.position.x > viewportRight || - nodeBottom < viewport.y || - node.position.y > viewportBottom - ) - }) - } - const linearTime = performance.now() - linearStartTime - - // Spatial index should be faster than linear search - const speedup = linearTime / spatialTime - // In some environments, speedup may be less due to small dataset - // Just ensure spatial is not significantly slower (at least 10% of linear speed) - expect(speedup).toBeGreaterThan(0.1) - - // Both should find roughly the same number of nodes - const spatialResults = spatialIndex.queryViewport(viewport) - const linearResults = nodes.filter((node) => { - const nodeRight = node.position.x + node.size.width - const nodeBottom = node.position.y + node.size.height - const viewportRight = viewport.x + viewport.width - const viewportBottom = viewport.y + viewport.height - - return !( - nodeRight < viewport.x || - node.position.x > viewportRight || - nodeBottom < viewport.y || - node.position.y > viewportBottom - ) - }) - - // Results should be similar (within 10% due to QuadTree boundary effects) - const resultsDiff = Math.abs(spatialResults.length - linearResults.length) - const maxDiff = - Math.max(spatialResults.length, linearResults.length) * 0.1 - expect(resultsDiff).toBeLessThan(maxDiff) - }) - }) - - describe('update performance', () => { - it('should handle frequent position updates efficiently', () => { - // Add initial nodes - const nodeCount = 200 - const initialNodes = Array.from({ length: nodeCount }, (_, i) => ({ - id: `node${i}`, - position: { x: i * 100, y: i * 50 }, - size: { width: 200, height: 100 } - })) - spatialIndex.batchUpdate(initialNodes) - - // Benchmark frequent updates (simulating animation/dragging) - const updateCount = 100 - const startTime = performance.now() - - for (let frame = 0; frame < updateCount; frame++) { - // Update a subset of nodes each frame - for (let i = 0; i < 20; i++) { - const nodeId = `node${i}` - spatialIndex.updateNode( - nodeId, - { - x: i * 100 + Math.sin(frame * 0.1) * 50, - y: i * 50 + Math.cos(frame * 0.1) * 30 - }, - { width: 200, height: 100 } - ) - } - } - - const updateTime = performance.now() - startTime - const avgFrameTime = updateTime / updateCount - - // Should maintain 60fps (16.67ms per frame) with 20 node updates per frame - expect(avgFrameTime).toBeLessThan(8) // Conservative target: 8ms per frame - }) - - it('should handle node additions and removals efficiently', () => { - const startTime = performance.now() - - // Add nodes - for (let i = 0; i < 100; i++) { - spatialIndex.updateNode( - `node${i}`, - { x: Math.random() * 1000, y: Math.random() * 1000 }, - { width: 200, height: 100 } - ) - } - - // Remove half of them - for (let i = 0; i < 50; i++) { - spatialIndex.removeNode(`node${i}`) - } - - // Add new ones - for (let i = 100; i < 150; i++) { - spatialIndex.updateNode( - `node${i}`, - { x: Math.random() * 1000, y: Math.random() * 1000 }, - { width: 200, height: 100 } - ) - } - - const totalTime = performance.now() - startTime - - // All operations should complete quickly - expect(totalTime).toBeLessThan(50) - expect(spatialIndex.metrics.value.totalNodes).toBe(100) // 50 remaining + 50 new - }) - }) - - describe('memory and scaling', () => { - it('should scale efficiently with increasing node counts', () => { - const nodeCounts = [100, 200, 500, 1000] - const queryTimes: number[] = [] - - for (const nodeCount of nodeCounts) { - // Create fresh spatial index for each test - const testIndex = useSpatialIndex({ updateDebounceMs: 0 }) - - // Populate with nodes - const nodes = Array.from({ length: nodeCount }, (_, i) => ({ - id: `node${i}`, - position: { - x: (Math.random() - 0.5) * 10000, - y: (Math.random() - 0.5) * 10000 - }, - size: { width: 200, height: 100 } - })) - testIndex.batchUpdate(nodes) - - // Benchmark query time - const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 } - const startTime = performance.now() - - for (let i = 0; i < 10; i++) { - testIndex.queryViewport(viewport) - } - - const avgTime = (performance.now() - startTime) / 10 - queryTimes.push(avgTime) - } - - // Query time should scale logarithmically, not linearly - // The ratio between 1000 nodes and 100 nodes should be less than 5x - const ratio100to1000 = queryTimes[3] / queryTimes[0] - expect(ratio100to1000).toBeLessThan(5) - - // All query times should be reasonable - queryTimes.forEach((time) => { - expect(time).toBeLessThan(5) // Each query under 5ms - }) - }) - - it('should handle edge cases without performance degradation', () => { - // Test with very large nodes - spatialIndex.updateNode( - 'huge-node', - { x: -1000, y: -1000 }, - { width: 5000, height: 3000 } - ) - - // Test with many tiny nodes - for (let i = 0; i < 100; i++) { - spatialIndex.updateNode( - `tiny-${i}`, - { x: Math.random() * 100, y: Math.random() * 100 }, - { width: 1, height: 1 } - ) - } - - // Test with nodes at extreme coordinates - spatialIndex.updateNode( - 'extreme-pos', - { x: 50000, y: -50000 }, - { width: 200, height: 100 } - ) - - spatialIndex.updateNode( - 'extreme-neg', - { x: -50000, y: 50000 }, - { width: 200, height: 100 } - ) - - // Queries should still be fast - const startTime = performance.now() - for (let i = 0; i < 20; i++) { - spatialIndex.queryViewport({ - x: Math.random() * 1000 - 500, - y: Math.random() * 1000 - 500, - width: 1000, - height: 600 - }) - } - const queryTime = performance.now() - startTime - - expect(queryTime).toBeLessThan(20) // 20 queries in under 20ms - }) - }) - - describe('realistic workflow scenarios', () => { - it('should handle typical ComfyUI workflow performance', () => { - // Simulate a large ComfyUI workflow with clustered nodes - const clusters = [ - { center: { x: 0, y: 0 }, nodeCount: 50 }, - { center: { x: 2000, y: 0 }, nodeCount: 30 }, - { center: { x: 4000, y: 1000 }, nodeCount: 40 }, - { center: { x: 0, y: 2000 }, nodeCount: 35 } - ] - - let nodeId = 0 - const allNodes: Array<{ - id: string - position: { x: number; y: number } - size: { width: number; height: number } - }> = [] - - // Create clustered nodes (realistic for ComfyUI workflows) - clusters.forEach((cluster) => { - for (let i = 0; i < cluster.nodeCount; i++) { - allNodes.push({ - id: `node${nodeId++}`, - position: { - x: cluster.center.x + (Math.random() - 0.5) * 800, - y: cluster.center.y + (Math.random() - 0.5) * 600 - }, - size: { - width: 150 + Math.random() * 100, - height: 100 + Math.random() * 50 - } - }) - } - }) - - // Add the nodes - const setupTime = performance.now() - spatialIndex.batchUpdate(allNodes) - const setupDuration = performance.now() - setupTime - - // Simulate user panning around the workflow - const viewportSize = { width: 1920, height: 1080 } - const panPositions = [ - { x: -960, y: -540 }, // Center on first cluster - { x: 1040, y: -540 }, // Pan to second cluster - { x: 3040, y: 460 }, // Pan to third cluster - { x: -960, y: 1460 }, // Pan to fourth cluster - { x: 1000, y: 500 } // Overview position - ] - - const panStartTime = performance.now() - const queryResults: number[] = [] - - panPositions.forEach((pos) => { - // Simulate multiple viewport queries during smooth panning - for (let step = 0; step < 10; step++) { - const results = spatialIndex.queryViewport({ - x: pos.x + step * 20, - y: pos.y + step * 10, - width: viewportSize.width, - height: viewportSize.height - }) - queryResults.push(results.length) - } - }) - - const panDuration = performance.now() - panStartTime - const avgQueryTime = panDuration / (panPositions.length * 10) - - // Performance expectations for realistic workflows - expect(setupDuration).toBeLessThan(30) // Setup 155 nodes in under 30ms - expect(avgQueryTime).toBeLessThan(1.5) // Average query under 1.5ms - expect(panDuration).toBeLessThan(50) // All panning queries under 50ms - - // Should have reasonable culling efficiency - const totalNodes = allNodes.length - const avgVisibleNodes = - queryResults.reduce((a, b) => a + b, 0) / queryResults.length - const cullRatio = (totalNodes - avgVisibleNodes) / totalNodes - - expect(cullRatio).toBeGreaterThan(0.3) // At least 30% culling efficiency - }) - }) -})