DreamNode3D.tsx.backup
1 import React, { useState, useRef, useMemo, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react'; 2 import { Html, Billboard } from '@react-three/drei'; 3 import { useFrame, useThree } from '@react-three/fiber'; 4 import { Vector3, Group, Mesh, Quaternion } from 'three'; 5 import { DreamNode } from '../types/dreamnode'; 6 import { calculateDynamicScaling, DEFAULT_SCALING_CONFIG } from '../dreamspace/DynamicViewScaling'; 7 import { useInterBrainStore } from '../store/interbrain-store'; 8 import { dreamNodeStyles } from './dreamNodeStyles'; 9 import { DreamSongParserService } from '../services/dreamsong-parser-service'; 10 import { CanvasParserService } from '../services/canvas-parser-service'; 11 import { VaultService } from '../services/vault-service'; 12 import { DreamSongData } from '../types/dreamsong'; 13 import { DreamTalkSide } from './DreamTalkSide'; 14 import { DreamSongSide } from './DreamSongSide'; 15 import './dreamNodeAnimations.css'; 16 17 // Universal Movement API interface 18 export interface DreamNode3DRef { 19 moveToPosition: (targetPosition: [number, number, number], duration?: number, easing?: string) => void; 20 returnToConstellation: (duration?: number, easing?: string) => void; 21 returnToScaledPosition: (duration?: number, worldRotation?: Quaternion, easing?: string) => void; // New method for full constellation return with rotation and easing support 22 interruptAndMoveToPosition: (targetPosition: [number, number, number], duration?: number, easing?: string) => void; 23 interruptAndReturnToConstellation: (duration?: number, easing?: string) => void; 24 interruptAndReturnToScaledPosition: (duration?: number, worldRotation?: Quaternion, easing?: string) => void; 25 setActiveState: (active: boolean) => void; 26 getCurrentPosition: () => [number, number, number]; 27 isMoving: () => boolean; 28 } 29 30 interface DreamNode3DProps { 31 dreamNode: DreamNode; 32 onHover?: (node: DreamNode, isHovered: boolean) => void; 33 onClick?: (node: DreamNode) => void; 34 onDoubleClick?: (node: DreamNode) => void; 35 enableDynamicScaling?: boolean; 36 onHitSphereRef?: (nodeId: string, meshRef: React.RefObject<Mesh | null>) => void; 37 // Services for DreamSong parsing 38 vaultService?: VaultService; 39 canvasParserService?: CanvasParserService; 40 } 41 42 /** 43 * 3D DreamNode component with dual-mode positioning system 44 * 45 * Features: 46 * - Dual-mode: constellation (continuous radial offset) vs active (discrete interpolation) 47 * - Universal movement API for liminal web transitions 48 * - Position sovereignty - component owns its position state 49 * - Counter-rotation support for world-space positioning 50 * - Color coding: blue for Dreams, red for Dreamers 51 */ 52 const DreamNode3D = forwardRef<DreamNode3DRef, DreamNode3DProps>(({ 53 dreamNode, 54 onHover, 55 onClick, 56 onDoubleClick, 57 enableDynamicScaling = false, 58 onHitSphereRef, 59 vaultService, 60 canvasParserService 61 }, ref) => { 62 const [isHovered, setIsHovered] = useState(false); 63 const [radialOffset, setRadialOffset] = useState(0); 64 const groupRef = useRef<Group>(null); 65 const hitSphereRef = useRef<Mesh>(null); 66 67 // Flip animation state - default to Math.PI for front side (corrected orientation) 68 const [flipRotation, setFlipRotation] = useState(Math.PI); 69 const [dreamSongData, setDreamSongData] = useState<DreamSongData | null>(null); 70 const [hasDreamSong, setHasDreamSong] = useState(false); 71 const [dreamSongHasContent, setDreamSongHasContent] = useState(false); 72 const [isLoadingDreamSong, setIsLoadingDreamSong] = useState(false); 73 74 // Dual-mode position state 75 const [positionMode, setPositionMode] = useState<'constellation' | 'active'>('constellation'); 76 const [targetPosition, setTargetPosition] = useState<[number, number, number]>(dreamNode.position); 77 const [currentPosition, setCurrentPosition] = useState<[number, number, number]>(dreamNode.position); 78 const [startPosition, setStartPosition] = useState<[number, number, number]>(dreamNode.position); 79 const [isTransitioning, setIsTransitioning] = useState(false); 80 const [transitionStartTime, setTransitionStartTime] = useState(0); 81 const [transitionDuration, setTransitionDuration] = useState(1000); 82 const [transitionType, setTransitionType] = useState<'liminal' | 'constellation' | 'scaled'>('liminal'); 83 const [transitionEasing, setTransitionEasing] = useState<'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'>('easeOutCubic'); 84 85 // Check global drag state to prevent hover interference during sphere rotation 86 const isDragging = useInterBrainStore(state => state.isDragging); 87 88 // Flip state management 89 const flipState = useInterBrainStore(state => state.flipState); 90 const setFlippedNode = useInterBrainStore(state => state.setFlippedNode); 91 const startFlipAnimation = useInterBrainStore(state => state.startFlipAnimation); 92 const completeFlipAnimation = useInterBrainStore(state => state.completeFlipAnimation); 93 const spatialLayout = useInterBrainStore(state => state.spatialLayout); 94 const selectedNode = useInterBrainStore(state => state.selectedNode); 95 96 // Subscribe to edit mode state for relationship glow 97 const isEditModeActive = useInterBrainStore(state => state.editMode.isActive); 98 const isPendingRelationship = useInterBrainStore(state => 99 state.editMode.pendingRelationships.includes(dreamNode.id) 100 ); 101 102 // Get current flip state for this node 103 const nodeFlipState = flipState.flipStates.get(dreamNode.id); 104 const isFlipped = nodeFlipState?.isFlipped || false; 105 const isFlipping = nodeFlipState?.isFlipping || false; 106 107 // Ensure initial state shows front side (flipRotation = Math.PI = front) 108 useEffect(() => { 109 if (!nodeFlipState) { 110 setFlipRotation(Math.PI); // Front side by default 111 } 112 }, [nodeFlipState]); 113 114 // Determine if flip button should be visible (only in liminal web mode for selected node) 115 const shouldShowFlipButton = useMemo(() => { 116 const result = spatialLayout === 'liminal-web' && 117 selectedNode?.id === dreamNode.id && 118 isHovered && 119 hasDreamSong && // Show button if DreamSong file exists (even if empty) 120 !isDragging; 121 122 // Debug logging for flip button visibility (only when conditions are close) 123 if (spatialLayout === 'liminal-web' && selectedNode?.id === dreamNode.id) { 124 console.log(`🔄 [DreamNode3D] Flip button logic for "${dreamNode.name}":`); 125 console.log(` - spatialLayout === 'liminal-web': ${spatialLayout === 'liminal-web'}`); 126 console.log(` - selectedNode?.id === dreamNode.id: ${selectedNode?.id === dreamNode.id}`); 127 console.log(` - isHovered: ${isHovered}`); 128 console.log(` - hasDreamSong: ${hasDreamSong}`); 129 console.log(` - dreamSongHasContent: ${dreamSongHasContent}`); 130 console.log(` - isDragging: ${isDragging}`); 131 console.log(` - shouldShowFlipButton: ${result}`); 132 } 133 134 return result; 135 }, [spatialLayout, selectedNode, dreamNode.id, isHovered, hasDreamSong, isDragging]); 136 137 // Register hit sphere reference with parent component 138 useEffect(() => { 139 if (onHitSphereRef && hitSphereRef) { 140 onHitSphereRef(dreamNode.id, hitSphereRef); 141 } 142 }, [dreamNode.id, onHitSphereRef]); 143 144 // Check for DreamSong canvas file on component mount, when selected, or when services become available 145 useEffect(() => { 146 const checkDreamSong = async () => { 147 console.log(`🎭 [DreamNode3D] Checking DreamSong for node: "${dreamNode.name}" (${dreamNode.id})`); 148 console.log(`🎭 [DreamNode3D] Services available - vault: ${!!vaultService}, canvas: ${!!canvasParserService}`); 149 console.log(`🎭 [DreamNode3D] Node selected: ${selectedNode?.id === dreamNode.id}, spatialLayout: ${spatialLayout}`); 150 151 if (!vaultService || !canvasParserService) { 152 console.log(`⚠️ [DreamNode3D] Cannot check DreamSong: missing services - will retry when services become available`); 153 // Reset states when services unavailable 154 setHasDreamSong(false); 155 setDreamSongHasContent(false); 156 return; 157 } 158 159 const canvasPath = `${dreamNode.repoPath}/DreamSong.canvas`; 160 console.log(`🎭 [DreamNode3D] Checking canvas at: "${canvasPath}"`); 161 162 try { 163 const exists = await vaultService.fileExists(canvasPath); 164 console.log(`${exists ? '✅' : '❌'} [DreamNode3D] DreamSong canvas ${exists ? 'EXISTS' : 'NOT FOUND'} for "${dreamNode.name}"`); 165 setHasDreamSong(exists); 166 167 // If DreamSong exists, try to parse it to check if it has content 168 if (exists) { 169 console.log(`🎭 [DreamNode3D] DreamSong exists, creating parser to check content...`); 170 const dreamSongParser = new DreamSongParserService(vaultService, canvasParserService); 171 const parseResult = await dreamSongParser.parseDreamSong(canvasPath, dreamNode.repoPath); 172 173 if (parseResult.success && parseResult.data) { 174 console.log(`✅ [DreamNode3D] DreamSong parsed successfully:`); 175 console.log(` - Blocks: ${parseResult.data.blocks.length}`); 176 console.log(` - Has content: ${parseResult.data.hasContent}`); 177 console.log(` - Total blocks: ${parseResult.data.totalBlocks}`); 178 179 // Set content availability based on actual parsed content 180 setDreamSongHasContent(parseResult.data.hasContent); 181 } else { 182 console.log(`❌ [DreamNode3D] DreamSong parse failed:`, parseResult.error?.message); 183 setDreamSongHasContent(false); 184 } 185 } else { 186 setDreamSongHasContent(false); 187 } 188 } catch (error) { 189 console.error(`❌ [DreamNode3D] Error checking DreamSong for ${dreamNode.id}:`, error); 190 setHasDreamSong(false); 191 setDreamSongHasContent(false); 192 } 193 }; 194 195 checkDreamSong(); 196 }, [dreamNode.id, dreamNode.repoPath, vaultService, canvasParserService, selectedNode?.id, spatialLayout]); 197 198 // Load DreamSong data when flipped to back side 199 useEffect(() => { 200 const loadDreamSongData = async () => { 201 if (!isFlipped || !hasDreamSong || !vaultService || !canvasParserService || dreamSongData) { 202 return; 203 } 204 205 setIsLoadingDreamSong(true); 206 207 try { 208 const dreamSongParser = new DreamSongParserService(vaultService, canvasParserService); 209 const canvasPath = `${dreamNode.repoPath}/DreamSong.canvas`; 210 const result = await dreamSongParser.parseDreamSong(canvasPath, dreamNode.repoPath); 211 212 if (result.success && result.data) { 213 setDreamSongData(result.data); 214 } else { 215 console.error(`Failed to parse DreamSong: ${result.error?.message}`); 216 } 217 } catch (error) { 218 console.error(`Error loading DreamSong for ${dreamNode.id}:`, error); 219 } finally { 220 setIsLoadingDreamSong(false); 221 } 222 }; 223 224 loadDreamSongData(); 225 }, [isFlipped, hasDreamSong, dreamNode.id, dreamNode.repoPath, vaultService, canvasParserService, dreamSongData]); 226 227 // Reset flip state when node is no longer selected in liminal web mode 228 useEffect(() => { 229 if (spatialLayout !== 'liminal-web' || selectedNode?.id !== dreamNode.id) { 230 if (flipState.flippedNodeId === dreamNode.id) { 231 setFlippedNode(null); 232 setFlipRotation(0); 233 setDreamSongData(null); 234 } 235 } 236 }, [spatialLayout, selectedNode, dreamNode.id, flipState.flippedNodeId, setFlippedNode]); 237 238 // Handle mouse events (suppress during sphere rotation to prevent interference) 239 const handleMouseEnter = () => { 240 if (isDragging) { 241 return; // Suppress hover during drag operations 242 } 243 setIsHovered(true); 244 onHover?.(dreamNode, true); 245 }; 246 247 const handleMouseLeave = () => { 248 if (isDragging) { 249 return; // Suppress hover during drag operations 250 } 251 setIsHovered(false); 252 onHover?.(dreamNode, false); 253 }; 254 255 const handleClick = (e: React.MouseEvent) => { 256 if (isDragging) return; // Suppress click during drag operations 257 e.stopPropagation(); 258 onClick?.(dreamNode); 259 }; 260 261 // Handle flip button click 262 const handleFlipClick = useCallback((e: React.MouseEvent) => { 263 e.stopPropagation(); 264 if (isDragging || isFlipping) return; 265 266 const direction = isFlipped ? 'back-to-front' : 'front-to-back'; 267 startFlipAnimation(dreamNode.id, direction); 268 }, [isDragging, isFlipping, isFlipped, startFlipAnimation, dreamNode.id]); 269 270 const handleDoubleClick = (e: React.MouseEvent) => { 271 if (isDragging) return; // Suppress double-click during drag operations 272 e.stopPropagation(); 273 onDoubleClick?.(dreamNode); 274 }; 275 276 // Universal Movement API 277 useImperativeHandle(ref, () => ({ 278 moveToPosition: (newTargetPosition, duration = 1000, easing = 'easeOutCubic') => { 279 // Switch to active mode and start transition 280 // CRITICAL FIX: Calculate actual current visual position 281 let actualCurrentPosition: [number, number, number]; 282 283 if (positionMode === 'constellation') { 284 // Calculate constellation position with radial offset 285 const anchorPos = dreamNode.position; 286 const direction = [-anchorPos[0], -anchorPos[1], -anchorPos[2]]; 287 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 288 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 289 290 actualCurrentPosition = [ 291 anchorPos[0] - normalizedDir[0] * radialOffset, 292 anchorPos[1] - normalizedDir[1] * radialOffset, 293 anchorPos[2] - normalizedDir[2] * radialOffset 294 ]; 295 } else { 296 actualCurrentPosition = [...currentPosition]; 297 } 298 299 setStartPosition(actualCurrentPosition); 300 setCurrentPosition(actualCurrentPosition); // Initialize currentPosition for active mode 301 setTargetPosition(newTargetPosition); 302 setTransitionDuration(duration); 303 setTransitionStartTime(globalThis.performance.now()); 304 setPositionMode('active'); 305 setIsTransitioning(true); 306 setTransitionType('liminal'); // This is a liminal web transition 307 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 308 309 }, 310 returnToConstellation: (duration = 1000, easing = 'easeInQuart') => { 311 // Enhanced method: returns to proper constellation position (with scaling if enabled) 312 let actualCurrentPosition: [number, number, number]; 313 314 if (positionMode === 'constellation') { 315 // Calculate current visual position with radial offset 316 const anchorPos = dreamNode.position; 317 const direction = [-anchorPos[0], -anchorPos[1], -anchorPos[2]]; 318 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 319 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 320 321 actualCurrentPosition = [ 322 anchorPos[0] - normalizedDir[0] * radialOffset, 323 anchorPos[1] - normalizedDir[1] * radialOffset, 324 anchorPos[2] - normalizedDir[2] * radialOffset 325 ]; 326 } else { 327 actualCurrentPosition = [...currentPosition]; 328 } 329 330 // Target position should be sphere surface - constellation mode will handle scaling 331 const constellationPosition = dreamNode.position; 332 setStartPosition(actualCurrentPosition); 333 setCurrentPosition(actualCurrentPosition); 334 setTargetPosition(constellationPosition); 335 setTransitionDuration(duration); 336 setTransitionStartTime(globalThis.performance.now()); 337 setPositionMode('active'); // Use active mode for the transition 338 setIsTransitioning(true); 339 setTransitionType('constellation'); // This is a constellation return transition 340 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 341 }, 342 returnToScaledPosition: (duration = 1000, worldRotation, easing = 'easeOutCubic') => { 343 // ROBUST METHOD: Returns ANY node to its proper scaled constellation position 344 // Handles both active nodes (from liminal positions) and inactive nodes (from sphere surface) 345 346 // Calculate target dynamically scaled position for this node 347 const anchorPosition = dreamNode.position; 348 349 // Transform anchor position to world space using provided rotation (similar to useFrame logic) 350 const worldAnchorPosition = new Vector3(anchorPosition[0], anchorPosition[1], anchorPosition[2]); 351 if (worldRotation) { 352 // Apply the sphere's world rotation to get the actual world position 353 worldAnchorPosition.applyQuaternion(worldRotation); 354 } 355 356 // Calculate what the radial offset should be using dynamic scaling 357 const { radialOffset: targetRadialOffset } = calculateDynamicScaling( 358 worldAnchorPosition, 359 DEFAULT_SCALING_CONFIG 360 ); 361 362 // Calculate target scaled position 363 const direction = [-anchorPosition[0], -anchorPosition[1], -anchorPosition[2]]; 364 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 365 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 366 367 const targetScaledPosition: [number, number, number] = [ 368 anchorPosition[0] - normalizedDir[0] * targetRadialOffset, 369 anchorPosition[1] - normalizedDir[1] * targetRadialOffset, 370 anchorPosition[2] - normalizedDir[2] * targetRadialOffset 371 ]; 372 373 // Get actual current position 374 let actualCurrentPosition: [number, number, number]; 375 if (positionMode === 'constellation') { 376 // Calculate current visual position with radial offset 377 actualCurrentPosition = [ 378 anchorPosition[0] - normalizedDir[0] * radialOffset, 379 anchorPosition[1] - normalizedDir[1] * radialOffset, 380 anchorPosition[2] - normalizedDir[2] * radialOffset 381 ]; 382 } else { 383 actualCurrentPosition = [...currentPosition]; 384 } 385 386 // Animate to target scaled position 387 setStartPosition(actualCurrentPosition); 388 setCurrentPosition(actualCurrentPosition); 389 setTargetPosition(targetScaledPosition); 390 setTransitionDuration(duration); 391 setTransitionStartTime(globalThis.performance.now()); 392 setPositionMode('active'); // Use active mode for the transition 393 setIsTransitioning(true); 394 setTransitionType('scaled'); // This is a scaled position return transition 395 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 396 397 // Determine node's current state for logging (removed unused variables for cleaner build) 398 399 // Set the target radial offset for when we switch back to constellation mode 400 globalThis.setTimeout(() => { 401 setRadialOffset(targetRadialOffset); 402 }, duration - 100); // Set slightly before transition completes 403 }, 404 interruptAndMoveToPosition: (newTargetPosition, duration = 1000, easing = 'easeOutCubic') => { 405 // Enhanced method: Can interrupt existing animation using current position as new start point 406 407 // CRITICAL: Calculate actual current visual position (including mid-flight positions) 408 let actualCurrentPosition: [number, number, number]; 409 410 if (positionMode === 'constellation') { 411 // Calculate constellation position with radial offset 412 const anchorPos = dreamNode.position; 413 const direction = [-anchorPos[0], -anchorPos[1], -anchorPos[2]]; 414 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 415 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 416 417 actualCurrentPosition = [ 418 anchorPos[0] - normalizedDir[0] * radialOffset, 419 anchorPos[1] - normalizedDir[1] * radialOffset, 420 anchorPos[2] - normalizedDir[2] * radialOffset 421 ]; 422 } else { 423 // Node is in active mode - use the current interpolated position 424 actualCurrentPosition = [...currentPosition]; 425 } 426 427 // Start new animation from current position (interrupts existing animation smoothly) 428 setStartPosition(actualCurrentPosition); 429 setCurrentPosition(actualCurrentPosition); 430 setTargetPosition(newTargetPosition); 431 setTransitionDuration(duration); 432 setTransitionStartTime(globalThis.performance.now()); 433 setPositionMode('active'); 434 setIsTransitioning(true); 435 setTransitionType('liminal'); 436 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 437 }, 438 interruptAndReturnToConstellation: (duration = 1000, easing = 'easeInQuart') => { 439 // Enhanced method: Can interrupt existing animation to return to constellation 440 441 let actualCurrentPosition: [number, number, number]; 442 443 if (positionMode === 'constellation') { 444 // Calculate current visual position with radial offset 445 const anchorPos = dreamNode.position; 446 const direction = [-anchorPos[0], -anchorPos[1], -anchorPos[2]]; 447 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 448 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 449 450 actualCurrentPosition = [ 451 anchorPos[0] - normalizedDir[0] * radialOffset, 452 anchorPos[1] - normalizedDir[1] * radialOffset, 453 anchorPos[2] - normalizedDir[2] * radialOffset 454 ]; 455 } else { 456 // Node is in active mode - use the current interpolated position 457 actualCurrentPosition = [...currentPosition]; 458 } 459 460 // Target position should be sphere surface - constellation mode will handle scaling 461 const constellationPosition = dreamNode.position; 462 setStartPosition(actualCurrentPosition); 463 setCurrentPosition(actualCurrentPosition); 464 setTargetPosition(constellationPosition); 465 setTransitionDuration(duration); 466 setTransitionStartTime(globalThis.performance.now()); 467 setPositionMode('active'); // Use active mode for the transition 468 setIsTransitioning(true); 469 setTransitionType('constellation'); 470 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 471 }, 472 interruptAndReturnToScaledPosition: (duration = 1000, worldRotation, easing = 'easeOutCubic') => { 473 // Enhanced method: Can interrupt existing animation to return to scaled constellation position 474 475 // Calculate target dynamically scaled position for this node 476 const anchorPosition = dreamNode.position; 477 478 // Transform anchor position to world space using provided rotation (similar to useFrame logic) 479 const worldAnchorPosition = new Vector3(anchorPosition[0], anchorPosition[1], anchorPosition[2]); 480 if (worldRotation) { 481 // Apply the sphere's world rotation to get the actual world position 482 worldAnchorPosition.applyQuaternion(worldRotation); 483 } 484 485 // Calculate what the radial offset should be using dynamic scaling 486 const { radialOffset: targetRadialOffset } = calculateDynamicScaling( 487 worldAnchorPosition, 488 DEFAULT_SCALING_CONFIG 489 ); 490 491 // Calculate target scaled position 492 const direction = [-anchorPosition[0], -anchorPosition[1], -anchorPosition[2]]; 493 const dirLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 494 const normalizedDir = [direction[0]/dirLength, direction[1]/dirLength, direction[2]/dirLength]; 495 496 const targetScaledPosition: [number, number, number] = [ 497 anchorPosition[0] - normalizedDir[0] * targetRadialOffset, 498 anchorPosition[1] - normalizedDir[1] * targetRadialOffset, 499 anchorPosition[2] - normalizedDir[2] * targetRadialOffset 500 ]; 501 502 // Get actual current position (including mid-flight positions) 503 let actualCurrentPosition: [number, number, number]; 504 if (positionMode === 'constellation') { 505 // Calculate current visual position with radial offset 506 actualCurrentPosition = [ 507 anchorPosition[0] - normalizedDir[0] * radialOffset, 508 anchorPosition[1] - normalizedDir[1] * radialOffset, 509 anchorPosition[2] - normalizedDir[2] * radialOffset 510 ]; 511 } else { 512 // Node is in active mode - use the current interpolated position 513 actualCurrentPosition = [...currentPosition]; 514 } 515 516 // Animate to target scaled position (interrupts existing animation smoothly) 517 setStartPosition(actualCurrentPosition); 518 setCurrentPosition(actualCurrentPosition); 519 setTargetPosition(targetScaledPosition); 520 setTransitionDuration(duration); 521 setTransitionStartTime(globalThis.performance.now()); 522 setPositionMode('active'); // Use active mode for the transition 523 setIsTransitioning(true); 524 setTransitionType('scaled'); 525 setTransitionEasing(easing as 'easeOutCubic' | 'easeInQuart' | 'easeOutQuart'); 526 527 // Set the target radial offset for when we switch back to constellation mode 528 globalThis.setTimeout(() => { 529 setRadialOffset(targetRadialOffset); 530 }, duration - 100); // Set slightly before transition completes 531 }, 532 setActiveState: (active: boolean) => { 533 if (active) { 534 setPositionMode('active'); 535 setCurrentPosition([...currentPosition]); // Preserve current position 536 } else { 537 setPositionMode('constellation'); 538 // Reset to original position state 539 setCurrentPosition(dreamNode.position); 540 } 541 }, 542 getCurrentPosition: () => currentPosition, 543 isMoving: () => isTransitioning 544 }), [currentPosition, isTransitioning, dreamNode.position, positionMode, radialOffset, transitionEasing]); 545 546 // Dual-mode position calculation with counter-rotation 547 useFrame((_state, _delta) => { 548 if (positionMode === 'constellation' && enableDynamicScaling && groupRef.current) { 549 // CONSTELLATION MODE: Continuous radial offset calculation (existing behavior) 550 // Get world position of the anchor (includes rotation from parent group) 551 const worldPosition = new Vector3(); 552 groupRef.current.getWorldPosition(worldPosition); 553 554 // But we need to get the world position of the ANCHOR, not the current final position 555 // So we need to calculate where the anchor would be in world space 556 const anchorGroup = groupRef.current.parent; // Get the rotatable group 557 const anchorVector = new Vector3(anchorPosition[0], anchorPosition[1], anchorPosition[2]); 558 if (anchorGroup) { 559 anchorGroup.localToWorld(anchorVector); 560 } 561 562 // Calculate dynamic scaling based on anchor's world position 563 const { radialOffset: newRadialOffset } = calculateDynamicScaling( 564 anchorVector, 565 DEFAULT_SCALING_CONFIG 566 ); 567 568 // Update state if changed 569 if (radialOffset !== newRadialOffset) { 570 setRadialOffset(newRadialOffset); 571 } 572 } else if (positionMode === 'active' && isTransitioning) { 573 // ACTIVE MODE: Discrete position interpolation 574 const elapsed = globalThis.performance.now() - transitionStartTime; 575 const progress = Math.min(elapsed / transitionDuration, 1); 576 577 // Apply selected easing function 578 let easedProgress: number; 579 switch (transitionEasing) { 580 case 'easeInQuart': 581 // Strong ease-in for nodes flying OUT to sphere 582 easedProgress = Math.pow(progress, 4); 583 break; 584 case 'easeOutQuart': 585 // Strong ease-out for nodes flying IN from sphere 586 easedProgress = 1 - Math.pow(1 - progress, 4); 587 break; 588 case 'easeOutCubic': 589 default: 590 // Default easing for other transitions 591 easedProgress = 1 - Math.pow(1 - progress, 3); 592 break; 593 } 594 595 // Linear interpolation from start to target position 596 const newPosition: [number, number, number] = [ 597 startPosition[0] + (targetPosition[0] - startPosition[0]) * easedProgress, 598 startPosition[1] + (targetPosition[1] - startPosition[1]) * easedProgress, 599 startPosition[2] + (targetPosition[2] - startPosition[2]) * easedProgress 600 ]; 601 602 setCurrentPosition(newPosition); 603 604 // Check if transition is complete 605 if (progress >= 1) { 606 setIsTransitioning(false); 607 setCurrentPosition(targetPosition); // Ensure exact target position 608 609 // Handle transition completion based on type 610 if (transitionType === 'liminal') { 611 // Liminal web transitions: STAY in active mode at target position 612 // Don't change positionMode - stay active! 613 } else if (transitionType === 'constellation') { 614 // Constellation return: Switch back to constellation mode 615 setPositionMode('constellation'); 616 setRadialOffset(0); // Reset radial offset for clean sphere positioning 617 } else if (transitionType === 'scaled') { 618 // Scaled position return: Switch back to constellation mode 619 setPositionMode('constellation'); 620 // radialOffset was already set during the animation 621 } 622 } 623 } 624 }); 625 626 // Get consistent colors from shared styles 627 const nodeColors = getNodeColors(dreamNode.type); 628 629 // Get git visual state and styling 630 const gitState = getGitVisualState(dreamNode.gitStatus); 631 const gitStyle = getGitStateStyle(gitState); 632 633 // Base size for 3D scaling - will scale with distance due to distanceFactor 634 const nodeSize = dreamNodeStyles.dimensions.nodeSizeThreeD; 635 const borderWidth = dreamNodeStyles.dimensions.borderWidth; // Use shared border width 636 637 // Calculate visual component position with radial offset 638 // Anchor point stays at dreamNode.position, visual component moves radially toward camera 639 const anchorPosition = dreamNode.position; 640 641 // Calculate normalized direction toward origin (radially inward) 642 const normalizedDirection = useMemo(() => { 643 const direction = [ 644 -anchorPosition[0], // Direction toward origin (radially inward) 645 -anchorPosition[1], 646 -anchorPosition[2] 647 ]; 648 const directionLength = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2); 649 return [ 650 direction[0] / directionLength, 651 direction[1] / directionLength, 652 direction[2] / directionLength 653 ]; 654 }, [anchorPosition]); 655 656 // Calculate final position based on mode 657 // CRITICAL: Don't memoize this - we need it to update every render when currentPosition changes 658 const finalPosition: [number, number, number] = positionMode === 'constellation' 659 ? [ 660 anchorPosition[0] - normalizedDirection[0] * radialOffset, 661 anchorPosition[1] - normalizedDirection[1] * radialOffset, 662 anchorPosition[2] - normalizedDirection[2] * radialOffset 663 ] 664 : currentPosition; 665 666 // Removed excessive dynamic scaling logging for performance 667 668 // Access camera for billboard rotation 669 const { camera } = useThree(); 670 671 // Handle flip animation updates 672 useFrame(() => { 673 if (!isFlipping) return; 674 675 const targetRotation = nodeFlipState?.flipDirection === 'front-to-back' ? 0 : Math.PI; 676 const animationDuration = 600; // ms 677 const elapsed = globalThis.performance.now() - (nodeFlipState?.animationStartTime || 0); 678 const progress = Math.min(elapsed / animationDuration, 1); 679 680 // Ease-in-out timing function 681 const easedProgress = progress < 0.5 682 ? 2 * progress * progress 683 : 1 - Math.pow(-2 * progress + 2, 2) / 2; 684 685 const newRotation = nodeFlipState?.flipDirection === 'front-to-back' 686 ? Math.PI - (easedProgress * Math.PI) // From Math.PI (front) to 0 (back) 687 : easedProgress * Math.PI; // From 0 (back) to Math.PI (front) 688 689 setFlipRotation(newRotation); 690 691 // Debug logging for flip animation 692 console.log(`🔄 [Flip Animation] Node: ${dreamNode.name}, Progress: ${progress.toFixed(2)}, Rotation: ${(newRotation * 180 / Math.PI).toFixed(1)}°`); 693 694 if (progress >= 1) { 695 console.log(`✅ [Flip Animation] Completed for ${dreamNode.name} at ${(targetRotation * 180 / Math.PI).toFixed(1)}°`); 696 completeFlipAnimation(dreamNode.id); 697 setFlipRotation(targetRotation); 698 } 699 }); 700 701 // Using sprite mode for automatic billboarding - no manual rotation needed 702 703 // Debug logging removed for cleaner console 704 705 // Wrap in group at final position for world position calculations 706 // Apply hover scaling to the entire group so both visual and hit detection scale together 707 return ( 708 <group 709 ref={groupRef} 710 position={finalPosition} 711 > 712 {/* Html wrapper for 3D flip animation - manual billboard via CSS */} 713 <Html 714 center 715 transform 716 distanceFactor={10} 717 style={{ 718 pointerEvents: isDragging ? 'none' : 'auto', 719 userSelect: 'none' 720 }} 721 > 722 {/* Rotatable container for flip animation with 3D transforms */} 723 <div 724 style={{ 725 transform: `rotateY(${flipRotation}rad) scaleX(-1)`, 726 transformStyle: 'preserve-3d', 727 transition: 'transform 0.6s ease-in-out', 728 width: `${nodeSize}px`, 729 height: `${nodeSize}px`, 730 position: 'relative' 731 }} 732 > 733 {/* Front side (DreamTalk) */} 734 <div 735 style={{ 736 position: 'absolute', 737 width: '100%', 738 height: '100%', 739 borderRadius: dreamNodeStyles.dimensions.borderRadius, 740 border: `${borderWidth}px ${gitStyle.borderStyle} ${nodeColors.border}`, 741 background: nodeColors.fill, 742 overflow: 'hidden', 743 cursor: 'pointer !important', 744 transition: `${dreamNodeStyles.transitions.default}, ${dreamNodeStyles.transitions.gitState}`, 745 transform: isHovered ? `scale(${dreamNodeStyles.states.hover.scale}) translateZ(1px)` : 'scale(1) translateZ(1px)', 746 animation: gitStyle.animation, 747 backfaceVisibility: 'hidden', 748 boxShadow: (() => { 749 // Priority 1: Git status glow (always highest priority) 750 if (gitStyle.glowIntensity > 0) { 751 return getGitGlow(gitState, gitStyle.glowIntensity); 752 } 753 754 // Priority 2: Edit mode relationship glow 755 if (isEditModeActive && isPendingRelationship) { 756 return getEditModeGlow(25); // Strong gold glow for relationships 757 } 758 759 // Priority 3: Hover glow (fallback) 760 return isHovered ? getNodeGlow(dreamNode.type, dreamNodeStyles.states.hover.glowIntensity) : 'none'; 761 })() 762 }} 763 onMouseEnter={handleMouseEnter} 764 onMouseLeave={handleMouseLeave} 765 onClick={handleClick} 766 onDoubleClick={handleDoubleClick} 767 > 768 {/* DreamTalk Media Container */} 769 {dreamNode.dreamTalkMedia[0] && ( 770 <div style={getMediaContainerStyle()}> 771 <MediaRenderer media={dreamNode.dreamTalkMedia[0]} /> 772 {/* Fade-to-black overlay */} 773 <div style={getMediaOverlayStyle()} /> 774 775 {/* Hover overlay with name */} 776 {isHovered && ( 777 <div 778 style={{ 779 position: 'absolute', 780 top: 0, 781 left: 0, 782 width: '100%', 783 height: '100%', 784 borderRadius: '50%', 785 background: 'rgba(0, 0, 0, 0.7)', 786 display: 'flex', 787 alignItems: 'center', 788 justifyContent: 'center', 789 opacity: isHovered ? 1 : 0, 790 transition: 'opacity 0.2s ease-in-out', 791 pointerEvents: 'none', 792 zIndex: 10 793 }} 794 > 795 <div 796 style={{ 797 color: dreamNodeStyles.colors.text.primary, 798 fontFamily: dreamNodeStyles.typography.fontFamily, 799 fontSize: `${Math.max(12, nodeSize * 0.08)}px`, 800 textAlign: 'center', 801 padding: '8px' 802 }} 803 > 804 {dreamNode.name} 805 </div> 806 </div> 807 )} 808 </div> 809 )} 810 811 {/* Empty state text - when no media */} 812 {!dreamNode.dreamTalkMedia[0] && ( 813 <div 814 style={{ 815 width: '100%', 816 height: '100%', 817 display: 'flex', 818 alignItems: 'center', 819 justifyContent: 'center', 820 color: dreamNodeStyles.colors.text.primary, 821 fontFamily: dreamNodeStyles.typography.fontFamily, 822 fontSize: `${Math.max(12, nodeSize * 0.08)}px`, 823 textAlign: 'center', 824 padding: '8px' 825 }} 826 > 827 {dreamNode.name} 828 </div> 829 )} 830 831 832 {/* Node label */} 833 <div 834 style={{ 835 position: 'absolute', 836 bottom: `-${nodeSize * 0.25}px`, 837 left: '50%', 838 transform: 'translateX(-50%)', 839 color: dreamNodeStyles.colors.text.primary, 840 fontFamily: dreamNodeStyles.typography.fontFamily, 841 fontSize: `${Math.max(12, nodeSize * 0.1)}px`, 842 textAlign: 'center', 843 background: 'rgba(0, 0, 0, 0.8)', 844 padding: '4px 8px', 845 borderRadius: '4px', 846 whiteSpace: 'nowrap', 847 pointerEvents: 'none' 848 }} 849 > 850 {dreamNode.name} 851 </div> 852 853 {/* Flip button (bottom-center, only when hovering and has DreamSong) */} 854 {shouldShowFlipButton && ( 855 <div 856 style={{ 857 position: 'absolute', 858 bottom: '8px', 859 left: '50%', 860 transform: 'translateX(-50%)', 861 width: '84px', 862 height: '84px', 863 borderRadius: '12px', 864 background: 'rgba(0, 0, 0, 0.1)', 865 border: 'none', 866 backdropFilter: 'blur(4px)', 867 display: 'flex', 868 alignItems: 'center', 869 justifyContent: 'center', 870 cursor: 'pointer !important', 871 fontSize: '12px', 872 color: '#fff', 873 boxShadow: '0 2px 12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1)', 874 filter: 'drop-shadow(0 0 4px rgba(255, 255, 255, 0.3))', 875 transition: 'all 0.2s ease', 876 zIndex: 20, 877 pointerEvents: 'auto' 878 }} 879 onClick={(e) => { 880 e.stopPropagation(); // Prevent event from bubbling to node 881 handleFlipClick(e); 882 }} 883 onMouseEnter={(e) => { 884 e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)'; 885 e.currentTarget.style.transform = 'translateX(-50%) scale(1.1)'; 886 }} 887 onMouseLeave={(e) => { 888 e.currentTarget.style.background = 'rgba(0, 0, 0, 0.1)'; 889 e.currentTarget.style.transform = 'translateX(-50%) scale(1)'; 890 }} 891 ref={(el) => { 892 if (el) { 893 // Clear existing content and add Obsidian icon 894 el.innerHTML = ''; 895 setIcon(el, 'lucide-flip-horizontal'); 896 // Scale icon for larger button 897 const iconElement = el.querySelector('.lucide-flip-horizontal'); 898 if (iconElement) { 899 (iconElement as HTMLElement).style.width = '36px'; 900 (iconElement as HTMLElement).style.height = '36px'; 901 } 902 } 903 }} 904 > 905 </div> 906 )} 907 </div> 908 909 {/* Back side (DreamSong) - rotated 180 degrees with Z offset */} 910 <div 911 style={{ 912 position: 'absolute', 913 width: '100%', 914 height: '100%', 915 borderRadius: dreamNodeStyles.dimensions.borderRadius, 916 border: `${borderWidth}px ${gitStyle.borderStyle} ${nodeColors.border}`, 917 background: nodeColors.fill, 918 overflow: 'hidden', 919 cursor: 'pointer !important', 920 transition: `${dreamNodeStyles.transitions.default}, ${dreamNodeStyles.transitions.gitState}`, 921 transform: `rotateY(180deg) translateZ(-2px) ${isHovered ? `scale(${dreamNodeStyles.states.hover.scale})` : 'scale(1)'}`, 922 animation: gitStyle.animation, 923 backfaceVisibility: 'hidden', 924 boxShadow: (() => { 925 // Priority 1: Git status glow (always highest priority) 926 if (gitStyle.glowIntensity > 0) { 927 return getGitGlow(gitState, gitStyle.glowIntensity); 928 } 929 930 // Priority 2: Edit mode relationship glow 931 if (isEditModeActive && isPendingRelationship) { 932 return getEditModeGlow(25); // Strong gold glow for relationships 933 } 934 935 // Priority 3: Hover glow (fallback) 936 return isHovered ? getNodeGlow(dreamNode.type, dreamNodeStyles.states.hover.glowIntensity) : 'none'; 937 })() 938 }} 939 onMouseEnter={handleMouseEnter} 940 onMouseLeave={handleMouseLeave} 941 onClick={handleClick} 942 onDoubleClick={handleDoubleClick} 943 > 944 {/* DreamSong content */} 945 {dreamSongData ? ( 946 <DreamSong 947 dreamSongData={dreamSongData} 948 className="flip-enter" 949 maxHeight={`${nodeSize}px`} 950 /> 951 ) : isLoadingDreamSong ? ( 952 <div 953 style={{ 954 width: '100%', 955 height: '100%', 956 display: 'flex', 957 alignItems: 'center', 958 justifyContent: 'center', 959 color: dreamNodeStyles.colors.text.primary 960 }} 961 > 962 Loading DreamSong... 963 </div> 964 ) : ( 965 <div 966 style={{ 967 width: '100%', 968 height: '100%', 969 display: 'flex', 970 alignItems: 'center', 971 justifyContent: 'center', 972 color: dreamNodeStyles.colors.text.primary 973 }} 974 > 975 No DreamSong available 976 </div> 977 )} 978 979 {/* Flip button (bottom-center, on back side) */} 980 {shouldShowFlipButton && ( 981 <div 982 style={{ 983 position: 'absolute', 984 bottom: '8px', 985 left: '50%', 986 transform: 'translateX(-50%)', 987 width: '84px', 988 height: '84px', 989 borderRadius: '12px', 990 background: 'rgba(0, 0, 0, 0.1)', 991 border: 'none', 992 backdropFilter: 'blur(4px)', 993 display: 'flex', 994 alignItems: 'center', 995 justifyContent: 'center', 996 cursor: 'pointer !important', 997 fontSize: '12px', 998 color: '#fff', 999 boxShadow: '0 2px 12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1)', 1000 filter: 'drop-shadow(0 0 4px rgba(255, 255, 255, 0.3))', 1001 transition: 'all 0.2s ease', 1002 zIndex: 20, 1003 pointerEvents: 'auto' 1004 }} 1005 onClick={(e) => { 1006 e.stopPropagation(); // Prevent event from bubbling to node 1007 handleFlipClick(e); 1008 }} 1009 onMouseEnter={(e) => { 1010 e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)'; 1011 e.currentTarget.style.transform = 'translateX(-50%) scale(1.1)'; 1012 }} 1013 onMouseLeave={(e) => { 1014 e.currentTarget.style.background = 'rgba(0, 0, 0, 0.1)'; 1015 e.currentTarget.style.transform = 'translateX(-50%) scale(1)'; 1016 }} 1017 ref={(el) => { 1018 if (el) { 1019 // Clear existing content and add Obsidian icon 1020 el.innerHTML = ''; 1021 setIcon(el, 'lucide-flip-horizontal'); 1022 // Scale icon for larger button 1023 const iconElement = el.querySelector('.lucide-flip-horizontal'); 1024 if (iconElement) { 1025 (iconElement as HTMLElement).style.width = '36px'; 1026 (iconElement as HTMLElement).style.height = '36px'; 1027 } 1028 } 1029 }} 1030 > 1031 </div> 1032 )} 1033 </div> 1034 </div> 1035 </Html> 1036 1037 {/* Invisible hit detection sphere - travels with visual node as unified object */} 1038 <mesh 1039 ref={hitSphereRef} 1040 position={[0, 0, 0]} 1041 userData={{ dreamNodeId: dreamNode.id, dreamNode: dreamNode }} 1042 > 1043 <sphereGeometry args={[12, 8, 8]} /> 1044 <meshBasicMaterial 1045 transparent={true} 1046 opacity={0} 1047 /> 1048 </mesh> 1049 </group> 1050 ); 1051 }); 1052 1053 DreamNode3D.displayName = 'DreamNode3D'; 1054 1055 export default DreamNode3D; 1056 1057 /** 1058 * Renders different types of media in the DreamTalk circle 1059 */ 1060 function MediaRenderer({ media }: { media: MediaFile }) { 1061 const mediaStyle = { 1062 width: '100%', 1063 height: '100%', 1064 objectFit: 'cover' as const, 1065 borderRadius: '50%' 1066 }; 1067 1068 if (media.type.startsWith('image/')) { 1069 return ( 1070 <img 1071 src={media.data} 1072 alt="DreamTalk symbol" 1073 style={mediaStyle} 1074 draggable={false} 1075 /> 1076 ); 1077 } 1078 1079 if (media.type.startsWith('video/')) { 1080 return ( 1081 <video 1082 src={media.data} 1083 style={mediaStyle} 1084 muted 1085 loop 1086 autoPlay 1087 playsInline 1088 /> 1089 ); 1090 } 1091 1092 if (media.type.startsWith('audio/')) { 1093 return ( 1094 <div 1095 style={{ 1096 ...mediaStyle, 1097 display: 'flex', 1098 alignItems: 'center', 1099 justifyContent: 'center', 1100 background: 'rgba(0, 0, 0, 0.8)' 1101 }} 1102 > 1103 <audio 1104 controls 1105 src={media.data} 1106 style={{ 1107 width: '90%', 1108 maxWidth: '80px', 1109 filter: 'invert(1)' 1110 }} 1111 /> 1112 </div> 1113 ); 1114 } 1115 1116 return ( 1117 <div 1118 style={{ 1119 ...mediaStyle, 1120 display: 'flex', 1121 alignItems: 'center', 1122 justifyContent: 'center', 1123 color: '#FFFFFF', 1124 fontSize: '10px', 1125 background: 'rgba(0, 0, 0, 0.8)' 1126 }} 1127 > 1128 {media.type} 1129 </div> 1130 ); 1131 }