TypeScript · 6820 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 }
13
14 const TreeVisualizer: React.FC<TreeVisualizerProps> = ({
15 treeData,
16 playerLocation,
17 onNodeClick,
18 }) => {
19 const svgRef = useRef<SVGSVGElement>(null);
20
21 useEffect(() => {
22 if (!treeData || !svgRef.current) return;
23
24 // Clear previous render
25 d3.select(svgRef.current).selectAll('*').remove();
26
27 const width = 1200;
28 const height = 800;
29 const margin = { top: 40, right: 120, bottom: 40, left: 120 };
30
31 const svg = d3
32 .select(svgRef.current)
33 .attr('viewBox', `0 0 ${width} ${height}`)
34 .attr('width', '100%')
35 .attr('height', '100%');
36
37 const g = svg
38 .append('g')
39 .attr('transform', `translate(${margin.left},${margin.top})`);
40
41 // Create tree layout
42 const treeLayout = d3
43 .tree<TreeNode>()
44 .size([height - margin.top - margin.bottom, width - margin.left - margin.right])
45 .separation((a, b) => (a.parent === b.parent ? 1 : 1.5));
46
47 // Create hierarchy
48 const root = d3.hierarchy(treeData);
49 const treeNodes = treeLayout(root);
50
51 // Create gradient for links
52 const gradient = svg.append('defs')
53 .append('linearGradient')
54 .attr('id', 'link-gradient')
55 .attr('gradientUnits', 'userSpaceOnUse');
56
57 gradient.append('stop')
58 .attr('offset', '0%')
59 .attr('stop-color', '#E5E7EB');
60
61 gradient.append('stop')
62 .attr('offset', '100%')
63 .attr('stop-color', '#9CA3AF');
64
65 // Create links with curved paths
66 const link = g
67 .selectAll('.link')
68 .data(treeNodes.links())
69 .enter()
70 .append('path')
71 .attr('class', 'link')
72 .attr('d', d3.linkHorizontal<any, any>()
73 .x(d => d.y)
74 .y(d => d.x))
75 .style('fill', 'none')
76 .style('stroke', 'url(#link-gradient)')
77 .style('stroke-width', 2)
78 .style('opacity', 0.6);
79
80 // Create node groups
81 const node = g
82 .selectAll('.node')
83 .data(treeNodes.descendants())
84 .enter()
85 .append('g')
86 .attr('class', 'node')
87 .attr('transform', d => `translate(${d.y},${d.x})`);
88
89 // Add circles for nodes with better styling
90 node
91 .append('circle')
92 .attr('r', d => {
93 if (d.data.path === '/') return 12; // Root is larger
94 if (d.data.path === playerLocation) return 10;
95 return 8;
96 })
97 .style('fill', d => {
98 if (d.data.path === playerLocation) return '#3B82F6'; // Player location - blue
99 if (d.data.has_mole) return '#EF4444'; // Mole location - red (only shown after win)
100 if (d.data.is_fhs) return '#8B5CF6'; // FHS standard - purple
101 return '#10B981'; // Generated directories - green
102 })
103 .style('stroke', d => {
104 if (d.data.path === playerLocation) return '#1E40AF';
105 if (d.data.has_mole) return '#991B1B';
106 return '#ffffff';
107 })
108 .style('stroke-width', 2)
109 .style('cursor', 'pointer')
110 .style('filter', d => d.data.path === playerLocation ? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))' : 'none')
111 .on('mouseover', function(event, d) {
112 d3.select(this)
113 .transition()
114 .duration(200)
115 .attr('r', d.data.path === '/' ? 14 : 10);
116 })
117 .on('mouseout', function(event, d) {
118 d3.select(this)
119 .transition()
120 .duration(200)
121 .attr('r', d.data.path === '/' ? 12 : d.data.path === playerLocation ? 10 : 8);
122 })
123 .on('click', (event, d) => {
124 if (onNodeClick) {
125 onNodeClick(d.data.path);
126 }
127 });
128
129 // Add tooltips
130 node
131 .append('title')
132 .text(d => `${d.data.path}\n${d.data.description}\n${d.data.has_mole ? '🐭 Mole is here!' : ''}`);
133
134 // Add labels with better positioning
135 node
136 .append('text')
137 .attr('dy', '.35em')
138 .attr('x', d => d.children ? -13 : 13)
139 .style('text-anchor', d => d.children ? 'end' : 'start')
140 .style('font-size', '13px')
141 .style('font-weight', d => d.data.path === playerLocation ? '600' : '400')
142 .style('fill', d => d.data.path === playerLocation ? '#1E40AF' : '#374151')
143 .text(d => d.data.name || '/')
144 .style('pointer-events', 'none');
145
146 // Add player indicator emoji
147 const playerNode = treeNodes.descendants().find(d => d.data.path === playerLocation);
148 if (playerNode) {
149 node
150 .filter(d => d.data.path === playerLocation)
151 .append('text')
152 .attr('dy', -20)
153 .attr('text-anchor', 'middle')
154 .style('font-size', '20px')
155 .text('🧑‍💻');
156 }
157
158 // Add mole indicator if game is won
159 const moleNode = treeNodes.descendants().find(d => d.data.has_mole);
160 if (moleNode) {
161 node
162 .filter(d => d.data.has_mole)
163 .append('text')
164 .attr('dy', -20)
165 .attr('text-anchor', 'middle')
166 .style('font-size', '20px')
167 .text('🐭');
168 }
169
170 // Add zoom and pan behavior
171 const zoom = d3.zoom<SVGSVGElement, unknown>()
172 .scaleExtent([0.3, 3])
173 .on('zoom', (event) => {
174 g.attr('transform', event.transform);
175 });
176
177 svg.call(zoom);
178
179 // Center on player location initially
180 if (playerNode) {
181 const scale = 0.8;
182 const x = width / 2 - playerNode.y * scale;
183 const y = height / 2 - playerNode.x * scale;
184
185 svg.call(
186 zoom.transform,
187 d3.zoomIdentity.translate(x, y).scale(scale)
188 );
189 }
190
191 // Add legend
192 const legend = svg.append('g')
193 .attr('transform', `translate(20, ${height - 100})`);
194
195 const legendItems = [
196 { color: '#3B82F6', label: 'You are here' },
197 { color: '#8B5CF6', label: 'System (FHS)' },
198 { color: '#10B981', label: 'User directories' },
199 { color: '#EF4444', label: 'Mole location', show: !!moleNode },
200 ];
201
202 legendItems.forEach((item, i) => {
203 if (item.show === false) return;
204
205 const legendItem = legend.append('g')
206 .attr('transform', `translate(0, ${i * 25})`);
207
208 legendItem.append('circle')
209 .attr('r', 6)
210 .style('fill', item.color);
211
212 legendItem.append('text')
213 .attr('x', 15)
214 .attr('y', 5)
215 .style('font-size', '12px')
216 .style('fill', '#6B7280')
217 .text(item.label);
218 });
219
220 }, [treeData, playerLocation, onNodeClick]);
221
222 return (
223 <div className="w-full h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg shadow-inner overflow-hidden">
224 <svg ref={svgRef} className="w-full h-full" />
225 </div>
226 );
227 };
228
229 export default TreeVisualizer;