From c21db239ac109521b42adcc32d5b3d2d84415af0 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Fri, 14 Nov 2025 17:42:29 +0100 Subject: [PATCH 01/10] feat: rebase main --- flowsint-app/src/api/sketch-service.ts | 9 + .../src/components/graphs/graph-main.tsx | 3 + .../src/components/graphs/graph-viewer.tsx | 323 +++++++++++------- .../src/components/graphs/toolbar.tsx | 85 +++-- .../src/stores/graph-controls-store.ts | 11 +- 5 files changed, 286 insertions(+), 145 deletions(-) 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..5797c467 100644 --- a/flowsint-app/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/components/graphs/graph-main.tsx @@ -5,8 +5,10 @@ import React, { useRef, useCallback } from 'react' 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) @@ -76,6 +78,7 @@ const GraphMain = () => { 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..63145f5c 100644 --- a/flowsint-app/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/components/graphs/graph-viewer.tsx @@ -10,7 +10,9 @@ 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 { sketchService } from '@/api/sketch-service' +import { useDebounce } from '@/hooks/use-debounce' +import { toast } from 'sonner' function truncateText(text: string, limit: number = 16) { if (text.length <= limit) return text @@ -35,6 +37,7 @@ interface GraphViewerProps { 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 +100,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 +152,8 @@ const GraphViewer: React.FC = ({ onGraphRef, instanceId, allowLasso = false, - minimap = false + minimap = false, + sketchId }) => { const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const isLassoActive = useGraphControls((s) => s.isLassoActive) @@ -163,6 +164,10 @@ const GraphViewer: React.FC = ({ const [currentZoom, setCurrentZoom] = useState(1) const zoomRef = useRef({ k: 1, x: 0, y: 0 }) const hoverFrameRef = useRef(null) + + // Node position tracking for saving + const [changedNodePositions, setChangedNodePositions] = useState>(new Map()) + const [isStabilizing, setIsStabilizing] = useState(true) // The ref for the node weighlist. const labelRenderingCompound = useRef({ weightList: new Map(), @@ -189,6 +194,9 @@ const GraphViewer: React.FC = ({ const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog) const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen) + // Debounced value to trigger save + const debouncedChangedPositions = useDebounce(changedNodePositions, 1000) + const shouldUseSimpleRendering = useMemo( () => nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom < 1.5, [nodes.length, currentZoom] @@ -245,9 +253,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 +292,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 +323,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 +333,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 +393,132 @@ 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) + if (graphNode && layoutedNode.x !== undefined && layoutedNode.y !== undefined) { + graphNode.x = layoutedNode.x + graphNode.y = layoutedNode.y + graphNode.fx = layoutedNode.x + graphNode.fy = layoutedNode.y + } + }) + } 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') + } + + graphRef.current.d3ReheatSimulation() + } + + // 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]) + + // 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 +547,21 @@ const GraphViewer: React.FC = ({ setImportModalOpen(true) }, [setImportModalOpen]) + // Handle node drag end with position tracking + const handleNodeDragEnd = useCallback((node: any) => { + node.fx = node.x + node.fy = node.y + + // Track position changes for saving (only if not stabilizing and sketchId exists) + if (!isStabilizing && sketchId && node.x !== undefined && node.y !== undefined) { + setChangedNodePositions(prev => { + const newMap = new Map(prev) + newMap.set(node.id, { x: node.x!, y: node.y! }) + return newMap + }) + } + }, [isStabilizing, sketchId]) + // Throttled hover handlers using RAF for better performance const handleNodeHover = useCallback((node: any) => { @@ -579,8 +630,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 +994,49 @@ const GraphViewer: React.FC = ({ } }, []) + // Mark graph as stabilized after warmupTicks + useEffect(() => { + const warmupTicks = forceSettings?.warmupTicks?.value ?? 0 + if (warmupTicks > 0) { + setIsStabilizing(true) + // Wait for warmup + a small buffer to ensure graph has stabilized + const timeout = setTimeout(() => { + setIsStabilizing(false) + }, warmupTicks * 10 + 500) // Approximate time for warmup ticks + + return () => clearTimeout(timeout) + } else { + setIsStabilizing(false) + } + }, [forceSettings?.warmupTicks?.value, nodes.length, edges.length]) + + // Save positions when debounced value changes + useEffect(() => { + if (isStabilizing || !sketchId || debouncedChangedPositions.size === 0) { + return + } + + const positions = Array.from(debouncedChangedPositions.entries()).map(([nodeId, pos]) => ({ + nodeId, + x: pos.x, + y: pos.y + })) + + const savePromise = sketchService.updateNodePositions(sketchId, positions) + + toast.promise(savePromise, { + loading: `Saving positions for ${positions.length} node${positions.length > 1 ? 's' : ''}...`, + success: (result) => { + setChangedNodePositions(new Map()) + return `Successfully saved ${result.count || positions.length} node position${positions.length > 1 ? 's' : ''}` + }, + error: (error) => { + console.error('[Graph] Failed to save node positions:', error) + return 'Failed to save node positions. Please try again.' + } + }) + }, [debouncedChangedPositions, isStabilizing, sketchId]) + // Empty state if (!nodes.length) { return ( @@ -1051,11 +1145,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/toolbar.tsx b/flowsint-app/src/components/graphs/toolbar.tsx index d545c431..03529a92 100644 --- a/flowsint-app/src/components/graphs/toolbar.tsx +++ b/flowsint-app/src/components/graphs/toolbar.tsx @@ -21,6 +21,7 @@ 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' // Tooltip wrapper component to avoid repetition export const ToolbarButton = memo(function ToolbarButton({ @@ -67,12 +68,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) @@ -87,12 +89,57 @@ 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 handleRegenerateForceLayout = useCallback(async () => { + console.log('[Toolbar] handleRegenerateForceLayout called') + console.log('[Toolbar] regenerateLayout function:', regenerateLayout) + console.log('[Toolbar] regenerateLayout is function?', typeof regenerateLayout === 'function') + + const confirmed = await confirm({ + title: 'Regenerate force layout?', + message: 'This will reset all node positions and regenerate them using the force-directed layout algorithm. Current positions will be lost.' + }) + + if (!confirmed) { + console.log('[Toolbar] User cancelled regeneration') + return + } + + console.log('[Toolbar] Calling regenerateLayout with force') + try { + regenerateLayout('force') + console.log('[Toolbar] regenerateLayout completed successfully') + toast.success('Force layout regenerated successfully') + } catch (error) { + console.error('[Toolbar] Failed to regenerate layout:', error) + toast.error(`Failed to regenerate layout: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, [confirm, regenerateLayout]) + + const handleRegenerateHierarchyLayout = useCallback(async () => { + console.log('[Toolbar] handleRegenerateHierarchyLayout called') + + const confirmed = await confirm({ + title: 'Regenerate 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 regeneration') + return + } + + console.log('[Toolbar] Calling regenerateLayout with hierarchy') + try { + regenerateLayout('hierarchy') + console.log('[Toolbar] regenerateLayout completed successfully') + toast.success('Hierarchy layout regenerated successfully') + } catch (error) { + console.error('[Toolbar] Failed to regenerate layout:', error) + toast.error(`Failed to regenerate layout: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, [confirm, regenerateLayout]) const handleTableLayout = useCallback(() => { setView('table') @@ -106,12 +153,6 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean setView('relationships') }, [setView]) - const handleDagreLayoutTB = useCallback(() => { - setView('hierarchy') - onLayout && onLayout('dagre-tb') - setTimeout(() => zoomToFit(), 200) - }, [onLayout, setView, zoomToFit]) - const handleOpenAddRelationDialog = useCallback(() => { setOpenAddRelationDialog(true) }, [setOpenAddRelationDialog]) @@ -154,26 +195,26 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean icon={} tooltip="Zoom In" onClick={zoomIn} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'force' || isLassoActive} /> } tooltip="Zoom Out" onClick={zoomOut} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'force' || isLassoActive} /> } tooltip="Fit to View" onClick={zoomToFit} - disabled={!['force', 'hierarchy'].includes(view) || isLassoActive} + disabled={view !== 'force' || isLassoActive} /> } tooltip={'Lasso select'} onClick={handleLassoSelect} toggled={isLassoActive} - disabled={!['force', 'hierarchy'].includes(view)} + disabled={view !== 'force'} /> } - tooltip={`Hierarchy`} - toggled={['hierarchy'].includes(view)} - onClick={handleDagreLayoutTB} + tooltip={'Regenerate hierarchy layout'} + onClick={handleRegenerateHierarchyLayout} + disabled={isLoading} /> } - tooltip={'Graph view'} - toggled={['force'].includes(view)} - onClick={handleForceLayout} + tooltip={'Regenerate force layout'} + onClick={handleRegenerateForceLayout} + disabled={isLoading} /> } diff --git a/flowsint-app/src/stores/graph-controls-store.ts b/flowsint-app/src/stores/graph-controls-store.ts index e6d956d3..407f1787 100644 --- a/flowsint-app/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/stores/graph-controls-store.ts @@ -2,11 +2,9 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' type ViewType = 'hierarchy' | 'force' | 'table' | 'map' | 'relationships' -type LayoutMode = 'none' | 'force' | 'dagre' type GraphControlsStore = { view: ViewType - layoutMode: LayoutMode isLassoActive: boolean zoomToFit: () => void zoomIn: () => void @@ -14,8 +12,8 @@ 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: 'force' | 'hierarchy' | 'table' | 'map' | 'relationships') => void setIsLassoActive: (active: boolean) => void } @@ -23,7 +21,6 @@ export const useGraphControls = create()( persist( (set) => ({ view: 'force', - layoutMode: 'none', 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 }) } ) ) From 0427c7919cba314abaa80a7c73b82a92c7928ef3 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Thu, 13 Nov 2025 23:12:22 +0100 Subject: [PATCH 02/10] feat(api): nodes positions --- flowsint-api/app/api/routes/sketches.py | 57 +++++++++++++++++++ .../flowsint_core/core/graph_repository.py | 34 +++++++++++ 2 files changed, 91 insertions(+) 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-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 From 877f52bca17197fe26904322745c38713b002bca Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Fri, 14 Nov 2025 22:49:13 +0100 Subject: [PATCH 03/10] feat(app): save nodes positions --- .../src/components/graphs/graph-main.tsx | 2 - .../src/components/graphs/graph-viewer.tsx | 85 +++----- flowsint-app/src/components/graphs/index.tsx | 2 +- .../graphs/save-status-indicator.tsx | 72 +++++++ .../src/components/graphs/toolbar.tsx | 200 +++++++++++------- .../src/hooks/use-save-node-positions.ts | 118 +++++++++++ flowsint-app/src/lib/utils.ts | 40 +++- .../src/stores/graph-controls-store.ts | 6 +- .../src/stores/graph-save-status-store.ts | 12 ++ 9 files changed, 398 insertions(+), 139 deletions(-) create mode 100644 flowsint-app/src/components/graphs/save-status-indicator.tsx create mode 100644 flowsint-app/src/hooks/use-save-node-positions.ts create mode 100644 flowsint-app/src/stores/graph-save-status-store.ts diff --git a/flowsint-app/src/components/graphs/graph-main.tsx b/flowsint-app/src/components/graphs/graph-main.tsx index 5797c467..893bc663 100644 --- a/flowsint-app/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/components/graphs/graph-main.tsx @@ -3,7 +3,6 @@ import React, { useRef, useCallback } from 'react' // 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' @@ -13,7 +12,6 @@ const GraphMain = () => { 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) diff --git a/flowsint-app/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/components/graphs/graph-viewer.tsx index 63145f5c..1f65c33f 100644 --- a/flowsint-app/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/components/graphs/graph-viewer.tsx @@ -10,9 +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 { sketchService } from '@/api/sketch-service' -import { useDebounce } from '@/hooks/use-debounce' -import { toast } from 'sonner' +import { useSaveNodePositions } from '@/hooks/use-save-node-positions' function truncateText(text: string, limit: number = 16) { if (text.length <= limit) return text @@ -165,9 +163,9 @@ const GraphViewer: React.FC = ({ const zoomRef = useRef({ k: 1, x: 0, y: 0 }) const hoverFrameRef = useRef(null) - // Node position tracking for saving - const [changedNodePositions, setChangedNodePositions] = useState>(new Map()) - const [isStabilizing, setIsStabilizing] = useState(true) + // 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(), @@ -194,9 +192,6 @@ const GraphViewer: React.FC = ({ const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog) const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen) - // Debounced value to trigger save - const debouncedChangedPositions = useDebounce(changedNodePositions, 1000) - const shouldUseSimpleRendering = useMemo( () => nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom < 1.5, [nodes.length, currentZoom] @@ -428,7 +423,7 @@ const GraphViewer: React.FC = ({ // Apply the calculated positions to the graph nodes layoutedNodes.forEach((layoutedNode: any) => { - const graphNode = graphData.nodes.find((n: any) => n.id === layoutedNode.id) + 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 @@ -436,6 +431,9 @@ const GraphViewer: React.FC = ({ 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') { @@ -443,7 +441,19 @@ const GraphViewer: React.FC = ({ 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 @@ -452,7 +462,7 @@ const GraphViewer: React.FC = ({ graphRef.current.zoomToFit(400) } }, 500) - }, [graphData]) + }, [graphData, sketchId, forceSettings, saveAllNodePositions, markAsStabilized, markAsStabilizing]) // Optimized graph initialization callback const initializeGraph = useCallback( @@ -547,20 +557,17 @@ const GraphViewer: React.FC = ({ setImportModalOpen(true) }, [setImportModalOpen]) - // Handle node drag end with position tracking + // Handle node drag end - save ALL node positions const handleNodeDragEnd = useCallback((node: any) => { node.fx = node.x node.fy = node.y - // Track position changes for saving (only if not stabilizing and sketchId exists) - if (!isStabilizing && sketchId && node.x !== undefined && node.y !== undefined) { - setChangedNodePositions(prev => { - const newMap = new Map(prev) - newMap.set(node.id, { x: node.x!, y: node.y! }) - return newMap - }) + // 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) } - }, [isStabilizing, sketchId]) + }, [graphData, saveAllNodePositions]) // Throttled hover handlers using RAF for better performance @@ -994,48 +1001,22 @@ const GraphViewer: React.FC = ({ } }, []) - // Mark graph as stabilized after warmupTicks + // Mark graph as stabilizing on initial load useEffect(() => { const warmupTicks = forceSettings?.warmupTicks?.value ?? 0 if (warmupTicks > 0) { - setIsStabilizing(true) + markAsStabilizing() // Wait for warmup + a small buffer to ensure graph has stabilized const timeout = setTimeout(() => { - setIsStabilizing(false) + markAsStabilized() }, warmupTicks * 10 + 500) // Approximate time for warmup ticks return () => clearTimeout(timeout) } else { - setIsStabilizing(false) + // No warmup, mark as stabilized immediately + markAsStabilized() } - }, [forceSettings?.warmupTicks?.value, nodes.length, edges.length]) - - // Save positions when debounced value changes - useEffect(() => { - if (isStabilizing || !sketchId || debouncedChangedPositions.size === 0) { - return - } - - const positions = Array.from(debouncedChangedPositions.entries()).map(([nodeId, pos]) => ({ - nodeId, - x: pos.x, - y: pos.y - })) - - const savePromise = sketchService.updateNodePositions(sketchId, positions) - - toast.promise(savePromise, { - loading: `Saving positions for ${positions.length} node${positions.length > 1 ? 's' : ''}...`, - success: (result) => { - setChangedNodePositions(new Map()) - return `Successfully saved ${result.count || positions.length} node position${positions.length > 1 ? 's' : ''}` - }, - error: (error) => { - console.error('[Graph] Failed to save node positions:', error) - return 'Failed to save node positions. Please try again.' - } - }) - }, [debouncedChangedPositions, isStabilizing, sketchId]) + }, [forceSettings?.warmupTicks?.value, nodes.length, edges.length, markAsStabilized, markAsStabilizing]) // Empty state if (!nodes.length) { 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..91609d60 --- /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 03529a92..ea754692 100644 --- a/flowsint-app/src/components/graphs/toolbar.tsx +++ b/flowsint-app/src/components/graphs/toolbar.tsx @@ -14,7 +14,8 @@ import { FunnelPlus, GitPullRequestArrow, LassoSelect, - Merge + Merge, + Network } from 'lucide-react' import { memo, useCallback } from 'react' import { toast } from 'sonner' @@ -22,6 +23,8 @@ 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({ @@ -82,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 { @@ -91,68 +95,54 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean } }, [refetchGraph]) - const handleRegenerateForceLayout = useCallback(async () => { - console.log('[Toolbar] handleRegenerateForceLayout called') - console.log('[Toolbar] regenerateLayout function:', regenerateLayout) - console.log('[Toolbar] regenerateLayout is function?', typeof regenerateLayout === 'function') + const handleApplyForceLayout = useCallback(async () => { + console.log('[Toolbar] handleApplyForceLayout called') const confirmed = await confirm({ - title: 'Regenerate force layout?', + 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.' }) if (!confirmed) { - console.log('[Toolbar] User cancelled regeneration') + console.log('[Toolbar] User cancelled layout change') return } console.log('[Toolbar] Calling regenerateLayout with force') try { regenerateLayout('force') - console.log('[Toolbar] regenerateLayout completed successfully') - toast.success('Force layout regenerated successfully') + console.log('[Toolbar] Force layout applied successfully') + toast.success('Force layout applied successfully') } catch (error) { - console.error('[Toolbar] Failed to regenerate layout:', error) - toast.error(`Failed to regenerate layout: ${error instanceof Error ? error.message : 'Unknown 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 handleRegenerateHierarchyLayout = useCallback(async () => { - console.log('[Toolbar] handleRegenerateHierarchyLayout called') + const handleApplyHierarchyLayout = useCallback(async () => { + console.log('[Toolbar] handleApplyHierarchyLayout called') const confirmed = await confirm({ - title: 'Regenerate hierarchy layout?', + 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 regeneration') + console.log('[Toolbar] User cancelled layout change') return } console.log('[Toolbar] Calling regenerateLayout with hierarchy') try { regenerateLayout('hierarchy') - console.log('[Toolbar] regenerateLayout completed successfully') - toast.success('Hierarchy layout regenerated successfully') + console.log('[Toolbar] Hierarchy layout applied successfully') + toast.success('Hierarchy layout applied successfully') } catch (error) { - console.error('[Toolbar] Failed to regenerate layout:', error) - toast.error(`Failed to regenerate layout: ${error instanceof Error ? error.message : 'Unknown 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 handleTableLayout = useCallback(() => { - setView('table') - }, [setView]) - - const handleMapLayout = useCallback(() => { - setView('map') - }, [setView]) - - const handleRelationshipsLayout = useCallback(() => { - setView('relationships') - }, [setView]) - const handleOpenAddRelationDialog = useCallback(() => { setOpenAddRelationDialog(true) }, [setOpenAddRelationDialog]) @@ -174,8 +164,8 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean ) return ( -
-
+ <> +
} @@ -195,26 +185,26 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean icon={} tooltip="Zoom In" onClick={zoomIn} - disabled={view !== 'force' || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip="Zoom Out" onClick={zoomOut} - disabled={view !== 'force' || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip="Fit to View" onClick={zoomToFit} - disabled={view !== 'force' || isLassoActive} + disabled={view !== 'graph' || isLassoActive} /> } tooltip={'Lasso select'} onClick={handleLassoSelect} toggled={isLassoActive} - disabled={view !== 'force'} + 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={'Regenerate hierarchy layout'} - onClick={handleRegenerateHierarchyLayout} - disabled={isLoading} - /> - } - tooltip={'Regenerate force layout'} - onClick={handleRegenerateForceLayout} - disabled={isLoading} - /> - } - 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/hooks/use-save-node-positions.ts b/flowsint-app/src/hooks/use-save-node-positions.ts new file mode 100644 index 00000000..ab45d202 --- /dev/null +++ b/flowsint-app/src/hooks/use-save-node-positions.ts @@ -0,0 +1,118 @@ +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' + +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 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, 2000) + + // 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) + 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 29d73ad2..d6caff12 100644 --- a/flowsint-app/src/lib/utils.ts +++ b/flowsint-app/src/lib/utils.ts @@ -26,21 +26,41 @@ 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: 80, // Horizontal spacing between nodes + ranksep: 120, // Vertical spacing between ranks + marginx: 20, + marginy: 20 + }); + + // 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 + edges.forEach((edge: GraphEdge) => { + const source = edge.source; + const target = 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, @@ -183,9 +203,9 @@ export const getAllNodeTypes = (actionItems: any[]) => { const types: string[] = [] actionItems.forEach(item => { if (item.children) { - item.children.forEach(child => { - if (child.type && !types.includes(child.type)) { - types.push(child.type) + item.children.forEach((child: GraphNode) => { + if (child.data.type && !types.includes(child.data.type)) { + types.push(child.data.type) } }) } else if (item.type && !types.includes(item.type)) { diff --git a/flowsint-app/src/stores/graph-controls-store.ts b/flowsint-app/src/stores/graph-controls-store.ts index 407f1787..0551601a 100644 --- a/flowsint-app/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/stores/graph-controls-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -type ViewType = 'hierarchy' | 'force' | 'table' | 'map' | 'relationships' +type ViewType = 'graph' | 'table' | 'map' | 'relationships' type GraphControlsStore = { view: ViewType @@ -13,14 +13,14 @@ type GraphControlsStore = { setActions: (actions: Partial) => void refetchGraph: () => void regenerateLayout: (layoutType: 'force' | 'hierarchy') => void - setView: (view: 'force' | 'hierarchy' | 'table' | 'map' | 'relationships') => void + setView: (view: 'graph' | 'table' | 'map' | 'relationships') => void setIsLassoActive: (active: boolean) => void } export const useGraphControls = create()( persist( (set) => ({ - view: 'force', + view: 'graph', isLassoActive: false, zoomToFit: () => { }, zoomIn: () => { }, 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 }) +})) From 27f54673e948a6d0c1d16a47764e4c441c633fde Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Fri, 14 Nov 2025 22:50:20 +0100 Subject: [PATCH 04/10] fix(app): remove react-scan --- flowsint-app/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flowsint-app/index.html b/flowsint-app/index.html index 153b424e..c41e6577 100644 --- a/flowsint-app/index.html +++ b/flowsint-app/index.html @@ -1,11 +1,11 @@ - + --> From eead6a101820d57dfdf6cc5542da267db93e5282 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Sat, 15 Nov 2025 02:03:00 +0100 Subject: [PATCH 05/10] feat: update docker kill in Makefile --- Makefile | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index dbab9d9d..68e8fcaf 100644 --- a/Makefile +++ b/Makefile @@ -101,10 +101,18 @@ stop-prod: docker compose -f docker-compose.prod.yml down clean: - @echo "๐Ÿงน Removing containers, volumes and venvs..." - -docker compose -f docker-compose.dev.yml down -v --remove-orphans - -docker compose -f docker-compose.prod.yml down -v --remove-orphans - -docker compose down -v --remove-orphans + @echo "โš ๏ธ WARNING: This will remove ALL Docker containers, images, volumes, and virtual environments." + @echo "โš ๏ธ ALL DATA in databases and volumes will be permanently deleted!" + @echo "" + @read -p "Are you sure you want to continue? [y/N]: " confirm; \ + if [ "$$confirm" != "y" ] && [ "$$confirm" != "Y" ]; then \ + echo "โŒ Cleanup cancelled."; \ + exit 1; \ + fi + @echo "๐Ÿงน Removing containers, images, volumes and venvs..." + -docker compose -f docker-compose.dev.yml down -v --rmi all --remove-orphans + -docker compose -f docker-compose.prod.yml down -v --rmi all --remove-orphans + -docker compose down -v --rmi all --remove-orphans rm -rf $(PROJECT_ROOT)/flowsint-app/node_modules rm -rf $(PROJECT_ROOT)/flowsint-core/.venv rm -rf $(PROJECT_ROOT)/flowsint-transforms/.venv From 4e9c17d98e0a3c33591f16b3fd60ef368447830a Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Nov 2025 00:21:01 +0100 Subject: [PATCH 06/10] feat(app): fix node positions --- flowsint-app/src/components/graphs/graph-main.tsx | 1 - flowsint-app/src/components/graphs/graph-viewer.tsx | 3 --- flowsint-app/src/hooks/use-save-node-positions.ts | 3 +++ 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flowsint-app/src/components/graphs/graph-main.tsx b/flowsint-app/src/components/graphs/graph-main.tsx index 893bc663..62b320de 100644 --- a/flowsint-app/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/components/graphs/graph-main.tsx @@ -75,7 +75,6 @@ const GraphMain = () => { showIcons={true} onGraphRef={handleGraphRef} allowLasso - minimap={false} sketchId={sketchId} /> diff --git a/flowsint-app/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/components/graphs/graph-viewer.tsx index 1f65c33f..c27a7b15 100644 --- a/flowsint-app/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/components/graphs/graph-viewer.tsx @@ -34,7 +34,6 @@ 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 } @@ -150,7 +149,6 @@ const GraphViewer: React.FC = ({ onGraphRef, instanceId, allowLasso = false, - minimap = false, sketchId }) => { const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) @@ -181,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) diff --git a/flowsint-app/src/hooks/use-save-node-positions.ts b/flowsint-app/src/hooks/use-save-node-positions.ts index ab45d202..ee528802 100644 --- a/flowsint-app/src/hooks/use-save-node-positions.ts +++ b/flowsint-app/src/hooks/use-save-node-positions.ts @@ -2,6 +2,7 @@ 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 @@ -16,6 +17,7 @@ export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' */ 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) @@ -68,6 +70,7 @@ export function useSaveNodePositions(sketchId?: string) { if (newMap.size > 0) { setChangedNodePositions(newMap) + setNodes(nodes) setSaveStatus('pending') } }, [sketchId]) From fa6725c19068f7ad58a57a85b8ea869ee4ffddaa Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Nov 2025 00:21:33 +0100 Subject: [PATCH 07/10] feat(app): decrease distance between nodes on dagre layout --- flowsint-app/src/lib/utils.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/flowsint-app/src/lib/utils.ts b/flowsint-app/src/lib/utils.ts index d6caff12..4f51ae23 100644 --- a/flowsint-app/src/lib/utils.ts +++ b/flowsint-app/src/lib/utils.ts @@ -30,10 +30,10 @@ export const getDagreLayoutedElements = (nodes: GraphNode[], // Configure dagre with proper spacing g.setGraph({ rankdir: options.direction, - nodesep: 80, // Horizontal spacing between nodes - ranksep: 120, // Vertical spacing between ranks - marginx: 20, - marginy: 20 + 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 @@ -48,9 +48,11 @@ export const getDagreLayoutedElements = (nodes: GraphNode[], ); // Then add edges - edges.forEach((edge: GraphEdge) => { - const source = edge.source; - const target = edge.target; + // 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); }); From 7f2dc36e4409ddaacf99881b7cabf3ce20eab582 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Nov 2025 12:27:28 +0100 Subject: [PATCH 08/10] feat(app): remove md files and update styles --- .../src/components/graphs/graph-main.tsx | 5 +- .../components/graphs/webgl-graph-viewer.tsx | 37 -- .../src/components/graphs/webgl/CHANGELOG.md | 395 ----------------- .../components/graphs/webgl/DECLUTTERING.md | 418 ------------------ .../components/graphs/webgl/LABEL_STYLES.md | 194 -------- .../src/components/graphs/webgl/README.md | 358 --------------- 6 files changed, 2 insertions(+), 1405 deletions(-) delete mode 100644 flowsint-app/src/components/graphs/webgl-graph-viewer.tsx delete mode 100644 flowsint-app/src/components/graphs/webgl/CHANGELOG.md delete mode 100644 flowsint-app/src/components/graphs/webgl/DECLUTTERING.md delete mode 100644 flowsint-app/src/components/graphs/webgl/LABEL_STYLES.md delete mode 100644 flowsint-app/src/components/graphs/webgl/README.md diff --git a/flowsint-app/src/components/graphs/graph-main.tsx b/flowsint-app/src/components/graphs/graph-main.tsx index 62b320de..4e5e96ba 100644 --- a/flowsint-app/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/components/graphs/graph-main.tsx @@ -1,9 +1,8 @@ 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 GraphViewer from './graph-viewer' import { useParams } from '@tanstack/react-router' const GraphMain = () => { @@ -58,12 +57,12 @@ const GraphMain = () => { return (
{/* */} 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. From 0171ba7ba419ce4ac166c379d9027d67f840f1eb Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Nov 2025 12:27:43 +0100 Subject: [PATCH 09/10] feat(app): update status indicator styles --- .../src/components/graphs/save-status-indicator.tsx | 4 ++-- flowsint-app/src/components/graphs/toolbar.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flowsint-app/src/components/graphs/save-status-indicator.tsx b/flowsint-app/src/components/graphs/save-status-indicator.tsx index 91609d60..878ac294 100644 --- a/flowsint-app/src/components/graphs/save-status-indicator.tsx +++ b/flowsint-app/src/components/graphs/save-status-indicator.tsx @@ -57,14 +57,14 @@ export const SaveStatusIndicator = memo(({ status }: SaveStatusIndicatorProps) =
- {config.text} + {/* {config.text} */}
) }) diff --git a/flowsint-app/src/components/graphs/toolbar.tsx b/flowsint-app/src/components/graphs/toolbar.tsx index ea754692..41ef5047 100644 --- a/flowsint-app/src/components/graphs/toolbar.tsx +++ b/flowsint-app/src/components/graphs/toolbar.tsx @@ -165,7 +165,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean return ( <> -
+
} @@ -224,7 +224,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
{/* Center: View Toggle Group */} -
+
@@ -236,7 +236,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean className={cn( 'h-7 px-2 rounded-sm', view === 'graph' - ? 'bg-background text-foreground shadow-sm' + ? 'bg-background text-foreground border' : 'text-muted-foreground hover:text-foreground hover:bg-background/50' )} > @@ -254,7 +254,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean className={cn( 'h-7 px-2 rounded-sm', view === 'table' - ? 'bg-background text-foreground shadow-sm' + ? 'bg-background text-foreground border' : 'text-muted-foreground hover:text-foreground hover:bg-background/50' )} > @@ -272,7 +272,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean className={cn( 'h-7 px-2 rounded-sm', view === 'relationships' - ? 'bg-background text-foreground shadow-sm' + ? 'bg-background text-foreground border' : 'text-muted-foreground hover:text-foreground hover:bg-background/50' )} > @@ -290,7 +290,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean className={cn( 'h-7 px-2 rounded-sm', view === 'map' - ? 'bg-background text-foreground shadow-sm' + ? 'bg-background text-foreground border' : 'text-muted-foreground hover:text-foreground hover:bg-background/50' )} > From 9787d92d50d83dfa91d94f8bf63303ef2ef659a7 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Nov 2025 12:34:31 +0100 Subject: [PATCH 10/10] feat(app): decrease save position debounce --- flowsint-app/src/hooks/use-save-node-positions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowsint-app/src/hooks/use-save-node-positions.ts b/flowsint-app/src/hooks/use-save-node-positions.ts index ee528802..2dd55f40 100644 --- a/flowsint-app/src/hooks/use-save-node-positions.ts +++ b/flowsint-app/src/hooks/use-save-node-positions.ts @@ -22,7 +22,7 @@ export function useSaveNodePositions(sketchId?: string) { const setSaveStatus = useGraphSaveStatus((state) => state.setSaveStatus) // Debounced value to trigger save (2 seconds) - const debouncedChangedPositions = useDebounce(changedNodePositions, 2000) + const debouncedChangedPositions = useDebounce(changedNodePositions, 1200) // Initialize status on mount useEffect(() => {