supermemory/packages/memory-graph/src/components/memory-graph.tsx
abhay-codes07 a0179cd1eb
feat(memory-graph): add in-graph node search
The graph had no way to find a specific document or memory once the node
count grew past a screenful. You could pan, zoom, fit, and arrow-key
between neighbours, but locating "the Berlin memory" in a few hundred
nodes meant eyeballing the canvas.

Add a search box to the bottom-left control stack that finds nodes by
text and steps through the matches:

- searchNodes(nodes, query): a pure, case-insensitive substring matcher
  over document title/summary/type and memory text/content. Results are
  ranked (title/memory prefix, then substring, then supporting copy) with
  input order as a stable tiebreaker, so ordering never depends on where
  the force simulation happened to drop a node. Exported for advanced use.
- SearchControl: an input styled like the existing navigation controls,
  with a live "current/total" counter, prev/next steppers, and a clear
  button. Enter / Shift+Enter cycle matches, Escape clears.
- Wiring in MemoryGraph: the focused match is selected and centered
  (reusing the same selectAndCenter path as arrow-key navigation), and the
  documents behind all matches are highlighted (memory matches highlight
  their parent document) by merging into the existing highlightDocumentIds
  pipeline. Press "/" to focus the box, consistent with the existing
  single-key shortcuts.

The box is on by default; embedders can hide it with showSearch={false}
or relabel it with searchPlaceholder. Existing keyboard handlers already
ignore events originating from inputs, so typing a query never triggers
the fit/zoom/arrow shortcuts.

Tested: 14 new tests (searchNodes ranking/empty-query/null-field cases;
SearchControl typing, Enter/Shift+Enter/Escape, clear, counter, disabled
states). Full package suite green (202 tests), tsc --noEmit clean, biome
clean, vite build succeeds.
2026-07-18 13:51:54 +05:30

923 lines
26 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,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
import { searchNodes } from "../utils/node-search"
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 { SearchControl } from "./search-control"
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,
showSearch = true,
searchPlaceholder,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides],
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
const resolvedColors = useGraphTheme(colorOverrides)
const colors = useMemo<GraphThemeColors>(
() =>
colorOverrides
? { ...resolvedColors, ...colorOverrides }
: resolvedColors,
[resolvedColors, 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)
const [searchQuery, setSearchQuery] = useState("")
const [searchIndex, setSearchIndex] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
// 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 "/":
if (showSearch) {
e.preventDefault()
searchInputRef.current?.focus()
}
break
case "Escape":
setSelectedNode(null)
break
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut, showSearch])
// 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],
)
// Node search — find documents/memories by text and step through matches.
const searchMatches = useMemo(
() => (showSearch ? searchNodes(nodes, searchQuery) : []),
[showSearch, nodes, searchQuery],
)
const matchCount = searchMatches.length
// Keep the focused index in range as the match set changes (typing, data
// reloads, pagination).
useEffect(() => {
setSearchIndex((i) => (matchCount === 0 ? 0 : Math.min(i, matchCount - 1)))
}, [matchCount])
const currentMatchId = searchMatches[searchIndex]?.id ?? null
// Focus (select + center) the current match whenever it changes.
useEffect(() => {
if (currentMatchId) selectAndCenter(currentMatchId)
}, [currentMatchId, selectAndCenter])
// Highlight the documents behind every match (memory matches highlight their
// parent document) so the user can see where results sit, merged with any
// externally-supplied highlights.
const effectiveHighlightIds = useMemo(() => {
if (!highlightsVisible) return []
if (matchCount === 0) return highlightDocumentIds
const ids = new Set(highlightDocumentIds)
for (const node of searchMatches) {
if (node.type === "document") ids.add(node.id)
else if ("documentId" in node.data) ids.add(node.data.documentId)
}
return [...ids]
}, [highlightsVisible, highlightDocumentIds, searchMatches, matchCount])
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value)
setSearchIndex(0)
}, [])
const handleSearchNext = useCallback(() => {
if (matchCount === 0) return
setSearchIndex((i) => (i + 1) % matchCount)
}, [matchCount])
const handleSearchPrev = useCallback(() => {
if (matchCount === 0) return
setSearchIndex((i) => (i - 1 + matchCount) % matchCount)
}, [matchCount])
const handleSearchClear = useCallback(() => {
setSearchQuery("")
setSearchIndex(0)
setSelectedNode(null)
}, [])
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={effectiveHighlightIds}
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}>
{showSearch && nodes.length > 0 && (
<SearchControl
colors={colors}
compact={isCompactViewport}
currentIndex={searchIndex}
inputRef={searchInputRef}
matchCount={matchCount}
onClear={handleSearchClear}
onNext={handleSearchNext}
onPrev={handleSearchPrev}
onQueryChange={handleSearchChange}
placeholder={searchPlaceholder}
query={searchQuery}
/>
)}
<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>
)
}