Merge branch 'feat/save-nodes-position'

This commit is contained in:
dextmorgn
2025-11-17 12:39:43 +01:00
17 changed files with 693 additions and 1604 deletions
+57
View File
@@ -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(
+9
View File
@@ -95,5 +95,14 @@ export const sketchService = {
method: 'POST',
body: formData
})
},
updateNodePositions: async (
sketchId: string,
positions: Array<{ nodeId: string; x: number; y: number }>
): Promise<any> => {
return fetchWithAuth(`/api/sketches/${sketchId}/nodes/positions`, {
method: 'PUT',
body: JSON.stringify({ positions })
})
}
}
@@ -1,17 +1,16 @@
import { useGraphStore } from '@/stores/graph-store'
import React, { useRef, useCallback } from 'react'
// import GraphViewer from './graph-viewer'
import GraphViewer from './graph-viewer'
// import WebGLGraphViewer from './webgl'
import ContextMenu from './context-menu'
import { useGraphControls } from '@/stores/graph-controls-store'
import GraphViewer from './graph-viewer'
import { useParams } from '@tanstack/react-router'
const GraphMain = () => {
const { id: sketchId } = useParams({ strict: false })
const filteredNodes = useGraphStore((s) => s.filteredNodes)
const filteredEdges = useGraphStore((s) => s.filteredEdges)
const toggleNodeSelection = useGraphStore((s) => s.toggleNodeSelection)
const clearSelectedNodes = useGraphStore((s) => s.clearSelectedNodes)
const layoutMode = useGraphControls((s) => s.layoutMode)
const graphRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -58,12 +57,12 @@ const GraphMain = () => {
return (
<div ref={containerRef} className="relative h-full w-full bg-background">
{/* <WebGLGraphViewer
sketchId={sketchId as string}
nodes={filteredNodes}
edges={filteredEdges}
onNodeClick={handleNodeClick}
onNodeRightClick={onNodeContextMenu}
onBackgroundClick={handleBackgroundClick}
layoutMode={layoutMode}
/> */}
<GraphViewer
nodes={filteredNodes}
@@ -75,7 +74,7 @@ const GraphMain = () => {
showIcons={true}
onGraphRef={handleGraphRef}
allowLasso
minimap={false}
sketchId={sketchId}
/>
{menu && <ContextMenu onClick={handleBackgroundClick} {...menu} />}
@@ -10,7 +10,7 @@ import { useTheme } from '@/components/theme-provider'
import { Info, Plus, Share2, Type, Upload } from 'lucide-react'
import Lasso from './lasso'
import { GraphNode, GraphEdge } from '@/types'
import MiniMap from './minimap'
import { useSaveNodePositions } from '@/hooks/use-save-node-positions'
function truncateText(text: string, limit: number = 16) {
if (text.length <= limit) return text
@@ -34,7 +34,7 @@ interface GraphViewerProps {
onGraphRef?: (ref: any) => void
instanceId?: string // Add instanceId prop for instance-specific actions
allowLasso?: boolean
minimap?: boolean
sketchId?: string // Add sketchId for saving node positions
}
// Graph viewer specific colors
@@ -97,9 +97,6 @@ interface LabelRenderingCompound {
}
}
// Pre-computed constants
const LABEL_FONT_STRING = `${CONSTANTS.LABEL_FONT_SIZE}px Sans-Serif`
// Reusable objects to avoid allocations
const tempPos = { x: 0, y: 0 }
const tempDimensions = [0, 0]
@@ -152,7 +149,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
onGraphRef,
instanceId,
allowLasso = false,
minimap = false
sketchId
}) => {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const isLassoActive = useGraphControls((s) => s.isLassoActive)
@@ -163,6 +160,10 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
const [currentZoom, setCurrentZoom] = useState<number>(1)
const zoomRef = useRef({ k: 1, x: 0, y: 0 })
const hoverFrameRef = useRef<number | null>(null)
// Use the dedicated hook for saving node positions
const { saveAllNodePositions, markAsStabilized, markAsStabilizing } = useSaveNodePositions(sketchId)
// The ref for the node weighlist.
const labelRenderingCompound = useRef<LabelRenderingCompound>({
weightList: new Map(),
@@ -178,7 +179,6 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
// Store selectors
const nodeColors = useNodesDisplaySettings((s) => s.colors)
const getSize = useNodesDisplaySettings((s) => s.getSize)
const view = useGraphControls((s) => s.view)
// Get settings by categories to avoid re-renders
const forceSettings = useGraphSettingsStore((s) => s.forceSettings)
@@ -245,9 +245,10 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
const graph2ScreenCoords = useCallback(
(node: GraphNode) => {
if (!graphRef.current) return { x: 0, y: 0 }
return graphRef.current.graph2ScreenCoords(node.x, node.y)
},
[graphRef.current]
[]
)
const isCurrent = useCallback(
@@ -283,79 +284,6 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
}
}, [currentZoom])
// Optimized graph initialization callback
const initializeGraph = useCallback(
(graphInstance: any) => {
if (!graphInstance) return
// Check if the graph instance has the required methods
if (
typeof graphInstance.zoom !== 'function' ||
typeof graphInstance.zoomToFit !== 'function'
) {
// If methods aren't available yet, retry after a short delay
setTimeout(() => {
if (graphRef.current && !isGraphReadyRef.current) {
initializeGraph(graphRef.current)
}
}, 100)
return
}
if (isGraphReadyRef.current) return
isGraphReadyRef.current = true
// Only set global actions if no instanceId is provided (for main graph)
if (!instanceId) {
setActions({
zoomIn: () => {
if (graphRef.current && typeof graphRef.current.zoom === 'function') {
const zoom = graphRef.current.zoom()
graphRef.current.zoom(zoom * 1.5)
}
},
zoomOut: () => {
if (graphRef.current && typeof graphRef.current.zoom === 'function') {
const zoom = graphRef.current.zoom()
graphRef.current.zoom(zoom * 0.75)
}
},
zoomToFit: () => {
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
}
})
}
// Call external ref callback
onGraphRef?.(graphInstance)
},
[setActions, onGraphRef, instanceId]
)
// Handle graph ref changes and ensure actions are set up
useEffect(() => {
if (graphRef.current) {
initializeGraph(graphRef.current)
}
// Cleanup: reset actions when component unmounts (only for main graph)
return () => {
if (!instanceId && isGraphReadyRef.current) {
setActions({
zoomIn: () => { },
zoomOut: () => { },
zoomToFit: () => { }
})
isGraphReadyRef.current = false
}
}
}, [initializeGraph])
// Additional effect to ensure actions are set up when graph is ready
useEffect(() => {
if (graphRef.current && !instanceId && !isGraphReadyRef.current) {
// Try to initialize again if the graph is available but not marked as ready
initializeGraph(graphRef.current)
}
}, [graphRef.current, initializeGraph, instanceId])
// Handle container size changes
useEffect(() => {
const updateSize = () => {
@@ -387,7 +315,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
// Transform nodes
const transformedNodes = nodes.map((node) => {
const type = node.data?.type as ItemType
return {
const transformed = {
...node,
nodeLabel: node.data?.label || node.id,
nodeColor: nodeColors[type] || GRAPH_COLORS.NODE_DEFAULT,
@@ -397,6 +325,16 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
neighbors: [] as any[],
links: [] as any[]
} as GraphNode & { neighbors: any[]; links: any[] }
// If node has saved positions, fix them so ForceGraph doesn't recalculate
if (node.x !== undefined && node.y !== undefined) {
// @ts-ignore
transformed.fx = node.x
// @ts-ignore
transformed.fy = node.y
}
return transformed
})
// Create a map for quick node lookup
const nodeMap = new Map(transformedNodes.map((node) => [node.id, node]))
@@ -447,42 +385,147 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
.map((node) => [node.id, node.neighbors.length])
)
labelRenderingCompound.current.constants = { nodesLength: transformedNodes.length }
// Handle hierarchy layout
if (view === 'hierarchy') {
const { nodes: nds, edges: eds } = getDagreLayoutedElements(
transformedNodes,
transformedEdges
)
return {
nodes: nds.map((nd) => ({
...nd,
// Preserve the neighbors and links from the original transformed node
neighbors: nodeMap.get(nd.id)?.neighbors || [],
links: nodeMap.get(nd.id)?.links || []
})),
links: eds
}
}
// Always use force layout - positions are preserved if they exist (fx/fy set above)
return {
nodes: transformedNodes,
links: transformedEdges
}
}, [nodes, edges, nodeColors, getSize, view])
}, [nodes, edges, nodeColors, getSize])
// Retry initialization if graph data changes and actions aren't set up
useEffect(() => {
if (graphData.nodes.length > 0 && graphRef.current && !instanceId && !isGraphReadyRef.current) {
// Small delay to ensure the graph has rendered
const timeoutId = setTimeout(() => {
if (graphRef.current && !isGraphReadyRef.current) {
initializeGraph(graphRef.current)
}
}, 200)
return () => clearTimeout(timeoutId)
// Regenerate layout by removing fixed positions and reheating simulation
const regenerateLayout = useCallback((layoutType: 'force' | 'hierarchy') => {
if (!graphRef.current) {
console.error('[Graph] graphRef.current is null')
throw new Error('Graph reference is not available')
}
return undefined
}, [graphData.nodes.length, initializeGraph, instanceId])
if (!graphData || !graphData.nodes) {
console.error('[Graph] No nodes in graph data')
throw new Error('No nodes available in graph')
}
// Remove fx and fy from all nodes to allow repositioning
graphData.nodes.forEach((node: any) => {
delete node.fx
delete node.fy
})
if (layoutType === 'hierarchy') {
// Apply dagre layout for hierarchical positioning
const { nodes: layoutedNodes } = getDagreLayoutedElements(
graphData.nodes,
graphData.links
)
// Apply the calculated positions to the graph nodes
layoutedNodes.forEach((layoutedNode: any) => {
const graphNode = graphData.nodes.find((n: any) => n.id === layoutedNode.id) as any
if (graphNode && layoutedNode.x !== undefined && layoutedNode.y !== undefined) {
graphNode.x = layoutedNode.x
graphNode.y = layoutedNode.y
graphNode.fx = layoutedNode.x
graphNode.fy = layoutedNode.y
}
})
// Save all node positions immediately after hierarchy layout (force save)
saveAllNodePositions(graphData.nodes, true)
} else {
// Force layout: reheat the simulation to recalculate positions
if (typeof graphRef.current.d3ReheatSimulation !== 'function') {
console.error('[Graph] d3ReheatSimulation method not available')
throw new Error('d3ReheatSimulation method is not available')
}
// Mark as stabilizing to prevent saving during force simulation
markAsStabilizing()
graphRef.current.d3ReheatSimulation()
// For force layout, save positions after simulation stabilizes
const warmupTicks = forceSettings?.warmupTicks?.value ?? 0
const stabilizationTime = warmupTicks * 10 + 1500 // Wait for simulation to stabilize
setTimeout(() => {
markAsStabilized()
// Force save all positions after stabilization
saveAllNodePositions(graphData.nodes, true)
}, stabilizationTime)
}
// Zoom to fit after a delay to see the new layout
setTimeout(() => {
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
}, 500)
}, [graphData, sketchId, forceSettings, saveAllNodePositions, markAsStabilized, markAsStabilizing])
// Optimized graph initialization callback
const initializeGraph = useCallback(
(graphInstance: any) => {
if (!graphInstance) return
// Check if the graph instance has the required methods
if (
typeof graphInstance.zoom !== 'function' ||
typeof graphInstance.zoomToFit !== 'function'
) {
// If methods aren't available yet, retry after a short delay
setTimeout(() => {
if (graphRef.current && !isGraphReadyRef.current) {
initializeGraph(graphRef.current)
}
}, 100)
return
}
if (isGraphReadyRef.current) return
isGraphReadyRef.current = true
// Only set global actions if no instanceId is provided (for main graph)
if (!instanceId) {
setActions({
zoomIn: () => {
if (graphRef.current && typeof graphRef.current.zoom === 'function') {
const zoom = graphRef.current.zoom()
graphRef.current.zoom(zoom * 1.5)
}
},
zoomOut: () => {
if (graphRef.current && typeof graphRef.current.zoom === 'function') {
const zoom = graphRef.current.zoom()
graphRef.current.zoom(zoom * 0.75)
}
},
zoomToFit: () => {
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
},
regenerateLayout: regenerateLayout
})
}
// Call external ref callback
onGraphRef?.(graphInstance)
},
[setActions, onGraphRef, instanceId, regenerateLayout]
)
// Initialize graph once ready
useEffect(() => {
if (graphRef.current) {
initializeGraph(graphRef.current)
}
return () => {
if (!instanceId && isGraphReadyRef.current) {
setActions({
zoomIn: () => { },
zoomOut: () => { },
zoomToFit: () => { },
regenerateLayout: () => { }
})
isGraphReadyRef.current = false
}
}
}, [initializeGraph, instanceId, setActions])
// Event handlers with proper memoization
const handleNodeClick = useCallback(
@@ -511,6 +554,18 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
setImportModalOpen(true)
}, [setImportModalOpen])
// Handle node drag end - save ALL node positions
const handleNodeDragEnd = useCallback((node: any) => {
node.fx = node.x
node.fy = node.y
// Save positions of ALL nodes when one node is dragged
// In a force-directed graph, moving one node can affect others
if (graphData?.nodes) {
saveAllNodePositions(graphData.nodes)
}
}, [graphData, saveAllNodePositions])
// Throttled hover handlers using RAF for better performance
const handleNodeHover = useCallback((node: any) => {
@@ -579,8 +634,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
y,
data: {
label,
type: node.data.type,
connections: weight
type: node.data?.type || 'unknown',
connections: weight.toString()
},
visible: true
})
@@ -943,6 +998,23 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
}
}, [])
// Mark graph as stabilizing on initial load
useEffect(() => {
const warmupTicks = forceSettings?.warmupTicks?.value ?? 0
if (warmupTicks > 0) {
markAsStabilizing()
// Wait for warmup + a small buffer to ensure graph has stabilized
const timeout = setTimeout(() => {
markAsStabilized()
}, warmupTicks * 10 + 500) // Approximate time for warmup ticks
return () => clearTimeout(timeout)
} else {
// No warmup, mark as stabilized immediately
markAsStabilized()
}
}, [forceSettings?.warmupTicks?.value, nodes.length, edges.length, markAsStabilized, markAsStabilizing])
// Empty state
if (!nodes.length) {
return (
@@ -1051,11 +1123,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
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}
+1 -1
View File
@@ -133,7 +133,7 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => {
}
>
<>
{['force', 'hierarchy'].includes(view) && <GraphMain />}
{view === 'graph' && <GraphMain />}
{view === 'table' && <NodesTable />}
{view === 'map' && <MapPanel />}
{view === 'relationships' && <RelationshipsTable />}
@@ -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 (
<div
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5',
'rounded-md border',
'text-xs font-medium',
'transition-all duration-200',
config.className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
{/* <span>{config.text}</span> */}
</div>
)
})
SaveStatusIndicator.displayName = 'SaveStatusIndicator'
+160 -61
View File
@@ -14,13 +14,17 @@ import {
FunnelPlus,
GitPullRequestArrow,
LassoSelect,
Merge
Merge,
Network
} from 'lucide-react'
import { memo, useCallback } from 'react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import Filters from './filters'
import { useGraphStore } from '@/stores/graph-store'
import { useConfirm } from '@/components/use-confirm-dialog'
import { useGraphSaveStatus } from '@/stores/graph-save-status-store'
import { SaveStatusIndicator } from './save-status-indicator'
// Tooltip wrapper component to avoid repetition
export const ToolbarButton = memo(function ToolbarButton({
@@ -67,12 +71,13 @@ export const ToolbarButton = memo(function ToolbarButton({
)
})
export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean }) {
const { confirm } = useConfirm()
const view = useGraphControls((s) => s.view)
const setView = useGraphControls((s) => s.setView)
const zoomToFit = useGraphControls((s) => s.zoomToFit)
const zoomIn = useGraphControls((s) => s.zoomIn)
const zoomOut = useGraphControls((s) => s.zoomOut)
const onLayout = useGraphControls((s) => s.onLayout)
const regenerateLayout = useGraphControls((s) => s.regenerateLayout)
const refetchGraph = useGraphControls((s) => s.refetchGraph)
const isLassoActive = useGraphControls((s) => s.isLassoActive)
const setIsLassoActive = useGraphControls((s) => s.setIsLassoActive)
@@ -80,6 +85,7 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
const setOpenAddRelationDialog = useGraphStore((state) => state.setOpenAddRelationDialog)
const setOpenMergeDialog = useGraphStore((state) => state.setOpenMergeDialog)
const filters = useGraphStore((s) => s.filters)
const saveStatus = useGraphSaveStatus((s) => s.saveStatus)
const handleRefresh = useCallback(() => {
try {
@@ -87,30 +93,55 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
} catch (error) {
toast.error('Failed to refresh graph data')
}
}, [refetchGraph, onLayout])
}, [refetchGraph])
const handleForceLayout = useCallback(() => {
setView('force')
setTimeout(() => zoomToFit(), 500)
}, [setView, zoomToFit])
const handleApplyForceLayout = useCallback(async () => {
console.log('[Toolbar] handleApplyForceLayout called')
const handleTableLayout = useCallback(() => {
setView('table')
}, [setView])
const confirmed = await confirm({
title: 'Apply force layout?',
message: 'This will reset all node positions and regenerate them using the force-directed layout algorithm. Current positions will be lost.'
})
const handleMapLayout = useCallback(() => {
setView('map')
}, [setView])
if (!confirmed) {
console.log('[Toolbar] User cancelled layout change')
return
}
const handleRelationshipsLayout = useCallback(() => {
setView('relationships')
}, [setView])
console.log('[Toolbar] Calling regenerateLayout with force')
try {
regenerateLayout('force')
console.log('[Toolbar] Force layout applied successfully')
toast.success('Force layout applied successfully')
} catch (error) {
console.error('[Toolbar] Failed to apply layout:', error)
toast.error(`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}, [confirm, regenerateLayout])
const handleDagreLayoutTB = useCallback(() => {
setView('hierarchy')
onLayout && onLayout('dagre-tb')
setTimeout(() => zoomToFit(), 200)
}, [onLayout, setView, zoomToFit])
const handleApplyHierarchyLayout = useCallback(async () => {
console.log('[Toolbar] handleApplyHierarchyLayout called')
const confirmed = await confirm({
title: 'Apply hierarchy layout?',
message: 'This will reset all node positions and regenerate them using the hierarchical layout algorithm. Current positions will be lost.'
})
if (!confirmed) {
console.log('[Toolbar] User cancelled layout change')
return
}
console.log('[Toolbar] Calling regenerateLayout with hierarchy')
try {
regenerateLayout('hierarchy')
console.log('[Toolbar] Hierarchy layout applied successfully')
toast.success('Hierarchy layout applied successfully')
} catch (error) {
console.error('[Toolbar] Failed to apply layout:', error)
toast.error(`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}, [confirm, regenerateLayout])
const handleOpenAddRelationDialog = useCallback(() => {
setOpenAddRelationDialog(true)
@@ -133,8 +164,8 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
)
return (
<div className="flex w-full justify-between gap-2 items-center">
<div className="flex items-center gap-2">
<>
<div className='absolute flex gap-2 left-2 top-2'>
<TooltipProvider>
<ToolbarButton
icon={<GitPullRequestArrow className="h-4 w-4 opacity-70" />}
@@ -154,26 +185,26 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
icon={<ZoomIn className="h-4 w-4 opacity-70" />}
tooltip="Zoom In"
onClick={zoomIn}
disabled={!['force', 'hierarchy'].includes(view) || isLassoActive}
disabled={view !== 'graph' || isLassoActive}
/>
<ToolbarButton
icon={<Minus className="h-4 w-4 opacity-70" />}
tooltip="Zoom Out"
onClick={zoomOut}
disabled={!['force', 'hierarchy'].includes(view) || isLassoActive}
disabled={view !== 'graph' || isLassoActive}
/>
<ToolbarButton
icon={<Maximize className="h-4 w-4 opacity-70" />}
tooltip="Fit to View"
onClick={zoomToFit}
disabled={!['force', 'hierarchy'].includes(view) || isLassoActive}
disabled={view !== 'graph' || isLassoActive}
/>
<ToolbarButton
icon={<LassoSelect className="h-4 w-4 opacity-70" />}
tooltip={'Lasso select'}
onClick={handleLassoSelect}
toggled={isLassoActive}
disabled={!['force', 'hierarchy'].includes(view)}
disabled={view !== 'graph'}
/>
<Filters>
<ToolbarButton
@@ -190,41 +221,109 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
tooltip="Refresh"
/>
</TooltipProvider>
</div>
<div className="flex items-center gap-2">
</div >
{/* Center: View Toggle Group */}
<div className="flex-1 flex border rounded-md justify-center absolute left-1/2 top-1.5 -translate-x-1/2" >
<div className="flex items-center bg-muted/40 p-1 gap-0.5 rounded-md">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setView('graph')}
className={cn(
'h-7 px-2 rounded-sm',
view === 'graph'
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
)}
>
<Network className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Graph view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setView('table')}
className={cn(
'h-7 px-2 rounded-sm',
view === 'table'
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
)}
>
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Table view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setView('relationships')}
className={cn(
'h-7 px-2 rounded-sm',
view === 'relationships'
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
)}
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Relationships view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setView('map')}
className={cn(
'h-7 px-2 rounded-sm',
view === 'map'
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
)}
>
<MapPin className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Map view</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div >
< div className="flex items-center gap-2 absolute right-2 top-2" >
<TooltipProvider>
<ToolbarButton
icon={<GitFork className="h-4 w-4 opacity-70 rotate-180" />}
tooltip={`Hierarchy`}
toggled={['hierarchy'].includes(view)}
onClick={handleDagreLayoutTB}
/>
<ToolbarButton
icon={<Waypoints className="h-4 w-4 opacity-70" />}
tooltip={'Graph view'}
toggled={['force'].includes(view)}
onClick={handleForceLayout}
/>
<ToolbarButton
icon={<List className="h-4 w-4 opacity-70" />}
tooltip={'Table view'}
toggled={['table'].includes(view)}
onClick={handleTableLayout}
/>
<ToolbarButton
icon={<ArrowRightLeft className="h-4 w-4 opacity-70" />}
tooltip={'Relationships view'}
toggled={['relationships'].includes(view)}
onClick={handleRelationshipsLayout}
/>
<ToolbarButton
icon={<MapPin className="h-4 w-4 opacity-70" />}
tooltip={'Map view'}
toggled={['map'].includes(view)}
onClick={handleMapLayout}
/>
{view === 'graph' && (
<>
<ToolbarButton
icon={<Waypoints className="h-4 w-4 opacity-70" />}
tooltip={'Force layout'}
onClick={handleApplyForceLayout}
disabled={isLoading}
/>
<ToolbarButton
icon={<GitFork className="h-4 w-4 opacity-70 rotate-180" />}
tooltip={'Hierarchy layout'}
onClick={handleApplyHierarchyLayout}
disabled={isLoading}
/>
</>
)}
</TooltipProvider>
</div>
</div>
<SaveStatusIndicator status={saveStatus} />
</div >
</ >
)
})
@@ -1,37 +0,0 @@
/**
* WebGL Graph Viewer - Legacy Entry Point
* This file is kept for backward compatibility.
* The actual implementation has been refactored into a modular architecture.
* @see ./webgl/ for the new modular implementation
*/
import React from 'react'
import type { GraphNode, GraphEdge } from '@/types'
import WebGLGraphViewer from './webgl'
/**
* @deprecated Use the new modular implementation from './webgl' directly
*/
interface WebGLGraphViewerProps {
nodes: GraphNode[]
edges: GraphEdge[]
className?: string
style?: React.CSSProperties
onNodeClick?: (node: GraphNode, event: MouseEvent) => void
onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void
onBackgroundClick?: () => void
showIcons?: boolean
showLabels?: boolean
layoutMode?: 'none' | 'force' | 'dagre'
}
/**
* WebGL Graph Viewer Component
* High-performance graph visualization using WebGL (Pixi.js) and D3-force
*
* @deprecated This is a legacy wrapper. Import from './webgl' for the new modular version.
*/
const WebGLGraphViewerLegacy: React.FC<WebGLGraphViewerProps> = (props) => {
return <WebGLGraphViewer {...props} />
}
export default WebGLGraphViewerLegacy
@@ -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
<WebGLGraphViewer
nodes={nodes}
edges={edges}
layoutMode="force" // NEW: 'none' | 'force' | 'dagre'
// ... other props
/>
```
### 🎯 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
@@ -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<string>()
// 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<string>` - 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<string>` - Always show
- `highlightedNodeIds: Set<string>` - Always show
- `maxLabels: number` - Max labels (default: 200)
**Returns:** `Set<string>` - Node IDs with visible labels
## License
Internal use only.
@@ -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
@@ -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 (
<WebGLGraphViewer
nodes={nodes}
edges={edges}
onNodeClick={(node) => console.log('Clicked:', node)}
showIcons={true}
showLabels={true}
/>
)
}
```
### With Custom Handlers
```tsx
<WebGLGraphViewer
nodes={nodes}
edges={edges}
onNodeClick={(node, event) => {
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:
<LODIndicator
zoomLevel={currentZoom}
visible={process.env.NODE_ENV === 'development'}
/>
```
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.
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDebounce } from './use-debounce'
import { sketchService } from '@/api/sketch-service'
import { useGraphSaveStatus } from '@/stores/graph-save-status-store'
import { useGraphStore } from '@/stores/graph-store'
interface NodePosition {
x: number
y: number
}
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
/**
* Hook to save node positions to the backend
* Handles debouncing and batch updates for all nodes
*/
export function useSaveNodePositions(sketchId?: string) {
const [changedNodePositions, setChangedNodePositions] = useState<Map<string, NodePosition>>(new Map())
const setNodes = useGraphStore(s => s.setNodes)
const isStabilizingRef = useRef(false) // Start as false, mark as true only during layout regeneration
const setSaveStatus = useGraphSaveStatus((state) => state.setSaveStatus)
// Debounced value to trigger save (2 seconds)
const debouncedChangedPositions = useDebounce(changedNodePositions, 1200)
// Initialize status on mount
useEffect(() => {
setSaveStatus('idle')
}, [setSaveStatus])
/**
* Mark the graph as stabilized (can start saving positions)
*/
const markAsStabilized = useCallback(() => {
isStabilizingRef.current = false
}, [])
/**
* Mark the graph as stabilizing (prevent saving positions)
*/
const markAsStabilizing = useCallback(() => {
isStabilizingRef.current = true
}, [])
/**
* Save positions for all nodes
* @param nodes - Array of nodes or graphData.nodes
* @param force - Force save even if stabilizing (for layout regeneration)
*/
const saveAllNodePositions = useCallback((nodes: any[], force = false) => {
if (!sketchId) {
return
}
if (!force && isStabilizingRef.current) {
return
}
const newMap = new Map<string, NodePosition>()
nodes.forEach((node: any) => {
if (node.x !== undefined && node.y !== undefined) {
// Fix node position to prevent force simulation from moving it
node.fx = node.x
node.fy = node.y
newMap.set(node.id, { x: node.x, y: node.y })
}
})
if (newMap.size > 0) {
setChangedNodePositions(newMap)
setNodes(nodes)
setSaveStatus('pending')
}
}, [sketchId])
/**
* Clear pending changes (useful when switching sketches)
*/
const clearPendingChanges = useCallback(() => {
setChangedNodePositions(new Map())
}, [])
// Save positions when debounced value changes
useEffect(() => {
if (!sketchId || debouncedChangedPositions.size === 0) {
return
}
const positions = Array.from(debouncedChangedPositions.entries()).map(([nodeId, pos]) => ({
nodeId,
x: pos.x,
y: pos.y
}))
setSaveStatus('saving')
sketchService.updateNodePositions(sketchId, positions)
.then(() => {
setChangedNodePositions(new Map())
setSaveStatus('saved')
// Reset to idle after 2 seconds
setTimeout(() => setSaveStatus('idle'), 2000)
})
.catch((error) => {
console.error('[useSaveNodePositions] Failed to save:', error)
setSaveStatus('error')
// Reset to idle after 3 seconds
setTimeout(() => setSaveStatus('idle'), 3000)
})
}, [debouncedChangedPositions, sketchId, setSaveStatus])
return {
saveAllNodePositions,
markAsStabilized,
markAsStabilizing,
clearPendingChanges,
hasPendingChanges: changedNodePositions.size > 0
}
}
+29 -7
View File
@@ -26,21 +26,43 @@ export const getDagreLayoutedElements = (nodes: GraphNode[],
},) => {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: options.direction });
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
// Configure dagre with proper spacing
g.setGraph({
rankdir: options.direction,
nodesep: 10, // Horizontal spacing between nodes
ranksep: 20, // Vertical spacing between ranks
marginx: 10,
marginy: 10
});
// Set node dimensions first - use a consistent size for all nodes
const nodeWidth = 50;
const nodeHeight = 50;
nodes.forEach((node) =>
g.setNode(node.id, {
...node,
width: node.nodeSize ?? 0,
height: node.nodeSize ?? 0,
width: nodeWidth,
height: nodeHeight,
}),
);
// Then add edges
// Note: react-force-graph transforms edges so source/target become node objects
// We need to extract the IDs for Dagre
edges.forEach((edge: GraphEdge | any) => {
const source = typeof edge.source === 'object' ? edge.source.id : edge.source;
const target = typeof edge.target === 'object' ? edge.target.id : edge.target;
g.setEdge(source, target);
});
Dagre.layout(g);
return {
nodes: nodes.map((node) => {
const position = g.node(node.id);
const x = position.x - (node.nodeSize ?? 0) / 2;
const y = position.y - (node.nodeSize ?? 0) / 2;
const x = position.x;
const y = position.y;
return { ...node, x, y };
}),
edges,
@@ -1,12 +1,10 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type ViewType = 'hierarchy' | 'force' | 'table' | 'map' | 'relationships'
type LayoutMode = 'none' | 'force' | 'dagre'
type ViewType = 'graph' | 'table' | 'map' | 'relationships'
type GraphControlsStore = {
view: ViewType
layoutMode: LayoutMode
isLassoActive: boolean
zoomToFit: () => void
zoomIn: () => void
@@ -14,16 +12,15 @@ type GraphControlsStore = {
onLayout: (layout: any) => void
setActions: (actions: Partial<GraphControlsStore>) => void
refetchGraph: () => void
setView: (view: ViewType) => void
setLayoutMode: (mode: LayoutMode) => void
regenerateLayout: (layoutType: 'force' | 'hierarchy') => void
setView: (view: 'graph' | 'table' | 'map' | 'relationships') => void
setIsLassoActive: (active: boolean) => void
}
export const useGraphControls = create<GraphControlsStore>()(
persist(
(set) => ({
view: 'force',
layoutMode: 'none',
view: 'graph',
isLassoActive: false,
zoomToFit: () => { },
zoomIn: () => { },
@@ -31,13 +28,13 @@ export const useGraphControls = create<GraphControlsStore>()(
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 })
}
)
)
@@ -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<GraphSaveStatusStore>((set) => ({
saveStatus: 'idle',
setSaveStatus: (status) => set({ saveStatus: status })
}))
@@ -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