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:
@@ -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;
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
+456
-487
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user