/**
* MiniMap Component
*
* 2D mini-map showing local graph structure with neural network visual style.
* Displays current node, neighbors, traversal path, and supports click-to-navigate.
* Enhanced with subtle border glow, improved node visibility at small sizes,
* zoom controls, and improved path trail visibility.
*
* Requirements: 8.1, 8.2, 8.3, 8.4, 28.1, 28.2, 28.3, 28.4, 28.5, 28.6, 28.7
*/
import React, { useCallback, useMemo, useState } from 'react';
import type { GraphEdge, GraphNode, MemorySectorType } from '../../types/api';
import { getSectorColor } from '../../utils/visualization';
// ============================================================================
// Types
// ============================================================================
export interface MiniMapNode {
/** Node ID */
id: string;
/** Node label (content preview) */
label: string;
/** Primary sector type */
primarySector: MemorySectorType;
/** Salience value (0-1) */
salience: number;
/** 2D position [x, y] for mini-map display */
position?: [number, number];
}
export interface MiniMapEdge {
/** Source node ID */
source: string;
/** Target node ID */
target: string;
/** Edge weight (0-1) */
weight: number;
}
export interface MiniMapProps {
/** All visible nodes in the graph */
nodes: MiniMapNode[];
/** All edges in the graph - must be actual waypoint connections */
edges: MiniMapEdge[];
/** ID of the current/focused node */
currentNodeId: string | null;
/** Navigation history (array of node IDs in order visited) */
history: string[];
/** Callback when a node is clicked */
onNodeClick?: (nodeId: string) => void;
/** Width of the mini-map in pixels */
width?: number;
/** Height of the mini-map in pixels */
height?: number;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Constants
// ============================================================================
/** Default mini-map dimensions */
const DEFAULT_WIDTH = 200;
const DEFAULT_HEIGHT = 200;
/** Zoom levels */
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.0;
const ZOOM_STEP = 0.25;
const DEFAULT_ZOOM = 1.0;
/** Node sizes - improved visibility at small sizes */
const NODE_RADIUS_MIN = 4;
const NODE_RADIUS_MAX = 8;
const CURRENT_NODE_RADIUS = 10;
/** Path styling - enhanced trail visibility */
const PATH_COLOR = '#00FFFF';
const PATH_WIDTH = 3;
const PATH_GLOW_COLOR = 'rgba(0, 255, 255, 0.5)';
const PATH_GLOW_WIDTH = 8;
/** Edge styling - neuron-like connections */
const EDGE_COLOR_DARK = 'rgba(255, 255, 255, 0.35)';
const EDGE_COLOR_LIGHT = 'rgba(0, 0, 0, 0.4)';
const EDGE_WIDTH_MIN = 1.0;
const EDGE_WIDTH_MAX = 2.5;
/** Padding inside the mini-map */
const PADDING = 25;
/** Animation timing for pulsing effect */
const PULSE_DURATION = '2s';
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Calculate 2D positions for nodes using a simple force-directed layout approximation.
* Places current node at center and distributes neighbors around it.
* Scales graph to fit within bounds while maintaining relative positions.
* Requirements: 28.7
*/
function calculateNodePositions(
nodes: MiniMapNode[],
edges: MiniMapEdge[],
currentNodeId: string | null,
width: number,
height: number,
zoom: number
): Map<string, [number, number]> {
const positions = new Map<string, [number, number]>();
const centerX = width / 2;
const centerY = height / 2;
const baseRadius = Math.min(width, height) / 2 - PADDING;
const radius = baseRadius / zoom; // Adjust radius based on zoom
if (nodes.length === 0) {
return positions;
}
// If there's a current node, place it at center
if (currentNodeId !== null && currentNodeId !== '') {
positions.set(currentNodeId, [centerX, centerY]);
}
// Find neighbors of current node using actual waypoint connections
const neighborIds = new Set<string>();
if (currentNodeId !== null && currentNodeId !== '') {
for (const edge of edges) {
if (edge.source === currentNodeId) {
neighborIds.add(edge.target);
} else if (edge.target === currentNodeId) {
neighborIds.add(edge.source);
}
}
}
// Place neighbors in a circle around the current node
const neighbors = nodes.filter((n) => neighborIds.has(n.id));
const angleStep = (2 * Math.PI) / Math.max(neighbors.length, 1);
neighbors.forEach((node, index) => {
const angle = index * angleStep - Math.PI / 2; // Start from top
const x = centerX + Math.cos(angle) * radius * 0.7;
const y = centerY + Math.sin(angle) * radius * 0.7;
positions.set(node.id, [x, y]);
});
// Place remaining nodes (not current, not neighbors) in outer ring
const otherNodes = nodes.filter((n) => n.id !== currentNodeId && !neighborIds.has(n.id));
const outerAngleStep = (2 * Math.PI) / Math.max(otherNodes.length, 1);
otherNodes.forEach((node, index) => {
const angle = index * outerAngleStep;
const x = centerX + Math.cos(angle) * radius * 0.95;
const y = centerY + Math.sin(angle) * radius * 0.95;
positions.set(node.id, [x, y]);
});
// If no current node, distribute all nodes evenly
if ((currentNodeId === null || currentNodeId === '') && nodes.length > 0) {
const allAngleStep = (2 * Math.PI) / nodes.length;
nodes.forEach((node, index) => {
const angle = index * allAngleStep - Math.PI / 2;
const x = centerX + Math.cos(angle) * radius * 0.7;
const y = centerY + Math.sin(angle) * radius * 0.7;
positions.set(node.id, [x, y]);
});
}
return positions;
}
/**
* Calculate node radius based on salience - improved visibility at small sizes
* Requirements: 28.1
*/
function calculateNodeRadius(salience: number, zoom: number): number {
const clampedSalience = Math.max(0, Math.min(1, salience));
const baseRadius = NODE_RADIUS_MIN + clampedSalience * (NODE_RADIUS_MAX - NODE_RADIUS_MIN);
return baseRadius * zoom;
}
/**
* Calculate edge width based on weight
*/
function calculateEdgeWidth(weight: number, zoom: number): number {
const clampedWeight = Math.max(0, Math.min(1, weight));
const baseWidth = EDGE_WIDTH_MIN + clampedWeight * (EDGE_WIDTH_MAX - EDGE_WIDTH_MIN);
return baseWidth * zoom;
}
/**
* Truncate label for display
*/
function truncateLabel(label: string, maxLength: number = 12): string {
if (label.length <= maxLength) return label;
return label.substring(0, maxLength - 3) + '...';
}
/**
* Generate a bezier curve path for neuron-like connections
* Requirements: 28.1
*/
function generateNeuronPath(sourcePos: [number, number], targetPos: [number, number]): string {
const [x1, y1] = sourcePos;
const [x2, y2] = targetPos;
// Calculate midpoint
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
// Calculate perpendicular offset for curve
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.sqrt(dx * dx + dy * dy);
// Add slight curve based on distance
const curveAmount = Math.min(length * 0.15, 15);
const perpX = (-dy / length) * curveAmount;
const perpY = (dx / length) * curveAmount;
// Control point with slight offset
const ctrlX = midX + perpX;
const ctrlY = midY + perpY;
return `M ${String(x1)} ${String(y1)} Q ${String(ctrlX)} ${String(ctrlY)} ${String(x2)} ${String(y2)}`;
}
// ============================================================================
// Zoom Controls Component
// ============================================================================
interface ZoomControlsProps {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
/**
* Zoom controls for the mini-map
*/
function ZoomControls({
zoom,
onZoomIn,
onZoomOut,
onReset,
}: ZoomControlsProps): React.ReactElement {
const canZoomIn = zoom < ZOOM_MAX;
const canZoomOut = zoom > ZOOM_MIN;
const isDefault = zoom === DEFAULT_ZOOM;
const buttonStyle: React.CSSProperties = {
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--theme-surface)',
border: '1px solid var(--theme-primary-glow)',
borderRadius: 4,
color: 'var(--theme-text-secondary)',
cursor: 'pointer',
transition: 'all 150ms ease-out',
};
const disabledStyle: React.CSSProperties = {
...buttonStyle,
opacity: 0.4,
cursor: 'not-allowed',
};
return (
<div
className="absolute bottom-2 right-2 flex flex-col gap-1"
style={{
zIndex: 10,
}}
>
{/* Zoom In */}
<button
onClick={onZoomIn}
disabled={!canZoomIn}
title="Zoom in"
aria-label="Zoom in"
style={canZoomIn ? buttonStyle : disabledStyle}
onMouseEnter={(e) => {
if (canZoomIn) {
e.currentTarget.style.background = 'var(--theme-primary-subtle)';
e.currentTarget.style.borderColor = 'var(--theme-primary)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--theme-surface)';
e.currentTarget.style.borderColor = 'var(--theme-primary-glow)';
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 2V10M2 6H10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Reset Zoom */}
<button
onClick={onReset}
disabled={isDefault}
title={`Reset zoom (${String(Math.round(zoom * 100))}%)`}
aria-label="Reset zoom"
style={!isDefault ? buttonStyle : disabledStyle}
onMouseEnter={(e) => {
if (!isDefault) {
e.currentTarget.style.background = 'var(--theme-primary-subtle)';
e.currentTarget.style.borderColor = 'var(--theme-primary)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--theme-surface)';
e.currentTarget.style.borderColor = 'var(--theme-primary-glow)';
}}
>
<span className="text-xs font-mono">{Math.round(zoom * 100)}%</span>
</button>
{/* Zoom Out */}
<button
onClick={onZoomOut}
disabled={!canZoomOut}
title="Zoom out"
aria-label="Zoom out"
style={canZoomOut ? buttonStyle : disabledStyle}
onMouseEnter={(e) => {
if (canZoomOut) {
e.currentTarget.style.background = 'var(--theme-primary-subtle)';
e.currentTarget.style.borderColor = 'var(--theme-primary)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--theme-surface)';
e.currentTarget.style.borderColor = 'var(--theme-primary-glow)';
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 6H10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
// ============================================================================
// Sub-Components
// ============================================================================
interface NeuronEdgeProps {
sourcePos: [number, number];
targetPos: [number, number];
weight: number;
isPathEdge: boolean;
zoom: number;
lightMode?: boolean;
}
/**
* Renders a neuron-like edge connection between two nodes
* Enhanced with improved path trail visibility
* Requirements: 28.1, 28.3
*/
function NeuronEdge({
sourcePos,
targetPos,
weight,
isPathEdge,
zoom,
lightMode = false,
}: NeuronEdgeProps): React.ReactElement {
const strokeWidth = isPathEdge ? PATH_WIDTH * zoom : calculateEdgeWidth(weight, zoom);
const edgeColor = lightMode ? EDGE_COLOR_LIGHT : EDGE_COLOR_DARK;
const strokeColor = isPathEdge ? (lightMode ? '#0088aa' : PATH_COLOR) : edgeColor;
const path = generateNeuronPath(sourcePos, targetPos);
return (
<g>
{/* Enhanced glow effect for path edges - improved visibility */}
{isPathEdge && (
<>
{/* Outer glow */}
<path
d={path}
fill="none"
stroke={PATH_GLOW_COLOR}
strokeWidth={PATH_GLOW_WIDTH * zoom}
strokeLinecap="round"
opacity={0.3}
/>
{/* Inner glow */}
<path
d={path}
fill="none"
stroke={PATH_GLOW_COLOR}
strokeWidth={(strokeWidth + 4) * zoom}
strokeLinecap="round"
opacity={0.5}
/>
</>
)}
{/* Main edge path */}
<path
d={path}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
opacity={isPathEdge ? 1 : lightMode ? 0.8 : 0.5}
/>
</g>
);
}
interface NeuronNodeProps {
node: MiniMapNode;
position: [number, number];
isCurrent: boolean;
isInPath: boolean;
zoom: number;
onClick?: () => void;
}
/**
* Renders a neuron-like node dot on the mini-map
* Enhanced with improved visibility at small sizes
* Requirements: 28.1, 28.2, 28.4
*/
function NeuronNode({
node,
position,
isCurrent,
isInPath,
zoom,
onClick,
}: NeuronNodeProps): React.ReactElement {
const [isHovered, setIsHovered] = useState(false);
const color = getSectorColor(node.primarySector);
const baseRadius = isCurrent ? CURRENT_NODE_RADIUS : calculateNodeRadius(node.salience, 1);
const radius = baseRadius * zoom;
// Hover scale effect
const displayRadius = isHovered && !isCurrent ? radius * 1.2 : radius;
return (
<g
onClick={onClick}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
style={{ cursor: onClick ? 'pointer' : 'default' }}
role="button"
tabIndex={0}
aria-label={`Navigate to ${truncateLabel(node.label)}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.();
}
}}
>
{/* Pulsing glow effect for current node - Requirements: 28.4 */}
{isCurrent && (
<>
{/* Outer pulsing glow - enhanced visibility */}
<circle
cx={position[0]}
cy={position[1]}
r={radius + 10}
fill="none"
stroke="#FFD700"
strokeWidth={2.5}
opacity={0.4}
>
<animate
attributeName="r"
values={`${String(radius + 8)};${String(radius + 14)};${String(radius + 8)}`}
dur={PULSE_DURATION}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.4;0.15;0.4"
dur={PULSE_DURATION}
repeatCount="indefinite"
/>
</circle>
{/* Inner glow ring */}
<circle
cx={position[0]}
cy={position[1]}
r={radius + 5}
fill="none"
stroke="#FFD700"
strokeWidth={2}
opacity={0.7}
>
<animate
attributeName="opacity"
values="0.7;0.4;0.7"
dur={PULSE_DURATION}
repeatCount="indefinite"
/>
</circle>
</>
)}
{/* Path indicator ring for nodes in traversal path - enhanced */}
{isInPath && !isCurrent && (
<>
<circle
cx={position[0]}
cy={position[1]}
r={displayRadius + 4}
fill="none"
stroke={PATH_COLOR}
strokeWidth={1}
opacity={0.4}
/>
<circle
cx={position[0]}
cy={position[1]}
r={displayRadius + 2}
fill="none"
stroke={PATH_COLOR}
strokeWidth={1.5}
opacity={0.8}
/>
</>
)}
{/* Hover glow effect */}
{isHovered && !isCurrent && (
<circle
cx={position[0]}
cy={position[1]}
r={displayRadius + 3}
fill="none"
stroke={color}
strokeWidth={1.5}
opacity={0.5}
/>
)}
{/* Subtle glow behind node - improved visibility */}
<circle
cx={position[0]}
cy={position[1]}
r={displayRadius + 2}
fill={color}
opacity={0.4}
style={{ filter: 'blur(3px)' }}
/>
{/* Main node dot - improved visibility with border */}
<circle
cx={position[0]}
cy={position[1]}
r={displayRadius}
fill={color}
opacity={isCurrent ? 1 : 0.9}
stroke={isCurrent ? '#FFD700' : 'rgba(255, 255, 255, 0.3)'}
strokeWidth={isCurrent ? 2 : 0.5}
/>
</g>
);
}
// ============================================================================
// Main Component
// ============================================================================
/**
* MiniMap - 2D visualization of the local memory graph with neural network style
*
* Features:
* - Displays local graph structure with neuron-like nodes and connections
* - Uses sector colors matching main visualization (Requirements: 28.2)
* - Only displays actual waypoint connections (Requirements: 28.3)
* - Highlights current node with pulsing glow effect (Requirements: 28.4)
* - Shows traversal path as highlighted trail (Requirements: 28.5)
* - Dark background matching main viewport (Requirements: 28.6)
* - Scales graph to fit bounds (Requirements: 28.7)
* - Supports click-to-navigate to any visible node
* - Enhanced with subtle border glow
* - Improved node visibility at small sizes
* - Zoom controls for better exploration
* - Improved path trail visibility
*
* Requirements: 8.1, 8.2, 8.3, 8.4, 28.1-28.7
*/
export function MiniMap({
nodes,
edges,
currentNodeId,
history,
onNodeClick,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
className = '',
}: MiniMapProps): React.ReactElement {
// Zoom state
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
// Detect light mode from document attribute
const isLightMode =
typeof document !== 'undefined' &&
document.documentElement.getAttribute('data-theme-mode') === 'light';
// Zoom handlers
const handleZoomIn = useCallback(() => {
setZoom((prev: number) => Math.min(prev + ZOOM_STEP, ZOOM_MAX));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev: number) => Math.max(prev - ZOOM_STEP, ZOOM_MIN));
}, []);
const handleZoomReset = useCallback(() => {
setZoom(DEFAULT_ZOOM);
}, []);
// Calculate node positions - scales to fit bounds with zoom
const nodePositions = useMemo(
() => calculateNodePositions(nodes, edges, currentNodeId, width, height, zoom),
[nodes, edges, currentNodeId, width, height, zoom]
);
// Create set of nodes in the path for quick lookup
const pathNodeIds = useMemo(() => new Set(history), [history]);
// Create set of path edges (consecutive pairs in history)
const pathEdges = useMemo(() => {
const pathEdgeSet = new Set<string>();
for (let i = 0; i < history.length - 1; i++) {
const source: string | undefined = history[i];
const target: string | undefined = history[i + 1];
// Store both directions for lookup
if (source !== undefined && target !== undefined) {
pathEdgeSet.add(`${source}-${target}`);
pathEdgeSet.add(`${target}-${source}`);
}
}
return pathEdgeSet;
}, [history]);
// Check if an edge is part of the path
const isPathEdge = useCallback(
(source: string, target: string): boolean => {
return pathEdges.has(`${source}-${target}`);
},
[pathEdges]
);
// Handle node click
const handleNodeClick = useCallback(
(nodeId: string) => {
if (onNodeClick && nodeId !== currentNodeId) {
onNodeClick(nodeId);
}
},
[onNodeClick, currentNodeId]
);
// Filter edges to only include actual waypoint connections
// Requirements: 28.3 - Only display actual connections from waypoint graph data
const validEdges = useMemo(() => {
const nodeIdSet = new Set(nodes.map((n) => n.id));
return edges.filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target));
}, [edges, nodes]);
return (
<div
className={`relative rounded-xl overflow-hidden minimap-container ${className}`}
style={{
width,
height,
// Theme-aware background - Requirements: 28.6
background: 'var(--theme-surface)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
// Enhanced border using theme colors
border: '1px solid var(--theme-border)',
boxShadow: `
0 4px 20px rgba(0, 0, 0, 0.15),
0 0 0 1px var(--theme-border)
`,
}}
>
{/* Animated border glow effect */}
<div
className="absolute inset-0 rounded-xl pointer-events-none animate-glow-slow"
style={{
border: '1px solid transparent',
background:
'linear-gradient(135deg, rgba(0, 255, 255, 0.1), transparent, rgba(0, 255, 255, 0.1)) border-box',
WebkitMask: 'linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}
/>
<svg
width={width}
height={height}
viewBox={`0 0 ${String(width)} ${String(height)}`}
aria-label="Memory graph mini-map"
role="img"
>
{/* Definitions for filters and gradients */}
<defs>
{/* Enhanced glow filter for nodes */}
<filter id="minimap-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Path glow filter */}
<filter id="minimap-path-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Radial gradient for background depth */}
<radialGradient id="minimap-bg-gradient" cx="50%" cy="50%" r="70%">
<stop offset="0%" stopColor="rgba(20, 20, 40, 0.4)" />
<stop offset="100%" stopColor="rgba(5, 5, 15, 0.6)" />
</radialGradient>
</defs>
{/* Background with depth effect */}
<rect width={width} height={height} fill="url(#minimap-bg-gradient)" />
{/* Subtle grid pattern for depth */}
<g opacity={0.1}>
{Array.from({ length: 5 }).map((_, i) => (
<circle
key={`grid-${String(i)}`}
cx={width / 2}
cy={height / 2}
r={(Math.min(width, height) / 2 - PADDING) * ((i + 1) / 5)}
fill="none"
stroke="rgba(0, 255, 255, 0.3)"
strokeWidth={0.5}
strokeDasharray="2 4"
/>
))}
</g>
{/* Render edges first (below nodes) - neuron-like connections */}
<g className="minimap-edges">
{validEdges.map((edge) => {
const sourcePos = nodePositions.get(edge.source);
const targetPos = nodePositions.get(edge.target);
if (!sourcePos || !targetPos) return null;
const edgeKey = `${edge.source}-${edge.target}`;
const isPath = isPathEdge(edge.source, edge.target);
return (
<NeuronEdge
key={edgeKey}
sourcePos={sourcePos}
targetPos={targetPos}
weight={edge.weight}
isPathEdge={isPath}
zoom={zoom}
lightMode={isLightMode}
/>
);
})}
</g>
{/* Render path trail on top of regular edges - Requirements: 28.5 */}
<g className="minimap-path" filter="url(#minimap-path-glow)">
{history.slice(0, -1).map((nodeId, index) => {
const nextNodeId: string | undefined = history[index + 1];
if (nextNodeId === undefined || nextNodeId === '') return null;
const sourcePos = nodePositions.get(nodeId);
const targetPos = nodePositions.get(nextNodeId);
if (!sourcePos || !targetPos) return null;
const path = generateNeuronPath(sourcePos, targetPos);
return (
<g key={`path-${nodeId}-${nextNodeId}`}>
{/* Outer glow for path */}
<path
d={path}
fill="none"
stroke={PATH_GLOW_COLOR}
strokeWidth={PATH_GLOW_WIDTH * zoom}
strokeLinecap="round"
opacity={0.3}
/>
{/* Inner glow for path */}
<path
d={path}
fill="none"
stroke={PATH_GLOW_COLOR}
strokeWidth={(PATH_WIDTH + 4) * zoom}
strokeLinecap="round"
opacity={0.5}
/>
{/* Main path line */}
<path
d={path}
fill="none"
stroke={PATH_COLOR}
strokeWidth={PATH_WIDTH * zoom}
strokeLinecap="round"
opacity={1}
/>
</g>
);
})}
</g>
{/* Render nodes on top - improved visibility with sector colors */}
<g className="minimap-nodes" filter="url(#minimap-glow)">
{nodes.map((node) => {
const position = nodePositions.get(node.id);
if (!position) return null;
const isCurrent = node.id === currentNodeId;
const isInPath = pathNodeIds.has(node.id);
return (
<NeuronNode
key={node.id}
node={node}
position={position}
isCurrent={isCurrent}
isInPath={isInPath}
zoom={zoom}
onClick={() => {
handleNodeClick(node.id);
}}
/>
);
})}
</g>
</svg>
{/* Zoom controls */}
<ZoomControls
zoom={zoom}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleZoomReset}
/>
{/* Node count indicator */}
<div
className="absolute top-2 left-2 text-xs text-ui-text-tertiary"
style={{
background: 'var(--theme-surface)',
padding: '2px 6px',
borderRadius: 4,
border: '1px solid var(--theme-border)',
}}
>
{nodes.length} nodes
</div>
</div>
);
}
// ============================================================================
// Utility Functions for External Use
// ============================================================================
/**
* Convert GraphNode to MiniMapNode
*/
export function graphNodeToMiniMapNode(node: GraphNode): MiniMapNode {
return {
id: node.id,
label: node.content.substring(0, 50),
primarySector: node.primarySector,
salience: node.salience,
};
}
/**
* Convert GraphEdge to MiniMapEdge
* Only converts actual waypoint connections - Requirements: 28.3
*/
export function graphEdgeToMiniMapEdge(edge: GraphEdge): MiniMapEdge {
return {
source: edge.source,
target: edge.target,
weight: edge.weight,
};
}
export default MiniMap;