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