feat(app): auto center on node

This commit is contained in:
dextmorgn
2025-11-29 12:06:07 +01:00
parent b7023f8848
commit 885366e930
4 changed files with 91 additions and 16 deletions

View File

@@ -167,6 +167,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
const graphRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isGraphReadyRef = useRef(false)
const hasInitialZoomedRef = useRef(false)
const previousNodeCountRef = useRef(0)
// Store selectors
const nodeColors = useNodesDisplaySettings((s) => s.colors)
@@ -175,6 +177,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
const setCurrentLayoutType = useGraphControls((s) => s.setCurrentLayoutType)
const shouldRegenerateLayoutOnNextRefetch = useGraphControls((s) => s.shouldRegenerateLayoutOnNextRefetch)
const setShouldRegenerateLayoutOnNextRefetch = useGraphControls((s) => s.setShouldRegenerateLayoutOnNextRefetch)
const autoZoomOnCurrentNode = useGraphSettingsStore((s) => s.getSettingValue('general', 'autoZoomOnCurrentNode'))
// Combine graph store selectors with useShallow for better performance
const { currentNode, currentEdge, selectedNodes, selectedEdges, toggleEdgeSelection, setCurrentEdge, clearSelectedEdges, setOpenMainDialog } = useGraphStore(
@@ -421,7 +424,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
setIsRegeneratingLayout(false)
}
})
}, [graphData, applyLayout, setCurrentLayoutType])
}, [applyLayout, setCurrentLayoutType])
// Optimized graph initialization callback
const initializeGraph = useCallback(
@@ -442,6 +445,16 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
}
if (isGraphReadyRef.current) return
isGraphReadyRef.current = true
// Center graph immediately once ready
if (graphData.nodes.length > 0) {
setTimeout(() => {
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
}, 50)
}
// Only set global actions if no instanceId is provided (for main graph)
if (!instanceId) {
@@ -463,6 +476,14 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
graphRef.current.zoomToFit(400)
}
},
centerOnNode: (x: number, y: number) => {
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
graphRef.current.centerAt(x, y, 400)
if (typeof graphRef.current.zoom === 'function') {
graphRef.current.zoom(6, 400)
}
}
},
regenerateLayout: regenerateLayout,
getViewportCenter: () => {
if (!graphRef.current || !containerRef.current) return null
@@ -484,7 +505,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
// Call external ref callback
onGraphRef?.(graphInstance)
},
[setActions, onGraphRef, instanceId, regenerateLayout]
[setActions, onGraphRef, instanceId, regenerateLayout, graphData.nodes.length]
)
// Initialize graph once ready
@@ -498,6 +519,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
zoomIn: () => { },
zoomOut: () => { },
zoomToFit: () => { },
centerOnNode: () => { },
regenerateLayout: () => { },
getViewportCenter: () => null
})
@@ -506,26 +528,34 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
}
}, [initializeGraph, instanceId, setActions])
// Auto-center graph on initial load
// Auto-center graph on initial load ONLY
useEffect(() => {
// Only auto-center if we have nodes and the graph is ready
if (graphRef.current && graphData.nodes.length > 0 && containerSize.width > 0) {
const currentNodeCount = graphData.nodes.length
// Reset zoom flag if node count changed significantly (new graph loaded or major changes)
if (previousNodeCountRef.current !== currentNodeCount) {
hasInitialZoomedRef.current = false
previousNodeCountRef.current = currentNodeCount
}
// Only auto-center if we have nodes, the graph is ready, and we haven't zoomed yet
if (graphRef.current && currentNodeCount > 0 && containerSize.width > 0 && !hasInitialZoomedRef.current) {
// If flag is set, regenerate layout instead of just zooming
if (shouldRegenerateLayoutOnNextRefetch && currentLayoutType) {
setShouldRegenerateLayoutOnNextRefetch(false)
hasInitialZoomedRef.current = true
const timer = setTimeout(() => {
regenerateLayout(currentLayoutType)
}, 500)
}, 100)
return () => clearTimeout(timer)
}
// Otherwise just zoom to fit on initial load
const timer = setTimeout(() => {
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
}, 500)
return () => clearTimeout(timer)
hasInitialZoomedRef.current = true
// Zoom immediately when graph is ready
if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') {
graphRef.current.zoomToFit(400)
}
}
}, [graphData.nodes.length, containerSize.width, shouldRegenerateLayoutOnNextRefetch, currentLayoutType, regenerateLayout, setShouldRegenerateLayoutOnNextRefetch])
@@ -533,8 +563,21 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
const handleNodeClick = useCallback(
(node: any, event: MouseEvent) => {
onNodeClick?.(node, event)
// Auto-zoom to clicked node if enabled and not multi-selecting
const isMultiSelect = event.ctrlKey || event.shiftKey
if (autoZoomOnCurrentNode && !isMultiSelect && node?.x && node?.y && graphRef.current) {
setTimeout(() => {
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
graphRef.current.centerAt(node.x, node.y, 400)
if (typeof graphRef.current.zoom === 'function') {
graphRef.current.zoom(6, 400)
}
}
}, 100)
}
},
[onNodeClick]
[onNodeClick, autoZoomOnCurrentNode]
)
const handleNodeRightClick = useCallback(

View File

@@ -2,6 +2,8 @@ import type React from 'react'
import { memo, useMemo, useState, useCallback, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useGraphStore } from '@/stores/graph-store'
import { useGraphControls } from '@/stores/graph-controls-store'
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { TypeBadge } from '@/components/type-badge'
@@ -27,15 +29,28 @@ const NodeRenderer = memo(
setCurrentNode,
onCheckboxChange,
isNodeChecked,
isCurrent
isCurrent,
centerOnNode,
autoZoomOnCurrentNode
}: {
node: any
setCurrentNode: (node: GraphNode) => void
onCheckboxChange: (node: GraphNode, checked: boolean) => void
isNodeChecked: (nodeId: string) => boolean
isCurrent: (nodeId: string) => boolean
centerOnNode: (x: number, y: number) => void
autoZoomOnCurrentNode: boolean
}) => {
const handleClick = useCallback(() => setCurrentNode(node), [node, setCurrentNode])
const handleClick = useCallback(() => {
setCurrentNode(node)
// Auto-zoom if enabled and node has coordinates
if (autoZoomOnCurrentNode && node?.x !== undefined && node?.y !== undefined) {
setTimeout(() => {
centerOnNode(node.x, node.y)
}, 100)
}
}, [node, setCurrentNode, centerOnNode, autoZoomOnCurrentNode])
const handleCheckboxChange = useCallback(
(checked: boolean) => {
onCheckboxChange(node, checked)
@@ -78,7 +93,9 @@ const VirtualizedItem = memo(
setCurrentNode,
onCheckboxChange,
isNodeChecked,
isCurrent
isCurrent,
centerOnNode,
autoZoomOnCurrentNode
}: {
index: number
node: GraphNode
@@ -86,6 +103,8 @@ const VirtualizedItem = memo(
onCheckboxChange: (node: GraphNode, checked: boolean) => void
isNodeChecked: (nodeId: string) => boolean
isCurrent: (nodeId: string) => boolean
centerOnNode: (x: number, y: number) => void
autoZoomOnCurrentNode: boolean
}) => {
return (
<NodeRenderer
@@ -94,6 +113,8 @@ const VirtualizedItem = memo(
setCurrentNode={setCurrentNode}
onCheckboxChange={onCheckboxChange}
isNodeChecked={isNodeChecked}
centerOnNode={centerOnNode}
autoZoomOnCurrentNode={autoZoomOnCurrentNode}
/>
)
}
@@ -104,6 +125,8 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?:
const setCurrentNode = useGraphStore((state) => state.setCurrentNode)
const setSelectedNodes = useGraphStore((state) => state.setSelectedNodes)
const selectedNodes = useGraphStore((state) => state.selectedNodes || [])
const centerOnNode = useGraphControls((state) => state.centerOnNode)
const autoZoomOnCurrentNode = useGraphSettingsStore((s) => s.getSettingValue('general', 'autoZoomOnCurrentNode'))
const [searchQuery, setSearchQuery] = useState<string>('')
const [filters, setFilters] = useState<string[] | null>(null)
@@ -341,6 +364,8 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?:
setCurrentNode={setCurrentNode}
onCheckboxChange={handleCheckboxChange}
isNodeChecked={isNodeChecked}
centerOnNode={centerOnNode}
autoZoomOnCurrentNode={autoZoomOnCurrentNode}
/>
</div>
)

View File

@@ -14,6 +14,7 @@ type GraphControlsStore = {
zoomToFit: () => void
zoomIn: () => void
zoomOut: () => void
centerOnNode: (x: number, y: number) => void
onLayout: (layout: any) => void
setActions: (actions: Partial<GraphControlsStore>) => void
refetchGraph: (onSuccess?: () => void) => void
@@ -37,6 +38,7 @@ export const useGraphControls = create<GraphControlsStore>()(
zoomToFit: () => { },
zoomIn: () => { },
zoomOut: () => { },
centerOnNode: () => { },
onLayout: () => { },
setActions: (actions) => set(actions),
refetchGraph: (onSuccess) => { },

View File

@@ -10,6 +10,11 @@ const DEFAULT_SETTINGS = {
value: false,
description: 'Display Flo, your AI assistant.'
},
autoZoomOnCurrentNode: {
type: 'boolean',
value: true,
description: 'Automatically zoom to the current node when it changes.'
},
},
graph: {
nodeSize: {