feat: sync pane workspace overhaul from private repo
- ship the expanded left-nav and pane header workspace updates - add dimension-driven browse flow, map view upgrades, and delete-node tooling - expand eval coverage and refresh the public UI docs for the new layout Generated with Codex
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { DimensionsPaneProps, PaneType } from './types';
|
||||
@@ -10,11 +11,13 @@ export default function DimensionsPane({
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onNodeOpen,
|
||||
refreshToken,
|
||||
onDataChanged,
|
||||
onDimensionSelect,
|
||||
}: DimensionsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
@@ -32,7 +35,13 @@ export default function DimensionsPane({
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
|
||||
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
|
||||
@@ -47,6 +56,7 @@ export default function DimensionsPane({
|
||||
refreshToken={refreshToken}
|
||||
onDataChanged={onDataChanged}
|
||||
onDimensionSelect={onDimensionSelect}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties }
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge as rfAddEdge,
|
||||
@@ -22,10 +21,11 @@ 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 { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils';
|
||||
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import './map/map-styles.css';
|
||||
|
||||
@@ -74,6 +74,7 @@ function MapPaneInner({
|
||||
// --- 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);
|
||||
|
||||
@@ -86,6 +87,7 @@ function MapPaneInner({
|
||||
|
||||
// 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(() => {
|
||||
@@ -115,6 +117,26 @@ function MapPaneInner({
|
||||
[lockedDimensions],
|
||||
);
|
||||
|
||||
const clusterLabels = useMemo(() => {
|
||||
if (viewMode !== 'dimension') {
|
||||
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 };
|
||||
current.x += node.position.x;
|
||||
current.y += node.position.y;
|
||||
current.count += 1;
|
||||
grouped.set(dimension, current);
|
||||
}
|
||||
return [...grouped.entries()].map(([dimension, totals]) => ({
|
||||
dimension,
|
||||
x: totals.x / totals.count,
|
||||
y: totals.y / totals.count - 84,
|
||||
}));
|
||||
}, [rfNodes, viewMode]);
|
||||
|
||||
// ----- Close dropdown on outside click -----
|
||||
useEffect(() => {
|
||||
if (!dimensionDropdownOpen) return;
|
||||
@@ -157,9 +179,7 @@ function MapPaneInner({
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
setLockedDimensions(
|
||||
(dimsPayload.data as DimensionInfo[]).filter(d => d.isPriority),
|
||||
);
|
||||
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -196,6 +216,8 @@ function MapPaneInner({
|
||||
connectedNodeIds,
|
||||
rfPositionsRef.current,
|
||||
dimensionIcons,
|
||||
viewMode,
|
||||
dbEdges,
|
||||
);
|
||||
|
||||
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
|
||||
@@ -203,7 +225,47 @@ function MapPaneInner({
|
||||
|
||||
setRfNodes(newRfNodes);
|
||||
setRfEdges(newRfEdges);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds]);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
|
||||
hasInitialFitRef.current = true;
|
||||
const hubNodeIds = baseNodes
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
setTimeout(() => {
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}, [rfNodes, loading, baseNodes, reactFlowInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
hasInitialFitRef.current = false;
|
||||
}, [selectedDimension, viewMode]);
|
||||
|
||||
const fitAllNodes = useCallback(() => {
|
||||
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
||||
}, [reactFlowInstance]);
|
||||
|
||||
const fitHubNodes = useCallback(() => {
|
||||
const hubNodeIds = baseNodes
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}, [baseNodes, reactFlowInstance]);
|
||||
|
||||
// ----- Node traversal: fetch connected nodes -----
|
||||
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
|
||||
@@ -379,7 +441,7 @@ function MapPaneInner({
|
||||
|
||||
// ----- Node drag → save position to metadata (debounced) -----
|
||||
const savePositionRef = useRef(
|
||||
debounce(async (nodeId: number, x: number, y: number) => {
|
||||
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${nodeId}`);
|
||||
if (!res.ok) return;
|
||||
@@ -393,7 +455,14 @@ function MapPaneInner({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
metadata: { ...mergedMeta, map_position: { x, y } },
|
||||
metadata: {
|
||||
...mergedMeta,
|
||||
map_positions: {
|
||||
...(mergedMeta.map_positions || {}),
|
||||
[mode]: { x, y },
|
||||
},
|
||||
[`map_position_${mode}`]: { x, y },
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -406,9 +475,21 @@ function MapPaneInner({
|
||||
const nodeId = parseInt(node.id);
|
||||
if (!isNaN(nodeId)) {
|
||||
rfPositionsRef.current.set(node.id, node.position);
|
||||
savePositionRef.current(nodeId, node.position.x, node.position.y);
|
||||
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
|
||||
}
|
||||
}, []);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
rfPositionsRef.current.clear();
|
||||
}, [viewMode]);
|
||||
|
||||
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 -----
|
||||
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
@@ -481,6 +562,39 @@ function MapPaneInner({
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => setViewMode('dimension')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : '#2a2a2a',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'dimension' ? '#a7f3b8' : '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Dimension View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('hub')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : '#2a2a2a',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'hub' ? '#a7f3b8' : '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Hub View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dimension filter dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -597,8 +711,6 @@ function MapPaneInner({
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={3}
|
||||
defaultEdgeOptions={{ type: 'rahEdge' }}
|
||||
@@ -608,16 +720,76 @@ function MapPaneInner({
|
||||
<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';
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as RahNodeData | undefined;
|
||||
return data?.primaryDimensionColor || '#2a2a2a';
|
||||
}}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{viewMode === 'dimension' && clusterLabels.map((label) => (
|
||||
<div
|
||||
key={label.dimension}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(${label.x}px, ${label.y}px)`,
|
||||
pointerEvents: 'none',
|
||||
color: '#7a7a7a',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{label.dimension}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={fitAllNodes}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 4,
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Fit all nodes"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
{viewMode === 'hub' && (
|
||||
<button
|
||||
onClick={fitHubNodes}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 4,
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Fit to hub nodes"
|
||||
>
|
||||
Hubs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected node info panel */}
|
||||
{selectedDbNode && (
|
||||
<div style={infoPanel}>
|
||||
|
||||
@@ -8,7 +8,9 @@ export default function PaneHeader({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
children
|
||||
tabBar,
|
||||
children,
|
||||
toolbarHostRef,
|
||||
}: PaneHeaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -56,32 +58,53 @@ export default function PaneHeader({
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
gap: '10px',
|
||||
background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
minHeight: '44px',
|
||||
cursor: slot && onSwapPanes ? 'grab' : 'default',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderRadius: isDragOver ? '6px' : '0',
|
||||
transition: 'background 0.15s ease',
|
||||
padding: '8px 12px',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
>
|
||||
{/* Children (tabs, etc.) - takes up available space */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
|
||||
{children}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
{tabBar ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
|
||||
{tabBar}
|
||||
</div>
|
||||
) : null}
|
||||
{children ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flexWrap: 'wrap' }}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{toolbarHostRef ? (
|
||||
<div
|
||||
ref={toolbarHostRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!tabBar && !children && !toolbarHostRef ? (
|
||||
<div style={{ color: '#666', fontSize: '12px' }} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Close button (when onCollapse is provided) */}
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #2a2a2a',
|
||||
background: '#111',
|
||||
color: '#777',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #3a3a3a',
|
||||
background: '#161616',
|
||||
color: '#c2c2c2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -91,17 +114,17 @@ export default function PaneHeader({
|
||||
}}
|
||||
title="Close pane"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
e.currentTarget.style.color = '#aaa';
|
||||
e.currentTarget.style.background = 'rgba(127, 29, 29, 0.22)';
|
||||
e.currentTarget.style.borderColor = 'rgba(248, 113, 113, 0.5)';
|
||||
e.currentTarget.style.color = '#fca5a5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#111';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#777';
|
||||
e.currentTarget.style.background = '#161616';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
e.currentTarget.style.color = '#c2c2c2';
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={15} strokeWidth={2.25} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import DatabaseTableView from '../views/DatabaseTableView';
|
||||
import type { BasePaneProps } from './types';
|
||||
@@ -19,6 +20,7 @@ export default function TablePane({
|
||||
onNodeClick,
|
||||
refreshToken
|
||||
}: TablePaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -27,11 +29,18 @@ export default function TablePane({
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar} />
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<DatabaseTableView
|
||||
onNodeClick={onNodeClick}
|
||||
refreshToken={refreshToken}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import ViewsOverlay from '../views/ViewsOverlay';
|
||||
import type { BasePaneProps, PaneAction, PaneType } from './types';
|
||||
@@ -11,6 +12,8 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalDimensionFilter?: string | null;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -19,12 +22,16 @@ export default function ViewsPane({
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onNodeClick,
|
||||
onNodeOpenInOtherPane,
|
||||
refreshToken,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalDimensionFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
@@ -37,7 +44,13 @@ export default function ViewsPane({
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ViewsOverlay
|
||||
onNodeClick={onNodeClick}
|
||||
@@ -45,6 +58,9 @@ export default function ViewsPane({
|
||||
refreshToken={refreshToken}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalDimensionFilter={externalDimensionFilter}
|
||||
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+163
-114
@@ -15,6 +15,8 @@ const DIMENSION_COLORS = [
|
||||
'#a855f7', // purple
|
||||
];
|
||||
|
||||
export type MapViewMode = 'dimension' | 'hub';
|
||||
|
||||
function hashDimensionColor(dimension: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < dimension.length; i++) {
|
||||
@@ -23,10 +25,21 @@ function hashDimensionColor(dimension: string): string {
|
||||
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
|
||||
}
|
||||
|
||||
export function getOrderedDimensions(dimensions: string[] | undefined): string[] {
|
||||
if (!dimensions || dimensions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [...dimensions].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function getPrimaryDimension(dimensions: string[] | undefined): string {
|
||||
const ordered = getOrderedDimensions(dimensions);
|
||||
return ordered[0] || 'Unsorted';
|
||||
}
|
||||
|
||||
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
|
||||
if (!dimensions || dimensions.length === 0) return undefined;
|
||||
// Use first dimension for border color
|
||||
return hashDimensionColor(dimensions[0]);
|
||||
const primary = getPrimaryDimension(dimensions);
|
||||
return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
|
||||
}
|
||||
|
||||
export interface RahNodeData {
|
||||
@@ -37,74 +50,155 @@ export interface RahNodeData {
|
||||
dbNode: DbNode;
|
||||
dimensionIcons?: Record<string, string>;
|
||||
primaryDimensionColor?: string;
|
||||
clusterKey?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_LIMIT = 200;
|
||||
const LABEL_THRESHOLD = 15;
|
||||
export const NODE_LIMIT = 200;
|
||||
export const LABEL_THRESHOLD = 15;
|
||||
|
||||
export { NODE_LIMIT, LABEL_THRESHOLD };
|
||||
|
||||
/**
|
||||
* Get node position from saved metadata or calculate using Fibonacci spiral.
|
||||
*/
|
||||
export function getNodePosition(
|
||||
export function getSavedMapPosition(
|
||||
node: DbNode,
|
||||
index: number,
|
||||
total: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
maxEdges: number,
|
||||
): { x: number; y: number } {
|
||||
// Check for saved position in metadata
|
||||
viewMode: MapViewMode,
|
||||
): { x: number; y: number } | null {
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
const savedPos = metadata?.map_position;
|
||||
if (savedPos?.x !== undefined && savedPos?.y !== undefined) {
|
||||
return { x: savedPos.x, y: savedPos.y };
|
||||
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
|
||||
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
|
||||
const saved = nested?.[viewMode] || scoped;
|
||||
if (saved?.x !== undefined && saved?.y !== undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
|
||||
const baseIds = new Set(baseNodes.map((node) => node.id));
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}
|
||||
|
||||
function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const groups = new Map<string, DbNode[]>();
|
||||
for (const node of nodes) {
|
||||
const key = getPrimaryDimension(node.dimensions);
|
||||
const existing = groups.get(key) || [];
|
||||
existing.push(node);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
|
||||
// Fibonacci spiral layout
|
||||
const edgeCount = node.edge_count ?? 0;
|
||||
const edgeRatio = maxEdges > 0 ? edgeCount / maxEdges : 0;
|
||||
const clusterKeys = [...groups.keys()].sort((a, b) => a.localeCompare(b));
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(clusterKeys.length || 1)));
|
||||
const clusterGapX = 360;
|
||||
const clusterGapY = 280;
|
||||
const originX = centerX - ((columns - 1) * clusterGapX) / 2;
|
||||
const rows = Math.max(1, Math.ceil(clusterKeys.length / columns));
|
||||
const originY = centerY - ((rows - 1) * clusterGapY) / 2;
|
||||
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
clusterKeys.forEach((clusterKey, clusterIndex) => {
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const clusterColumn = clusterIndex % columns;
|
||||
const clusterRow = Math.floor(clusterIndex / columns);
|
||||
const clusterCenterX = originX + clusterColumn * clusterGapX;
|
||||
const clusterCenterY = originY + clusterRow * clusterGapY;
|
||||
const clusterCols = Math.max(1, Math.ceil(Math.sqrt(clusterNodes.length)));
|
||||
|
||||
const isLabeled = index < LABEL_THRESHOLD;
|
||||
const labelSpacing = isLabeled ? 60 : 0;
|
||||
const containerSize = Math.min(centerX * 2, centerY * 2);
|
||||
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * containerSize * 0.35;
|
||||
const distance = baseDistance + index * 4;
|
||||
clusterNodes.forEach((node, index) => {
|
||||
const col = index % clusterCols;
|
||||
const row = Math.floor(index / clusterCols);
|
||||
const x = clusterCenterX + (col - (clusterCols - 1) / 2) * 120 + (row % 2 === 0 ? 0 : 18);
|
||||
const y = clusterCenterY + row * 96;
|
||||
positions.set(String(node.id), { x, y });
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
x: centerX + Math.cos(angle) * distance,
|
||||
y: centerY + Math.sin(angle) * distance,
|
||||
};
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position expanded (traversal) nodes in a circle around a reference node.
|
||||
*/
|
||||
export function getExpandedNodePosition(
|
||||
index: number,
|
||||
total: number,
|
||||
refX: number,
|
||||
refY: number,
|
||||
): { x: number; y: number } {
|
||||
const angle = (index / Math.max(total, 1)) * Math.PI * 2;
|
||||
const distance = 150 + (index % 3) * 40;
|
||||
return {
|
||||
x: refX + Math.cos(angle) * distance,
|
||||
y: refY + Math.sin(angle) * distance,
|
||||
};
|
||||
function buildHubLayout(
|
||||
nodes: DbNode[],
|
||||
dbEdges: DbEdge[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedNodes = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const hubCount = Math.max(1, Math.min(10, Math.ceil(Math.sqrt(sortedNodes.length || 1))));
|
||||
const hubs = sortedNodes.slice(0, hubCount);
|
||||
const hubIds = new Set(hubs.map((node) => node.id));
|
||||
|
||||
const adjacency = new Map<number, number[]>();
|
||||
for (const edge of dbEdges) {
|
||||
const from = adjacency.get(edge.from_node_id) || [];
|
||||
from.push(edge.to_node_id);
|
||||
adjacency.set(edge.from_node_id, from);
|
||||
|
||||
const to = adjacency.get(edge.to_node_id) || [];
|
||||
to.push(edge.from_node_id);
|
||||
adjacency.set(edge.to_node_id, to);
|
||||
}
|
||||
|
||||
const clusterMembers = new Map<number, DbNode[]>();
|
||||
for (const hub of hubs) {
|
||||
clusterMembers.set(hub.id, [hub]);
|
||||
}
|
||||
|
||||
const orphanNodes: DbNode[] = [];
|
||||
for (const node of sortedNodes) {
|
||||
if (hubIds.has(node.id)) continue;
|
||||
const neighbours = adjacency.get(node.id) || [];
|
||||
const connectedHub = hubs
|
||||
.filter((hub) => neighbours.includes(hub.id))
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))[0];
|
||||
|
||||
if (connectedHub) {
|
||||
const members = clusterMembers.get(connectedHub.id) || [connectedHub];
|
||||
members.push(node);
|
||||
clusterMembers.set(connectedHub.id, members);
|
||||
} else {
|
||||
orphanNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const hubRadius = Math.max(160, hubCount * 42);
|
||||
hubs.forEach((hub, index) => {
|
||||
const angle = (index / hubCount) * Math.PI * 2 - Math.PI / 2;
|
||||
const hubX = centerX + Math.cos(angle) * hubRadius;
|
||||
const hubY = centerY + Math.sin(angle) * hubRadius;
|
||||
positions.set(String(hub.id), { x: hubX, y: hubY });
|
||||
|
||||
const members = (clusterMembers.get(hub.id) || []).filter((member) => member.id !== hub.id);
|
||||
members.forEach((member, memberIndex) => {
|
||||
const memberAngle = (memberIndex / Math.max(members.length, 1)) * Math.PI * 2;
|
||||
const ringRadius = 115 + Math.floor(memberIndex / 10) * 46;
|
||||
positions.set(String(member.id), {
|
||||
x: hubX + Math.cos(memberAngle) * ringRadius,
|
||||
y: hubY + Math.sin(memberAngle) * ringRadius,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
orphanNodes.forEach((node, index) => {
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(orphanNodes.length)));
|
||||
const col = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
positions.set(String(node.id), {
|
||||
x: centerX - 220 + col * 110,
|
||||
y: centerY + hubRadius + 140 + row * 90,
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
|
||||
if (viewMode === 'dimension') {
|
||||
return getPrimaryDimension(node.dimensions);
|
||||
}
|
||||
return `hub:${node.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DB nodes into React Flow nodes.
|
||||
* When a node is selected, non-connected nodes get dimmed via className.
|
||||
*/
|
||||
export function toRFNodes(
|
||||
baseNodes: DbNode[],
|
||||
expandedNodes: DbNode[],
|
||||
@@ -113,20 +207,23 @@ export function toRFNodes(
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
dimensionIcons?: Record<string, string>,
|
||||
dimensionIcons: Record<string, string> | undefined,
|
||||
viewMode: MapViewMode,
|
||||
dbEdges: DbEdge[],
|
||||
): RFNode<RahNodeData>[] {
|
||||
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
|
||||
const baseNodeIds = new Set(baseNodes.map(n => n.id));
|
||||
const allNodes = getAllNodes(baseNodes, expandedNodes);
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
const clusterLayout = viewMode === 'dimension'
|
||||
? buildDimensionLayout(allNodes, centerX, centerY)
|
||||
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
|
||||
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
|
||||
|
||||
const rfNodes: RFNode<RahNodeData>[] = sortedBase.map((node, index) => {
|
||||
return allNodes.map((node) => {
|
||||
const id = String(node.id);
|
||||
// Prefer React Flow's current position (for drag state), then saved, then calculated
|
||||
const existingPos = existingPositions.get(id);
|
||||
const pos = existingPos || getNodePosition(node, index, sortedBase.length, centerX, centerY, maxEdges);
|
||||
|
||||
// Dim nodes that aren't selected or connected to selection
|
||||
const savedPos = getSavedMapPosition(node, viewMode);
|
||||
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
|
||||
const pos = existingPos || savedPos || fallbackPos;
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
|
||||
return {
|
||||
@@ -136,64 +233,16 @@ export function toRFNodes(
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: node.dimensions || [],
|
||||
dimensions: getOrderedDimensions(node.dimensions),
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: false,
|
||||
isExpanded: !baseNodeIds.has(node.id),
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add expanded nodes not already in base
|
||||
const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id));
|
||||
|
||||
// Find reference position for expanded nodes (the selected node)
|
||||
let refX = centerX;
|
||||
let refY = centerY;
|
||||
if (selectedNodeId) {
|
||||
const selectedRF = rfNodes.find(n => n.id === String(selectedNodeId));
|
||||
if (selectedRF) {
|
||||
refX = selectedRF.position.x;
|
||||
refY = selectedRF.position.y;
|
||||
}
|
||||
}
|
||||
|
||||
uniqueExpanded.forEach((node, index) => {
|
||||
const id = String(node.id);
|
||||
const existingPos = existingPositions.get(id);
|
||||
|
||||
// Check for saved metadata position
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
const savedPos = metadata?.map_position;
|
||||
|
||||
const pos = existingPos
|
||||
|| (savedPos?.x !== undefined ? { x: savedPos.x, y: savedPos.y } : null)
|
||||
|| getExpandedNodePosition(index, uniqueExpanded.length, refX, refY);
|
||||
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
|
||||
rfNodes.push({
|
||||
id,
|
||||
type: 'rahNode',
|
||||
position: pos,
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: node.dimensions || [],
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: true,
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return rfNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalDimensionFilter?: string | null;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
// TablePane specific props
|
||||
@@ -102,6 +104,7 @@ export interface PaneHeaderProps {
|
||||
onSwapPanes?: () => void;
|
||||
tabBar?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
toolbarHostRef?: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
// Labels for pane types
|
||||
|
||||
Reference in New Issue
Block a user