"use client"; import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } from 'react'; import { ReactFlow, Background, MiniMap, useNodesState, useEdgesState, addEdge as rfAddEdge, type Connection, type NodeMouseHandler, type Node as RFNode, type Edge as RFEdge, ReactFlowProvider, useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type { Edge as DbEdge, Node as DbNode } from '@/types/database'; import PaneHeader from './PaneHeader'; import type { MapPaneProps } from './types'; import { ChevronDown } from 'lucide-react'; import { RahNode } from './map/RahNode'; import { RahEdge } from './map/RahEdge'; import EdgeExplanationModal from './map/EdgeExplanationModal'; import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import './map/map-styles.css'; interface DimensionInfo { dimension: string; count: number; isPriority: boolean; description: string | null; } const nodeTypes = { rahNode: RahNode }; const edgeTypes = { rahEdge: RahEdge }; // Debounce helper // eslint-disable-next-line @typescript-eslint/no-explicit-any function debounce void>(fn: T, ms: number): T { let timer: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any return ((...args: any[]) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }) as T; } function MapPaneInner({ slot, isActive, onPaneAction, onCollapse, onSwapPanes, tabBar, onNodeClick, activeTabId, }: MapPaneProps) { const reactFlowInstance = useReactFlow(); const { dimensionIcons } = useDimensionIcons(); // --- Data state (DB-level) --- const [baseNodes, setBaseNodes] = useState([]); const [expandedNodes, setExpandedNodes] = useState([]); const [dbEdges, setDbEdges] = useState([]); const [lockedDimensions, setLockedDimensions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // --- UI state --- const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedDimension, setSelectedDimension] = useState(null); const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false); const dropdownRef = useRef(null); // --- React Flow state --- const [rfNodes, setRfNodes, onNodesChange] = useNodesState>([]); const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]); // --- Edge creation modal --- const [pendingConnection, setPendingConnection] = useState(null); // Track current RF positions so we can preserve them across data refreshes const rfPositionsRef = useRef>(new Map()); // Combine base + expanded const allDbNodes = useMemo(() => { const baseIds = new Set(baseNodes.map(n => n.id)); return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))]; }, [baseNodes, expandedNodes]); // Selected DB node const selectedDbNode = useMemo( () => allDbNodes.find(n => n.id === selectedNodeId) ?? null, [allDbNodes, selectedNodeId], ); // Connected node IDs for info panel const connectedNodeIds = useMemo(() => { if (!selectedNodeId) return new Set(); const connected = new Set(); dbEdges.forEach(e => { if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id); if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id); }); return connected; }, [selectedNodeId, dbEdges]); const lockedDimensionNames = useMemo( () => new Set(lockedDimensions.map(d => d.dimension)), [lockedDimensions], ); // ----- Close dropdown on outside click ----- useEffect(() => { if (!dimensionDropdownOpen) return; const handler = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) { setDimensionDropdownOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [dimensionDropdownOpen]); // ----- Fetch base data ----- useEffect(() => { const fetchData = async () => { setLoading(true); setError(null); try { const nodesUrl = selectedDimension ? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}` : `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`; const [nodesRes, edgesRes, dimsRes] = await Promise.all([ fetch(nodesUrl), fetch('/api/edges'), fetch('/api/dimensions/popular'), ]); if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data'); const nodesPayload = await nodesRes.json(); const edgesPayload = await edgesRes.json(); setBaseNodes(nodesPayload.data || []); setDbEdges(edgesPayload.data || []); setExpandedNodes([]); setSelectedNodeId(null); rfPositionsRef.current.clear(); if (dimsRes.ok) { const dimsPayload = await dimsRes.json(); if (dimsPayload.success && dimsPayload.data) { setLockedDimensions( (dimsPayload.data as DimensionInfo[]).filter(d => d.isPriority), ); } } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); } }; fetchData(); }, [selectedDimension]); // ----- Sync DB data → React Flow nodes/edges ----- useEffect(() => { if (allDbNodes.length === 0) { setRfNodes([]); setRfEdges([]); return; } // Capture current RF positions before rebuild rfNodes.forEach(n => { rfPositionsRef.current.set(n.id, n.position); }); const centerX = 600; const centerY = 400; const newRfNodes = toRFNodes( baseNodes, expandedNodes, centerX, centerY, selectedNodeId, connectedNodeIds, rfPositionsRef.current, dimensionIcons, ); const nodeIdSet = new Set(newRfNodes.map(n => n.id)); const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId); setRfNodes(newRfNodes); setRfEdges(newRfEdges); }, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds]); // ----- Node traversal: fetch connected nodes ----- const fetchConnectedNodes = useCallback(async (nodeId: number) => { try { const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`); let nodeEdges: DbEdge[] = []; if (edgesRes.ok) { const edgesData = await edgesRes.json(); nodeEdges = edgesData.data || []; if (nodeEdges.length > 0) { setDbEdges(prev => { const existing = new Set(prev.map(e => e.id)); const fresh = nodeEdges.filter(e => !existing.has(e.id)); return fresh.length > 0 ? [...prev, ...fresh] : prev; }); } } // Find missing connected node IDs const connectedIds = new Set(); dbEdges.forEach(e => { if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id); if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id); }); nodeEdges.forEach(e => { if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id); if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id); }); const existingIds = new Set(allDbNodes.map(n => n.id)); const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id)); if (missingIds.length === 0) return; const fetched = ( await Promise.all( missingIds.slice(0, 50).map(async id => { try { const res = await fetch(`/api/nodes/${id}`); if (res.ok) { const data = await res.json(); return data.node as DbNode; } } catch { /* ignore */ } return null; }), ) ).filter((n): n is DbNode => n !== null); if (fetched.length > 0) { setExpandedNodes(prev => { const ids = new Set(prev.map(n => n.id)); const fresh = fetched.filter(n => !ids.has(n.id)); return fresh.length > 0 ? [...prev, ...fresh] : prev; }); } } catch (err) { console.error('Failed to fetch connected nodes:', err); } }, [dbEdges, allDbNodes]); useEffect(() => { if (selectedNodeId) fetchConnectedNodes(selectedNodeId); }, [selectedNodeId, fetchConnectedNodes]); // ----- Focused node awareness ----- useEffect(() => { if (!activeTabId) return; const existing = allDbNodes.find(n => n.id === activeTabId); if (existing) { setSelectedNodeId(activeTabId); // Pan + zoom closer to the focused node const rfNode = rfNodes.find(n => n.id === String(activeTabId)); if (rfNode) { reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 }); } } else { (async () => { try { const res = await fetch(`/api/nodes/${activeTabId}`); if (res.ok) { const data = await res.json(); const node = data.node as DbNode; if (node) { setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]); setSelectedNodeId(node.id); // After the next render cycle, zoom to the newly added node setTimeout(() => { reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 }); }, 100); } } } catch (err) { console.error('Failed to fetch focused node:', err); } })(); } }, [activeTabId]); // ----- SSE real-time sync ----- useEffect(() => { let eventSource: EventSource | null = null; try { eventSource = new EventSource('/api/events'); eventSource.onmessage = (event) => { try { const payload = JSON.parse(event.data); switch (payload.type) { case 'NODE_UPDATED': { const node = payload.data?.node as DbNode | undefined; if (node?.id) { const updater = (prev: DbNode[]) => prev.map(n => n.id === node.id ? { ...n, ...node } : n); setBaseNodes(updater); setExpandedNodes(updater); } break; } case 'NODE_DELETED': { const deletedId = payload.data?.nodeId; if (deletedId) { setBaseNodes(prev => prev.filter(n => n.id !== deletedId)); setExpandedNodes(prev => prev.filter(n => n.id !== deletedId)); setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId)); setSelectedNodeId(prev => prev === deletedId ? null : prev); } break; } case 'NODE_CREATED': { // If filtering by dimension and new node matches, could add it // For now, just note it happened — user can refresh break; } case 'EDGE_CREATED': { const edge = payload.data?.edge as DbEdge | undefined; if (edge?.id) { setDbEdges(prev => { if (prev.some(e => e.id === edge.id)) return prev; return [...prev, edge]; }); } break; } case 'EDGE_DELETED': { const edgeId = payload.data?.edgeId; if (edgeId) { setDbEdges(prev => prev.filter(e => e.id !== edgeId)); } break; } } } catch { // Ignore parse errors (keep-alive pings, etc.) } }; eventSource.onerror = () => { // EventSource auto-reconnects, just log console.error('Map SSE connection error'); }; } catch { console.error('Failed to establish Map SSE connection'); } return () => { eventSource?.close(); }; }, []); // ----- Node drag → save position to metadata (debounced) ----- const savePositionRef = useRef( debounce(async (nodeId: number, x: number, y: number) => { try { const res = await fetch(`/api/nodes/${nodeId}`); if (!res.ok) return; const { node: existing } = await res.json(); const existingMetadata = existing?.metadata ?? {}; const mergedMeta = typeof existingMetadata === 'string' ? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })() : existingMetadata; await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata: { ...mergedMeta, map_position: { x, y } }, }), }); } catch (err) { console.error('Failed to save node position:', err); } }, 400), ); const onNodeDragStop: NodeMouseHandler> = useCallback((_event, node) => { const nodeId = parseInt(node.id); if (!isNaN(nodeId)) { rfPositionsRef.current.set(node.id, node.position); savePositionRef.current(nodeId, node.position.x, node.position.y); } }, []); // ----- Node click → select + traverse ----- const onNodeClickHandler: NodeMouseHandler> = useCallback((_event, node) => { const nodeId = parseInt(node.id); if (isNaN(nodeId)) return; setSelectedNodeId(prev => prev === nodeId ? null : nodeId); }, []); // ----- Node double-click → open in other pane ----- const onNodeDoubleClick: NodeMouseHandler> = useCallback((_event, node) => { const nodeId = parseInt(node.id); if (!isNaN(nodeId)) onNodeClick?.(nodeId); }, [onNodeClick]); // ----- Edge creation via drag ----- const onConnect = useCallback((connection: Connection) => { if (connection.source === connection.target) return; // No self-connections setPendingConnection(connection); }, []); const handleEdgeCreate = useCallback(async (explanation: string) => { if (!pendingConnection?.source || !pendingConnection?.target) return; const fromId = parseInt(pendingConnection.source); const toId = parseInt(pendingConnection.target); try { const res = await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: fromId, to_node_id: toId, source: 'user', explanation, created_via: 'ui', }), }); if (res.ok) { const payload = await res.json(); const edge = payload.data; if (edge?.id) { // Add to DB edges (SSE may also add it, dedup in handler) setDbEdges(prev => { if (prev.some(e => e.id === edge.id)) return prev; return [...prev, edge]; }); } } } catch (err) { console.error('Failed to create edge:', err); } setPendingConnection(null); }, [pendingConnection]); const handleEdgeCancel = useCallback(() => { setPendingConnection(null); }, []); // Get source/target titles for modal const pendingSourceTitle = pendingConnection?.source ? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown' : ''; const pendingTargetTitle = pendingConnection?.target ? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown' : ''; return (
{/* Dimension filter dropdown */}
{dimensionDropdownOpen && (
{lockedDimensions.map(dim => ( ))}
)}
{/* Map content */}
{loading ? (
Loading map...
) : error ? (
{error}
) : rfNodes.length === 0 ? (
No nodes to display
) : (
{ const data = n.data as RahNodeData | undefined; return data?.primaryDimensionColor || '#374151'; }} pannable zoomable /> {/* Selected node info panel */} {selectedDbNode && (
{selectedDbNode.title || 'Untitled'}
{connectedNodeIds.size} connected nodes
Click a connected node to traverse · Double-click to open
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
{selectedDbNode.dimensions.slice(0, 5).map(dim => ( {dim} ))}
)}
)}
)}
{/* Edge creation explanation modal */} {pendingConnection && ( )}
); } // Wrap with ReactFlowProvider export default function MapPane(props: MapPaneProps) { return ( ); } const infoPanel: CSSProperties = { position: 'absolute', bottom: 16, left: 16, width: 260, background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 8, padding: 14, zIndex: 10, };