From b09419c4d5e97ea991e8f1935a8d8c214bd44369 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 13 Aug 2025 01:59:18 -0700 Subject: [PATCH] refactor: Extract services and split composables for better organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created SpatialIndexManager to handle QuadTree operations separately - Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations) - Split GraphNodeManager into focused composables: - useNodeWidgets: Widget state and callback management - useNodeChangeDetection: RAF-based geometry change detection - useNodeState: Node visibility and reactive state management - Extracted constants for magic numbers and configuration values - Updated layout store to use SpatialIndexManager and constants This improves code organization, testability, and makes it easier to swap CRDT implementations or mock services for testing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/adapters/layoutAdapter.ts | 82 ++++++ src/adapters/mockLayoutAdapter.ts | 137 +++++++++ src/adapters/yjsLayoutAdapter.ts | 202 ++++++++++++++ .../graph/useNodeChangeDetection.ts | 180 ++++++++++++ src/composables/graph/useNodeState.ts | 260 ++++++++++++++++++ src/composables/graph/useNodeWidgets.ts | 182 ++++++++++++ src/constants/layout.ts | 73 +++++ src/services/spatialIndexManager.ts | 166 +++++++++++ src/stores/layoutStore.ts | 50 ++-- 9 files changed, 1299 insertions(+), 33 deletions(-) create mode 100644 src/adapters/layoutAdapter.ts create mode 100644 src/adapters/mockLayoutAdapter.ts create mode 100644 src/adapters/yjsLayoutAdapter.ts create mode 100644 src/composables/graph/useNodeChangeDetection.ts create mode 100644 src/composables/graph/useNodeState.ts create mode 100644 src/composables/graph/useNodeWidgets.ts create mode 100644 src/constants/layout.ts create mode 100644 src/services/spatialIndexManager.ts diff --git a/src/adapters/layoutAdapter.ts b/src/adapters/layoutAdapter.ts new file mode 100644 index 0000000000..abc50bc29f --- /dev/null +++ b/src/adapters/layoutAdapter.ts @@ -0,0 +1,82 @@ +/** + * Layout Adapter Interface + * + * Abstracts the underlying CRDT implementation to allow for different + * backends (Yjs, Automerge, etc.) and easier testing. + */ +import type { LayoutOperation } from '@/types/layoutOperations' +import type { NodeId, NodeLayout } from '@/types/layoutTypes' + +/** + * Change event emitted by the adapter + */ +export interface AdapterChange { + /** Type of change */ + type: 'set' | 'delete' | 'clear' + /** Affected node IDs */ + nodeIds: NodeId[] + /** Actor who made the change */ + actor?: string +} + +/** + * Layout adapter interface for CRDT abstraction + */ +export interface LayoutAdapter { + /** + * Set a node's layout data + */ + setNode(nodeId: NodeId, layout: NodeLayout): void + + /** + * Get a node's layout data + */ + getNode(nodeId: NodeId): NodeLayout | null + + /** + * Delete a node + */ + deleteNode(nodeId: NodeId): void + + /** + * Get all nodes + */ + getAllNodes(): Map + + /** + * Clear all nodes + */ + clear(): void + + /** + * Add an operation to the log + */ + addOperation(operation: LayoutOperation): void + + /** + * Get operations since a timestamp + */ + getOperationsSince(timestamp: number): LayoutOperation[] + + /** + * Get operations by a specific actor + */ + getOperationsByActor(actor: string): LayoutOperation[] + + /** + * Subscribe to changes + */ + subscribe(callback: (change: AdapterChange) => void): () => void + + /** + * Transaction support for atomic updates + */ + transaction(fn: () => void, actor?: string): void + + /** + * Network sync methods (for future use) + */ + getStateVector(): Uint8Array + getStateAsUpdate(): Uint8Array + applyUpdate(update: Uint8Array): void +} diff --git a/src/adapters/mockLayoutAdapter.ts b/src/adapters/mockLayoutAdapter.ts new file mode 100644 index 0000000000..1657575bc4 --- /dev/null +++ b/src/adapters/mockLayoutAdapter.ts @@ -0,0 +1,137 @@ +/** + * Mock Layout Adapter + * + * Simple in-memory implementation for testing without CRDT overhead. + */ +import type { LayoutOperation } from '@/types/layoutOperations' +import type { NodeId, NodeLayout } from '@/types/layoutTypes' + +import type { AdapterChange, LayoutAdapter } from './layoutAdapter' + +/** + * Mock implementation for testing + */ +export class MockLayoutAdapter implements LayoutAdapter { + private nodes = new Map() + private operations: LayoutOperation[] = [] + private changeCallbacks = new Set<(change: AdapterChange) => void>() + private currentActor?: string + + setNode(nodeId: NodeId, layout: NodeLayout): void { + this.nodes.set(nodeId, { ...layout }) + this.notifyChange({ + type: 'set', + nodeIds: [nodeId], + actor: this.currentActor + }) + } + + getNode(nodeId: NodeId): NodeLayout | null { + const layout = this.nodes.get(nodeId) + return layout ? { ...layout } : null + } + + deleteNode(nodeId: NodeId): void { + const existed = this.nodes.delete(nodeId) + if (existed) { + this.notifyChange({ + type: 'delete', + nodeIds: [nodeId], + actor: this.currentActor + }) + } + } + + getAllNodes(): Map { + // Return a copy to prevent external mutations + const copy = new Map() + for (const [id, layout] of this.nodes) { + copy.set(id, { ...layout }) + } + return copy + } + + clear(): void { + const nodeIds = Array.from(this.nodes.keys()) + this.nodes.clear() + this.operations = [] + + if (nodeIds.length > 0) { + this.notifyChange({ + type: 'clear', + nodeIds, + actor: this.currentActor + }) + } + } + + addOperation(operation: LayoutOperation): void { + this.operations.push({ ...operation }) + } + + getOperationsSince(timestamp: number): LayoutOperation[] { + return this.operations + .filter((op) => op.timestamp > timestamp) + .map((op) => ({ ...op })) + } + + getOperationsByActor(actor: string): LayoutOperation[] { + return this.operations + .filter((op) => op.actor === actor) + .map((op) => ({ ...op })) + } + + subscribe(callback: (change: AdapterChange) => void): () => void { + this.changeCallbacks.add(callback) + return () => this.changeCallbacks.delete(callback) + } + + transaction(fn: () => void, actor?: string): void { + const previousActor = this.currentActor + this.currentActor = actor + try { + fn() + } finally { + this.currentActor = previousActor + } + } + + // Mock network sync methods + getStateVector(): Uint8Array { + return new Uint8Array([1, 2, 3]) // Mock data + } + + getStateAsUpdate(): Uint8Array { + // Simple serialization for testing + const json = JSON.stringify({ + nodes: Array.from(this.nodes.entries()), + operations: this.operations + }) + return new TextEncoder().encode(json) + } + + applyUpdate(update: Uint8Array): void { + // Simple deserialization for testing + const json = new TextDecoder().decode(update) + const data = JSON.parse(json) as { + nodes: Array<[NodeId, NodeLayout]> + operations: LayoutOperation[] + } + + this.nodes.clear() + for (const [id, layout] of data.nodes) { + this.nodes.set(id, layout) + } + this.operations = data.operations + } + + private notifyChange(change: AdapterChange): void { + this.changeCallbacks.forEach((callback) => { + try { + callback(change) + } catch (error) { + console.error('Error in mock adapter change callback:', error) + } + }) + } +} diff --git a/src/adapters/yjsLayoutAdapter.ts b/src/adapters/yjsLayoutAdapter.ts new file mode 100644 index 0000000000..d0a9ee4974 --- /dev/null +++ b/src/adapters/yjsLayoutAdapter.ts @@ -0,0 +1,202 @@ +/** + * Yjs Layout Adapter + * + * Implements the LayoutAdapter interface using Yjs as the CRDT backend. + * Provides efficient local state management with future collaboration support. + */ +import * as Y from 'yjs' + +import type { LayoutOperation } from '@/types/layoutOperations' +import type { Bounds, NodeId, NodeLayout, Point } from '@/types/layoutTypes' + +import type { AdapterChange, LayoutAdapter } from './layoutAdapter' + +/** + * Yjs implementation of the layout adapter + */ +export class YjsLayoutAdapter implements LayoutAdapter { + private ydoc: Y.Doc + private ynodes: Y.Map> + private yoperations: Y.Array + private changeCallbacks = new Set<(change: AdapterChange) => void>() + + constructor() { + this.ydoc = new Y.Doc() + this.ynodes = this.ydoc.getMap('nodes') + this.yoperations = this.ydoc.getArray('operations') + + // Set up change observation + this.ynodes.observe((event, transaction) => { + const change: AdapterChange = { + type: 'set', // Yjs doesn't distinguish set/delete in observe + nodeIds: [], + actor: transaction.origin as string | undefined + } + + // Collect affected node IDs + event.changes.keys.forEach((changeType, key) => { + change.nodeIds.push(key) + if (changeType.action === 'delete') { + change.type = 'delete' + } + }) + + // Notify subscribers + this.notifyChange(change) + }) + } + + /** + * Set a node's layout data + */ + setNode(nodeId: NodeId, layout: NodeLayout): void { + const ynode = this.layoutToYNode(layout) + this.ynodes.set(nodeId, ynode) + } + + /** + * Get a node's layout data + */ + getNode(nodeId: NodeId): NodeLayout | null { + const ynode = this.ynodes.get(nodeId) + return ynode ? this.yNodeToLayout(ynode) : null + } + + /** + * Delete a node + */ + deleteNode(nodeId: NodeId): void { + this.ynodes.delete(nodeId) + } + + /** + * Get all nodes + */ + getAllNodes(): Map { + const result = new Map() + for (const [nodeId] of this.ynodes) { + const ynode = this.ynodes.get(nodeId) + if (ynode) { + result.set(nodeId, this.yNodeToLayout(ynode)) + } + } + return result + } + + /** + * Clear all nodes + */ + clear(): void { + this.ynodes.clear() + } + + /** + * Add an operation to the log + */ + addOperation(operation: LayoutOperation): void { + this.yoperations.push([operation]) + } + + /** + * Get operations since a timestamp + */ + getOperationsSince(timestamp: number): LayoutOperation[] { + const operations: LayoutOperation[] = [] + this.yoperations.forEach((op) => { + if (op && op.timestamp > timestamp) { + operations.push(op) + } + }) + return operations + } + + /** + * Get operations by a specific actor + */ + getOperationsByActor(actor: string): LayoutOperation[] { + const operations: LayoutOperation[] = [] + this.yoperations.forEach((op) => { + if (op && op.actor === actor) { + operations.push(op) + } + }) + return operations + } + + /** + * Subscribe to changes + */ + subscribe(callback: (change: AdapterChange) => void): () => void { + this.changeCallbacks.add(callback) + return () => this.changeCallbacks.delete(callback) + } + + /** + * Transaction support for atomic updates + */ + transaction(fn: () => void, actor?: string): void { + this.ydoc.transact(fn, actor) + } + + /** + * Get the current state vector for sync + */ + getStateVector(): Uint8Array { + return Y.encodeStateVector(this.ydoc) + } + + /** + * Get state as update for sending to peers + */ + getStateAsUpdate(): Uint8Array { + return Y.encodeStateAsUpdate(this.ydoc) + } + + /** + * Apply updates from remote peers + */ + applyUpdate(update: Uint8Array): void { + Y.applyUpdate(this.ydoc, update) + } + + /** + * Convert layout to Yjs structure + */ + private layoutToYNode(layout: NodeLayout): Y.Map { + const ynode = new Y.Map() + ynode.set('id', layout.id) + ynode.set('position', layout.position) + ynode.set('size', layout.size) + ynode.set('zIndex', layout.zIndex) + ynode.set('visible', layout.visible) + ynode.set('bounds', layout.bounds) + return ynode + } + + /** + * Convert Yjs structure to layout + */ + private yNodeToLayout(ynode: Y.Map): NodeLayout { + return { + id: ynode.get('id') as string, + position: ynode.get('position') as Point, + size: ynode.get('size') as { width: number; height: number }, + zIndex: ynode.get('zIndex') as number, + visible: ynode.get('visible') as boolean, + bounds: ynode.get('bounds') as Bounds + } + } + + /** + * Notify all change subscribers + */ + private notifyChange(change: AdapterChange): void { + this.changeCallbacks.forEach((callback) => { + try { + callback(change) + } catch (error) { + console.error('Error in adapter change callback:', error) + } + }) + } +} diff --git a/src/composables/graph/useNodeChangeDetection.ts b/src/composables/graph/useNodeChangeDetection.ts new file mode 100644 index 0000000000..54748893a2 --- /dev/null +++ b/src/composables/graph/useNodeChangeDetection.ts @@ -0,0 +1,180 @@ +/** + * Node Change Detection + * + * RAF-based change detection for node positions and sizes. + * Syncs LiteGraph changes to the layout system. + */ +import { reactive } from 'vue' + +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { layoutMutations } from '@/services/layoutMutations' + +export interface ChangeDetectionMetrics { + updateTime: number + positionUpdates: number + sizeUpdates: number + rafUpdateCount: number +} + +/** + * Change detection for node geometry + */ +export function useNodeChangeDetection(graph: LGraph) { + const metrics = reactive({ + updateTime: 0, + positionUpdates: 0, + sizeUpdates: 0, + rafUpdateCount: 0 + }) + + // Track last known positions/sizes + const lastSnapshot = new Map< + string, + { pos: [number, number]; size: [number, number] } + >() + + /** + * Detects position changes for a single node + */ + const detectPositionChanges = ( + node: LGraphNode, + nodePositions: Map + ): boolean => { + const id = String(node.id) + const currentPos = nodePositions.get(id) + + if ( + !currentPos || + currentPos.x !== node.pos[0] || + currentPos.y !== node.pos[1] + ) { + nodePositions.set(id, { x: node.pos[0], y: node.pos[1] }) + + // Push position change to layout store + void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] }) + + return true + } + return false + } + + /** + * Detects size changes for a single node + */ + const detectSizeChanges = ( + node: LGraphNode, + nodeSizes: Map + ): boolean => { + const id = String(node.id) + const currentSize = nodeSizes.get(id) + + if ( + !currentSize || + currentSize.width !== node.size[0] || + currentSize.height !== node.size[1] + ) { + nodeSizes.set(id, { width: node.size[0], height: node.size[1] }) + + // Push size change to layout store + void layoutMutations.resizeNode(id, { + width: node.size[0], + height: node.size[1] + }) + + return true + } + return false + } + + /** + * Main RAF change detection function + */ + const detectChanges = ( + nodePositions: Map, + nodeSizes: Map, + onSpatialChange?: (node: LGraphNode, id: string) => void + ) => { + const startTime = performance.now() + + if (!graph?._nodes) return + + let positionUpdates = 0 + let sizeUpdates = 0 + + // Set source for all canvas-driven updates + layoutMutations.setSource('canvas') + + // Process each node for changes + for (const node of graph._nodes) { + const id = String(node.id) + + const posChanged = detectPositionChanges(node, nodePositions) + const sizeChanged = detectSizeChanges(node, nodeSizes) + + if (posChanged) positionUpdates++ + if (sizeChanged) sizeUpdates++ + + // Notify spatial change if needed + if ((posChanged || sizeChanged) && onSpatialChange) { + onSpatialChange(node, id) + } + } + + // Update metrics + const endTime = performance.now() + metrics.updateTime = endTime - startTime + metrics.positionUpdates = positionUpdates + metrics.sizeUpdates = sizeUpdates + + if (positionUpdates > 0 || sizeUpdates > 0) { + metrics.rafUpdateCount++ + } + } + + /** + * Take a snapshot of current node positions/sizes + */ + const takeSnapshot = () => { + if (!graph?._nodes) return + + lastSnapshot.clear() + for (const node of graph._nodes) { + lastSnapshot.set(String(node.id), { + pos: [node.pos[0], node.pos[1]], + size: [node.size[0], node.size[1]] + }) + } + } + + /** + * Check if any nodes have changed since last snapshot + */ + const hasChangedSinceSnapshot = (): boolean => { + if (!graph?._nodes) return false + + for (const node of graph._nodes) { + const id = String(node.id) + const last = lastSnapshot.get(id) + if (!last) continue + + if ( + last.pos[0] !== node.pos[0] || + last.pos[1] !== node.pos[1] || + last.size[0] !== node.size[0] || + last.size[1] !== node.size[1] + ) { + return true + } + } + return false + } + + return { + metrics, + detectChanges, + detectPositionChanges, + detectSizeChanges, + takeSnapshot, + hasChangedSinceSnapshot + } +} diff --git a/src/composables/graph/useNodeState.ts b/src/composables/graph/useNodeState.ts new file mode 100644 index 0000000000..eed71f9154 --- /dev/null +++ b/src/composables/graph/useNodeState.ts @@ -0,0 +1,260 @@ +/** + * Node State Management + * + * Manages node visibility, dirty state, and other UI state. + * Provides reactive state for Vue components. + */ +import { nextTick, reactive, readonly } from 'vue' + +import { PERFORMANCE_CONFIG } from '@/constants/layout' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +import type { SafeWidgetData, VueNodeData, WidgetValue } from './useNodeWidgets' + +export interface NodeState { + visible: boolean + dirty: boolean + lastUpdate: number + culled: boolean +} + +export interface NodeMetadata { + lastRenderTime: number + cachedBounds: DOMRect | null + lodLevel: 'high' | 'medium' | 'low' +} + +/** + * Extract safe Vue data from LiteGraph node + */ +export function extractVueNodeData( + node: LGraphNode, + widgets?: SafeWidgetData[] +): VueNodeData { + return { + id: String(node.id), + title: node.title || 'Untitled', + type: node.type || 'Unknown', + mode: node.mode || 0, + selected: node.selected || false, + executing: false, // Will be updated separately based on execution state + widgets, + inputs: node.inputs ? [...node.inputs] : undefined, + outputs: node.outputs ? [...node.outputs] : undefined, + flags: node.flags ? { ...node.flags } : undefined + } +} + +/** + * Node state management composable + */ +export function useNodeState() { + // Reactive state maps + const vueNodeData = reactive(new Map()) + const nodeState = reactive(new Map()) + const nodePositions = reactive(new Map()) + const nodeSizes = reactive( + new Map() + ) + + // Non-reactive node references + const nodeRefs = new Map() + + // WeakMap for heavy metadata that auto-GCs + const nodeMetadata = new WeakMap() + + // Update batching + const pendingUpdates = new Set() + const criticalUpdates = new Set() + const lowPriorityUpdates = new Set() + let updateScheduled = false + let batchTimeoutId: number | null = null + + /** + * Attach metadata to a node + */ + const attachMetadata = (node: LGraphNode) => { + nodeMetadata.set(node, { + lastRenderTime: performance.now(), + cachedBounds: null, + lodLevel: 'high' + }) + } + + /** + * Get access to original LiteGraph node + */ + const getNode = (id: string): LGraphNode | undefined => { + return nodeRefs.get(id) + } + + /** + * Schedule an update for a node + */ + const scheduleUpdate = ( + nodeId?: string, + priority: 'critical' | 'normal' | 'low' = 'normal' + ) => { + if (nodeId) { + const state = nodeState.get(nodeId) + if (state) state.dirty = true + + // Priority queuing + if (priority === 'critical') { + criticalUpdates.add(nodeId) + flush() // Immediate flush for critical updates + return + } else if (priority === 'low') { + lowPriorityUpdates.add(nodeId) + } else { + pendingUpdates.add(nodeId) + } + } + + if (!updateScheduled) { + updateScheduled = true + + // Adaptive batching strategy + if (pendingUpdates.size > 10) { + // Many updates - batch in nextTick + void nextTick(() => flush()) + } else { + // Few updates - small delay for more batching + batchTimeoutId = window.setTimeout( + () => flush(), + PERFORMANCE_CONFIG.BATCH_UPDATE_DELAY + ) + } + } + } + + /** + * Flush all pending updates + */ + const flush = () => { + if (batchTimeoutId !== null) { + clearTimeout(batchTimeoutId) + batchTimeoutId = null + } + + // Clear all pending updates + criticalUpdates.clear() + pendingUpdates.clear() + lowPriorityUpdates.clear() + updateScheduled = false + + // Trigger any additional update logic here + } + + /** + * Initialize node state + */ + const initializeNode = (node: LGraphNode, vueData: VueNodeData): void => { + const id = String(node.id) + + // Store references + nodeRefs.set(id, node) + vueNodeData.set(id, vueData) + + // Initialize state + nodeState.set(id, { + visible: true, + dirty: false, + lastUpdate: performance.now(), + culled: false + }) + + // Initialize position and size + nodePositions.set(id, { x: node.pos[0], y: node.pos[1] }) + nodeSizes.set(id, { width: node.size[0], height: node.size[1] }) + + // Attach metadata + attachMetadata(node) + } + + /** + * Clean up node state + */ + const cleanupNode = (nodeId: string): void => { + nodeRefs.delete(nodeId) + vueNodeData.delete(nodeId) + nodeState.delete(nodeId) + nodePositions.delete(nodeId) + nodeSizes.delete(nodeId) + } + + /** + * Update node property + */ + const updateNodeProperty = ( + nodeId: string, + property: string, + value: unknown + ): void => { + const currentData = vueNodeData.get(nodeId) + if (!currentData) return + + if (property === 'title') { + vueNodeData.set(nodeId, { + ...currentData, + title: String(value) + }) + } else if (property === 'flags.collapsed') { + vueNodeData.set(nodeId, { + ...currentData, + flags: { + ...currentData.flags, + collapsed: Boolean(value) + } + }) + } + } + + /** + * Update widget state + */ + const updateWidgetState = ( + nodeId: string, + widgetName: string, + value: unknown + ): void => { + const currentData = vueNodeData.get(nodeId) + if (!currentData?.widgets) return + + const updatedWidgets = currentData.widgets.map((w) => + w.name === widgetName ? { ...w, value: value as WidgetValue } : w + ) + vueNodeData.set(nodeId, { + ...currentData, + widgets: updatedWidgets + }) + } + + return { + // State maps (read-only) + vueNodeData: readonly(vueNodeData) as ReadonlyMap, + nodeState: readonly(nodeState) as ReadonlyMap, + nodePositions: readonly(nodePositions) as ReadonlyMap< + string, + { x: number; y: number } + >, + nodeSizes: readonly(nodeSizes) as ReadonlyMap< + string, + { width: number; height: number } + >, + + // Methods + getNode, + attachMetadata, + scheduleUpdate, + flush, + initializeNode, + cleanupNode, + updateNodeProperty, + updateWidgetState, + + // Mutable access for internal use + _mutableNodePositions: nodePositions, + _mutableNodeSizes: nodeSizes + } +} diff --git a/src/composables/graph/useNodeWidgets.ts b/src/composables/graph/useNodeWidgets.ts new file mode 100644 index 0000000000..260e26d4dc --- /dev/null +++ b/src/composables/graph/useNodeWidgets.ts @@ -0,0 +1,182 @@ +/** + * Node Widget Management + * + * Handles widget state synchronization between LiteGraph and Vue. + * Provides wrapped callbacks to maintain consistency. + */ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { WidgetValue } from '@/types/simplifiedWidget' + +export type { WidgetValue } + +export interface SafeWidgetData { + name: string + type: string + value: WidgetValue + options?: Record + callback?: ((value: unknown) => void) | undefined +} + +export interface VueNodeData { + id: string + title: string + type: string + mode: number + selected: boolean + executing: boolean + widgets?: SafeWidgetData[] + inputs?: unknown[] + outputs?: unknown[] + flags?: { + collapsed?: boolean + } +} + +/** + * Validates that a value is a valid WidgetValue type + */ +export function validateWidgetValue(value: unknown): WidgetValue { + if (value === null || value === undefined || value === void 0) { + return undefined + } + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + if (typeof value === 'object') { + // Check if it's a File array + if (Array.isArray(value) && value.every((item) => item instanceof File)) { + return value as File[] + } + // Otherwise it's a generic object + return value as object + } + // If none of the above, return undefined + console.warn(`Invalid widget value type: ${typeof value}`, value) + return undefined +} + +/** + * Extract safe widget data from LiteGraph widgets + */ +export function extractWidgetData( + widgets?: any[] +): SafeWidgetData[] | undefined { + if (!widgets) return undefined + + return widgets.map((widget) => { + try { + let value = widget.value + + // For combo widgets, if value is undefined, use the first option as default + if ( + value === undefined && + widget.type === 'combo' && + widget.options?.values && + Array.isArray(widget.options.values) && + widget.options.values.length > 0 + ) { + value = widget.options.values[0] + } + + return { + name: widget.name, + type: widget.type, + value: validateWidgetValue(value), + options: widget.options ? { ...widget.options } : undefined, + callback: widget.callback + } + } catch (error) { + return { + name: widget.name || 'unknown', + type: widget.type || 'text', + value: undefined, + options: undefined, + callback: undefined + } + } + }) +} + +/** + * Widget callback management for LiteGraph/Vue sync + */ +export function useNodeWidgets() { + /** + * Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync + */ + const createWrappedCallback = ( + widget: { value?: unknown; name: string }, + originalCallback: ((value: unknown) => void) | undefined, + nodeId: string, + onUpdate: (nodeId: string, widgetName: string, value: unknown) => void + ) => { + let updateInProgress = false + + return (value: unknown) => { + if (updateInProgress) return + updateInProgress = true + + try { + // Validate that the value is of an acceptable type + if ( + value !== null && + value !== undefined && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + typeof value !== 'object' + ) { + console.warn(`Invalid widget value type: ${typeof value}`) + updateInProgress = false + return + } + + // Always update widget.value to ensure sync + widget.value = value + + // Call the original callback if it exists + if (originalCallback) { + originalCallback.call(widget, value) + } + + // Update Vue state to maintain synchronization + onUpdate(nodeId, widget.name, value) + } finally { + updateInProgress = false + } + } + } + + /** + * Sets up widget callbacks for a node + */ + const setupNodeWidgetCallbacks = ( + node: LGraphNode, + onUpdate: (nodeId: string, widgetName: string, value: unknown) => void + ) => { + if (!node.widgets) return + + const nodeId = String(node.id) + + node.widgets.forEach((widget) => { + const originalCallback = widget.callback + widget.callback = createWrappedCallback( + widget, + originalCallback, + nodeId, + onUpdate + ) + }) + } + + return { + validateWidgetValue, + extractWidgetData, + createWrappedCallback, + setupNodeWidgetCallbacks + } +} diff --git a/src/constants/layout.ts b/src/constants/layout.ts new file mode 100644 index 0000000000..128982e80e --- /dev/null +++ b/src/constants/layout.ts @@ -0,0 +1,73 @@ +/** + * Layout System Constants + * + * Centralized configuration values for the layout system. + * These values control spatial indexing, performance, and behavior. + */ + +/** + * QuadTree configuration for spatial indexing + */ +export const QUADTREE_CONFIG = { + /** Default bounds for the QuadTree - covers a large canvas area */ + DEFAULT_BOUNDS: { + x: -10000, + y: -10000, + width: 20000, + height: 20000 + }, + /** Maximum tree depth to prevent excessive subdivision */ + MAX_DEPTH: 6, + /** Maximum items per node before subdivision */ + MAX_ITEMS_PER_NODE: 4 +} as const + +/** + * Performance and optimization settings + */ +export const PERFORMANCE_CONFIG = { + /** RAF-based change detection interval (roughly 60fps) */ + CHANGE_DETECTION_INTERVAL: 16, + /** Spatial query cache TTL in milliseconds */ + SPATIAL_CACHE_TTL: 1000, + /** Maximum cache size for spatial queries */ + SPATIAL_CACHE_MAX_SIZE: 100, + /** Batch update delay in milliseconds */ + BATCH_UPDATE_DELAY: 4 +} as const + +/** + * Default values for node layout + */ +export const NODE_DEFAULTS = { + /** Default node size when not specified */ + SIZE: { width: 200, height: 100 }, + /** Default z-index for new nodes */ + Z_INDEX: 0, + /** Default visibility state */ + VISIBLE: true +} as const + +/** + * Debug and development settings + */ +export const DEBUG_CONFIG = { + /** LocalStorage key for enabling layout debug mode */ + LAYOUT_DEBUG_KEY: 'layout-debug', + /** Logger name for layout system */ + LOGGER_NAME: 'layout', + /** Logger name for layout store */ + STORE_LOGGER_NAME: 'layout-store' +} as const + +/** + * Actor and source identifiers + */ +export const ACTOR_CONFIG = { + /** Prefix for auto-generated actor IDs */ + USER_PREFIX: 'user-', + /** Length of random suffix for actor IDs */ + ID_LENGTH: 9, + /** Default source when not specified */ + DEFAULT_SOURCE: 'external' as const +} as const diff --git a/src/services/spatialIndexManager.ts b/src/services/spatialIndexManager.ts new file mode 100644 index 0000000000..874530761f --- /dev/null +++ b/src/services/spatialIndexManager.ts @@ -0,0 +1,166 @@ +/** + * Spatial Index Manager + * + * Manages spatial indexing for efficient node queries based on bounds. + * Uses QuadTree for fast spatial lookups with caching for performance. + */ +import { PERFORMANCE_CONFIG, QUADTREE_CONFIG } from '@/constants/layout' +import type { Bounds, NodeId } from '@/types/layoutTypes' +import { QuadTree } from '@/utils/spatial/QuadTree' + +/** + * Cache entry for spatial queries + */ +interface CacheEntry { + result: NodeId[] + timestamp: number +} + +/** + * Spatial index manager using QuadTree + */ +export class SpatialIndexManager { + private quadTree: QuadTree + private queryCache: Map + private cacheSize = 0 + + constructor(bounds?: Bounds) { + this.quadTree = new QuadTree( + bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS, + { + maxDepth: QUADTREE_CONFIG.MAX_DEPTH, + maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE + } + ) + this.queryCache = new Map() + } + + /** + * Insert a node into the spatial index + */ + insert(nodeId: NodeId, bounds: Bounds): void { + this.quadTree.insert(nodeId, bounds, nodeId) + this.invalidateCache() + } + + /** + * Update a node's bounds in the spatial index + */ + update(nodeId: NodeId, bounds: Bounds): void { + this.quadTree.update(nodeId, bounds) + this.invalidateCache() + } + + /** + * Remove a node from the spatial index + */ + remove(nodeId: NodeId): void { + this.quadTree.remove(nodeId) + this.invalidateCache() + } + + /** + * Query nodes within the given bounds + */ + query(bounds: Bounds): NodeId[] { + const cacheKey = this.getCacheKey(bounds) + const cached = this.queryCache.get(cacheKey) + + // Check cache validity + if (cached) { + const age = Date.now() - cached.timestamp + if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) { + return cached.result + } + // Remove stale entry + this.queryCache.delete(cacheKey) + this.cacheSize-- + } + + // Perform query + const result = this.quadTree.query(bounds) + + // Cache result + this.addToCache(cacheKey, result) + + return result + } + + /** + * Clear all nodes from the spatial index + */ + clear(): void { + this.quadTree.clear() + this.invalidateCache() + } + + /** + * Get the current size of the index + */ + get size(): number { + return this.quadTree.size + } + + /** + * Get debug information about the spatial index + */ + getDebugInfo() { + return { + quadTreeInfo: this.quadTree.getDebugInfo(), + cacheSize: this.cacheSize, + cacheEntries: this.queryCache.size + } + } + + /** + * Generate cache key for bounds + */ + private getCacheKey(bounds: Bounds): string { + return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}` + } + + /** + * Add result to cache with LRU eviction + */ + private addToCache(key: string, result: NodeId[]): void { + // Evict oldest entries if cache is full + if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) { + const oldestKey = this.findOldestCacheEntry() + if (oldestKey) { + this.queryCache.delete(oldestKey) + this.cacheSize-- + } + } + + this.queryCache.set(key, { + result, + timestamp: Date.now() + }) + this.cacheSize++ + } + + /** + * Find oldest cache entry for LRU eviction + */ + private findOldestCacheEntry(): string | null { + let oldestKey: string | null = null + let oldestTime = Infinity + + for (const [key, entry] of this.queryCache) { + if (entry.timestamp < oldestTime) { + oldestTime = entry.timestamp + oldestKey = key + } + } + + return oldestKey + } + + /** + * Invalidate all cached queries + */ + private invalidateCache(): void { + this.queryCache.clear() + this.cacheSize = 0 + } +} diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts index 0f13d70994..039ee1c95d 100644 --- a/src/stores/layoutStore.ts +++ b/src/stores/layoutStore.ts @@ -8,6 +8,8 @@ import log from 'loglevel' import { type ComputedRef, type Ref, computed, customRef } from 'vue' import * as Y from 'yjs' +import { ACTOR_CONFIG, DEBUG_CONFIG } from '@/constants/layout' +import { SpatialIndexManager } from '@/services/spatialIndexManager' import type { CreateNodeOperation, DeleteNodeOperation, @@ -24,10 +26,9 @@ import type { NodeLayout, Point } from '@/types/layoutTypes' -import { QuadTree } from '@/utils/spatial/QuadTree' // Create logger for layout store -const logger = log.getLogger('layout-store') +const logger = log.getLogger(DEBUG_CONFIG.STORE_LOGGER_NAME) // In dev mode, always show debug logs if (import.meta.env.DEV) { logger.setLevel('debug') @@ -41,8 +42,11 @@ class LayoutStoreImpl implements LayoutStore { // Vue reactivity layer private version = 0 - private currentSource: 'canvas' | 'vue' | 'external' = 'external' - private currentActor = `user-${Math.random().toString(36).substr(2, 9)}` // Random actor ID + private currentSource: 'canvas' | 'vue' | 'external' = + ACTOR_CONFIG.DEFAULT_SOURCE + private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random() + .toString(36) + .substr(2, ACTOR_CONFIG.ID_LENGTH)}` // Change listeners private changeListeners = new Set<(change: LayoutChange) => void>() @@ -51,25 +55,20 @@ class LayoutStoreImpl implements LayoutStore { private nodeRefs = new Map>() private nodeTriggers = new Map void>() - // Spatial index using existing QuadTree infrastructure - private spatialIndex: QuadTree - private spatialQueryCache = new Map() + // Spatial index manager + private spatialIndex: SpatialIndexManager constructor() { // Initialize Yjs data structures this.ynodes = this.ydoc.getMap('nodes') this.yoperations = this.ydoc.getArray('operations') - // Initialize QuadTree with reasonable bounds - this.spatialIndex = new QuadTree( - { x: -10000, y: -10000, width: 20000, height: 20000 }, - { maxDepth: 6, maxItemsPerNode: 4 } - ) + // Initialize spatial index manager + this.spatialIndex = new SpatialIndexManager() // Listen for Yjs changes and trigger Vue reactivity this.ynodes.observe((event) => { this.version++ - this.spatialQueryCache.clear() // Trigger all affected node refs event.changes.keys.forEach((_change, key) => { @@ -82,7 +81,7 @@ class LayoutStoreImpl implements LayoutStore { }) // Debug: Log layout operations - if (localStorage.getItem('layout-debug') === 'true') { + if (localStorage.getItem(DEBUG_CONFIG.LAYOUT_DEBUG_KEY) === 'true') { this.yoperations.observe((event) => { const operations: LayoutOperation[] = [] event.changes.added.forEach((item) => { @@ -288,17 +287,7 @@ class LayoutStoreImpl implements LayoutStore { * Query nodes in bounds (non-reactive for performance) */ queryNodesInBounds(bounds: Bounds): NodeId[] { - // Check cache first - const cacheKey = `${bounds.x},${bounds.y},${bounds.width},${bounds.height}` - const cached = this.spatialQueryCache.get(cacheKey) - if (cached) return cached - - // Use QuadTree for efficient spatial query - const result = this.spatialIndex.query(bounds) - - // Cache result - this.spatialQueryCache.set(cacheKey, result) - return result + return this.spatialIndex.query(bounds) } /** @@ -363,9 +352,8 @@ class LayoutStoreImpl implements LayoutStore { * Finalize operation after transaction */ private finalizeOperation(change: LayoutChange): void { - // Update version and clear cache + // Update version this.version++ - this.spatialQueryCache.clear() // Manually trigger affected node refs after transaction // This is needed because Yjs observers don't fire for property changes @@ -454,7 +442,7 @@ class LayoutStoreImpl implements LayoutStore { this.ynodes.set(layout.id, this.layoutToYNode(layout)) // Add to spatial index - this.spatialIndex.insert(layout.id, layout.bounds, layout.id) + this.spatialIndex.insert(layout.id, layout.bounds) logger.debug( `Initialized node ${layout.id} at position:`, @@ -537,11 +525,7 @@ class LayoutStoreImpl implements LayoutStore { this.ynodes.set(operation.nodeId, ynode) // Add to spatial index - this.spatialIndex.insert( - operation.nodeId, - operation.layout.bounds, - operation.nodeId - ) + this.spatialIndex.insert(operation.nodeId, operation.layout.bounds) change.type = 'create' change.nodeIds.push(operation.nodeId)