/ src / dreamspace / SpatialOrchestrator.tsx
SpatialOrchestrator.tsx
   1  /**
   2   * SpatialOrchestrator Component
   3   * 
   4   * Central hub for all spatial layouts and interactions in the dreamspace.
   5   * Manages DreamNode3D refs and orchestrates position changes via Universal Movement API.
   6   * 
   7   * Follows the "test command pattern" from VALUABLE_WORK_EXTRACTION.md:
   8   * - Pure animation orchestrator
   9   * - No store updates (that's Step 5)
  10   * - Direct animation calls on existing refs
  11   * - Same nodes stay rendered throughout
  12   */
  13  
  14  import React, { useRef, useImperativeHandle, forwardRef, useEffect } from 'react';
  15  import { Vector3, Group } from 'three';
  16  import { DreamNode } from '../types/dreamnode';
  17  import { DreamNode3DRef } from './DreamNode3D';
  18  import { buildRelationshipGraph } from '../utils/relationship-graph';
  19  import { calculateRingLayoutPositions, calculateRingLayoutPositionsForSearch, DEFAULT_RING_CONFIG } from './layouts/RingLayout';
  20  import { computeConstellationLayout, createFallbackLayout } from './constellation/ConstellationLayout';
  21  import { useInterBrainStore } from '../store/interbrain-store';
  22  
  23  export interface SpatialOrchestratorRef {
  24    /** Focus on a specific node - trigger liminal web layout */
  25    focusOnNode: (nodeId: string) => void;
  26    
  27    /** Focus on a specific node with smooth fly-in animation for newly created node */
  28    focusOnNodeWithFlyIn: (nodeId: string, newNodeId: string) => void;
  29    
  30    /** Return all nodes to constellation layout */
  31    returnToConstellation: () => void;
  32    
  33    /** Focus on a specific node with mid-flight interruption support */
  34    interruptAndFocusOnNode: (nodeId: string) => void;
  35    
  36    /** Return all nodes to constellation with mid-flight interruption support */
  37    interruptAndReturnToConstellation: () => void;
  38    
  39    /** Get current focused node ID */
  40    getFocusedNodeId: () => string | null;
  41    
  42    /** Check if currently in focused mode (any node is focused) */
  43    isFocusedMode: () => boolean;
  44    
  45    /** Show search results in honeycomb layout */
  46    showSearchResults: (searchResults: DreamNode[]) => void;
  47    
  48    /** Move all nodes to sphere surface for search interface mode (like liminal web) */
  49    moveAllToSphereForSearch: () => void;
  50    
  51    /** Special transition for edit mode save - center node doesn't move */
  52    animateToLiminalWebFromEdit: (nodeId: string) => void;
  53    
  54    /** Show search results for edit mode - keep center node in place */
  55    showEditModeSearchResults: (centerNodeId: string, searchResults: DreamNode[]) => void;
  56    
  57    /** Reorder edit mode search results based on current pending relationships */
  58    reorderEditModeSearchResults: () => void;
  59    
  60    /** Clear stale edit mode data when exiting edit mode */
  61    clearEditModeData: () => void;
  62    
  63    /** Register a DreamNode3D ref for orchestration */
  64    registerNodeRef: (nodeId: string, ref: React.RefObject<DreamNode3DRef>) => void;
  65  
  66    /** Unregister a DreamNode3D ref */
  67    unregisterNodeRef: (nodeId: string) => void;
  68  
  69    /** Apply constellation layout based on relationship graph */
  70    applyConstellationLayout: () => Promise<void>;
  71  
  72    /** Hide related nodes in liminal-web mode (move to constellation) */
  73    hideRelatedNodesInLiminalWeb: () => void;
  74  
  75    /** Show related nodes in liminal-web mode (move back to ring positions) */
  76    showRelatedNodesInLiminalWeb: () => void;
  77  }
  78  
  79  interface SpatialOrchestratorProps {
  80    /** All available dream nodes */
  81    dreamNodes: DreamNode[];
  82    
  83    /** Reference to the rotatable dream world group for position correction */
  84    dreamWorldRef: React.RefObject<Group | null>;
  85    
  86    /** Callback when a node is focused */
  87    onNodeFocused?: (nodeId: string) => void;
  88    
  89    /** Callback when returning to constellation */
  90    onConstellationReturn?: () => void;
  91    
  92    /** Callback when orchestrator is ready to receive refs */
  93    onOrchestratorReady?: () => void;
  94    
  95    /** Animation duration for transitions */
  96    transitionDuration?: number;
  97  }
  98  
  99  /**
 100   * SpatialOrchestrator - Central hub for spatial layout management
 101   * 
 102   * This component doesn't render anything visible itself, but manages all spatial
 103   * interactions and position orchestration for DreamNode3D components.
 104   */
 105  const SpatialOrchestrator = forwardRef<SpatialOrchestratorRef, SpatialOrchestratorProps>(({
 106    dreamNodes,
 107    dreamWorldRef,
 108    onNodeFocused,
 109    onConstellationReturn,
 110    onOrchestratorReady,
 111    transitionDuration = 1000
 112  }, ref) => {
 113    
 114    // Registry of all DreamNode3D refs for position orchestration
 115    const nodeRefs = useRef<Map<string, React.RefObject<DreamNode3DRef>>>(new Map());
 116    
 117    // Current state tracking
 118    const focusedNodeId = useRef<string | null>(null);
 119    const isTransitioning = useRef<boolean>(false);
 120    
 121    // Track node roles during liminal-web mode for proper constellation return
 122    const liminalWebRoles = useRef<{
 123      centerNodeId: string | null;
 124      ring1NodeIds: Set<string>;
 125      ring2NodeIds: Set<string>;
 126      ring3NodeIds: Set<string>;
 127      sphereNodeIds: Set<string>;
 128    }>({
 129      centerNodeId: null,
 130      ring1NodeIds: new Set(),
 131      ring2NodeIds: new Set(),
 132      ring3NodeIds: new Set(),
 133      sphereNodeIds: new Set()
 134    });
 135    
 136    // Store integration
 137    const setSpatialLayout = useInterBrainStore(state => state.setSpatialLayout);
 138    // const resetAllFlips = useInterBrainStore(state => state.resetAllFlips); // Removed - flip reset now handled by nodes
 139    
 140    // Track current edit mode search results for dynamic reordering
 141    const currentEditModeSearchResults = useRef<DreamNode[]>([]);
 142    const currentEditModeCenterNodeId = useRef<string | null>(null);
 143    
 144    // Track the stable lists for swapping logic
 145    const relatedNodesList = useRef<Array<{ id: string; name: string; type: string }>>([]);
 146    const unrelatedSearchResultsList = useRef<Array<{ id: string; name: string; type: string }>>([]);
 147    
 148    useImperativeHandle(ref, () => ({
 149      focusOnNode: (nodeId: string) => {
 150        try {
 151          // Build relationship graph from current nodes
 152          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 153  
 154          // Calculate ring layout positions (in local sphere space)
 155          const positions = calculateRingLayoutPositions(nodeId, relationshipGraph, DEFAULT_RING_CONFIG);
 156  
 157          // Defensive check - ensure all arrays exist
 158          if (!positions || !positions.ring1Nodes || !positions.ring2Nodes || !positions.ring3Nodes) {
 159            console.error('SpatialOrchestrator: Invalid positions returned from calculateRingLayoutPositions', positions);
 160            throw new Error('Failed to calculate ring layout positions');
 161          }
 162  
 163          // Track node roles for proper constellation return
 164          liminalWebRoles.current = {
 165            centerNodeId: positions.centerNode?.nodeId || null,
 166            ring1NodeIds: new Set(positions.ring1Nodes.map(n => n.nodeId)),
 167            ring2NodeIds: new Set(positions.ring2Nodes.map(n => n.nodeId)),
 168            ring3NodeIds: new Set(positions.ring3Nodes.map(n => n.nodeId)),
 169            sphereNodeIds: new Set(positions.sphereNodes || [])
 170          };
 171  
 172          // Apply world-space position correction based on current sphere rotation
 173          if (dreamWorldRef.current) {
 174            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 175  
 176            // We need to apply the INVERSE rotation to counteract the sphere's rotation
 177            // This makes the liminal web appear in camera-relative positions regardless of sphere rotation
 178            const inverseRotation = sphereRotation.invert();
 179  
 180            // Transform center node position to world space (if exists)
 181            if (positions.centerNode) {
 182              const centerPos = new Vector3(...positions.centerNode.position);
 183              centerPos.applyQuaternion(inverseRotation);
 184              positions.centerNode.position = [centerPos.x, centerPos.y, centerPos.z];
 185            }
 186  
 187            // Transform all ring node positions to world space
 188            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 189              const originalPos = new Vector3(...node.position);
 190              originalPos.applyQuaternion(inverseRotation);
 191              node.position = [originalPos.x, originalPos.y, originalPos.z];
 192            });
 193          }
 194          
 195          // Start transition
 196          isTransitioning.current = true;
 197          focusedNodeId.current = nodeId;
 198          
 199          // Only update to liminal-web if not already in edit mode or copilot mode
 200          // Edit mode and copilot mode manage their own layout state
 201          const currentLayout = useInterBrainStore.getState().spatialLayout;
 202          if (currentLayout !== 'edit' && currentLayout !== 'edit-search' && currentLayout !== 'copilot') {
 203            setSpatialLayout('liminal-web');
 204          }
 205          
 206          // Move center node to focus position (if exists)
 207          if (positions.centerNode) {
 208            const centerNodeRef = nodeRefs.current.get(positions.centerNode.nodeId);
 209            if (centerNodeRef?.current) {
 210              centerNodeRef.current.setActiveState(true);
 211              // Center node uses ease-out for smooth arrival
 212              centerNodeRef.current.moveToPosition(positions.centerNode.position, transitionDuration, 'easeOutQuart');
 213            }
 214          }
 215          
 216          // Move all ring nodes to their positions (hexagonal rings "break free")
 217          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: ringNodeId, position }) => {
 218            const nodeRef = nodeRefs.current.get(ringNodeId);
 219            if (nodeRef?.current) {
 220              nodeRef.current.setActiveState(true);
 221              // Ring nodes use ease-out for smooth arrival into view
 222              nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
 223            }
 224          });
 225          
 226          // Move sphere nodes to sphere surface (out of the way for clean liminal web view)
 227          positions.sphereNodes.forEach(sphereNodeId => {
 228            const nodeRef = nodeRefs.current.get(sphereNodeId);
 229            if (nodeRef?.current) {
 230              // Sphere nodes use ease-in for quick departure from view
 231              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 232            }
 233          });
 234          
 235          // Set transition complete after animation duration
 236          globalThis.setTimeout(() => {
 237            isTransitioning.current = false;
 238          }, transitionDuration);
 239          
 240          // Notify callback
 241          onNodeFocused?.(nodeId);
 242          
 243        } catch (error) {
 244          console.error('SpatialOrchestrator: Error during focus transition:', error);
 245          isTransitioning.current = false;
 246        }
 247      },
 248      
 249      focusOnNodeWithFlyIn: (nodeId: string, newNodeId: string) => {
 250        try {
 251          console.log(`SpatialOrchestrator: Focus on ${nodeId} with fly-in animation for new node ${newNodeId}`);
 252          
 253          // Build relationship graph from current nodes
 254          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 255          
 256          // Calculate ring layout positions (in local sphere space)
 257          const positions = calculateRingLayoutPositions(nodeId, relationshipGraph, DEFAULT_RING_CONFIG);
 258          
 259          // Track node roles for proper constellation return
 260          liminalWebRoles.current = {
 261            centerNodeId: positions.centerNode?.nodeId || null,
 262            ring1NodeIds: new Set(positions.ring1Nodes.map(n => n.nodeId)),
 263            ring2NodeIds: new Set(positions.ring2Nodes.map(n => n.nodeId)),
 264            ring3NodeIds: new Set(positions.ring3Nodes.map(n => n.nodeId)),
 265            sphereNodeIds: new Set(positions.sphereNodes)
 266          };
 267          
 268          // Apply world-space position correction based on current sphere rotation
 269          if (dreamWorldRef.current) {
 270            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 271            const inverseRotation = sphereRotation.invert();
 272            
 273            // Transform center node position to world space (if exists)
 274            if (positions.centerNode) {
 275              const centerPos = new Vector3(...positions.centerNode.position);
 276              centerPos.applyQuaternion(inverseRotation);
 277              positions.centerNode.position = [centerPos.x, centerPos.y, centerPos.z];
 278            }
 279            
 280            // Transform all ring node positions to world space
 281            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 282              const originalPos = new Vector3(...node.position);
 283              originalPos.applyQuaternion(inverseRotation);
 284              node.position = [originalPos.x, originalPos.y, originalPos.z];
 285            });
 286          }
 287          
 288          // Start transition
 289          isTransitioning.current = true;
 290          focusedNodeId.current = nodeId;
 291          
 292          // Only update to liminal-web if not already in edit mode or copilot mode
 293          // Edit mode and copilot mode manage their own layout state
 294          const currentLayout = useInterBrainStore.getState().spatialLayout;
 295          if (currentLayout !== 'edit' && currentLayout !== 'edit-search' && currentLayout !== 'copilot') {
 296            setSpatialLayout('liminal-web');
 297          }
 298          
 299          // Move center node to focus position (if exists)
 300          if (positions.centerNode) {
 301            const centerNodeRef = nodeRefs.current.get(positions.centerNode.nodeId);
 302            if (centerNodeRef?.current) {
 303              centerNodeRef.current.setActiveState(true);
 304              // Center node uses ease-out for smooth arrival
 305              centerNodeRef.current.moveToPosition(positions.centerNode.position, transitionDuration, 'easeOutQuart');
 306            }
 307          }
 308          
 309          // Move ring nodes with special handling for the newly created node
 310          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: ringNodeId, position }) => {
 311            const nodeRef = nodeRefs.current.get(ringNodeId);
 312            if (nodeRef?.current) {
 313              nodeRef.current.setActiveState(true);
 314              
 315              if (ringNodeId === newNodeId) {
 316                // NEW NODE: Let it spawn at drop position first, then fly to ring position
 317                console.log(`SpatialOrchestrator: New node ${newNodeId} will fly from spawn position to ring position`);
 318                // Use a slightly longer duration for the fly-in effect to make it more dramatic
 319                nodeRef.current.moveToPosition(position, transitionDuration * 1.2, 'easeOutCubic');
 320              } else {
 321                // EXISTING NODES: Move normally to their ring positions
 322                nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
 323              }
 324            }
 325          });
 326          
 327          // Move sphere nodes to sphere surface (out of the way for clean liminal web view)
 328          positions.sphereNodes.forEach(sphereNodeId => {
 329            const nodeRef = nodeRefs.current.get(sphereNodeId);
 330            if (nodeRef?.current) {
 331              // Sphere nodes use ease-in for quick departure from view
 332              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 333            }
 334          });
 335          
 336          // Set transition complete after animation duration (use longer duration for fly-in)
 337          globalThis.setTimeout(() => {
 338            isTransitioning.current = false;
 339          }, transitionDuration * 1.2);
 340          
 341          // Notify callback
 342          onNodeFocused?.(nodeId);
 343          
 344        } catch (error) {
 345          console.error('SpatialOrchestrator: Error during focus with fly-in transition:', error);
 346          isTransitioning.current = false;
 347        }
 348      },
 349      
 350      returnToConstellation: () => {
 351        // Start transition
 352        isTransitioning.current = true;
 353        focusedNodeId.current = null;
 354        
 355        // Note: Flip states now reset smoothly via Universal Movement API flip-back animation
 356        // resetAllFlips(); // Removed - handled by individual nodes during movement
 357        
 358        // Update store to constellation layout mode
 359        setSpatialLayout('constellation');
 360        
 361        // Get current sphere rotation for accurate scaled position calculation
 362        let worldRotation = undefined;
 363        if (dreamWorldRef.current) {
 364          worldRotation = dreamWorldRef.current.quaternion.clone();
 365        }
 366        
 367        // Return ALL nodes to their dynamically scaled constellation positions
 368        // This handles both active (center+rings) and inactive (sphere) nodes correctly
 369        const { centerNodeId, ring1NodeIds, ring2NodeIds, ring3NodeIds, sphereNodeIds } = liminalWebRoles.current;
 370        
 371        // Return ALL nodes to scaled positions with role-based easing
 372        nodeRefs.current.forEach((nodeRef, nodeId) => {
 373          if (nodeRef.current) {
 374            // Determine appropriate easing based on node's role in liminal web
 375            let easing = 'easeOutCubic'; // Default fallback
 376            if (nodeId === centerNodeId || ring1NodeIds.has(nodeId) || ring2NodeIds.has(nodeId) || ring3NodeIds.has(nodeId)) {
 377              // Active nodes moving OUT from liminal positions - accelerate as they leave
 378              easing = 'easeInQuart';
 379            } else if (sphereNodeIds.has(nodeId)) {
 380              // Inactive nodes moving IN from sphere surface - decelerate as they arrive
 381              easing = 'easeOutQuart';
 382            }
 383            
 384            // Pass world rotation for accurate scaling + role-based easing
 385            nodeRef.current.returnToScaledPosition(transitionDuration, worldRotation, easing);
 386          }
 387        });
 388        
 389        // Clear role tracking after initiating return
 390        liminalWebRoles.current = {
 391          centerNodeId: null,
 392          ring1NodeIds: new Set(),
 393          ring2NodeIds: new Set(),
 394          ring3NodeIds: new Set(),
 395          sphereNodeIds: new Set()
 396        };
 397        
 398        // Set transition complete after animation duration
 399        globalThis.setTimeout(() => {
 400          // Ensure all nodes are back in constellation mode
 401          nodeRefs.current.forEach((nodeRef, _nodeId) => {
 402            if (nodeRef.current) {
 403              nodeRef.current.setActiveState(false);
 404            }
 405          });
 406          
 407          isTransitioning.current = false;
 408        }, transitionDuration);
 409        
 410        // Notify callback
 411        onConstellationReturn?.();
 412      },
 413      
 414      interruptAndFocusOnNode: (nodeId: string) => {
 415        try {
 416          // Build relationship graph from current nodes
 417          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 418          
 419          // Calculate ring layout positions (in local sphere space)
 420          const positions = calculateRingLayoutPositions(nodeId, relationshipGraph, DEFAULT_RING_CONFIG);
 421          
 422          // Track node roles for proper constellation return
 423          liminalWebRoles.current = {
 424            centerNodeId: positions.centerNode?.nodeId || null,
 425            ring1NodeIds: new Set(positions.ring1Nodes.map(n => n.nodeId)),
 426            ring2NodeIds: new Set(positions.ring2Nodes.map(n => n.nodeId)),
 427            ring3NodeIds: new Set(positions.ring3Nodes.map(n => n.nodeId)),
 428            sphereNodeIds: new Set(positions.sphereNodes)
 429          };
 430          
 431          // Apply world-space position correction based on current sphere rotation
 432          if (dreamWorldRef.current) {
 433            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 434            
 435            // We need to apply the INVERSE rotation to counteract the sphere's rotation
 436            // This makes the liminal web appear in camera-relative positions regardless of sphere rotation
 437            const inverseRotation = sphereRotation.invert();
 438            
 439            // Transform center node position to world space (if exists)
 440            if (positions.centerNode) {
 441              const centerPos = new Vector3(...positions.centerNode.position);
 442              centerPos.applyQuaternion(inverseRotation);
 443              positions.centerNode.position = [centerPos.x, centerPos.y, centerPos.z];
 444            }
 445            
 446            // Transform all ring node positions to world space
 447            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 448              const originalPos = new Vector3(...node.position);
 449              originalPos.applyQuaternion(inverseRotation);
 450              node.position = [originalPos.x, originalPos.y, originalPos.z];
 451            });
 452          }
 453          
 454          // Start transition (allow interruption of existing transitions)
 455          isTransitioning.current = true;
 456          focusedNodeId.current = nodeId;
 457          
 458          // Only update to liminal-web if not already in edit mode
 459          const currentLayout = useInterBrainStore.getState().spatialLayout;
 460          if (currentLayout !== 'edit' && currentLayout !== 'edit-search' && currentLayout !== 'copilot') {
 461            setSpatialLayout('liminal-web');
 462          }
 463          
 464          // Move center node to focus position (with interruption support)
 465          if (positions.centerNode) {
 466            const centerNodeRef = nodeRefs.current.get(positions.centerNode.nodeId);
 467            if (centerNodeRef?.current) {
 468              centerNodeRef.current.setActiveState(true);
 469              
 470              // Use interruption-capable method if the node is currently moving
 471              if (centerNodeRef.current.isMoving()) {
 472                centerNodeRef.current.interruptAndMoveToPosition(positions.centerNode.position, transitionDuration, 'easeOutQuart');
 473              } else {
 474                // Center node uses ease-out for smooth arrival
 475                centerNodeRef.current.moveToPosition(positions.centerNode.position, transitionDuration, 'easeOutQuart');
 476              }
 477            }
 478          }
 479          
 480          // Move all ring nodes to their positions (with interruption support)
 481          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: ringNodeId, position }) => {
 482            const nodeRef = nodeRefs.current.get(ringNodeId);
 483            if (nodeRef?.current) {
 484              nodeRef.current.setActiveState(true);
 485              
 486              // Use interruption-capable method if the node is currently moving
 487              if (nodeRef.current.isMoving()) {
 488                nodeRef.current.interruptAndMoveToPosition(position, transitionDuration, 'easeOutQuart');
 489              } else {
 490                // Inner circle nodes use ease-out for smooth arrival into view
 491                nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
 492              }
 493            }
 494          });
 495          
 496          // Move sphere nodes to sphere surface (with interruption support)
 497          positions.sphereNodes.forEach(sphereNodeId => {
 498            const nodeRef = nodeRefs.current.get(sphereNodeId);
 499            if (nodeRef?.current) {
 500              // Use interruption-capable method if the node is currently moving
 501              if (nodeRef.current.isMoving()) {
 502                nodeRef.current.interruptAndReturnToConstellation(transitionDuration, 'easeInQuart');
 503              } else {
 504                // Sphere nodes use ease-in for quick departure from view
 505                nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 506              }
 507            }
 508          });
 509          
 510          // Set transition complete after animation duration
 511          globalThis.setTimeout(() => {
 512            isTransitioning.current = false;
 513          }, transitionDuration);
 514          
 515          // Notify callback
 516          onNodeFocused?.(nodeId);
 517          
 518        } catch (error) {
 519          console.error('SpatialOrchestrator: Error during interrupt focus transition:', error);
 520          isTransitioning.current = false;
 521        }
 522      },
 523      
 524      interruptAndReturnToConstellation: () => {
 525        // Start transition (allow interruption of existing transitions)
 526        isTransitioning.current = true;
 527        focusedNodeId.current = null;
 528        
 529        // Update store to constellation layout mode
 530        setSpatialLayout('constellation');
 531        
 532        // Get current sphere rotation for accurate scaled position calculation
 533        let worldRotation = undefined;
 534        if (dreamWorldRef.current) {
 535          worldRotation = dreamWorldRef.current.quaternion.clone();
 536        }
 537        
 538        // Return ALL nodes to their dynamically scaled constellation positions
 539        // This handles both active (center+rings) and inactive (sphere) nodes correctly
 540        const { centerNodeId, ring1NodeIds, ring2NodeIds, ring3NodeIds, sphereNodeIds } = liminalWebRoles.current;
 541        
 542        // Return ALL nodes to scaled positions with role-based easing (with interruption support)
 543        nodeRefs.current.forEach((nodeRef, nodeId) => {
 544          if (nodeRef.current) {
 545            // Determine appropriate easing based on node's role in liminal web
 546            let easing = 'easeOutCubic'; // Default fallback
 547            if (nodeId === centerNodeId || ring1NodeIds.has(nodeId) || ring2NodeIds.has(nodeId) || ring3NodeIds.has(nodeId)) {
 548              // Active nodes moving OUT from liminal positions - accelerate as they leave
 549              easing = 'easeInQuart';
 550            } else if (sphereNodeIds.has(nodeId)) {
 551              // Inactive nodes moving IN from sphere surface - decelerate as they arrive
 552              easing = 'easeOutQuart';
 553            }
 554            
 555            // Use interruption-capable method if the node is currently moving
 556            if (nodeRef.current.isMoving()) {
 557              nodeRef.current.interruptAndReturnToScaledPosition(transitionDuration, worldRotation, easing);
 558            } else {
 559              // Pass world rotation for accurate scaling + role-based easing
 560              nodeRef.current.returnToScaledPosition(transitionDuration, worldRotation, easing);
 561            }
 562          }
 563        });
 564        
 565        // Clear role tracking after initiating return
 566        liminalWebRoles.current = {
 567          centerNodeId: null,
 568          ring1NodeIds: new Set(),
 569          ring2NodeIds: new Set(),
 570          ring3NodeIds: new Set(),
 571          sphereNodeIds: new Set()
 572        };
 573        
 574        // Set transition complete after animation duration
 575        globalThis.setTimeout(() => {
 576          // Ensure all nodes are back in constellation mode
 577          nodeRefs.current.forEach((nodeRef, _nodeId) => {
 578            if (nodeRef.current) {
 579              nodeRef.current.setActiveState(false);
 580            }
 581          });
 582          
 583          isTransitioning.current = false;
 584        }, transitionDuration);
 585        
 586        // Notify callback
 587        onConstellationReturn?.();
 588      },
 589      
 590      getFocusedNodeId: () => focusedNodeId.current,
 591      
 592      isFocusedMode: () => focusedNodeId.current !== null,
 593      
 594      showSearchResults: (searchResults: DreamNode[]) => {
 595        try {
 596          // Build relationship graph from current nodes for search context
 597          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 598          
 599          // Create ordered nodes from search results (already ordered by relevance)
 600          const orderedNodes = searchResults.map(node => ({ 
 601            id: node.id, 
 602            name: node.name, 
 603            type: node.type 
 604          }));
 605          
 606          // Calculate ring layout positions for search results (no center node)
 607          const positions = calculateRingLayoutPositionsForSearch(orderedNodes, relationshipGraph, DEFAULT_RING_CONFIG);
 608          
 609          // Track node roles for proper constellation return
 610          liminalWebRoles.current = {
 611            centerNodeId: null, // No center in search mode
 612            ring1NodeIds: new Set(positions.ring1Nodes.map(n => n.nodeId)),
 613            ring2NodeIds: new Set(positions.ring2Nodes.map(n => n.nodeId)),
 614            ring3NodeIds: new Set(positions.ring3Nodes.map(n => n.nodeId)),
 615            sphereNodeIds: new Set(positions.sphereNodes)
 616          };
 617          
 618          // Apply world-space position correction based on current sphere rotation
 619          if (dreamWorldRef.current) {
 620            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 621            const inverseRotation = sphereRotation.invert();
 622            
 623            // Transform all ring node positions to world space
 624            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 625              const originalPos = new Vector3(...node.position);
 626              originalPos.applyQuaternion(inverseRotation);
 627              node.position = [originalPos.x, originalPos.y, originalPos.z];
 628            });
 629          }
 630          
 631          // Start transition
 632          isTransitioning.current = true;
 633          focusedNodeId.current = null; // No focused node in search mode
 634          
 635          // Update store to search layout mode (already done by search command)
 636          setSpatialLayout('search');
 637          
 638          // Move all ring nodes to their positions (search results in honeycomb)
 639          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: ringNodeId, position }) => {
 640            const nodeRef = nodeRefs.current.get(ringNodeId);
 641            if (nodeRef?.current) {
 642              nodeRef.current.setActiveState(true);
 643              // Ring nodes use ease-out for smooth arrival into view
 644              nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
 645            }
 646          });
 647          
 648          // Move non-search nodes to sphere surface (out of the way)
 649          positions.sphereNodes.forEach(sphereNodeId => {
 650            const nodeRef = nodeRefs.current.get(sphereNodeId);
 651            if (nodeRef?.current) {
 652              // Sphere nodes use ease-in for quick departure from view
 653              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 654            }
 655          });
 656          
 657          // Set transition complete after animation duration
 658          globalThis.setTimeout(() => {
 659            isTransitioning.current = false;
 660          }, transitionDuration);
 661          
 662          console.log(`SpatialOrchestrator: Showing ${searchResults.length} search results in honeycomb layout`);
 663          
 664        } catch (error) {
 665          console.error('SpatialOrchestrator: Error during search results display:', error);
 666          isTransitioning.current = false;
 667        }
 668      },
 669      
 670      moveAllToSphereForSearch: () => {
 671        try {
 672          console.log('SpatialOrchestrator: Moving all nodes to sphere surface for search interface');
 673          
 674          // Mark as transitioning to prevent interference
 675          isTransitioning.current = true;
 676          
 677          // Move all nodes to sphere surface using sphere node easing (like liminal web mode)
 678          nodeRefs.current.forEach((nodeRef) => {
 679            if (nodeRef?.current) {
 680              // Use same easing as liminal web: sphere nodes use ease-in for departure
 681              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 682            }
 683          });
 684          
 685          // Track this as a search focused state (SearchNode acts as focused node)
 686          focusedNodeId.current = 'search-interface';
 687          
 688          // Set transition complete after animation duration
 689          globalThis.setTimeout(() => {
 690            isTransitioning.current = false;
 691          }, transitionDuration);
 692          
 693          console.log('SpatialOrchestrator: All nodes moved to sphere surface for search interface');
 694          
 695        } catch (error) {
 696          console.error('SpatialOrchestrator: Error during search interface setup:', error);
 697          isTransitioning.current = false;
 698        }
 699      },
 700      
 701      showEditModeSearchResults: (centerNodeId: string, searchResults: DreamNode[]) => {
 702        try {
 703          // Store current search results for dynamic reordering
 704          const previousCenterNodeId = currentEditModeCenterNodeId.current;
 705          currentEditModeSearchResults.current = searchResults;
 706          currentEditModeCenterNodeId.current = centerNodeId;
 707  
 708          // Mark as transitioning
 709          isTransitioning.current = true;
 710          
 711          // Build relationship graph from current nodes
 712          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 713          
 714          // Get current pending relationships from store for priority ordering
 715          const store = useInterBrainStore.getState();
 716          const pendingRelationshipIds = store.editMode.pendingRelationships || [];
 717          
 718          // Filter out already-related nodes from search results to avoid duplicates
 719          const filteredSearchResults = searchResults.filter(node => 
 720            !pendingRelationshipIds.includes(node.id)
 721          );
 722          
 723          // Create stable lists for swapping logic
 724          const relatedNodes = pendingRelationshipIds
 725            .map(id => dreamNodes.find(node => node.id === id))
 726            .filter(node => node !== undefined)
 727            .map(node => ({ 
 728              id: node.id, 
 729              name: node.name, 
 730              type: node.type 
 731            }));
 732          
 733          const unrelatedSearchNodes = filteredSearchResults.map(node => ({ 
 734            id: node.id, 
 735            name: node.name, 
 736            type: node.type 
 737          }));
 738          
 739          // Check if this is a new edit mode session (different center node)
 740          const isNewEditModeSession = previousCenterNodeId !== centerNodeId;
 741          
 742          // Update stable lists for swapping logic - handle both initial, new session, and subsequent calls
 743          if (relatedNodesList.current.length === 0 && unrelatedSearchResultsList.current.length === 0) {
 744            // Initial call - set up the lists
 745            relatedNodesList.current = [...relatedNodes];
 746            unrelatedSearchResultsList.current = [...unrelatedSearchNodes];
 747          } else if (isNewEditModeSession) {
 748            // New edit mode session for different node - reset lists to avoid stale data
 749            relatedNodesList.current = [...relatedNodes];
 750            unrelatedSearchResultsList.current = [...unrelatedSearchNodes];
 751          } else {
 752            // Check if we're in copilot mode vs edit mode
 753            const store = useInterBrainStore.getState();
 754            const isInCopilotMode = store.spatialLayout === 'copilot';
 755  
 756            if (isInCopilotMode) {
 757              // COPILOT MODE: Replace entire list with new search results
 758              // Copilot needs complete replacement on each search, not accumulation
 759              unrelatedSearchResultsList.current = [...unrelatedSearchNodes];
 760            } else {
 761              // EDIT MODE: Keep existing stable list management for relationship editing
 762              // Subsequent call (new search results) - merge new unrelated nodes with existing lists
 763              // Keep existing related nodes, but update unrelated list with new search results
 764              const existingUnrelatedIds = new Set(unrelatedSearchResultsList.current.map(n => n.id));
 765              const newUnrelatedNodes = unrelatedSearchNodes.filter(node => !existingUnrelatedIds.has(node.id));
 766  
 767              // Add new unrelated nodes to the list
 768              unrelatedSearchResultsList.current.push(...newUnrelatedNodes);
 769            }
 770          }
 771          
 772          // Check if we're in copilot mode with show/hide functionality
 773          const isInCopilotMode = store.spatialLayout === 'copilot';
 774          const shouldShowResults = !isInCopilotMode || store.copilotMode.showSearchResults;
 775  
 776          let orderedNodes: Array<{ id: string; name: string; type: string }>;
 777  
 778          if (!shouldShowResults) {
 779            // Hide all search results in copilot mode when Option key not held
 780            orderedNodes = [];
 781          } else if (isInCopilotMode && store.copilotMode.showSearchResults) {
 782            // Show frozen snapshot in copilot mode when Option key is held
 783            // Convert full DreamNode objects to simplified format for layout calculation
 784            orderedNodes = store.copilotMode.frozenSearchResults.map(node => ({
 785              id: node.id,
 786              name: node.name,
 787              type: node.type
 788            }));
 789          } else {
 790            // Normal edit mode behavior: show live search results
 791            orderedNodes = [...relatedNodesList.current, ...unrelatedSearchResultsList.current];
 792          }
 793          
 794          
 795          // Calculate ring layout positions for search results (honeycomb pattern)
 796          const positions = calculateRingLayoutPositionsForSearch(orderedNodes, relationshipGraph, DEFAULT_RING_CONFIG);
 797          
 798          // Apply world-space position correction
 799          if (dreamWorldRef.current) {
 800            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 801            const inverseRotation = sphereRotation.invert();
 802            
 803            // Transform all ring node positions to world space
 804            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 805              const originalPos = new Vector3(...node.position);
 806              originalPos.applyQuaternion(inverseRotation);
 807              node.position = [originalPos.x, originalPos.y, originalPos.z];
 808            });
 809          }
 810          
 811          // IMPORTANT: Keep the center node at its current position (already correctly positioned with sphere rotation)
 812          const centerNodeRef = nodeRefs.current.get(centerNodeId);
 813          if (centerNodeRef?.current) {
 814            console.log(`[SpatialOrchestrator] Center node ${centerNodeId} found - keeping at current position (already correctly centered)`);
 815            
 816            // Log current position for verification
 817            const currentPosition = centerNodeRef.current.getCurrentPosition?.();
 818            console.log(`[SpatialOrchestrator] Center node staying at position:`, currentPosition);
 819            
 820            centerNodeRef.current.setActiveState(true);
 821            // DO NOT move center node - it's already correctly positioned with sphere rotation counteracted
 822          } else {
 823            console.error(`[SpatialOrchestrator] Center node ${centerNodeId} not found in nodeRefs! Available nodes:`, Array.from(nodeRefs.current.keys()));
 824          }
 825          
 826          // Move search result nodes to ring positions
 827          const ringNodes = [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes];
 828  
 829          ringNodes.forEach(({ nodeId: searchNodeId, position }) => {
 830            if (searchNodeId === centerNodeId) {
 831              // Skip the center node - it stays where it is
 832              return;
 833            }
 834            
 835            const nodeRef = nodeRefs.current.get(searchNodeId);
 836            if (nodeRef?.current) {
 837              nodeRef.current.setActiveState(true);
 838              nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
 839            } else {
 840              console.warn(`⚠️ [Orchestrator-EditMode] Node ref not found for ring node ${searchNodeId}`);
 841            }
 842          });
 843          
 844          // Move sphere nodes to sphere surface
 845          positions.sphereNodes.forEach(sphereNodeId => {
 846            // Skip the center node if it's somehow in sphere nodes
 847            if (sphereNodeId === centerNodeId) {
 848              return;
 849            }
 850            
 851            const nodeRef = nodeRefs.current.get(sphereNodeId);
 852            if (nodeRef?.current) {
 853              // Move to sphere surface
 854              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
 855            }
 856          });
 857          
 858          // Set transition complete after animation
 859          globalThis.setTimeout(() => {
 860            isTransitioning.current = false;
 861          }, transitionDuration);
 862          
 863          console.log('SpatialOrchestrator: Edit mode search layout complete');
 864          
 865        } catch (error) {
 866          console.error('SpatialOrchestrator: Error during edit mode search display:', error);
 867          isTransitioning.current = false;
 868        }
 869      },
 870      
 871      reorderEditModeSearchResults: () => {
 872        try {
 873          // Only reorder if we're currently in edit mode with stable lists
 874          const centerNodeId = currentEditModeCenterNodeId.current;
 875          
 876          if (!centerNodeId || !relatedNodesList.current.length && !unrelatedSearchResultsList.current.length) {
 877            console.log('SpatialOrchestrator: No stable lists to reorder');
 878            return;
 879          }
 880          
 881          console.log('SpatialOrchestrator: Performing position swapping based on relationship changes');
 882          
 883          // Build relationship graph from current nodes
 884          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 885          
 886          // Get current pending relationships from store
 887          const store = useInterBrainStore.getState();
 888          const currentPendingIds = store.editMode.pendingRelationships || [];
 889          
 890          // Detect what changed: which nodes were added/removed from relationships
 891          const previousRelatedIds = relatedNodesList.current.map(n => n.id);
 892          const addedRelationshipIds = currentPendingIds.filter(id => !previousRelatedIds.includes(id));
 893          const removedRelationshipIds = previousRelatedIds.filter(id => !currentPendingIds.includes(id));
 894          
 895          console.log(`🔄 [Orchestrator-Reorder] Relationship changes - added: ${addedRelationshipIds.length}, removed: ${removedRelationshipIds.length}`);
 896          
 897          // Process additions: Move from unrelated list to end of related list
 898          addedRelationshipIds.forEach(addedId => {
 899            const nodeIndex = unrelatedSearchResultsList.current.findIndex(n => n.id === addedId);
 900            if (nodeIndex !== -1) {
 901              // Remove from unrelated list
 902              const [movedNode] = unrelatedSearchResultsList.current.splice(nodeIndex, 1);
 903              // Add to end of related list
 904              relatedNodesList.current.push(movedNode);
 905            }
 906          });
 907          
 908          // Process removals: Move from related list to beginning of unrelated list
 909          removedRelationshipIds.forEach(removedId => {
 910            const nodeIndex = relatedNodesList.current.findIndex(n => n.id === removedId);
 911            if (nodeIndex !== -1) {
 912              // Remove from related list
 913              const [movedNode] = relatedNodesList.current.splice(nodeIndex, 1);
 914              // Add to beginning of unrelated list
 915              unrelatedSearchResultsList.current.unshift(movedNode);
 916              console.log(`SpatialOrchestrator: Moved node ${movedNode.id} from related to unrelated`);
 917            }
 918          });
 919          
 920          // Rebuild the combined ordered list with updated stable lists
 921          const orderedNodes = [...relatedNodesList.current, ...unrelatedSearchResultsList.current];
 922          
 923          console.log(`✅ [Orchestrator-Reorder] Lists updated - related: ${relatedNodesList.current.length}, unrelated: ${unrelatedSearchResultsList.current.length}`);
 924          
 925          // Calculate new ring layout positions
 926          const positions = calculateRingLayoutPositionsForSearch(orderedNodes, relationshipGraph, DEFAULT_RING_CONFIG);
 927          
 928          // Apply world-space position correction
 929          if (dreamWorldRef.current) {
 930            const sphereRotation = dreamWorldRef.current.quaternion.clone();
 931            const inverseRotation = sphereRotation.invert();
 932            
 933            // Transform all ring node positions to world space
 934            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
 935              const originalPos = new Vector3(...node.position);
 936              originalPos.applyQuaternion(inverseRotation);
 937              node.position = [originalPos.x, originalPos.y, originalPos.z];
 938            });
 939          }
 940          
 941          // Move nodes to their new positions (fast animation for immediate feedback)
 942          const fastTransitionDuration = 300; // 300ms for quick reordering
 943          
 944          // Move search result nodes to their new ring positions
 945          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: searchNodeId, position }) => {
 946            if (searchNodeId === centerNodeId) {
 947              // Skip the center node - it stays where it is
 948              return;
 949            }
 950            
 951            const nodeRef = nodeRefs.current.get(searchNodeId);
 952            if (nodeRef?.current) {
 953              nodeRef.current.setActiveState(true);
 954              nodeRef.current.moveToPosition(position, fastTransitionDuration, 'easeOutQuart');
 955            }
 956          });
 957          
 958          // Move sphere nodes to sphere surface
 959          positions.sphereNodes.forEach(sphereNodeId => {
 960            // Skip the center node if it's somehow in sphere nodes
 961            if (sphereNodeId === centerNodeId) {
 962              return;
 963            }
 964            
 965            const nodeRef = nodeRefs.current.get(sphereNodeId);
 966            if (nodeRef?.current) {
 967              // Move to sphere surface
 968              nodeRef.current.returnToConstellation(fastTransitionDuration, 'easeInQuart');
 969            }
 970          });
 971          
 972          console.log('SpatialOrchestrator: Edit mode reordering complete');
 973          
 974        } catch (error) {
 975          console.error('SpatialOrchestrator: Error during edit mode reordering:', error);
 976        }
 977      },
 978      
 979      animateToLiminalWebFromEdit: (nodeId: string) => {
 980        try {
 981          console.log('SpatialOrchestrator: Special edit mode save transition for node:', nodeId);
 982          
 983          // Build relationship graph from current nodes
 984          const relationshipGraph = buildRelationshipGraph(dreamNodes);
 985          
 986          // Calculate ring layout positions (in local sphere space)
 987          const positions = calculateRingLayoutPositions(nodeId, relationshipGraph, DEFAULT_RING_CONFIG);
 988          
 989          // Track node roles for proper constellation return
 990          liminalWebRoles.current = {
 991            centerNodeId: positions.centerNode?.nodeId || null,
 992            ring1NodeIds: new Set(positions.ring1Nodes.map(n => n.nodeId)),
 993            ring2NodeIds: new Set(positions.ring2Nodes.map(n => n.nodeId)),
 994            ring3NodeIds: new Set(positions.ring3Nodes.map(n => n.nodeId)),
 995            sphereNodeIds: new Set(positions.sphereNodes)
 996          };
 997          
 998          // Apply world-space position correction based on current sphere rotation
 999          if (dreamWorldRef.current) {
1000            const sphereRotation = dreamWorldRef.current.quaternion.clone();
1001            const inverseRotation = sphereRotation.invert();
1002            
1003            // Transform center node position to world space (if exists)
1004            if (positions.centerNode) {
1005              const centerPos = new Vector3(...positions.centerNode.position);
1006              centerPos.applyQuaternion(inverseRotation);
1007              positions.centerNode.position = [centerPos.x, centerPos.y, centerPos.z];
1008            }
1009            
1010            // Transform all ring node positions to world space
1011            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
1012              const originalPos = new Vector3(...node.position);
1013              originalPos.applyQuaternion(inverseRotation);
1014              node.position = [originalPos.x, originalPos.y, originalPos.z];
1015            });
1016          }
1017          
1018          // Start transition
1019          isTransitioning.current = true;
1020          focusedNodeId.current = nodeId;
1021          
1022          // Update store to liminal-web layout mode
1023          setSpatialLayout('liminal-web');
1024          
1025          // IMPORTANT: Move center node TO center position (it might be in honeycomb layout)
1026          // The EditNode is fading out, so we need the actual DreamNode at center
1027          if (positions.centerNode) {
1028            const centerNodeRef = nodeRefs.current.get(positions.centerNode.nodeId);
1029            if (centerNodeRef?.current) {
1030              centerNodeRef.current.setActiveState(true);
1031              // Move the center node to the center position
1032              // It might currently be in a honeycomb position from edit mode search layout
1033              centerNodeRef.current.moveToPosition(positions.centerNode.position, transitionDuration, 'easeOutQuart');
1034              console.log('SpatialOrchestrator: Moving center node to center for liminal web transition');
1035            }
1036          }
1037          
1038          // Move all ring nodes to their positions (these DO animate)
1039          [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(({ nodeId: ringNodeId, position }) => {
1040            const nodeRef = nodeRefs.current.get(ringNodeId);
1041            if (nodeRef?.current) {
1042              nodeRef.current.setActiveState(true);
1043              // Ring nodes use ease-out for smooth arrival into view
1044              nodeRef.current.moveToPosition(position, transitionDuration, 'easeOutQuart');
1045            }
1046          });
1047          
1048          // Move sphere nodes to sphere surface (out of the way)
1049          positions.sphereNodes.forEach(sphereNodeId => {
1050            const nodeRef = nodeRefs.current.get(sphereNodeId);
1051            if (nodeRef?.current) {
1052              // Sphere nodes use ease-in for quick departure from view
1053              nodeRef.current.returnToConstellation(transitionDuration, 'easeInQuart');
1054            }
1055          });
1056          
1057          // Set transition complete after animation duration
1058          globalThis.setTimeout(() => {
1059            isTransitioning.current = false;
1060          }, transitionDuration);
1061          
1062          // Notify callback
1063          onNodeFocused?.(nodeId);
1064          
1065        } catch (error) {
1066          console.error('SpatialOrchestrator: Error during edit mode save transition:', error);
1067          isTransitioning.current = false;
1068        }
1069      },
1070      
1071      registerNodeRef: (nodeId: string, nodeRef: React.RefObject<DreamNode3DRef>) => {
1072        nodeRefs.current.set(nodeId, nodeRef);
1073      },
1074      
1075      unregisterNodeRef: (nodeId: string) => {
1076        nodeRefs.current.delete(nodeId);
1077      },
1078      
1079      clearEditModeData: () => {
1080        // Clear stable edit mode lists
1081        relatedNodesList.current = [];
1082        unrelatedSearchResultsList.current = [];
1083  
1084        // Clear edit mode tracking
1085        currentEditModeSearchResults.current = [];
1086        currentEditModeCenterNodeId.current = null;
1087      },
1088  
1089      applyConstellationLayout: async () => {
1090        console.log('🌌 [SpatialOrchestrator] Applying constellation layout...');
1091  
1092        const store = useInterBrainStore.getState();
1093        const relationshipGraph = store.constellationData.relationshipGraph;
1094  
1095        if (!relationshipGraph) {
1096          console.warn('⚠️ [SpatialOrchestrator] No relationship graph available for constellation layout');
1097          return;
1098        }
1099  
1100        try {
1101          // Compute constellation layout
1102          const layoutResult = computeConstellationLayout(relationshipGraph, dreamNodes);
1103  
1104          if (layoutResult.nodePositions.size === 0) {
1105            console.warn('⚠️ [SpatialOrchestrator] Constellation layout returned no positions');
1106            return;
1107          }
1108  
1109          // Create fallback positions for any missing nodes
1110          const completePositions = createFallbackLayout(dreamNodes, layoutResult.nodePositions);
1111  
1112          // Store the positions in the store for persistence
1113          store.setConstellationPositions(completePositions);
1114  
1115          // Update node positions in single batch transaction (100x faster than sequential updates)
1116          store.batchUpdateNodePositions(completePositions);
1117  
1118          console.log(`✅ [SpatialOrchestrator] Constellation layout applied to ${completePositions.size} nodes via batch update`);
1119          console.log(`📊 [SpatialOrchestrator] Layout stats:`, {
1120            clusters: layoutResult.stats.totalClusters,
1121            nodes: layoutResult.stats.totalNodes,
1122            edges: layoutResult.stats.totalEdges,
1123            computationTime: `${layoutResult.stats.computationTimeMs.toFixed(1)}ms`
1124          });
1125  
1126        } catch (error) {
1127          console.error('❌ [SpatialOrchestrator] Failed to apply constellation layout:', error);
1128        }
1129      },
1130  
1131      hideRelatedNodesInLiminalWeb: () => {
1132        try {
1133          // Get all ring nodes from stored roles
1134          const allRingNodeIds = [
1135            ...liminalWebRoles.current.ring1NodeIds,
1136            ...liminalWebRoles.current.ring2NodeIds,
1137            ...liminalWebRoles.current.ring3NodeIds
1138          ];
1139  
1140          // Match button animation duration (500ms) for parallel motion
1141          const buttonAnimationDuration = 500;
1142  
1143          // Move all ring nodes to constellation surface
1144          allRingNodeIds.forEach(nodeId => {
1145            const nodeRef = nodeRefs.current.get(nodeId);
1146            if (nodeRef?.current) {
1147              // Use easeInQuart for quick departure, but match button timing
1148              nodeRef.current.returnToConstellation(buttonAnimationDuration, 'easeInQuart');
1149            }
1150          });
1151  
1152        } catch (error) {
1153          console.error('[Orchestrator-LiminalWeb] Error hiding related nodes:', error);
1154        }
1155      },
1156  
1157      showRelatedNodesInLiminalWeb: () => {
1158        try {
1159  
1160          // Need to recalculate positions to get them back to their ring spots
1161          if (!liminalWebRoles.current.centerNodeId) {
1162            console.warn('[Orchestrator-LiminalWeb] No center node found in roles');
1163            return;
1164          }
1165  
1166          const relationshipGraph = buildRelationshipGraph(dreamNodes);
1167          const positions = calculateRingLayoutPositions(
1168            liminalWebRoles.current.centerNodeId,
1169            relationshipGraph,
1170            DEFAULT_RING_CONFIG
1171          );
1172  
1173          // Apply world-space position correction
1174          if (dreamWorldRef.current) {
1175            const sphereRotation = dreamWorldRef.current.quaternion.clone();
1176            const inverseRotation = sphereRotation.invert();
1177  
1178            // Transform all ring node positions to world space
1179            [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes].forEach(node => {
1180              const originalPos = new Vector3(...node.position);
1181              originalPos.applyQuaternion(inverseRotation);
1182              node.position = [originalPos.x, originalPos.y, originalPos.z];
1183            });
1184          }
1185  
1186          // Match button animation duration (500ms) for parallel motion
1187          const buttonAnimationDuration = 500;
1188  
1189          // Move ring nodes back to their positions
1190          const allRingNodes = [...positions.ring1Nodes, ...positions.ring2Nodes, ...positions.ring3Nodes];
1191  
1192          allRingNodes.forEach(({ nodeId, position }) => {
1193            const nodeRef = nodeRefs.current.get(nodeId);
1194            if (nodeRef?.current) {
1195              nodeRef.current.setActiveState(true);
1196              // Use easeOutQuart for smooth arrival, but match button timing
1197              nodeRef.current.moveToPosition(position, buttonAnimationDuration, 'easeOutQuart');
1198            }
1199          });
1200  
1201        } catch (error) {
1202          console.error('[Orchestrator-LiminalWeb] Error showing related nodes:', error);
1203        }
1204      }
1205    }), [dreamNodes, onNodeFocused, onConstellationReturn, transitionDuration]);
1206  
1207    // Removed excessive node count logging
1208  
1209    // Call ready callback on mount
1210    useEffect(() => {
1211      onOrchestratorReady?.();
1212    }, [onOrchestratorReady]);
1213    
1214    // This component renders nothing - it's purely for orchestration
1215    return null;
1216  });
1217  
1218  SpatialOrchestrator.displayName = 'SpatialOrchestrator';
1219  
1220  export default SpatialOrchestrator;