[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:
bymyself
2025-07-04 22:20:52 -07:00
parent 0de3b8a864
commit a23d8be77b
9 changed files with 701 additions and 183 deletions

View File

@@ -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
* <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 { 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
}
}

View File

@@ -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<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
@@ -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<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let spatialIndexEnabled = false
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
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<typeof originalCallback>[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<string> => {
const startTime = performance.now()
let visibleIds: Set<string>
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<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)
}
}
}
// 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<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
@@ -636,6 +603,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
getVisibleNodeIds,
performanceMetrics,
spatialMetrics: readonly(spatialMetrics),
getSpatialIndexDebugInfo: () => spatialIndexEnabled ? spatialIndex.getDebugInfo() : null
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
}
}

View 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)
}
}

View File

@@ -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
}