feat: sync map pane exploration overhaul

- port the read-only map exploration behavior and focus wiring from the private repo
- align the OS map pane with the shipped focused-node and overview interactions

Generated with Claude Code
This commit is contained in:
“BeeRad”
2026-04-15 20:17:35 +10:00
parent 4c9f2c6f5a
commit 097c4c61bb
9 changed files with 902 additions and 799 deletions
+31
View File
@@ -19,6 +19,7 @@ interface FocusPanelProps {
activeTab: number | null; activeTab: number | null;
onTabSelect: (nodeId: number) => void; onTabSelect: (nodeId: number) => void;
onNodeClick?: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void;
onOpenInMap?: () => void;
onTabClose: (nodeId: number) => void; onTabClose: (nodeId: number) => void;
refreshTrigger?: number; refreshTrigger?: number;
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
@@ -32,6 +33,7 @@ export default function FocusPanel({
activeTab, activeTab,
onTabSelect, onTabSelect,
onNodeClick, onNodeClick,
onOpenInMap,
onTabClose, onTabClose,
refreshTrigger, refreshTrigger,
onTextSelect, onTextSelect,
@@ -1107,6 +1109,16 @@ export default function FocusPanel({
<Check size={11} strokeWidth={3} /> <Check size={11} strokeWidth={3} />
</span> </span>
) : null} ) : null}
{onOpenInMap ? (
<button
type="button"
className="node-map-button"
onClick={onOpenInMap}
title="Open this node in the map"
>
Map
</button>
) : null}
<button <button
type="button" type="button"
className="delete-node-button node-delete-button" className="delete-node-button node-delete-button"
@@ -1519,6 +1531,25 @@ export default function FocusPanel({
flex-shrink: 0; flex-shrink: 0;
} }
.node-map-button {
border: 1px solid var(--rah-border-strong);
background: var(--rah-bg-panel);
color: var(--rah-text-muted);
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
padding: 3px 8px;
cursor: pointer;
flex-shrink: 0;
}
.node-map-button:hover {
border-color: var(--rah-accent-green-soft-strong);
color: var(--rah-accent-green);
background: var(--rah-accent-green-soft);
}
.node-delete-button { .node-delete-button {
width: 20px; width: 20px;
height: 20px; height: 20px;
+58 -13
View File
@@ -239,6 +239,8 @@ export default function ThreePanelLayout() {
() => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))), () => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))),
[visiblePaneCount] [visiblePaneCount]
); );
const [focusedNodeId, setFocusedNodeId] = useState<number | null>(null);
const [isMapFocusSuppressed, setIsMapFocusSuppressed] = useState(false);
const isPanelExpanded = useCallback((slot: SlotId) => { const isPanelExpanded = useCallback((slot: SlotId) => {
return visibleSlots.includes(slot); return visibleSlots.includes(slot);
@@ -307,23 +309,47 @@ export default function ThreePanelLayout() {
} }
}, [activePane, visibleSlots]); }, [activePane, visibleSlots]);
const activeNodeId = useMemo(() => { const deriveFallbackFocusedNode = useCallback((): number | null => {
const activeSlotState = slotStates[activePane]; const orderedSlots: SlotId[] = [activePane, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== activePane)];
const activeTab = activeSlotState ? getActiveTab(activeSlotState) : undefined;
if (activeTab?.type === 'node' && activeTab.nodeId != null) {
return activeTab.nodeId;
}
for (const slot of ['A', 'B', 'C'] as SlotId[]) { for (const slot of orderedSlots) {
const state = slotStates[slot]; const state = slotStates[slot];
const tab = state ? getActiveTab(state) : undefined; const activeTab = state ? getActiveTab(state) : undefined;
if (tab?.type === 'node' && tab.nodeId != null) { if (activeTab?.type === 'node' && activeTab.nodeId != null) {
return tab.nodeId; return activeTab.nodeId;
} }
} }
return allOpenNodeIds[0] ?? null; for (const slot of orderedSlots) {
}, [activePane, allOpenNodeIds, slotStates]); const fallbackTab = slotStates[slot]?.tabs.find((tab) => tab.type === 'node' && tab.nodeId != null);
if (fallbackTab?.nodeId != null) {
return fallbackTab.nodeId;
}
}
return null;
}, [activePane, slotStates]);
useEffect(() => {
if (focusedNodeId == null) {
return;
}
if (!allOpenNodeIds.includes(focusedNodeId)) {
setFocusedNodeId(deriveFallbackFocusedNode());
}
}, [allOpenNodeIds, deriveFallbackFocusedNode, focusedNodeId]);
useEffect(() => {
if (focusedNodeId != null || isMapFocusSuppressed) {
return;
}
const initialFocusedNode = deriveFallbackFocusedNode();
if (initialFocusedNode != null) {
setFocusedNodeId(initialFocusedNode);
}
}, [deriveFallbackFocusedNode, focusedNodeId, isMapFocusSuppressed]);
const handleRefreshAll = useCallback(() => { const handleRefreshAll = useCallback(() => {
setNodesPanelRefresh((prev) => prev + 1); setNodesPanelRefresh((prev) => prev + 1);
@@ -458,6 +484,8 @@ export default function ThreePanelLayout() {
setPanelExpanded(slot, true); setPanelExpanded(slot, true);
setActivePane(slot); setActivePane(slot);
setIsMapFocusSuppressed(false);
setFocusedNodeId(nodeId);
}, [getSlotSetter, setPanelExpanded]); }, [getSlotSetter, setPanelExpanded]);
const closeTabInSlot = useCallback((slot: SlotId, tabId: string) => { const closeTabInSlot = useCallback((slot: SlotId, tabId: string) => {
@@ -487,6 +515,8 @@ export default function ThreePanelLayout() {
const state = getSlotState(slot); const state = getSlotState(slot);
if (state?.tabs.some((tab) => tab.id === existingTabId)) { if (state?.tabs.some((tab) => tab.id === existingTabId)) {
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId }); getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId });
setIsMapFocusSuppressed(false);
setFocusedNodeId(nodeId);
setActivePane(slot); setActivePane(slot);
return; return;
} }
@@ -505,6 +535,8 @@ export default function ThreePanelLayout() {
if (preferredNodeTarget) { if (preferredNodeTarget) {
addNodeTabToSlot(preferredNodeTarget, nodeId); addNodeTabToSlot(preferredNodeTarget, nodeId);
setIsMapFocusSuppressed(false);
setFocusedNodeId(nodeId);
setActivePane(preferredNodeTarget); setActivePane(preferredNodeTarget);
return; return;
} }
@@ -519,6 +551,8 @@ export default function ThreePanelLayout() {
?? 'A'; ?? 'A';
addNodeTabToSlot(target, nodeId); addNodeTabToSlot(target, nodeId);
setIsMapFocusSuppressed(false);
setFocusedNodeId(nodeId);
}, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]); }, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]);
const openPaneSingleton = useCallback((paneType: Exclude<PaneType, 'node'>, preferredSlot?: SlotId) => { const openPaneSingleton = useCallback((paneType: Exclude<PaneType, 'node'>, preferredSlot?: SlotId) => {
@@ -552,9 +586,19 @@ export default function ThreePanelLayout() {
const state = getSlotState(slot); const state = getSlotState(slot);
if (!state) return; if (!state) return;
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: tabId }); getSlotSetter(slot)({ tabs: state.tabs, activeTabId: tabId });
const tab = state.tabs.find((current) => current.id === tabId);
if (tab?.type === 'node' && tab.nodeId != null) {
setIsMapFocusSuppressed(false);
setFocusedNodeId(tab.nodeId);
}
setActivePane(slot); setActivePane(slot);
}, [getSlotSetter, getSlotState]); }, [getSlotSetter, getSlotState]);
const clearMapFocus = useCallback(() => {
setIsMapFocusSuppressed(true);
setFocusedNodeId(null);
}, []);
const handleNodeDeleted = useCallback((nodeId: number) => { const handleNodeDeleted = useCallback((nodeId: number) => {
const tabId = createTabId('node', nodeId); const tabId = createTabId('node', nodeId);
(['A', 'B', 'C'] as SlotId[]).forEach((slot) => closeTabInSlot(slot, tabId)); (['A', 'B', 'C'] as SlotId[]).forEach((slot) => closeTabInSlot(slot, tabId));
@@ -785,7 +829,8 @@ export default function ThreePanelLayout() {
<MapPane <MapPane
{...commonProps} {...commonProps}
onNodeClick={(nodeId) => openNodeFromSlot(nodeId, slot)} onNodeClick={(nodeId) => openNodeFromSlot(nodeId, slot)}
activeTabId={activeNodeId} focusedNodeId={focusedNodeId}
onClearFocus={clearMapFocus}
/> />
); );
case 'views': case 'views':
File diff suppressed because it is too large Load Diff
+1
View File
@@ -170,6 +170,7 @@ export default function NodePane({
activeTab={activeTab} activeTab={activeTab}
onTabSelect={onTabSelect} onTabSelect={onTabSelect}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onOpenInMap={onPaneAction ? () => onPaneAction({ type: 'switch-pane-type', paneType: 'map' }) : undefined}
onTabClose={onTabClose} onTabClose={onTabClose}
refreshTrigger={refreshTrigger} refreshTrigger={refreshTrigger}
onTextSelect={onTextSelect} onTextSelect={onTextSelect}
+7 -5
View File
@@ -3,7 +3,7 @@
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { import {
BaseEdge, BaseEdge,
getStraightPath, getBezierPath,
type EdgeProps, type EdgeProps,
} from '@xyflow/react'; } from '@xyflow/react';
@@ -25,11 +25,12 @@ function RahEdgeComponent({
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const explanation = (data as RahEdgeData | undefined)?.explanation; const explanation = (data as RahEdgeData | undefined)?.explanation;
const [edgePath, labelX, labelY] = getStraightPath({ const [edgePath, labelX, labelY] = getBezierPath({
sourceX, sourceX,
sourceY, sourceY,
targetX, targetX,
targetY, targetY,
curvature: 0.18,
}); });
return ( return (
@@ -45,7 +46,7 @@ function RahEdgeComponent({
strokeWidth={12} strokeWidth={12}
style={{ cursor: 'default' }} style={{ cursor: 'default' }}
/> />
<BaseEdge id={id} path={edgePath} style={style} /> <BaseEdge id={id} path={edgePath} style={{ strokeLinecap: 'round', ...style }} />
{hovered && explanation && ( {hovered && explanation && (
<foreignObject <foreignObject
x={labelX - 80} x={labelX - 80}
@@ -56,8 +57,8 @@ function RahEdgeComponent({
> >
<div <div
style={{ style={{
background: 'var(--rah-bg-active)', background: 'var(--rah-bg-modal)',
border: '1px solid var(--rah-border-stronger)', border: '1px solid var(--rah-border-strong)',
borderRadius: 4, borderRadius: 4,
padding: '2px 8px', padding: '2px 8px',
fontSize: 10, fontSize: 10,
@@ -67,6 +68,7 @@ function RahEdgeComponent({
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
maxWidth: 160, maxWidth: 160,
boxShadow: 'var(--rah-shadow-floating)',
}} }}
> >
{explanation} {explanation}
+17 -21
View File
@@ -8,40 +8,36 @@ import { getNodeIcon } from '@/utils/nodeIcons';
type RahNodeType = Node<RahNodeData, 'rahNode'>; type RahNodeType = Node<RahNodeData, 'rahNode'>;
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) { function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data; const { label, dbNode, role, prominence } = data;
const isTop = !isExpanded && edgeCount > 3; const isSelected = selected || role === 'selected';
const sizeScale = role === 'selected'
? 1.08
: role === 'first-hop'
? 1.02 + prominence * 0.06
: 0.92 + prominence * 0.12;
const strongNode = prominence > 0.72 && role === 'overview';
return ( return (
<div <div
className={[ className={[
'rah-map-node', 'rah-map-node',
isExpanded && 'rah-map-node--expanded', role === 'selected' && 'rah-map-node--selected',
isTop && 'rah-map-node--top', role === 'first-hop' && 'rah-map-node--first-hop',
selected && 'rah-map-node--selected', role === 'second-hop' && 'rah-map-node--second-hop',
role === 'overview' && 'rah-map-node--overview',
isSelected && 'rah-map-node--active',
strongNode && 'rah-map-node--strong',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined} style={{ transform: `scale(${sizeScale})` }}
> >
<Handle <Handle type="target" position={Position.Top} className="rah-map-handle rah-map-handle--hidden" isConnectable={false} />
type="target"
position={Position.Top}
className="rah-map-handle"
/>
<div className="rah-map-node__title"> <div className="rah-map-node__title">
<span className="rah-map-node__icon"> <span className="rah-map-node__icon">
{getNodeIcon(dbNode, 14)} {getNodeIcon(dbNode, 14)}
</span> </span>
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label} {label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
</div> </div>
{(isTop || isExpanded) && clusterLabel && ( <Handle type="source" position={Position.Bottom} className="rah-map-handle rah-map-handle--hidden" isConnectable={false} />
<div className="rah-map-node__dims">
{clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
</div>
)}
<Handle
type="source"
position={Position.Bottom}
className="rah-map-handle"
/>
</div> </div>
); );
} }
+45 -69
View File
@@ -14,22 +14,15 @@
/* Edges */ /* Edges */
.rah-map-wrapper .react-flow__edge-path { .rah-map-wrapper .react-flow__edge-path {
stroke: var(--rah-border-strong); stroke: rgba(148, 163, 184, 0.38);
stroke-width: 1.5; stroke-width: 1.5;
} }
.rah-map-wrapper .react-flow__edge.selected .react-flow__edge-path { .rah-map-wrapper .react-flow__edge.selected .react-flow__edge-path {
stroke: #22c55e; stroke: var(--rah-accent-green);
stroke-width: 2; stroke-width: 2;
} }
/* Connection line while dragging */
.rah-map-wrapper .react-flow__connection-path {
stroke: #22c55e;
stroke-width: 2;
stroke-dasharray: 5 3;
}
/* Attribution */ /* Attribution */
.rah-map-wrapper .react-flow__attribution { .rah-map-wrapper .react-flow__attribution {
display: none; display: none;
@@ -42,49 +35,62 @@
/* Selection box */ /* Selection box */
.rah-map-wrapper .react-flow__selection { .rah-map-wrapper .react-flow__selection {
border: 1px solid rgba(34, 197, 94, 0.4); border: 1px solid var(--rah-accent-green-soft-strong);
background: rgba(34, 197, 94, 0.05); background: var(--rah-accent-green-soft);
} }
/* Custom node styles */ /* Custom node styles */
.rah-map-node { .rah-map-node {
background: var(--rah-bg-surface); background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong); border: 1px solid var(--rah-border-strong);
border-radius: 8px; border-radius: 10px;
padding: 8px 12px; padding: 6px 8px;
color: var(--rah-text-base); color: var(--rah-text-base);
font-size: 12px; font-size: 11px;
min-width: 60px; min-width: 56px;
max-width: 180px; max-width: 168px;
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, opacity 0.15s ease;
cursor: pointer; cursor: pointer;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
} }
.rah-map-node:hover { .rah-map-node:hover {
border-color: var(--rah-border-stronger); border-color: var(--rah-border-stronger);
background: var(--rah-bg-hover);
} }
.rah-map-node--selected { .rah-map-node--active {
border-color: #22c55e !important; border-color: var(--rah-accent-green) !important;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
} }
.rah-map-node--top { .rah-map-node--selected {
border-color: #166534; border-color: color-mix(in srgb, var(--rah-accent-green) 80%, white) !important;
background: var(--rah-bg-panel); background: color-mix(in srgb, var(--rah-accent-green-soft) 70%, var(--rah-bg-modal));
} }
.rah-map-node--expanded { .rah-map-node--first-hop {
border-color: #b45309; border-color: color-mix(in srgb, var(--rah-accent-green) 45%, var(--rah-border-strong));
background: var(--rah-bg-panel); background: color-mix(in srgb, var(--rah-accent-green-soft) 42%, var(--rah-bg-modal));
} }
.rah-map-node--expanded .rah-map-node__title { .rah-map-node--second-hop {
color: #fbbf24; border-color: color-mix(in srgb, #94a3b8 55%, var(--rah-border-strong));
background: color-mix(in srgb, #94a3b8 12%, var(--rah-bg-modal));
opacity: 0.88;
}
.rah-map-node--overview {
background: color-mix(in srgb, var(--rah-bg-panel) 60%, var(--rah-bg-modal));
}
.rah-map-node--strong {
border-color: color-mix(in srgb, var(--rah-accent-green) 28%, var(--rah-border-strong));
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
} }
.rah-map-node__title { .rah-map-node__title {
font-weight: 500; font-weight: 600;
line-height: 1.3; line-height: 1.3;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -100,49 +106,19 @@
align-items: center; align-items: center;
} }
.rah-map-node__dims { .rah-map-handle--hidden {
margin-top: 4px; opacity: 0 !important;
font-size: 10px; pointer-events: none !important;
color: var(--rah-text-muted); width: 2px !important;
white-space: nowrap; height: 2px !important;
overflow: hidden; min-width: 2px !important;
text-overflow: ellipsis; min-height: 2px !important;
border: none !important;
} }
/* Handles */ /* MiniMap dark theme */
.rah-map-handle {
width: 8px !important;
height: 8px !important;
background: #22c55e !important;
border: 2px solid var(--rah-bg-surface) !important;
opacity: 0;
transition: opacity 0.15s ease;
}
.rah-map-node:hover .rah-map-handle,
.react-flow__node.selected .rah-map-handle {
opacity: 1;
}
/* Connecting state: show all handles */
.rah-map-wrapper .react-flow__node.connecting .rah-map-handle,
.rah-map-wrapper.connecting .rah-map-handle {
opacity: 1;
}
/* Dimmed nodes (not selected or connected to selection) */
.rah-map-wrapper .react-flow__node.dimmed {
opacity: 0.2;
transition: opacity 0.2s ease;
}
.rah-map-wrapper .react-flow__node.dimmed:hover {
opacity: 0.6;
}
/* MiniMap */
.rah-map-wrapper .react-flow__minimap { .rah-map-wrapper .react-flow__minimap {
background: var(--rah-bg-base); background: var(--rah-bg-panel);
border: 1px solid var(--rah-border); border: 1px solid var(--rah-border);
border-radius: 6px; border-radius: 6px;
} }
+285 -203
View File
@@ -1,285 +1,367 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database'; import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react'; import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
const CLUSTER_COLORS = [ export type MapViewMode = 'overview' | 'focused';
'#22c55e', export type MapNodeRole = 'overview' | 'selected' | 'first-hop' | 'second-hop';
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#f97316',
'#14b8a6',
'#a855f7',
];
export type MapViewMode = 'context' | 'hub'; export interface FocusedGraph {
selectedNodeId: number;
function hashClusterColor(label: string): string { firstHopIds: number[];
let hash = 0; secondHopIds: number[];
for (let i = 0; i < label.length; i++) { nodeIds: Set<number>;
hash = ((hash << 5) - hash + label.charCodeAt(i)) | 0;
}
return CLUSTER_COLORS[Math.abs(hash) % CLUSTER_COLORS.length];
}
export function getNodeClusterLabel(node: DbNode): string {
return node.context?.name?.trim() || 'Unscoped';
}
export function getClusterColor(label: string): string {
return label === 'Unscoped' ? '#4b5563' : hashClusterColor(label);
} }
export interface RahNodeData { export interface RahNodeData {
label: string; label: string;
clusterLabel: string;
edgeCount: number; edgeCount: number;
isExpanded: boolean;
dbNode: DbNode; dbNode: DbNode;
clusterColor?: string; role: MapNodeRole;
clusterKey?: string; connectionCount: number;
prominence: number;
[key: string]: unknown; [key: string]: unknown;
} }
export const NODE_LIMIT = 200; export const NODE_LIMIT = 200;
export const LABEL_THRESHOLD = 15; export const FIRST_HOP_LIMIT = 10;
export const SECOND_HOP_LIMIT = 18;
export function getSavedMapPosition( type Point = { x: number; y: number };
node: DbNode,
viewMode: MapViewMode,
): { x: number; y: number } | null {
const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata)
: node.metadata;
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined; export function buildAdjacency(dbEdges: DbEdge[]): Map<number, Set<number>> {
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined; const adjacency = new Map<number, Set<number>>();
const saved = nested?.[viewMode] || scoped;
if (saved?.x !== undefined && saved?.y !== undefined) { for (const edge of dbEdges) {
return { x: saved.x, y: saved.y }; if (!adjacency.has(edge.from_node_id)) adjacency.set(edge.from_node_id, new Set());
if (!adjacency.has(edge.to_node_id)) adjacency.set(edge.to_node_id, new Set());
adjacency.get(edge.from_node_id)?.add(edge.to_node_id);
adjacency.get(edge.to_node_id)?.add(edge.from_node_id);
} }
return null; return adjacency;
} }
function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] { export function buildDegreeMap(dbEdges: DbEdge[]): Map<number, number> {
const baseIds = new Set(baseNodes.map((node) => node.id)); const adjacency = buildAdjacency(dbEdges);
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))]; const degreeMap = new Map<number, number>();
adjacency.forEach((neighbours, nodeId) => {
degreeMap.set(nodeId, neighbours.size);
});
return degreeMap;
} }
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> { export function buildFocusedGraph(
const positions = new Map<string, { x: number; y: number }>(); selectedNodeId: number,
const groups = new Map<string, DbNode[]>(); adjacency: Map<number, Set<number>>,
degreeMap: Map<number, number>,
): FocusedGraph {
const directNeighbours = [...(adjacency.get(selectedNodeId) ?? [])]
.sort((a, b) => {
const degreeDiff = (degreeMap.get(b) ?? 0) - (degreeMap.get(a) ?? 0);
return degreeDiff !== 0 ? degreeDiff : a - b;
})
.slice(0, FIRST_HOP_LIMIT);
for (const node of nodes) { const firstHopSet = new Set(directNeighbours);
const key = getNodeClusterLabel(node); const secondHopScores = new Map<number, { sharedCount: number; degree: number }>();
const existing = groups.get(key) || [];
existing.push(node); for (const firstHopId of directNeighbours) {
groups.set(key, existing); for (const candidateId of adjacency.get(firstHopId) ?? []) {
if (candidateId === selectedNodeId || firstHopSet.has(candidateId)) continue;
const existing = secondHopScores.get(candidateId) ?? {
sharedCount: 0,
degree: degreeMap.get(candidateId) ?? 0,
};
existing.sharedCount += 1;
secondHopScores.set(candidateId, existing);
}
} }
const clusterKeys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); const secondHopIds = [...secondHopScores.entries()]
const columns = Math.max(1, Math.ceil(Math.sqrt(clusterKeys.length || 1))); .sort((a, b) => {
const clusterGapX = 360; const sharedDiff = b[1].sharedCount - a[1].sharedCount;
const clusterGapY = 280; if (sharedDiff !== 0) return sharedDiff;
const originX = centerX - ((columns - 1) * clusterGapX) / 2;
const rows = Math.max(1, Math.ceil(clusterKeys.length / columns));
const originY = centerY - ((rows - 1) * clusterGapY) / 2;
clusterKeys.forEach((clusterKey, clusterIndex) => { const degreeDiff = b[1].degree - a[1].degree;
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => { return degreeDiff !== 0 ? degreeDiff : a[0] - b[0];
return (b.edge_count ?? 0) - (a.edge_count ?? 0); })
.slice(0, SECOND_HOP_LIMIT)
.map(([nodeId]) => nodeId);
return {
selectedNodeId,
firstHopIds: directNeighbours,
secondHopIds,
nodeIds: new Set([selectedNodeId, ...directNeighbours, ...secondHopIds]),
};
}
function buildOverviewLayout(
nodes: DbNode[],
adjacency: Map<number, Set<number>>,
degreeMap: Map<number, number>,
centerX: number,
centerY: number,
): Map<string, Point> {
const positions = new Map<string, Point>();
const sortNodesByWeight = (left: DbNode, right: DbNode) => {
const degreeDiff = (degreeMap.get(right.id) ?? right.edge_count ?? 0) - (degreeMap.get(left.id) ?? left.edge_count ?? 0);
return degreeDiff !== 0 ? degreeDiff : left.id - right.id;
};
const sortedNodes = [...nodes].sort(sortNodesByWeight);
if (sortedNodes.length === 0) {
return positions;
}
const maxDegree = Math.max(...sortedNodes.map((node) => degreeMap.get(node.id) ?? node.edge_count ?? 0), 1);
const minRadius = 24;
const maxRadius = Math.max(280, Math.min(520, 140 + sortedNodes.length * 2.2));
const nodeIds = sortedNodes.map((node) => node.id);
const state = new Map<number, { x: number; y: number; vx: number; vy: number; targetRadius: number; mass: number }>();
sortedNodes.forEach((node, index) => {
const degree = degreeMap.get(node.id) ?? node.edge_count ?? 0;
const centrality = degree / maxDegree;
const targetRadius = minRadius + (1 - centrality) * (maxRadius - minRadius);
const angle = (index / Math.max(sortedNodes.length, 1)) * Math.PI * 2 - Math.PI / 2;
state.set(node.id, {
x: centerX + Math.cos(angle) * targetRadius,
y: centerY + Math.sin(angle) * targetRadius,
vx: 0,
vy: 0,
targetRadius,
mass: 1 + centrality * 1.8,
});
});
const iterations = 140;
for (let step = 0; step < iterations; step += 1) {
const alpha = 1 - step / iterations;
for (let i = 0; i < nodeIds.length; i += 1) {
const leftId = nodeIds[i];
const left = state.get(leftId);
if (!left) continue;
for (let j = i + 1; j < nodeIds.length; j += 1) {
const rightId = nodeIds[j];
const right = state.get(rightId);
if (!right) continue;
const dx = right.x - left.x;
const dy = right.y - left.y;
const distanceSquared = dx * dx + dy * dy + 0.01;
const distance = Math.sqrt(distanceSquared);
const repulsion = (2200 * alpha) / distanceSquared;
const fx = (dx / distance) * repulsion;
const fy = (dy / distance) * repulsion;
left.vx -= fx / left.mass;
left.vy -= fy / left.mass;
right.vx += fx / right.mass;
right.vy += fy / right.mass;
}
}
sortedNodes.forEach((node) => {
const current = state.get(node.id);
if (!current) return;
for (const neighbourId of adjacency.get(node.id) ?? []) {
if (!state.has(neighbourId) || neighbourId <= node.id) continue;
const neighbour = state.get(neighbourId);
if (!neighbour) continue;
const dx = neighbour.x - current.x;
const dy = neighbour.y - current.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const desired = 70 + Math.min(
Math.abs(current.targetRadius - neighbour.targetRadius) * 0.35 + 24,
150,
);
const pull = (distance - desired) * 0.0048 * alpha;
const fx = (dx / distance) * pull;
const fy = (dy / distance) * pull;
current.vx += fx;
current.vy += fy;
neighbour.vx -= fx;
neighbour.vy -= fy;
}
}); });
const clusterColumn = clusterIndex % columns; sortedNodes.forEach((node) => {
const clusterRow = Math.floor(clusterIndex / columns); const current = state.get(node.id);
const clusterCenterX = originX + clusterColumn * clusterGapX; if (!current) return;
const clusterCenterY = originY + clusterRow * clusterGapY;
const clusterCols = Math.max(1, Math.ceil(Math.sqrt(clusterNodes.length)));
clusterNodes.forEach((node, index) => { const dx = current.x - centerX;
const col = index % clusterCols; const dy = current.y - centerY;
const row = Math.floor(index / clusterCols); const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const x = clusterCenterX + (col - (clusterCols - 1) / 2) * 120 + (row % 2 === 0 ? 0 : 18); const radialError = distance - current.targetRadius;
const y = clusterCenterY + row * 96; const radialPull = radialError * 0.0075;
positions.set(String(node.id), { x, y });
current.vx -= (dx / distance) * radialPull;
current.vy -= (dy / distance) * radialPull;
if (current.targetRadius <= minRadius + 8) {
current.vx += (centerX - current.x) * 0.0035;
current.vy += (centerY - current.y) * 0.0035;
}
current.vx *= 0.86;
current.vy *= 0.86;
current.x += current.vx;
current.y += current.vy;
}); });
}
sortedNodes.forEach((node) => {
const current = state.get(node.id);
if (!current) return;
positions.set(String(node.id), { x: current.x, y: current.y });
}); });
return positions; return positions;
} }
function buildHubLayout( function buildFocusedLayout(
nodes: DbNode[], nodes: DbNode[],
dbEdges: DbEdge[], focusedGraph: FocusedGraph,
adjacency: Map<number, Set<number>>,
centerX: number, centerX: number,
centerY: number, centerY: number,
): Map<string, { x: number; y: number }> { ): Map<string, Point> {
const positions = new Map<string, { x: number; y: number }>(); const positions = new Map<string, Point>();
const sortedNodes = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); const firstHopCount = Math.max(1, focusedGraph.firstHopIds.length);
const hubCount = Math.max(1, Math.min(10, Math.ceil(Math.sqrt(sortedNodes.length || 1)))); const firstHopRadius = Math.max(220, Math.min(320, 180 + firstHopCount * 12));
const hubs = sortedNodes.slice(0, hubCount);
const hubIds = new Set(hubs.map((node) => node.id));
const adjacency = new Map<number, number[]>(); positions.set(String(focusedGraph.selectedNodeId), { x: centerX, y: centerY });
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) || []; const firstHopAngles = new Map<number, number>();
to.push(edge.from_node_id); focusedGraph.firstHopIds.forEach((nodeId, index) => {
adjacency.set(edge.to_node_id, to); const angle = (index / firstHopCount) * Math.PI * 2 - Math.PI / 2;
} firstHopAngles.set(nodeId, angle);
positions.set(String(nodeId), {
x: centerX + Math.cos(angle) * firstHopRadius,
y: centerY + Math.sin(angle) * firstHopRadius,
});
});
const clusterMembers = new Map<number, DbNode[]>(); const secondHopByParent = new Map<number, number[]>();
for (const hub of hubs) { focusedGraph.firstHopIds.forEach((nodeId) => secondHopByParent.set(nodeId, []));
clusterMembers.set(hub.id, [hub]);
}
const orphanNodes: DbNode[] = []; focusedGraph.secondHopIds.forEach((nodeId) => {
for (const node of sortedNodes) { const parentId = focusedGraph.firstHopIds
if (hubIds.has(node.id)) continue; .filter((firstHopId) => adjacency.get(nodeId)?.has(firstHopId))
.sort((a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0))[0];
const neighbours = adjacency.get(node.id) || []; const targetParent = parentId ?? focusedGraph.firstHopIds[0];
const connectedHub = hubs const group = secondHopByParent.get(targetParent) ?? [];
.filter((hub) => neighbours.includes(hub.id)) group.push(nodeId);
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))[0]; secondHopByParent.set(targetParent, group);
});
if (connectedHub) { secondHopByParent.forEach((group, parentId) => {
const members = clusterMembers.get(connectedHub.id) || [connectedHub]; const parentAngle = firstHopAngles.get(parentId) ?? -Math.PI / 2;
members.push(node); group.forEach((nodeId, index) => {
clusterMembers.set(connectedHub.id, members); const localOffset = (index - (group.length - 1) / 2) * 0.24;
} else { const angle = parentAngle + localOffset;
orphanNodes.push(node); const radius = firstHopRadius + 150 + Math.floor(index / 4) * 30;
} positions.set(String(nodeId), {
} x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
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) => { nodes.forEach((node) => {
const columns = Math.max(1, Math.ceil(Math.sqrt(orphanNodes.length))); if (!positions.has(String(node.id))) {
const col = index % columns; positions.set(String(node.id), { x: centerX, y: centerY });
const row = Math.floor(index / columns); }
positions.set(String(node.id), {
x: centerX - 220 + col * 110,
y: centerY + hubRadius + 140 + row * 90,
});
}); });
return positions; return positions;
} }
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string { export function toRFNodes(params: {
if (viewMode === 'context') { nodes: DbNode[];
return getNodeClusterLabel(node); viewMode: MapViewMode;
} degreeMap: Map<number, number>;
adjacency: Map<number, Set<number>>;
focusedGraph: FocusedGraph | null;
centerX: number;
centerY: number;
}): RFNode<RahNodeData>[] {
const { nodes, viewMode, degreeMap, adjacency, focusedGraph, centerX, centerY } = params;
return `hub:${node.id}`; const positions = viewMode === 'focused' && focusedGraph
} ? buildFocusedLayout(nodes, focusedGraph, adjacency, centerX, centerY)
: buildOverviewLayout(nodes, adjacency, degreeMap, centerX, centerY);
export function toRFNodes( return nodes.map((node) => {
baseNodes: DbNode[], const role: MapNodeRole = focusedGraph
expandedNodes: DbNode[], ? node.id === focusedGraph.selectedNodeId
centerX: number, ? 'selected'
centerY: number, : focusedGraph.firstHopIds.includes(node.id)
selectedNodeId: number | null, ? 'first-hop'
connectedNodeIds: Set<number>, : 'second-hop'
existingPositions: Map<string, { x: number; y: number }>, : 'overview';
viewMode: MapViewMode,
dbEdges: DbEdge[],
): RFNode<RahNodeData>[] {
const allNodes = getAllNodes(baseNodes, expandedNodes);
const hasSelection = selectedNodeId !== null;
const clusterLayout = viewMode === 'context'
? buildContextLayout(allNodes, centerX, centerY)
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
return allNodes.map((node) => {
const id = String(node.id);
const existingPos = existingPositions.get(id);
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);
const clusterLabel = getNodeClusterLabel(node);
return { return {
id, id: String(node.id),
type: 'rahNode', type: 'rahNode',
position: pos, position: positions.get(String(node.id)) ?? { x: centerX, y: centerY },
className: isDimmed ? 'dimmed' : undefined,
data: { data: {
label: node.title || 'Untitled', label: node.title || 'Untitled',
clusterLabel, edgeCount: degreeMap.get(node.id) ?? node.edge_count ?? 0,
edgeCount: node.edge_count ?? 0,
isExpanded: !baseNodeIds.has(node.id),
dbNode: node, dbNode: node,
clusterColor: getClusterColor(clusterLabel), role,
clusterKey: viewMode === 'context' ? clusterLabel : undefined, connectionCount: adjacency.get(node.id)?.size ?? 0,
prominence: Math.min(1, (degreeMap.get(node.id) ?? node.edge_count ?? 0) / Math.max(...nodes.map((n) => degreeMap.get(n.id) ?? n.edge_count ?? 0), 1)),
}, },
}; };
}); });
} }
export function toRFEdges( export function toRFEdges(params: {
dbEdges: DbEdge[], dbEdges: DbEdge[];
nodeIds: Set<string>, nodeIds: Set<string>;
selectedNodeId: number | null, focusedGraph: FocusedGraph | null;
): RFEdge[] { }): RFEdge[] {
const hasSelection = selectedNodeId !== null; const { dbEdges, nodeIds, focusedGraph } = params;
return dbEdges return dbEdges
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id))) .filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
.map((edge) => { .map((edge) => {
const isConnected = hasSelection && (
edge.from_node_id === selectedNodeId || edge.to_node_id === selectedNodeId
);
const isDimmed = hasSelection && !isConnected;
const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : ''; const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : '';
const touchesSelected = focusedGraph
? edge.from_node_id === focusedGraph.selectedNodeId || edge.to_node_id === focusedGraph.selectedNodeId
: false;
const touchesSecondHop = focusedGraph
? focusedGraph.secondHopIds.includes(edge.from_node_id) || focusedGraph.secondHopIds.includes(edge.to_node_id)
: false;
return { return {
id: String(edge.id), id: String(edge.id),
source: String(edge.from_node_id), source: String(edge.from_node_id),
target: String(edge.to_node_id), target: String(edge.to_node_id),
type: 'rahEdge', type: 'rahEdge',
animated: isConnected, animated: false,
data: { explanation }, data: { explanation },
style: isConnected style: focusedGraph
? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 } ? touchesSelected
: isDimmed ? { stroke: 'color-mix(in srgb, var(--rah-accent-green) 55%, #94a3b8)', strokeWidth: 1.9, opacity: 0.72 }
? { stroke: '#374151', strokeWidth: 1, opacity: 0.15 } : touchesSecondHop
: undefined, ? { stroke: '#64748b', strokeWidth: 1.15, opacity: 0.3 }
zIndex: isConnected ? 10 : 0, : { stroke: '#475569', strokeWidth: 1.05, opacity: 0.22 }
: { stroke: '#475569', strokeWidth: 1.05, opacity: 0.2 },
zIndex: touchesSelected ? 10 : 1,
}; };
}); });
} }
function safeParseJSON(str: string | null | undefined): Record<string, any> | null {
if (!str || str === 'null') return null;
try {
return JSON.parse(str);
} catch {
return null;
}
}
+2 -1
View File
@@ -90,7 +90,8 @@ export interface ChatPanelProps {
// MapPane specific props // MapPane specific props
export interface MapPaneProps extends BasePaneProps { export interface MapPaneProps extends BasePaneProps {
onNodeClick?: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void;
activeTabId?: number | null; focusedNodeId?: number | null;
onClearFocus?: () => void;
} }
// ViewsPane specific props // ViewsPane specific props