diff --git a/package-lock.json b/package-lock.json index 4ad99c7..79fe106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", + "@xyflow/react": "^12.10.0", "ai": "^6.0.27", "better-sqlite3": "^12.2.0", "cheerio": "^1.1.2", @@ -2797,6 +2798,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3550,6 +3600,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4498,6 +4580,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4666,6 +4754,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -13253,6 +13446,34 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 050c2c1..bb0a85a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", + "@xyflow/react": "^12.10.0", "ai": "^6.0.27", "better-sqlite3": "^12.2.0", "cheerio": "^1.1.2", diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx index 8164ef0..5c9ddb3 100644 --- a/src/components/agents/AgentsPanel.tsx +++ b/src/components/agents/AgentsPanel.tsx @@ -241,7 +241,6 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension {/* Add Stuff - top right */}
diff --git a/src/components/agents/QuickAddInput.tsx b/src/components/agents/QuickAddInput.tsx index f6213ae..dae0e19 100644 --- a/src/components/agents/QuickAddInput.tsx +++ b/src/components/agents/QuickAddInput.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback } from 'react'; -import type { AgentDelegation } from '@/services/agents/delegation'; +import { createPortal } from 'react-dom'; interface QuickAddSubmitPayload { input: string; @@ -10,14 +10,13 @@ interface QuickAddSubmitPayload { } interface QuickAddInputProps { - activeDelegations: AgentDelegation[]; onSubmit: (payload: QuickAddSubmitPayload) => Promise; // External control (optional - if provided, component is controlled) isOpen?: boolean; onClose?: () => void; } -export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onClose }: QuickAddInputProps) { +export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) { const [input, setInput] = useState(''); const [isPosting, setIsPosting] = useState(false); const [isExpandedInternal, setIsExpandedInternal] = useState(false); @@ -32,12 +31,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC ? (value: boolean) => { if (!value && onClose) onClose(); } : setIsExpandedInternal; - const maxConcurrent = 5; - const activeCount = activeDelegations.filter( - (d) => d.status === 'queued' || d.status === 'in_progress' - ).length; - const isSoftLimited = activeCount >= maxConcurrent; - const handleFileUpload = useCallback(async (file: File) => { setIsPosting(true); setUploadError(null); @@ -83,7 +76,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC } // Otherwise, submit text as before - if (!input.trim() || isPosting || isSoftLimited) return; + if (!input.trim() || isPosting) return; setIsPosting(true); try { @@ -159,6 +152,13 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC const hasContent = input.trim() || uploadedFile; + const handleClose = () => { + setIsExpanded(false); + setInput(''); + setUploadedFile(null); + setUploadError(null); + }; + // Collapsed state - only show button if NOT controlled externally if (!isExpanded) { // In controlled mode, don't render anything when closed @@ -215,81 +215,19 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC ); } - // Expanded state - modal overlay (centered if controlled, absolute if uncontrolled) + // Expanded state - the card content const modalContent = (
e.stopPropagation()} > {/* Header */} -
- - + - Add Stuff - - - {/* Close button */} -
- {/* Active processing indicator */} - {activeCount > 0 && ( -
- - Adding {activeCount} node{activeCount > 1 ? 's' : ''}... -
- )} -
); - // In controlled mode, wrap with backdrop + // In controlled mode, wrap with blurred backdrop + portal if (isControlled) { - return ( + const backdrop = (
setIsExpanded(false)} + className="qa-backdrop" + onClick={handleClose} > - {modalContent} +
+ {modalContent} +
+ +
); + + return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null; } // Uncontrolled mode - render with absolute positioning diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index b174593..d3d078e 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -1073,7 +1073,6 @@ export default function ThreePanelLayout() { setShowAddStuff(false)} - activeDelegations={delegations} onSubmit={handleQuickAddSubmit} /> diff --git a/src/components/panes/MapPane.tsx b/src/components/panes/MapPane.tsx index 6d5c221..be572a9 100644 --- a/src/components/panes/MapPane.tsx +++ b/src/components/panes/MapPane.tsx @@ -1,18 +1,30 @@ "use client"; import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } from 'react'; -import type { Edge, Node as DbNode } from '@/types/database'; +import { + ReactFlow, + Background, + useNodesState, + useEdgesState, + addEdge as rfAddEdge, + type Connection, + type NodeMouseHandler, + type Node as RFNode, + type Edge as RFEdge, + ReactFlowProvider, + useReactFlow, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import type { Edge as DbEdge, Node as DbNode } from '@/types/database'; import PaneHeader from './PaneHeader'; import type { MapPaneProps } from './types'; import { ChevronDown } from 'lucide-react'; -interface GraphNode extends DbNode { - edge_count?: number; - x: number; - y: number; - radius: number; - isExpanded?: boolean; // Node was dynamically loaded as a connection -} +import { RahNode } from './map/RahNode'; +import EdgeExplanationModal from './map/EdgeExplanationModal'; +import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils'; +import './map/map-styles.css'; interface DimensionInfo { dimension: string; @@ -21,82 +33,101 @@ interface DimensionInfo { description: string | null; } -const NODE_LIMIT = 200; -const LABEL_THRESHOLD = 15; +const nodeTypes = { rahNode: RahNode }; -export default function MapPane({ +// Debounce helper +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function debounce void>(fn: T, ms: number): T { + let timer: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +} + +function MapPaneInner({ slot, isActive, onPaneAction, onCollapse, onSwapPanes, + tabBar, onNodeClick, activeTabId, }: MapPaneProps) { - const containerRef = useRef(null); - const [containerSize, setContainerSize] = useState({ width: 800, height: 600 }); - const [baseNodes, setBaseNodes] = useState([]); // Nodes from dimension filter - const [expandedNodes, setExpandedNodes] = useState([]); // Connected nodes loaded dynamically - const [edges, setEdges] = useState([]); + const reactFlowInstance = useReactFlow(); + + // --- Data state (DB-level) --- + const [baseNodes, setBaseNodes] = useState([]); + const [expandedNodes, setExpandedNodes] = useState([]); + const [dbEdges, setDbEdges] = useState([]); const [lockedDimensions, setLockedDimensions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); - // Dimension filter state + // --- UI state --- + const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedDimension, setSelectedDimension] = useState(null); const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false); const dropdownRef = useRef(null); - // Combine base nodes + expanded nodes - const allNodes = useMemo(() => { - const baseNodeIds = new Set(baseNodes.map(n => n.id)); - // Add expanded nodes that aren't already in base nodes - const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id)); - return [...baseNodes, ...uniqueExpanded]; + // --- React Flow state --- + const [rfNodes, setRfNodes, onNodesChange] = useNodesState>([]); + const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]); + + // --- Edge creation modal --- + const [pendingConnection, setPendingConnection] = useState(null); + + // Track current RF positions so we can preserve them across data refreshes + const rfPositionsRef = useRef>(new Map()); + + // Combine base + expanded + const allDbNodes = useMemo(() => { + const baseIds = new Set(baseNodes.map(n => n.id)); + return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))]; }, [baseNodes, expandedNodes]); - // Close dropdown when clicking outside + // Selected DB node + const selectedDbNode = useMemo( + () => allDbNodes.find(n => n.id === selectedNodeId) ?? null, + [allDbNodes, selectedNodeId], + ); + + // Connected node IDs for info panel + const connectedNodeIds = useMemo(() => { + if (!selectedNodeId) return new Set(); + const connected = new Set(); + dbEdges.forEach(e => { + if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id); + if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id); + }); + return connected; + }, [selectedNodeId, dbEdges]); + + const lockedDimensionNames = useMemo( + () => new Set(lockedDimensions.map(d => d.dimension)), + [lockedDimensions], + ); + + // ----- Close dropdown on outside click ----- useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as HTMLElement)) { + if (!dimensionDropdownOpen) return; + const handler = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) { setDimensionDropdownOpen(false); } }; - - if (dimensionDropdownOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); }, [dimensionDropdownOpen]); - // Resize observer - useEffect(() => { - const observer = new ResizeObserver(entries => { - const entry = entries[0]; - if (entry?.contentRect) { - setContainerSize({ - width: entry.contentRect.width, - height: entry.contentRect.height, - }); - } - }); - - if (containerRef.current) { - observer.observe(containerRef.current); - } - - return () => observer.disconnect(); - }, []); - - // Fetch base data (dimension-filtered nodes) + // ----- Fetch base data ----- useEffect(() => { const fetchData = async () => { setLoading(true); setError(null); try { - // Build nodes URL with optional dimension filter const nodesUrl = selectedDimension ? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}` : `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`; @@ -107,25 +138,23 @@ export default function MapPane({ fetch('/api/dimensions/popular'), ]); - if (!nodesRes.ok || !edgesRes.ok) { - throw new Error('Failed to load data'); - } + if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data'); const nodesPayload = await nodesRes.json(); const edgesPayload = await edgesRes.json(); setBaseNodes(nodesPayload.data || []); - setEdges(edgesPayload.data || []); - setExpandedNodes([]); // Clear expanded nodes when filter changes - setSelectedNode(null); // Clear selection when filter changes + setDbEdges(edgesPayload.data || []); + setExpandedNodes([]); + setSelectedNodeId(null); + rfPositionsRef.current.clear(); - // Get locked (priority) dimensions if (dimsRes.ok) { const dimsPayload = await dimsRes.json(); if (dimsPayload.success && dimsPayload.data) { - const locked = (dimsPayload.data as DimensionInfo[]) - .filter((d) => d.isPriority); - setLockedDimensions(locked); + setLockedDimensions( + (dimsPayload.data as DimensionInfo[]).filter(d => d.isPriority), + ); } } } catch (err) { @@ -134,310 +163,318 @@ export default function MapPane({ setLoading(false); } }; - fetchData(); }, [selectedDimension]); - // Fetch edges and connected nodes when a node is selected + // ----- Sync DB data → React Flow nodes/edges ----- + useEffect(() => { + if (allDbNodes.length === 0) { + setRfNodes([]); + setRfEdges([]); + return; + } + + // Capture current RF positions before rebuild + rfNodes.forEach(n => { + rfPositionsRef.current.set(n.id, n.position); + }); + + const centerX = 600; + const centerY = 400; + + const newRfNodes = toRFNodes( + baseNodes, + expandedNodes, + centerX, + centerY, + selectedNodeId, + connectedNodeIds, + rfPositionsRef.current, + ); + + const nodeIdSet = new Set(newRfNodes.map(n => n.id)); + const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId); + + setRfNodes(newRfNodes); + setRfEdges(newRfEdges); + }, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds]); + + // ----- Node traversal: fetch connected nodes ----- const fetchConnectedNodes = useCallback(async (nodeId: number) => { try { - // First, fetch edges for this specific node (in case we don't have them) const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`); - let nodeEdges: Edge[] = []; + let nodeEdges: DbEdge[] = []; if (edgesRes.ok) { const edgesData = await edgesRes.json(); nodeEdges = edgesData.data || []; - // Add new edges to our edges state if (nodeEdges.length > 0) { - setEdges(prev => { - const existingEdgeIds = new Set(prev.map(e => e.id)); - const newEdges = nodeEdges.filter(e => !existingEdgeIds.has(e.id)); - if (newEdges.length > 0) { - return [...prev, ...newEdges]; - } - return prev; + setDbEdges(prev => { + const existing = new Set(prev.map(e => e.id)); + const fresh = nodeEdges.filter(e => !existing.has(e.id)); + return fresh.length > 0 ? [...prev, ...fresh] : prev; }); } } - // Get connected node IDs from both existing edges and newly fetched edges + // Find missing connected node IDs const connectedIds = new Set(); - - // From existing edges - edges.forEach(edge => { - if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id); - if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id); + dbEdges.forEach(e => { + if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id); + if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id); + }); + nodeEdges.forEach(e => { + if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id); + if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id); }); - // From newly fetched edges - nodeEdges.forEach(edge => { - if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id); - if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id); - }); - - // Find which connected nodes we don't have yet - const existingIds = new Set(allNodes.map(n => n.id)); + const existingIds = new Set(allDbNodes.map(n => n.id)); const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id)); - if (missingIds.length === 0) return; - // Fetch missing nodes - const fetchPromises = missingIds.slice(0, 50).map(async (id) => { // Limit to 50 to prevent overload - try { - const res = await fetch(`/api/nodes/${id}`); - if (res.ok) { - const data = await res.json(); - return data.node as DbNode; - } - } catch { - // Ignore individual fetch errors - } - return null; - }); + const fetched = ( + await Promise.all( + missingIds.slice(0, 50).map(async id => { + try { + const res = await fetch(`/api/nodes/${id}`); + if (res.ok) { + const data = await res.json(); + return data.node as DbNode; + } + } catch { /* ignore */ } + return null; + }), + ) + ).filter((n): n is DbNode => n !== null); - const fetchedNodes = (await Promise.all(fetchPromises)).filter((n): n is DbNode => n !== null); - - if (fetchedNodes.length > 0) { + if (fetched.length > 0) { setExpandedNodes(prev => { - const existingExpandedIds = new Set(prev.map(n => n.id)); - const newNodes = fetchedNodes.filter(n => !existingExpandedIds.has(n.id)); - return [...prev, ...newNodes]; + const ids = new Set(prev.map(n => n.id)); + const fresh = fetched.filter(n => !ids.has(n.id)); + return fresh.length > 0 ? [...prev, ...fresh] : prev; }); } } catch (err) { console.error('Failed to fetch connected nodes:', err); } - }, [edges, allNodes]); + }, [dbEdges, allDbNodes]); - // When selection changes, fetch connected nodes useEffect(() => { - if (selectedNode) { - fetchConnectedNodes(selectedNode.id); - } - }, [selectedNode, fetchConnectedNodes]); + if (selectedNodeId) fetchConnectedNodes(selectedNodeId); + }, [selectedNodeId, fetchConnectedNodes]); - // Sync with focused node from other panes (focused node awareness) + // ----- Focused node awareness ----- useEffect(() => { if (!activeTabId) return; - - // Check if this node is already in our nodes - const existingNode = allNodes.find(n => n.id === activeTabId); - - if (existingNode) { - // Node is visible, just select it - setSelectedNode(existingNode); + const existing = allDbNodes.find(n => n.id === activeTabId); + if (existing) { + setSelectedNodeId(activeTabId); + // Pan + zoom closer to the focused node + const rfNode = rfNodes.find(n => n.id === String(activeTabId)); + if (rfNode) { + reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 }); + } } else { - // Node not in current view - fetch it and add as expanded - const fetchFocusedNode = async () => { + (async () => { try { const res = await fetch(`/api/nodes/${activeTabId}`); if (res.ok) { const data = await res.json(); const node = data.node as DbNode; if (node) { - // Add to expanded nodes - setExpandedNodes(prev => { - if (prev.some(n => n.id === node.id)) return prev; - return [...prev, node]; - }); - // Select it - setSelectedNode(node); + setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]); + setSelectedNodeId(node.id); + // After the next render cycle, zoom to the newly added node + setTimeout(() => { + reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 }); + }, 100); } } } catch (err) { console.error('Failed to fetch focused node:', err); } + })(); + } + }, [activeTabId]); + + // ----- SSE real-time sync ----- + useEffect(() => { + let eventSource: EventSource | null = null; + + try { + eventSource = new EventSource('/api/events'); + + eventSource.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + + switch (payload.type) { + case 'NODE_UPDATED': { + const node = payload.data?.node as DbNode | undefined; + if (node?.id) { + const updater = (prev: DbNode[]) => + prev.map(n => n.id === node.id ? { ...n, ...node } : n); + setBaseNodes(updater); + setExpandedNodes(updater); + } + break; + } + case 'NODE_DELETED': { + const deletedId = payload.data?.nodeId; + if (deletedId) { + setBaseNodes(prev => prev.filter(n => n.id !== deletedId)); + setExpandedNodes(prev => prev.filter(n => n.id !== deletedId)); + setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId)); + setSelectedNodeId(prev => prev === deletedId ? null : prev); + } + break; + } + case 'NODE_CREATED': { + // If filtering by dimension and new node matches, could add it + // For now, just note it happened — user can refresh + break; + } + case 'EDGE_CREATED': { + const edge = payload.data?.edge as DbEdge | undefined; + if (edge?.id) { + setDbEdges(prev => { + if (prev.some(e => e.id === edge.id)) return prev; + return [...prev, edge]; + }); + } + break; + } + case 'EDGE_DELETED': { + const edgeId = payload.data?.edgeId; + if (edgeId) { + setDbEdges(prev => prev.filter(e => e.id !== edgeId)); + } + break; + } + } + } catch { + // Ignore parse errors (keep-alive pings, etc.) + } }; - fetchFocusedNode(); + + eventSource.onerror = () => { + // EventSource auto-reconnects, just log + console.error('Map SSE connection error'); + }; + } catch { + console.error('Failed to establish Map SSE connection'); } - }, [activeTabId, allNodes]); - // Position nodes in a cluster layout - const graphNodes = useMemo(() => { - if (allNodes.length === 0) return []; + return () => { + eventSource?.close(); + }; + }, []); - const { width, height } = containerSize; - const centerX = width / 2; - const centerY = height / 2; + // ----- Node drag → save position to metadata (debounced) ----- + const savePositionRef = useRef( + debounce(async (nodeId: number, x: number, y: number) => { + try { + const res = await fetch(`/api/nodes/${nodeId}`); + if (!res.ok) return; + const { node: existing } = await res.json(); + const existingMetadata = existing?.metadata ?? {}; + const mergedMeta = typeof existingMetadata === 'string' + ? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })() + : existingMetadata; - // Separate base nodes and expanded nodes - const baseNodeIds = new Set(baseNodes.map(n => n.id)); - - // Sort base nodes by edge count - const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); - const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1); - - // Position base nodes using spiral layout - const positioned: GraphNode[] = sortedBase.map((node, index) => { - const edgeCount = node.edge_count ?? 0; - const edgeRatio = edgeCount / maxEdges; - - const goldenAngle = Math.PI * (3 - Math.sqrt(5)); - const angle = index * goldenAngle; - - const isLabeled = index < LABEL_THRESHOLD; - const labelSpacing = isLabeled ? 60 : 0; - const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * Math.min(width, height) * 0.35; - const distance = baseDistance + (index * 4); - - const x = centerX + Math.cos(angle) * distance; - const y = centerY + Math.sin(angle) * distance; - - const minRadius = 3; - const maxRadius = 18; - const radius = minRadius + edgeRatio * (maxRadius - minRadius); - - return { ...node, x, y, radius, isExpanded: false }; - }); - - // Track positioned node IDs for quick lookup - const positionedMap = new Map(); - positioned.forEach(n => positionedMap.set(n.id, n)); - - // Position expanded nodes - if (selectedNode) { - // Find the selected node's position (could be in base or already expanded) - let selectedGraphNode = positionedMap.get(selectedNode.id); - - // If selected node is an expanded node not yet positioned, position it first - if (!selectedGraphNode && !baseNodeIds.has(selectedNode.id)) { - // Position the selected expanded node at a reasonable location - // Use the center or find a reference point - const selectedExpanded: GraphNode = { - ...selectedNode, - x: centerX, - y: centerY, - radius: 10, - isExpanded: true, - }; - positioned.push(selectedExpanded); - positionedMap.set(selectedNode.id, selectedExpanded); - selectedGraphNode = selectedExpanded; - } - - if (selectedGraphNode) { - // Get all expanded nodes that need positioning (not in base and not already positioned) - const expandedToPosition = expandedNodes.filter(n => - !baseNodeIds.has(n.id) && !positionedMap.has(n.id) - ); - - expandedToPosition.forEach((node, index) => { - // Position in a circle around the selected node - const angle = (index / Math.max(expandedToPosition.length, 1)) * Math.PI * 2; - const distance = 120 + (index % 3) * 30; // Vary distance slightly - - const x = selectedGraphNode!.x + Math.cos(angle) * distance; - const y = selectedGraphNode!.y + Math.sin(angle) * distance; - - const newNode: GraphNode = { - ...node, - x, - y, - radius: 8, // Fixed smaller radius for expanded nodes - isExpanded: true, - }; - positioned.push(newNode); - positionedMap.set(node.id, newNode); + await fetch(`/api/nodes/${nodeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + metadata: { ...mergedMeta, map_position: { x, y } }, + }), }); + } catch (err) { + console.error('Failed to save node position:', err); } - } - - return positioned; - }, [allNodes, baseNodes, expandedNodes, containerSize, selectedNode]); - - // Get edges between visible nodes - const graphEdges = useMemo(() => { - if (graphNodes.length === 0 || edges.length === 0) return []; - - const nodeMap = new Map(); - graphNodes.forEach(node => nodeMap.set(node.id, node)); - - return edges - .map(edge => { - const source = nodeMap.get(edge.from_node_id); - const target = nodeMap.get(edge.to_node_id); - if (!source || !target) return null; - return { id: edge.id, source, target }; - }) - .filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>; - }, [edges, graphNodes]); - - // Get connected node IDs for selected node - const connectedNodeIds = useMemo(() => { - if (!selectedNode) return new Set(); - const connected = new Set(); - edges.forEach(edge => { - if (edge.from_node_id === selectedNode.id) connected.add(edge.to_node_id); - if (edge.to_node_id === selectedNode.id) connected.add(edge.from_node_id); - }); - return connected; - }, [selectedNode, edges]); - - // Set of locked dimension names for styling - const lockedDimensionNames = useMemo(() => - new Set(lockedDimensions.map(d => d.dimension)), - [lockedDimensions] + }, 400), ); - // Pan handling - const handlePanStart = (event: React.PointerEvent) => { - const startX = event.clientX; - const startY = event.clientY; - const originX = transform.x; - const originY = transform.y; - - const handleMove = (moveEvent: PointerEvent) => { - setTransform(prev => ({ - ...prev, - x: originX + (moveEvent.clientX - startX), - y: originY + (moveEvent.clientY - startY), - })); - }; - - const handleUp = () => { - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', handleUp); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', handleUp); - }; - - const handleZoom = (direction: 'in' | 'out' | 'reset') => { - if (direction === 'reset') { - setTransform({ x: 0, y: 0, scale: 1 }); - return; + const onNodeDragStop: NodeMouseHandler> = useCallback((_event, node) => { + const nodeId = parseInt(node.id); + if (!isNaN(nodeId)) { + rfPositionsRef.current.set(node.id, node.position); + savePositionRef.current(nodeId, node.position.x, node.position.y); } - setTransform(prev => ({ - ...prev, - scale: direction === 'in' - ? Math.min(prev.scale + 0.2, 3) - : Math.max(prev.scale - 0.2, 0.5), - })); - }; + }, []); - // Handle node click - supports traversal - const handleNodeClick = (node: GraphNode) => { - const isCurrentlySelected = selectedNode?.id === node.id; + // ----- Node click → select + traverse ----- + const onNodeClickHandler: NodeMouseHandler> = useCallback((_event, node) => { + const nodeId = parseInt(node.id); + if (isNaN(nodeId)) return; + setSelectedNodeId(prev => prev === nodeId ? null : nodeId); + }, []); - if (isCurrentlySelected) { - // Clicking selected node deselects it - setSelectedNode(null); - } else { - // Select this node - will trigger fetch of its connections - setSelectedNode(node); + // ----- Node double-click → open in other pane ----- + const onNodeDoubleClick: NodeMouseHandler> = useCallback((_event, node) => { + const nodeId = parseInt(node.id); + if (!isNaN(nodeId)) onNodeClick?.(nodeId); + }, [onNodeClick]); + + // ----- Edge creation via drag ----- + const onConnect = useCallback((connection: Connection) => { + if (connection.source === connection.target) return; // No self-connections + setPendingConnection(connection); + }, []); + + const handleEdgeCreate = useCallback(async (explanation: string) => { + if (!pendingConnection?.source || !pendingConnection?.target) return; + + const fromId = parseInt(pendingConnection.source); + const toId = parseInt(pendingConnection.target); + + try { + const res = await fetch('/api/edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from_node_id: fromId, + to_node_id: toId, + source: 'user', + explanation, + created_via: 'ui', + }), + }); + + if (res.ok) { + const payload = await res.json(); + const edge = payload.data; + if (edge?.id) { + // Add to DB edges (SSE may also add it, dedup in handler) + setDbEdges(prev => { + if (prev.some(e => e.id === edge.id)) return prev; + return [...prev, edge]; + }); + } + } + } catch (err) { + console.error('Failed to create edge:', err); } - }; - const handlePaneTypeChange = (type: typeof slot extends 'A' | 'B' ? import('./types').PaneType : never) => { - onPaneAction?.({ type: 'switch-pane-type', paneType: type }); - }; + setPendingConnection(null); + }, [pendingConnection]); + + const handleEdgeCancel = useCallback(() => { + setPendingConnection(null); + }, []); + + // Get source/target titles for modal + const pendingSourceTitle = pendingConnection?.source + ? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown' + : ''; + const pendingTargetTitle = pendingConnection?.target + ? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown' + : ''; return (
- + {/* Dimension filter dropdown */}
- {lockedDimensions.map((dim) => ( + {lockedDimensions.map(dim => ( ))}
@@ -549,7 +565,7 @@ export default function MapPane({
{/* Map content */} -
+
{loading ? (
Loading map... @@ -558,48 +574,60 @@ export default function MapPane({
{error}
- ) : graphNodes.length === 0 ? ( + ) : rfNodes.length === 0 ? (
No nodes to display
) : ( - <> - {/* Zoom controls */} -
- - - -
+
+ + + - {/* Selected node info */} - {selectedNode && ( + {/* Selected node info panel */} + {selectedDbNode && (
-
- {selectedNode.title || 'Untitled'} +
+ {selectedDbNode.title || 'Untitled'}
{connectedNodeIds.size} connected nodes
- Click a connected node to traverse + Click a connected node to traverse · Double-click to open
- {selectedNode.dimensions && selectedNode.dimensions.length > 0 && ( + {selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
- {selectedNode.dimensions.slice(0, 5).map(dim => ( + {selectedDbNode.dimensions.slice(0, 5).map(dim => ( )} - {/* Open node button */}
)} - - {/* SVG Graph */} - - - - - {/* Edges */} - {graphEdges.map(edge => { - const isConnected = selectedNode && ( - edge.source.id === selectedNode.id || edge.target.id === selectedNode.id - ); - return ( - - ); - })} - - {/* Nodes */} - {graphNodes.map((node, index) => { - const isBaseNode = !node.isExpanded; - const isTop = isBaseNode && index < LABEL_THRESHOLD; - const isSelected = selectedNode?.id === node.id; - const isConnectedToSelected = connectedNodeIds.has(node.id); - const isDimmed = selectedNode && !isSelected && !isConnectedToSelected; - - return ( - handleNodeClick(node)} - style={{ cursor: 'pointer' }} - opacity={isDimmed ? 0.25 : 1} - > - {/* Highlight ring for connected nodes */} - {isConnectedToSelected && !isSelected && ( - - )} - {/* Node circle */} - - - {/* Label for top base nodes OR expanded nodes */} - {(isTop || node.isExpanded) && ( - <> - - {(node.title || 'Untitled').slice(0, 20)} - {(node.title?.length ?? 0) > 20 ? '…' : ''} - - - {!node.isExpanded && node.dimensions && node.dimensions.length > 0 && (() => { - const dims = node.dimensions.slice(0, 3).map(d => d.length > 10 ? d.slice(0, 9) + '…' : d).join(' · '); - const labelWidth = dims.length * 5 + 16; - return ( - - - - {dims} - - - ); - })()} - - {/* Show dimension for expanded nodes */} - {node.isExpanded && node.dimensions && node.dimensions.length > 0 && ( - - {node.dimensions[0]} - - )} - - )} - - ); - })} - - - +
)}
+ + {/* Edge creation explanation modal */} + {pendingConnection && ( + + )}
); } -const controlBtn: CSSProperties = { - width: 32, - height: 32, - borderRadius: 6, - border: '1px solid #262626', - background: '#141414', - color: '#888', - fontSize: 16, - cursor: 'pointer', -}; +// Wrap with ReactFlowProvider +export default function MapPane(props: MapPaneProps) { + return ( + + + + ); +} const infoPanel: CSSProperties = { position: 'absolute', diff --git a/src/components/panes/map/EdgeExplanationModal.tsx b/src/components/panes/map/EdgeExplanationModal.tsx new file mode 100644 index 0000000..eb79b99 --- /dev/null +++ b/src/components/panes/map/EdgeExplanationModal.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +interface EdgeExplanationModalProps { + sourceTitle: string; + targetTitle: string; + onSubmit: (explanation: string) => void; + onCancel: () => void; +} + +export default function EdgeExplanationModal({ + sourceTitle, + targetTitle, + onSubmit, + onCancel, +}: EdgeExplanationModalProps) { + const [explanation, setExplanation] = useState(''); + const textareaRef = useRef(null); + + useEffect(() => { + textareaRef.current?.focus(); + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && explanation.trim()) { + e.preventDefault(); + onSubmit(explanation.trim()); + } + }; + + const modalContent = ( +
+
+
e.stopPropagation()} + > + {/* Connection visualization */} +
+ {sourceTitle.length > 24 ? sourceTitle.slice(0, 22) + '\u2026' : sourceTitle} + + + + {targetTitle.length > 24 ? targetTitle.slice(0, 22) + '\u2026' : targetTitle} +
+ + {/* Textarea */} +
+