{loading ? (
-
- Loading map...
-
+
Loading map…
) : error ? (
-
- {error}
-
+
{error}
) : rfNodes.length === 0 ? (
-
- No nodes to display
-
+
No nodes to display
) : (
{
const data = node.data as RahNodeData | undefined;
- return data?.clusterColor || '#2a2a2a';
+ switch (data?.role) {
+ case 'selected':
+ return '#16a34a';
+ case 'first-hop':
+ return '#22c55e';
+ case 'second-hop':
+ return '#94a3b8';
+ default:
+ return '#64748b';
+ }
}}
pannable
zoomable
/>
- {viewMode === 'context' && clusterLabels.map((label) => (
-
- {label.clusterLabel}
-
- ))}
-
-
-
-
- {pendingConnection && (
-
- )}
);
}
@@ -714,15 +547,151 @@ export default function MapPane(props: MapPaneProps) {
);
}
-const infoPanel: CSSProperties = {
+const emptyState: CSSProperties = {
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--rah-text-muted)',
+};
+
+const headerTools: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+};
+
+const overviewBadge: CSSProperties = {
+ padding: '5px 9px',
+ borderRadius: 999,
+ border: '1px solid var(--rah-border-strong)',
+ background: 'var(--rah-bg-panel)',
+ color: 'var(--rah-text-muted)',
+ fontSize: 11,
+ fontWeight: 600,
+};
+
+const focusedBadge: CSSProperties = {
+ ...overviewBadge,
+ borderColor: 'var(--rah-accent-green-soft-strong)',
+ background: 'var(--rah-accent-green-soft)',
+ color: 'var(--rah-accent-green)',
+};
+
+const searchShell: CSSProperties = {
+ position: 'relative',
+ width: 220,
+};
+
+const searchInput: CSSProperties = {
+ width: '100%',
+ padding: '7px 10px',
+ borderRadius: 8,
+ border: '1px solid var(--rah-border-strong)',
+ background: 'var(--rah-bg-panel)',
+ color: 'var(--rah-text-base)',
+ fontSize: 12,
+ outline: 'none',
+};
+
+const searchResultsPanel: CSSProperties = {
position: 'absolute',
- bottom: 16,
- left: 16,
- width: 260,
+ top: 'calc(100% + 6px)',
+ left: 0,
+ right: 0,
background: 'var(--rah-bg-modal)',
border: '1px solid var(--rah-border)',
- borderRadius: 8,
- padding: 14,
- zIndex: 10,
+ borderRadius: 10,
boxShadow: 'var(--rah-shadow-floating)',
+ overflow: 'hidden',
+ zIndex: 30,
+};
+
+const searchHint: CSSProperties = {
+ padding: '10px 12px',
+ fontSize: 12,
+ color: 'var(--rah-text-muted)',
+};
+
+const searchResultButton: CSSProperties = {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 8,
+ padding: '10px 12px',
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ color: 'var(--rah-text-base)',
+ textAlign: 'left',
+};
+
+const searchResultTitle: CSSProperties = {
+ flex: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ fontSize: 12,
+};
+
+const searchResultMeta: CSSProperties = {
+ fontSize: 11,
+ color: 'var(--rah-text-muted)',
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
+};
+
+const floatingActions: CSSProperties = {
+ position: 'absolute',
+ top: 10,
+ right: 10,
+ display: 'flex',
+ gap: 6,
+ zIndex: 10,
+};
+
+const floatingButton: CSSProperties = {
+ padding: '5px 9px',
+ fontSize: 11,
+ background: 'var(--rah-bg-panel)',
+ border: '1px solid var(--rah-border-strong)',
+ borderRadius: 6,
+ color: 'var(--rah-text-muted)',
+ cursor: 'pointer',
+};
+
+const hoverPreviewCard: CSSProperties = {
+ position: 'absolute',
+ top: 14,
+ left: 14,
+ width: 260,
+ padding: 12,
+ borderRadius: 10,
+ background: 'var(--rah-bg-modal)',
+ border: '1px solid var(--rah-border)',
+ boxShadow: 'var(--rah-shadow-floating)',
+ zIndex: 12,
+};
+
+const hoverPreviewTitle: CSSProperties = {
+ fontSize: 13,
+ fontWeight: 600,
+ color: 'var(--rah-text-base)',
+ marginBottom: 6,
+};
+
+const hoverPreviewBody: CSSProperties = {
+ fontSize: 12,
+ color: 'var(--rah-text-muted)',
+ lineHeight: 1.45,
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+};
+
+const hoverPreviewMeta: CSSProperties = {
+ fontSize: 12,
+ color: 'var(--rah-text-muted)',
};
diff --git a/src/components/panes/NodePane.tsx b/src/components/panes/NodePane.tsx
index 9cd754f..cc530e5 100644
--- a/src/components/panes/NodePane.tsx
+++ b/src/components/panes/NodePane.tsx
@@ -170,6 +170,7 @@ export default function NodePane({
activeTab={activeTab}
onTabSelect={onTabSelect}
onNodeClick={onNodeClick}
+ onOpenInMap={onPaneAction ? () => onPaneAction({ type: 'switch-pane-type', paneType: 'map' }) : undefined}
onTabClose={onTabClose}
refreshTrigger={refreshTrigger}
onTextSelect={onTextSelect}
diff --git a/src/components/panes/map/RahEdge.tsx b/src/components/panes/map/RahEdge.tsx
index 5495fec..199fec4 100644
--- a/src/components/panes/map/RahEdge.tsx
+++ b/src/components/panes/map/RahEdge.tsx
@@ -3,7 +3,7 @@
import { memo, useState } from 'react';
import {
BaseEdge,
- getStraightPath,
+ getBezierPath,
type EdgeProps,
} from '@xyflow/react';
@@ -25,11 +25,12 @@ function RahEdgeComponent({
const [hovered, setHovered] = useState(false);
const explanation = (data as RahEdgeData | undefined)?.explanation;
- const [edgePath, labelX, labelY] = getStraightPath({
+ const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
+ curvature: 0.18,
});
return (
@@ -45,7 +46,7 @@ function RahEdgeComponent({
strokeWidth={12}
style={{ cursor: 'default' }}
/>
-
+
{hovered && explanation && (
{explanation}
diff --git a/src/components/panes/map/RahNode.tsx b/src/components/panes/map/RahNode.tsx
index e43d744..8ed2b4b 100644
--- a/src/components/panes/map/RahNode.tsx
+++ b/src/components/panes/map/RahNode.tsx
@@ -8,40 +8,36 @@ import { getNodeIcon } from '@/utils/nodeIcons';
type RahNodeType = Node
;
function RahNodeComponent({ data, selected }: NodeProps) {
- const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data;
- const isTop = !isExpanded && edgeCount > 3;
+ const { label, dbNode, role, prominence } = data;
+ 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 (
-
+
{getNodeIcon(dbNode, 14)}
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
- {(isTop || isExpanded) && clusterLabel && (
-
- {clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
-
- )}
-
+
);
}
diff --git a/src/components/panes/map/map-styles.css b/src/components/panes/map/map-styles.css
index dbea17f..eb2f902 100644
--- a/src/components/panes/map/map-styles.css
+++ b/src/components/panes/map/map-styles.css
@@ -14,22 +14,15 @@
/* Edges */
.rah-map-wrapper .react-flow__edge-path {
- stroke: var(--rah-border-strong);
+ stroke: rgba(148, 163, 184, 0.38);
stroke-width: 1.5;
}
.rah-map-wrapper .react-flow__edge.selected .react-flow__edge-path {
- stroke: #22c55e;
+ stroke: var(--rah-accent-green);
stroke-width: 2;
}
-/* Connection line while dragging */
-.rah-map-wrapper .react-flow__connection-path {
- stroke: #22c55e;
- stroke-width: 2;
- stroke-dasharray: 5 3;
-}
-
/* Attribution */
.rah-map-wrapper .react-flow__attribution {
display: none;
@@ -42,49 +35,62 @@
/* Selection box */
.rah-map-wrapper .react-flow__selection {
- border: 1px solid rgba(34, 197, 94, 0.4);
- background: rgba(34, 197, 94, 0.05);
+ border: 1px solid var(--rah-accent-green-soft-strong);
+ background: var(--rah-accent-green-soft);
}
/* Custom node styles */
.rah-map-node {
- background: var(--rah-bg-surface);
+ background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
- border-radius: 8px;
- padding: 8px 12px;
+ border-radius: 10px;
+ padding: 6px 8px;
color: var(--rah-text-base);
- font-size: 12px;
- min-width: 60px;
- max-width: 180px;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ font-size: 11px;
+ min-width: 56px;
+ max-width: 168px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, opacity 0.15s ease;
cursor: pointer;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
}
.rah-map-node:hover {
border-color: var(--rah-border-stronger);
+ background: var(--rah-bg-hover);
}
-.rah-map-node--selected {
- border-color: #22c55e !important;
+.rah-map-node--active {
+ border-color: var(--rah-accent-green) !important;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
-.rah-map-node--top {
- border-color: #166534;
- background: var(--rah-bg-panel);
+.rah-map-node--selected {
+ border-color: color-mix(in srgb, var(--rah-accent-green) 80%, white) !important;
+ background: color-mix(in srgb, var(--rah-accent-green-soft) 70%, var(--rah-bg-modal));
}
-.rah-map-node--expanded {
- border-color: #b45309;
- background: var(--rah-bg-panel);
+.rah-map-node--first-hop {
+ border-color: color-mix(in srgb, var(--rah-accent-green) 45%, var(--rah-border-strong));
+ background: color-mix(in srgb, var(--rah-accent-green-soft) 42%, var(--rah-bg-modal));
}
-.rah-map-node--expanded .rah-map-node__title {
- color: #fbbf24;
+.rah-map-node--second-hop {
+ 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 {
- font-weight: 500;
+ font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
@@ -100,49 +106,19 @@
align-items: center;
}
-.rah-map-node__dims {
- margin-top: 4px;
- font-size: 10px;
- color: var(--rah-text-muted);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+.rah-map-handle--hidden {
+ opacity: 0 !important;
+ pointer-events: none !important;
+ width: 2px !important;
+ height: 2px !important;
+ min-width: 2px !important;
+ min-height: 2px !important;
+ border: none !important;
}
-/* Handles */
-.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 */
+/* MiniMap dark theme */
.rah-map-wrapper .react-flow__minimap {
- background: var(--rah-bg-base);
+ background: var(--rah-bg-panel);
border: 1px solid var(--rah-border);
border-radius: 6px;
}
diff --git a/src/components/panes/map/utils.ts b/src/components/panes/map/utils.ts
index 2a5a768..a5f8519 100644
--- a/src/components/panes/map/utils.ts
+++ b/src/components/panes/map/utils.ts
@@ -1,285 +1,367 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
-const CLUSTER_COLORS = [
- '#22c55e',
- '#3b82f6',
- '#f59e0b',
- '#ef4444',
- '#8b5cf6',
- '#ec4899',
- '#06b6d4',
- '#f97316',
- '#14b8a6',
- '#a855f7',
-];
+export type MapViewMode = 'overview' | 'focused';
+export type MapNodeRole = 'overview' | 'selected' | 'first-hop' | 'second-hop';
-export type MapViewMode = 'context' | 'hub';
-
-function hashClusterColor(label: string): string {
- let hash = 0;
- for (let i = 0; i < label.length; i++) {
- 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 FocusedGraph {
+ selectedNodeId: number;
+ firstHopIds: number[];
+ secondHopIds: number[];
+ nodeIds: Set;
}
export interface RahNodeData {
label: string;
- clusterLabel: string;
edgeCount: number;
- isExpanded: boolean;
dbNode: DbNode;
- clusterColor?: string;
- clusterKey?: string;
+ role: MapNodeRole;
+ connectionCount: number;
+ prominence: number;
[key: string]: unknown;
}
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(
- node: DbNode,
- viewMode: MapViewMode,
-): { x: number; y: number } | null {
- const metadata = typeof node.metadata === 'string'
- ? safeParseJSON(node.metadata)
- : node.metadata;
+type Point = { x: number; y: number };
- const nested = metadata?.map_positions as Record | undefined;
- const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
- const saved = nested?.[viewMode] || scoped;
+export function buildAdjacency(dbEdges: DbEdge[]): Map> {
+ const adjacency = new Map>();
- if (saved?.x !== undefined && saved?.y !== undefined) {
- return { x: saved.x, y: saved.y };
+ for (const edge of dbEdges) {
+ 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[] {
- const baseIds = new Set(baseNodes.map((node) => node.id));
- return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
+export function buildDegreeMap(dbEdges: DbEdge[]): Map {
+ const adjacency = buildAdjacency(dbEdges);
+ const degreeMap = new Map();
+
+ adjacency.forEach((neighbours, nodeId) => {
+ degreeMap.set(nodeId, neighbours.size);
+ });
+
+ return degreeMap;
}
-function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map {
- const positions = new Map();
- const groups = new Map();
+export function buildFocusedGraph(
+ selectedNodeId: number,
+ adjacency: Map>,
+ degreeMap: Map,
+): 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 key = getNodeClusterLabel(node);
- const existing = groups.get(key) || [];
- existing.push(node);
- groups.set(key, existing);
+ const firstHopSet = new Set(directNeighbours);
+ const secondHopScores = new Map();
+
+ for (const firstHopId of directNeighbours) {
+ 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 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 secondHopIds = [...secondHopScores.entries()]
+ .sort((a, b) => {
+ const sharedDiff = b[1].sharedCount - a[1].sharedCount;
+ if (sharedDiff !== 0) return sharedDiff;
- clusterKeys.forEach((clusterKey, clusterIndex) => {
- const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
- return (b.edge_count ?? 0) - (a.edge_count ?? 0);
+ const degreeDiff = b[1].degree - a[1].degree;
+ return degreeDiff !== 0 ? degreeDiff : a[0] - b[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>,
+ degreeMap: Map,
+ centerX: number,
+ centerY: number,
+): Map {
+ const positions = new Map();
+ 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();
+
+ 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;
- 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)));
+ sortedNodes.forEach((node) => {
+ const current = state.get(node.id);
+ if (!current) return;
- 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 });
+ const dx = current.x - centerX;
+ const dy = current.y - centerY;
+ const distance = Math.sqrt(dx * dx + dy * dy) || 1;
+ const radialError = distance - current.targetRadius;
+ const radialPull = radialError * 0.0075;
+
+ 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;
}
-function buildHubLayout(
+function buildFocusedLayout(
nodes: DbNode[],
- dbEdges: DbEdge[],
+ focusedGraph: FocusedGraph,
+ adjacency: Map>,
centerX: number,
centerY: number,
-): Map {
- const positions = new Map();
- 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));
+): Map {
+ const positions = new Map();
+ const firstHopCount = Math.max(1, focusedGraph.firstHopIds.length);
+ const firstHopRadius = Math.max(220, Math.min(320, 180 + firstHopCount * 12));
- const adjacency = new Map();
- 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);
+ positions.set(String(focusedGraph.selectedNodeId), { x: centerX, y: centerY });
- const to = adjacency.get(edge.to_node_id) || [];
- to.push(edge.from_node_id);
- adjacency.set(edge.to_node_id, to);
- }
+ const firstHopAngles = new Map();
+ focusedGraph.firstHopIds.forEach((nodeId, index) => {
+ 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();
- for (const hub of hubs) {
- clusterMembers.set(hub.id, [hub]);
- }
+ const secondHopByParent = new Map();
+ focusedGraph.firstHopIds.forEach((nodeId) => secondHopByParent.set(nodeId, []));
- const orphanNodes: DbNode[] = [];
- for (const node of sortedNodes) {
- if (hubIds.has(node.id)) continue;
+ focusedGraph.secondHopIds.forEach((nodeId) => {
+ const parentId = focusedGraph.firstHopIds
+ .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 connectedHub = hubs
- .filter((hub) => neighbours.includes(hub.id))
- .sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))[0];
+ const targetParent = parentId ?? focusedGraph.firstHopIds[0];
+ const group = secondHopByParent.get(targetParent) ?? [];
+ group.push(nodeId);
+ secondHopByParent.set(targetParent, group);
+ });
- 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,
+ secondHopByParent.forEach((group, parentId) => {
+ const parentAngle = firstHopAngles.get(parentId) ?? -Math.PI / 2;
+ group.forEach((nodeId, index) => {
+ const localOffset = (index - (group.length - 1) / 2) * 0.24;
+ const angle = parentAngle + localOffset;
+ 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,
});
});
});
- 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,
- });
+ nodes.forEach((node) => {
+ if (!positions.has(String(node.id))) {
+ positions.set(String(node.id), { x: centerX, y: centerY });
+ }
});
return positions;
}
-export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
- if (viewMode === 'context') {
- return getNodeClusterLabel(node);
- }
+export function toRFNodes(params: {
+ nodes: DbNode[];
+ viewMode: MapViewMode;
+ degreeMap: Map;
+ adjacency: Map>;
+ focusedGraph: FocusedGraph | null;
+ centerX: number;
+ centerY: number;
+}): RFNode[] {
+ 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(
- baseNodes: DbNode[],
- expandedNodes: DbNode[],
- centerX: number,
- centerY: number,
- selectedNodeId: number | null,
- connectedNodeIds: Set,
- existingPositions: Map,
- viewMode: MapViewMode,
- dbEdges: DbEdge[],
-): RFNode[] {
- 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 nodes.map((node) => {
+ const role: MapNodeRole = focusedGraph
+ ? node.id === focusedGraph.selectedNodeId
+ ? 'selected'
+ : focusedGraph.firstHopIds.includes(node.id)
+ ? 'first-hop'
+ : 'second-hop'
+ : 'overview';
return {
- id,
+ id: String(node.id),
type: 'rahNode',
- position: pos,
- className: isDimmed ? 'dimmed' : undefined,
+ position: positions.get(String(node.id)) ?? { x: centerX, y: centerY },
data: {
label: node.title || 'Untitled',
- clusterLabel,
- edgeCount: node.edge_count ?? 0,
- isExpanded: !baseNodeIds.has(node.id),
+ edgeCount: degreeMap.get(node.id) ?? node.edge_count ?? 0,
dbNode: node,
- clusterColor: getClusterColor(clusterLabel),
- clusterKey: viewMode === 'context' ? clusterLabel : undefined,
+ role,
+ 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(
- dbEdges: DbEdge[],
- nodeIds: Set,
- selectedNodeId: number | null,
-): RFEdge[] {
- const hasSelection = selectedNodeId !== null;
+export function toRFEdges(params: {
+ dbEdges: DbEdge[];
+ nodeIds: Set;
+ focusedGraph: FocusedGraph | null;
+}): RFEdge[] {
+ const { dbEdges, nodeIds, focusedGraph } = params;
return dbEdges
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
.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 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 {
id: String(edge.id),
source: String(edge.from_node_id),
target: String(edge.to_node_id),
type: 'rahEdge',
- animated: isConnected,
+ animated: false,
data: { explanation },
- style: isConnected
- ? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 }
- : isDimmed
- ? { stroke: '#374151', strokeWidth: 1, opacity: 0.15 }
- : undefined,
- zIndex: isConnected ? 10 : 0,
+ style: focusedGraph
+ ? touchesSelected
+ ? { stroke: 'color-mix(in srgb, var(--rah-accent-green) 55%, #94a3b8)', strokeWidth: 1.9, opacity: 0.72 }
+ : touchesSecondHop
+ ? { stroke: '#64748b', strokeWidth: 1.15, opacity: 0.3 }
+ : { 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 | null {
- if (!str || str === 'null') return null;
- try {
- return JSON.parse(str);
- } catch {
- return null;
- }
-}
diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts
index 6e22838..7904004 100644
--- a/src/components/panes/types.ts
+++ b/src/components/panes/types.ts
@@ -90,7 +90,8 @@ export interface ChatPanelProps {
// MapPane specific props
export interface MapPaneProps extends BasePaneProps {
onNodeClick?: (nodeId: number) => void;
- activeTabId?: number | null;
+ focusedNodeId?: number | null;
+ onClearFocus?: () => void;
}
// ViewsPane specific props