- Add DimensionIconsContext for app-wide dimension icon sharing - Enhanced getNodeIcon() with favicon/YouTube/PDF detection + dimension icons - Propagate dynamic icons to GridView, ListView, SearchModal, FocusPanel, ViewsOverlay, FolderViewOverlay - Add RahEdge component with hover labels for map edges - Update RahNode with dimension-colored borders and icon support - Add MiniMap with dimension color coding to MapPane - MCP server: add description field to rah_update_node, bump standalone to v1.4.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
706 lines
25 KiB
TypeScript
706 lines
25 KiB
TypeScript
"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<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
// 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<DbNode[]>([]);
|
|
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]);
|
|
const [dbEdges, setDbEdges] = useState<DbEdge[]>([]);
|
|
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// --- UI state ---
|
|
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
|
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
|
|
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// --- React Flow state ---
|
|
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<RFNode<RahNodeData>>([]);
|
|
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
|
|
|
|
// --- Edge creation modal ---
|
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
|
|
|
|
// Track current RF positions so we can preserve them across data refreshes
|
|
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(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<number>();
|
|
const connected = new Set<number>();
|
|
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<number>();
|
|
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<RFNode<RahNodeData>> = 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<RFNode<RahNodeData>> = 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<RFNode<RahNodeData>> = 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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}>
|
|
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
|
{/* Dimension filter dropdown */}
|
|
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
|
<button
|
|
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 10px',
|
|
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
|
border: '1px solid',
|
|
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : '#2a2a2a',
|
|
borderRadius: '6px',
|
|
color: selectedDimension ? '#22c55e' : '#888',
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s ease',
|
|
}}
|
|
>
|
|
<span>{selectedDimension || 'All dimensions'}</span>
|
|
<ChevronDown size={12} style={{
|
|
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
transition: 'transform 0.15s ease',
|
|
}} />
|
|
</button>
|
|
|
|
{dimensionDropdownOpen && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
marginTop: '4px',
|
|
background: '#1a1a1a',
|
|
border: '1px solid #2a2a2a',
|
|
borderRadius: '8px',
|
|
padding: '4px',
|
|
minWidth: '180px',
|
|
maxHeight: '300px',
|
|
overflowY: 'auto',
|
|
zIndex: 1000,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
}}>
|
|
<button
|
|
onClick={() => { setSelectedDimension(null); setDimensionDropdownOpen(false); }}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
|
background: !selectedDimension ? '#2a2a2a' : 'transparent',
|
|
border: 'none', borderRadius: '4px',
|
|
color: !selectedDimension ? '#fff' : '#888',
|
|
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
|
}}
|
|
>
|
|
All dimensions
|
|
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
|
</button>
|
|
|
|
{lockedDimensions.map(dim => (
|
|
<button
|
|
key={dim.dimension}
|
|
onClick={() => { setSelectedDimension(dim.dimension); setDimensionDropdownOpen(false); }}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
|
background: selectedDimension === dim.dimension ? '#2a2a2a' : 'transparent',
|
|
border: 'none', borderRadius: '4px',
|
|
color: selectedDimension === dim.dimension ? '#fff' : '#888',
|
|
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
|
}}
|
|
onMouseEnter={e => {
|
|
if (selectedDimension !== dim.dimension) {
|
|
e.currentTarget.style.background = '#222';
|
|
e.currentTarget.style.color = '#ccc';
|
|
}
|
|
}}
|
|
onMouseLeave={e => {
|
|
if (selectedDimension !== dim.dimension) {
|
|
e.currentTarget.style.background = 'transparent';
|
|
e.currentTarget.style.color = '#888';
|
|
}
|
|
}}
|
|
>
|
|
{dim.dimension}
|
|
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PaneHeader>
|
|
|
|
{/* Map content */}
|
|
<div style={{ position: 'relative', flex: 1, background: '#080808' }}>
|
|
{loading ? (
|
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
|
Loading map...
|
|
</div>
|
|
) : error ? (
|
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
|
|
{error}
|
|
</div>
|
|
) : rfNodes.length === 0 ? (
|
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
|
No nodes to display
|
|
</div>
|
|
) : (
|
|
<div className="rah-map-wrapper" style={{ width: '100%', height: '100%' }}>
|
|
<ReactFlow
|
|
nodes={rfNodes}
|
|
edges={rfEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={onNodeClickHandler}
|
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
onNodeDragStop={onNodeDragStop}
|
|
onConnect={onConnect}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
minZoom={0.1}
|
|
maxZoom={3}
|
|
defaultEdgeOptions={{ type: 'rahEdge' }}
|
|
proOptions={{ hideAttribution: true }}
|
|
colorMode="dark"
|
|
>
|
|
<Background color="#1a1a1a" gap={40} size={1} />
|
|
<MiniMap
|
|
style={{ background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 6 }}
|
|
maskColor="rgba(0,0,0,0.6)"
|
|
nodeColor={(n) => {
|
|
const data = n.data as RahNodeData | undefined;
|
|
return data?.primaryDimensionColor || '#374151';
|
|
}}
|
|
pannable
|
|
zoomable
|
|
/>
|
|
</ReactFlow>
|
|
|
|
{/* Selected node info panel */}
|
|
{selectedDbNode && (
|
|
<div style={infoPanel}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#e5e7eb' }}>
|
|
{selectedDbNode.title || 'Untitled'}
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedNodeId(null)}
|
|
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 16, lineHeight: 1 }}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
{connectedNodeIds.size} connected nodes
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
|
Click a connected node to traverse · Double-click to open
|
|
</div>
|
|
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
|
{selectedDbNode.dimensions.slice(0, 5).map(dim => (
|
|
<span
|
|
key={dim}
|
|
style={{
|
|
padding: '2px 8px', borderRadius: 999, fontSize: 11,
|
|
background: lockedDimensionNames.has(dim) ? '#132018' : '#1a1a1a',
|
|
color: lockedDimensionNames.has(dim) ? '#86efac' : '#888',
|
|
}}
|
|
>
|
|
{dim}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => onNodeClick?.(selectedDbNode.id)}
|
|
style={{
|
|
marginTop: 4, padding: '8px 12px', background: '#22c55e', color: '#052e16',
|
|
border: 'none', borderRadius: '6px', fontSize: 12, fontWeight: 500,
|
|
cursor: 'pointer', width: '100%',
|
|
}}
|
|
>
|
|
Open Node
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edge creation explanation modal */}
|
|
{pendingConnection && (
|
|
<EdgeExplanationModal
|
|
sourceTitle={pendingSourceTitle}
|
|
targetTitle={pendingTargetTitle}
|
|
onSubmit={handleEdgeCreate}
|
|
onCancel={handleEdgeCancel}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Wrap with ReactFlowProvider
|
|
export default function MapPane(props: MapPaneProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<MapPaneInner {...props} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
const infoPanel: CSSProperties = {
|
|
position: 'absolute',
|
|
bottom: 16,
|
|
left: 16,
|
|
width: 260,
|
|
background: '#0a0a0a',
|
|
border: '1px solid #1f1f1f',
|
|
borderRadius: 8,
|
|
padding: 14,
|
|
zIndex: 10,
|
|
};
|