mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
[feat] Add Vue-based node rendering system with widget support
Complete Vue node lifecycle management system that safely extracts data from LiteGraph and renders nodes with working widgets in Vue components. Key features: - Safe data extraction pattern to avoid Vue proxy issues with LiteGraph private fields - Event-driven lifecycle management using onNodeAdded/onNodeRemoved hooks - Widget system integration with functional dropdowns, inputs, and controls - Performance optimizations including viewport culling and RAF batching - Transform container pattern for O(1) scaling regardless of node count - QuadTree spatial indexing for efficient visibility queries - Debug tools and performance monitoring - Feature flag system for safe rollout Architecture: - LiteGraph remains source of truth for all graph logic and data - Vue components render nodes positioned over canvas using CSS transforms - Widget updates flow through LiteGraph callbacks to maintain consistency - Reactive state separated from node references to prevent proxy overhead Components: - useGraphNodeManager: Core lifecycle management with safe data extraction - TransformPane: Performance-optimized viewport container - LGraphNode.vue: Vue node component with widget rendering - Widget system: PrimeVue-based components for all widget types
This commit is contained in:
@@ -441,11 +441,11 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Transform state for viewport culling
|
// Transform state for viewport culling
|
||||||
const { isNodeInViewport } = useTransformState()
|
const { syncWithCanvas } = useTransformState()
|
||||||
|
|
||||||
// Viewport culling settings - use feature flags as defaults but allow debug override
|
// Viewport culling settings - use feature flags as defaults but allow debug override
|
||||||
const viewportCullingEnabled = ref(false) // Debug override, starts false for testing
|
const viewportCullingEnabled = ref(true) // Enable viewport culling
|
||||||
const cullingMargin = ref(0.2) // Debug override
|
const cullingMargin = ref(0.2) // 20% margin outside viewport
|
||||||
|
|
||||||
// Initialize from feature flags
|
// Initialize from feature flags
|
||||||
watch(
|
watch(
|
||||||
@@ -467,47 +467,63 @@ watch(
|
|||||||
// Replace problematic computed property with proper reactive system
|
// Replace problematic computed property with proper reactive system
|
||||||
const nodesToRender = computed(() => {
|
const nodesToRender = computed(() => {
|
||||||
// Access performanceMetrics to trigger on RAF updates
|
// Access performanceMetrics to trigger on RAF updates
|
||||||
const updateCount = performanceMetrics.updateTime
|
void performanceMetrics.updateTime
|
||||||
|
|
||||||
console.log(
|
if (!renderAllNodes.value || !comfyApp.graph || !transformPaneEnabled.value) {
|
||||||
'[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
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const allNodes = Array.from(vueNodeData.value.values())
|
const allNodes = Array.from(vueNodeData.value.values())
|
||||||
|
|
||||||
// Apply viewport culling
|
// Apply viewport culling - check if node bounds intersect with viewport
|
||||||
if (viewportCullingEnabled.value && nodeManager) {
|
if (
|
||||||
const filtered = allNodes.filter((nodeData) => {
|
viewportCullingEnabled.value &&
|
||||||
const originalNode = nodeManager?.getNode(nodeData.id)
|
nodeManager &&
|
||||||
if (!originalNode) return false
|
canvasStore.canvas &&
|
||||||
|
comfyApp.canvas
|
||||||
|
) {
|
||||||
|
const canvas = canvasStore.canvas
|
||||||
|
const manager = nodeManager
|
||||||
|
|
||||||
const inViewport = isNodeInViewport(
|
// Ensure transform is synced before checking visibility
|
||||||
originalNode.pos,
|
syncWithCanvas(comfyApp.canvas)
|
||||||
originalNode.size,
|
|
||||||
canvasViewport.value,
|
const ds = canvas.ds
|
||||||
cullingMargin.value
|
|
||||||
|
// 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
|
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
|
// 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) => {
|
const handleTransformUpdate = (time: number) => {
|
||||||
lastTransformTime.value = time
|
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
|
// Detect node changes during transform updates
|
||||||
detectChangesInRAF()
|
detectChangesInRAF()
|
||||||
|
|
||||||
@@ -706,7 +755,7 @@ const loadCustomNodesI18n = async () => {
|
|||||||
i18n.global.mergeLocaleMessage(locale, message)
|
i18n.global.mergeLocaleMessage(locale, message)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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()
|
await settingStore.loadSettingValues()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedError) {
|
if (error instanceof UnauthorizedError) {
|
||||||
console.log(
|
|
||||||
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
|
|
||||||
)
|
|
||||||
localStorage.removeItem('Comfy.userId')
|
localStorage.removeItem('Comfy.userId')
|
||||||
localStorage.removeItem('Comfy.userName')
|
localStorage.removeItem('Comfy.userName')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
|
|||||||
109
src/components/graph/debug/QuadTreeDebugSection.vue
Normal file
109
src/components/graph/debug/QuadTreeDebugSection.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pt-2 border-t border-surface-200 dark-theme:border-surface-700">
|
||||||
|
<h4 class="font-semibold mb-1">QuadTree Spatial Index</h4>
|
||||||
|
|
||||||
|
<!-- Enable/Disable Toggle -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:checked="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span>Enable Spatial Indexing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Message -->
|
||||||
|
<p v-if="!enabled" class="text-muted text-xs italic">
|
||||||
|
{{ statusMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Metrics when enabled -->
|
||||||
|
<template v-if="enabled && metrics">
|
||||||
|
<p class="text-muted">Strategy: {{ strategy }}</p>
|
||||||
|
<p class="text-muted">Total Nodes: {{ metrics.totalNodes }}</p>
|
||||||
|
<p class="text-muted">Visible Nodes: {{ metrics.visibleNodes }}</p>
|
||||||
|
<p class="text-muted">Query Time: {{ metrics.queryTime.toFixed(2) }}ms</p>
|
||||||
|
<p class="text-muted">Tree Depth: {{ metrics.treeDepth }}</p>
|
||||||
|
<p class="text-muted">Culling Efficiency: {{ cullingEfficiency }}</p>
|
||||||
|
<p class="text-muted">Rebuilds: {{ metrics.rebuildCount }}</p>
|
||||||
|
|
||||||
|
<!-- Show debug visualization toggle -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:checked="showVisualization"
|
||||||
|
type="checkbox"
|
||||||
|
@change="
|
||||||
|
$emit(
|
||||||
|
'toggle-visualization',
|
||||||
|
($event.target as HTMLInputElement).checked
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span>Show QuadTree Boundaries</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Performance Comparison -->
|
||||||
|
<template v-if="enabled && performanceComparison">
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
<p class="text-muted font-semibold">Performance vs Linear:</p>
|
||||||
|
<p class="text-muted">Speedup: {{ performanceComparison.speedup }}x</p>
|
||||||
|
<p class="text-muted">
|
||||||
|
Break-even: ~{{ performanceComparison.breakEvenNodeCount }} nodes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean
|
||||||
|
metrics?: {
|
||||||
|
totalNodes: number
|
||||||
|
visibleNodes: number
|
||||||
|
queryTime: number
|
||||||
|
treeDepth: number
|
||||||
|
rebuildCount: number
|
||||||
|
}
|
||||||
|
strategy?: string
|
||||||
|
threshold?: number
|
||||||
|
showVisualization?: boolean
|
||||||
|
performanceComparison?: {
|
||||||
|
speedup: number
|
||||||
|
breakEvenNodeCount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
strategy: 'quadtree',
|
||||||
|
threshold: 100,
|
||||||
|
showVisualization: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
toggle: [enabled: boolean]
|
||||||
|
'toggle-visualization': [show: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusMessage = computed(() => {
|
||||||
|
if (!props.enabled && props.metrics) {
|
||||||
|
return `Disabled (threshold: ${props.threshold} nodes, current: ${props.metrics.totalNodes})`
|
||||||
|
}
|
||||||
|
return `Spatial indexing will enable at ${props.threshold}+ nodes`
|
||||||
|
})
|
||||||
|
|
||||||
|
const cullingEfficiency = computed(() => {
|
||||||
|
if (!props.metrics || props.metrics.totalNodes === 0) return 'N/A'
|
||||||
|
|
||||||
|
const culled = props.metrics.totalNodes - props.metrics.visibleNodes
|
||||||
|
const percentage = ((culled / props.metrics.totalNodes) * 100).toFixed(1)
|
||||||
|
return `${culled} nodes (${percentage}%)`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
112
src/components/graph/debug/QuadTreeVisualization.vue
Normal file
112
src/components/graph/debug/QuadTreeVisualization.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="visible && debugInfo"
|
||||||
|
:width="svgSize.width"
|
||||||
|
:height="svgSize.height"
|
||||||
|
:style="svgStyle"
|
||||||
|
class="quadtree-visualization"
|
||||||
|
>
|
||||||
|
<!-- QuadTree boundaries -->
|
||||||
|
<g v-for="(node, index) in flattenedNodes" :key="`quad-${index}`">
|
||||||
|
<rect
|
||||||
|
:x="node.bounds.x"
|
||||||
|
:y="node.bounds.y"
|
||||||
|
:width="node.bounds.width"
|
||||||
|
:height="node.bounds.height"
|
||||||
|
:stroke="getDepthColor(node.depth)"
|
||||||
|
:stroke-width="getStrokeWidth(node.depth)"
|
||||||
|
fill="none"
|
||||||
|
:opacity="0.3 + node.depth * 0.05"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Viewport bounds (optional) -->
|
||||||
|
<rect
|
||||||
|
v-if="viewportBounds"
|
||||||
|
:x="viewportBounds.x"
|
||||||
|
:y="viewportBounds.y"
|
||||||
|
:width="viewportBounds.width"
|
||||||
|
:height="viewportBounds.height"
|
||||||
|
stroke="#00ff00"
|
||||||
|
stroke-width="3"
|
||||||
|
fill="none"
|
||||||
|
stroke-dasharray="10,5"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { Bounds } from '@/utils/spatial/QuadTree'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
debugInfo: any | null
|
||||||
|
transformStyle: any
|
||||||
|
viewportBounds?: Bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Flatten the tree structure for rendering
|
||||||
|
const flattenedNodes = computed(() => {
|
||||||
|
if (!props.debugInfo?.tree) return []
|
||||||
|
|
||||||
|
const nodes: any[] = []
|
||||||
|
const traverse = (node: any, depth = 0) => {
|
||||||
|
nodes.push({
|
||||||
|
bounds: node.bounds,
|
||||||
|
depth,
|
||||||
|
itemCount: node.itemCount,
|
||||||
|
divided: node.divided
|
||||||
|
})
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach((child: any) => traverse(child, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(props.debugInfo.tree)
|
||||||
|
return nodes
|
||||||
|
})
|
||||||
|
|
||||||
|
// SVG size (matches the transform pane size)
|
||||||
|
const svgSize = ref({ width: 20000, height: 20000 })
|
||||||
|
|
||||||
|
// Apply the same transform as the TransformPane
|
||||||
|
const svgStyle = computed(() => ({
|
||||||
|
...props.transformStyle,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Color based on depth
|
||||||
|
const getDepthColor = (depth: number): string => {
|
||||||
|
const colors = [
|
||||||
|
'#ff6b6b', // Red
|
||||||
|
'#ffa500', // Orange
|
||||||
|
'#ffd93d', // Yellow
|
||||||
|
'#6bcf7f', // Green
|
||||||
|
'#4da6ff', // Blue
|
||||||
|
'#a78bfa' // Purple
|
||||||
|
]
|
||||||
|
return colors[depth % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stroke width based on depth
|
||||||
|
const getStrokeWidth = (depth: number): number => {
|
||||||
|
return Math.max(0.5, 2 - depth * 0.3)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quadtree-visualization {
|
||||||
|
position: absolute;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 10; /* Above nodes but below UI */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -58,56 +58,29 @@ const widgets = computed((): SafeWidgetData[] => {
|
|||||||
const info = nodeInfo.value
|
const info = nodeInfo.value
|
||||||
if (!info?.widgets) return []
|
if (!info?.widgets) return []
|
||||||
|
|
||||||
console.log('[NodeWidgets] Raw widgets from nodeInfo:', info.widgets)
|
|
||||||
|
|
||||||
const filtered = (info.widgets as SafeWidgetData[]).filter(
|
const filtered = (info.widgets as SafeWidgetData[]).filter(
|
||||||
(w: SafeWidgetData) => !w.options?.hidden
|
(w: SafeWidgetData) => !w.options?.hidden
|
||||||
)
|
)
|
||||||
console.log('[NodeWidgets] Filtered widgets:', filtered)
|
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only render widgets that have Vue component support
|
// Only render widgets that have Vue component support
|
||||||
const supportedWidgets = computed((): SafeWidgetData[] => {
|
const supportedWidgets = computed((): SafeWidgetData[] => {
|
||||||
const allWidgets = widgets.value
|
const allWidgets = widgets.value
|
||||||
console.log('[NodeWidgets] All widgets:', allWidgets)
|
|
||||||
|
|
||||||
const supported = allWidgets.filter((widget: SafeWidgetData) => {
|
const supported = allWidgets.filter((widget: SafeWidgetData) => {
|
||||||
const isSupported = shouldRenderAsVue(widget)
|
return shouldRenderAsVue(widget)
|
||||||
console.log(
|
|
||||||
'[NodeWidgets] Widget:',
|
|
||||||
widget.name,
|
|
||||||
'type:',
|
|
||||||
widget.type,
|
|
||||||
'supported:',
|
|
||||||
isSupported
|
|
||||||
)
|
|
||||||
return isSupported
|
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[NodeWidgets] Supported widgets:', supported)
|
|
||||||
return supported
|
return supported
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get Vue component for widget
|
// Get Vue component for widget
|
||||||
const getVueComponent = (widget: SafeWidgetData) => {
|
const getVueComponent = (widget: SafeWidgetData) => {
|
||||||
const componentName = getWidgetComponent(widget.type)
|
const componentName = getWidgetComponent(widget.type)
|
||||||
console.log(
|
|
||||||
'[NodeWidgets] Widget type:',
|
|
||||||
widget.type,
|
|
||||||
'Component name:',
|
|
||||||
componentName
|
|
||||||
)
|
|
||||||
|
|
||||||
const component = widgetTypeToComponent[componentName]
|
const component = widgetTypeToComponent[componentName]
|
||||||
console.log('[NodeWidgets] Resolved component:', component)
|
|
||||||
|
|
||||||
return component || WidgetInputText // Fallback to text input
|
return component || WidgetInputText // Fallback to text input
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWidgetValue = (widget: SafeWidgetData): unknown => {
|
const getWidgetValue = (widget: SafeWidgetData): unknown => {
|
||||||
console.log('[NodeWidgets] Widget value for', widget.name, ':', widget.value)
|
|
||||||
return widget.value
|
return widget.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* Composable for managing transform state synchronized with LiteGraph canvas
|
* 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
|
||||||
|
* <div :style="transformStyle">
|
||||||
|
* <NodeComponent
|
||||||
|
* v-for="node in nodes"
|
||||||
|
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||||
|
* />
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* // Convert coordinates
|
||||||
|
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
import type { LGraphCanvas } from '@comfyorg/litegraph'
|
import type { LGraphCanvas } from '@comfyorg/litegraph'
|
||||||
import { computed, reactive, readonly } from 'vue'
|
import { computed, reactive, readonly } from 'vue'
|
||||||
@@ -30,16 +77,36 @@ export const useTransformState = () => {
|
|||||||
transformOrigin: '0 0'
|
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) => {
|
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||||
if (!canvas || !canvas.ds) return
|
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.x = canvas.ds.offset[0]
|
||||||
camera.y = canvas.ds.offset[1]
|
camera.y = canvas.ds.offset[1]
|
||||||
camera.z = canvas.ds.scale || 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 => {
|
const canvasToScreen = (point: Point): Point => {
|
||||||
return {
|
return {
|
||||||
x: point.x * camera.z + camera.x,
|
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 => {
|
const screenToCanvas = (point: Point): Point => {
|
||||||
return {
|
return {
|
||||||
x: (point.x - camera.x) / camera.z,
|
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 {
|
return {
|
||||||
camera: readonly(camera),
|
camera: readonly(camera),
|
||||||
transformStyle,
|
transformStyle,
|
||||||
@@ -117,6 +216,7 @@ export const useTransformState = () => {
|
|||||||
canvasToScreen,
|
canvasToScreen,
|
||||||
screenToCanvas,
|
screenToCanvas,
|
||||||
getNodeScreenBounds,
|
getNodeScreenBounds,
|
||||||
isNodeInViewport
|
isNodeInViewport,
|
||||||
|
getViewportBounds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import type { LGraph, LGraphNode } from '@comfyorg/litegraph'
|
import type { LGraph, LGraphNode } from '@comfyorg/litegraph'
|
||||||
import { nextTick, reactive, readonly } from 'vue'
|
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 {
|
export interface NodeState {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -52,10 +53,8 @@ export interface VueNodeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SpatialMetrics {
|
export interface SpatialMetrics {
|
||||||
strategy: 'linear' | 'quadtree'
|
|
||||||
queryTime: number
|
queryTime: number
|
||||||
nodesInIndex: number
|
nodesInIndex: number
|
||||||
threshold: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphNodeManager {
|
export interface GraphNodeManager {
|
||||||
@@ -86,14 +85,12 @@ export interface GraphNodeManager {
|
|||||||
// Performance
|
// Performance
|
||||||
performanceMetrics: PerformanceMetrics
|
performanceMetrics: PerformanceMetrics
|
||||||
spatialMetrics: SpatialMetrics
|
spatialMetrics: SpatialMetrics
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
getSpatialIndexDebugInfo(): any | null
|
getSpatialIndexDebugInfo(): any | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||||
console.log('[useGraphNodeManager] Initializing with graph:', graph)
|
|
||||||
|
|
||||||
// Safe reactive data extracted from LiteGraph nodes
|
// Safe reactive data extracted from LiteGraph nodes
|
||||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||||
const nodeState = reactive(new Map<string, NodeState>())
|
const nodeState = reactive(new Map<string, NodeState>())
|
||||||
@@ -120,21 +117,17 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
adaptiveQuality: false
|
adaptiveQuality: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Spatial indexing for large graphs (auto-enables at 100+ nodes)
|
// Spatial indexing using QuadTree
|
||||||
const SPATIAL_INDEX_THRESHOLD = 100
|
|
||||||
const spatialIndex = new QuadTree<string>(
|
const spatialIndex = new QuadTree<string>(
|
||||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||||
)
|
)
|
||||||
let spatialIndexEnabled = false
|
|
||||||
let lastSpatialQueryTime = 0
|
let lastSpatialQueryTime = 0
|
||||||
|
|
||||||
// Spatial metrics
|
// Spatial metrics
|
||||||
const spatialMetrics = reactive<SpatialMetrics>({
|
const spatialMetrics = reactive<SpatialMetrics>({
|
||||||
strategy: 'linear',
|
|
||||||
queryTime: 0,
|
queryTime: 0,
|
||||||
nodesInIndex: 0,
|
nodesInIndex: 0
|
||||||
threshold: SPATIAL_INDEX_THRESHOLD
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// FPS tracking state
|
// FPS tracking state
|
||||||
@@ -146,7 +139,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
frameCount++
|
frameCount++
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
const elapsed = now - lastFrameTime
|
const elapsed = now - lastFrameTime
|
||||||
|
|
||||||
if (elapsed >= 1000) {
|
if (elapsed >= 1000) {
|
||||||
performanceMetrics.fps = Math.round((frameCount * 1000) / elapsed)
|
performanceMetrics.fps = Math.round((frameCount * 1000) / elapsed)
|
||||||
performanceMetrics.frameTime = elapsed / frameCount
|
performanceMetrics.frameTime = elapsed / frameCount
|
||||||
@@ -157,7 +150,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
|
|
||||||
const startFPSTracking = () => {
|
const startFPSTracking = () => {
|
||||||
if (fpsUpdateInterval) return
|
if (fpsUpdateInterval) return
|
||||||
|
|
||||||
const trackFrame = () => {
|
const trackFrame = () => {
|
||||||
updateFPS()
|
updateFPS()
|
||||||
fpsUpdateInterval = requestAnimationFrame(trackFrame)
|
fpsUpdateInterval = requestAnimationFrame(trackFrame)
|
||||||
@@ -221,12 +214,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
callback: widget.callback
|
callback: widget.callback
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
|
||||||
'[useGraphNodeManager] Error extracting widget data for',
|
|
||||||
widget.name,
|
|
||||||
':',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
name: widget.name || 'unknown',
|
name: widget.name || 'unknown',
|
||||||
type: widget.type || 'text',
|
type: widget.type || 'text',
|
||||||
@@ -257,36 +244,35 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
|
|
||||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||||
const nodeId = String(node.id)
|
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
|
// Create a new callback that updates the widget value AND the Vue state
|
||||||
const originalCallback = widget.callback
|
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
|
// 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
|
// 2. Call the original callback if it exists
|
||||||
if (originalCallback) {
|
if (originalCallback) {
|
||||||
originalCallback.call(widget, value as Parameters<typeof originalCallback>[0], ...args)
|
originalCallback.call(widget, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update Vue state
|
// 3. Update Vue state
|
||||||
try {
|
try {
|
||||||
const currentData = vueNodeData.get(nodeId)
|
const currentData = vueNodeData.get(nodeId)
|
||||||
if (currentData?.widgets) {
|
if (currentData?.widgets) {
|
||||||
const updatedWidgets = currentData.widgets.map(w =>
|
const updatedWidgets = currentData.widgets.map((w) =>
|
||||||
w.name === widget.name
|
w.name === widget.name ? { ...w, value: value } : w
|
||||||
? { ...w, value: value }
|
|
||||||
: w
|
|
||||||
)
|
)
|
||||||
vueNodeData.set(nodeId, {
|
vueNodeData.set(nodeId, {
|
||||||
...currentData,
|
...currentData,
|
||||||
widgets: updatedWidgets
|
widgets: updatedWidgets
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
performanceMetrics.callbackUpdateCount++
|
performanceMetrics.callbackUpdateCount++
|
||||||
} catch (error) {
|
} 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)))
|
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||||
|
|
||||||
// Remove deleted nodes
|
// Remove deleted nodes
|
||||||
for (const [id] of vueNodeData) {
|
for (const id of Array.from(vueNodeData.keys())) {
|
||||||
if (!currentNodes.has(id)) {
|
if (!currentNodes.has(id)) {
|
||||||
nodeRefs.delete(id)
|
nodeRefs.delete(id)
|
||||||
vueNodeData.delete(id)
|
vueNodeData.delete(id)
|
||||||
@@ -371,6 +357,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
nodePositions.delete(id)
|
nodePositions.delete(id)
|
||||||
nodeSizes.delete(id)
|
nodeSizes.delete(id)
|
||||||
lastNodesSnapshot.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] })
|
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||||
attachMetadata(node)
|
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
|
// 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<string> => {
|
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
|
|
||||||
let visibleIds: Set<string>
|
// Use QuadTree for fast spatial query
|
||||||
|
const results: string[] = spatialIndex.query(viewportBounds)
|
||||||
if (spatialIndexEnabled) {
|
const visibleIds = new Set(results)
|
||||||
// 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<string>()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSpatialQueryTime = performance.now() - startTime
|
lastSpatialQueryTime = performance.now() - startTime
|
||||||
spatialMetrics.queryTime = lastSpatialQueryTime
|
spatialMetrics.queryTime = lastSpatialQueryTime
|
||||||
|
|
||||||
return visibleIds
|
return visibleIds
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectChangesInRAF = () => {
|
const detectChangesInRAF = () => {
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
|
|
||||||
if (!graph?._nodes) return
|
if (!graph?._nodes) return
|
||||||
|
|
||||||
let positionUpdates = 0
|
let positionUpdates = 0
|
||||||
let sizeUpdates = 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
|
// Update reactive positions and sizes
|
||||||
for (const node of graph._nodes) {
|
for (const node of graph._nodes) {
|
||||||
const id = String(node.id)
|
const id = String(node.id)
|
||||||
@@ -489,8 +452,8 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
sizeChanged = true
|
sizeChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update spatial index if enabled and position/size changed
|
// Update spatial index if position/size changed
|
||||||
if (spatialIndexEnabled && (posChanged || sizeChanged)) {
|
if (posChanged || sizeChanged) {
|
||||||
const bounds: Bounds = {
|
const bounds: Bounds = {
|
||||||
x: node.pos[0],
|
x: node.pos[0],
|
||||||
y: node.pos[1],
|
y: node.pos[1],
|
||||||
@@ -506,10 +469,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
performanceMetrics.updateTime = endTime - startTime
|
performanceMetrics.updateTime = endTime - startTime
|
||||||
performanceMetrics.nodeCount = vueNodeData.size
|
performanceMetrics.nodeCount = vueNodeData.size
|
||||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||||
state => state.culled
|
(state) => state.culled
|
||||||
).length
|
).length
|
||||||
spatialMetrics.nodesInIndex = spatialIndexEnabled ? spatialIndex.size : 0
|
spatialMetrics.nodesInIndex = spatialIndex.size
|
||||||
|
|
||||||
if (positionUpdates > 0 || sizeUpdates > 0) {
|
if (positionUpdates > 0 || sizeUpdates > 0) {
|
||||||
performanceMetrics.rafUpdateCount++
|
performanceMetrics.rafUpdateCount++
|
||||||
}
|
}
|
||||||
@@ -522,12 +485,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
|
|
||||||
// Override callbacks
|
// Override callbacks
|
||||||
graph.onNodeAdded = (node: LGraphNode) => {
|
graph.onNodeAdded = (node: LGraphNode) => {
|
||||||
console.log('[useGraphNodeManager] onNodeAdded:', node.id)
|
|
||||||
const id = String(node.id)
|
const id = String(node.id)
|
||||||
|
|
||||||
// Store non-reactive reference to original node
|
// Store non-reactive reference to original node
|
||||||
nodeRefs.set(id, node)
|
nodeRefs.set(id, node)
|
||||||
|
|
||||||
// Set up widget callbacks BEFORE extracting data
|
// Set up widget callbacks BEFORE extracting data
|
||||||
setupNodeWidgetCallbacks(node)
|
setupNodeWidgetCallbacks(node)
|
||||||
|
|
||||||
@@ -544,18 +506,16 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||||
attachMetadata(node)
|
attachMetadata(node)
|
||||||
|
|
||||||
// Add to spatial index if enabled
|
// Add to spatial index
|
||||||
if (spatialIndexEnabled) {
|
const bounds: Bounds = {
|
||||||
const bounds: Bounds = {
|
x: node.pos[0],
|
||||||
x: node.pos[0],
|
y: node.pos[1],
|
||||||
y: node.pos[1],
|
width: node.size[0],
|
||||||
width: node.size[0],
|
height: node.size[1]
|
||||||
height: node.size[1]
|
|
||||||
}
|
|
||||||
spatialIndex.insert(id, bounds, id)
|
|
||||||
}
|
}
|
||||||
|
spatialIndex.insert(id, bounds, id)
|
||||||
|
|
||||||
if (originalOnNodeAdded) {
|
if (originalOnNodeAdded) {
|
||||||
void originalOnNodeAdded(node)
|
void originalOnNodeAdded(node)
|
||||||
}
|
}
|
||||||
@@ -563,12 +523,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
|
|
||||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||||
const id = String(node.id)
|
const id = String(node.id)
|
||||||
|
|
||||||
// Remove from spatial index if enabled
|
// Remove from spatial index
|
||||||
if (spatialIndexEnabled) {
|
spatialIndex.remove(id)
|
||||||
spatialIndex.remove(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeRefs.delete(id)
|
nodeRefs.delete(id)
|
||||||
vueNodeData.delete(id)
|
vueNodeData.delete(id)
|
||||||
nodeState.delete(id)
|
nodeState.delete(id)
|
||||||
@@ -580,7 +538,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
|
|
||||||
// Initial sync
|
// Initial sync
|
||||||
syncWithGraph()
|
syncWithGraph()
|
||||||
|
|
||||||
// Start FPS tracking
|
// Start FPS tracking
|
||||||
startFPSTracking()
|
startFPSTracking()
|
||||||
|
|
||||||
@@ -595,7 +553,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
clearTimeout(batchTimeoutId)
|
clearTimeout(batchTimeoutId)
|
||||||
batchTimeoutId = null
|
batchTimeoutId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop FPS tracking
|
// Stop FPS tracking
|
||||||
stopFPSTracking()
|
stopFPSTracking()
|
||||||
|
|
||||||
@@ -616,6 +574,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
// Set up event listeners immediately
|
// Set up event listeners immediately
|
||||||
const cleanup = setupEventListeners()
|
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 {
|
return {
|
||||||
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
|
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
|
||||||
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
|
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
|
||||||
@@ -636,6 +603,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
|||||||
getVisibleNodeIds,
|
getVisibleNodeIds,
|
||||||
performanceMetrics,
|
performanceMetrics,
|
||||||
spatialMetrics: readonly(spatialMetrics),
|
spatialMetrics: readonly(spatialMetrics),
|
||||||
getSpatialIndexDebugInfo: () => spatialIndexEnabled ? spatialIndex.getDebugInfo() : null
|
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
210
src/composables/graph/useSpatialIndex.ts
Normal file
210
src/composables/graph/useSpatialIndex.ts
Normal file
@@ -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<QuadTree<string> | null>(null)
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
const metrics = reactive<SpatialMetrics>({
|
||||||
|
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<Bounds[]>([])
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,15 @@ export const useWidgetRenderer = () => {
|
|||||||
image: WidgetType.IMAGE,
|
image: WidgetType.IMAGE,
|
||||||
IMAGE: WidgetType.IMAGE,
|
IMAGE: WidgetType.IMAGE,
|
||||||
file: WidgetType.FILEUPLOAD,
|
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
|
// Get mapped enum key
|
||||||
@@ -55,13 +63,6 @@ export const useWidgetRenderer = () => {
|
|||||||
return enumKey
|
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
|
return WidgetType.STRING // Return enum key for WidgetInputText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user