mirror of
https://github.com/reconurge/flowsint.git
synced 2026-07-19 04:03:08 -05:00
Merge branch 'feat/save-nodes-position'
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user