feat: port holistic node refinement contract
This commit is contained in:
+173
-322
@@ -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' }}>✓</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' }}>✓</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 · 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)',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user