@@ -57,9 +57,12 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 57 | 57 | navigation: { duration: 750, easing: d3.easeCubicInOut }, |
| 58 | 58 | intro: { |
| 59 | 59 | phases: [ |
| 60 | | - { duration: 1000 }, // Initial zoom |
| 61 | | - { duration: 2000, easing: d3.easeCubicInOut }, // Zoom out |
| 62 | | - { duration: 1000 }, // Pause |
| 60 | + { duration: 1000 }, // Initial pause on root |
| 61 | + { duration: 2000, easing: d3.easeCubicInOut }, // Zoom out to full tree |
| 62 | + { duration: 1000 }, // Pause on full tree |
| 63 | + { duration: 1500, easing: d3.easeCubicInOut }, // Zoom to mole |
| 64 | + { duration: 800 }, // Brief pause on mole |
| 65 | + { duration: 1200, easing: d3.easeCubicInOut }, // Zoom out partially |
| 63 | 66 | { duration: 1500, easing: d3.easeCubicInOut } // Zoom to player |
| 64 | 67 | ] |
| 65 | 68 | }, |
@@ -85,6 +88,7 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 85 | 88 | scaleExtent: [0.1, 3] as [number, number], |
| 86 | 89 | defaultScale: 3, |
| 87 | 90 | fullTreeScale: 0.8, |
| 91 | + partialTreeScale: 1.5, // For the partial zoom out after showing mole |
| 88 | 92 | treePadding: 200, |
| 89 | 93 | nudgeOffset: { x: 0.15, y: 0.2 } |
| 90 | 94 | }; |
@@ -129,6 +133,8 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 129 | 133 | } |
| 130 | 134 | }; |
| 131 | 135 | |
| 136 | + const isAnimatingRef = useRef(false); |
| 137 | + |
| 132 | 138 | useEffect(() => { |
| 133 | 139 | if (!treeData || !svgRef.current || !containerRef.current) return; |
| 134 | 140 | |
@@ -531,28 +537,77 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 531 | 537 | const treeCenter = { x: treeCenterX, y: treeCenterY } as d3.HierarchyPointNode<TreeNode>; |
| 532 | 538 | const fullTreeTransform = getZoomTransform(treeCenter, fullTreeScale); |
| 533 | 539 | |
| 540 | + // Find mole location |
| 541 | + const moleNode = treeNodes.descendants().find(d => d.data.has_mole); |
| 542 | + const moleTransform = moleNode ? getZoomTransform(moleNode, ZOOM_CONFIG.defaultScale) : null; |
| 543 | + |
| 544 | + // Partial tree view (for after showing mole) |
| 545 | + const partialTreeTransform = getZoomTransform(treeCenter, ZOOM_CONFIG.partialTreeScale); |
| 546 | + |
| 534 | 547 | // Final player position with nudge offset |
| 535 | 548 | const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale, |
| 536 | 549 | ZOOM_CONFIG.nudgeOffset.x, |
| 537 | 550 | ZOOM_CONFIG.nudgeOffset.y); |
| 538 | 551 | |
| 539 | | - // Animate using zoom transitions |
| 552 | + // Animate using zoom transitions with new sequence |
| 540 | 553 | const phases = ANIMATION_CONFIG.intro.phases; |
| 541 | | - svg.transition() |
| 542 | | - .duration(phases[0].duration) |
| 543 | | - .call(zoom.transform, rootTransform) |
| 554 | + |
| 555 | + // Build the transition chain |
| 556 | + if (moleTransform) { |
| 557 | + isAnimatingRef.current = true; |
| 558 | + |
| 559 | + // Create a single transition and chain all the calls |
| 560 | + svg.transition() |
| 561 | + .duration(phases[0].duration) |
| 562 | + .call(zoom.transform, rootTransform) |
| 563 | + .transition() |
| 564 | + .duration(phases[1].duration) |
| 565 | + .ease(phases[1].easing!) |
| 566 | + .call(zoom.transform, fullTreeTransform) |
| 567 | + .transition() |
| 568 | + .duration(phases[2].duration) |
| 569 | + .call(zoom.transform, fullTreeTransform) |
| 544 | 570 | .transition() |
| 545 | | - .duration(phases[1].duration) |
| 546 | | - .ease(phases[1].easing!) |
| 547 | | - .call(zoom.transform, fullTreeTransform) |
| 571 | + .duration(phases[3].duration) |
| 572 | + .ease(phases[3].easing!) |
| 573 | + .call(zoom.transform, moleTransform) |
| 548 | 574 | .transition() |
| 549 | | - .duration(phases[2].duration) |
| 550 | | - .call(zoom.transform, fullTreeTransform) |
| 575 | + .duration(phases[4].duration) |
| 576 | + .call(zoom.transform, moleTransform) |
| 551 | 577 | .transition() |
| 552 | | - .duration(phases[3].duration) |
| 553 | | - .ease(phases[3].easing!) |
| 554 | | - .call(zoom.transform, playerTransform); |
| 555 | | - } else if (playerNode) { |
| 578 | + .duration(phases[5].duration) |
| 579 | + .ease(phases[5].easing!) |
| 580 | + .call(zoom.transform, partialTreeTransform) |
| 581 | + .transition() |
| 582 | + .duration(phases[6].duration) |
| 583 | + .ease(phases[6].easing!) |
| 584 | + .call(zoom.transform, playerTransform) |
| 585 | + .on('end', () => { |
| 586 | + isAnimatingRef.current = false; |
| 587 | + }); |
| 588 | + } else { |
| 589 | + isAnimatingRef.current = true; |
| 590 | + |
| 591 | + // Shorter sequence without mole |
| 592 | + svg.transition() |
| 593 | + .duration(phases[0].duration) |
| 594 | + .call(zoom.transform, rootTransform) |
| 595 | + .transition() |
| 596 | + .duration(phases[1].duration) |
| 597 | + .ease(phases[1].easing!) |
| 598 | + .call(zoom.transform, fullTreeTransform) |
| 599 | + .transition() |
| 600 | + .duration(phases[2].duration) |
| 601 | + .call(zoom.transform, fullTreeTransform) |
| 602 | + .transition() |
| 603 | + .duration(phases[6].duration) |
| 604 | + .ease(phases[6].easing!) |
| 605 | + .call(zoom.transform, playerTransform) |
| 606 | + .on('end', () => { |
| 607 | + isAnimatingRef.current = false; |
| 608 | + }); |
| 609 | + } |
| 610 | + } else if (playerNode && !isAnimatingRef.current) { |
| 556 | 611 | // No intro: position on player with nudge offset |
| 557 | 612 | const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale, |
| 558 | 613 | ZOOM_CONFIG.nudgeOffset.x, |