mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
remove unused spatial index manager (#5381)
This commit is contained in:
@@ -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<QuadTree<string> | null>(null)
|
||||
|
||||
// Performance metrics
|
||||
const metrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
totalNodes: 0,
|
||||
visibleNodes: 0,
|
||||
treeDepth: 0,
|
||||
rebuildCount: 0
|
||||
})
|
||||
|
||||
// Initialize QuadTree
|
||||
const initialize = (bounds: Bounds = defaultBounds) => {
|
||||
quadTree.value = new QuadTree<string>(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)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof useSpatialIndex>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof useSpatialIndex>
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user