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;
|
||||
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({
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
) : null}
|
||||
{onOpenInMap ? (
|
||||
<button
|
||||
type="button"
|
||||
className="node-map-button"
|
||||
onClick={onOpenInMap}
|
||||
title="Open this node in the map"
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="delete-node-button node-delete-button"
|
||||
@@ -1519,6 +1531,25 @@ export default function FocusPanel({
|
||||
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 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
@@ -239,6 +239,8 @@ export default function ThreePanelLayout() {
|
||||
() => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))),
|
||||
[visiblePaneCount]
|
||||
);
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<number | null>(null);
|
||||
const [isMapFocusSuppressed, setIsMapFocusSuppressed] = useState(false);
|
||||
|
||||
const isPanelExpanded = useCallback((slot: SlotId) => {
|
||||
return visibleSlots.includes(slot);
|
||||
@@ -307,23 +309,47 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
}, [activePane, visibleSlots]);
|
||||
|
||||
const activeNodeId = useMemo(() => {
|
||||
const activeSlotState = slotStates[activePane];
|
||||
const activeTab = activeSlotState ? getActiveTab(activeSlotState) : undefined;
|
||||
const deriveFallbackFocusedNode = useCallback((): number | null => {
|
||||
const orderedSlots: SlotId[] = [activePane, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== activePane)];
|
||||
|
||||
for (const slot of orderedSlots) {
|
||||
const state = slotStates[slot];
|
||||
const activeTab = state ? getActiveTab(state) : undefined;
|
||||
if (activeTab?.type === 'node' && activeTab.nodeId != null) {
|
||||
return activeTab.nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const slot of ['A', 'B', 'C'] as SlotId[]) {
|
||||
const state = slotStates[slot];
|
||||
const tab = state ? getActiveTab(state) : undefined;
|
||||
if (tab?.type === 'node' && tab.nodeId != null) {
|
||||
return tab.nodeId;
|
||||
for (const slot of orderedSlots) {
|
||||
const fallbackTab = slotStates[slot]?.tabs.find((tab) => tab.type === 'node' && tab.nodeId != null);
|
||||
if (fallbackTab?.nodeId != null) {
|
||||
return fallbackTab.nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
return allOpenNodeIds[0] ?? null;
|
||||
}, [activePane, allOpenNodeIds, slotStates]);
|
||||
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(() => {
|
||||
setNodesPanelRefresh((prev) => prev + 1);
|
||||
@@ -458,6 +484,8 @@ export default function ThreePanelLayout() {
|
||||
|
||||
setPanelExpanded(slot, true);
|
||||
setActivePane(slot);
|
||||
setIsMapFocusSuppressed(false);
|
||||
setFocusedNodeId(nodeId);
|
||||
}, [getSlotSetter, setPanelExpanded]);
|
||||
|
||||
const closeTabInSlot = useCallback((slot: SlotId, tabId: string) => {
|
||||
@@ -487,6 +515,8 @@ export default function ThreePanelLayout() {
|
||||
const state = getSlotState(slot);
|
||||
if (state?.tabs.some((tab) => tab.id === existingTabId)) {
|
||||
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId });
|
||||
setIsMapFocusSuppressed(false);
|
||||
setFocusedNodeId(nodeId);
|
||||
setActivePane(slot);
|
||||
return;
|
||||
}
|
||||
@@ -505,6 +535,8 @@ export default function ThreePanelLayout() {
|
||||
|
||||
if (preferredNodeTarget) {
|
||||
addNodeTabToSlot(preferredNodeTarget, nodeId);
|
||||
setIsMapFocusSuppressed(false);
|
||||
setFocusedNodeId(nodeId);
|
||||
setActivePane(preferredNodeTarget);
|
||||
return;
|
||||
}
|
||||
@@ -519,6 +551,8 @@ export default function ThreePanelLayout() {
|
||||
?? 'A';
|
||||
|
||||
addNodeTabToSlot(target, nodeId);
|
||||
setIsMapFocusSuppressed(false);
|
||||
setFocusedNodeId(nodeId);
|
||||
}, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]);
|
||||
|
||||
const openPaneSingleton = useCallback((paneType: Exclude<PaneType, 'node'>, preferredSlot?: SlotId) => {
|
||||
@@ -552,9 +586,19 @@ export default function ThreePanelLayout() {
|
||||
const state = getSlotState(slot);
|
||||
if (!state) return;
|
||||
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);
|
||||
}, [getSlotSetter, getSlotState]);
|
||||
|
||||
const clearMapFocus = useCallback(() => {
|
||||
setIsMapFocusSuppressed(true);
|
||||
setFocusedNodeId(null);
|
||||
}, []);
|
||||
|
||||
const handleNodeDeleted = useCallback((nodeId: number) => {
|
||||
const tabId = createTabId('node', nodeId);
|
||||
(['A', 'B', 'C'] as SlotId[]).forEach((slot) => closeTabInSlot(slot, tabId));
|
||||
@@ -785,7 +829,8 @@ export default function ThreePanelLayout() {
|
||||
<MapPane
|
||||
{...commonProps}
|
||||
onNodeClick={(nodeId) => openNodeFromSlot(nodeId, slot)}
|
||||
activeTabId={activeNodeId}
|
||||
focusedNodeId={focusedNodeId}
|
||||
onClearFocus={clearMapFocus}
|
||||
/>
|
||||
);
|
||||
case 'views':
|
||||
|
||||
+440
-471
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
<BaseEdge id={id} path={edgePath} style={{ strokeLinecap: 'round', ...style }} />
|
||||
{hovered && explanation && (
|
||||
<foreignObject
|
||||
x={labelX - 80}
|
||||
@@ -56,8 +57,8 @@ function RahEdgeComponent({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: 'var(--rah-bg-modal)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 10,
|
||||
@@ -67,6 +68,7 @@ function RahEdgeComponent({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 160,
|
||||
boxShadow: 'var(--rah-shadow-floating)',
|
||||
}}
|
||||
>
|
||||
{explanation}
|
||||
|
||||
@@ -8,40 +8,36 @@ import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
type RahNodeType = Node<RahNodeData, 'rahNode'>;
|
||||
|
||||
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
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 (
|
||||
<div
|
||||
className={[
|
||||
'rah-map-node',
|
||||
isExpanded && 'rah-map-node--expanded',
|
||||
isTop && 'rah-map-node--top',
|
||||
selected && 'rah-map-node--selected',
|
||||
role === 'selected' && 'rah-map-node--selected',
|
||||
role === 'first-hop' && 'rah-map-node--first-hop',
|
||||
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(' ')}
|
||||
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined}
|
||||
style={{ transform: `scale(${sizeScale})` }}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="rah-map-handle"
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} className="rah-map-handle rah-map-handle--hidden" isConnectable={false} />
|
||||
<div className="rah-map-node__title">
|
||||
<span className="rah-map-node__icon">
|
||||
{getNodeIcon(dbNode, 14)}
|
||||
</span>
|
||||
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
|
||||
</div>
|
||||
{(isTop || isExpanded) && clusterLabel && (
|
||||
<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"
|
||||
/>
|
||||
<Handle type="source" position={Position.Bottom} className="rah-map-handle rah-map-handle--hidden" isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+311
-229
@@ -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<number>;
|
||||
}
|
||||
|
||||
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<string, { x?: number; y?: number }> | undefined;
|
||||
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
|
||||
const saved = nested?.[viewMode] || scoped;
|
||||
export function buildAdjacency(dbEdges: DbEdge[]): Map<number, Set<number>> {
|
||||
const adjacency = new Map<number, Set<number>>();
|
||||
|
||||
if (saved?.x !== undefined && saved?.y !== undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
|
||||
const baseIds = new Set(baseNodes.map((node) => node.id));
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}
|
||||
|
||||
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const groups = new Map<string, DbNode[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const key = getNodeClusterLabel(node);
|
||||
const existing = groups.get(key) || [];
|
||||
existing.push(node);
|
||||
groups.set(key, 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;
|
||||
|
||||
clusterKeys.forEach((clusterKey, clusterIndex) => {
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
|
||||
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
|
||||
});
|
||||
|
||||
const clusterColumn = clusterIndex % columns;
|
||||
const clusterRow = Math.floor(clusterIndex / columns);
|
||||
const clusterCenterX = originX + clusterColumn * clusterGapX;
|
||||
const clusterCenterY = originY + clusterRow * clusterGapY;
|
||||
const clusterCols = Math.max(1, Math.ceil(Math.sqrt(clusterNodes.length)));
|
||||
|
||||
clusterNodes.forEach((node, index) => {
|
||||
const col = index % clusterCols;
|
||||
const row = Math.floor(index / clusterCols);
|
||||
const x = clusterCenterX + (col - (clusterCols - 1) / 2) * 120 + (row % 2 === 0 ? 0 : 18);
|
||||
const y = clusterCenterY + row * 96;
|
||||
positions.set(String(node.id), { x, y });
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function buildHubLayout(
|
||||
nodes: DbNode[],
|
||||
dbEdges: DbEdge[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedNodes = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const hubCount = Math.max(1, Math.min(10, Math.ceil(Math.sqrt(sortedNodes.length || 1))));
|
||||
const hubs = sortedNodes.slice(0, hubCount);
|
||||
const hubIds = new Set(hubs.map((node) => node.id));
|
||||
|
||||
const adjacency = new Map<number, number[]>();
|
||||
for (const edge of dbEdges) {
|
||||
const from = adjacency.get(edge.from_node_id) || [];
|
||||
from.push(edge.to_node_id);
|
||||
adjacency.set(edge.from_node_id, from);
|
||||
|
||||
const to = adjacency.get(edge.to_node_id) || [];
|
||||
to.push(edge.from_node_id);
|
||||
adjacency.set(edge.to_node_id, to);
|
||||
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);
|
||||
}
|
||||
|
||||
const clusterMembers = new Map<number, DbNode[]>();
|
||||
for (const hub of hubs) {
|
||||
clusterMembers.set(hub.id, [hub]);
|
||||
return adjacency;
|
||||
}
|
||||
|
||||
const orphanNodes: DbNode[] = [];
|
||||
for (const node of sortedNodes) {
|
||||
if (hubIds.has(node.id)) continue;
|
||||
export function buildDegreeMap(dbEdges: DbEdge[]): Map<number, number> {
|
||||
const adjacency = buildAdjacency(dbEdges);
|
||||
const degreeMap = new Map<number, number>();
|
||||
|
||||
const neighbours = adjacency.get(node.id) || [];
|
||||
const connectedHub = hubs
|
||||
.filter((hub) => neighbours.includes(hub.id))
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))[0];
|
||||
|
||||
if (connectedHub) {
|
||||
const members = clusterMembers.get(connectedHub.id) || [connectedHub];
|
||||
members.push(node);
|
||||
clusterMembers.set(connectedHub.id, members);
|
||||
} else {
|
||||
orphanNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const hubRadius = Math.max(160, hubCount * 42);
|
||||
hubs.forEach((hub, index) => {
|
||||
const angle = (index / hubCount) * Math.PI * 2 - Math.PI / 2;
|
||||
const hubX = centerX + Math.cos(angle) * hubRadius;
|
||||
const hubY = centerY + Math.sin(angle) * hubRadius;
|
||||
positions.set(String(hub.id), { x: hubX, y: hubY });
|
||||
|
||||
const members = (clusterMembers.get(hub.id) || []).filter((member) => member.id !== hub.id);
|
||||
members.forEach((member, memberIndex) => {
|
||||
const memberAngle = (memberIndex / Math.max(members.length, 1)) * Math.PI * 2;
|
||||
const ringRadius = 115 + Math.floor(memberIndex / 10) * 46;
|
||||
positions.set(String(member.id), {
|
||||
x: hubX + Math.cos(memberAngle) * ringRadius,
|
||||
y: hubY + Math.sin(memberAngle) * ringRadius,
|
||||
});
|
||||
});
|
||||
adjacency.forEach((neighbours, nodeId) => {
|
||||
degreeMap.set(nodeId, neighbours.size);
|
||||
});
|
||||
|
||||
orphanNodes.forEach((node, index) => {
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(orphanNodes.length)));
|
||||
const col = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
positions.set(String(node.id), {
|
||||
x: centerX - 220 + col * 110,
|
||||
y: centerY + hubRadius + 140 + row * 90,
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
return degreeMap;
|
||||
}
|
||||
|
||||
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
|
||||
if (viewMode === 'context') {
|
||||
return getNodeClusterLabel(node);
|
||||
export function buildFocusedGraph(
|
||||
selectedNodeId: number,
|
||||
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);
|
||||
|
||||
const firstHopSet = new Set(directNeighbours);
|
||||
const secondHopScores = new Map<number, { sharedCount: number; degree: number }>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return `hub:${node.id}`;
|
||||
}
|
||||
const secondHopIds = [...secondHopScores.entries()]
|
||||
.sort((a, b) => {
|
||||
const sharedDiff = b[1].sharedCount - a[1].sharedCount;
|
||||
if (sharedDiff !== 0) return sharedDiff;
|
||||
|
||||
export function toRFNodes(
|
||||
baseNodes: DbNode[],
|
||||
expandedNodes: DbNode[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
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);
|
||||
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 {
|
||||
id,
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
sortedNodes.forEach((node) => {
|
||||
const current = state.get(node.id);
|
||||
if (!current) return;
|
||||
|
||||
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 buildFocusedLayout(
|
||||
nodes: DbNode[],
|
||||
focusedGraph: FocusedGraph,
|
||||
adjacency: Map<number, Set<number>>,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): Map<string, Point> {
|
||||
const positions = new Map<string, Point>();
|
||||
const firstHopCount = Math.max(1, focusedGraph.firstHopIds.length);
|
||||
const firstHopRadius = Math.max(220, Math.min(320, 180 + firstHopCount * 12));
|
||||
|
||||
positions.set(String(focusedGraph.selectedNodeId), { x: centerX, y: centerY });
|
||||
|
||||
const firstHopAngles = new Map<number, number>();
|
||||
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 secondHopByParent = new Map<number, number[]>();
|
||||
focusedGraph.firstHopIds.forEach((nodeId) => secondHopByParent.set(nodeId, []));
|
||||
|
||||
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 targetParent = parentId ?? focusedGraph.firstHopIds[0];
|
||||
const group = secondHopByParent.get(targetParent) ?? [];
|
||||
group.push(nodeId);
|
||||
secondHopByParent.set(targetParent, group);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!positions.has(String(node.id))) {
|
||||
positions.set(String(node.id), { x: centerX, y: centerY });
|
||||
}
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function toRFNodes(params: {
|
||||
nodes: DbNode[];
|
||||
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;
|
||||
|
||||
const positions = viewMode === 'focused' && focusedGraph
|
||||
? buildFocusedLayout(nodes, focusedGraph, adjacency, centerX, centerY)
|
||||
: buildOverviewLayout(nodes, adjacency, degreeMap, centerX, centerY);
|
||||
|
||||
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: 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<string>,
|
||||
selectedNodeId: number | null,
|
||||
): RFEdge[] {
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
export function toRFEdges(params: {
|
||||
dbEdges: DbEdge[];
|
||||
nodeIds: Set<string>;
|
||||
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<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
|
||||
export interface MapPaneProps extends BasePaneProps {
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
activeTabId?: number | null;
|
||||
focusedNodeId?: number | null;
|
||||
onClearFocus?: () => void;
|
||||
}
|
||||
|
||||
// ViewsPane specific props
|
||||
|
||||
Reference in New Issue
Block a user