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;