diff --git a/flowsint-api/app/api/routes/sketches.py b/flowsint-api/app/api/routes/sketches.py index 395829af..5c92d7e8 100644 --- a/flowsint-api/app/api/routes/sketches.py +++ b/flowsint-api/app/api/routes/sketches.py @@ -203,6 +203,9 @@ async def get_sketch_nodes( "data": record["data"], "label": record["data"].get("label", "Node"), "idx": idx, + # Extract x and y positions if they exist + **({"x": record["data"]["x"]} if "x" in record["data"] else {}), + **({"y": record["data"]["y"]} if "y" in record["data"] else {}), } for idx, record in enumerate(nodes_result) ] @@ -400,6 +403,60 @@ def edit_node( } +class NodePosition(BaseModel): + nodeId: str + x: float + y: float + + +class UpdatePositionsInput(BaseModel): + positions: List[NodePosition] + + +@router.put("/{sketch_id}/nodes/positions") +@update_sketch_timestamp +def update_node_positions( + sketch_id: str, + data: UpdatePositionsInput, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: Profile = Depends(get_current_user), +): + """ + Update positions (x, y) for multiple nodes in batch. + This is used to persist node positions after drag operations in the graph viewer. + """ + # Verify the sketch exists and user has access + sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first() + if not sketch: + raise HTTPException(status_code=404, detail="Sketch not found") + check_investigation_permission( + current_user.id, sketch.investigation_id, actions=["update"], db=db + ) + + if not data.positions: + return {"status": "no positions to update", "count": 0} + + # Convert Pydantic models to dicts for GraphRepository + positions = [pos.model_dump() for pos in data.positions] + + # Update positions using GraphRepository + try: + graph_repo = GraphRepository(neo4j_connection) + updated_count = graph_repo.update_nodes_positions( + positions=positions, + sketch_id=sketch_id + ) + except Exception as e: + print(f"Position update error: {e}") + raise HTTPException(status_code=500, detail="Failed to update node positions") + + return { + "status": "positions updated", + "count": updated_count, + } + + @router.delete("/{sketch_id}/nodes") @update_sketch_timestamp def delete_nodes( diff --git a/flowsint-app/src/api/sketch-service.ts b/flowsint-app/src/api/sketch-service.ts index f3efd77b..ce9642bf 100644 --- a/flowsint-app/src/api/sketch-service.ts +++ b/flowsint-app/src/api/sketch-service.ts @@ -95,5 +95,14 @@ export const sketchService = { method: 'POST', body: formData }) + }, + updateNodePositions: async ( + sketchId: string, + positions: Array<{ nodeId: string; x: number; y: number }> + ): Promise => { + return fetchWithAuth(`/api/sketches/${sketchId}/nodes/positions`, { + method: 'PUT', + body: JSON.stringify({ positions }) + }) } } diff --git a/flowsint-app/src/components/graphs/graph-main.tsx b/flowsint-app/src/components/graphs/graph-main.tsx index d934a461..4e5e96ba 100644 --- a/flowsint-app/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/components/graphs/graph-main.tsx @@ -1,17 +1,16 @@ import { useGraphStore } from '@/stores/graph-store' import React, { useRef, useCallback } from 'react' -// import GraphViewer from './graph-viewer' +import GraphViewer from './graph-viewer' // import WebGLGraphViewer from './webgl' import ContextMenu from './context-menu' -import { useGraphControls } from '@/stores/graph-controls-store' -import GraphViewer from './graph-viewer' +import { useParams } from '@tanstack/react-router' const GraphMain = () => { + const { id: sketchId } = useParams({ strict: false }) const filteredNodes = useGraphStore((s) => s.filteredNodes) const filteredEdges = useGraphStore((s) => s.filteredEdges) const toggleNodeSelection = useGraphStore((s) => s.toggleNodeSelection) const clearSelectedNodes = useGraphStore((s) => s.clearSelectedNodes) - const layoutMode = useGraphControls((s) => s.layoutMode) const graphRef = useRef(null) const containerRef = useRef(null) @@ -58,12 +57,12 @@ const GraphMain = () => { return (
{/* */} { showIcons={true} onGraphRef={handleGraphRef} allowLasso - minimap={false} + sketchId={sketchId} /> {menu && } diff --git a/flowsint-app/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/components/graphs/graph-viewer.tsx index 481f3a5d..c27a7b15 100644 --- a/flowsint-app/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/components/graphs/graph-viewer.tsx @@ -10,7 +10,7 @@ import { useTheme } from '@/components/theme-provider' import { Info, Plus, Share2, Type, Upload } from 'lucide-react' import Lasso from './lasso' import { GraphNode, GraphEdge } from '@/types' -import MiniMap from './minimap' +import { useSaveNodePositions } from '@/hooks/use-save-node-positions' function truncateText(text: string, limit: number = 16) { if (text.length <= limit) return text @@ -34,7 +34,7 @@ interface GraphViewerProps { onGraphRef?: (ref: any) => void instanceId?: string // Add instanceId prop for instance-specific actions allowLasso?: boolean - minimap?: boolean + sketchId?: string // Add sketchId for saving node positions } // Graph viewer specific colors @@ -97,9 +97,6 @@ interface LabelRenderingCompound { } } -// Pre-computed constants -const LABEL_FONT_STRING = `${CONSTANTS.LABEL_FONT_SIZE}px Sans-Serif` - // Reusable objects to avoid allocations const tempPos = { x: 0, y: 0 } const tempDimensions = [0, 0] @@ -152,7 +149,7 @@ const GraphViewer: React.FC = ({ onGraphRef, instanceId, allowLasso = false, - minimap = false + sketchId }) => { const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const isLassoActive = useGraphControls((s) => s.isLassoActive) @@ -163,6 +160,10 @@ const GraphViewer: React.FC = ({ const [currentZoom, setCurrentZoom] = useState(1) const zoomRef = useRef({ k: 1, x: 0, y: 0 }) const hoverFrameRef = useRef(null) + + // Use the dedicated hook for saving node positions + const { saveAllNodePositions, markAsStabilized, markAsStabilizing } = useSaveNodePositions(sketchId) + // The ref for the node weighlist. const labelRenderingCompound = useRef({ weightList: new Map(), @@ -178,7 +179,6 @@ const GraphViewer: React.FC = ({ // Store selectors const nodeColors = useNodesDisplaySettings((s) => s.colors) const getSize = useNodesDisplaySettings((s) => s.getSize) - const view = useGraphControls((s) => s.view) // Get settings by categories to avoid re-renders const forceSettings = useGraphSettingsStore((s) => s.forceSettings) @@ -245,9 +245,10 @@ const GraphViewer: React.FC = ({ const graph2ScreenCoords = useCallback( (node: GraphNode) => { + if (!graphRef.current) return { x: 0, y: 0 } return graphRef.current.graph2ScreenCoords(node.x, node.y) }, - [graphRef.current] + [] ) const isCurrent = useCallback( @@ -283,79 +284,6 @@ const GraphViewer: React.FC = ({ } }, [currentZoom]) - // Optimized graph initialization callback - const initializeGraph = useCallback( - (graphInstance: any) => { - if (!graphInstance) return - // Check if the graph instance has the required methods - if ( - typeof graphInstance.zoom !== 'function' || - typeof graphInstance.zoomToFit !== 'function' - ) { - // If methods aren't available yet, retry after a short delay - setTimeout(() => { - if (graphRef.current && !isGraphReadyRef.current) { - initializeGraph(graphRef.current) - } - }, 100) - return - } - if (isGraphReadyRef.current) return - isGraphReadyRef.current = true - // Only set global actions if no instanceId is provided (for main graph) - if (!instanceId) { - setActions({ - zoomIn: () => { - if (graphRef.current && typeof graphRef.current.zoom === 'function') { - const zoom = graphRef.current.zoom() - graphRef.current.zoom(zoom * 1.5) - } - }, - zoomOut: () => { - if (graphRef.current && typeof graphRef.current.zoom === 'function') { - const zoom = graphRef.current.zoom() - graphRef.current.zoom(zoom * 0.75) - } - }, - zoomToFit: () => { - if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { - graphRef.current.zoomToFit(400) - } - } - }) - } - // Call external ref callback - onGraphRef?.(graphInstance) - }, - [setActions, onGraphRef, instanceId] - ) - - // Handle graph ref changes and ensure actions are set up - useEffect(() => { - if (graphRef.current) { - initializeGraph(graphRef.current) - } - // Cleanup: reset actions when component unmounts (only for main graph) - return () => { - if (!instanceId && isGraphReadyRef.current) { - setActions({ - zoomIn: () => { }, - zoomOut: () => { }, - zoomToFit: () => { } - }) - isGraphReadyRef.current = false - } - } - }, [initializeGraph]) - - // Additional effect to ensure actions are set up when graph is ready - useEffect(() => { - if (graphRef.current && !instanceId && !isGraphReadyRef.current) { - // Try to initialize again if the graph is available but not marked as ready - initializeGraph(graphRef.current) - } - }, [graphRef.current, initializeGraph, instanceId]) - // Handle container size changes useEffect(() => { const updateSize = () => { @@ -387,7 +315,7 @@ const GraphViewer: React.FC = ({ // Transform nodes const transformedNodes = nodes.map((node) => { const type = node.data?.type as ItemType - return { + const transformed = { ...node, nodeLabel: node.data?.label || node.id, nodeColor: nodeColors[type] || GRAPH_COLORS.NODE_DEFAULT, @@ -397,6 +325,16 @@ const GraphViewer: React.FC = ({ neighbors: [] as any[], links: [] as any[] } as GraphNode & { neighbors: any[]; links: any[] } + + // If node has saved positions, fix them so ForceGraph doesn't recalculate + if (node.x !== undefined && node.y !== undefined) { + // @ts-ignore + transformed.fx = node.x + // @ts-ignore + transformed.fy = node.y + } + + return transformed }) // Create a map for quick node lookup const nodeMap = new Map(transformedNodes.map((node) => [node.id, node])) @@ -447,42 +385,147 @@ const GraphViewer: React.FC = ({ .map((node) => [node.id, node.neighbors.length]) ) labelRenderingCompound.current.constants = { nodesLength: transformedNodes.length } - // Handle hierarchy layout - if (view === 'hierarchy') { - const { nodes: nds, edges: eds } = getDagreLayoutedElements( - transformedNodes, - transformedEdges - ) - return { - nodes: nds.map((nd) => ({ - ...nd, - // Preserve the neighbors and links from the original transformed node - neighbors: nodeMap.get(nd.id)?.neighbors || [], - links: nodeMap.get(nd.id)?.links || [] - })), - links: eds - } - } + + // Always use force layout - positions are preserved if they exist (fx/fy set above) return { nodes: transformedNodes, links: transformedEdges } - }, [nodes, edges, nodeColors, getSize, view]) + }, [nodes, edges, nodeColors, getSize]) - // Retry initialization if graph data changes and actions aren't set up - useEffect(() => { - if (graphData.nodes.length > 0 && graphRef.current && !instanceId && !isGraphReadyRef.current) { - // Small delay to ensure the graph has rendered - const timeoutId = setTimeout(() => { - if (graphRef.current && !isGraphReadyRef.current) { - initializeGraph(graphRef.current) - } - }, 200) - return () => clearTimeout(timeoutId) + // Regenerate layout by removing fixed positions and reheating simulation + const regenerateLayout = useCallback((layoutType: 'force' | 'hierarchy') => { + if (!graphRef.current) { + console.error('[Graph] graphRef.current is null') + throw new Error('Graph reference is not available') } - return undefined - }, [graphData.nodes.length, initializeGraph, instanceId]) + if (!graphData || !graphData.nodes) { + console.error('[Graph] No nodes in graph data') + throw new Error('No nodes available in graph') + } + + // Remove fx and fy from all nodes to allow repositioning + graphData.nodes.forEach((node: any) => { + delete node.fx + delete node.fy + }) + + if (layoutType === 'hierarchy') { + // Apply dagre layout for hierarchical positioning + const { nodes: layoutedNodes } = getDagreLayoutedElements( + graphData.nodes, + graphData.links + ) + + // Apply the calculated positions to the graph nodes + layoutedNodes.forEach((layoutedNode: any) => { + const graphNode = graphData.nodes.find((n: any) => n.id === layoutedNode.id) as any + if (graphNode && layoutedNode.x !== undefined && layoutedNode.y !== undefined) { + graphNode.x = layoutedNode.x + graphNode.y = layoutedNode.y + graphNode.fx = layoutedNode.x + graphNode.fy = layoutedNode.y + } + }) + + // Save all node positions immediately after hierarchy layout (force save) + saveAllNodePositions(graphData.nodes, true) + } else { + // Force layout: reheat the simulation to recalculate positions + if (typeof graphRef.current.d3ReheatSimulation !== 'function') { + console.error('[Graph] d3ReheatSimulation method not available') + throw new Error('d3ReheatSimulation method is not available') + } + + // Mark as stabilizing to prevent saving during force simulation + markAsStabilizing() + graphRef.current.d3ReheatSimulation() + + // For force layout, save positions after simulation stabilizes + const warmupTicks = forceSettings?.warmupTicks?.value ?? 0 + const stabilizationTime = warmupTicks * 10 + 1500 // Wait for simulation to stabilize + + setTimeout(() => { + markAsStabilized() + // Force save all positions after stabilization + saveAllNodePositions(graphData.nodes, true) + }, stabilizationTime) + } + + // Zoom to fit after a delay to see the new layout + setTimeout(() => { + if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { + graphRef.current.zoomToFit(400) + } + }, 500) + }, [graphData, sketchId, forceSettings, saveAllNodePositions, markAsStabilized, markAsStabilizing]) + + // Optimized graph initialization callback + const initializeGraph = useCallback( + (graphInstance: any) => { + if (!graphInstance) return + // Check if the graph instance has the required methods + if ( + typeof graphInstance.zoom !== 'function' || + typeof graphInstance.zoomToFit !== 'function' + ) { + // If methods aren't available yet, retry after a short delay + setTimeout(() => { + if (graphRef.current && !isGraphReadyRef.current) { + initializeGraph(graphRef.current) + } + }, 100) + return + } + if (isGraphReadyRef.current) return + isGraphReadyRef.current = true + // Only set global actions if no instanceId is provided (for main graph) + if (!instanceId) { + setActions({ + zoomIn: () => { + if (graphRef.current && typeof graphRef.current.zoom === 'function') { + const zoom = graphRef.current.zoom() + graphRef.current.zoom(zoom * 1.5) + } + }, + zoomOut: () => { + if (graphRef.current && typeof graphRef.current.zoom === 'function') { + const zoom = graphRef.current.zoom() + graphRef.current.zoom(zoom * 0.75) + } + }, + zoomToFit: () => { + if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { + graphRef.current.zoomToFit(400) + } + }, + regenerateLayout: regenerateLayout + }) + } + // Call external ref callback + onGraphRef?.(graphInstance) + }, + [setActions, onGraphRef, instanceId, regenerateLayout] + ) + + // Initialize graph once ready + useEffect(() => { + if (graphRef.current) { + initializeGraph(graphRef.current) + } + return () => { + if (!instanceId && isGraphReadyRef.current) { + setActions({ + zoomIn: () => { }, + zoomOut: () => { }, + zoomToFit: () => { }, + regenerateLayout: () => { } + }) + isGraphReadyRef.current = false + } + } + }, [initializeGraph, instanceId, setActions]) // Event handlers with proper memoization const handleNodeClick = useCallback( @@ -511,6 +554,18 @@ const GraphViewer: React.FC = ({ setImportModalOpen(true) }, [setImportModalOpen]) + // Handle node drag end - save ALL node positions + const handleNodeDragEnd = useCallback((node: any) => { + node.fx = node.x + node.fy = node.y + + // Save positions of ALL nodes when one node is dragged + // In a force-directed graph, moving one node can affect others + if (graphData?.nodes) { + saveAllNodePositions(graphData.nodes) + } + }, [graphData, saveAllNodePositions]) + // Throttled hover handlers using RAF for better performance const handleNodeHover = useCallback((node: any) => { @@ -579,8 +634,8 @@ const GraphViewer: React.FC = ({ y, data: { label, - type: node.data.type, - connections: weight + type: node.data?.type || 'unknown', + connections: weight.toString() }, visible: true }) @@ -943,6 +998,23 @@ const GraphViewer: React.FC = ({ } }, []) + // Mark graph as stabilizing on initial load + useEffect(() => { + const warmupTicks = forceSettings?.warmupTicks?.value ?? 0 + if (warmupTicks > 0) { + markAsStabilizing() + // Wait for warmup + a small buffer to ensure graph has stabilized + const timeout = setTimeout(() => { + markAsStabilized() + }, warmupTicks * 10 + 500) // Approximate time for warmup ticks + + return () => clearTimeout(timeout) + } else { + // No warmup, mark as stabilized immediately + markAsStabilized() + } + }, [forceSettings?.warmupTicks?.value, nodes.length, edges.length, markAsStabilized, markAsStabilizing]) + // Empty state if (!nodes.length) { return ( @@ -1051,11 +1123,8 @@ const GraphViewer: React.FC = ({ onBackgroundClick={handleBackgroundClick} linkCurvature={(link) => link.curvature || 0} nodeCanvasObject={renderNode} - onNodeDragEnd={(node) => { - node.fx = node.x - node.fy = node.y - }} - cooldownTicks={view === 'hierarchy' ? 0 : forceSettings.cooldownTicks.value} + onNodeDragEnd={handleNodeDragEnd} + cooldownTicks={forceSettings.cooldownTicks.value} cooldownTime={forceSettings.cooldownTime.value} d3AlphaDecay={forceSettings.d3AlphaDecay.value} d3AlphaMin={forceSettings.d3AlphaMin.value} diff --git a/flowsint-app/src/components/graphs/index.tsx b/flowsint-app/src/components/graphs/index.tsx index 4c3ba0f4..82ce788e 100644 --- a/flowsint-app/src/components/graphs/index.tsx +++ b/flowsint-app/src/components/graphs/index.tsx @@ -133,7 +133,7 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => { } > <> - {['force', 'hierarchy'].includes(view) && } + {view === 'graph' && } {view === 'table' && } {view === 'map' && } {view === 'relationships' && } diff --git a/flowsint-app/src/components/graphs/save-status-indicator.tsx b/flowsint-app/src/components/graphs/save-status-indicator.tsx new file mode 100644 index 00000000..878ac294 --- /dev/null +++ b/flowsint-app/src/components/graphs/save-status-indicator.tsx @@ -0,0 +1,72 @@ +import { memo } from 'react' +import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react' +import { SaveStatus } from '@/hooks/use-save-node-positions' +import { cn } from '@/lib/utils' + +interface SaveStatusIndicatorProps { + status: SaveStatus +} + +export const SaveStatusIndicator = memo(({ status }: SaveStatusIndicatorProps) => { + const getStatusConfig = () => { + switch (status) { + case 'idle': + return { + icon: Cloud, + text: 'All changes saved', + className: 'text-muted-foreground/60 bg-muted/30 border-muted' + } + case 'pending': + return { + icon: Cloud, + text: 'Pending...', + className: 'text-muted-foreground bg-muted/50 border-muted' + } + case 'saving': + return { + icon: Loader2, + text: 'Saving...', + className: 'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800', + iconClassName: 'animate-spin' + } + case 'saved': + return { + icon: Check, + text: 'Saved', + className: 'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800' + } + case 'error': + return { + icon: AlertCircle, + text: 'Error saving', + className: 'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800' + } + default: + return { + icon: CloudOff, + text: 'Unknown', + className: 'text-muted-foreground bg-muted/50 border-muted' + } + } + } + + const config = getStatusConfig() + const Icon = config.icon + + return ( +
+ + {/* {config.text} */} +
+ ) +}) + +SaveStatusIndicator.displayName = 'SaveStatusIndicator' diff --git a/flowsint-app/src/components/graphs/toolbar.tsx b/flowsint-app/src/components/graphs/toolbar.tsx index d545c431..41ef5047 100644 --- a/flowsint-app/src/components/graphs/toolbar.tsx +++ b/flowsint-app/src/components/graphs/toolbar.tsx @@ -14,13 +14,17 @@ import { FunnelPlus, GitPullRequestArrow, LassoSelect, - Merge + Merge, + Network } from 'lucide-react' import { memo, useCallback } from 'react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import Filters from './filters' import { useGraphStore } from '@/stores/graph-store' +import { useConfirm } from '@/components/use-confirm-dialog' +import { useGraphSaveStatus } from '@/stores/graph-save-status-store' +import { SaveStatusIndicator } from './save-status-indicator' // Tooltip wrapper component to avoid repetition export const ToolbarButton = memo(function ToolbarButton({ @@ -67,12 +71,13 @@ export const ToolbarButton = memo(function ToolbarButton({ ) }) export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean }) { + const { confirm } = useConfirm() const view = useGraphControls((s) => s.view) const setView = useGraphControls((s) => s.setView) const zoomToFit = useGraphControls((s) => s.zoomToFit) const zoomIn = useGraphControls((s) => s.zoomIn) const zoomOut = useGraphControls((s) => s.zoomOut) - const onLayout = useGraphControls((s) => s.onLayout) + const regenerateLayout = useGraphControls((s) => s.regenerateLayout) const refetchGraph = useGraphControls((s) => s.refetchGraph) const isLassoActive = useGraphControls((s) => s.isLassoActive) const setIsLassoActive = useGraphControls((s) => s.setIsLassoActive) @@ -80,6 +85,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean const setOpenAddRelationDialog = useGraphStore((state) => state.setOpenAddRelationDialog) const setOpenMergeDialog = useGraphStore((state) => state.setOpenMergeDialog) const filters = useGraphStore((s) => s.filters) + const saveStatus = useGraphSaveStatus((s) => s.saveStatus) const handleRefresh = useCallback(() => { try { @@ -87,30 +93,55 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean } catch (error) { toast.error('Failed to refresh graph data') } - }, [refetchGraph, onLayout]) + }, [refetchGraph]) - const handleForceLayout = useCallback(() => { - setView('force') - setTimeout(() => zoomToFit(), 500) - }, [setView, zoomToFit]) + const handleApplyForceLayout = useCallback(async () => { + console.log('[Toolbar] handleApplyForceLayout called') - const handleTableLayout = useCallback(() => { - setView('table') - }, [setView]) + const confirmed = await confirm({ + title: 'Apply force layout?', + message: 'This will reset all node positions and regenerate them using the force-directed layout algorithm. Current positions will be lost.' + }) - const handleMapLayout = useCallback(() => { - setView('map') - }, [setView]) + if (!confirmed) { + console.log('[Toolbar] User cancelled layout change') + return + } - const handleRelationshipsLayout = useCallback(() => { - setView('relationships') - }, [setView]) + console.log('[Toolbar] Calling regenerateLayout with force') + try { + regenerateLayout('force') + console.log('[Toolbar] Force layout applied successfully') + toast.success('Force layout applied successfully') + } catch (error) { + console.error('[Toolbar] Failed to apply layout:', error) + toast.error(`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, [confirm, regenerateLayout]) - const handleDagreLayoutTB = useCallback(() => { - setView('hierarchy') - onLayout && onLayout('dagre-tb') - setTimeout(() => zoomToFit(), 200) - }, [onLayout, setView, zoomToFit]) + const handleApplyHierarchyLayout = useCallback(async () => { + console.log('[Toolbar] handleApplyHierarchyLayout called') + + const confirmed = await confirm({ + title: 'Apply hierarchy layout?', + message: 'This will reset all node positions and regenerate them using the hierarchical layout algorithm. Current positions will be lost.' + }) + + if (!confirmed) { + console.log('[Toolbar] User cancelled layout change') + return + } + + console.log('[Toolbar] Calling regenerateLayout with hierarchy') + try { + regenerateLayout('hierarchy') + console.log('[Toolbar] Hierarchy layout applied successfully') + toast.success('Hierarchy layout applied successfully') + } catch (error) { + console.error('[Toolbar] Failed to apply layout:', error) + toast.error(`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, [confirm, regenerateLayout]) const handleOpenAddRelationDialog = useCallback(() => { setOpenAddRelationDialog(true) @@ -133,8 +164,8 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean ) return ( -
-
+ <> +
} @@ -154,26 +185,26 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean icon={} tooltip="Zoom In" onClick={zoomIn} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip="Zoom Out" onClick={zoomOut} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip="Fit to View" onClick={zoomToFit} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip={'Lasso select'} onClick={handleLassoSelect} toggled={isLassoActive} - disabled={!['force', 'hierarchy'].includes(view)} + disabled={view !== 'graph'} /> -
-
+
+ + {/* Center: View Toggle Group */} +
+
+ + + + + + Graph view + + + + + + Table view + + + + + + Relationships view + + + + + + Map view + + +
+
+ + < div className="flex items-center gap-2 absolute right-2 top-2" > - } - tooltip={`Hierarchy`} - toggled={['hierarchy'].includes(view)} - onClick={handleDagreLayoutTB} - /> - } - tooltip={'Graph view'} - toggled={['force'].includes(view)} - onClick={handleForceLayout} - /> - } - tooltip={'Table view'} - toggled={['table'].includes(view)} - onClick={handleTableLayout} - /> - } - tooltip={'Relationships view'} - toggled={['relationships'].includes(view)} - onClick={handleRelationshipsLayout} - /> - } - tooltip={'Map view'} - toggled={['map'].includes(view)} - onClick={handleMapLayout} - /> + {view === 'graph' && ( + <> + } + tooltip={'Force layout'} + onClick={handleApplyForceLayout} + disabled={isLoading} + /> + } + tooltip={'Hierarchy layout'} + onClick={handleApplyHierarchyLayout} + disabled={isLoading} + /> + + )} -
-
+ +
+ ) }) \ No newline at end of file diff --git a/flowsint-app/src/components/graphs/webgl-graph-viewer.tsx b/flowsint-app/src/components/graphs/webgl-graph-viewer.tsx deleted file mode 100644 index 11374d54..00000000 --- a/flowsint-app/src/components/graphs/webgl-graph-viewer.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * WebGL Graph Viewer - Legacy Entry Point - * This file is kept for backward compatibility. - * The actual implementation has been refactored into a modular architecture. - * @see ./webgl/ for the new modular implementation - */ -import React from 'react' -import type { GraphNode, GraphEdge } from '@/types' -import WebGLGraphViewer from './webgl' - -/** - * @deprecated Use the new modular implementation from './webgl' directly - */ -interface WebGLGraphViewerProps { - nodes: GraphNode[] - edges: GraphEdge[] - className?: string - style?: React.CSSProperties - onNodeClick?: (node: GraphNode, event: MouseEvent) => void - onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void - onBackgroundClick?: () => void - showIcons?: boolean - showLabels?: boolean - layoutMode?: 'none' | 'force' | 'dagre' -} - -/** - * WebGL Graph Viewer Component - * High-performance graph visualization using WebGL (Pixi.js) and D3-force - * - * @deprecated This is a legacy wrapper. Import from './webgl' for the new modular version. - */ -const WebGLGraphViewerLegacy: React.FC = (props) => { - return -} - -export default WebGLGraphViewerLegacy diff --git a/flowsint-app/src/components/graphs/webgl/CHANGELOG.md b/flowsint-app/src/components/graphs/webgl/CHANGELOG.md deleted file mode 100644 index a3c587f1..00000000 --- a/flowsint-app/src/components/graphs/webgl/CHANGELOG.md +++ /dev/null @@ -1,395 +0,0 @@ -# Changelog - WebGL Graph Viewer - -## [2.3.0] - Layout Modes Architecture - -### šŸ—ļø Architecture Refactoring: Unified View System - -**Major architectural change**: Graph views consolidated into a single "graph" view with switchable layout modes. - -#### ✨ New Features - -##### Unified View Architecture -- **4 Views instead of 5**: `graph`, `table`, `relationships`, `map` -- **Graph view with layout modes**: `none` (default), `force`, `dagre` -- **Dynamic layout switching**: Change layouts without changing views -- **Layout buttons**: Only visible when in graph view - -##### Layout Mode Support -```typescript -type LayoutMode = 'none' | 'force' | 'dagre' - -// none: Static grid layout (default) -// force: D3 force-directed physics simulation -// dagre: Hierarchical top-down layout -``` - -##### Store Updates -```typescript -// graph-controls-store.ts -view: 'graph' | 'table' | 'map' | 'relationships' -layoutMode: 'none' | 'force' | 'dagre' -setLayoutMode: (mode: LayoutMode) => void -``` - -### šŸ”§ Technical Changes - -#### Updated Files - -**`use-force-simulation.ts`** -- Now supports 3 layout modes -- Static grid layout for `layoutMode === 'none'` -- Force simulation only runs when `layoutMode === 'force'` -- Dagre hierarchical layout for `layoutMode === 'dagre'` - -**`toolbar.tsx`** -- Layout buttons (Force/Dagre) only visible in graph view -- View buttons always visible -- Updated disabled states to use `view !== 'graph'` - -**`graph-main.tsx`** -- Passes `layoutMode` prop to WebGLGraphViewer -- Removed separate hierarchy/force view logic - -**`WebGLGraphViewer`** -- Added `layoutMode?: LayoutMode` prop -- Defaults to `'none'` (static grid) - -### šŸ“ Migration Guide - -#### For Users - -**Before:** -- Separate views: "Force" and "Hierarchy" -- Switching between force/hierarchy changed the entire view - -**After:** -- Single "Graph" view -- Toggle between Force/Dagre layouts within graph view -- Default: Static grid (no automatic layout) - -#### For Developers - -**Old imports (still work):** -```typescript -import WebGLGraphViewer from './webgl-graph-viewer' -// Legacy wrapper, automatically redirects to new module -``` - -**New imports (recommended):** -```typescript -import WebGLGraphViewer from './webgl' -``` - -**Props:** -```typescript - -``` - -### šŸŽÆ User Experience - -**Graph View Workflow:** -1. Open graph view (default: static grid layout) -2. Click "Force Layout" button → activates physics simulation -3. Click "Dagre Layout" button → applies hierarchical layout -4. Layout buttons hidden when switching to table/map/relationships views - -### āš ļø Breaking Changes - -None! Fully backward compatible via legacy wrapper. - -### šŸ“Š Performance - -- Static grid layout (none): Instant rendering, no CPU overhead -- Force layout: Same performance as before -- Dagre layout: One-time calculation, then static - ---- - -## [2.2.0] - Smart Label Decluttering - -### šŸš€ Major Feature: Intelligent Label Selection - -**No more overlapping labels!** Complete redesign of the labeling system with collision detection and priority-based selection. - -#### ✨ New Features - -##### Smart Decluttering Algorithm -- **Collision detection**: Labels never overlap (4px minimum margin) -- **Priority-based selection**: Important nodes get labels first -- **Bounding box calculation**: Accurate screen-space dimensions -- **Greedy algorithm**: Optimal label placement without overlap -- **Spatial hashing**: Fast collision detection for large graphs (10,000+ nodes) - -##### Priority Scoring System -```typescript -priority = connectionScore * 0.7 + centerScore * 0.3 -``` -- **Connection score (70%)**: Highly connected nodes prioritized -- **Center score (30%)**: Viewport-centered nodes prioritized -- **Selection-aware**: Selected/highlighted nodes always show labels - -##### Performance Optimizations -- Small graphs (< 500 nodes): Accurate O(n²) collision detection -- Large graphs (≄ 500 nodes): Fast O(1) spatial hashing -- Max 200 labels by default (configurable) -- Throttled recalculation (200ms) -- Zoom hiding (labels hidden during active zoom) - -### šŸ“Š Performance Impact - -| Graph Size | Before | After | Result | -|------------|--------|-------|--------| -| 100 nodes | 60 FPS (overlaps) | 60 FPS | āœ… No overlap | -| 1,000 nodes | 50 FPS (overlaps) | 58 FPS | āœ… +16% + Clean | -| 5,000 nodes | 45 FPS (overlaps) | 55 FPS | āœ… +22% + Clean | -| 10,000 nodes | 35 FPS (overlaps) | 50 FPS | āœ… +43% + Clean | - -**Benefits:** -- šŸŽØ **Cleaner visuals**: No overlapping labels -- 🧠 **Smarter selection**: Important nodes prioritized -- šŸš€ **Better performance**: Fewer labels = faster rendering -- šŸ“ **Viewport-aware**: Relevant labels shown - -### šŸ”§ Technical Changes - -#### New Module: `label-decluttering.ts` -```typescript -// New functions -calculateLabelBoundingBox() // Exact screen-space dimensions -boundingBoxesOverlap() // Collision detection -selectLabelsWithDecluttering() // Accurate selection -selectLabelsWithDeclutteringFast() // Fast selection for large graphs -``` - -#### New Type: `LabelBoundingBox` -```typescript -interface LabelBoundingBox { - nodeId: string - x: number - y: number - width: number - height: number - priority: number // 0-1, higher = more important -} -``` - -#### Updated: `use-graph-renderer.ts` -- Replaced `calculateVisibleLabels()` with `selectLabelsWithDeclutteringFast()` -- Integrated viewport and camera position -- Added decluttering to zoom throttling - -### šŸ“ New Files - -- `utils/label-decluttering.ts` - Core decluttering algorithms -- `DECLUTTERING.md` - Comprehensive documentation - -### šŸ“ Documentation - -- New [DECLUTTERING.md](./DECLUTTERING.md) with detailed explanation -- Updated [README.md](./README.md) with decluttering section -- Updated exports with new functions - -### āš ļø Breaking Changes - -None! Fully backward compatible. - -### šŸ”„ Migration - -No migration needed - decluttering is automatic! - -Old function still available (deprecated): -```typescript -// Old way (deprecated, no collision detection) -calculateVisibleLabels(nodes, zoomLevel) - -// New way (automatic in renderer) -selectLabelsWithDeclutteringFast(...) -``` - ---- - -## [2.1.0] - Styled Label Backgrounds - -### ✨ New Features - -#### Cosmograph-Inspired Label Backgrounds -- **Beautiful label backgrounds** with rounded corners -- **Subtle borders** for better visibility -- **Configurable styling** (padding, radius, colors, opacity) -- **Responsive alpha** based on highlight state -- **Performance optimized** - backgrounds update with labels - -#### Visual Improvements -- Labels now stand out clearly against any background -- Better readability in dense graphs -- Professional, polished appearance -- Consistent with modern graph visualization tools - -### šŸŽØ Style Configuration - -```ts -NODE_LABEL_PADDING_X: 6 // Horizontal padding -NODE_LABEL_PADDING_Y: 3 // Vertical padding -NODE_LABEL_BORDER_RADIUS: 4 // Rounded corners -NODE_LABEL_BORDER_WIDTH: 1 // Border thickness -LABEL_BG_COLOR: 0x1a1a1a // Dark background -LABEL_BG_BORDER_COLOR: 0x404040 // Subtle gray border -NODE_LABEL_BG_ALPHA: 0.92 // 92% opacity -NODE_LABEL_BG_BORDER_ALPHA: 0.3 // 30% opacity -``` - -### šŸ”§ Technical Changes - -- Added `NodeLabelObjects` type with `text` and `background` -- Updated `createNodeLabel()` to return label objects -- Modified `updateNodeLabel()` to manage backgrounds -- Optimized rendering order (backgrounds before text) - ---- - -## [2.0.0] - LOD System + Context-Aware Labels - -### šŸš€ Major Features - -#### Level of Details (LOD) System -- **Adaptive rendering** based on zoom level -- **4 LOD levels**: minimal, low, medium, high -- **Automatic optimization** - no configuration needed -- **Performance gain**: +80% when zoomed out - -#### Context-Aware Edge Labels -- Edge labels **only appear when nodes are active** -- Active = hovered, selected, or highlighted -- **90% reduction** in visual clutter -- **50% fewer labels** rendered on average - -### ✨ Improvements - -#### Architecture -- **Modular structure**: 16 specialized files -- **Hooks-based**: Clean separation of concerns -- **Type-safe**: Full TypeScript coverage -- **Testable**: Each module can be tested independently - -#### Performance -- **Texture caching**: Icons loaded once, reused globally -- **Batch rendering**: Edges grouped by style -- **Early returns**: Skip calculations for invisible elements -- **Manual rendering**: Better control over frame timing - -#### Developer Experience -- **LOD Indicator**: Debug component for development -- **Comprehensive docs**: README with examples -- **Public exports**: Hooks and utilities available -- **Backward compatible**: Old imports still work - -### šŸ“Š Performance Metrics - -| Nodes | Zoom Out (0.25x) | Medium (1.0x) | Zoomed In (2.0x) | -|-------|------------------|---------------|------------------| -| 1K | 60 FPS | 60 FPS | 60 FPS | -| 5K | 60 FPS | 55 FPS | 50 FPS | -| 10K | 55-60 FPS | 50 FPS | 45 FPS | -| 50K | 45-60 FPS | 35 FPS | 25-35 FPS | - -### šŸ”§ Technical Details - -#### LOD Thresholds -```ts -LOD_ICON_MIN_ZOOM: 0.6 // Icons visible at zoom >= 0.6 -LOD_NODE_LABEL_MIN_ZOOM: 0.5 // Node labels visible at zoom >= 0.5 -LOD_EDGE_LABEL_MIN_ZOOM: 1.2 // Edge labels enabled at zoom >= 1.2 -LOD_HIGH_DETAIL_ZOOM: 1.5 // High quality mode at zoom >= 1.5 -``` - -#### Edge Label Logic -```ts -// Show edge label if: -// 1. Zoom >= LOD_EDGE_LABEL_MIN_ZOOM -// 2. Source node is active OR target node is active -// Active = hovered || selected || highlighted -``` - -### šŸ“ File Structure - -``` -webgl/ -ā”œā”€ā”€ index.tsx # Main component (150 lines) -ā”œā”€ā”€ constants.ts # Configuration -ā”œā”€ā”€ exports.ts # Public API -ā”œā”€ā”€ README.md # Documentation -ā”œā”€ā”€ CHANGELOG.md # This file -ā”œā”€ā”€ components/ -│ └── lod-indicator.tsx # Debug component -ā”œā”€ā”€ hooks/ # Business logic -│ ā”œā”€ā”€ use-pixi-app.ts -│ ā”œā”€ā”€ use-force-simulation.ts -│ ā”œā”€ā”€ use-graph-interactions.ts -│ ā”œā”€ā”€ use-zoom-pan.ts -│ └── use-graph-renderer.ts -ā”œā”€ā”€ renderers/ # Rendering logic -│ ā”œā”€ā”€ node-renderer.ts -│ ā”œā”€ā”€ edge-renderer.ts -│ └── label-renderer.ts -ā”œā”€ā”€ utils/ # Utilities -│ ā”œā”€ā”€ texture-cache.ts -│ ā”œā”€ā”€ visibility-calculator.ts -│ └── color-utils.ts -└── types/ - └── graph.types.ts # TypeScript types -``` - -### šŸŽÆ Breaking Changes - -None - fully backward compatible! - -### šŸ› Bug Fixes - -- Fixed edge label overlap issues -- Fixed icon flickering on zoom -- Fixed memory leak on component unmount - -### šŸ“ Migration Guide - -#### From v1.0 (monolithic) -```tsx -// Old way - still works! -import WebGLGraphViewer from './webgl-graph-viewer' - -// New way - recommended -import WebGLGraphViewer from './webgl' -``` - -#### Using new features -```tsx -// Enable LOD indicator for debugging -import { LODIndicator } from './webgl/exports' - -// Use LOD utilities -import { getLODLevel, shouldShowNodeIcons } from './webgl/exports' - -const lodLevel = getLODLevel(zoomLevel) -console.log(`Current LOD: ${lodLevel}`) // 'minimal', 'low', 'medium', 'high' -``` - -### šŸ‘„ Contributors - -- Refactored from monolithic 1000-line component -- Implemented modular architecture -- Added LOD system -- Context-aware edge labels - ---- - -## [1.0.0] - Initial Release - -- Basic WebGL graph visualization -- Pixi.js + D3-force -- Node interactions (hover, click, drag) -- Zoom/pan controls -- Basic icon and label support diff --git a/flowsint-app/src/components/graphs/webgl/DECLUTTERING.md b/flowsint-app/src/components/graphs/webgl/DECLUTTERING.md deleted file mode 100644 index bbab1f16..00000000 --- a/flowsint-app/src/components/graphs/webgl/DECLUTTERING.md +++ /dev/null @@ -1,418 +0,0 @@ -# Label Decluttering System - -This document explains the intelligent label decluttering system that prevents labels from overlapping. - -## Overview - -The decluttering system automatically selects which labels to display based on: -1. **Collision detection** - Labels never overlap -2. **Priority scoring** - Important nodes get labels first -3. **Spatial awareness** - Viewport-centered labels preferred -4. **Selection state** - Selected/highlighted nodes always show labels - -## Architecture - -### Before: Simple Selection -```typescript -// Old system (basic, no collision detection) -calculateVisibleLabels(nodes, zoomLevel) -// - Selected top N nodes by connection count -// - No collision detection -// - Labels could overlap completely -``` - -### After: Intelligent Decluttering -```typescript -// New system (collision-aware, priority-based) -selectLabelsWithDeclutteringFast( - nodes, - nodeSize, - zoomLevel, - fontSizeSetting, - viewportWidth, - viewportHeight, - cameraX, - cameraY, - selectedNodeIds, - highlightedNodeIds -) -// - Calculates real bounding boxes -// - Detects collisions -// - Greedy selection by priority -// - Optimized with spatial hashing for large graphs -``` - -## Key Components - -### 1. Bounding Box Calculation - -Each label's exact screen space is calculated: - -```typescript -interface LabelBoundingBox { - nodeId: string - x: number // Center X position - y: number // Top Y position - width: number // Background width (text + padding) - height: number // Background height (text + padding) - priority: number // 0-1, higher = more important -} -``` - -**Calculation includes:** -- Text length estimation (`fontSize * 0.6` per character) -- Background padding (`NODE_LABEL_PADDING_X`, `NODE_LABEL_PADDING_Y`) -- Label position (below node: `node.y + nodeSize + fontSize * 0.6`) -- Zoom-adjusted font size - -### 2. Priority Scoring - -Labels are prioritized using multiple factors: - -```typescript -priority = connectionScore * 0.7 + centerScore * 0.3 -``` - -**Connection Score (70% weight):** -- Normalized by neighbor count -- `Math.min(node.neighbors.length / 20, 1)` -- Highly connected nodes = more important - -**Center Score (30% weight):** -- Distance from viewport center -- Closer to center = more important -- Calculated as: `1 - distanceFromCenter / maxDistance` - -**Result:** -- Nodes with many connections in viewport center get highest priority -- Peripheral or less-connected nodes deprioritized - -### 3. Collision Detection - -Two methods for different graph sizes: - -#### Accurate Detection (< 500 nodes) -```typescript -function boundingBoxesOverlap(a: LabelBoundingBox, b: LabelBoundingBox): boolean { - // Add 4px margin for breathing room - const margin = 4 - - // Calculate bounds with margin - const aLeft = a.x - a.width / 2 - margin - const aRight = a.x + a.width / 2 + margin - const aTop = a.y - margin - const aBottom = a.y + a.height + margin - - // Check for overlap - return !(aRight < bLeft || aLeft > bRight || aBottom < bTop || aTop > bBottom) -} -``` - -#### Spatial Hashing (≄ 500 nodes) -For large graphs, uses a grid-based approach: - -```typescript -const gridSize = 100 // 100px grid cells -const occupiedCells = new Set() - -// Mark cell as occupied -const cellKey = `${Math.floor(x / gridSize)},${Math.floor(y / gridSize)}` -occupiedCells.add(cellKey) - -// Also mark adjacent cells (9 cells total) -for (let dx = -1; dx <= 1; dx++) { - for (let dy = -1; dy <= 1; dy++) { - occupiedCells.add(`${cellX + dx},${cellY + dy}`) - } -} -``` - -**Benefits:** -- O(1) collision check instead of O(n²) -- 10-100x faster for large graphs -- Slight accuracy tradeoff acceptable for performance - -### 4. Greedy Selection Algorithm - -Labels are selected in order of priority: - -```typescript -// Step 1: Sort all labels by priority (highest first) -boundingBoxes.sort((a, b) => b.priority - a.priority) - -// Step 2: Always add selected/highlighted nodes first -const placedBboxes = [...selectedBboxes, ...highlightedBboxes] -visibleSet.add(selectedNodeIds) -visibleSet.add(highlightedNodeIds) - -// Step 3: Greedily add non-colliding labels -for (const candidate of regularBboxes) { - let hasCollision = false - - for (const placed of placedBboxes) { - if (boundingBoxesOverlap(candidate, placed)) { - hasCollision = true - break - } - } - - if (!hasCollision) { - placedBboxes.push(candidate) - visibleSet.add(candidate.nodeId) - } -} -``` - -**Guarantees:** -- āœ… Selected/highlighted nodes always have visible labels -- āœ… No two labels ever overlap (4px minimum margin) -- āœ… Higher priority nodes get labels before lower priority -- āœ… Maximum label density without collisions - -## Performance Optimizations - -### 1. Algorithm Selection -```typescript -if (nodes.length < 500) { - // Use accurate collision detection - selectLabelsWithDecluttering(...) -} else { - // Use spatial hashing - selectLabelsWithDeclutteringFast(..., maxLabels: 200) -} -``` - -### 2. Throttling -```typescript -// Recalculate max once every 200ms during zoom -if (now - lastLabelUpdateRef.current > 200) { - lastLabelUpdateRef.current = now - setVisibleLabels(selectLabelsWithDeclutteringFast(...)) -} -``` - -### 3. Zoom Hiding -```typescript -// Hide ALL labels during active zoom (< 300ms since last zoom change) -if (isZoomingRef.current) { - labelObjects.text.visible = false - labelObjects.background.visible = false - return -} -``` - -### 4. Frame Limiting -```typescript -// Still limit to 100 labels rendered per frame -const MAX_LABELS_PER_FRAME = 100 -let renderedLabelCount = 0 - -// In render loop: -if (renderedLabelCount >= MAX_LABELS_PER_FRAME) { - label.visible = false - return -} -``` - -## Performance Metrics - -| Graph Size | Old System | New System | Improvement | -|------------|-----------|------------|-------------| -| 100 nodes | 60 FPS | 60 FPS | Same | -| 500 nodes | 55 FPS (overlaps) | 60 FPS (clean) | +9% + No overlap | -| 1,000 nodes | 50 FPS (overlaps) | 58 FPS (clean) | +16% + No overlap | -| 5,000 nodes | 45 FPS (overlaps) | 55 FPS (clean) | +22% + No overlap | -| 10,000 nodes | 35 FPS (overlaps) | 50 FPS (clean) | +43% + No overlap | - -**Benefits:** -- šŸš€ **Better performance** (fewer labels = faster rendering) -- šŸŽØ **Cleaner visuals** (no overlapping labels) -- 🧠 **Smarter selection** (important nodes prioritized) -- šŸ“ **Viewport-aware** (relevant labels shown) - -## Customization - -### Adjust Priority Weighting -```typescript -// In label-decluttering.ts > calculateNodePriority() -priority = connectionScore * 0.7 + centerScore * 0.3 -// ^^^^ ^^^^ -// Importance Position - -// Make position more important: -priority = connectionScore * 0.5 + centerScore * 0.5 - -// Make connections more important: -priority = connectionScore * 0.9 + centerScore * 0.1 -``` - -### Adjust Collision Margin -```typescript -// In boundingBoxesOverlap() -const margin = 4 // Increase for more spacing -``` - -### Adjust Grid Size (Large Graphs) -```typescript -// In selectLabelsWithDeclutteringFast() -const gridSize = 100 // Smaller = more accurate, slower - // Larger = faster, less accurate -``` - -### Adjust Max Labels -```typescript -selectLabelsWithDeclutteringFast( - ..., - maxLabels: 200 // Increase for more labels, decrease for performance -) -``` - -## Integration - -The decluttering system is automatically used in `use-graph-renderer.ts`: - -```typescript -// On zoom change (throttled) -const visibleLabels = selectLabelsWithDeclutteringFast( - simulationNodes, - forceSettings.nodeSize.value, - zoomLevel, - forceSettings.nodeLabelFontSize?.value ?? 50, - viewport.width, - viewport.height, - -stage.position.x / stage.scale.x, // Camera X - -stage.position.y / stage.scale.y, // Camera Y - selectedNodeIds, - highlightedNodeIds -) -setVisibleLabels(visibleLabels) - -// In render loop -const shouldShow = showLabelsLOD && visibleLabels.has(node.id) && !isZoomingRef.current -updateNodeLabel(labelObjects, node, nodeSize, shouldShow, ...) -``` - -## Future Enhancements - -Potential improvements: - -1. **Dynamic repulsion**: Slightly adjust label positions to fit more -2. **Multi-line labels**: Wrap long text instead of truncating -3. **Leader lines**: Connect labels to nodes when repositioned -4. **Semantic zoom**: Different label content at different zoom levels -5. **Animation**: Smooth fade in/out when labels change -6. **GPU acceleration**: Move collision detection to compute shader - -## Comparison: Before vs After - -### Before (Simple Selection) -``` - ā— ā— - Long Label Name Another Label - ā— - Third Label - -āŒ Labels overlap -āŒ Hard to read -āŒ No spatial awareness -āŒ Random selection -``` - -### After (Decluttering) -``` - ā— ā— - Long Label Name - - ā— - Another Label - - ā— - -āœ… No overlap (4px margin) -āœ… Clear and readable -āœ… Viewport-centered priority -āœ… Smart selection -``` - -## Debug & Testing - -### Visualize Bounding Boxes (Development) -```typescript -// In use-graph-renderer.ts render loop -if (process.env.NODE_ENV === 'development') { - const bbox = calculateLabelBoundingBox(node, ...) - if (bbox) { - const debugRect = new Graphics() - debugRect.rect( - bbox.x - bbox.width / 2, - bbox.y, - bbox.width, - bbox.height - ) - debugRect.stroke({ width: 1, color: 0xff0000, alpha: 0.5 }) - debugContainer.addChild(debugRect) - } -} -``` - -### Log Priority Scores -```typescript -const visibleLabels = selectLabelsWithDeclutteringFast(...) -console.log('Visible labels:', visibleLabels.size) -console.log('Top priority nodes:', - boundingBoxes.slice(0, 10).map(b => ({ - id: b.nodeId, - priority: b.priority - })) -) -``` - -## API Reference - -### `calculateLabelBoundingBox()` -Calculates the exact screen-space bounding box for a node label. - -**Parameters:** -- `node: SimulationNode` - The node -- `nodeSize: number` - Visual node size -- `zoomLevel: number` - Current zoom -- `fontSizeSetting: number` - Font size % -- `viewportWidth: number` - Viewport width -- `viewportHeight: number` - Viewport height -- `cameraX: number` - Camera X position -- `cameraY: number` - Camera Y position - -**Returns:** `LabelBoundingBox | null` - -### `boundingBoxesOverlap()` -Checks if two bounding boxes collide (with 4px margin). - -**Parameters:** -- `a: LabelBoundingBox` - First box -- `b: LabelBoundingBox` - Second box - -**Returns:** `boolean` - True if overlap - -### `selectLabelsWithDecluttering()` -Accurate greedy selection with full collision detection. - -**Use for:** < 500 nodes - -**Returns:** `Set` - Node IDs with visible labels - -### `selectLabelsWithDeclutteringFast()` -Fast selection with spatial hashing for large graphs. - -**Use for:** ≄ 500 nodes - -**Parameters:** -- All same as `calculateLabelBoundingBox()` -- `selectedNodeIds: Set` - Always show -- `highlightedNodeIds: Set` - Always show -- `maxLabels: number` - Max labels (default: 200) - -**Returns:** `Set` - Node IDs with visible labels - -## License - -Internal use only. diff --git a/flowsint-app/src/components/graphs/webgl/LABEL_STYLES.md b/flowsint-app/src/components/graphs/webgl/LABEL_STYLES.md deleted file mode 100644 index 5dbc7d24..00000000 --- a/flowsint-app/src/components/graphs/webgl/LABEL_STYLES.md +++ /dev/null @@ -1,194 +0,0 @@ -# Label Styling Guide - -This document explains the label background styling system inspired by Cosmograph. - -## Visual Appearance - -### Node Labels with Backgrounds (Active Only) - -**Important**: Backgrounds are shown ONLY for active nodes (hovered or selected) to maintain optimal performance. - -``` -Normal Node: Active Node (hover/selected): - ā— ā— - Node Label ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ Node Label │ ← Background appears - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - -### Anatomy of a Label Background - -``` - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ← Border (1px, #404040, 30% opacity) - │ ā”Œā”€ Padding (6px horizontal) │ - │ │ │ - │ │ Text Content │ ← Text (#e0e0e0) - │ │ │ - │ └─ Padding (3px vertical) │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - Background (#1a1a1a, 92% opacity) - Border Radius: 4px -``` - -## Configuration - -### Default Values - -```typescript -NODE_LABEL_PADDING_X: 6 // 6px left and right -NODE_LABEL_PADDING_Y: 3 // 3px top and bottom -NODE_LABEL_BORDER_RADIUS: 4 // 4px rounded corners -NODE_LABEL_BORDER_WIDTH: 1 // 1px border -LABEL_BG_COLOR: 0x1a1a1a // Dark gray (#1a1a1a) -LABEL_BG_BORDER_COLOR: 0x404040 // Medium gray (#404040) -NODE_LABEL_BG_ALPHA: 0.92 // 92% opacity -NODE_LABEL_BG_BORDER_ALPHA: 0.3 // 30% opacity -``` - -### State-Based Styling - -Labels and backgrounds adapt based on interaction state: - -| State | Label Visible | Background Visible | Alpha | -|-------|---------------|-------------------|-------| -| **Normal** | āœ… (if in visible set) | āŒ No background | 1.0 | -| **Hovered** | āœ… Always | āœ… **Background shown** | 1.0 | -| **Selected** | āœ… Always | āœ… **Background shown** | 1.0 | -| **Dimmed** | āœ… (if visible) | āŒ No background | 0.2 | - -**Performance Note**: Backgrounds only render for active nodes (hovered/selected), reducing GPU load by ~99%. - -## Customization Examples - -### Lighter Background -```typescript -LABEL_BG_COLOR: 0x2a2a2a // Lighter gray -NODE_LABEL_BG_ALPHA: 0.95 // More opaque -``` - -### More Prominent Border -```typescript -LABEL_BG_BORDER_COLOR: 0x606060 // Lighter border -NODE_LABEL_BG_BORDER_ALPHA: 0.5 // More visible -NODE_LABEL_BORDER_WIDTH: 2 // Thicker -``` - -### More Padding -```typescript -NODE_LABEL_PADDING_X: 8 // More horizontal space -NODE_LABEL_PADDING_Y: 4 // More vertical space -``` - -### Sharper Corners -```typescript -NODE_LABEL_BORDER_RADIUS: 2 // Less rounded -``` - -### Glass Morphism Effect -```typescript -LABEL_BG_COLOR: 0x1a1a1a -NODE_LABEL_BG_ALPHA: 0.7 // More transparent -LABEL_BG_BORDER_COLOR: 0xffffff // White border -NODE_LABEL_BG_BORDER_ALPHA: 0.2 // Subtle glow -``` - -## Rendering Details - -### Rendering Order - -1. **Background graphics** (rendered first) -2. **Text content** (rendered on top) - -This ensures text is always readable and clickable. - -### Dynamic Sizing - -Backgrounds automatically adapt to text size: -```typescript -const textMetrics = label.getBounds() -const bgWidth = textMetrics.width + PADDING_X * 2 -const bgHeight = textMetrics.height + PADDING_Y * 2 -``` - -### Performance Optimizations - -āœ… **Context-Aware Rendering**: Backgrounds shown ONLY for active nodes (hovered/selected) -āœ… **Dimension Estimation**: Fast approximation instead of expensive `getBounds()` -āœ… **Dirty Checking**: Only redraw when dimensions change -āœ… **Cached Calculations**: Store width to avoid recalculation -āœ… **Combined Draw Calls**: Fill and stroke in one operation -āœ… **GPU Compositing**: Alpha blending handled by hardware - -**Result**: ~99% reduction in background rendering cost! šŸš€ - -## Comparison with Cosmograph - -| Feature | Cosmograph | This Implementation | -|---------|-----------|---------------------| -| Background | āœ… | āœ… | -| Border | āœ… | āœ… | -| Rounded corners | āœ… | āœ… | -| Adaptive opacity | āœ… | āœ… | -| Configurable | Limited | āœ… Fully | -| Performance | High | High | - -## Tips - -1. **Readability**: Keep background alpha > 0.8 for best readability -2. **Contrast**: Ensure border color contrasts with background -3. **Padding**: More padding = clearer labels but larger footprint -4. **Border**: Keep border subtle (alpha 0.2-0.4) to avoid visual noise - -## Examples in Code - -### Basic Label -```typescript -const label = createNodeLabel("John Doe") -// Returns: { text: Text, background: Graphics } -``` - -### Update Label -```typescript -updateNodeLabel( - labelObjects, - node, - nodeSize, - shouldShow, - zoomLevel, - fontSizeSetting, - isHighlighted, - hasAnyHighlight -) -// Automatically updates both text and background -``` - -### Custom Styling (in constants.ts) -```typescript -export const GRAPH_CONSTANTS = { - // ... other constants - - // Custom label style - NODE_LABEL_PADDING_X: 8, - NODE_LABEL_PADDING_Y: 5, - NODE_LABEL_BORDER_RADIUS: 6, - LABEL_BG_COLOR: 0x2a2a2a, - NODE_LABEL_BG_ALPHA: 0.95, - - // ... rest of constants -} -``` - -## Browser Rendering - -Labels use Pixi.js Graphics API which: -- Renders via WebGL for maximum performance -- Supports anti-aliasing for smooth borders -- Hardware accelerated alpha blending -- Batches similar draw calls automatically - -## Accessibility - -While this is a canvas-based visualization (not DOM), consider: -- High contrast mode support via color constants -- Font size adjustments via zoom level -- Clear backgrounds improve readability for all users diff --git a/flowsint-app/src/components/graphs/webgl/README.md b/flowsint-app/src/components/graphs/webgl/README.md deleted file mode 100644 index 299be5db..00000000 --- a/flowsint-app/src/components/graphs/webgl/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# WebGL Graph Viewer - -A high-performance, modular graph visualization component built with Pixi.js (WebGL) and D3-force. - -## Features - -- **High Performance**: WebGL-based rendering with Pixi.js -- **Physics Simulation**: D3-force for realistic node interactions -- **Modular Architecture**: Clean separation of concerns -- **Type Safe**: Full TypeScript support -- **Customizable**: Extensive configuration options -- **Interactive**: Zoom, pan, drag, hover, and click interactions -- **Smart Decluttering**: Intelligent label selection with collision detection -- **Progressive Disclosure**: Priority-based label visibility -- **Icon Support**: SVG icon rendering with texture caching -- **Styled Labels**: Cosmograph-inspired label backgrounds with borders -- **Context-Aware**: Edge labels only appear when nodes are active - -## Architecture - -``` -webgl/ -ā”œā”€ā”€ index.tsx # Main component -ā”œā”€ā”€ constants.ts # Configuration constants -ā”œā”€ā”€ types/ -│ └── graph.types.ts # TypeScript types -ā”œā”€ā”€ hooks/ -│ ā”œā”€ā”€ use-pixi-app.ts # Pixi.js app management -│ ā”œā”€ā”€ use-force-simulation.ts # D3-force simulation -│ ā”œā”€ā”€ use-graph-interactions.ts # User interactions -│ ā”œā”€ā”€ use-zoom-pan.ts # Zoom/pan controls -│ └── use-graph-renderer.ts # Main rendering logic -ā”œā”€ā”€ renderers/ -│ ā”œā”€ā”€ node-renderer.ts # Node rendering -│ ā”œā”€ā”€ edge-renderer.ts # Edge rendering -│ └── label-renderer.ts # Label rendering -└── utils/ - ā”œā”€ā”€ texture-cache.ts # Icon texture caching - ā”œā”€ā”€ visibility-calculator.ts # LOD thresholds - ā”œā”€ā”€ label-decluttering.ts # Smart label selection - └── color-utils.ts # Color utilities -``` - -## Usage - -### Basic Example - -```tsx -import WebGLGraphViewer from '@/components/graphs/webgl' - -function MyGraph() { - const nodes = [ - { id: '1', data: { label: 'Node 1', type: 'person' } }, - { id: '2', data: { label: 'Node 2', type: 'company' } }, - ] - - const edges = [ - { source: '1', target: '2', label: 'works at' }, - ] - - return ( - console.log('Clicked:', node)} - showIcons={true} - showLabels={true} - /> - ) -} -``` - -### With Custom Handlers - -```tsx - { - if (event.ctrlKey) { - // Multi-select - } else { - // Single select - } - }} - onNodeRightClick={(node, event) => { - // Show context menu - }} - onBackgroundClick={() => { - // Deselect all - }} - className="my-graph" - style={{ width: '100%', height: '600px' }} -/> -``` - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `nodes` | `GraphNode[]` | Required | Array of graph nodes | -| `edges` | `GraphEdge[]` | Required | Array of graph edges | -| `className` | `string` | `''` | CSS class name | -| `style` | `React.CSSProperties` | `undefined` | Inline styles | -| `onNodeClick` | `(node, event) => void` | `undefined` | Node click handler | -| `onNodeRightClick` | `(node, event) => void` | `undefined` | Node right-click handler | -| `onBackgroundClick` | `() => void` | `undefined` | Background click handler | -| `showIcons` | `boolean` | `true` | Show node icons | -| `showLabels` | `boolean` | `true` | Show node/edge labels | - -## Performance Optimizations - -### 1. Smart Label Decluttering - -**Intelligent label selection with collision detection** - -The graph uses an advanced decluttering algorithm that: -- āœ… **Prevents overlap**: Labels never collide (4px minimum margin) -- āœ… **Priority-based**: Important nodes (highly connected, viewport-centered) get labels first -- āœ… **Collision detection**: Real bounding box calculations -- āœ… **Spatial hashing**: O(1) collision checks for large graphs (10,000+ nodes) -- āœ… **Selection-aware**: Selected/highlighted nodes always show labels - -**How it works:** - -```typescript -// Combines multiple factors for smart selection -priority = connectionScore * 0.7 + centerScore * 0.3 - -// Greedy algorithm: -// 1. Sort nodes by priority (highest first) -// 2. Always add selected/highlighted nodes -// 3. Add remaining nodes if they don't collide -``` - -**Performance:** -- Small graphs (< 500 nodes): Accurate collision detection -- Large graphs (≄ 500 nodes): Fast spatial hashing -- 200 max labels by default (configurable) - -See [DECLUTTERING.md](./DECLUTTERING.md) for detailed documentation. - -### 2. Level of Details (LOD) System -Automatically adjusts visual complexity based on zoom level: - -| Zoom Level | LOD | Features Visible | -|------------|-----|------------------| -| < 0.5 | **Minimal** | Nodes + Edges only | -| 0.5 - 0.6 | **Low** | + Basic node labels | -| 0.6 - 1.2 | **Medium** | + Icons | -| 1.2 - 1.5 | **High** | + Edge labels* | -| > 1.5 | **Ultra** | All features at max quality | - -**Edge labels are context-aware**: They only appear when at least one of their connected nodes is active (hovered or selected), reducing visual clutter and improving performance. - -```ts -import { getLODLevel, shouldShowNodeIcons } from './exports' - -const lodLevel = getLODLevel(zoomLevel) // 'minimal', 'low', 'medium', 'high' -const showIcons = shouldShowNodeIcons(zoomLevel) // boolean -``` - -**Benefits:** -- šŸš€ **60 FPS maintained** even with 10,000+ nodes when zoomed out -- šŸ’¾ **Reduced GPU load** by hiding unnecessary details -- ⚔ **Instant zoom** response without lag - -### 3. Texture Caching -Icons are loaded once and cached globally: -```ts -import { textureCache } from './utils/texture-cache' - -// Preload textures -await textureCache.preloadBatch(['person', 'company']) - -// Get cached texture -const texture = textureCache.get('person') -``` - -### 4. Batch Rendering -Edges are grouped by style and rendered in batches: -- Highlighted edges (orange) -- Dimmed edges (low opacity) -- Default edges (normal) - -### 5. Progressive Label Disclosure (Deprecated) - -**Note:** Progressive disclosure is now handled by the Smart Decluttering system (see above). - - -Labels appear based on: -- Node importance (connection count) -- Current zoom level -- Selection/highlight state -- **LOD thresholds** - -**Smart Edge Labels:** -- Edge labels are **context-aware** and only appear when their nodes are active -- Active = hovered, selected, or highlighted -- This reduces visual clutter by 90% in typical use cases -- Performance improvement: ~50% fewer labels rendered at any time - -### 6. Manual Rendering -Pixi.js app uses manual rendering (`autoStart: false`) for better control. - -### 7. Optimized Updates -- Refs used for values that don't need re-renders -- Memoized calculations -- Debounced resize handler -- **LOD-based early returns** (skip calculations for invisible elements) - -## Customization - -### Constants -Edit `constants.ts` to customize: -- Node sizes and colors -- Edge widths and colors -- Zoom limits -- Label visibility thresholds -- **LOD thresholds** (when to show/hide features) -- **Label styling** (background, border, padding) -- Animation durations - -**LOD Configuration:** -```ts -// In constants.ts -LOD_ICON_MIN_ZOOM: 0.6, // Show icons when zoom >= 0.6 -LOD_NODE_LABEL_MIN_ZOOM: 0.5, // Show node labels when zoom >= 0.5 -LOD_EDGE_LABEL_MIN_ZOOM: 1.2, // Enable edge labels when zoom >= 1.2 - // (only shown when nodes are active) -LOD_HIGH_DETAIL_ZOOM: 1.5, // High detail mode when zoom >= 1.5 -``` - -**Label Styling:** -```ts -// Node label backgrounds (Cosmograph-inspired) -NODE_LABEL_PADDING_X: 6, // Horizontal padding -NODE_LABEL_PADDING_Y: 3, // Vertical padding -NODE_LABEL_BORDER_RADIUS: 4, // Rounded corners -NODE_LABEL_BORDER_WIDTH: 1, // Border thickness -LABEL_BG_COLOR: 0x1a1a1a, // Background color -LABEL_BG_BORDER_COLOR: 0x404040, // Border color -NODE_LABEL_BG_ALPHA: 0.92, // Background opacity -NODE_LABEL_BG_BORDER_ALPHA: 0.3, // Border opacity -``` - -**Edge Label Behavior:** -Edge labels are now **context-aware** - they only appear when: -1. Zoom level >= `LOD_EDGE_LABEL_MIN_ZOOM` (LOD check) -2. At least one connected node is **active** (hovered or selected) - -This dramatically reduces visual clutter while keeping relevant information visible. - -### Renderers -Modify renderer functions to change visual appearance: -- `renderNode()` - Node shapes and colors -- `renderEdges()` - Edge styles -- `updateNodeLabel()` - Label positioning and styling - -### Hooks -Extend or replace hooks for custom behavior: -- `useForceSimulation()` - Custom physics -- `useGraphInteractions()` - Custom interactions -- `useZoomPan()` - Custom zoom/pan behavior - -## Debugging - -### LOD Indicator -Enable the LOD indicator to see current zoom level and active LOD mode: - -```tsx -// In index.tsx, uncomment the import and component: -import { LODIndicator } from './components/lod-indicator' - -// In the return statement: - -``` - -The indicator shows: -- Current LOD level (color-coded) -- Description of visible features -- Exact zoom value - -**Color Legend:** -- šŸ”“ Red: Minimal (nodes + edges only) -- 🟠 Orange: Low (+ basic labels) -- 🟔 Yellow: Medium (+ icons) -- 🟢 Green: High (all features) - -### Performance Profiling -```tsx -// Use Chrome DevTools Performance tab -// Look for "render" calls in the flame graph -// LOD should reduce GPU time when zoomed out -``` - -## Testing - -### Unit Tests -```bash -npm test -- webgl -``` - -### Visual Testing -```tsx -// In Storybook -import WebGLGraphViewer from '@/components/graphs/webgl' - -export default { - title: 'Graph/WebGLGraphViewer', - component: WebGLGraphViewer, -} -``` - -### LOD Testing -1. Zoom out completely - should see minimal LOD (no icons, no labels) -2. Zoom to 0.6x - icons should appear -3. Zoom to 1.2x - edge labels **enabled** (but only visible on hover/select) -4. Hover over a node - edge labels for connected edges should appear -5. Select a node - edge labels remain visible -6. Deselect/unhover - edge labels disappear -7. Use LODIndicator component to verify thresholds - -## Performance Metrics - -Tested with LOD system: -- **1,000 nodes**: 60 FPS (all zoom levels) -- **5,000 nodes**: 60 FPS (zoomed out), 50-60 FPS (zoomed in) -- **10,000 nodes**: 55-60 FPS (zoomed out), 40-50 FPS (zoomed in) -- **50,000 nodes**: 45-60 FPS (zoomed out), 25-35 FPS (zoomed in) - -Performance depends on: -- Device capabilities -- **Zoom level (LOD)** ⭐ Major factor -- Number of visible labels -- Active interactions - -**LOD Impact:** -- Zoomed out (LOD: minimal): **+80% performance** -- Medium zoom (LOD: medium): **+40% performance** -- Fully zoomed in (LOD: high): Baseline performance -- **Context-aware edge labels**: **+50% fewer labels** rendered on average - -## Browser Compatibility - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -Requires WebGL support. - -## License - -Internal use only. diff --git a/flowsint-app/src/hooks/use-save-node-positions.ts b/flowsint-app/src/hooks/use-save-node-positions.ts new file mode 100644 index 00000000..2dd55f40 --- /dev/null +++ b/flowsint-app/src/hooks/use-save-node-positions.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDebounce } from './use-debounce' +import { sketchService } from '@/api/sketch-service' +import { useGraphSaveStatus } from '@/stores/graph-save-status-store' +import { useGraphStore } from '@/stores/graph-store' + +interface NodePosition { + x: number + y: number +} + +export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' + +/** + * Hook to save node positions to the backend + * Handles debouncing and batch updates for all nodes + */ +export function useSaveNodePositions(sketchId?: string) { + const [changedNodePositions, setChangedNodePositions] = useState>(new Map()) + const setNodes = useGraphStore(s => s.setNodes) + const isStabilizingRef = useRef(false) // Start as false, mark as true only during layout regeneration + const setSaveStatus = useGraphSaveStatus((state) => state.setSaveStatus) + + // Debounced value to trigger save (2 seconds) + const debouncedChangedPositions = useDebounce(changedNodePositions, 1200) + + // Initialize status on mount + useEffect(() => { + setSaveStatus('idle') + }, [setSaveStatus]) + + /** + * Mark the graph as stabilized (can start saving positions) + */ + const markAsStabilized = useCallback(() => { + isStabilizingRef.current = false + }, []) + + /** + * Mark the graph as stabilizing (prevent saving positions) + */ + const markAsStabilizing = useCallback(() => { + isStabilizingRef.current = true + }, []) + + /** + * Save positions for all nodes + * @param nodes - Array of nodes or graphData.nodes + * @param force - Force save even if stabilizing (for layout regeneration) + */ + const saveAllNodePositions = useCallback((nodes: any[], force = false) => { + if (!sketchId) { + return + } + + if (!force && isStabilizingRef.current) { + return + } + + const newMap = new Map() + + nodes.forEach((node: any) => { + if (node.x !== undefined && node.y !== undefined) { + // Fix node position to prevent force simulation from moving it + node.fx = node.x + node.fy = node.y + newMap.set(node.id, { x: node.x, y: node.y }) + } + }) + + if (newMap.size > 0) { + setChangedNodePositions(newMap) + setNodes(nodes) + setSaveStatus('pending') + } + }, [sketchId]) + + /** + * Clear pending changes (useful when switching sketches) + */ + const clearPendingChanges = useCallback(() => { + setChangedNodePositions(new Map()) + }, []) + + // Save positions when debounced value changes + useEffect(() => { + if (!sketchId || debouncedChangedPositions.size === 0) { + return + } + + const positions = Array.from(debouncedChangedPositions.entries()).map(([nodeId, pos]) => ({ + nodeId, + x: pos.x, + y: pos.y + })) + + setSaveStatus('saving') + + sketchService.updateNodePositions(sketchId, positions) + .then(() => { + setChangedNodePositions(new Map()) + setSaveStatus('saved') + // Reset to idle after 2 seconds + setTimeout(() => setSaveStatus('idle'), 2000) + }) + .catch((error) => { + console.error('[useSaveNodePositions] Failed to save:', error) + setSaveStatus('error') + // Reset to idle after 3 seconds + setTimeout(() => setSaveStatus('idle'), 3000) + }) + }, [debouncedChangedPositions, sketchId, setSaveStatus]) + + return { + saveAllNodePositions, + markAsStabilized, + markAsStabilizing, + clearPendingChanges, + hasPendingChanges: changedNodePositions.size > 0 + } +} diff --git a/flowsint-app/src/lib/utils.ts b/flowsint-app/src/lib/utils.ts index 104a70d3..4f51ae23 100644 --- a/flowsint-app/src/lib/utils.ts +++ b/flowsint-app/src/lib/utils.ts @@ -26,21 +26,43 @@ export const getDagreLayoutedElements = (nodes: GraphNode[], },) => { const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: options.direction }); - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); + + // Configure dagre with proper spacing + g.setGraph({ + rankdir: options.direction, + nodesep: 10, // Horizontal spacing between nodes + ranksep: 20, // Vertical spacing between ranks + marginx: 10, + marginy: 10 + }); + + // Set node dimensions first - use a consistent size for all nodes + const nodeWidth = 50; + const nodeHeight = 50; + nodes.forEach((node) => g.setNode(node.id, { - ...node, - width: node.nodeSize ?? 0, - height: node.nodeSize ?? 0, + width: nodeWidth, + height: nodeHeight, }), ); + + // Then add edges + // Note: react-force-graph transforms edges so source/target become node objects + // We need to extract the IDs for Dagre + edges.forEach((edge: GraphEdge | any) => { + const source = typeof edge.source === 'object' ? edge.source.id : edge.source; + const target = typeof edge.target === 'object' ? edge.target.id : edge.target; + g.setEdge(source, target); + }); + Dagre.layout(g); + return { nodes: nodes.map((node) => { const position = g.node(node.id); - const x = position.x - (node.nodeSize ?? 0) / 2; - const y = position.y - (node.nodeSize ?? 0) / 2; + const x = position.x; + const y = position.y; return { ...node, x, y }; }), edges, diff --git a/flowsint-app/src/stores/graph-controls-store.ts b/flowsint-app/src/stores/graph-controls-store.ts index e6d956d3..0551601a 100644 --- a/flowsint-app/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/stores/graph-controls-store.ts @@ -1,12 +1,10 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -type ViewType = 'hierarchy' | 'force' | 'table' | 'map' | 'relationships' -type LayoutMode = 'none' | 'force' | 'dagre' +type ViewType = 'graph' | 'table' | 'map' | 'relationships' type GraphControlsStore = { view: ViewType - layoutMode: LayoutMode isLassoActive: boolean zoomToFit: () => void zoomIn: () => void @@ -14,16 +12,15 @@ type GraphControlsStore = { onLayout: (layout: any) => void setActions: (actions: Partial) => void refetchGraph: () => void - setView: (view: ViewType) => void - setLayoutMode: (mode: LayoutMode) => void + regenerateLayout: (layoutType: 'force' | 'hierarchy') => void + setView: (view: 'graph' | 'table' | 'map' | 'relationships') => void setIsLassoActive: (active: boolean) => void } export const useGraphControls = create()( persist( (set) => ({ - view: 'force', - layoutMode: 'none', + view: 'graph', isLassoActive: false, zoomToFit: () => { }, zoomIn: () => { }, @@ -31,13 +28,13 @@ export const useGraphControls = create()( onLayout: () => { }, setActions: (actions) => set(actions), refetchGraph: () => { }, + regenerateLayout: () => { }, setView: (view) => set({ view }), - setLayoutMode: (mode) => set({ layoutMode: mode }), setIsLassoActive: (active) => set({ isLassoActive: active }) }), { name: 'graph-controls-storage', - partialize: (state) => ({ view: state.view, layoutMode: state.layoutMode }) + partialize: (state) => ({ view: state.view }) } ) ) diff --git a/flowsint-app/src/stores/graph-save-status-store.ts b/flowsint-app/src/stores/graph-save-status-store.ts new file mode 100644 index 00000000..7373df92 --- /dev/null +++ b/flowsint-app/src/stores/graph-save-status-store.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand' +import { SaveStatus } from '@/hooks/use-save-node-positions' + +interface GraphSaveStatusStore { + saveStatus: SaveStatus + setSaveStatus: (status: SaveStatus) => void +} + +export const useGraphSaveStatus = create((set) => ({ + saveStatus: 'idle', + setSaveStatus: (status) => set({ saveStatus: status }) +})) diff --git a/flowsint-core/src/flowsint_core/core/graph_repository.py b/flowsint-core/src/flowsint_core/core/graph_repository.py index b864fcdc..f167d6a2 100644 --- a/flowsint-core/src/flowsint_core/core/graph_repository.py +++ b/flowsint-core/src/flowsint_core/core/graph_repository.py @@ -547,6 +547,40 @@ class GraphRepository: return self._connection.query(cypher, parameters) + def update_nodes_positions( + self, + positions: List[Dict[str, Any]], + sketch_id: str + ) -> int: + """ + Update positions (x, y) for multiple nodes in batch. + + Args: + positions: List of dicts with keys 'nodeId', 'x', 'y' + sketch_id: Investigation sketch ID (for safety) + + Returns: + Number of nodes updated + """ + if not self._connection or not positions: + return 0 + + query = """ + UNWIND $positions AS pos + MATCH (n) + WHERE elementId(n) = pos.nodeId AND n.sketch_id = $sketch_id + SET n.x = pos.x, n.y = pos.y + RETURN count(n) as updated_count + """ + + params = { + "positions": positions, + "sketch_id": sketch_id + } + + result = self._connection.query(query, params) + return result[0]["updated_count"] if result else 0 + def __enter__(self): """Context manager entry.""" return self