diff --git a/.husky/pre-commit.backup b/.husky/pre-commit
similarity index 100%
rename from .husky/pre-commit.backup
rename to .husky/pre-commit
diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue
index 5de9a6a36..5ff91f61a 100644
--- a/src/components/graph/GraphCanvas.vue
+++ b/src/components/graph/GraphCanvas.vue
@@ -441,11 +441,11 @@ watch(
)
// Transform state for viewport culling
-const { isNodeInViewport } = useTransformState()
+const { syncWithCanvas } = useTransformState()
// Viewport culling settings - use feature flags as defaults but allow debug override
-const viewportCullingEnabled = ref(false) // Debug override, starts false for testing
-const cullingMargin = ref(0.2) // Debug override
+const viewportCullingEnabled = ref(true) // Enable viewport culling
+const cullingMargin = ref(0.2) // 20% margin outside viewport
// Initialize from feature flags
watch(
@@ -467,47 +467,63 @@ watch(
// Replace problematic computed property with proper reactive system
const nodesToRender = computed(() => {
// Access performanceMetrics to trigger on RAF updates
- const updateCount = performanceMetrics.updateTime
+ void performanceMetrics.updateTime
- console.log(
- '[GraphCanvas] Computing nodesToRender. renderAllNodes:',
- renderAllNodes.value,
- 'vueNodeData size:',
- vueNodeData.value.size,
- 'updateCount:',
- updateCount,
- 'transformPaneEnabled:',
- transformPaneEnabled.value,
- 'shouldRenderVueNodes:',
- shouldRenderVueNodes.value
- )
- if (!renderAllNodes.value || !comfyApp.graph) {
- console.log(
- '[GraphCanvas] Early return - renderAllNodes:',
- renderAllNodes.value,
- 'graph:',
- !!comfyApp.graph
- )
+ if (!renderAllNodes.value || !comfyApp.graph || !transformPaneEnabled.value) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
- // Apply viewport culling
- if (viewportCullingEnabled.value && nodeManager) {
- const filtered = allNodes.filter((nodeData) => {
- const originalNode = nodeManager?.getNode(nodeData.id)
- if (!originalNode) return false
+ // Apply viewport culling - check if node bounds intersect with viewport
+ if (
+ viewportCullingEnabled.value &&
+ nodeManager &&
+ canvasStore.canvas &&
+ comfyApp.canvas
+ ) {
+ const canvas = canvasStore.canvas
+ const manager = nodeManager
- const inViewport = isNodeInViewport(
- originalNode.pos,
- originalNode.size,
- canvasViewport.value,
- cullingMargin.value
+ // Ensure transform is synced before checking visibility
+ syncWithCanvas(comfyApp.canvas)
+
+ const ds = canvas.ds
+
+ // Access transform time to make this reactive to transform changes
+ void lastTransformTime.value
+
+ // Work in screen space - viewport is simply the canvas element size
+ const viewport_width = canvas.canvas.width
+ const viewport_height = canvas.canvas.height
+
+ // Add margin that represents a constant distance in canvas space
+ // Convert canvas units to screen pixels by multiplying by scale
+ const canvasMarginDistance = 200 // Fixed margin in canvas units
+ const margin_x = canvasMarginDistance * ds.scale
+ const margin_y = canvasMarginDistance * ds.scale
+
+ const filtered = allNodes.filter((nodeData) => {
+ const node = manager.getNode(nodeData.id)
+ if (!node) return false
+
+ // Transform node position to screen space (same as DOM widgets)
+ const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
+ const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
+ const screen_width = node.size[0] * ds.scale
+ const screen_height = node.size[1] * ds.scale
+
+ // Check if node bounds intersect with expanded viewport (in screen space)
+ const isVisible = !(
+ screen_x + screen_width < -margin_x ||
+ screen_x > viewport_width + margin_x ||
+ screen_y + screen_height < -margin_y ||
+ screen_y > viewport_height + margin_y
)
- return inViewport
+ return isVisible
})
+
return filtered
}
@@ -530,9 +546,42 @@ watch(
}
)
+// Update performance metrics when node counts change
+watch(
+ () => [vueNodeData.value.size, nodesToRender.value.length],
+ ([totalNodes, visibleNodes]) => {
+ performanceMetrics.nodeCount = totalNodes
+ performanceMetrics.culledCount = totalNodes - visibleNodes
+ }
+)
+
// Integrate change detection with TransformPane RAF
+// Track previous transform to detect changes
+let lastScale = 1
+let lastOffsetX = 0
+let lastOffsetY = 0
+
const handleTransformUpdate = (time: number) => {
lastTransformTime.value = time
+
+ // Sync transform state only when it changes (avoids reflows)
+ if (comfyApp.canvas?.ds) {
+ const currentScale = comfyApp.canvas.ds.scale
+ const currentOffsetX = comfyApp.canvas.ds.offset[0]
+ const currentOffsetY = comfyApp.canvas.ds.offset[1]
+
+ if (
+ currentScale !== lastScale ||
+ currentOffsetX !== lastOffsetX ||
+ currentOffsetY !== lastOffsetY
+ ) {
+ syncWithCanvas(comfyApp.canvas)
+ lastScale = currentScale
+ lastOffsetX = currentOffsetX
+ lastOffsetY = currentOffsetY
+ }
+ }
+
// Detect node changes during transform updates
detectChangesInRAF()
@@ -706,7 +755,7 @@ const loadCustomNodesI18n = async () => {
i18n.global.mergeLocaleMessage(locale, message)
})
} catch (error) {
- console.error('Failed to load custom nodes i18n', error)
+ // Ignore i18n loading errors - not critical
}
}
@@ -735,9 +784,6 @@ onMounted(async () => {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
- console.log(
- 'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
- )
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
diff --git a/src/components/graph/debug/QuadTreeDebugSection.vue b/src/components/graph/debug/QuadTreeDebugSection.vue
new file mode 100644
index 000000000..dcfa7990f
--- /dev/null
+++ b/src/components/graph/debug/QuadTreeDebugSection.vue
@@ -0,0 +1,109 @@
+
+
+
QuadTree Spatial Index
+
+
+
+
+
+
+
+
+ {{ statusMessage }}
+
+
+
+
+ Strategy: {{ strategy }}
+ Total Nodes: {{ metrics.totalNodes }}
+ Visible Nodes: {{ metrics.visibleNodes }}
+ Query Time: {{ metrics.queryTime.toFixed(2) }}ms
+ Tree Depth: {{ metrics.treeDepth }}
+ Culling Efficiency: {{ cullingEfficiency }}
+ Rebuilds: {{ metrics.rebuildCount }}
+
+
+
+
+
+
+
+
+
+
+
Performance vs Linear:
+
Speedup: {{ performanceComparison.speedup }}x
+
+ Break-even: ~{{ performanceComparison.breakEvenNodeCount }} nodes
+
+
+
+
+
+
+
diff --git a/src/components/graph/debug/QuadTreeVisualization.vue b/src/components/graph/debug/QuadTreeVisualization.vue
new file mode 100644
index 000000000..28ade900d
--- /dev/null
+++ b/src/components/graph/debug/QuadTreeVisualization.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
diff --git a/src/components/graph/vueNodes/NodeWidgets.vue b/src/components/graph/vueNodes/NodeWidgets.vue
index aa60ea0c9..5429d5527 100644
--- a/src/components/graph/vueNodes/NodeWidgets.vue
+++ b/src/components/graph/vueNodes/NodeWidgets.vue
@@ -58,56 +58,29 @@ const widgets = computed((): SafeWidgetData[] => {
const info = nodeInfo.value
if (!info?.widgets) return []
- console.log('[NodeWidgets] Raw widgets from nodeInfo:', info.widgets)
-
const filtered = (info.widgets as SafeWidgetData[]).filter(
(w: SafeWidgetData) => !w.options?.hidden
)
- console.log('[NodeWidgets] Filtered widgets:', filtered)
-
return filtered
})
// Only render widgets that have Vue component support
const supportedWidgets = computed((): SafeWidgetData[] => {
const allWidgets = widgets.value
- console.log('[NodeWidgets] All widgets:', allWidgets)
-
const supported = allWidgets.filter((widget: SafeWidgetData) => {
- const isSupported = shouldRenderAsVue(widget)
- console.log(
- '[NodeWidgets] Widget:',
- widget.name,
- 'type:',
- widget.type,
- 'supported:',
- isSupported
- )
- return isSupported
+ return shouldRenderAsVue(widget)
})
-
- console.log('[NodeWidgets] Supported widgets:', supported)
return supported
})
// Get Vue component for widget
const getVueComponent = (widget: SafeWidgetData) => {
const componentName = getWidgetComponent(widget.type)
- console.log(
- '[NodeWidgets] Widget type:',
- widget.type,
- 'Component name:',
- componentName
- )
-
const component = widgetTypeToComponent[componentName]
- console.log('[NodeWidgets] Resolved component:', component)
-
return component || WidgetInputText // Fallback to text input
}
const getWidgetValue = (widget: SafeWidgetData): unknown => {
- console.log('[NodeWidgets] Widget value for', widget.name, ':', widget.value)
return widget.value
}
diff --git a/src/composables/element/useTransformState.ts b/src/composables/element/useTransformState.ts
index 3d8f3a809..095eaa822 100644
--- a/src/composables/element/useTransformState.ts
+++ b/src/composables/element/useTransformState.ts
@@ -1,6 +1,53 @@
/**
* Composable for managing transform state synchronized with LiteGraph canvas
- * Provides reactive transform state and coordinate conversion utilities
+ *
+ * This composable is a critical part of the hybrid rendering architecture that
+ * allows Vue components to render in perfect alignment with LiteGraph's canvas.
+ *
+ * ## Core Concept
+ *
+ * LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
+ * Vue components need to render nodes on top of this canvas. The challenge is
+ * synchronizing the coordinate systems:
+ *
+ * - LiteGraph: Uses canvas coordinates with its own transform matrix
+ * - Vue/DOM: Uses screen coordinates with CSS transforms
+ *
+ * ## Solution: Transform Container Pattern
+ *
+ * Instead of transforming individual nodes (O(n) complexity), we:
+ * 1. Mirror LiteGraph's transform matrix to a single CSS container
+ * 2. Place all Vue nodes as children with simple absolute positioning
+ * 3. Achieve O(1) transform updates regardless of node count
+ *
+ * ## Coordinate Systems
+ *
+ * - **Canvas coordinates**: LiteGraph's internal coordinate system
+ * - **Screen coordinates**: Browser's viewport coordinate system
+ * - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
+ *
+ * ## Performance Benefits
+ *
+ * - GPU acceleration via CSS transforms
+ * - No layout thrashing (only transform changes)
+ * - Efficient viewport culling calculations
+ * - Scales to 1000+ nodes while maintaining 60 FPS
+ *
+ * @example
+ * ```typescript
+ * const { camera, transformStyle, canvasToScreen } = useTransformState()
+ *
+ * // In template
+ *
+ *
+ *
+ *
+ * // Convert coordinates
+ * const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
+ * ```
*/
import type { LGraphCanvas } from '@comfyorg/litegraph'
import { computed, reactive, readonly } from 'vue'
@@ -30,16 +77,36 @@ export const useTransformState = () => {
transformOrigin: '0 0'
}))
- // Sync with LiteGraph during draw cycle
+ /**
+ * Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
+ *
+ * Called every frame via RAF to ensure Vue components stay aligned with canvas.
+ * This is the heart of the hybrid rendering system - it bridges the gap between
+ * LiteGraph's canvas transforms and Vue's reactive system.
+ *
+ * @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
+ */
const syncWithCanvas = (canvas: LGraphCanvas) => {
if (!canvas || !canvas.ds) return
+ // Mirror LiteGraph's transform state to Vue's reactive state
+ // ds.offset = pan offset, ds.scale = zoom level
camera.x = canvas.ds.offset[0]
camera.y = canvas.ds.offset[1]
camera.z = canvas.ds.scale || 1
}
- // Convert canvas coordinates to screen coordinates
+ /**
+ * Converts canvas coordinates to screen coordinates
+ *
+ * Applies the same transform that LiteGraph uses for rendering.
+ * Essential for positioning Vue components to align with canvas elements.
+ *
+ * Formula: screen = canvas * scale + offset
+ *
+ * @param point - Point in canvas coordinate system
+ * @returns Point in screen coordinate system
+ */
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
@@ -47,7 +114,17 @@ export const useTransformState = () => {
}
}
- // Convert screen coordinates to canvas coordinates
+ /**
+ * Converts screen coordinates to canvas coordinates
+ *
+ * Inverse of canvasToScreen. Useful for hit testing and converting
+ * mouse events back to canvas space.
+ *
+ * Formula: canvas = (screen - offset) / scale
+ *
+ * @param point - Point in screen coordinate system
+ * @returns Point in canvas coordinate system
+ */
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
@@ -110,6 +187,28 @@ export const useTransformState = () => {
)
}
+ // Get viewport bounds in canvas coordinates (for spatial index queries)
+ const getViewportBounds = (
+ viewport: { width: number; height: number },
+ margin: number = 0.2
+ ) => {
+ const marginX = viewport.width * margin
+ const marginY = viewport.height * margin
+
+ const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
+ const bottomRight = screenToCanvas({
+ x: viewport.width + marginX,
+ y: viewport.height + marginY
+ })
+
+ return {
+ x: topLeft.x,
+ y: topLeft.y,
+ width: bottomRight.x - topLeft.x,
+ height: bottomRight.y - topLeft.y
+ }
+ }
+
return {
camera: readonly(camera),
transformStyle,
@@ -117,6 +216,7 @@ export const useTransformState = () => {
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
- isNodeInViewport
+ isNodeInViewport,
+ getViewportBounds
}
}
diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts
index 99fd4536a..3530b4dd7 100644
--- a/src/composables/graph/useGraphNodeManager.ts
+++ b/src/composables/graph/useGraphNodeManager.ts
@@ -4,7 +4,8 @@
*/
import type { LGraph, LGraphNode } from '@comfyorg/litegraph'
import { nextTick, reactive, readonly } from 'vue'
-import { QuadTree, type Bounds } from '@/utils/spatial/QuadTree'
+
+import { type Bounds, QuadTree } from '../../utils/spatial/QuadTree'
export interface NodeState {
visible: boolean
@@ -52,10 +53,8 @@ export interface VueNodeData {
}
export interface SpatialMetrics {
- strategy: 'linear' | 'quadtree'
queryTime: number
nodesInIndex: number
- threshold: number
}
export interface GraphNodeManager {
@@ -86,14 +85,12 @@ export interface GraphNodeManager {
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
-
+
// Debug
getSpatialIndexDebugInfo(): any | null
}
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
- console.log('[useGraphNodeManager] Initializing with graph:', graph)
-
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map())
const nodeState = reactive(new Map())
@@ -120,21 +117,17 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
adaptiveQuality: false
})
- // Spatial indexing for large graphs (auto-enables at 100+ nodes)
- const SPATIAL_INDEX_THRESHOLD = 100
+ // Spatial indexing using QuadTree
const spatialIndex = new QuadTree(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
- let spatialIndexEnabled = false
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive({
- strategy: 'linear',
queryTime: 0,
- nodesInIndex: 0,
- threshold: SPATIAL_INDEX_THRESHOLD
+ nodesInIndex: 0
})
// FPS tracking state
@@ -146,7 +139,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
frameCount++
const now = performance.now()
const elapsed = now - lastFrameTime
-
+
if (elapsed >= 1000) {
performanceMetrics.fps = Math.round((frameCount * 1000) / elapsed)
performanceMetrics.frameTime = elapsed / frameCount
@@ -157,7 +150,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const startFPSTracking = () => {
if (fpsUpdateInterval) return
-
+
const trackFrame = () => {
updateFPS()
fpsUpdateInterval = requestAnimationFrame(trackFrame)
@@ -221,12 +214,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
callback: widget.callback
}
} catch (error) {
- console.warn(
- '[useGraphNodeManager] Error extracting widget data for',
- widget.name,
- ':',
- error
- )
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
@@ -257,36 +244,35 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
const nodeId = String(node.id)
-
- node.widgets?.forEach(widget => {
+
+ node.widgets?.forEach((widget) => {
// Create a new callback that updates the widget value AND the Vue state
const originalCallback = widget.callback
- widget.callback = (value: string | number | boolean | object | undefined, ...args: unknown[]) => {
+ widget.callback = (value: unknown) => {
// 1. Update the widget value in LiteGraph
- widget.value = value
-
+ // Widget value can be various types, cast appropriately
+ widget.value = value as string | number | boolean | object | undefined
+
// 2. Call the original callback if it exists
if (originalCallback) {
- originalCallback.call(widget, value as Parameters[0], ...args)
+ originalCallback.call(widget, value)
}
-
+
// 3. Update Vue state
try {
const currentData = vueNodeData.get(nodeId)
if (currentData?.widgets) {
- const updatedWidgets = currentData.widgets.map(w =>
- w.name === widget.name
- ? { ...w, value: value }
- : w
+ const updatedWidgets = currentData.widgets.map((w) =>
+ w.name === widget.name ? { ...w, value: value } : w
)
- vueNodeData.set(nodeId, {
- ...currentData,
- widgets: updatedWidgets
+ vueNodeData.set(nodeId, {
+ ...currentData,
+ widgets: updatedWidgets
})
}
performanceMetrics.callbackUpdateCount++
} catch (error) {
- console.warn(`[useGraphNodeManager] Failed to update Vue state for widget ${widget.name}:`, error)
+ // Ignore widget update errors to prevent cascade failures
}
}
})
@@ -363,7 +349,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes
- for (const [id] of vueNodeData) {
+ for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
@@ -371,6 +357,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
+ spatialIndex.remove(id)
}
}
@@ -394,6 +381,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
+
+ // Add to spatial index
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ spatialIndex.insert(id, bounds, id)
}
})
@@ -405,61 +401,28 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
// Most performant: Direct position sync without re-setting entire node
- // Query visible nodes using spatial index when available
+ // Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set => {
const startTime = performance.now()
-
- let visibleIds: Set
-
- if (spatialIndexEnabled) {
- // Use QuadTree for fast spatial query
- const results = spatialIndex.query(viewportBounds)
- visibleIds = new Set(results)
- } else {
- // Use simple linear search for small graphs
- visibleIds = new Set()
- for (const [id, pos] of nodePositions) {
- const size = nodeSizes.get(id)
- if (!size) continue
-
- // Simple bounds check
- if (!(
- pos.x + size.width < viewportBounds.x ||
- pos.x > viewportBounds.x + viewportBounds.width ||
- pos.y + size.height < viewportBounds.y ||
- pos.y > viewportBounds.y + viewportBounds.height
- )) {
- visibleIds.add(id)
- }
- }
- }
-
+
+ // Use QuadTree for fast spatial query
+ const results: string[] = spatialIndex.query(viewportBounds)
+ const visibleIds = new Set(results)
+
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
-
+
return visibleIds
}
const detectChangesInRAF = () => {
const startTime = performance.now()
-
+
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
- // Check if we should enable/disable spatial indexing
- const nodeCount = graph._nodes.length
- const shouldUseSpatialIndex = nodeCount >= SPATIAL_INDEX_THRESHOLD
-
- if (shouldUseSpatialIndex !== spatialIndexEnabled) {
- spatialIndexEnabled = shouldUseSpatialIndex
- if (!spatialIndexEnabled) {
- spatialIndex.clear()
- }
- spatialMetrics.strategy = spatialIndexEnabled ? 'quadtree' : 'linear'
- }
-
// Update reactive positions and sizes
for (const node of graph._nodes) {
const id = String(node.id)
@@ -489,8 +452,8 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
sizeChanged = true
}
- // Update spatial index if enabled and position/size changed
- if (spatialIndexEnabled && (posChanged || sizeChanged)) {
+ // Update spatial index if position/size changed
+ if (posChanged || sizeChanged) {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
@@ -506,10 +469,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
performanceMetrics.updateTime = endTime - startTime
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
- state => state.culled
+ (state) => state.culled
).length
- spatialMetrics.nodesInIndex = spatialIndexEnabled ? spatialIndex.size : 0
-
+ spatialMetrics.nodesInIndex = spatialIndex.size
+
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
}
@@ -522,12 +485,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Override callbacks
graph.onNodeAdded = (node: LGraphNode) => {
- console.log('[useGraphNodeManager] onNodeAdded:', node.id)
const id = String(node.id)
// Store non-reactive reference to original node
nodeRefs.set(id, node)
-
+
// Set up widget callbacks BEFORE extracting data
setupNodeWidgetCallbacks(node)
@@ -544,18 +506,16 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
-
- // Add to spatial index if enabled
- if (spatialIndexEnabled) {
- const bounds: Bounds = {
- x: node.pos[0],
- y: node.pos[1],
- width: node.size[0],
- height: node.size[1]
- }
- spatialIndex.insert(id, bounds, id)
+
+ // Add to spatial index
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
}
-
+ spatialIndex.insert(id, bounds, id)
+
if (originalOnNodeAdded) {
void originalOnNodeAdded(node)
}
@@ -563,12 +523,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
graph.onNodeRemoved = (node: LGraphNode) => {
const id = String(node.id)
-
- // Remove from spatial index if enabled
- if (spatialIndexEnabled) {
- spatialIndex.remove(id)
- }
-
+
+ // Remove from spatial index
+ spatialIndex.remove(id)
+
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
@@ -580,7 +538,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Initial sync
syncWithGraph()
-
+
// Start FPS tracking
startFPSTracking()
@@ -595,7 +553,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
-
+
// Stop FPS tracking
stopFPSTracking()
@@ -616,6 +574,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Set up event listeners immediately
const cleanup = setupEventListeners()
+ // Process any existing nodes after event listeners are set up
+ if (graph._nodes && graph._nodes.length > 0) {
+ graph._nodes.forEach((node: LGraphNode) => {
+ if (graph.onNodeAdded) {
+ graph.onNodeAdded(node)
+ }
+ })
+ }
+
return {
vueNodeData: readonly(vueNodeData) as ReadonlyMap,
nodeState: readonly(nodeState) as ReadonlyMap,
@@ -636,6 +603,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
getVisibleNodeIds,
performanceMetrics,
spatialMetrics: readonly(spatialMetrics),
- getSpatialIndexDebugInfo: () => spatialIndexEnabled ? spatialIndex.getDebugInfo() : null
+ getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
}
}
diff --git a/src/composables/graph/useSpatialIndex.ts b/src/composables/graph/useSpatialIndex.ts
new file mode 100644
index 000000000..b234c3fe5
--- /dev/null
+++ b/src/composables/graph/useSpatialIndex.ts
@@ -0,0 +1,210 @@
+/**
+ * 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
+ enableDebugVisualization?: boolean
+ 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
+ })
+
+ // Debug visualization data (unused for now but may be used in future)
+ // const debugBounds = ref([])
+
+ // 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
+ }
+
+ quadTree.value!.update(nodeId, bounds)
+ 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
+ }
+ quadTree.value!.update(update.id, bounds)
+ }
+
+ 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)
+ }
+
+ // Get debug visualization data
+ const getDebugVisualization = () => {
+ if (!quadTree.value || !options.enableDebugVisualization) return null
+
+ return quadTree.value.getDebugInfo()
+ }
+
+ // 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),
+
+ // Debug
+ getDebugVisualization,
+
+ // Direct access to QuadTree (for advanced usage)
+ quadTree: computed(() => quadTree.value)
+ }
+}
diff --git a/src/composables/graph/useWidgetRenderer.ts b/src/composables/graph/useWidgetRenderer.ts
index fc4831cf6..eb8d25666 100644
--- a/src/composables/graph/useWidgetRenderer.ts
+++ b/src/composables/graph/useWidgetRenderer.ts
@@ -44,7 +44,15 @@ export const useWidgetRenderer = () => {
image: WidgetType.IMAGE,
IMAGE: WidgetType.IMAGE,
file: WidgetType.FILEUPLOAD,
- FILEUPLOAD: WidgetType.FILEUPLOAD
+ FILEUPLOAD: WidgetType.FILEUPLOAD,
+
+ // Button widget
+ button: WidgetType.BUTTON,
+ BUTTON: WidgetType.BUTTON,
+
+ // Text-based widgets that don't have dedicated components yet
+ MARKDOWN: WidgetType.TEXTAREA, // Markdown should use textarea for now
+ customtext: WidgetType.TEXTAREA // Custom text widgets use textarea for multiline
}
// Get mapped enum key
@@ -55,13 +63,6 @@ export const useWidgetRenderer = () => {
return enumKey
}
- // Log unmapped widget types for debugging
- if (process.env.NODE_ENV === 'development') {
- console.warn(
- `[useWidgetRenderer] Unknown widget type: ${widgetType}, falling back to WidgetInputText`
- )
- }
-
return WidgetType.STRING // Return enum key for WidgetInputText
}