supermemory/packages/memory-graph/src/components/memory-graph.tsx
abhay-codes07 714d273ba5
fix(memory-graph): stop hit-testing against stale nodes after the array regenerates
SpatialIndex.rebuild() early-returns on an unchanged content hash, and
the hash only covers node ids and rounded positions. useGraphData
produces a brand-new generation of node objects whenever its memo deps
change — including on container resize and light/dark theme switches —
with identical ids and coordinates. The GraphCanvas [nodes] effect then
called rebuild(), the hash matched, and the grid kept serving the
previous generation of objects while the renderer and nodeMap moved on.

The first drag after any resize or theme toggle therefore grabbed a
detached object: onMouseDown pinned fx/fy on a node the renderer no
longer draws, the visible node ignored the cursor, and the drag-start
reheat() let it drift freely. Hover and click were unaffected (they
only use node.id), which is what kept this hidden.

Give rebuild() a force parameter and pass it from the identity-keyed
effect, keeping the cheap hash guard for the per-frame rebuilds in the
rAF loop. Covered with SpatialIndex tests that pin both behaviours:
the unforced hash guard keeps the old generation (documenting why force
exists), and a forced rebuild swaps the grid to the new objects and
refreshes the hash.

Also remove the colors re-merge in MemoryGraph: useGraphTheme already
returns the overrides merged and referentially stable by value, and
re-merging keyed on the raw prop identity made `colors` a fresh object
every render for inline `colors={{...}}` consumers — rebuilding the
entire node array each render and multiplying how often the stale-index
window occurred.
2026-07-13 14:58:32 +05:30

