From 097c4c61bb94ab8f71c9603411cc14e58fbf563d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Wed, 15 Apr 2026 20:17:35 +1000 Subject: [PATCH] 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 --- src/components/focus/FocusPanel.tsx | 31 + src/components/layout/ThreePanelLayout.tsx | 71 +- src/components/panes/MapPane.tsx | 943 ++++++++++----------- src/components/panes/NodePane.tsx | 1 + src/components/panes/map/RahEdge.tsx | 12 +- src/components/panes/map/RahNode.tsx | 38 +- src/components/panes/map/map-styles.css | 114 +-- src/components/panes/map/utils.ts | 488 ++++++----- src/components/panes/types.ts | 3 +- 9 files changed, 902 insertions(+), 799 deletions(-) diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index 6d16f16..b46a07d 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -19,6 +19,7 @@ interface FocusPanelProps { activeTab: number | null; onTabSelect: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void; + onOpenInMap?: () => void; onTabClose: (nodeId: number) => void; refreshTrigger?: number; onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; @@ -32,6 +33,7 @@ export default function FocusPanel({ activeTab, onTabSelect, onNodeClick, + onOpenInMap, onTabClose, refreshTrigger, onTextSelect, @@ -1107,6 +1109,16 @@ export default function FocusPanel({ ) : null} + {onOpenInMap ? ( + + ) : null} - +
+ + {viewMode === 'focused' ? 'Focused' : 'Overview'} + + +
+ { + setSearchQuery(event.target.value); + setSearchOpen(true); + }} + onFocus={() => setSearchOpen(true)} + placeholder="Search nodes..." + style={searchInput} + /> + + {searchOpen && (searchQuery.trim().length >= SEARCH_MIN_CHARS || searchResults.length > 0) && ( +
+ {searchLoading ? ( +
Searching…
+ ) : searchResults.length === 0 ? ( +
No matching nodes
+ ) : ( + searchResults.map((result) => ( + + )) + )} +
+ )} +
{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} -
- ))} - -
- - {viewMode === 'hub' && ( - - )} + ) : null}
- {selectedDbNode && ( -
-
-
- {selectedDbNode.title || 'Untitled'} -
- -
-
- {connectedNodeIds.size} connected nodes -
-
- Click a connected node to traverse · Double-click to open -
-
- - {selectedDbNode.context?.name || 'Unscoped'} - -
- + {hoveredDbNode && hoveredNodeId !== selectedNodeId ? ( +
+
{hoveredDbNode.title || 'Untitled'}
+ {hoveredDbNode.description ? ( +
{hoveredDbNode.description}
+ ) : ( +
No description
+ )}
- )} + ) : null} +
)}
- - {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