feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+173 -322
View File
@@ -6,40 +6,29 @@ import {
Background,
useNodesState,
useEdgesState,
addEdge as rfAddEdge,
type Connection,
type NodeMouseHandler,
type Node as RFNode,
type Edge as RFEdge,
ReactFlowProvider,
useReactFlow,
MiniMap,
} 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 { MiniMap } from '@xyflow/react';
import { RahNode } from './map/RahNode';
import { RahEdge } from './map/RahEdge';
import EdgeExplanationModal from './map/EdgeExplanationModal';
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useTheme } from '@/hooks/useTheme';
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>;
@@ -52,8 +41,6 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
function MapPaneInner({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
@@ -61,111 +48,75 @@ function MapPaneInner({
activeTabId,
}: MapPaneProps) {
const reactFlowInstance = useReactFlow();
const { dimensionIcons } = useDimensionIcons();
const [theme] = useTheme();
// --- 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 [viewMode, setViewMode] = useState<MapViewMode>('dimension');
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('context');
// --- 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());
const hasInitialFitRef = useRef(false);
// Combine base + expanded
const allDbNodes = useMemo(() => {
const baseIds = new Set(baseNodes.map(n => n.id));
return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))];
const baseIds = new Set(baseNodes.map((node) => node.id));
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
}, [baseNodes, expandedNodes]);
// Selected DB node
const selectedDbNode = useMemo(
() => allDbNodes.find(n => n.id === selectedNodeId) ?? null,
() => allDbNodes.find((node) => node.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);
dbEdges.forEach((edge) => {
if (edge.from_node_id === selectedNodeId) connected.add(edge.to_node_id);
if (edge.to_node_id === selectedNodeId) connected.add(edge.from_node_id);
});
return connected;
}, [selectedNodeId, dbEdges]);
const lockedDimensionNames = useMemo(
() => new Set(lockedDimensions.map(d => d.dimension)),
[lockedDimensions],
);
const clusterLabels = useMemo(() => {
if (viewMode !== 'dimension') {
return [];
}
if (viewMode !== 'context') return [];
const grouped = new Map<string, { x: number; y: number; count: number }>();
for (const node of rfNodes) {
const dimension = getPrimaryDimension((node.data as RahNodeData).dimensions);
const current = grouped.get(dimension) || { x: 0, y: 0, count: 0 };
const clusterLabel = (node.data as RahNodeData).clusterLabel;
const current = grouped.get(clusterLabel) || { x: 0, y: 0, count: 0 };
current.x += node.position.x;
current.y += node.position.y;
current.count += 1;
grouped.set(dimension, current);
grouped.set(clusterLabel, current);
}
return [...grouped.entries()].map(([dimension, totals]) => ({
dimension,
return [...grouped.entries()].map(([clusterLabel, totals]) => ({
clusterLabel,
x: totals.x / totals.count,
y: totals.y / totals.count - 84,
}));
}, [rfNodes, viewMode]);
// ----- 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),
const [nodesRes, edgesRes] = await Promise.all([
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
fetch('/api/edges'),
fetch('/api/dimensions/popular'),
]);
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data');
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load map data');
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
@@ -175,23 +126,16 @@ function MapPaneInner({
setExpandedNodes([]);
setSelectedNodeId(null);
rfPositionsRef.current.clear();
if (dimsRes.ok) {
const dimsPayload = await dimsRes.json();
if (dimsPayload.success && dimsPayload.data) {
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedDimension]);
// ----- Sync DB data → React Flow nodes/edges -----
void fetchData();
}, []);
useEffect(() => {
if (allDbNodes.length === 0) {
setRfNodes([]);
@@ -199,9 +143,8 @@ function MapPaneInner({
return;
}
// Capture current RF positions before rebuild
rfNodes.forEach(n => {
rfPositionsRef.current.set(n.id, n.position);
rfNodes.forEach((node) => {
rfPositionsRef.current.set(node.id, node.position);
});
const centerX = 600;
@@ -215,29 +158,30 @@ function MapPaneInner({
selectedNodeId,
connectedNodeIds,
rfPositionsRef.current,
dimensionIcons,
viewMode,
dbEdges,
);
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
const nodeIdSet = new Set(newRfNodes.map((node) => node.id));
const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId);
setRfNodes(newRfNodes);
setRfEdges(newRfEdges);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, viewMode, rfNodes, setRfEdges, setRfNodes]);
useEffect(() => {
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
hasInitialFitRef.current = true;
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
setTimeout(() => {
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
@@ -247,27 +191,26 @@ function MapPaneInner({
useEffect(() => {
hasInitialFitRef.current = false;
}, [selectedDimension, viewMode]);
}, [viewMode]);
const fitAllNodes = useCallback(() => {
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
}, [reactFlowInstance]);
const fitHubNodes = useCallback(() => {
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
}
}, [baseNodes, reactFlowInstance]);
// ----- Node traversal: fetch connected nodes -----
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
try {
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
@@ -278,48 +221,49 @@ function MapPaneInner({
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));
setDbEdges((prev) => {
const existing = new Set(prev.map((edge) => edge.id));
const fresh = nodeEdges.filter((edge) => !existing.has(edge.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);
dbEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.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);
nodeEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
});
const existingIds = new Set(allDbNodes.map(n => n.id));
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
const existingIds = new Set(allDbNodes.map((node) => node.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 => {
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 */ }
} catch {
return null;
}
return null;
}),
)
).filter((n): n is DbNode => n !== null);
).filter((node): node is DbNode => node !== 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));
setExpandedNodes((prev) => {
const ids = new Set(prev.map((node) => node.id));
const fresh = fetched.filter((node) => !ids.has(node.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
@@ -329,44 +273,41 @@ function MapPaneInner({
}, [dbEdges, allDbNodes]);
useEffect(() => {
if (selectedNodeId) fetchConnectedNodes(selectedNodeId);
if (selectedNodeId) {
void fetchConnectedNodes(selectedNodeId);
}
}, [selectedNodeId, fetchConnectedNodes]);
// ----- Focused node awareness -----
useEffect(() => {
if (!activeTabId) return;
const existing = allDbNodes.find(n => n.id === activeTabId);
const existing = allDbNodes.find((node) => node.id === activeTabId);
if (existing) {
setSelectedNodeId(activeTabId);
// Pan + zoom closer to the focused node
const rfNode = rfNodes.find(n => n.id === String(activeTabId));
const rfNode = rfNodes.find((node) => node.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);
}
})();
return;
}
}, [activeTabId]);
// ----- SSE real-time sync -----
void (async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (!res.ok) return;
const data = await res.json();
const node = data.node as DbNode;
if (!node) return;
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
setSelectedNodeId(node.id);
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
useEffect(() => {
let eventSource: EventSource | null = null;
@@ -382,7 +323,7 @@ function MapPaneInner({
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);
prev.map((existingNode) => (existingNode.id === node.id ? { ...existingNode, ...node } : existingNode));
setBaseNodes(updater);
setExpandedNodes(updater);
}
@@ -391,43 +332,36 @@ function MapPaneInner({
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);
setBaseNodes((prev) => prev.filter((node) => node.id !== deletedId));
setExpandedNodes((prev) => prev.filter((node) => node.id !== deletedId));
setDbEdges((prev) => prev.filter((edge) => edge.from_node_id !== deletedId && edge.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];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
break;
}
case 'EDGE_DELETED': {
const edgeId = payload.data?.edgeId;
if (edgeId) {
setDbEdges(prev => prev.filter(e => e.id !== edgeId));
setDbEdges((prev) => prev.filter((edge) => edge.id !== edgeId));
}
break;
}
default:
break;
}
} catch {
// Ignore parse errors (keep-alive pings, etc.)
// Ignore keep-alives and malformed payloads.
}
};
eventSource.onerror = () => {
// EventSource auto-reconnects, just log
console.error('Map SSE connection error');
};
} catch {
@@ -439,7 +373,6 @@ function MapPaneInner({
};
}, []);
// ----- Node drag → save position to metadata (debounced) -----
const savePositionRef = useRef(
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
try {
@@ -448,7 +381,13 @@ function MapPaneInner({
const { node: existing } = await res.json();
const existingMetadata = existing?.metadata ?? {};
const mergedMeta = typeof existingMetadata === 'string'
? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })()
? (() => {
try {
return JSON.parse(existingMetadata);
} catch {
return {};
}
})()
: existingMetadata;
await fetch(`/api/nodes/${nodeId}`, {
@@ -472,8 +411,8 @@ function MapPaneInner({
);
const onNodeDragStop: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) {
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
rfPositionsRef.current.set(node.id, node.position);
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
}
@@ -485,36 +424,38 @@ function MapPaneInner({
useEffect(() => {
if (loading || rfNodes.length === 0) return;
const timeout = setTimeout(() => {
reactFlowInstance.fitView({ padding: 0.22, duration: 300 });
}, 80);
return () => clearTimeout(timeout);
}, [viewMode, selectedDimension, loading, rfNodes, reactFlowInstance]);
// ----- Node click → select + traverse -----
return () => clearTimeout(timeout);
}, [viewMode, loading, rfNodes, reactFlowInstance]);
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (isNaN(nodeId)) return;
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
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);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
onNodeClick?.(nodeId);
}
}, [onNodeClick]);
// ----- Edge creation via drag -----
const onConnect = useCallback((connection: Connection) => {
if (connection.source === connection.target) return; // No self-connections
if (connection.source === connection.target) return;
setPendingConnection(connection);
}, []);
const handleEdgeCreate = useCallback(async (explanation: string) => {
if (!pendingConnection?.source || !pendingConnection?.target) return;
const fromId = parseInt(pendingConnection.source);
const toId = parseInt(pendingConnection.target);
const fromId = parseInt(pendingConnection.source, 10);
const toId = parseInt(pendingConnection.target, 10);
try {
const res = await fetch('/api/edges', {
@@ -533,11 +474,7 @@ function MapPaneInner({
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];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
}
} catch (err) {
@@ -551,12 +488,11 @@ function MapPaneInner({
setPendingConnection(null);
}, []);
// Get source/target titles for modal
const pendingSourceTitle = pendingConnection?.source
? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.source, 10))?.title || 'Unknown'
: '';
const pendingTargetTitle = pendingConnection?.target
? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.target, 10))?.title || 'Unknown'
: '';
return (
@@ -564,29 +500,29 @@ function MapPaneInner({
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => setViewMode('dimension')}
onClick={() => setViewMode('context')}
style={{
padding: '6px 10px',
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'context' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'context' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'dimension' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'context' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
>
Dimension View
Context View
</button>
<button
onClick={() => setViewMode('hub')}
style={{
padding: '6px 10px',
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'hub' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'hub' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'hub' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'hub' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
@@ -594,97 +530,8 @@ function MapPaneInner({
Hub View
</button>
</div>
{/* 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)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: selectedDimension ? '#22c55e' : 'var(--rah-text-muted)',
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: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
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 ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: !selectedDimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
>
All dimensions
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</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 ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: selectedDimension === dim.dimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
}
}}
onMouseLeave={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-muted)';
}
}}
>
{dim.dimension}
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</span>}
</button>
))}
</div>
)}
</div>
</PaneHeader>
{/* Map content */}
<div style={{ position: 'relative', flex: 1, background: 'var(--rah-bg-base)' }}>
{loading ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)' }}>
@@ -715,24 +562,24 @@ function MapPaneInner({
maxZoom={3}
defaultEdgeOptions={{ type: 'rahEdge' }}
proOptions={{ hideAttribution: true }}
colorMode="dark"
colorMode={theme}
>
<Background color="var(--rah-border)" gap={40} size={1} />
<MiniMap
style={{ background: 'var(--rah-bg-base)', border: '1px solid var(--rah-bg-active)', borderRadius: 6 }}
maskColor="rgba(0, 0, 0, 0.7)"
style={{ background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: 6 }}
maskColor={theme === 'light' ? 'rgba(255, 255, 255, 0.72)' : 'rgba(0, 0, 0, 0.7)'}
nodeColor={(node) => {
const data = node.data as RahNodeData | undefined;
return data?.primaryDimensionColor || '#2a2a2a';
return data?.clusterColor || '#2a2a2a';
}}
pannable
zoomable
/>
</ReactFlow>
{viewMode === 'dimension' && clusterLabels.map((label) => (
{viewMode === 'context' && clusterLabels.map((label) => (
<div
key={label.dimension}
key={label.clusterLabel}
style={{
position: 'absolute',
transform: `translate(${label.x}px, ${label.y}px)`,
@@ -741,27 +588,29 @@ function MapPaneInner({
fontSize: '11px',
letterSpacing: '0.05em',
textTransform: 'uppercase',
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
textShadow: theme === 'light' ? '0 1px 3px rgba(255,255,255,0.95)' : '0 1px 6px rgba(0,0,0,0.45)',
}}
>
{label.dimension}
{label.clusterLabel}
</div>
))}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}
>
<button
onClick={fitAllNodes}
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -777,7 +626,7 @@ function MapPaneInner({
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -790,7 +639,6 @@ function MapPaneInner({
)}
</div>
{/* Selected node info panel */}
{selectedDbNode && (
<div style={infoPanel}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
@@ -807,31 +655,35 @@ function MapPaneInner({
<div style={{ fontSize: 12, color: 'var(--rah-text-muted)', marginBottom: 8 }}>
{connectedNodeIds.size} connected nodes
</div>
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--rah-accent-green)', marginBottom: 8 }}>
Click a connected node to traverse &middot; 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' : 'var(--rah-bg-active)',
color: lockedDimensionNames.has(dim) ? '#86efac' : 'var(--rah-text-muted)',
}}
>
{dim}
</span>
))}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: selectedDbNode.context?.name ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
color: selectedDbNode.context?.name ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
}}
>
{selectedDbNode.context?.name || 'Unscoped'}
</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%',
marginTop: 4,
padding: '8px 12px',
background: 'var(--rah-accent-green)',
color: 'var(--rah-text-inverse)',
border: 'none',
borderRadius: '6px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
width: '100%',
}}
>
Open Node
@@ -842,7 +694,6 @@ function MapPaneInner({
)}
</div>
{/* Edge creation explanation modal */}
{pendingConnection && (
<EdgeExplanationModal
sourceTitle={pendingSourceTitle}
@@ -855,7 +706,6 @@ function MapPaneInner({
);
}
// Wrap with ReactFlowProvider
export default function MapPane(props: MapPaneProps) {
return (
<ReactFlowProvider>
@@ -869,9 +719,10 @@ const infoPanel: CSSProperties = {
bottom: 16,
left: 16,
width: 260,
background: 'var(--rah-bg-base)',
border: '1px solid var(--rah-bg-active)',
background: 'var(--rah-bg-modal)',
border: '1px solid var(--rah-border)',
borderRadius: 8,
padding: 14,
zIndex: 10,
boxShadow: 'var(--rah-shadow-floating)',
};