TypeScript · 28444 bytes Raw Blame History
1 'use client';
2
3 // src/components/TreeVisualizer.tsx
4 import React, { useEffect, useRef } from 'react';
5 import * as d3 from 'd3';
6 import { TreeNode } from '@/lib/api';
7
8 interface TreeVisualizerProps {
9 treeData: TreeNode;
10 playerLocation: string;
11 onNodeClick?: (path: string) => void;
12 playIntro?: boolean;
13 isDarkMode?: boolean;
14 moleKilled?: boolean;
15 }
16
17 const TreeVisualizer: React.FC<TreeVisualizerProps> = ({
18 treeData,
19 playerLocation,
20 onNodeClick,
21 playIntro = true,
22 isDarkMode = true,
23 moleKilled = false,
24 }) => {
25 const svgRef = useRef<SVGSVGElement>(null);
26 const containerRef = useRef<HTMLDivElement>(null);
27 const previousLocationRef = useRef<string | null>(null);
28
29 // Quirky visual configuration
30 const NODE_CONFIG = {
31 sizes: {
32 root: { base: 30, hover: 35 },
33 player: { base: 26, hover: 28 },
34 regular: { base: 20, hover: 24 },
35 mole: { base: 22, hover: 26 }
36 },
37 colors: {
38 player: {
39 fill: '#60A5FA',
40 stroke: '#3B82F6',
41 glow: '#93C5FD'
42 },
43 mole: {
44 fill: '#F87171',
45 stroke: '#DC2626',
46 pulse: '#FCA5A5'
47 },
48 fhs: {
49 fill: '#C084FC',
50 stroke: '#9333EA',
51 pattern: 'fhs-pattern'
52 },
53 regular: {
54 fill: '#86EFAC',
55 stroke: '#22C55E',
56 hover: '#BBF7D0'
57 },
58 root: {
59 fill: '#FDE047',
60 stroke: '#EAB308'
61 }
62 },
63 strokeWidth: { base: 3, hover: 4 },
64 wobble: {
65 amount: 2,
66 speed: 3000
67 }
68 };
69
70 const ICON_CONFIG = {
71 size: 40,
72 offset: -20,
73 paths: {
74 player: '/player.svg',
75 mole: '/mole.svg'
76 }
77 };
78
79 const ANIMATION_CONFIG = {
80 nodeHover: { duration: 300 },
81 navigation: { duration: 750, easing: d3.easeCubicInOut },
82 intro: {
83 phases: [
84 { duration: 1000 },
85 { duration: 2000, easing: d3.easeCubicInOut },
86 { duration: 1000 },
87 { duration: 1500, easing: d3.easeCubicInOut },
88 { duration: 800 },
89 { duration: 1200, easing: d3.easeCubicInOut },
90 { duration: 1500, easing: d3.easeCubicInOut }
91 ]
92 },
93 celebration: { duration: '1s', repeatCount: 'indefinite' },
94 pulse: { duration: '2s', repeatCount: 'indefinite' }
95 };
96
97 const LAYOUT_CONFIG = {
98 nodeSpacing: 140,
99 margin: { top: 120, right: 160, bottom: 120, left: 160 },
100 viewBoxMultiplier: 2.5,
101 minHeight: 1200,
102 background: {
103 color: isDarkMode ? '#0F172A' : '#FEF3C7',
104 opacity: 1
105 }
106 };
107
108 const ZOOM_CONFIG = {
109 scaleExtent: [0.1, 3] as [number, number],
110 defaultScale: 2.5,
111 fullTreeScale: 0.8,
112 partialTreeScale: 1.5,
113 treePadding: 200,
114 nudgeOffset: { x: 0.15, y: 0.2 }
115 };
116
117 const LINK_CONFIG = {
118 strokeWidth: 3,
119 opacity: 0.6,
120 dashArray: '5,5',
121 colors: {
122 default: isDarkMode ? '#475569' : '#92400E',
123 hover: isDarkMode ? '#64748B' : '#DC2626',
124 adjacent: isDarkMode ? '#3B82F6' : '#2563EB'
125 }
126 };
127
128 const LABEL_CONFIG = {
129 fontSize: 15,
130 fontWeight: { base: '600', player: '800' },
131 offset: { parent: -38, leaf: 44 },
132 colors: {
133 player: isDarkMode ? '#93C5FD' : '#1E40AF',
134 regular: isDarkMode ? '#E5E7EB' : '#451A03',
135 mole: '#DC2626'
136 },
137 background: {
138 fill: isDarkMode ? 'rgba(15, 23, 42, 0.9)' : 'rgba(254, 243, 199, 0.9)',
139 padding: { x: 8, y: 4 },
140 radius: 4
141 }
142 };
143
144 const PARTICLE_CONFIG = {
145 count: 30,
146 size: { min: 2, max: 6 },
147 colors: ['#FDE047', '#A78BFA', '#F87171', '#60A5FA', '#86EFAC'],
148 speed: { min: 20000, max: 40000 }
149 };
150
151 const isAnimatingRef = useRef(false);
152
153 useEffect(() => {
154 if (!treeData || !svgRef.current || !containerRef.current) return;
155
156 const isNavigation = previousLocationRef.current !== null &&
157 previousLocationRef.current !== playerLocation &&
158 !playIntro;
159
160 previousLocationRef.current = playerLocation;
161
162 // Clear previous render
163 d3.select(svgRef.current).selectAll('*').remove();
164
165 // Get container dimensions
166 const containerWidth = containerRef.current.clientWidth;
167 const containerHeight = containerRef.current.clientHeight;
168
169 // Create hierarchy and calculate dimensions
170 const root = d3.hierarchy(treeData);
171
172 const levelCounts: { [key: number]: number } = {};
173 root.each(d => {
174 levelCounts[d.depth] = (levelCounts[d.depth] || 0) + 1;
175 });
176 const maxNodesAtLevel = Math.max(...Object.values(levelCounts));
177
178 const nodeSpacing = LAYOUT_CONFIG.nodeSpacing;
179 const dynamicWidth = Math.max(maxNodesAtLevel * nodeSpacing, containerWidth * LAYOUT_CONFIG.viewBoxMultiplier);
180 const margin = LAYOUT_CONFIG.margin;
181 const width = dynamicWidth;
182 const height = Math.max(containerHeight, LAYOUT_CONFIG.minHeight);
183
184 const svg = d3
185 .select(svgRef.current)
186 .attr('width', containerWidth)
187 .attr('height', containerHeight)
188 .attr('viewBox', `0 0 ${width} ${height}`)
189 .attr('preserveAspectRatio', 'xMidYMid meet');
190
191 // Add definitions
192 const defs = svg.append('defs');
193
194 // Create quirky patterns
195 const fhsPattern = defs.append('pattern')
196 .attr('id', 'fhs-pattern')
197 .attr('patternUnits', 'objectBoundingBox')
198 .attr('width', 0.25)
199 .attr('height', 0.25);
200
201 fhsPattern.append('circle')
202 .attr('cx', 2)
203 .attr('cy', 2)
204 .attr('r', 1.5)
205 .attr('fill', '#9333EA')
206 .attr('opacity', 0.3);
207
208 // Add glow filters
209 const glowFilter = defs.append('filter')
210 .attr('id', 'glow');
211
212 glowFilter.append('feGaussianBlur')
213 .attr('stdDeviation', 4)
214 .attr('result', 'coloredBlur');
215
216 const feMerge = glowFilter.append('feMerge');
217 feMerge.append('feMergeNode').attr('in', 'coloredBlur');
218 feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
219
220 // Add drop shadow filter
221 const dropShadow = defs.append('filter')
222 .attr('id', 'drop-shadow')
223 .attr('x', '-50%')
224 .attr('y', '-50%')
225 .attr('width', '200%')
226 .attr('height', '200%');
227
228 dropShadow.append('feGaussianBlur')
229 .attr('in', 'SourceAlpha')
230 .attr('stdDeviation', 3);
231
232 dropShadow.append('feOffset')
233 .attr('dx', 2)
234 .attr('dy', 2)
235 .attr('result', 'offsetblur');
236
237 const feMerge2 = dropShadow.append('feMerge');
238 feMerge2.append('feMergeNode').attr('in', 'offsetblur');
239 feMerge2.append('feMergeNode').attr('in', 'SourceGraphic');
240
241 // Quirky background with floating particles
242 svg.append('rect')
243 .attr('width', '100%')
244 .attr('height', '100%')
245 .attr('fill', LAYOUT_CONFIG.background.color)
246 .style('opacity', LAYOUT_CONFIG.background.opacity);
247
248 // Add floating background particles
249 const particlesGroup = svg.append('g').attr('class', 'particles');
250
251 for (let i = 0; i < PARTICLE_CONFIG.count; i++) {
252 const particle = particlesGroup.append('circle')
253 .attr('cx', Math.random() * width)
254 .attr('cy', Math.random() * height)
255 .attr('r', Math.random() * (PARTICLE_CONFIG.size.max - PARTICLE_CONFIG.size.min) + PARTICLE_CONFIG.size.min)
256 .attr('fill', PARTICLE_CONFIG.colors[Math.floor(Math.random() * PARTICLE_CONFIG.colors.length)])
257 .attr('opacity', 0.3);
258
259 // Animate particles floating
260 particle
261 .transition()
262 .duration(Math.random() * (PARTICLE_CONFIG.speed.max - PARTICLE_CONFIG.speed.min) + PARTICLE_CONFIG.speed.min)
263 .ease(d3.easeLinear)
264 .attr('cy', -20)
265 .on('end', function repeat() {
266 d3.select(this)
267 .attr('cy', height + 20)
268 .attr('cx', Math.random() * width)
269 .transition()
270 .duration(Math.random() * (PARTICLE_CONFIG.speed.max - PARTICLE_CONFIG.speed.min) + PARTICLE_CONFIG.speed.min)
271 .ease(d3.easeLinear)
272 .attr('cy', -20)
273 .on('end', repeat);
274 });
275 }
276
277 const g = svg
278 .append('g')
279 .attr('transform', `translate(${margin.left},${margin.top})`);
280
281 // Create tree layout
282 const treeLayout = d3
283 .tree<TreeNode>()
284 .size([width - margin.left - margin.right, height - margin.top - margin.bottom])
285 .separation((a, b) => {
286 const aParentChildCount = a.parent ? (a.parent.children?.length || 0) : 0;
287 const bParentChildCount = b.parent ? (b.parent.children?.length || 0) : 0;
288
289 if (a.parent === b.parent && aParentChildCount > 3) {
290 const aIsLeaf = !a.children || a.children.length === 0;
291 const bIsLeaf = !b.children || b.children.length === 0;
292
293 if (aIsLeaf && bIsLeaf) {
294 return 2.5;
295 }
296 return 2;
297 }
298
299 if (a.depth === 0 || b.depth === 0) return 4;
300 if (a.depth === 1 || b.depth === 1) return 3;
301
302 const aIsLeaf = !a.children || a.children.length === 0;
303 const bIsLeaf = !b.children || b.children.length === 0;
304
305 if (aIsLeaf && bIsLeaf) {
306 return 1.5;
307 }
308 return a.parent === b.parent ? 1.5 : 2;
309 });
310
311 // Apply tree layout
312 const treeNodes = treeLayout(root);
313
314 // Center the root
315 const rootX = width / 2;
316 treeNodes.each(d => {
317 d.x = d.x + (rootX - root.x);
318 });
319
320 // Helper function to check if a node is adjacent
321 const isAdjacentNode = (nodePath: string, currentPath: string): boolean => {
322 if (currentPath !== '/') {
323 const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
324 if (nodePath === parentPath) return true;
325 }
326
327 if (currentPath === '/') {
328 const segments = nodePath.split('/').filter(s => s);
329 if (segments.length === 1) return true;
330 } else {
331 if (nodePath.startsWith(currentPath + '/')) {
332 const relativePath = nodePath.substring(currentPath.length + 1);
333 if (!relativePath.includes('/')) return true;
334 }
335 }
336
337 return false;
338 };
339
340 // Create quirky curved links
341 const linkGenerator = d3.linkVertical<any, any>()
342 .x(d => d.x)
343 .y(d => d.y)
344 .source(d => {
345 // Add some wobble to the source point
346 const wobbleX = Math.sin(Date.now() / NODE_CONFIG.wobble.speed) * NODE_CONFIG.wobble.amount;
347 return { x: d.source.x + wobbleX, y: d.source.y };
348 })
349 .target(d => d.target);
350
351 const link = g
352 .selectAll('.link')
353 .data(treeNodes.links())
354 .enter()
355 .append('path')
356 .attr('class', 'link')
357 .attr('d', linkGenerator)
358 .style('fill', 'none')
359 .style('stroke', d => {
360 const targetPath = (d.target as any).data.path;
361 if (isAdjacentNode(targetPath, playerLocation) || targetPath === playerLocation) {
362 return LINK_CONFIG.colors.adjacent;
363 }
364 return LINK_CONFIG.colors.default;
365 })
366 .style('stroke-width', LINK_CONFIG.strokeWidth)
367 .style('stroke-dasharray', d => {
368 const targetPath = (d.target as any).data.path;
369 if (targetPath === playerLocation) return 'none';
370 return LINK_CONFIG.dashArray;
371 })
372 .style('opacity', LINK_CONFIG.opacity)
373 .style('filter', 'drop-shadow(0 0 3px rgba(0,0,0,0.3))');
374
375 // Animate link dashes
376 link
377 .style('stroke-dashoffset', 0)
378 .transition()
379 .duration(20000)
380 .ease(d3.easeLinear)
381 .style('stroke-dashoffset', -100)
382 .on('end', function repeat() {
383 d3.select(this)
384 .style('stroke-dashoffset', 0)
385 .transition()
386 .duration(20000)
387 .ease(d3.easeLinear)
388 .style('stroke-dashoffset', -100)
389 .on('end', repeat);
390 });
391
392 // Create node groups
393 const node = g
394 .selectAll('.node')
395 .data(treeNodes.descendants())
396 .enter()
397 .append('g')
398 .attr('class', 'node')
399 .attr('transform', d => `translate(${d.x},${d.y})`);
400
401 // Add subtle wobble animation to all nodes
402 node.each(function(d, i) {
403 const nodeGroup = d3.select(this);
404 const delay = i * 100;
405
406 nodeGroup
407 .transition()
408 .delay(delay)
409 .duration(NODE_CONFIG.wobble.speed)
410 .ease(d3.easeSinInOut)
411 .attr('transform', `translate(${d.x + NODE_CONFIG.wobble.amount},${d.y})`)
412 .transition()
413 .duration(NODE_CONFIG.wobble.speed)
414 .ease(d3.easeSinInOut)
415 .attr('transform', `translate(${d.x - NODE_CONFIG.wobble.amount},${d.y})`)
416 .on('end', function repeat() {
417 d3.select(this)
418 .transition()
419 .duration(NODE_CONFIG.wobble.speed)
420 .ease(d3.easeSinInOut)
421 .attr('transform', `translate(${d.x + NODE_CONFIG.wobble.amount},${d.y})`)
422 .transition()
423 .duration(NODE_CONFIG.wobble.speed)
424 .ease(d3.easeSinInOut)
425 .attr('transform', `translate(${d.x - NODE_CONFIG.wobble.amount},${d.y})`)
426 .on('end', repeat);
427 });
428 });
429
430 // Add node backgrounds (quirky shapes)
431 node.each(function(d) {
432 const nodeEl = d3.select(this);
433 const isRoot = d.data.path === '/';
434 const isPlayer = d.data.path === playerLocation;
435 const hasMole = d.data.has_mole;
436
437 if (isRoot) {
438 // Star shape for root
439 const starPoints = 8;
440 const outerRadius = NODE_CONFIG.sizes.root.base;
441 const innerRadius = outerRadius * 0.6;
442
443 let path = '';
444 for (let i = 0; i < starPoints * 2; i++) {
445 const angle = (i * Math.PI) / starPoints;
446 const radius = i % 2 === 0 ? outerRadius : innerRadius;
447 const x = Math.cos(angle) * radius;
448 const y = Math.sin(angle) * radius;
449 path += `${i === 0 ? 'M' : 'L'} ${x},${y}`;
450 }
451 path += 'Z';
452
453 nodeEl.append('path')
454 .attr('d', path)
455 .attr('class', 'node-shape')
456 .style('fill', NODE_CONFIG.colors.root.fill)
457 .style('stroke', NODE_CONFIG.colors.root.stroke)
458 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
459 .style('filter', 'url(#drop-shadow)');
460 } else if (hasMole) {
461 // Irregular shape for mole locations
462 const size = NODE_CONFIG.sizes.mole.base;
463 nodeEl.append('path')
464 .attr('d', `M ${-size},0 Q ${-size/2},${-size} 0,${-size} T ${size},0 Q ${size/2},${size} 0,${size} T ${-size},0`)
465 .attr('class', 'node-shape mole-node')
466 .style('fill', NODE_CONFIG.colors.mole.fill)
467 .style('stroke', NODE_CONFIG.colors.mole.stroke)
468 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
469 .style('filter', 'url(#glow)');
470 } else {
471 // Regular circles with personality
472 nodeEl.append('circle')
473 .attr('r', () => {
474 if (isPlayer) return NODE_CONFIG.sizes.player.base;
475 if (d.data.is_fhs) return NODE_CONFIG.sizes.regular.base + 2;
476 return NODE_CONFIG.sizes.regular.base;
477 })
478 .attr('class', 'node-shape')
479 .style('fill', () => {
480 if (isPlayer) return NODE_CONFIG.colors.player.fill;
481 if (d.data.is_fhs) return `url(#${NODE_CONFIG.colors.fhs.pattern})`;
482 return NODE_CONFIG.colors.regular.fill;
483 })
484 .style('stroke', () => {
485 if (isPlayer) return NODE_CONFIG.colors.player.stroke;
486 if (d.data.is_fhs) return NODE_CONFIG.colors.fhs.stroke;
487 return NODE_CONFIG.colors.regular.stroke;
488 })
489 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
490 .style('filter', isPlayer ? 'url(#glow)' : 'url(#drop-shadow)');
491 }
492 });
493
494 // Add interactivity
495 node.selectAll('.node-shape')
496 .style('cursor', d => {
497 if (d.data.path === playerLocation) return 'default';
498 return isAdjacentNode(d.data.path, playerLocation) ? 'pointer' : 'not-allowed';
499 })
500 .style('opacity', d => {
501 if (d.data.path === playerLocation) return 1;
502 return isAdjacentNode(d.data.path, playerLocation) ? 1 : 0.5;
503 })
504 .on('mouseover', function(event, d) {
505 if (d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
506 d3.select(this)
507 .transition()
508 .duration(ANIMATION_CONFIG.nodeHover.duration)
509 .attr('r', function() {
510 const currentR = d3.select(this).attr('r');
511 return currentR ? parseFloat(currentR) * 1.2 : NODE_CONFIG.sizes.regular.hover;
512 })
513 .style('filter', 'url(#glow) drop-shadow(0 0 8px rgba(0,0,0,0.4))');
514 }
515 })
516 .on('mouseout', function(event, d) {
517 d3.select(this)
518 .transition()
519 .duration(ANIMATION_CONFIG.nodeHover.duration)
520 .attr('r', function() {
521 if (d.data.path === '/') return NODE_CONFIG.sizes.root.base;
522 if (d.data.path === playerLocation) return NODE_CONFIG.sizes.player.base;
523 if (d.data.has_mole) return NODE_CONFIG.sizes.mole.base;
524 return NODE_CONFIG.sizes.regular.base;
525 })
526 .style('filter', d.data.path === playerLocation ? 'url(#glow)' : 'url(#drop-shadow)');
527 })
528 .on('click', (event, d) => {
529 if (onNodeClick && d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
530 onNodeClick(d.data.path);
531 }
532 });
533
534 // Add pulse animation to mole nodes
535 node.filter(d => d.data.has_mole)
536 .select('.mole-node')
537 .append('animate')
538 .attr('attributeName', 'opacity')
539 .attr('values', '0.7;1;0.7')
540 .attr('dur', ANIMATION_CONFIG.pulse.duration)
541 .attr('repeatCount', ANIMATION_CONFIG.pulse.repeatCount);
542
543 // Add labels with backgrounds
544 const labels = node
545 .append('g')
546 .attr('class', 'label-group');
547
548 // Label background
549 labels.append('rect')
550 .attr('class', 'label-bg')
551 .attr('fill', LABEL_CONFIG.background.fill)
552 .attr('rx', LABEL_CONFIG.background.radius)
553 .attr('ry', LABEL_CONFIG.background.radius)
554 .style('filter', 'drop-shadow(0 1px 2px rgba(0,0,0,0.2))');
555
556 // Label text
557 labels.append('text')
558 .attr('class', 'label-text')
559 .attr('dy', d => d.children ? LABEL_CONFIG.offset.parent : LABEL_CONFIG.offset.leaf)
560 .attr('text-anchor', 'middle')
561 .style('font-size', `${LABEL_CONFIG.fontSize}px`)
562 .style('font-weight', d => d.data.path === playerLocation ? LABEL_CONFIG.fontWeight.player : LABEL_CONFIG.fontWeight.base)
563 .style('fill', d => {
564 if (d.data.path === playerLocation) return LABEL_CONFIG.colors.player;
565 if (d.data.has_mole) return LABEL_CONFIG.colors.mole;
566 return LABEL_CONFIG.colors.regular;
567 })
568 .style('font-family', 'Comic Sans MS, cursive')
569 .text(d => d.data.name || '/')
570 .style('pointer-events', 'none');
571
572 // Size backgrounds to fit text
573 labels.each(function() {
574 const labelGroup = d3.select(this);
575 const text = labelGroup.select('.label-text');
576 const bg = labelGroup.select('.label-bg');
577
578 const bbox = (text.node() as SVGTextElement).getBBox();
579
580 bg.attr('x', bbox.x - LABEL_CONFIG.background.padding.x)
581 .attr('y', bbox.y - LABEL_CONFIG.background.padding.y)
582 .attr('width', bbox.width + LABEL_CONFIG.background.padding.x * 2)
583 .attr('height', bbox.height + LABEL_CONFIG.background.padding.y * 2);
584 });
585
586 // Add icon images for special directories
587 node.each(function(d) {
588 const nodeEl = d3.select(this);
589 let iconPath = null;
590
591 // Map paths to icon files
592 if (d.data.path === '/home') iconPath = '/icons/home.svg';
593 else if (d.data.path === '/tmp') iconPath = '/icons/trash.svg';
594 else if (d.data.path === '/etc') iconPath = '/icons/config.svg';
595 else if (d.data.path === '/bin' || d.data.path === '/sbin') iconPath = '/icons/terminal.svg';
596 else if (d.data.path === '/var') iconPath = '/icons/database.svg';
597 else if (d.data.path === '/usr') iconPath = '/icons/folder.svg';
598 else if (d.data.path === '/opt') iconPath = '/icons/package.svg';
599 else if (d.data.path.includes('Documents')) iconPath = '/icons/document.svg';
600 else if (d.data.path.includes('Pictures')) iconPath = '/icons/picture.svg';
601 else if (d.data.path.includes('Downloads')) iconPath = '/icons/download.svg';
602 else if (d.data.path.includes('Desktop')) iconPath = '/icons/desktop.svg';
603
604 if (iconPath) {
605 const iconSize = 24;
606 nodeEl.append('image')
607 .attr('class', 'directory-icon')
608 .attr('xlink:href', iconPath)
609 .attr('width', iconSize)
610 .attr('height', iconSize)
611 .attr('x', -iconSize / 2)
612 .attr('y', -iconSize / 2)
613 .style('pointer-events', 'none')
614 .style('opacity', 0.8)
615 .on('mouseover', function() {
616 d3.select(this)
617 .transition()
618 .duration(200)
619 .style('opacity', 1)
620 .attr('transform', 'scale(1.2)');
621 })
622 .on('mouseout', function() {
623 d3.select(this)
624 .transition()
625 .duration(200)
626 .style('opacity', 0.8)
627 .attr('transform', 'scale(1)');
628 });
629 }
630 });
631
632 // Add player and mole indicators
633 const playerNode = treeNodes.descendants().find(d => d.data.path === playerLocation);
634 if (playerNode) {
635 const playerGroup = node
636 .filter(d => d.data.path === playerLocation)
637 .append('g');
638
639 playerGroup
640 .append('image')
641 .attr('xlink:href', ICON_CONFIG.paths.player)
642 .attr('width', ICON_CONFIG.size)
643 .attr('height', ICON_CONFIG.size)
644 .attr('x', ICON_CONFIG.offset)
645 .attr('y', ICON_CONFIG.offset)
646 .style('pointer-events', 'none')
647 .style('filter', 'drop-shadow(0 0 6px rgba(59, 130, 246, 0.8))');
648 }
649
650 const moleNode = treeNodes.descendants().find(d => d.data.has_mole);
651 if (moleNode) {
652 const moleGroup = node
653 .filter(d => d.data.has_mole)
654 .append('g');
655
656 // Celebration rings
657 for (let i = 0; i < 3; i++) {
658 moleGroup
659 .append('circle')
660 .attr('r', 15)
661 .style('fill', 'none')
662 .style('stroke', NODE_CONFIG.colors.mole.pulse)
663 .style('stroke-width', 2)
664 .style('opacity', 0)
665 .transition()
666 .delay(i * 300)
667 .duration(1500)
668 .ease(d3.easeQuadOut)
669 .attr('r', 40)
670 .style('opacity', 0)
671 .on('end', function repeat() {
672 d3.select(this)
673 .attr('r', 15)
674 .style('opacity', 0)
675 .transition()
676 .duration(1500)
677 .ease(d3.easeQuadOut)
678 .attr('r', 40)
679 .style('opacity', 0)
680 .on('end', repeat);
681 });
682 }
683
684 moleGroup
685 .append('image')
686 .attr('xlink:href', ICON_CONFIG.paths.mole)
687 .attr('width', ICON_CONFIG.size)
688 .attr('height', ICON_CONFIG.size)
689 .attr('x', ICON_CONFIG.offset)
690 .attr('y', ICON_CONFIG.offset)
691 .style('pointer-events', 'none')
692 .style('filter', 'drop-shadow(0 0 6px rgba(239, 68, 68, 0.8))')
693 .classed('mole-death', moleKilled);
694 }
695
696 // Zoom behavior
697 const zoom = d3.zoom<SVGSVGElement, unknown>()
698 .scaleExtent(ZOOM_CONFIG.scaleExtent)
699 .on('zoom', (event) => {
700 g.attr('transform', event.transform);
701 });
702
703 svg.call(zoom);
704
705 // Zoom transform helper
706 const getZoomTransform = (node: d3.HierarchyPointNode<TreeNode>, scale: number, offsetX: number = 0, offsetY: number = 0) => {
707 const viewBoxCenterX = width / 2;
708 const viewBoxCenterY = height / 2;
709
710 const translateX = viewBoxCenterX - (node.x + margin.left) * scale + (width * offsetX);
711 const translateY = viewBoxCenterY - (node.y + margin.top) * scale + (height * offsetY);
712
713 return d3.zoomIdentity.translate(translateX, translateY).scale(scale);
714 };
715
716 // Handle intro and navigation animations
717 if (playIntro && playerNode) {
718 const rootTransform = getZoomTransform(treeNodes, ZOOM_CONFIG.defaultScale);
719 svg.call(zoom.transform, rootTransform);
720
721 const allNodes = treeNodes.descendants();
722 const xExtent = d3.extent(allNodes, d => d.x) as [number, number];
723 const yExtent = d3.extent(allNodes, d => d.y) as [number, number];
724
725 const treeWidth = xExtent[1] - xExtent[0] + ZOOM_CONFIG.treePadding;
726 const treeHeight = yExtent[1] - yExtent[0] + ZOOM_CONFIG.treePadding;
727
728 const scaleX = (width - margin.left - margin.right) / treeWidth;
729 const scaleY = (height - margin.top - margin.bottom) / treeHeight;
730 const fullTreeScale = Math.min(scaleX, scaleY, ZOOM_CONFIG.fullTreeScale);
731
732 const treeCenterX = (xExtent[0] + xExtent[1]) / 2;
733 const treeCenterY = (yExtent[0] + yExtent[1]) / 2;
734 const treeCenter = { x: treeCenterX, y: treeCenterY } as d3.HierarchyPointNode<TreeNode>;
735 const fullTreeTransform = getZoomTransform(treeCenter, fullTreeScale);
736
737 const moleTransform = moleNode ? getZoomTransform(moleNode, ZOOM_CONFIG.defaultScale) : null;
738 const partialTreeTransform = getZoomTransform(treeCenter, ZOOM_CONFIG.partialTreeScale);
739 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
740 ZOOM_CONFIG.nudgeOffset.x,
741 ZOOM_CONFIG.nudgeOffset.y);
742
743 const phases = ANIMATION_CONFIG.intro.phases;
744
745 if (moleTransform) {
746 isAnimatingRef.current = true;
747
748 svg.transition()
749 .duration(phases[0].duration)
750 .call(zoom.transform, rootTransform)
751 .transition()
752 .duration(phases[1].duration)
753 .ease(phases[1].easing!)
754 .call(zoom.transform, fullTreeTransform)
755 .transition()
756 .duration(phases[2].duration)
757 .call(zoom.transform, fullTreeTransform)
758 .transition()
759 .duration(phases[3].duration)
760 .ease(phases[3].easing!)
761 .call(zoom.transform, moleTransform)
762 .transition()
763 .duration(phases[4].duration)
764 .call(zoom.transform, moleTransform)
765 .transition()
766 .duration(phases[5].duration)
767 .ease(phases[5].easing!)
768 .call(zoom.transform, partialTreeTransform)
769 .transition()
770 .duration(phases[6].duration)
771 .ease(phases[6].easing!)
772 .call(zoom.transform, playerTransform)
773 .on('end', () => {
774 isAnimatingRef.current = false;
775 });
776 } else {
777 isAnimatingRef.current = true;
778
779 svg.transition()
780 .duration(phases[0].duration)
781 .call(zoom.transform, rootTransform)
782 .transition()
783 .duration(phases[1].duration)
784 .ease(phases[1].easing!)
785 .call(zoom.transform, fullTreeTransform)
786 .transition()
787 .duration(phases[2].duration)
788 .call(zoom.transform, fullTreeTransform)
789 .transition()
790 .duration(phases[6].duration)
791 .ease(phases[6].easing!)
792 .call(zoom.transform, playerTransform)
793 .on('end', () => {
794 isAnimatingRef.current = false;
795 });
796 }
797 } else if (playerNode && !isAnimatingRef.current) {
798 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
799 ZOOM_CONFIG.nudgeOffset.x,
800 ZOOM_CONFIG.nudgeOffset.y);
801
802 if (isNavigation) {
803 svg.transition()
804 .duration(ANIMATION_CONFIG.navigation.duration)
805 .ease(ANIMATION_CONFIG.navigation.easing)
806 .call(zoom.transform, playerTransform);
807 } else {
808 svg.call(zoom.transform, playerTransform);
809 }
810 }
811
812 }, [treeData, playerLocation, onNodeClick, playIntro, isDarkMode, moleKilled]);
813
814 return (
815 <div ref={containerRef} className="w-full h-full">
816 <svg ref={svgRef} className="w-full h-full" />
817 </div>
818 );
819 };
820
821 export default TreeVisualizer;