TypeScript · 29004 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 // Quirky visual configuration - moved outside component to avoid dependency issues
9 const NODE_CONFIG = {
10 sizes: {
11 root: { base: 30, hover: 35 },
12 player: { base: 26, hover: 28 },
13 regular: { base: 20, hover: 24 },
14 mole: { base: 22, hover: 26 }
15 },
16 colors: {
17 player: {
18 fill: '#60A5FA',
19 stroke: '#3B82F6',
20 glow: '#93C5FD'
21 },
22 mole: {
23 fill: '#F87171',
24 stroke: '#DC2626',
25 pulse: '#FCA5A5'
26 },
27 fhs: {
28 fill: '#C084FC',
29 stroke: '#9333EA',
30 pattern: 'fhs-pattern'
31 },
32 regular: {
33 fill: '#86EFAC',
34 stroke: '#22C55E',
35 hover: '#BBF7D0'
36 },
37 root: {
38 fill: '#FDE047',
39 stroke: '#EAB308'
40 }
41 },
42 strokeWidth: { base: 3, hover: 4 },
43 wobble: {
44 amount: 2,
45 speed: 3000
46 }
47 };
48
49 const ICON_CONFIG = {
50 size: 40,
51 offset: -20,
52 paths: {
53 player: '/player.svg',
54 mole: '/mole.svg'
55 }
56 };
57
58 const ANIMATION_CONFIG = {
59 nodeHover: { duration: 300 },
60 navigation: { duration: 750, easing: d3.easeCubicInOut },
61 intro: {
62 phases: [
63 { duration: 1000 },
64 { duration: 2000, easing: d3.easeCubicInOut },
65 { duration: 1000 },
66 { duration: 1500, easing: d3.easeCubicInOut },
67 { duration: 800 },
68 { duration: 1200, easing: d3.easeCubicInOut },
69 { duration: 1500, easing: d3.easeCubicInOut }
70 ]
71 },
72 celebration: { duration: '1s', repeatCount: 'indefinite' },
73 pulse: { duration: '2s', repeatCount: 'indefinite' }
74 };
75
76 const PARTICLE_CONFIG = {
77 count: 30,
78 size: { min: 2, max: 6 },
79 colors: ['#FDE047', '#A78BFA', '#F87171', '#60A5FA', '#86EFAC'],
80 speed: { min: 20000, max: 40000 }
81 };
82
83 const ZOOM_CONFIG = {
84 scaleExtent: [0.1, 3] as [number, number],
85 defaultScale: 2.5,
86 fullTreeScale: 0.8,
87 partialTreeScale: 1.5,
88 treePadding: 200,
89 nudgeOffset: { x: 0.15, y: 0.2 }
90 };
91
92 interface TreeVisualizerProps {
93 treeData: TreeNode;
94 playerLocation: string;
95 onNodeClick?: (path: string) => void;
96 playIntro?: boolean;
97 isDarkMode?: boolean;
98 moleKilled?: boolean;
99 }
100
101 const TreeVisualizer: React.FC<TreeVisualizerProps> = ({
102 treeData,
103 playerLocation,
104 onNodeClick,
105 playIntro = true,
106 isDarkMode = true,
107 moleKilled = false,
108 }) => {
109 const svgRef = useRef<SVGSVGElement>(null);
110 const containerRef = useRef<HTMLDivElement>(null);
111 const previousLocationRef = useRef<string | null>(null);
112
113 const LAYOUT_CONFIG = {
114 nodeSpacing: 140,
115 margin: { top: 120, right: 160, bottom: 120, left: 160 },
116 viewBoxMultiplier: 2.5,
117 minHeight: 1200,
118 background: {
119 color: isDarkMode ? '#0F172A' : '#FEF3C7',
120 opacity: 1
121 }
122 };
123
124 const LINK_CONFIG = {
125 strokeWidth: 3,
126 opacity: 0.6,
127 dashArray: '5,5',
128 colors: {
129 default: isDarkMode ? '#475569' : '#92400E',
130 hover: isDarkMode ? '#64748B' : '#DC2626',
131 adjacent: isDarkMode ? '#3B82F6' : '#2563EB'
132 }
133 };
134
135 const LABEL_CONFIG = {
136 fontSize: 15,
137 fontWeight: { base: '600', player: '800' },
138 offset: { parent: -38, leaf: 44 },
139 colors: {
140 player: isDarkMode ? '#93C5FD' : '#1E40AF',
141 regular: isDarkMode ? '#E5E7EB' : '#451A03',
142 mole: '#DC2626'
143 },
144 background: {
145 fill: isDarkMode ? 'rgba(15, 23, 42, 0.9)' : 'rgba(254, 243, 199, 0.9)',
146 padding: { x: 8, y: 4 },
147 radius: 4
148 }
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 - treeNodes.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 d3.HierarchyPointNode<TreeNode>).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 d3.HierarchyPointNode<TreeNode>).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', function(this: any) {
497 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
498 if (d.data.path === playerLocation) return 'default';
499 return isAdjacentNode(d.data.path, playerLocation) ? 'pointer' : 'not-allowed';
500 })
501 .style('opacity', function(this: any) {
502 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
503 if (d.data.path === playerLocation) return 1;
504 return isAdjacentNode(d.data.path, playerLocation) ? 1 : 0.5;
505 })
506 .on('mouseover', function(this: any, event: MouseEvent) {
507 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
508 if (d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
509 d3.select(this)
510 .transition()
511 .duration(ANIMATION_CONFIG.nodeHover.duration)
512 .attr('r', function() {
513 const currentR = d3.select(this).attr('r');
514 return currentR ? parseFloat(currentR) * 1.2 : NODE_CONFIG.sizes.regular.hover;
515 })
516 .style('filter', 'url(#glow) drop-shadow(0 0 8px rgba(0,0,0,0.4))');
517 }
518 })
519 .on('mouseout', function(this: any, event: MouseEvent) {
520 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
521 d3.select(this)
522 .transition()
523 .duration(ANIMATION_CONFIG.nodeHover.duration)
524 .attr('r', function() {
525 if (d.data.path === '/') return NODE_CONFIG.sizes.root.base;
526 if (d.data.path === playerLocation) return NODE_CONFIG.sizes.player.base;
527 if (d.data.has_mole) return NODE_CONFIG.sizes.mole.base;
528 return NODE_CONFIG.sizes.regular.base;
529 })
530 .style('filter', d.data.path === playerLocation ? 'url(#glow)' : 'url(#drop-shadow)');
531 })
532 .on('click', function(this: any, event: MouseEvent) {
533 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
534 if (onNodeClick && d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
535 onNodeClick(d.data.path);
536 }
537 });
538
539 // Add pulse animation to mole nodes
540 node.filter(d => d.data.has_mole)
541 .select('.mole-node')
542 .append('animate')
543 .attr('attributeName', 'opacity')
544 .attr('values', '0.7;1;0.7')
545 .attr('dur', ANIMATION_CONFIG.pulse.duration)
546 .attr('repeatCount', ANIMATION_CONFIG.pulse.repeatCount);
547
548 // Add labels with backgrounds
549 const labels = node
550 .append('g')
551 .attr('class', 'label-group');
552
553 // Label background
554 labels.append('rect')
555 .attr('class', 'label-bg')
556 .attr('fill', LABEL_CONFIG.background.fill)
557 .attr('rx', LABEL_CONFIG.background.radius)
558 .attr('ry', LABEL_CONFIG.background.radius)
559 .style('filter', 'drop-shadow(0 1px 2px rgba(0,0,0,0.2))');
560
561 // Label text
562 labels.append('text')
563 .attr('class', 'label-text')
564 .attr('dy', d => d.children ? LABEL_CONFIG.offset.parent : LABEL_CONFIG.offset.leaf)
565 .attr('text-anchor', 'middle')
566 .style('font-size', `${LABEL_CONFIG.fontSize}px`)
567 .style('font-weight', d => d.data.path === playerLocation ? LABEL_CONFIG.fontWeight.player : LABEL_CONFIG.fontWeight.base)
568 .style('fill', d => {
569 if (d.data.path === playerLocation) return LABEL_CONFIG.colors.player;
570 if (d.data.has_mole) return LABEL_CONFIG.colors.mole;
571 return LABEL_CONFIG.colors.regular;
572 })
573 .style('font-family', 'Comic Sans MS, cursive')
574 .text(d => d.data.name || '/')
575 .style('pointer-events', 'none');
576
577 // Size backgrounds to fit text
578 labels.each(function() {
579 const labelGroup = d3.select(this);
580 const text = labelGroup.select('.label-text');
581 const bg = labelGroup.select('.label-bg');
582
583 const bbox = (text.node() as SVGTextElement).getBBox();
584
585 bg.attr('x', bbox.x - LABEL_CONFIG.background.padding.x)
586 .attr('y', bbox.y - LABEL_CONFIG.background.padding.y)
587 .attr('width', bbox.width + LABEL_CONFIG.background.padding.x * 2)
588 .attr('height', bbox.height + LABEL_CONFIG.background.padding.y * 2);
589 });
590
591 // Add icon images for special directories
592 node.each(function(d) {
593 const nodeEl = d3.select(this);
594 let iconPath = null;
595
596 // Map paths to icon files
597 if (d.data.path === '/home') iconPath = '/icons/home.svg';
598 else if (d.data.path === '/tmp') iconPath = '/icons/trash.svg';
599 else if (d.data.path === '/etc') iconPath = '/icons/config.svg';
600 else if (d.data.path === '/bin' || d.data.path === '/sbin') iconPath = '/icons/terminal.svg';
601 else if (d.data.path === '/var') iconPath = '/icons/database.svg';
602 else if (d.data.path === '/usr') iconPath = '/icons/folder.svg';
603 else if (d.data.path === '/opt') iconPath = '/icons/package.svg';
604 else if (d.data.path.includes('Documents')) iconPath = '/icons/document.svg';
605 else if (d.data.path.includes('Pictures')) iconPath = '/icons/picture.svg';
606 else if (d.data.path.includes('Downloads')) iconPath = '/icons/download.svg';
607 else if (d.data.path.includes('Desktop')) iconPath = '/icons/desktop.svg';
608
609 if (iconPath) {
610 const iconSize = 24;
611 nodeEl.append('image')
612 .attr('class', 'directory-icon')
613 .attr('xlink:href', iconPath)
614 .attr('width', iconSize)
615 .attr('height', iconSize)
616 .attr('x', -iconSize / 2)
617 .attr('y', -iconSize / 2)
618 .style('pointer-events', 'none')
619 .style('opacity', 0.8)
620 .on('mouseover', function() {
621 d3.select(this)
622 .transition()
623 .duration(200)
624 .style('opacity', 1)
625 .attr('transform', 'scale(1.2)');
626 })
627 .on('mouseout', function() {
628 d3.select(this)
629 .transition()
630 .duration(200)
631 .style('opacity', 0.8)
632 .attr('transform', 'scale(1)');
633 });
634 }
635 });
636
637 // Add player and mole indicators
638 const playerNode = treeNodes.descendants().find(d => d.data.path === playerLocation);
639 if (playerNode) {
640 const playerGroup = node
641 .filter(d => d.data.path === playerLocation)
642 .append('g');
643
644 playerGroup
645 .append('image')
646 .attr('xlink:href', ICON_CONFIG.paths.player)
647 .attr('width', ICON_CONFIG.size)
648 .attr('height', ICON_CONFIG.size)
649 .attr('x', ICON_CONFIG.offset)
650 .attr('y', ICON_CONFIG.offset)
651 .style('pointer-events', 'none')
652 .style('filter', 'drop-shadow(0 0 6px rgba(59, 130, 246, 0.8))');
653 }
654
655 const moleNode = treeNodes.descendants().find(d => d.data.has_mole);
656 if (moleNode) {
657 const moleGroup = node
658 .filter(d => d.data.has_mole)
659 .append('g');
660
661 // Celebration rings
662 for (let i = 0; i < 3; i++) {
663 moleGroup
664 .append('circle')
665 .attr('r', 15)
666 .style('fill', 'none')
667 .style('stroke', NODE_CONFIG.colors.mole.pulse)
668 .style('stroke-width', 2)
669 .style('opacity', 0)
670 .transition()
671 .delay(i * 300)
672 .duration(1500)
673 .ease(d3.easeQuadOut)
674 .attr('r', 40)
675 .style('opacity', 0)
676 .on('end', function repeat() {
677 d3.select(this)
678 .attr('r', 15)
679 .style('opacity', 0)
680 .transition()
681 .duration(1500)
682 .ease(d3.easeQuadOut)
683 .attr('r', 40)
684 .style('opacity', 0)
685 .on('end', repeat);
686 });
687 }
688
689 moleGroup
690 .append('image')
691 .attr('xlink:href', ICON_CONFIG.paths.mole)
692 .attr('width', ICON_CONFIG.size)
693 .attr('height', ICON_CONFIG.size)
694 .attr('x', ICON_CONFIG.offset)
695 .attr('y', ICON_CONFIG.offset)
696 .style('pointer-events', 'none')
697 .style('filter', 'drop-shadow(0 0 6px rgba(239, 68, 68, 0.8))')
698 .classed('mole-death', moleKilled);
699 }
700
701 // Zoom behavior
702 const zoom = d3.zoom<SVGSVGElement, unknown>()
703 .scaleExtent(ZOOM_CONFIG.scaleExtent)
704 .on('zoom', (event) => {
705 g.attr('transform', event.transform);
706 });
707
708 svg.call(zoom);
709
710 // Zoom transform helper
711 const getZoomTransform = (node: d3.HierarchyPointNode<TreeNode>, scale: number, offsetX: number = 0, offsetY: number = 0) => {
712 const viewBoxCenterX = width / 2;
713 const viewBoxCenterY = height / 2;
714
715 const translateX = viewBoxCenterX - (node.x + margin.left) * scale + (width * offsetX);
716 const translateY = viewBoxCenterY - (node.y + margin.top) * scale + (height * offsetY);
717
718 return d3.zoomIdentity.translate(translateX, translateY).scale(scale);
719 };
720
721 // Handle intro and navigation animations
722 if (playIntro && playerNode) {
723 const rootTransform = getZoomTransform(treeNodes, ZOOM_CONFIG.defaultScale);
724 svg.call(zoom.transform, rootTransform);
725
726 const allNodes = treeNodes.descendants();
727 const xExtent = d3.extent(allNodes, d => d.x) as [number, number];
728 const yExtent = d3.extent(allNodes, d => d.y) as [number, number];
729
730 const treeWidth = xExtent[1] - xExtent[0] + ZOOM_CONFIG.treePadding;
731 const treeHeight = yExtent[1] - yExtent[0] + ZOOM_CONFIG.treePadding;
732
733 const scaleX = (width - margin.left - margin.right) / treeWidth;
734 const scaleY = (height - margin.top - margin.bottom) / treeHeight;
735 const fullTreeScale = Math.min(scaleX, scaleY, ZOOM_CONFIG.fullTreeScale);
736
737 const treeCenterX = (xExtent[0] + xExtent[1]) / 2;
738 const treeCenterY = (yExtent[0] + yExtent[1]) / 2;
739 const treeCenter = { x: treeCenterX, y: treeCenterY } as d3.HierarchyPointNode<TreeNode>;
740 const fullTreeTransform = getZoomTransform(treeCenter, fullTreeScale);
741
742 const moleTransform = moleNode ? getZoomTransform(moleNode, ZOOM_CONFIG.defaultScale) : null;
743 const partialTreeTransform = getZoomTransform(treeCenter, ZOOM_CONFIG.partialTreeScale);
744 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
745 ZOOM_CONFIG.nudgeOffset.x,
746 ZOOM_CONFIG.nudgeOffset.y);
747
748 const phases = ANIMATION_CONFIG.intro.phases;
749
750 if (moleTransform) {
751 isAnimatingRef.current = true;
752
753 svg.transition()
754 .duration(phases[0].duration)
755 .call(zoom.transform, rootTransform)
756 .transition()
757 .duration(phases[1].duration)
758 .ease(phases[1].easing!)
759 .call(zoom.transform, fullTreeTransform)
760 .transition()
761 .duration(phases[2].duration)
762 .call(zoom.transform, fullTreeTransform)
763 .transition()
764 .duration(phases[3].duration)
765 .ease(phases[3].easing!)
766 .call(zoom.transform, moleTransform)
767 .transition()
768 .duration(phases[4].duration)
769 .call(zoom.transform, moleTransform)
770 .transition()
771 .duration(phases[5].duration)
772 .ease(phases[5].easing!)
773 .call(zoom.transform, partialTreeTransform)
774 .transition()
775 .duration(phases[6].duration)
776 .ease(phases[6].easing!)
777 .call(zoom.transform, playerTransform)
778 .on('end', () => {
779 isAnimatingRef.current = false;
780 });
781 } else {
782 isAnimatingRef.current = true;
783
784 svg.transition()
785 .duration(phases[0].duration)
786 .call(zoom.transform, rootTransform)
787 .transition()
788 .duration(phases[1].duration)
789 .ease(phases[1].easing!)
790 .call(zoom.transform, fullTreeTransform)
791 .transition()
792 .duration(phases[2].duration)
793 .call(zoom.transform, fullTreeTransform)
794 .transition()
795 .duration(phases[6].duration)
796 .ease(phases[6].easing!)
797 .call(zoom.transform, playerTransform)
798 .on('end', () => {
799 isAnimatingRef.current = false;
800 });
801 }
802 } else if (playerNode && !isAnimatingRef.current) {
803 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
804 ZOOM_CONFIG.nudgeOffset.x,
805 ZOOM_CONFIG.nudgeOffset.y);
806
807 if (isNavigation) {
808 svg.transition()
809 .duration(ANIMATION_CONFIG.navigation.duration)
810 .ease(ANIMATION_CONFIG.navigation.easing)
811 .call(zoom.transform, playerTransform);
812 } else {
813 svg.call(zoom.transform, playerTransform);
814 }
815 }
816 // eslint-disable-next-line react-hooks/exhaustive-deps
817 }, [treeData, playerLocation, onNodeClick, playIntro, isDarkMode, moleKilled]);
818
819 return (
820 <div ref={containerRef} className="w-full h-full">
821 <svg ref={svgRef} className="w-full h-full" />
822 </div>
823 );
824 };
825
826 export default TreeVisualizer;