mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 06:44:32 +00:00
refactor: Extract services and split composables for better organization
- 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 <noreply@anthropic.com>
This commit is contained in:
82
src/adapters/layoutAdapter.ts
Normal file
82
src/adapters/layoutAdapter.ts
Normal file
@@ -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<NodeId, NodeLayout>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
137
src/adapters/mockLayoutAdapter.ts
Normal file
137
src/adapters/mockLayoutAdapter.ts
Normal file
@@ -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<NodeId, NodeLayout>()
|
||||
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<NodeId, NodeLayout> {
|
||||
// Return a copy to prevent external mutations
|
||||
const copy = new Map<NodeId, NodeLayout>()
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
202
src/adapters/yjsLayoutAdapter.ts
Normal file
202
src/adapters/yjsLayoutAdapter.ts
Normal file
@@ -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<Y.Map<unknown>>
|
||||
private yoperations: Y.Array<LayoutOperation>
|
||||
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<NodeId, NodeLayout> {
|
||||
const result = new Map<NodeId, NodeLayout>()
|
||||
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<unknown> {
|
||||
const ynode = new Y.Map<unknown>()
|
||||
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<unknown>): 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
180
src/composables/graph/useNodeChangeDetection.ts
Normal file
180
src/composables/graph/useNodeChangeDetection.ts
Normal file
@@ -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<ChangeDetectionMetrics>({
|
||||
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<string, { x: number; y: number }>
|
||||
): 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<string, { width: number; height: number }>
|
||||
): 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<string, { x: number; y: number }>,
|
||||
nodeSizes: Map<string, { width: number; height: number }>,
|
||||
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
|
||||
}
|
||||
}
|
||||
260
src/composables/graph/useNodeState.ts
Normal file
260
src/composables/graph/useNodeState.ts
Normal file
@@ -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<string, VueNodeData>())
|
||||
const nodeState = reactive(new Map<string, NodeState>())
|
||||
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
|
||||
const nodeSizes = reactive(
|
||||
new Map<string, { width: number; height: number }>()
|
||||
)
|
||||
|
||||
// Non-reactive node references
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// WeakMap for heavy metadata that auto-GCs
|
||||
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
|
||||
|
||||
// Update batching
|
||||
const pendingUpdates = new Set<string>()
|
||||
const criticalUpdates = new Set<string>()
|
||||
const lowPriorityUpdates = new Set<string>()
|
||||
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<string, VueNodeData>,
|
||||
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
|
||||
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
|
||||
}
|
||||
}
|
||||
182
src/composables/graph/useNodeWidgets.ts
Normal file
182
src/composables/graph/useNodeWidgets.ts
Normal file
@@ -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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
73
src/constants/layout.ts
Normal file
73
src/constants/layout.ts
Normal file
@@ -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
|
||||
166
src/services/spatialIndexManager.ts
Normal file
166
src/services/spatialIndexManager.ts
Normal file
@@ -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<NodeId>
|
||||
private queryCache: Map<string, CacheEntry>
|
||||
private cacheSize = 0
|
||||
|
||||
constructor(bounds?: Bounds) {
|
||||
this.quadTree = new QuadTree<NodeId>(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId, Ref<NodeLayout | null>>()
|
||||
private nodeTriggers = new Map<NodeId, () => void>()
|
||||
|
||||
// Spatial index using existing QuadTree infrastructure
|
||||
private spatialIndex: QuadTree<NodeId>
|
||||
private spatialQueryCache = new Map<string, NodeId[]>()
|
||||
// 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<NodeId>(
|
||||
{ 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)
|
||||
|
||||
Reference in New Issue
Block a user