837 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
} from "../canvas/simulation"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import { VersionChainIndex } from "../canvas/version-chain"
import type { ViewportState } from "../canvas/viewport"
import { useGraphData } from "../hooks/use-graph-data"
import { useGraphTheme } from "../hooks/use-graph-theme"
import type {
GraphApiDocument,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
import { GraphCanvas } from "./graph-canvas"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover"
export function MemoryGraph({
documents = [],
isLoading: externalIsLoading = false,
isLoadingMore = false,
onLoadMore,
hasMore = false,
error: externalError = null,
children,
variant = "console",
highlightDocumentIds = [],
highlightsVisible = true,
showFps = false,
maxNodes,
isSlideshowActive = false,
onSlideshowNodeChange,
canvasRef: externalCanvasRef,
colors: colorOverrides,
totalCount,
onOpenDocument,
labels: labelOverrides,
layering,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides],
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
// useGraphTheme already merges the overrides and keeps the result
// referentially stable while the override values are unchanged.
// Re-merging here keyed on the raw prop identity made `colors` a new
// object on every render for inline `colors={{...}}` consumers, which
// rebuilt the entire node array (useGraphData dependency) each render.
const colors = useGraphTheme(colorOverrides)
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<ViewportState | null>(null)
const simulationRef = useRef<ForceSimulation | null>(null)
const [simulation, setSimulation] = useState<ForceSimulation | null>(null)
const chainIndex = useRef(new VersionChainIndex())
// React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [zoomDisplay, setZoomDisplay] = useState(50)
// Monotonic counter that increments on any viewport change (pan or zoom)
// Used as a dependency proxy to recalculate popover positions
const [viewportVersion, setViewportVersion] = useState(0)
// Limit documents so total node count (documents + their memories) stays under maxNodes
const limitedDocuments = useMemo(() => {
if (!maxNodes || documents.length === 0) return documents
let totalNodes = 0
const limited: GraphApiDocument[] = []
for (let i = 0; i < documents.length; i++) {
const doc = documents[i]
if (!doc) continue
if (totalNodes >= maxNodes) break
const remainingNodes = maxNodes - totalNodes
const memories = doc.memories ?? []
const docNodes = 1 + memories.length
if (docNodes <= remainingNodes) {
limited.push(doc)
totalNodes += docNodes
continue
}
if (remainingNodes > 1) {
limited.push({
...doc,
memories: memories.slice(0, remainingNodes - 1),
})
totalNodes = maxNodes
break
}
limited.push({ ...doc, memories: [] })
totalNodes += 1
}
return limited
}, [documents, maxNodes])
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
const { nodes, edges } = useGraphData(
hasContainerSize ? limitedDocuments : [],
null,
containerSize.width,
containerSize.height,
colors,
)
const isCompactViewport = containerSize.width > 0 && containerSize.width < 640
const graphFitHeight = isCompactViewport
? Math.max(containerSize.height - 170, 240)
: containerSize.height
const compactLegendMaxHeight = isCompactViewport
? Math.max(containerSize.height - 104, 160)
: undefined
// Rebuild version chain index during render (not in an effect) so that
// the chain data is up-to-date when getChain() is called in useMemo below.
// rebuild() has an early-return guard (referential equality on documents)
// that makes this a no-op on re-renders where limitedDocuments hasn't changed.
chainIndex.current.rebuild(limitedDocuments)
// Initial loads get a full force settle. Append-only pagination keeps
// existing coordinates stable and renders new nodes in nearby open areas.
const prevSimIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (nodes.length === 0) {
simulationRef.current?.destroy()
simulationRef.current = null
setSimulation(null)
prevSimIdsRef.current = new Set()
return
}
const currentIds = new Set(nodes.map((n) => n.id))
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
simulationRef.current?.destroy()
simulationRef.current = null
setSimulation(null)
prevSimIdsRef.current = currentIds
return
}
const previousIds = prevSimIdsRef.current
const hasPreviousIds = previousIds.size > 0
const idsChanged =
currentIds.size !== previousIds.size ||
[...currentIds].some((id) => !previousIds.has(id))
const isAppendOnly =
hasPreviousIds && [...previousIds].every((id) => currentIds.has(id))
if (!simulationRef.current) {
const sim = new ForceSimulation()
simulationRef.current = sim
setSimulation(sim)
}
if (!hasPreviousIds || (idsChanged && !isAppendOnly)) {
prevSimIdsRef.current = currentIds
simulationRef.current.init(nodes, edges)
} else if (idsChanged && isAppendOnly) {
prevSimIdsRef.current = currentIds
simulationRef.current.update(nodes, edges)
simulationRef.current.stop()
} else {
simulationRef.current.update(nodes, edges)
}
}, [nodes, edges])
// Cleanup simulation on unmount
useEffect(() => {
return () => {
simulationRef.current?.destroy()
simulationRef.current = null
setSimulation(null)
}
}, [])
useEffect(() => {
viewportRef.current?.setMinZoomForNodes(
nodes,
containerSize.width,
graphFitHeight,
)
}, [nodes, containerSize.width, graphFitHeight])
// Auto-fit when data first loads. Mobile needs a few passes because the
// force simulation can move nodes after the first layout frame.
const hasAutoFittedRef = useRef(false)
const hadValidContainerSizeRef = useRef(false)
useEffect(() => {
if (
!hasAutoFittedRef.current &&
nodes.length > 0 &&
viewportRef.current &&
hasContainerSize
) {
const fitDelays = isCompactViewport ? [100, 450, 900] : [100, 300]
const timers = fitDelays.map((delay, index) =>
setTimeout(() => {
if (!viewportRef.current || !hasContainerSize) return
viewportRef.current.fitToNodes(
nodes,
containerSize.width,
graphFitHeight,
)
if (index === fitDelays.length - 1) {
hasAutoFittedRef.current = true
}
}, delay),
)
return () => {
for (const timer of timers) clearTimeout(timer)
}
}
}, [
nodes,
containerSize.width,
graphFitHeight,
isCompactViewport,
hasContainerSize,
])
useEffect(() => {
if (!isCompactViewport || nodes.length === 0 || !viewportRef.current) return
if (!hasContainerSize) return
const timer = setTimeout(() => {
viewportRef.current?.fitToNodes(
nodes,
containerSize.width,
graphFitHeight,
)
}, 120)
return () => clearTimeout(timer)
}, [
isCompactViewport,
nodes,
containerSize.width,
graphFitHeight,
hasContainerSize,
])
useEffect(() => {
if (nodes.length === 0) hasAutoFittedRef.current = false
}, [nodes.length])
useEffect(() => {
if (isCompactViewport) {
hasAutoFittedRef.current = false
}
}, [isCompactViewport])
useEffect(() => {
if (hasContainerSize && !hadValidContainerSizeRef.current) {
hadValidContainerSizeRef.current = true
hasAutoFittedRef.current = false
}
if (!hasContainerSize) {
hadValidContainerSizeRef.current = false
}
}, [hasContainerSize])
// Container resize observer
useEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => {
const rect = el.getBoundingClientRect()
const width = Math.round(rect.width) || el.clientWidth
const height = Math.round(rect.height) || el.clientHeight
setContainerSize({ width, height })
setContainerBounds(rect)
}
const ro = new ResizeObserver(measure)
ro.observe(el)
const parent = el.parentElement
if (parent) ro.observe(parent)
measure()
const raf = requestAnimationFrame(measure)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
// Callbacks for GraphCanvas
const handleNodeHover = useCallback(
(id: string | null) => setHoveredNode(id),
[],
)
const handleNodeClick = useCallback((id: string | null) => {
setSelectedNode((prev) => (id === null ? null : prev === id ? null : id))
}, [])
const handleNodeDragStart = useCallback((_id: string) => {
// Drag is handled imperatively by InputHandler
}, [])
const handleNodeDragEnd = useCallback(() => {
// Drag end handled by InputHandler
}, [])
// Load more when user zooms out enough that visible area dwarfs the node extent
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadMoreRef = useRef({ hasMore, isLoadingMore, onLoadMore })
loadMoreRef.current = { hasMore, isLoadingMore, onLoadMore }
const handleViewportChange = useCallback(
(zoom: number, popoverVisible: boolean) => {
setZoomDisplay(Math.round(zoom * 100))
if (popoverVisible) {
setViewportVersion((v) => v + 1)
}
const {
hasMore: more,
isLoadingMore: loading,
onLoadMore: load,
} = loadMoreRef.current
if (!more || loading || !load || !viewportRef.current) return
const vp = viewportRef.current
const currentNodes = nodes
if (currentNodes.length === 0) return
const topLeft = vp.screenToWorld(0, 0)
const bottomRight = vp.screenToWorld(
containerSize.width,
containerSize.height,
)
const viewW = bottomRight.x - topLeft.x
const viewH = bottomRight.y - topLeft.y
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const n of currentNodes) {
if (n.x < minX) minX = n.x
if (n.y < minY) minY = n.y
if (n.x > maxX) maxX = n.x
if (n.y > maxY) maxY = n.y
}
const nodeW = maxX - minX || 1
const nodeH = maxY - minY || 1
// Only trigger when the visible area is 3x larger than the node extent
// (i.e. user has zoomed out significantly past the data)
const zoomedOut = viewW > nodeW * 3 || viewH > nodeH * 3
if (zoomedOut && !loadMoreTimerRef.current) {
loadMoreTimerRef.current = setTimeout(() => {
loadMoreTimerRef.current = null
loadMoreRef.current.onLoadMore?.()
}, 500)
}
},
[nodes, containerSize.width, containerSize.height],
)
// Navigation
const handleAutoFit = useCallback(() => {
if (nodes.length === 0 || !viewportRef.current) return
viewportRef.current.fitToNodes(nodes, containerSize.width, graphFitHeight)
}, [nodes, containerSize.width, graphFitHeight])
const handleCenter = useCallback(() => {
if (nodes.length === 0 || !viewportRef.current) return
let sx = 0
let sy = 0
for (const n of nodes) {
sx += n.x
sy += n.y
}
viewportRef.current.centerOn(
sx / nodes.length,
sy / nodes.length,
containerSize.width,
graphFitHeight,
)
}, [nodes, containerSize.width, graphFitHeight])
const handleZoomIn = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, graphFitHeight / 2)
}, [containerSize.width, graphFitHeight])
const handleZoomOut = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
}, [containerSize.width, graphFitHeight])
// Wrap onOpenDocument to dismiss the popover before opening the modal.
// Without this, the popover overlay stays mounted on top of the
// document modal, obscuring it and intercepting clicks.
const handleOpenDocument = useCallback(
(documentId: string) => {
setSelectedNode(null)
setHoveredNode(null)
onOpenDocument?.(documentId)
},
[onOpenDocument],
)
// Keyboard shortcuts — using useEffect with keydown listener
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Don't handle if user is typing in an input
const target = e.target as HTMLElement
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
)
return
switch (e.key) {
case "z":
case "Z":
handleAutoFit()
break
case "c":
case "C":
handleCenter()
break
case "=":
case "+":
handleZoomIn()
break
case "-":
case "_":
handleZoomOut()
break
case "Escape":
setSelectedNode(null)
break
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut])
// Arrow key navigation through nodes
const selectAndCenter = useCallback(
(nodeId: string) => {
setSelectedNode(nodeId)
const n = nodes.find((nd) => nd.id === nodeId)
if (n && viewportRef.current)
viewportRef.current.centerOn(
n.x,
n.y,
containerSize.width,
containerSize.height,
)
},
[nodes, containerSize.width, containerSize.height],
)
const navigateUp = useCallback(() => {
if (!selectedNode) return
const chain = chainIndex.current.getChain(selectedNode)
if (chain && chain.length > 1) {
const idx = chain.findIndex((e) => e.id === selectedNode)
const prev = chain[idx - 1]
if (idx > 0 && prev) {
selectAndCenter(prev.id)
return
}
}
// At top of chain or no chain — go to parent document
const node = nodes.find((n) => n.id === selectedNode)
if (node?.type === "memory" && "documentId" in node.data) {
selectAndCenter(node.data.documentId)
}
}, [selectedNode, nodes, selectAndCenter])
const navigateDown = useCallback(() => {
if (!selectedNode) return
// Version chain navigation
const chain = chainIndex.current.getChain(selectedNode)
if (chain && chain.length > 1) {
const idx = chain.findIndex((e) => e.id === selectedNode)
const next = chain[idx + 1]
if (idx >= 0 && idx < chain.length - 1 && next) {
selectAndCenter(next.id)
return
}
}
// On a document — go to its first memory
const node = nodes.find((n) => n.id === selectedNode)
if (node?.type === "document") {
const child = nodes.find(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === selectedNode,
)
if (child) selectAndCenter(child.id)
}
}, [selectedNode, nodes, selectAndCenter])
const navigateNext = useCallback(() => {
if (!selectedNode) return
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "document") {
const docs = nodes.filter((n) => n.type === "document")
const idx = docs.findIndex((n) => n.id === selectedNode)
const next = docs[(idx + 1) % docs.length]
if (next) selectAndCenter(next.id)
} else {
const docId = "documentId" in node.data ? node.data.documentId : null
const siblings = nodes.filter(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === docId,
)
if (siblings.length === 0) return
const idx = siblings.findIndex((n) => n.id === selectedNode)
const next = siblings[(idx + 1) % siblings.length]
if (next) selectAndCenter(next.id)
}
}, [selectedNode, nodes, selectAndCenter])
const navigatePrev = useCallback(() => {
if (!selectedNode) return
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "document") {
const docs = nodes.filter((n) => n.type === "document")
const idx = docs.findIndex((n) => n.id === selectedNode)
const prev = docs[(idx - 1 + docs.length) % docs.length]
if (prev) selectAndCenter(prev.id)
} else {
const docId = "documentId" in node.data ? node.data.documentId : null
const siblings = nodes.filter(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === docId,
)
if (siblings.length === 0) return
const idx = siblings.findIndex((n) => n.id === selectedNode)
const prev = siblings[(idx - 1 + siblings.length) % siblings.length]
if (prev) selectAndCenter(prev.id)
}
}, [selectedNode, nodes, selectAndCenter])
// Arrow key navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
)
return
switch (e.key) {
case "ArrowUp":
e.preventDefault()
navigateUp()
break
case "ArrowDown":
e.preventDefault()
navigateDown()
break
case "ArrowRight":
e.preventDefault()
navigateNext()
break
case "ArrowLeft":
e.preventDefault()
navigatePrev()
break
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [navigateUp, navigateDown, navigateNext, navigatePrev])
// Slideshow — use refs to avoid re-creating the interval on resize or node updates
const nodesRef = useRef(nodes)
nodesRef.current = nodes
const containerSizeRef = useRef(containerSize)
containerSizeRef.current = containerSize
const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange)
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
useEffect(() => {
if (!isSlideshowActive || nodes.length === 0) {
if (!isSlideshowActive) {
setSelectedNode(null)
simulationRef.current?.coolDown()
}
return
}
let lastIdx = -1
let coolDownTimer: ReturnType<typeof setTimeout> | null = null
const pick = () => {
const currentNodes = nodesRef.current
if (currentNodes.length === 0) return
let idx: number
if (currentNodes.length > 1) {
do {
idx = Math.floor(Math.random() * currentNodes.length)
} while (idx === lastIdx)
} else {
idx = 0
}
lastIdx = idx
const n = currentNodes[idx]
if (!n) return
setSelectedNode(n.id)
const sz = containerSizeRef.current
viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height)
simulationRef.current?.reheat()
onSlideshowNodeChangeRef.current?.(n.id)
// Clear any pending coolDown before scheduling a new one
if (coolDownTimer) clearTimeout(coolDownTimer)
coolDownTimer = setTimeout(() => {
simulationRef.current?.coolDown()
coolDownTimer = null
}, 1000)
}
pick()
const interval = setInterval(pick, 3500)
return () => {
clearInterval(interval)
if (coolDownTimer) clearTimeout(coolDownTimer)
}
}, [isSlideshowActive, nodes.length])
// Active node: selected takes priority, then hovered
const activeNodeId = selectedNode ?? hoveredNode
const activeNodeData = useMemo(() => {
if (!activeNodeId) return null
return nodes.find((n) => n.id === activeNodeId) ?? null
}, [activeNodeId, nodes])
// biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion intentionally used as proxy for viewport state changes
const activePopoverPosition = useMemo(() => {
if (!activeNodeData || !viewportRef.current) return null
const vp = viewportRef.current
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
return {
screenX: screen.x,
screenY: screen.y,
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
}
// viewportVersion triggers re-computation on any viewport change (pan + zoom)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNodeData, viewportVersion])
// biome-ignore lint/correctness/useExhaustiveDependencies: limitedDocuments triggers re-computation after chainIndex.current.rebuild() runs with new data
const activeVersionChain = useMemo(() => {
if (!activeNodeData || activeNodeData.type !== "memory") return null
return chainIndex.current.getChain(activeNodeData.id)
}, [activeNodeData, limitedDocuments])
const isLayoutPending = !hasContainerSize && limitedDocuments.length > 0
const isLoading = externalIsLoading || isLayoutPending
if (externalError) {
const errorContainerStyle: React.CSSProperties = {
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderRadius: 12,
}
const errorBoxStyle: React.CSSProperties = {
color: colors.textSecondary,
paddingLeft: 24,
paddingRight: 24,
paddingTop: 16,
paddingBottom: 16,
}
return (
<div style={errorContainerStyle}>
<div style={errorBoxStyle}>
Error loading graph: {externalError.message}
</div>
</div>
)
}
const wrapperStyle: React.CSSProperties = {
position: "relative",
height: "100%",
borderRadius: 12,
overflow: "hidden",
backgroundColor: "transparent",
}
const canvasContainerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
position: "relative",
overflow: "hidden",
touchAction: "none",
userSelect: "none",
}
const emptyStateStyle: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}
const bottomLeftStackStyle: React.CSSProperties = {
position: "absolute",
bottom: isCompactViewport ? 12 : 16,
left: isCompactViewport ? 12 : 16,
zIndex: 20,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 8,
maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined,
}
return (
<div style={wrapperStyle}>
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={isLoadingMore}
labels={resolvedLabels}
totalLoaded={totalCount ?? documents.length}
colors={colors}
/>
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
<div style={emptyStateStyle}>{children}</div>
)}
<div style={canvasContainerStyle} ref={containerRef}>
{hasContainerSize && (
<GraphCanvas
colors={colors}
edges={edges}
height={containerSize.height}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
nodes={nodes}
onNodeClick={handleNodeClick}
onNodeDragEnd={handleNodeDragEnd}
onNodeDragStart={handleNodeDragStart}
onNodeHover={handleNodeHover}
onViewportChange={handleViewportChange}
selectedNodeId={selectedNode}
simulation={simulation ?? undefined}
viewportRef={viewportRef}
width={containerSize.width}
canvasRef={externalCanvasRef}
showFps={showFps}
variant={variant}
/>
)}
{activeNodeData && activePopoverPosition && (
<NodeHoverPopover
colors={colors}
containerBounds={containerBounds ?? undefined}
labels={resolvedLabels}
node={activeNodeData}
zIndex={hoverPopoverZIndex}
nodeRadius={activePopoverPosition.nodeRadius}
onNavigateDown={navigateDown}
onNavigateNext={navigateNext}
onNavigatePrev={navigatePrev}
onNavigateUp={navigateUp}
onSelectNode={handleNodeClick}
onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
screenX={activePopoverPosition.screenX}
screenY={activePopoverPosition.screenY}
versionChain={activeVersionChain}
/>
)}
{containerSize.width > 0 && (
<div style={bottomLeftStackStyle}>
<NavigationControls
nodes={nodes}
compact={isCompactViewport}
onAutoFit={handleAutoFit}
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
zoomLevel={zoomDisplay}
colors={colors}
/>
<Legend
colors={colors}
edges={edges}
labels={resolvedLabels}
hoveredNode={hoveredNode}
compact={isCompactViewport}
isLoading={isLoading}
maxHeight={compactLegendMaxHeight}
nodes={nodes}
/>
</div>
)}
</div>
</div>
)
}