supermemory/packages/memory-graph/src/components/memory-graph.tsx

971 lines
27 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
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 { useInitialGraphFit } from "../hooks/use-initial-graph-fit"
import type {
GraphApiDocument,
GraphNode,
MemoryNodeData,
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"
import { Minimap } from "./minimap"
function documentIdForNode(
node: GraphNode | null,
documents: GraphApiDocument[],
): string | null {
if (!node || typeof node.data.id !== "string" || !node.data.id.trim())
return null
if (node.type === "document") {
return documents.some((document) => document.id === node.data.id)
? node.data.id
: null
}
const sourceId = (node.data as MemoryNodeData).documentId
if (typeof sourceId !== "string" || !sourceId.trim()) return null
const ids = new Set(
documents
.filter(
(document) =>
document.id.trim() &&
document.memories.some((memory) => memory.id === node.data.id),
)
.map((document) => document.id),
)
return ids.size === 1 && ids.has(sourceId) ? sourceId : null
}
export function MemoryGraph({
documents = [],
isLoading: externalIsLoading = false,
isLoadingMore = false,
onLoadMore,
hasMore = false,
error: externalError = null,
children,
variant = "console",
highlightDocumentIds = [],
highlightsVisible = true,
showFps = false,
showMinimap = false,
maxNodes,
isSlideshowActive = false,
onSlideshowNodeChange,
canvasRef: externalCanvasRef,
colors: colorOverrides,
totalCount,
onOpenDocument: externalOnOpenDocument,
onNodeSelect,
labels: labelOverrides,
layering,
appearance,
openDocumentOnEnter = false,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides],
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
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 onNodeSelectRef = useRef(onNodeSelect)
onNodeSelectRef.current = onNodeSelect
const lastSelectedRef = useRef(selectedNode)
// Compares against the last value so mount and StrictMode re-runs do not emit.
useEffect(() => {
if (lastSelectedRef.current === selectedNode) return
lastSelectedRef.current = selectedNode
onNodeSelectRef.current?.(selectedNode)
}, [selectedNode])
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)
// Cap documents + unique memories at maxNodes; shared memories count once.
const limitedDocuments = useMemo(() => {
if (!maxNodes || documents.length === 0) return documents
let totalNodes = 0
const seenMemoryIds = new Set<string>()
const limited: GraphApiDocument[] = []
for (const doc of documents) {
if (totalNodes >= maxNodes) break
const memories = doc.memories ?? []
const includedMemories: GraphApiDocument["memories"] = []
totalNodes += 1
for (const memory of memories) {
if (seenMemoryIds.has(memory.id)) {
includedMemories.push(memory)
continue
}
if (totalNodes >= maxNodes) continue
seenMemoryIds.add(memory.id)
includedMemories.push(memory)
totalNodes += 1
}
limited.push(
includedMemories.length === memories.length
? doc
: { ...doc, memories: includedMemories },
)
}
return limited
}, [documents, maxNodes])
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
const { nodes, edges } = useGraphData(
hasContainerSize ? limitedDocuments : [],
null,
containerSize.width,
containerSize.height,
colors,
variant === "console" ? "theme" : "cluster",
)
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)
const prevSimIdsRef = useRef<Set<string>>(new Set())
useLayoutEffect(() => {
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.settle()
} 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])
const stopFollowing = useInitialGraphFit({
documents: limitedDocuments,
nodes,
viewportRef,
simulationRef,
width: containerSize.width,
height: hasContainerSize ? graphFitHeight : 0,
isLoading: externalIsLoading,
isLoadingMore,
hasMore,
})
// 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
stopFollowing()
viewportRef.current.setMinZoomForNodes(
nodes,
containerSize.width,
graphFitHeight,
)
viewportRef.current.fitToNodes(nodes, containerSize.width, graphFitHeight)
}, [nodes, containerSize.width, graphFitHeight, stopFollowing])
const handleCenter = useCallback(() => {
if (nodes.length === 0 || !viewportRef.current) return
stopFollowing()
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, stopFollowing])
const handleZoomIn = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
stopFollowing()
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, graphFitHeight / 2)
}, [containerSize.width, graphFitHeight, stopFollowing])
const handleZoomOut = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
stopFollowing()
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
}, [containerSize.width, graphFitHeight, stopFollowing])
const activeDocumentId = useMemo(
() =>
openDocumentOnEnter
? documentIdForNode(
nodes.find((node) => node.id === (selectedNode ?? hoveredNode)) ??
null,
documents,
)
: null,
[openDocumentOnEnter, selectedNode, hoveredNode, nodes, documents],
)
const onOpenDocument = useMemo(
() =>
!openDocumentOnEnter
? externalOnOpenDocument
: externalOnOpenDocument && activeDocumentId
? (_documentId: string) => externalOnOpenDocument(activeDocumentId)
: undefined,
[openDocumentOnEnter, externalOnOpenDocument, activeDocumentId],
)
const selectedDocumentId = useMemo(
() =>
openDocumentOnEnter
? documentIdForNode(
nodes.find((node) => node.id === selectedNode) ?? null,
documents,
)
: null,
[openDocumentOnEnter, selectedNode, nodes, documents],
)
const canOpenOnEnter = openDocumentOnEnter && !!externalOnOpenDocument
const handleOpenDocument = useCallback(
(documentId: string) => {
setSelectedNode(null)
setHoveredNode(null)
onOpenDocument?.(documentId)
},
[onOpenDocument],
)
const documentNavigationProps = canOpenOnEnter
? {
tabIndex: 0,
role: "region" as const,
"aria-label":
"Memory graph. Select a node, then press Enter to open its document.",
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
!selectedDocumentId ||
event.key !== "Enter" ||
event.defaultPrevented ||
event.repeat ||
event.nativeEvent.isComposing ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
)
return
if (
event.target !== event.currentTarget &&
!(event.target instanceof HTMLCanvasElement)
)
return
event.preventDefault()
event.stopPropagation()
handleOpenDocument(selectedDocumentId)
},
}
: {}
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
openDocumentOnEnter &&
(e.defaultPrevented ||
e.isComposing ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
(target instanceof Element &&
target.closest(
"input, textarea, select, [contenteditable], [role=textbox]",
)))
)
return
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,
openDocumentOnEnter,
])
// Arrow key navigation through nodes
const selectAndCenter = useCallback(
(nodeId: string) => {
stopFollowing()
setSelectedNode(nodeId)
const n = nodes.find((nd) => nd.id === nodeId)
if (n && viewportRef.current)
viewportRef.current.centerOn(
n.x,
n.y,
containerSize.width,
graphFitHeight,
)
},
[nodes, containerSize.width, graphFitHeight, stopFollowing],
)
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 (
openDocumentOnEnter &&
(e.defaultPrevented ||
e.isComposing ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey ||
(target instanceof Element &&
target.closest(
"input, textarea, select, [contenteditable], [role=textbox]",
)))
)
return
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,
openDocumentOnEnter,
])
// 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)
}
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)
simulationRef.current?.coolDown()
}
}, [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: variant === "console" ? colors.bg : "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: variant === "console" ? colors.bg : "transparent",
backgroundImage:
variant === "console"
? `radial-gradient(circle, ${colors.dotColor ?? colors.textMuted} 0.5px, transparent 0.5px)`
: undefined,
backgroundSize: variant === "console" ? "16px 16px" : undefined,
}
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}
{...documentNavigationProps}
onPointerDownCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) {
stopFollowing()
if (canOpenOnEnter)
containerRef.current?.focus({ preventScroll: true })
}
}}
onWheelCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) stopFollowing()
}}
>
{hasContainerSize && (
<GraphCanvas
appearance={appearance}
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}
openDocumentShortcut={
canOpenOnEnter && selectedDocumentId ? "Enter" : undefined
}
showDocumentTitle={appearance?.documentNodes === "stacked"}
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>
)}
{showMinimap &&
!isCompactViewport &&
containerSize.width > 0 &&
nodes.length > 0 && (
<div
aria-hidden="true"
style={{
position: "absolute",
bottom: 16,
right: 16,
zIndex: 20,
}}
>
<Minimap
nodes={nodes}
colors={colors}
viewportRef={viewportRef}
canvasWidth={containerSize.width}
canvasHeight={containerSize.height}
simulation={simulation}
/>
</div>
)}
</div>
</div>
)
}