PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/MindMap.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:16 UTC 29,597 bytes, 944 lines download raw bytes

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.