1 2import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; 3import { html } from '../html.js?v=3.8.36'; 4import { ChevronDown, ChevronRight, ZoomIn, ZoomOut, Maximize2, Focus, HelpCircle, X } from 'lucide-react'; 5 6// Enhanced node type styles with gradients and shadows 7const NODE_STYLES = { 8 root: { 9 bg: '#1c1917', 10 bgHover: '#292524', 11 text: '#ffffff', 12 border: '#44403c', 13 shadow: 'rgba(28, 25, 23, 0.4)', 14 accent: '#78716c' 15 }, 16 intro: { 17 bg: '#fafaf9', 18 bgHover: '#f5f5f4', 19 text: '#44403c', 20 border: '#a8a29e', 21 shadow: 'rgba(168, 162, 158, 0.3)', 22 accent: '#78716c' 23 }, 24 part: { 25 bg: '#fef3c7', 26 bgHover: '#fde68a', 27 text: '#78350f', 28 border: '#f59e0b', 29 shadow: 'rgba(245, 158, 11, 0.3)', 30 accent: '#d97706' 31 }, 32 chapter: { 33 bg: '#e0f2fe', 34 bgHover: '#bae6fd', 35 text: '#0c4a6e', 36 border: '#0284c7', 37 shadow: 'rgba(2, 132, 199, 0.25)', 38 accent: '#0369a1' 39 }, 40 conclusion: { 41 bg: '#fafaf9', 42 bgHover: '#f5f5f4', 43 text: '#44403c', 44 border: '#a8a29e', 45 shadow: 'rgba(168, 162, 158, 0.3)', 46 accent: '#78716c' 47 }, 48 concept: { 49 bg: '#ede9fe', 50 bgHover: '#ddd6fe', 51 text: '#5b21b6', 52 border: '#8b5cf6', 53 shadow: 'rgba(139, 92, 246, 0.25)', 54 accent: '#7c3aed' 55 }, 56 figure: { 57 bg: '#dcfce7', 58 bgHover: '#bbf7d0', 59 text: '#166534', 60 border: '#22c55e', 61 shadow: 'rgba(34, 197, 94, 0.25)', 62 accent: '#16a34a' 63 } 64}; 65 66// Calculate LEFT-TO-RIGHT tree layout 67const calculateLayout = (nodes, expandedIds) => { 68 const NODE_WIDTH = 220; 69 const NODE_HEIGHT = 72; 70 const HORIZONTAL_GAP = 80; // Gap between levels (left to right) 71 const VERTICAL_GAP = 20; // Gap between siblings (top to bottom) 72 73 const visibleNodes = []; 74 const edges = []; 75 76 // Get visible children based on expansion state 77 const getVisibleChildren = (parentId) => { 78 if (!expandedIds.has(parentId)) return []; 79 return nodes.filter(n => n.parentId === parentId); 80 }; 81 82 // Calculate subtree height (vertical space needed) 83 const getSubtreeHeight = (nodeId) => { 84 const children = getVisibleChildren(nodeId); 85 if (children.length === 0) return NODE_HEIGHT; 86 87 const childrenHeight = children.reduce((sum, child) => { 88 return sum + getSubtreeHeight(child.id) + VERTICAL_GAP; 89 }, -VERTICAL_GAP); 90 91 return Math.max(NODE_HEIGHT, childrenHeight); 92 }; 93 94 // Position nodes recursively (left-to-right) 95 const positionNode = (node, x, y, level) => { 96 visibleNodes.push({ ...node, x, y, level }); 97 98 const children = getVisibleChildren(node.id); 99 if (children.length === 0) return; 100 101 // Calculate total height of all children subtrees 102 const totalHeight = children.reduce((sum, child) => { 103 return sum + getSubtreeHeight(child.id) + VERTICAL_GAP; 104 }, -VERTICAL_GAP); 105 106 // Start y position (centered relative to parent) 107 let currentY = y - totalHeight / 2; 108 109 children.forEach(child => { 110 const childHeight = getSubtreeHeight(child.id); 111 const childX = x + NODE_WIDTH + HORIZONTAL_GAP; 112 const childY = currentY + childHeight / 2; 113 114 // Create edge from parent right side to child left side 115 edges.push({ 116 id: `${node.id}-${child.id}`, 117 from: { x: x + NODE_WIDTH / 2, y: y }, 118 to: { x: childX - NODE_WIDTH / 2, y: childY } 119 }); 120 121 positionNode(child, childX, childY, level + 1); 122 currentY += childHeight + VERTICAL_GAP; 123 }); 124 }; 125 126 // Start from root 127 const rootNode = nodes.find(n => n.id === 'root'); 128 if (rootNode) { 129 positionNode(rootNode, 0, 0, 0); 130 } 131 132 // Calculate bounds 133 let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; 134 visibleNodes.forEach(n => { 135 minX = Math.min(minX, n.x - NODE_WIDTH / 2); 136 maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); 137 minY = Math.min(minY, n.y - NODE_HEIGHT / 2); 138 maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2); 139 }); 140 141 const padding = 80; 142 const bounds = { 143 x: minX - padding, 144 y: minY - padding, 145 width: (maxX - minX) + padding * 2, 146 height: (maxY - minY) + padding * 2 147 }; 148 149 return { visibleNodes, edges, bounds, nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT }; 150}; 151 152// Mind Map Node Component - single click to expand, detail panel via icon 153const MindMapNode = ({ node, nodeWidth, nodeHeight, isExpanded, hasChildren, isSelected, onSelect, onToggle }) => { 154 const style = NODE_STYLES[node.type] || NODE_STYLES.chapter;
155 const isRoot = node.type === 'root'; 156 const [isHovered, setIsHovered] = useState(false); 157 const [isFocused, setIsFocused] = useState(false); 158 159 // Single click on node: expand if has children, always select 160 const handleNodeClick = useCallback((e) => { 161 e.stopPropagation(); 162 e.preventDefault(); 163 // Always select the node (opens detail panel) 164 onSelect(node); 165 // If has children, also toggle expansion 166 if (hasChildren) { 167 onToggle(node.id); 168 } 169 }, [node, hasChildren, onSelect, onToggle]); 170 171 // Click on expand button only toggles, doesn't open panel 172 const handleExpandClick = useCallback((e) => { 173 e.stopPropagation(); 174 e.preventDefault(); 175 onToggle(node.id); 176 }, [node.id, onToggle]); 177 178 const handleNodeKeyDown = useCallback((e) => { 179 if (e.key === 'Enter' || e.key === ' ') { 180 handleNodeClick(e); 181 } 182 }, [handleNodeClick]); 183 184 const isEmphasized = isSelected || isFocused; 185 const shadowOffset = isEmphasized ? 4 : isHovered ? 3 : 2; 186 const borderWidth = isEmphasized ? 2.5 : isHovered ? 2 : 1.5; 187 188 return html` 189 <g 190 transform="translate(${node.x - nodeWidth / 2}, ${node.y - nodeHeight / 2})" 191 role="button" 192 tabIndex="0" 193 aria-label=${`${node.label}${node.subtitle ? `: ${node.subtitle}` : ''}`} 194 aria-expanded=${hasChildren ? isExpanded : undefined} 195 aria-pressed=${isSelected} 196 style=${{ 197 cursor: 'pointer', 198 transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)' 199 }} 200 onClick=${handleNodeClick} 201 onKeyDown=${handleNodeKeyDown} 202 onMouseEnter=${() => setIsHovered(true)} 203 onMouseLeave=${() => setIsHovered(false)} 204 onFocus=${() => setIsFocused(true)} 205 onBlur=${() => setIsFocused(false)} 206 > 207 <rect 208 x=${shadowOffset} 209 y=${shadowOffset} 210 width=${nodeWidth} 211 height=${nodeHeight} 212 rx="2" 213 fill=${style.shadow} 214 style=${{ transition: 'all 0.2s ease' }} 215 /> 216 217 <rect 218 width=${nodeWidth} 219 height=${nodeHeight} 220 rx="2" 221 fill=${isHovered ? style.bgHover : style.bg} 222 stroke=${isEmphasized ? style.accent : style.border} 223 strokeWidth=${borderWidth} 224 style=${{ transition: 'all 0.2s ease', cursor: 'pointer' }} 225 /> 226 227 ${isEmphasized && html` 228 <rect 229 x="-3" 230 y="-3" 231 width=${nodeWidth + 6} 232 height=${nodeHeight + 6} 233 rx="4" 234 fill="none" 235 stroke=${style.accent} 236 strokeWidth="2" 237 strokeDasharray="4 2" 238 style=${{ opacity: 0.6 }} 239 /> 240 `} 241 242 <foreignObject 243 width=${nodeWidth} 244 height=${nodeHeight} 245 style=${{ pointerEvents: 'none' }} 246 > 247 <div 248 xmlns="http://www.w3.org/1999/xhtml" 249 style=${{ 250 width: '100%', 251 height: '100%', 252 padding: '12px 14px', 253 display: 'flex', 254 flexDirection: 'column', 255 justifyContent: 'center', 256 overflow: 'hidden', 257 color: style.text, 258 pointerEvents: 'none' 259 }} 260 > 261 <div style=${{ 262 fontWeight: 700, 263 lineHeight: 1.3, 264 overflow: 'hidden', 265 textOverflow: 'ellipsis', 266 whiteSpace: 'nowrap', 267 fontSize: isRoot ? '14px' : '12px', 268 fontFamily: isRoot ? "'Special Elite', cursive" : 'inherit' 269 }}> 270 ${node.label} 271 </div> 272 ${node.subtitle && html` 273 <div style=${{ 274 fontSize: '11px', 275 opacity: 0.8, 276 lineHeight: 1.3, 277 marginTop: '4px', 278 overflow: 'hidden', 279 textOverflow: 'ellipsis', 280 whiteSpace: 'nowrap' 281 }}> 282 ${node.subtitle} 283 </div> 284 `} 285 ${node.pageStart && !isRoot && html` 286 <div style=${{ 287 fontSize: '10px', 288 opacity: 0.6, 289 marginTop: '4px', 290 fontFamily: "'Roboto Mono', monospace" 291 }}> 292 pp. ${node.pageStart}${node.pageEnd ? `â${node.pageEnd}` : '+'} 293 </div> 294 `} 295 </div> 296 </foreignObject> 297 298 ${hasChildren && html` 299 <g 300 transform="translate(${nodeWidth - 14}, ${nodeHeight / 2 - 14})" 301 onClick=${handleExpandClick} 302 style=${{ cursor: 'pointer' }} 303 > 304 <circle 305 cx="14" 306 cy="14" 307 r="14" 308 fill=${isExpanded ? style.accent : '#ffffff'} 309 stroke=${style.border} 310 strokeWidth="2" 311 style=${{ transition: 'all 0.2s ease' }} 312 /> 313 <foreignObject x="4" y="4" width="20" height="20" style=${{ pointerEvents: 'none' }}> 314 <div 315 xmlns="http://www.w3.org/1999/xhtml" 316 style=${{ 317 width: '100%', 318 height: '100%', 319 display: 'flex', 320 alignItems: 'center', 321 justifyContent: 'center', 322 color: isExpanded ? '#ffffff' : style.accent, 323 pointerEvents: 'none' 324 }} 325 > 326 ${isExpanded 327 ? html`<${ChevronDown} style=${{ width: '16px', height: '16px' }} />` 328 : html`<${ChevronRight} style=${{ width: '16px', height: '16px' }} />` 329 } 330 </div> 331 </foreignObject> 332 </g> 333 `} 334 335 ${hasChildren && !isExpanded && html` 336 <g transform="translate(${nodeWidth - 28}, ${nodeHeight - 10})"> 337 <rect 338 width="24" 339 height="18" 340 rx="9" 341 fill=${style.accent} 342 /> 343 <text 344 x="12" 345 y="13" 346 textAnchor="middle" 347 fill="white" 348 fontSize="10" 349 fontWeight="bold" 350 style=${{ fontFamily: "'Roboto Mono', monospace" }} 351 > 352 ${node.childCount} 353 </text> 354 </g> 355 `} 356 </g> 357 `; 358}; 359 360// Mind Map Edge Component - horizontal bezier curves with smooth animation 361const MindMapEdge = ({ edge }) => { 362 const { from, to } = edge; 363 364 // Create a smooth horizontal bezier curve (S-curve from right of parent to left of child) 365 const midX = (from.x + to.x) / 2; 366 const path = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`; 367 368 return html` 369 <g style=${{ transition: 'opacity 0.3s ease' }}> 370 <path 371 d=${path} 372 fill="none"
373 stroke="rgba(168, 162, 158, 0.2)" 374 strokeWidth="8" 375 strokeLinecap="round" 376 style=${{ transition: 'd 0.35s cubic-bezier(0.4, 0, 0.2, 1)' }} 377 /> 378 <path 379 d=${path} 380 fill="none" 381 stroke="#a8a29e" 382 strokeWidth="2.5" 383 strokeLinecap="round" 384 style=${{ transition: 'd 0.35s cubic-bezier(0.4, 0, 0.2, 1)' }} 385 /> 386 </g> 387 `; 388}; 389 390// Main Mind Map Component 391const MindMap = ({ 392 nodes, 393 onNodeSelect, 394 className = '', 395 isPanelOpen = false, 396 minimumZoom = 0.3, 397}) => { 398 const containerRef = useRef(null); 399 const [expandedIds, setExpandedIds] = useState(new Set(['root'])); 400 const [selectedNodeId, setSelectedNodeId] = useState(null); 401 const [zoom, setZoom] = useState(1); 402 const [pan, setPan] = useState({ x: 0, y: 0 }); 403 const [targetPan, setTargetPan] = useState({ x: 0, y: 0 }); 404 const [isPanning, setIsPanning] = useState(false); 405 const [panStart, setPanStart] = useState({ x: 0, y: 0 }); 406 const [isAnimating, setIsAnimating] = useState(false); 407 const [showShortcuts, setShowShortcuts] = useState(false); 408 const animationRef = useRef(null); 409 const safeMinimumZoom = Number.isFinite(minimumZoom) 410 ? Math.min(1.5, Math.max(0.3, minimumZoom)) 411 : 0.3; 412 413 // Add child counts to nodes 414 const nodesWithCounts = useMemo(() => { 415 return nodes.map(node => ({ 416 ...node, 417 childCount: nodes.filter(n => n.parentId === node.id).length 418 })); 419 }, [nodes]); 420 421 // Calculate layout 422 const { visibleNodes, edges, bounds, nodeWidth, nodeHeight } = useMemo(() => { 423 return calculateLayout(nodesWithCounts, expandedIds); 424 }, [nodesWithCounts, expandedIds]); 425 426 // Smooth animated transition to target zoom and pan 427 const animateToFit = useCallback((targetZoom, targetPan, duration = 400) => { 428 if (animationRef.current) { 429 cancelAnimationFrame(animationRef.current); 430 } 431 432 const startZoom = zoom; 433 const startPan = { ...pan }; 434 const startTime = performance.now(); 435 const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 436 437 if (reduceMotion || duration <= 0) { 438 setZoom(targetZoom); 439 setPan(targetPan); 440 setIsAnimating(false); 441 return; 442 } 443 444 const animate = (currentTime) => { 445 const elapsed = currentTime - startTime; 446 const progress = Math.min(elapsed / duration, 1); 447 448 // Ease out cubic for smooth deceleration 449 const easeProgress = 1 - Math.pow(1 - progress, 3); 450
451 const newZoom = startZoom + (targetZoom - startZoom) * easeProgress; 452 const newPanX = startPan.x + (targetPan.x - startPan.x) * easeProgress; 453 const newPanY = startPan.y + (targetPan.y - startPan.y) * easeProgress; 454 455 setZoom(newZoom); 456 setPan({ x: newPanX, y: newPanY }); 457 458 if (progress < 1) { 459 animationRef.current = requestAnimationFrame(animate); 460 } else { 461 setIsAnimating(false); 462 } 463 }; 464 465 setIsAnimating(true); 466 animationRef.current = requestAnimationFrame(animate); 467 }, [zoom, pan]); 468 469 // Fit all visible nodes in view 470 const fitToView = useCallback((newBounds, animate = true) => { 471 if (!containerRef.current || !newBounds || newBounds.width === 0) return; 472 473 const container = containerRef.current; 474 const containerWidth = container.clientWidth; 475 const containerHeight = container.clientHeight; 476 477 const padding = 60; // Padding around the content 478 const availableWidth = containerWidth - padding * 2; 479 const availableHeight = containerHeight - padding * 2; 480 481 // Calculate zoom to fit content 482 const scaleX = availableWidth / newBounds.width; 483 const scaleY = availableHeight / newBounds.height; 484 const targetZoom = Math.max( 485 safeMinimumZoom, 486 Math.min(scaleX, scaleY, 1.5), 487 ); // Cap max zoom at 1.5 without shrinking desktop touch targets. 488 489 // Calculate pan to center content 490 const contentCenterX = newBounds.x + newBounds.width / 2; 491 const contentCenterY = newBounds.y + newBounds.height / 2; 492 const targetPanX = containerWidth / 2 - contentCenterX * targetZoom; 493 const targetPanY = containerHeight / 2 - contentCenterY * targetZoom; 494 495 if (animate) { 496 animateToFit(targetZoom, { x: targetPanX, y: targetPanY }, 400); 497 } else { 498 setZoom(targetZoom); 499 setPan({ x: targetPanX, y: targetPanY }); 500 } 501 }, [animateToFit, safeMinimumZoom]); 502 503 // Fit view to a specific node and its immediate connections 504 const fitToNodeCluster = useCallback((nodeId, animate = true) => { 505 if (!containerRef.current) return; 506 507 // Find the node and its immediate connections 508 const node = visibleNodes.find(n => n.id === nodeId); 509 if (!node) return; 510 511 // Get parent node (if visible) 512 const parentNode = visibleNodes.find(n => n.id === node.parentId); 513 514 // Get child nodes (if visible) 515 const childNodes = visibleNodes.filter(n => n.parentId === nodeId); 516 517 // Collect all nodes in the cluster 518 const clusterNodes = [node]; 519 if (parentNode) clusterNodes.push(parentNode); 520 clusterNodes.push(...childNodes); 521 522 // Calculate bounding box for the cluster 523 let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; 524 clusterNodes.forEach(n => { 525 minX = Math.min(minX, n.x - nodeWidth / 2); 526 maxX = Math.max(maxX, n.x + nodeWidth / 2); 527 minY = Math.min(minY, n.y - nodeHeight / 2); 528 maxY = Math.max(maxY, n.y + nodeHeight / 2); 529 }); 530 531 const padding = 80; 532 const clusterBounds = { 533 x: minX - padding, 534 y: minY - padding, 535 width: (maxX - minX) + padding * 2, 536 height: (maxY - minY) + padding * 2 537 }; 538 539 // Fit to the cluster bounds 540 const container = containerRef.current; 541 const containerWidth = container.clientWidth; 542 const containerHeight = container.clientHeight; 543 544 const availableWidth = containerWidth - 120; 545 const availableHeight = containerHeight - 120; 546 547 // Calculate zoom to fit cluster 548 const scaleX = availableWidth / clusterBounds.width; 549 const scaleY = availableHeight / clusterBounds.height; 550 const targetZoom = Math.max( 551 safeMinimumZoom, 552 Math.min(scaleX, scaleY, 1.2), 553 ); // Cap at 1.2 for readability. 554 555 // Calculate pan to center the cluster 556 const clusterCenterX = clusterBounds.x + clusterBounds.width / 2; 557 const clusterCenterY = clusterBounds.y + clusterBounds.height / 2; 558 const targetPanX = containerWidth / 2 - clusterCenterX * targetZoom; 559 const targetPanY = containerHeight / 2 - clusterCenterY * targetZoom; 560 561 if (animate) { 562 animateToFit(targetZoom, { x: targetPanX, y: targetPanY }, 350); 563 } else { 564 setZoom(targetZoom); 565 setPan({ x: targetPanX, y: targetPanY }); 566 } 567 }, [visibleNodes, nodeWidth, nodeHeight, animateToFit, safeMinimumZoom]); 568
569 // Handle node selection - auto-fit to node cluster and notify parent 570 const handleNodeSelect = useCallback((node) => { 571 setSelectedNodeId(node.id); 572 573 // Auto-fit to show the selected node and its connections 574 fitToNodeCluster(node.id, true); 575 576 if (onNodeSelect) { 577 onNodeSelect(node); 578 } 579 }, [onNodeSelect, fitToNodeCluster]); 580 581 // Handle deselecting a node (close panel) 582 const handleDeselectNode = useCallback(() => { 583 setSelectedNodeId(null); 584 if (onNodeSelect) { 585 onNodeSelect(null); 586 } 587 // Fit back to full view 588 fitToView(bounds, true); 589 }, [onNodeSelect, fitToView, bounds]); 590 591 // Toggle node expansion and auto-fit view 592 const toggleNode = useCallback((nodeId) => { 593 setExpandedIds(prev => { 594 const next = new Set(prev); 595 if (next.has(nodeId)) { 596 // Collapse: remove this node and all descendants 597 const removeDescendants = (id) => { 598 next.delete(id); 599 nodes.filter(n => n.parentId === id).forEach(child => removeDescendants(child.id)); 600 }; 601 nodes.filter(n => n.parentId === nodeId).forEach(child => removeDescendants(child.id)); 602 next.delete(nodeId); 603 } else { 604 next.add(nodeId); 605 } 606 return next; 607 }); 608 }, [nodes]); 609 610 // Zoom controls 611 const handleZoomIn = () => setZoom(z => Math.min(z * 1.2, 3)); 612 const handleZoomOut = () => setZoom(z => Math.max(z / 1.2, safeMinimumZoom)); 613 const handleReset = () => fitToView(bounds, true); 614 const handleFitToView = () => fitToView(bounds, true); 615 616 // Pan handling - also deselect when clicking empty space 617 const handleMouseDown = (e) => { 618 if (e.target.tagName === 'svg') { 619 setIsPanning(true); 620 setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); 621 } 622 }; 623 624 // Click on empty SVG space deselects the node 625 const handleSvgClick = (e) => { 626 // Only deselect if clicking directly on SVG (not on a node) 627 if (e.target.tagName === 'svg' && selectedNodeId) { 628 handleDeselectNode(); 629 } 630 }; 631 632 const handleMouseMove = (e) => { 633 if (isPanning) { 634 setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); 635 } 636 }; 637 638 const handleMouseUp = () => { 639 setIsPanning(false); 640 }; 641 642 // Touch handling for mobile 643 const handleTouchStart = useCallback((e) => { 644 if (e.touches.length === 1 && e.target.tagName === 'svg') { 645 const touch = e.touches[0]; 646 setIsPanning(true); 647 setPanStart({ x: touch.clientX - pan.x, y: touch.clientY - pan.y }); 648 } 649 }, [pan]); 650 651 const handleTouchMove = useCallback((e) => { 652 if (isPanning && e.touches.length === 1) { 653 e.preventDefault(); 654 const touch = e.touches[0]; 655 setPan({ x: touch.clientX - panStart.x, y: touch.clientY - panStart.y }); 656 } 657 }, [isPanning, panStart]); 658 659 const handleTouchEnd = useCallback(() => { 660 setIsPanning(false); 661 }, []); 662 663 // Mouse wheel zoom 664 const handleWheel = useCallback((e) => { 665 e.preventDefault(); 666 const delta = e.deltaY > 0 ? 0.9 : 1.1; 667 setZoom(z => Math.max(safeMinimumZoom, Math.min(3, z * delta))); 668 }, [safeMinimumZoom]); 669 670 // Keyboard navigation 671 useEffect(() => { 672 const handleKeyDown = (e) => { 673 // Keep map shortcuts local to the map. This matters when the canonical 674 // component is embedded in another shell with its own arrow-key model. 675 if (!containerRef.current?.contains(document.activeElement)) return; 676 677 switch(e.key) { 678 case 'Escape': 679 // Close detail panel and deselect node 680 if (selectedNodeId) { 681 e.preventDefault(); 682 handleDeselectNode(); 683 } 684 break; 685 case '+': 686 case '=': 687 e.preventDefault(); 688 setZoom(z => Math.min(z * 1.2, 3)); 689 break; 690 case '-': 691 e.preventDefault(); 692 setZoom(z => Math.max(z / 1.2, safeMinimumZoom)); 693 break; 694 case '0': 695 e.preventDefault(); 696 setZoom(1); 697 break; 698 case 'ArrowLeft': 699 e.preventDefault(); 700 setPan(p => ({ ...p, x: p.x + 50 })); 701 break; 702 case 'ArrowRight': 703 e.preventDefault(); 704 setPan(p => ({ ...p, x: p.x - 50 })); 705 break; 706 case 'ArrowUp': 707 e.preventDefault(); 708 setPan(p => ({ ...p, y: p.y + 50 })); 709 break; 710 case 'ArrowDown': 711 e.preventDefault(); 712 setPan(p => ({ ...p, y: p.y - 50 })); 713 break; 714 } 715 }; 716 717 window.addEventListener('keydown', handleKeyDown); 718 return () => window.removeEventListener('keydown', handleKeyDown); 719 }, [selectedNodeId, handleDeselectNode, safeMinimumZoom]); 720 721 // Cancel any in-flight fit-to-view animation on unmount. animateToFit 722 // re-schedules requestAnimationFrame until the tween finishes; without this, 723 // unmounting mid-tween (navigating away during the 400ms fit) leaves the 724 // loop calling setZoom/setPan on an unmounted component. See issue #152. 725 useEffect(() => { 726 return () => { 727 if (animationRef.current) { 728 cancelAnimationFrame(animationRef.current); 729 } 730 }; 731 }, []); 732 733 // Expand all nodes function 734 const expandAll = useCallback(() => { 735 const allIds = new Set(nodes.map(n => n.id)); 736 setExpandedIds(allIds); 737 }, [nodes]); 738 739 // Collapse all function 740 const collapseAll = useCallback(() => { 741 setExpandedIds(new Set(['root'])); 742 }, []); 743 744 // Track if this is the initial mount 745 const isInitialMount = useRef(true); 746 const prevBoundsRef = useRef(bounds); 747 const hasInitialFit = useRef(false); 748
749 // Initial fit on mount - wait for container to have dimensions 750 useEffect(() => { 751 if (hasInitialFit.current) return; 752 if (!containerRef.current || !bounds.width) return; 753 754 // Use a small delay to ensure container is fully rendered 755 const timer = setTimeout(() => { 756 if (containerRef.current && containerRef.current.clientWidth > 0) { 757 fitToView(bounds, false); 758 hasInitialFit.current = true; 759 isInitialMount.current = false; 760 prevBoundsRef.current = bounds; 761 } 762 }, 50); 763 764 return () => clearTimeout(timer); 765 }, [bounds, fitToView]); 766 767 // Auto-fit view when layout changes (after initial mount) 768 useEffect(() => { 769 // Skip if not yet initialized 770 if (!hasInitialFit.current) return; 771 if (!containerRef.current || !bounds.width) return; 772 773 // Check if bounds actually changed 774 const prev = prevBoundsRef.current; 775 if ( 776 bounds.x !== prev.x || 777 bounds.y !== prev.y || 778 bounds.width !== prev.width || 779 bounds.height !== prev.height 780 ) { 781 fitToView(bounds, true); 782 prevBoundsRef.current = bounds; 783 } 784 }, [bounds, fitToView]); 785 786 return html` 787 <div className=${`archive-mind-map ${className}`} ref=${containerRef}> 788 <div className="archive-mind-map__controls"> 789 <button 790 type="button" 791 onClick=${handleZoomIn} 792 className="archive-mind-map__control" 793 title="Zoom in" 794 aria-label="Zoom in" 795 > 796 <${ZoomIn} className="w-5 h-5 text-stone-600" /> 797 </button> 798 <button 799 type="button" 800 onClick=${handleZoomOut} 801 className="archive-mind-map__control" 802 title="Zoom out" 803 aria-label="Zoom out" 804 > 805 <${ZoomOut} className="w-5 h-5 text-stone-600" /> 806 </button> 807 <div className="archive-mind-map__control-rule" /> 808 <button 809 type="button" 810 onClick=${handleReset} 811 className="archive-mind-map__control" 812 title="Re-center view" 813 aria-label="Re-center view to show all nodes" 814 > 815 <${Focus} className="w-5 h-5 text-stone-600" /> 816 </button> 817 </div> 818 819 <div className="archive-mind-map__status"> 820 <div className="archive-mind-map__zoom" aria-live="polite" aria-label=${`Zoom level: ${Math.round(zoom * 100)} percent`}> 821 ${Math.round(zoom * 100)}% 822 </div> 823 <button 824 type="button" 825 onClick=${collapseAll} 826 className="archive-action archive-action--secondary" 827 title="Collapse all nodes" 828 aria-label="Collapse all nodes" 829 > 830 Collapse all 831 </button> 832 <button 833 type="button" 834 onClick=${expandAll} 835 className="archive-action archive-action--primary" 836 title="Expand all nodes" 837 aria-label="Expand all nodes" 838 > 839 Expand all 840 </button> 841 <button 842 type="button" 843 onClick=${() => setShowShortcuts(!showShortcuts)} 844 className="archive-mind-map__control" 845 title="Keyboard shortcuts" 846 aria-label="Show keyboard shortcuts" 847 > 848 <${HelpCircle} className="w-5 h-5" /> 849 </button> 850 </div> 851 852 ${showShortcuts && html` 853 <div className="archive-data-panel archive-mind-map__shortcuts"> 854 <div className="archive-mind-map__shortcuts-header"> 855 <h3>Keyboard shortcuts</h3> 856 <button 857 type="button" 858 onClick=${() => setShowShortcuts(false)} 859 className="archive-action archive-action--quiet" 860 aria-label="Close shortcuts panel" 861 > 862 <${X} className="w-4 h-4" /> 863 </button> 864 </div> 865 <div className="archive-mind-map__shortcut-list"> 866 <div> 867 <span>Zoom in</span> 868 <kbd>+</kbd> 869 </div> 870 <div> 871 <span>Zoom out</span> 872 <kbd>-</kbd> 873 </div> 874 <div> 875 <span>Reset zoom</span> 876 <kbd>0</kbd> 877 </div> 878 <div>
879 <span>Pan view</span> 880 <div> 881 <kbd>â</kbd> 882 <kbd>â</kbd> 883 <kbd>â</kbd> 884 <kbd>â</kbd> 885 </div> 886 </div> 887 <div> 888 <span>Close panel</span> 889 <kbd>Esc</kbd> 890 </div> 891 <div className="archive-mind-map__shortcut-note"> 892 <p>Click nodes to expand or collapse. Drag to pan.</p> 893 </div> 894 </div> 895 </div> 896 `} 897 898 <svg 899 className="w-full h-full touch-none" 900 style=${{ cursor: isPanning ? 'grabbing' : 'grab' }} 901 onMouseDown=${handleMouseDown} 902 onMouseMove=${handleMouseMove} 903 onMouseUp=${handleMouseUp} 904 onMouseLeave=${handleMouseUp} 905 onTouchStart=${handleTouchStart} 906 onTouchMove=${handleTouchMove} 907 onTouchEnd=${handleTouchEnd} 908 onClick=${handleSvgClick} 909 onWheel=${handleWheel} 910 tabIndex="0" 911 role="region" 912 aria-roledescription="interactive mind map" 913 aria-label="Dissertation mind map. Use arrow keys to pan, +/- to zoom. Press ESC to close panel. On touch devices, drag to pan." 914 > 915 <g 916 transform="translate(${pan.x}, ${pan.y}) scale(${zoom})" 917 style=${{ 918 transition: isAnimating ? 'none' : (isPanning ? 'none' : 'transform 0.3s ease-out') 919 }} 920 > 921 ${edges.map(edge => html` 922 <${MindMapEdge} key=${edge.id} edge=${edge} /> 923 `)} 924 925 ${visibleNodes.map(node => html` 926 <${MindMapNode} 927 key=${node.id} 928 node=${node} 929 nodeWidth=${nodeWidth} 930 nodeHeight=${nodeHeight} 931 isExpanded=${expandedIds.has(node.id)} 932 hasChildren=${node.childCount > 0} 933 isSelected=${selectedNodeId === node.id} 934 onSelect=${handleNodeSelect} 935 onToggle=${toggleNode} 936 /> 937 `)} 938 </g> 939 </svg> 940 </div> 941 `; 942}; 943 944export default MindMap;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.