TypeScript · 29809 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: '#0F172A', // Always dark mode color
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: '#475569', // Always dark mode colors
130 hover: '#64748B',
131 adjacent: '#3B82F6'
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: '#93C5FD', // Always dark mode colors
141 regular: '#E5E7EB',
142 mole: '#DC2626'
143 },
144 background: {
145 fill: 'rgba(15, 23, 42, 0.9)', // Always dark mode background
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 // Create a background group that won't be affected by zoom
242 const bgGroup = svg.append('g').attr('class', 'background-group');
243
244 // Quirky background that covers the entire viewport
245 bgGroup.append('rect')
246 .attr('x', -width)
247 .attr('y', -height)
248 .attr('width', width * 3)
249 .attr('height', height * 3)
250 .attr('fill', LAYOUT_CONFIG.background.color)
251 .style('opacity', LAYOUT_CONFIG.background.opacity);
252
253 // Add floating background particles across a larger area
254 const particlesGroup = bgGroup.append('g').attr('class', 'particles');
255
256 // Create particles across a much larger area to ensure coverage
257 const particleCount = PARTICLE_CONFIG.count * 5;
258
259 for (let i = 0; i < particleCount; i++) {
260 const startX = (Math.random() - 0.5) * width * 3;
261 const particle = particlesGroup.append('circle')
262 .attr('cx', startX)
263 .attr('cy', Math.random() * height * 3 - height)
264 .attr('r', Math.random() * (PARTICLE_CONFIG.size.max - PARTICLE_CONFIG.size.min) + PARTICLE_CONFIG.size.min)
265 .attr('fill', PARTICLE_CONFIG.colors[Math.floor(Math.random() * PARTICLE_CONFIG.colors.length)])
266 .attr('opacity', 0.3);
267
268 // Animate particles floating
269 const duration = Math.random() * (PARTICLE_CONFIG.speed.max - PARTICLE_CONFIG.speed.min) + PARTICLE_CONFIG.speed.min;
270 particle
271 .transition()
272 .duration(duration)
273 .ease(d3.easeLinear)
274 .attr('cy', -height)
275 .on('end', function repeat() {
276 d3.select(this)
277 .attr('cy', height * 2)
278 .attr('cx', (Math.random() - 0.5) * width * 3)
279 .transition()
280 .duration(duration)
281 .ease(d3.easeLinear)
282 .attr('cy', -height)
283 .on('end', repeat);
284 });
285 }
286
287 const g = svg
288 .append('g')
289 .attr('transform', `translate(${margin.left},${margin.top})`);
290
291 // Create tree layout
292 const treeLayout = d3
293 .tree<TreeNode>()
294 .size([width - margin.left - margin.right, height - margin.top - margin.bottom])
295 .separation((a, b) => {
296 const aParentChildCount = a.parent ? (a.parent.children?.length || 0) : 0;
297 // const bParentChildCount = b.parent ? (b.parent.children?.length || 0) : 0;
298
299 if (a.parent === b.parent && aParentChildCount > 3) {
300 const aIsLeaf = !a.children || a.children.length === 0;
301 const bIsLeaf = !b.children || b.children.length === 0;
302
303 if (aIsLeaf && bIsLeaf) {
304 return 2.5;
305 }
306 return 2;
307 }
308
309 if (a.depth === 0 || b.depth === 0) return 4;
310 if (a.depth === 1 || b.depth === 1) return 3;
311
312 const aIsLeaf = !a.children || a.children.length === 0;
313 const bIsLeaf = !b.children || b.children.length === 0;
314
315 if (aIsLeaf && bIsLeaf) {
316 return 1.5;
317 }
318 return a.parent === b.parent ? 1.5 : 2;
319 });
320
321 // Apply tree layout
322 const treeNodes = treeLayout(root);
323
324 // Center the root
325 const rootX = width / 2;
326 treeNodes.each((d) => {
327 d.x = d.x + (rootX - treeNodes.x);
328 });
329
330 // Helper function to check if a node is adjacent
331 const isAdjacentNode = (nodePath: string, currentPath: string): boolean => {
332 if (currentPath !== '/') {
333 const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
334 if (nodePath === parentPath) return true;
335 }
336
337 if (currentPath === '/') {
338 const segments = nodePath.split('/').filter(s => s);
339 if (segments.length === 1) return true;
340 } else {
341 if (nodePath.startsWith(currentPath + '/')) {
342 const relativePath = nodePath.substring(currentPath.length + 1);
343 if (!relativePath.includes('/')) return true;
344 }
345 }
346
347 return false;
348 };
349
350 // Create quirky curved links
351 // eslint-disable-next-line @typescript-eslint/no-explicit-any
352 const linkGenerator = d3.linkVertical<any, any>()
353 .x(d => d.x)
354 .y(d => d.y)
355 .source(d => {
356 // Add some wobble to the source point
357 const wobbleX = Math.sin(Date.now() / NODE_CONFIG.wobble.speed) * NODE_CONFIG.wobble.amount;
358 return { x: d.source.x + wobbleX, y: d.source.y };
359 })
360 .target(d => d.target);
361
362 const link = g
363 .selectAll('.link')
364 .data(treeNodes.links())
365 .enter()
366 .append('path')
367 .attr('class', 'link')
368 .attr('d', linkGenerator)
369 .style('fill', 'none')
370 .style('stroke', d => {
371 const targetPath = (d.target as d3.HierarchyPointNode<TreeNode>).data.path;
372 if (isAdjacentNode(targetPath, playerLocation) || targetPath === playerLocation) {
373 return LINK_CONFIG.colors.adjacent;
374 }
375 return LINK_CONFIG.colors.default;
376 })
377 .style('stroke-width', LINK_CONFIG.strokeWidth)
378 .style('stroke-dasharray', d => {
379 const targetPath = (d.target as d3.HierarchyPointNode<TreeNode>).data.path;
380 if (targetPath === playerLocation) return 'none';
381 return LINK_CONFIG.dashArray;
382 })
383 .style('opacity', LINK_CONFIG.opacity)
384 .style('filter', 'drop-shadow(0 0 3px rgba(0,0,0,0.3))');
385
386 // Animate link dashes
387 link
388 .style('stroke-dashoffset', 0)
389 .transition()
390 .duration(20000)
391 .ease(d3.easeLinear)
392 .style('stroke-dashoffset', -100)
393 .on('end', function repeat() {
394 d3.select(this)
395 .style('stroke-dashoffset', 0)
396 .transition()
397 .duration(20000)
398 .ease(d3.easeLinear)
399 .style('stroke-dashoffset', -100)
400 .on('end', repeat);
401 });
402
403 // Create node groups
404 const node = g
405 .selectAll('.node')
406 .data(treeNodes.descendants())
407 .enter()
408 .append('g')
409 .attr('class', 'node')
410 .attr('transform', d => `translate(${d.x},${d.y})`);
411
412 // Add subtle wobble animation to all nodes
413 node.each(function(d, i) {
414 const nodeGroup = d3.select(this);
415 const delay = i * 100;
416
417 nodeGroup
418 .transition()
419 .delay(delay)
420 .duration(NODE_CONFIG.wobble.speed)
421 .ease(d3.easeSinInOut)
422 .attr('transform', `translate(${d.x + NODE_CONFIG.wobble.amount},${d.y})`)
423 .transition()
424 .duration(NODE_CONFIG.wobble.speed)
425 .ease(d3.easeSinInOut)
426 .attr('transform', `translate(${d.x - NODE_CONFIG.wobble.amount},${d.y})`)
427 .on('end', function repeat() {
428 d3.select(this)
429 .transition()
430 .duration(NODE_CONFIG.wobble.speed)
431 .ease(d3.easeSinInOut)
432 .attr('transform', `translate(${d.x + NODE_CONFIG.wobble.amount},${d.y})`)
433 .transition()
434 .duration(NODE_CONFIG.wobble.speed)
435 .ease(d3.easeSinInOut)
436 .attr('transform', `translate(${d.x - NODE_CONFIG.wobble.amount},${d.y})`)
437 .on('end', repeat);
438 });
439 });
440
441 // Add node backgrounds (quirky shapes)
442 node.each(function(d) {
443 const nodeEl = d3.select(this);
444 const isRoot = d.data.path === '/';
445 const isPlayer = d.data.path === playerLocation;
446 const hasMole = d.data.has_mole;
447
448 if (isRoot) {
449 // Star shape for root
450 const starPoints = 8;
451 const outerRadius = NODE_CONFIG.sizes.root.base;
452 const innerRadius = outerRadius * 0.6;
453
454 let path = '';
455 for (let i = 0; i < starPoints * 2; i++) {
456 const angle = (i * Math.PI) / starPoints;
457 const radius = i % 2 === 0 ? outerRadius : innerRadius;
458 const x = Math.cos(angle) * radius;
459 const y = Math.sin(angle) * radius;
460 path += `${i === 0 ? 'M' : 'L'} ${x},${y}`;
461 }
462 path += 'Z';
463
464 nodeEl.append('path')
465 .attr('d', path)
466 .attr('class', 'node-shape')
467 .style('fill', NODE_CONFIG.colors.root.fill)
468 .style('stroke', NODE_CONFIG.colors.root.stroke)
469 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
470 .style('filter', 'url(#drop-shadow)');
471 } else if (hasMole) {
472 // Irregular shape for mole locations
473 const size = NODE_CONFIG.sizes.mole.base;
474 nodeEl.append('path')
475 .attr('d', `M ${-size},0 Q ${-size/2},${-size} 0,${-size} T ${size},0 Q ${size/2},${size} 0,${size} T ${-size},0`)
476 .attr('class', 'node-shape mole-node')
477 .style('fill', NODE_CONFIG.colors.mole.fill)
478 .style('stroke', NODE_CONFIG.colors.mole.stroke)
479 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
480 .style('filter', 'url(#glow)');
481 } else {
482 // Regular circles with personality
483 nodeEl.append('circle')
484 .attr('r', () => {
485 if (isPlayer) return NODE_CONFIG.sizes.player.base;
486 if (d.data.is_fhs) return NODE_CONFIG.sizes.regular.base + 2;
487 return NODE_CONFIG.sizes.regular.base;
488 })
489 .attr('class', 'node-shape')
490 .style('fill', () => {
491 if (isPlayer) return NODE_CONFIG.colors.player.fill;
492 if (d.data.is_fhs) return `url(#${NODE_CONFIG.colors.fhs.pattern})`;
493 return NODE_CONFIG.colors.regular.fill;
494 })
495 .style('stroke', () => {
496 if (isPlayer) return NODE_CONFIG.colors.player.stroke;
497 if (d.data.is_fhs) return NODE_CONFIG.colors.fhs.stroke;
498 return NODE_CONFIG.colors.regular.stroke;
499 })
500 .style('stroke-width', NODE_CONFIG.strokeWidth.base)
501 .style('filter', isPlayer ? 'url(#glow)' : 'url(#drop-shadow)');
502 }
503 });
504
505 // Add interactivity
506 node.selectAll('.node-shape')
507 // eslint-disable-next-line @typescript-eslint/no-explicit-any
508 .style('cursor', function(this: any) {
509 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
510 if (d.data.path === playerLocation) return 'default';
511 return isAdjacentNode(d.data.path, playerLocation) ? 'pointer' : 'not-allowed';
512 })
513 // eslint-disable-next-line @typescript-eslint/no-explicit-any
514 .style('opacity', function(this: any) {
515 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
516 if (d.data.path === playerLocation) return 1;
517 return isAdjacentNode(d.data.path, playerLocation) ? 1 : 0.5;
518 })
519 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
520 .on('mouseover', function(this: any, event: MouseEvent) {
521 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
522 if (d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
523 d3.select(this)
524 .transition()
525 .duration(ANIMATION_CONFIG.nodeHover.duration)
526 .attr('r', function() {
527 const currentR = d3.select(this).attr('r');
528 return currentR ? parseFloat(currentR) * 1.2 : NODE_CONFIG.sizes.regular.hover;
529 })
530 .style('filter', 'url(#glow) drop-shadow(0 0 8px rgba(0,0,0,0.4))');
531 }
532 })
533 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
534 .on('mouseout', function(this: any, event: MouseEvent) {
535 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
536 d3.select(this)
537 .transition()
538 .duration(ANIMATION_CONFIG.nodeHover.duration)
539 .attr('r', function() {
540 if (d.data.path === '/') return NODE_CONFIG.sizes.root.base;
541 if (d.data.path === playerLocation) return NODE_CONFIG.sizes.player.base;
542 if (d.data.has_mole) return NODE_CONFIG.sizes.mole.base;
543 return NODE_CONFIG.sizes.regular.base;
544 })
545 .style('filter', d.data.path === playerLocation ? 'url(#glow)' : 'url(#drop-shadow)');
546 })
547 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
548 .on('click', function(this: any, event: MouseEvent) {
549 const d = d3.select(this.parentNode).datum() as d3.HierarchyPointNode<TreeNode>;
550 if (onNodeClick && d.data.path !== playerLocation && isAdjacentNode(d.data.path, playerLocation)) {
551 onNodeClick(d.data.path);
552 }
553 });
554
555 // Add pulse animation to mole nodes
556 node.filter(d => d.data.has_mole)
557 .select('.mole-node')
558 .append('animate')
559 .attr('attributeName', 'opacity')
560 .attr('values', '0.7;1;0.7')
561 .attr('dur', ANIMATION_CONFIG.pulse.duration)
562 .attr('repeatCount', ANIMATION_CONFIG.pulse.repeatCount);
563
564 // Add labels with backgrounds
565 const labels = node
566 .append('g')
567 .attr('class', 'label-group');
568
569 // Label background
570 labels.append('rect')
571 .attr('class', 'label-bg')
572 .attr('fill', LABEL_CONFIG.background.fill)
573 .attr('rx', LABEL_CONFIG.background.radius)
574 .attr('ry', LABEL_CONFIG.background.radius)
575 .style('filter', 'drop-shadow(0 1px 2px rgba(0,0,0,0.2))');
576
577 // Label text
578 labels.append('text')
579 .attr('class', 'label-text')
580 .attr('dy', d => d.children ? LABEL_CONFIG.offset.parent : LABEL_CONFIG.offset.leaf)
581 .attr('text-anchor', 'middle')
582 .style('font-size', `${LABEL_CONFIG.fontSize}px`)
583 .style('font-weight', d => d.data.path === playerLocation ? LABEL_CONFIG.fontWeight.player : LABEL_CONFIG.fontWeight.base)
584 .style('fill', d => {
585 if (d.data.path === playerLocation) return LABEL_CONFIG.colors.player;
586 if (d.data.has_mole) return LABEL_CONFIG.colors.mole;
587 return LABEL_CONFIG.colors.regular;
588 })
589 .style('font-family', 'Comic Sans MS, cursive')
590 .text(d => d.data.name || '/')
591 .style('pointer-events', 'none');
592
593 // Size backgrounds to fit text
594 labels.each(function() {
595 const labelGroup = d3.select(this);
596 const text = labelGroup.select('.label-text');
597 const bg = labelGroup.select('.label-bg');
598
599 const bbox = (text.node() as SVGTextElement).getBBox();
600
601 bg.attr('x', bbox.x - LABEL_CONFIG.background.padding.x)
602 .attr('y', bbox.y - LABEL_CONFIG.background.padding.y)
603 .attr('width', bbox.width + LABEL_CONFIG.background.padding.x * 2)
604 .attr('height', bbox.height + LABEL_CONFIG.background.padding.y * 2);
605 });
606
607 // Add icon images for special directories
608 node.each(function(d) {
609 const nodeEl = d3.select(this);
610 let iconPath = null;
611
612 // Map paths to icon files
613 if (d.data.path === '/home') iconPath = '/icons/home.svg';
614 else if (d.data.path === '/tmp') iconPath = '/icons/trash.svg';
615 else if (d.data.path === '/etc') iconPath = '/icons/config.svg';
616 else if (d.data.path === '/bin' || d.data.path === '/sbin') iconPath = '/icons/terminal.svg';
617 else if (d.data.path === '/var') iconPath = '/icons/database.svg';
618 else if (d.data.path === '/usr') iconPath = '/icons/folder.svg';
619 else if (d.data.path === '/opt') iconPath = '/icons/package.svg';
620 else if (d.data.path.includes('Documents')) iconPath = '/icons/document.svg';
621 else if (d.data.path.includes('Pictures')) iconPath = '/icons/picture.svg';
622 else if (d.data.path.includes('Downloads')) iconPath = '/icons/download.svg';
623 else if (d.data.path.includes('Desktop')) iconPath = '/icons/desktop.svg';
624
625 if (iconPath) {
626 const iconSize = 24;
627 nodeEl.append('image')
628 .attr('class', 'directory-icon')
629 .attr('xlink:href', iconPath)
630 .attr('width', iconSize)
631 .attr('height', iconSize)
632 .attr('x', -iconSize / 2)
633 .attr('y', -iconSize / 2)
634 .style('pointer-events', 'none')
635 .style('opacity', 0.8)
636 .on('mouseover', function() {
637 d3.select(this)
638 .transition()
639 .duration(200)
640 .style('opacity', 1)
641 .attr('transform', 'scale(1.2)');
642 })
643 .on('mouseout', function() {
644 d3.select(this)
645 .transition()
646 .duration(200)
647 .style('opacity', 0.8)
648 .attr('transform', 'scale(1)');
649 });
650 }
651 });
652
653 // Add player and mole indicators
654 const playerNode = treeNodes.descendants().find(d => d.data.path === playerLocation);
655 if (playerNode) {
656 const playerGroup = node
657 .filter(d => d.data.path === playerLocation)
658 .append('g');
659
660 playerGroup
661 .append('image')
662 .attr('xlink:href', ICON_CONFIG.paths.player)
663 .attr('width', ICON_CONFIG.size)
664 .attr('height', ICON_CONFIG.size)
665 .attr('x', ICON_CONFIG.offset)
666 .attr('y', ICON_CONFIG.offset)
667 .style('pointer-events', 'none')
668 .style('filter', 'drop-shadow(0 0 6px rgba(59, 130, 246, 0.8))');
669 }
670
671 const moleNode = treeNodes.descendants().find(d => d.data.has_mole);
672 if (moleNode) {
673 const moleGroup = node
674 .filter(d => d.data.has_mole)
675 .append('g');
676
677 // Celebration rings
678 for (let i = 0; i < 3; i++) {
679 moleGroup
680 .append('circle')
681 .attr('r', 15)
682 .style('fill', 'none')
683 .style('stroke', NODE_CONFIG.colors.mole.pulse)
684 .style('stroke-width', 2)
685 .style('opacity', 0)
686 .transition()
687 .delay(i * 300)
688 .duration(1500)
689 .ease(d3.easeQuadOut)
690 .attr('r', 40)
691 .style('opacity', 0)
692 .on('end', function repeat() {
693 d3.select(this)
694 .attr('r', 15)
695 .style('opacity', 0)
696 .transition()
697 .duration(1500)
698 .ease(d3.easeQuadOut)
699 .attr('r', 40)
700 .style('opacity', 0)
701 .on('end', repeat);
702 });
703 }
704
705 moleGroup
706 .append('image')
707 .attr('xlink:href', ICON_CONFIG.paths.mole)
708 .attr('width', ICON_CONFIG.size)
709 .attr('height', ICON_CONFIG.size)
710 .attr('x', ICON_CONFIG.offset)
711 .attr('y', ICON_CONFIG.offset)
712 .style('pointer-events', 'none')
713 .style('filter', 'drop-shadow(0 0 6px rgba(239, 68, 68, 0.8))')
714 .classed('mole-death', moleKilled);
715 }
716
717 // Zoom behavior
718 const zoom = d3.zoom<SVGSVGElement, unknown>()
719 .scaleExtent(ZOOM_CONFIG.scaleExtent)
720 .on('zoom', (event) => {
721 g.attr('transform', event.transform);
722 });
723
724 svg.call(zoom);
725
726 // Zoom transform helper
727 const getZoomTransform = (node: d3.HierarchyPointNode<TreeNode>, scale: number, offsetX: number = 0, offsetY: number = 0) => {
728 const viewBoxCenterX = width / 2;
729 const viewBoxCenterY = height / 2;
730
731 const translateX = viewBoxCenterX - (node.x + margin.left) * scale + (width * offsetX);
732 const translateY = viewBoxCenterY - (node.y + margin.top) * scale + (height * offsetY);
733
734 return d3.zoomIdentity.translate(translateX, translateY).scale(scale);
735 };
736
737 // Handle intro and navigation animations
738 if (playIntro && playerNode) {
739 const rootTransform = getZoomTransform(treeNodes, ZOOM_CONFIG.defaultScale);
740 svg.call(zoom.transform, rootTransform);
741
742 const allNodes = treeNodes.descendants();
743 const xExtent = d3.extent(allNodes, d => d.x) as [number, number];
744 const yExtent = d3.extent(allNodes, d => d.y) as [number, number];
745
746 const treeWidth = xExtent[1] - xExtent[0] + ZOOM_CONFIG.treePadding;
747 const treeHeight = yExtent[1] - yExtent[0] + ZOOM_CONFIG.treePadding;
748
749 const scaleX = (width - margin.left - margin.right) / treeWidth;
750 const scaleY = (height - margin.top - margin.bottom) / treeHeight;
751 const fullTreeScale = Math.min(scaleX, scaleY, ZOOM_CONFIG.fullTreeScale);
752
753 const treeCenterX = (xExtent[0] + xExtent[1]) / 2;
754 const treeCenterY = (yExtent[0] + yExtent[1]) / 2;
755 const treeCenter = { x: treeCenterX, y: treeCenterY } as d3.HierarchyPointNode<TreeNode>;
756 const fullTreeTransform = getZoomTransform(treeCenter, fullTreeScale);
757
758 const moleTransform = moleNode ? getZoomTransform(moleNode, ZOOM_CONFIG.defaultScale) : null;
759 const partialTreeTransform = getZoomTransform(treeCenter, ZOOM_CONFIG.partialTreeScale);
760 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
761 ZOOM_CONFIG.nudgeOffset.x,
762 ZOOM_CONFIG.nudgeOffset.y);
763
764 const phases = ANIMATION_CONFIG.intro.phases;
765
766 if (moleTransform) {
767 isAnimatingRef.current = true;
768
769 svg.transition()
770 .duration(phases[0].duration)
771 .call(zoom.transform, rootTransform)
772 .transition()
773 .duration(phases[1].duration)
774 .ease(phases[1].easing!)
775 .call(zoom.transform, fullTreeTransform)
776 .transition()
777 .duration(phases[2].duration)
778 .call(zoom.transform, fullTreeTransform)
779 .transition()
780 .duration(phases[3].duration)
781 .ease(phases[3].easing!)
782 .call(zoom.transform, moleTransform)
783 .transition()
784 .duration(phases[4].duration)
785 .call(zoom.transform, moleTransform)
786 .transition()
787 .duration(phases[5].duration)
788 .ease(phases[5].easing!)
789 .call(zoom.transform, partialTreeTransform)
790 .transition()
791 .duration(phases[6].duration)
792 .ease(phases[6].easing!)
793 .call(zoom.transform, playerTransform)
794 .on('end', () => {
795 isAnimatingRef.current = false;
796 });
797 } else {
798 isAnimatingRef.current = true;
799
800 svg.transition()
801 .duration(phases[0].duration)
802 .call(zoom.transform, rootTransform)
803 .transition()
804 .duration(phases[1].duration)
805 .ease(phases[1].easing!)
806 .call(zoom.transform, fullTreeTransform)
807 .transition()
808 .duration(phases[2].duration)
809 .call(zoom.transform, fullTreeTransform)
810 .transition()
811 .duration(phases[6].duration)
812 .ease(phases[6].easing!)
813 .call(zoom.transform, playerTransform)
814 .on('end', () => {
815 isAnimatingRef.current = false;
816 });
817 }
818 } else if (playerNode && !isAnimatingRef.current) {
819 const playerTransform = getZoomTransform(playerNode, ZOOM_CONFIG.defaultScale,
820 ZOOM_CONFIG.nudgeOffset.x,
821 ZOOM_CONFIG.nudgeOffset.y);
822
823 if (isNavigation) {
824 svg.transition()
825 .duration(ANIMATION_CONFIG.navigation.duration)
826 .ease(ANIMATION_CONFIG.navigation.easing)
827 .call(zoom.transform, playerTransform);
828 } else {
829 svg.call(zoom.transform, playerTransform);
830 }
831 }
832 // eslint-disable-next-line react-hooks/exhaustive-deps
833 }, [treeData, playerLocation, onNodeClick, playIntro, isDarkMode, moleKilled]);
834
835 return (
836 <div ref={containerRef} className="w-full h-full">
837 <svg ref={svgRef} className="w-full h-full" />
838 </div>
839 );
840 };
841
842 export default TreeVisualizer;