@@ -17,65 +17,113 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 17 | 17 | onNodeClick, |
| 18 | 18 | }) => { |
| 19 | 19 | const svgRef = useRef<SVGSVGElement>(null); |
| 20 | + const containerRef = useRef<HTMLDivElement>(null); |
| 20 | 21 | |
| 21 | 22 | useEffect(() => { |
| 22 | | - if (!treeData || !svgRef.current) return; |
| 23 | + if (!treeData || !svgRef.current || !containerRef.current) return; |
| 23 | 24 | |
| 24 | 25 | // Clear previous render |
| 25 | 26 | d3.select(svgRef.current).selectAll('*').remove(); |
| 26 | 27 | |
| 27 | | - const width = 1200; |
| 28 | | - const height = 800; |
| 29 | | - const margin = { top: 40, right: 120, bottom: 40, left: 120 }; |
| 28 | + // Get container dimensions |
| 29 | + const containerWidth = containerRef.current.clientWidth; |
| 30 | + const containerHeight = containerRef.current.clientHeight; |
| 31 | + |
| 32 | + // Set up dimensions with dynamic sizing |
| 33 | + const margin = { top: 100, right: 50, bottom: 100, left: 50 }; |
| 34 | + const width = Math.max(containerWidth, 1600); |
| 35 | + const height = Math.max(containerHeight, 1200); |
| 30 | 36 | |
| 31 | 37 | const svg = d3 |
| 32 | 38 | .select(svgRef.current) |
| 39 | + .attr('width', containerWidth) |
| 40 | + .attr('height', containerHeight) |
| 33 | 41 | .attr('viewBox', `0 0 ${width} ${height}`) |
| 42 | + .attr('preserveAspectRatio', 'xMidYMid meet'); |
| 43 | + |
| 44 | + // Add a subtle grid pattern background |
| 45 | + const defs = svg.append('defs'); |
| 46 | + |
| 47 | + const pattern = defs.append('pattern') |
| 48 | + .attr('id', 'grid') |
| 49 | + .attr('width', 40) |
| 50 | + .attr('height', 40) |
| 51 | + .attr('patternUnits', 'userSpaceOnUse'); |
| 52 | + |
| 53 | + pattern.append('path') |
| 54 | + .attr('d', 'M 40 0 L 0 0 0 40') |
| 55 | + .attr('fill', 'none') |
| 56 | + .attr('stroke', '#1f2937') |
| 57 | + .attr('stroke-width', '1'); |
| 58 | + |
| 59 | + svg.append('rect') |
| 60 | + .attr('width', '100%') |
| 61 | + .attr('height', '100%') |
| 62 | + .attr('fill', '#111827') |
| 63 | + .style('opacity', 0.95); |
| 64 | + |
| 65 | + svg.append('rect') |
| 34 | 66 | .attr('width', '100%') |
| 35 | | - .attr('height', '100%'); |
| 67 | + .attr('height', '100%') |
| 68 | + .attr('fill', 'url(#grid)') |
| 69 | + .style('opacity', 0.3); |
| 36 | 70 | |
| 37 | 71 | const g = svg |
| 38 | 72 | .append('g') |
| 39 | | - .attr('transform', `translate(${margin.left},${margin.top})`); |
| 73 | + .attr('transform', `translate(${width / 2},${margin.top})`); |
| 40 | 74 | |
| 41 | | - // Create tree layout |
| 75 | + // Create tree layout - vertical orientation |
| 42 | 76 | const treeLayout = d3 |
| 43 | 77 | .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)); |
| 78 | + .size([width - margin.left - margin.right, height - margin.top - margin.bottom]) |
| 79 | + .separation((a, b) => { |
| 80 | + const aIsLeaf = !a.children || a.children.length === 0; |
| 81 | + const bIsLeaf = !b.children || b.children.length === 0; |
| 82 | + |
| 83 | + if (aIsLeaf && bIsLeaf) { |
| 84 | + return 1.5; |
| 85 | + } |
| 86 | + return a.parent === b.parent ? 1 : 1.2; |
| 87 | + }); |
| 46 | 88 | |
| 47 | 89 | // Create hierarchy |
| 48 | 90 | const root = d3.hierarchy(treeData); |
| 49 | 91 | const treeNodes = treeLayout(root); |
| 50 | 92 | |
| 51 | 93 | // Create gradient for links |
| 52 | | - const gradient = svg.append('defs') |
| 94 | + const linkGradient = defs |
| 53 | 95 | .append('linearGradient') |
| 54 | | - .attr('id', 'link-gradient') |
| 55 | | - .attr('gradientUnits', 'userSpaceOnUse'); |
| 96 | + .attr('id', 'link-gradient-v') |
| 97 | + .attr('gradientUnits', 'userSpaceOnUse') |
| 98 | + .attr('x1', '0%') |
| 99 | + .attr('y1', '0%') |
| 100 | + .attr('x2', '0%') |
| 101 | + .attr('y2', '100%'); |
| 56 | 102 | |
| 57 | | - gradient.append('stop') |
| 103 | + linkGradient.append('stop') |
| 58 | 104 | .attr('offset', '0%') |
| 59 | | - .attr('stop-color', '#E5E7EB'); |
| 105 | + .attr('stop-color', '#4B5563') |
| 106 | + .attr('stop-opacity', 0.6); |
| 60 | 107 | |
| 61 | | - gradient.append('stop') |
| 108 | + linkGradient.append('stop') |
| 62 | 109 | .attr('offset', '100%') |
| 63 | | - .attr('stop-color', '#9CA3AF'); |
| 110 | + .attr('stop-color', '#6B7280') |
| 111 | + .attr('stop-opacity', 0.3); |
| 64 | 112 | |
| 65 | | - // Create links with curved paths |
| 113 | + // Create links with vertical layout |
| 66 | 114 | const link = g |
| 67 | 115 | .selectAll('.link') |
| 68 | 116 | .data(treeNodes.links()) |
| 69 | 117 | .enter() |
| 70 | 118 | .append('path') |
| 71 | 119 | .attr('class', 'link') |
| 72 | | - .attr('d', d3.linkHorizontal<any, any>() |
| 73 | | - .x(d => d.y) |
| 74 | | - .y(d => d.x)) |
| 120 | + .attr('d', d3.linkVertical<any, any>() |
| 121 | + .x(d => d.x) |
| 122 | + .y(d => d.y)) |
| 75 | 123 | .style('fill', 'none') |
| 76 | | - .style('stroke', 'url(#link-gradient)') |
| 124 | + .style('stroke', 'url(#link-gradient-v)') |
| 77 | 125 | .style('stroke-width', 2) |
| 78 | | - .style('opacity', 0.6); |
| 126 | + .style('opacity', 0.8); |
| 79 | 127 | |
| 80 | 128 | // Create node groups |
| 81 | 129 | const node = g |
@@ -84,41 +132,56 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 84 | 132 | .enter() |
| 85 | 133 | .append('g') |
| 86 | 134 | .attr('class', 'node') |
| 87 | | - .attr('transform', d => `translate(${d.y},${d.x})`); |
| 135 | + .attr('transform', d => `translate(${d.x},${d.y})`); |
| 136 | + |
| 137 | + // Add glow effect for interactive nodes |
| 138 | + const glowFilter = defs.append('filter') |
| 139 | + .attr('id', 'glow'); |
| 140 | + |
| 141 | + glowFilter.append('feGaussianBlur') |
| 142 | + .attr('stdDeviation', '3') |
| 143 | + .attr('result', 'coloredBlur'); |
| 144 | + |
| 145 | + const feMerge = glowFilter.append('feMerge'); |
| 146 | + feMerge.append('feMergeNode').attr('in', 'coloredBlur'); |
| 147 | + feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); |
| 88 | 148 | |
| 89 | | - // Add circles for nodes with better styling |
| 149 | + // Add circles for nodes |
| 90 | 150 | node |
| 91 | 151 | .append('circle') |
| 92 | 152 | .attr('r', d => { |
| 93 | | - if (d.data.path === '/') return 12; // Root is larger |
| 94 | | - if (d.data.path === playerLocation) return 10; |
| 95 | | - return 8; |
| 153 | + if (d.data.path === '/') return 14; |
| 154 | + if (d.data.path === playerLocation) return 11; |
| 155 | + return 9; |
| 96 | 156 | }) |
| 97 | 157 | .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 |
| 158 | + if (d.data.path === playerLocation) return '#3B82F6'; |
| 159 | + if (d.data.has_mole) return '#EF4444'; |
| 160 | + if (d.data.is_fhs) return '#8B5CF6'; |
| 161 | + return '#10B981'; |
| 102 | 162 | }) |
| 103 | 163 | .style('stroke', d => { |
| 104 | | - if (d.data.path === playerLocation) return '#1E40AF'; |
| 105 | | - if (d.data.has_mole) return '#991B1B'; |
| 164 | + if (d.data.path === playerLocation) return '#60A5FA'; |
| 165 | + if (d.data.has_mole) return '#F87171'; |
| 106 | 166 | return '#ffffff'; |
| 107 | 167 | }) |
| 108 | 168 | .style('stroke-width', 2) |
| 109 | 169 | .style('cursor', 'pointer') |
| 110 | | - .style('filter', d => d.data.path === playerLocation ? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))' : 'none') |
| 170 | + .style('filter', d => d.data.path === playerLocation ? 'url(#glow)' : 'none') |
| 171 | + .style('transition', 'all 0.3s ease') |
| 111 | 172 | .on('mouseover', function(event, d) { |
| 112 | 173 | d3.select(this) |
| 113 | 174 | .transition() |
| 114 | 175 | .duration(200) |
| 115 | | - .attr('r', d.data.path === '/' ? 14 : 10); |
| 176 | + .attr('r', d.data.path === '/' ? 16 : 12) |
| 177 | + .style('stroke-width', 3); |
| 116 | 178 | }) |
| 117 | 179 | .on('mouseout', function(event, d) { |
| 118 | 180 | d3.select(this) |
| 119 | 181 | .transition() |
| 120 | 182 | .duration(200) |
| 121 | | - .attr('r', d.data.path === '/' ? 12 : d.data.path === playerLocation ? 10 : 8); |
| 183 | + .attr('r', d.data.path === '/' ? 14 : d.data.path === playerLocation ? 11 : 9) |
| 184 | + .style('stroke-width', 2); |
| 122 | 185 | }) |
| 123 | 186 | .on('click', (event, d) => { |
| 124 | 187 | if (onNodeClick) { |
@@ -131,96 +194,130 @@ const TreeVisualizer: React.FC<TreeVisualizerProps> = ({ |
| 131 | 194 | .append('title') |
| 132 | 195 | .text(d => `${d.data.path}\n${d.data.description}\n${d.data.has_mole ? '🐭 Mole is here!' : ''}`); |
| 133 | 196 | |
| 134 | | - // Add labels with better positioning |
| 197 | + // Add labels |
| 135 | 198 | node |
| 136 | 199 | .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') |
| 200 | + .attr('dy', d => d.children ? -20 : 25) |
| 201 | + .attr('text-anchor', 'middle') |
| 202 | + .style('font-size', '12px') |
| 141 | 203 | .style('font-weight', d => d.data.path === playerLocation ? '600' : '400') |
| 142 | | - .style('fill', d => d.data.path === playerLocation ? '#1E40AF' : '#374151') |
| 204 | + .style('fill', d => d.data.path === playerLocation ? '#93C5FD' : '#E5E7EB') |
| 205 | + .style('text-shadow', '0 0 4px rgba(0,0,0,0.8)') |
| 143 | 206 | .text(d => d.data.name || '/') |
| 144 | 207 | .style('pointer-events', 'none'); |
| 145 | 208 | |
| 146 | | - // Add player indicator emoji |
| 209 | + // Add player indicator with SVG |
| 147 | 210 | const playerNode = treeNodes.descendants().find(d => d.data.path === playerLocation); |
| 148 | 211 | if (playerNode) { |
| 149 | | - node |
| 212 | + const playerGroup = node |
| 150 | 213 | .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('🧑💻'); |
| 214 | + .append('g') |
| 215 | + .attr('transform', 'translate(0, -30)'); |
| 216 | + |
| 217 | + // Add pulsing animation |
| 218 | + playerGroup |
| 219 | + .append('circle') |
| 220 | + .attr('r', 15) |
| 221 | + .style('fill', 'none') |
| 222 | + .style('stroke', '#3B82F6') |
| 223 | + .style('stroke-width', 2) |
| 224 | + .style('opacity', 0) |
| 225 | + .append('animate') |
| 226 | + .attr('attributeName', 'r') |
| 227 | + .attr('from', '15') |
| 228 | + .attr('to', '25') |
| 229 | + .attr('dur', '2s') |
| 230 | + .attr('repeatCount', 'indefinite'); |
| 231 | + |
| 232 | + playerGroup |
| 233 | + .select('circle') |
| 234 | + .append('animate') |
| 235 | + .attr('attributeName', 'opacity') |
| 236 | + .attr('from', '0.8') |
| 237 | + .attr('to', '0') |
| 238 | + .attr('dur', '2s') |
| 239 | + .attr('repeatCount', 'indefinite'); |
| 240 | + |
| 241 | + // Add player icon |
| 242 | + playerGroup |
| 243 | + .append('image') |
| 244 | + .attr('xlink:href', '/player.svg') |
| 245 | + .attr('width', 24) |
| 246 | + .attr('height', 24) |
| 247 | + .attr('x', -12) |
| 248 | + .attr('y', -12); |
| 156 | 249 | } |
| 157 | 250 | |
| 158 | | - // Add mole indicator if game is won |
| 251 | + // Add mole indicator with SVG if game is won |
| 159 | 252 | const moleNode = treeNodes.descendants().find(d => d.data.has_mole); |
| 160 | 253 | if (moleNode) { |
| 161 | | - node |
| 254 | + const moleGroup = node |
| 162 | 255 | .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('🐭'); |
| 256 | + .append('g') |
| 257 | + .attr('transform', 'translate(0, -30)'); |
| 258 | + |
| 259 | + // Add celebration animation |
| 260 | + moleGroup |
| 261 | + .append('circle') |
| 262 | + .attr('r', 15) |
| 263 | + .style('fill', 'none') |
| 264 | + .style('stroke', '#EF4444') |
| 265 | + .style('stroke-width', 3) |
| 266 | + .style('opacity', 0) |
| 267 | + .append('animate') |
| 268 | + .attr('attributeName', 'r') |
| 269 | + .attr('from', '15') |
| 270 | + .attr('to', '30') |
| 271 | + .attr('dur', '1s') |
| 272 | + .attr('repeatCount', 'indefinite'); |
| 273 | + |
| 274 | + moleGroup |
| 275 | + .select('circle') |
| 276 | + .append('animate') |
| 277 | + .attr('attributeName', 'opacity') |
| 278 | + .attr('from', '1') |
| 279 | + .attr('to', '0') |
| 280 | + .attr('dur', '1s') |
| 281 | + .attr('repeatCount', 'indefinite'); |
| 282 | + |
| 283 | + // Add mole icon |
| 284 | + moleGroup |
| 285 | + .append('image') |
| 286 | + .attr('xlink:href', '/mole.svg') |
| 287 | + .attr('width', 24) |
| 288 | + .attr('height', 24) |
| 289 | + .attr('x', -12) |
| 290 | + .attr('y', -12); |
| 168 | 291 | } |
| 169 | 292 | |
| 170 | 293 | // Add zoom and pan behavior |
| 171 | 294 | const zoom = d3.zoom<SVGSVGElement, unknown>() |
| 172 | | - .scaleExtent([0.3, 3]) |
| 295 | + .scaleExtent([0.3, 2]) |
| 173 | 296 | .on('zoom', (event) => { |
| 174 | 297 | g.attr('transform', event.transform); |
| 175 | 298 | }); |
| 176 | 299 | |
| 177 | 300 | svg.call(zoom); |
| 178 | 301 | |
| 179 | | - // Center on player location initially |
| 302 | + // Center on player location with animation |
| 180 | 303 | if (playerNode) { |
| 181 | 304 | const scale = 0.8; |
| 182 | | - const x = width / 2 - playerNode.y * scale; |
| 183 | | - const y = height / 2 - playerNode.x * scale; |
| 305 | + const x = width / 2 - playerNode.x * scale; |
| 306 | + const y = containerHeight / 2 - playerNode.y * scale - margin.top; |
| 184 | 307 | |
| 185 | | - svg.call( |
| 186 | | - zoom.transform, |
| 187 | | - d3.zoomIdentity.translate(x, y).scale(scale) |
| 188 | | - ); |
| 308 | + svg |
| 309 | + .transition() |
| 310 | + .duration(750) |
| 311 | + .call( |
| 312 | + zoom.transform as any, |
| 313 | + d3.zoomIdentity.translate(x, y).scale(scale) |
| 314 | + ); |
| 189 | 315 | } |
| 190 | 316 | |
| 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 | 317 | }, [treeData, playerLocation, onNodeClick]); |
| 221 | 318 | |
| 222 | 319 | return ( |
| 223 | | - <div className="w-full h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg shadow-inner overflow-hidden"> |
| 320 | + <div ref={containerRef} className="w-full h-full"> |
| 224 | 321 | <svg ref={svgRef} className="w-full h-full" /> |
| 225 | 322 | </div> |
| 226 | 323 | ); |