fix graph styling and initial layout (#1684)

**Before:** Console lost its theme and dots. Incoming nodes needed a drag to reorganize, fit missed later pages, and clicks or drag release could leave the layout moving.

**After:** Restore themed rendering with configurable dots. Automatically settle and fit incoming nodes, keep clicks from reheating forces, and cool the layout after drag release.

**Checked:** Package types/build and Console build linked to this package.

Console companion: [mono#3295](https://github.com/supermemoryai/mono/pull/3295).
This commit is contained in:
Prasanna721 2026-09-18 21:18:33 +00:00
parent b26e917a83
commit 2a6dcda7f6
13 changed files with 346 additions and 135 deletions

View file

@ -53,7 +53,7 @@ function App() {
- **Relationship visualization** - Edges show document similarity and memory version chains
- **Space filtering** - Filter by workspace or view all memories
- **Two variants** - Full-featured console mode or embedded consumer mode
- **Pagination support** - Load more documents on demand
- **Pagination support** - Keep the initial graph fitted as pages arrive; manual pan, zoom, or selection takes control of the view
- **TypeScript support** - Full type definitions included
## Essential Props
@ -67,6 +67,12 @@ function App() {
| `loadMoreDocuments` | `() => Promise<void>` | Function to load more data |
| `highlightDocumentIds` | `string[]` | IDs of documents to highlight |
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.
Set `colors.dotColor` or the `--graph-dot` CSS variable to style the dot grid independently of text. When neither is set, the grid uses `textMuted`.
For paginated initial loading, pass `hasMore` and `isLoadingMore` alongside `documents`. In both variants, new batches gradually warm the force layout from the existing node positions. Initial and appended nodes relax until their movement stays low, then cool automatically; a tick limit bounds settling for layouts that keep drifting. The initial view smoothly follows the changing bounds until loading and settling finish. Manual interaction immediately cancels automatic camera movement. Clicking a node selects it without restarting the forces; dragging warms the layout until release, including release outside the canvas. Changing the document selection starts a new fit; the Fit control remains available at any time. The existing static layout safeguard for more than 6,000 nodes remains in place.
## Documentation
Full documentation available at [docs.supermemory.ai](https://docs.supermemory.ai):

View file

@ -23,6 +23,11 @@ export class InputHandler {
private posHistory: Array<{ x: number; y: number; t: number }> = []
private draggingNode: GraphNode | null = null
private pressedNode: GraphNode | null = null
private pressX = 0
private pressY = 0
private grabOffset = { x: 0, y: 0 }
private gestureWindow: Window | null = null
private didDrag = false
private currentHoveredId: string | null = null
@ -45,6 +50,8 @@ export class InputHandler {
private boundMouseDown: (e: MouseEvent) => void
private boundMouseMove: (e: MouseEvent) => void
private boundMouseUp: (e: MouseEvent) => void
private boundWindowMove: (e: MouseEvent) => void
private boundBlur: () => void
private boundWheel: (e: WheelEvent) => void
private boundClick: (e: MouseEvent) => void
private boundDblClick: (e: MouseEvent) => void
@ -67,6 +74,10 @@ export class InputHandler {
this.boundMouseDown = this.onMouseDown.bind(this)
this.boundMouseMove = this.onMouseMove.bind(this)
this.boundMouseUp = this.onMouseUp.bind(this)
this.boundWindowMove = (event) => {
if (event.target !== this.canvas) this.onMouseMove(event)
}
this.boundBlur = () => this.endMouseGesture(false)
this.boundWheel = this.onWheel.bind(this)
this.boundClick = this.onClick.bind(this)
this.boundDblClick = this.onDblClick.bind(this)
@ -77,7 +88,6 @@ export class InputHandler {
canvas.addEventListener("mousedown", this.boundMouseDown)
canvas.addEventListener("mousemove", this.boundMouseMove)
canvas.addEventListener("mouseup", this.boundMouseUp)
canvas.addEventListener("click", this.boundClick)
canvas.addEventListener("dblclick", this.boundDblClick)
canvas.addEventListener("wheel", this.boundWheel, { passive: false })
@ -98,10 +108,10 @@ export class InputHandler {
}
destroy(): void {
this.endMouseGesture(false)
const c = this.canvas
c.removeEventListener("mousedown", this.boundMouseDown)
c.removeEventListener("mousemove", this.boundMouseMove)
c.removeEventListener("mouseup", this.boundMouseUp)
c.removeEventListener("click", this.boundClick)
c.removeEventListener("dblclick", this.boundDblClick)
c.removeEventListener("wheel", this.boundWheel)
@ -117,12 +127,32 @@ export class InputHandler {
return this.draggingNode
}
syncNodes(nodes: Map<string, GraphNode>): void {
if (this.pressedNode) {
this.pressedNode = nodes.get(this.pressedNode.id) ?? null
}
if (!this.draggingNode) return
const current = nodes.get(this.draggingNode.id)
if (!current) {
this.endMouseGesture(false)
} else if (current !== this.draggingNode) {
current.x = current.fx = this.draggingNode.x
current.y = current.fy = this.draggingNode.y
this.draggingNode.fx = null
this.draggingNode.fy = null
this.draggingNode = current
}
}
private canvasXY(e: MouseEvent): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
private onMouseDown(e: MouseEvent): void {
if (e.button !== 0) return
this.endMouseGesture(false)
this.viewport.cancelAnimation()
const { x, y } = this.canvasXY(e)
const world = this.viewport.screenToWorld(x, y)
const node = this.spatialIndex.queryPoint(world.x, world.y)
@ -131,13 +161,16 @@ export class InputHandler {
this.lastMouseY = y
this.posHistory = [{ x, y, t: performance.now() }]
this.didDrag = false
this.pressX = x
this.pressY = y
this.gestureWindow = this.canvas.ownerDocument.defaultView
this.gestureWindow?.addEventListener("mousemove", this.boundWindowMove)
this.gestureWindow?.addEventListener("mouseup", this.boundMouseUp)
this.gestureWindow?.addEventListener("blur", this.boundBlur)
if (node) {
this.draggingNode = node
node.fx = node.x
node.fy = node.y
this.callbacks.onNodeDragStart(node.id, node)
this.canvas.style.cursor = "grabbing"
this.pressedNode = node
this.grabOffset = { x: world.x - node.x, y: world.y - node.y }
} else {
this.isPanning = true
this.canvas.style.cursor = "grabbing"
@ -146,13 +179,18 @@ export class InputHandler {
private onMouseMove(e: MouseEvent): void {
const { x, y } = this.canvasXY(e)
if (this.pressedNode) {
if (Math.hypot(x - this.pressX, y - this.pressY) < 4) return
this.draggingNode = this.pressedNode
this.pressedNode = null
this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode)
this.canvas.style.cursor = "grabbing"
}
if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.fx = world.x
this.draggingNode.fy = world.y
this.draggingNode.x = world.x
this.draggingNode.y = world.y
this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x
this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y
this.didDrag = true
this.callbacks.onRequestRender()
return
@ -186,6 +224,15 @@ export class InputHandler {
}
private onMouseUp(_e: MouseEvent): void {
this.endMouseGesture(true)
}
private endMouseGesture(withInertia: boolean): void {
this.gestureWindow?.removeEventListener("mousemove", this.boundWindowMove)
this.gestureWindow?.removeEventListener("mouseup", this.boundMouseUp)
this.gestureWindow?.removeEventListener("blur", this.boundBlur)
this.gestureWindow = null
this.pressedNode = null
if (this.draggingNode) {
this.draggingNode.fx = null
this.draggingNode.fy = null
@ -198,7 +245,7 @@ export class InputHandler {
if (this.isPanning) {
this.isPanning = false
if (this.posHistory.length >= 2) {
if (withInertia && this.posHistory.length >= 2) {
const newest = this.posHistory[this.posHistory.length - 1]
const oldest = this.posHistory[0]
if (!newest || !oldest) return

View file

@ -728,8 +728,18 @@ function drawDocumentNode(
sx + half,
sy + half,
)
grad.addColorStop(0, mixHexColors(colors.docFill, clusterColor, 0.1))
grad.addColorStop(1, mixHexColors(colors.docFill, clusterColor, 0.22))
grad.addColorStop(
0,
node.clusterColor
? mixHexColors(colors.docFill, clusterColor, 0.1)
: colors.docFill,
)
grad.addColorStop(
1,
node.clusterColor
? mixHexColors(colors.docFill, clusterColor, 0.22)
: colors.docFill,
)
ctx.fillStyle = grad
ctx.strokeStyle =
@ -750,14 +760,23 @@ function drawDocumentNode(
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = mixHexColors(colors.docInnerFill, clusterColor, 0.08)
ctx.fillStyle = node.clusterColor
? mixHexColors(colors.docInnerFill, clusterColor, 0.08)
: colors.docInnerFill
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
drawDocIcon(ctx, sx, sy, iconSize, docType || "text", clusterColor)
drawDocIcon(
ctx,
sx,
sy,
iconSize,
docType || "text",
node.clusterColor ? clusterColor : colors.iconColor,
)
}
function drawMemoryNode(

View file

@ -6,6 +6,7 @@ export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
private holdingHeat = false
init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy()
@ -72,7 +73,7 @@ export class ForceSimulation {
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
this.stop()
} else {
this.sim.alphaTarget(0).restart()
this.settle()
}
} catch (e) {
console.error("ForceSimulation.init failed:", e)
@ -89,22 +90,62 @@ export class ForceSimulation {
}
reheat(): void {
this.holdingHeat = true
this.sim?.on("tick.settle", null)
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}
settle(): void {
const sim = this.sim
if (!sim || this.holdingHeat) return
let ticks = 0
let stableTicks = 0
sim
.alphaTarget(FORCE_CONFIG.alphaTarget)
.on("tick.settle", () => {
let squaredVelocity = 0
let maxSquaredVelocity = 0
const nodes = sim.nodes()
for (const node of nodes) {
const velocity = (node.vx ?? 0) ** 2 + (node.vy ?? 0) ** 2
squaredVelocity += velocity
maxSquaredVelocity = Math.max(maxSquaredVelocity, velocity)
}
const settled =
sim.alpha() >= FORCE_CONFIG.alphaTarget * 0.9 &&
squaredVelocity / Math.max(1, nodes.length) <=
FORCE_CONFIG.settleMeanVelocity ** 2 &&
maxSquaredVelocity <= FORCE_CONFIG.settleMaxVelocity ** 2
stableTicks = settled ? stableTicks + 1 : 0
if (
++ticks >= FORCE_CONFIG.settleMaxTicks ||
stableTicks >= FORCE_CONFIG.settleStableTicks
) {
sim.alphaTarget(0).on("tick.settle", null)
}
})
.restart()
}
coolDown(): void {
this.sim?.alphaTarget(0)
this.holdingHeat = false
this.sim?.alphaTarget(0).on("tick.settle", null)
}
stop(): void {
this.sim?.alpha(0).alphaTarget(0).stop()
this.coolDown()
this.sim?.alpha(0).stop()
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
return (
Math.max(this.sim?.alpha() ?? 0, this.sim?.alphaTarget() ?? 0) >
FORCE_CONFIG.alphaMin
)
}
destroy(): void {
this.holdingHeat = false
if (this.sim) {
this.sim.stop()
this.sim = null

View file

@ -43,12 +43,21 @@ export class ViewportState {
}
pan(dx: number, dy: number): void {
this.cancelAnimation()
this.panX += dx
this.panY += dy
this.targetPanX = null
this.targetPanY = null
}
cancelAnimation(): void {
this.velocityX = 0
this.velocityY = 0
this.targetZoom = this.zoom
this.targetPanX = null
this.targetPanY = null
}
releaseWithVelocity(vx: number, vy: number): void {
this.velocityX = vx
this.velocityY = vy
@ -72,6 +81,7 @@ export class ViewportState {
nodes: Array<{ x: number; y: number; size: number }>,
width: number,
height: number,
{ animate = true }: { animate?: boolean } = {},
): void {
const fit = computeFit(nodes, width, height)
if (!fit) return
@ -83,6 +93,12 @@ export class ViewportState {
this.zoomAnchorY = height / 2
this.targetPanX = width / 2 - cx * this.targetZoom
this.targetPanY = height / 2 - cy * this.targetZoom
if (!animate) {
this.zoom = this.targetZoom
this.panX = this.targetPanX
this.panY = this.targetPanY
this.cancelAnimation()
}
}
setMinZoomForNodes(
@ -99,7 +115,6 @@ export class ViewportState {
ViewportState.ABSOLUTE_MIN_ZOOM,
ViewportState.DEFAULT_MIN_ZOOM,
)
this.zoom = clamp(this.zoom, this.minZoom, ViewportState.MAX_ZOOM)
this.targetZoom = clamp(
this.targetZoom,
this.minZoom,
@ -132,7 +147,7 @@ export class ViewportState {
}
const zoomDiff = this.targetZoom - this.zoom
if (Math.abs(zoomDiff) > 0.001) {
if (Math.abs(zoomDiff) > this.zoom * 0.001) {
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
this.zoom += zoomDiff * this.zoomSpring
this.panX = this.zoomAnchorX - world.x * this.zoom

View file

@ -99,6 +99,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
const map = nodeMapRef.current
map.clear()
for (const n of nodes) map.set(n.id, n)
inputRef.current?.syncNodes(map)
spatialRef.current.rebuild(nodes)
renderNeeded.current = true
}, [nodes])

View file

@ -320,6 +320,7 @@ export const Legend = memo(function Legend({
.filter(Boolean),
).size
const updateNodeCount = nodes.filter(isUpdateMemoryNode).length
const hasClusterColors = nodes.some((node) => node.clusterColor)
const outerStyle: React.CSSProperties = {
overflow: "hidden",
@ -571,22 +572,26 @@ export const Legend = memo(function Legend({
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={sectionLabelStyle}>Color</span>
<div style={statusRowStyle}>
<ClusterSwatches />
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={edgeLabelStyle}>Cluster</span>
<span style={detailTextStyle}>
Same document or connected memory group
</span>
<span style={sectionLabelStyle}>
{hasClusterColors ? "Color" : "Clusters"}
</span>
{hasClusterColors && (
<div style={statusRowStyle}>
<ClusterSwatches />
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={edgeLabelStyle}>Cluster</span>
<span style={detailTextStyle}>
Same document or connected memory group
</span>
</div>
</div>
</div>
)}
<div style={rowStyle}>
<span style={detailTextStyle}>Visible clusters</span>
<span style={countStyle}>{clusterCount}</span>

View file

@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
@ -8,6 +15,7 @@ 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,
GraphThemeColors,
@ -116,6 +124,7 @@ export function MemoryGraph({
containerSize.width,
containerSize.height,
colors,
variant === "console" ? "theme" : "cluster",
)
const isCompactViewport = containerSize.width > 0 && containerSize.width < 640
const graphFitHeight = isCompactViewport
@ -131,10 +140,8 @@ export function MemoryGraph({
// 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(() => {
useLayoutEffect(() => {
if (nodes.length === 0) {
simulationRef.current?.destroy()
simulationRef.current = null
@ -172,7 +179,7 @@ export function MemoryGraph({
} else if (idsChanged && isAppendOnly) {
prevSimIdsRef.current = currentIds
simulationRef.current.update(nodes, edges)
simulationRef.current.stop()
simulationRef.current.settle()
} else {
simulationRef.current.update(nodes, edges)
}
@ -195,81 +202,17 @@ export function MemoryGraph({
)
}, [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)
}
}
}, [
const stopFollowing = useInitialGraphFit({
documents: limitedDocuments,
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])
viewportRef,
simulationRef,
width: containerSize.width,
height: hasContainerSize ? graphFitHeight : 0,
isLoading: externalIsLoading,
isLoadingMore,
hasMore,
})
// Container resize observer
useEffect(() => {
@ -375,11 +318,18 @@ export function MemoryGraph({
// 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])
}, [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) {
@ -392,19 +342,21 @@ export function MemoryGraph({
containerSize.width,
graphFitHeight,
)
}, [nodes, 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])
}, [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])
}, [containerSize.width, graphFitHeight, stopFollowing])
// Wrap onOpenDocument to dismiss the popover before opening the modal.
// Without this, the popover overlay stays mounted on top of the
@ -460,6 +412,7 @@ export function MemoryGraph({
// 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)
@ -470,7 +423,7 @@ export function MemoryGraph({
graphFitHeight,
)
},
[nodes, containerSize.width, graphFitHeight],
[nodes, containerSize.width, graphFitHeight, stopFollowing],
)
const navigateUp = useCallback(() => {
@ -613,7 +566,6 @@ export function MemoryGraph({
if (!isSlideshowActive || nodes.length === 0) {
if (!isSlideshowActive) {
setSelectedNode(null)
simulationRef.current?.coolDown()
}
return
}
@ -653,6 +605,7 @@ export function MemoryGraph({
return () => {
clearInterval(interval)
if (coolDownTimer) clearTimeout(coolDownTimer)
simulationRef.current?.coolDown()
}
}, [isSlideshowActive, nodes.length])
@ -692,7 +645,7 @@ export function MemoryGraph({
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
backgroundColor: variant === "console" ? colors.bg : "transparent",
borderRadius: 12,
}
@ -718,7 +671,12 @@ export function MemoryGraph({
height: "100%",
borderRadius: 12,
overflow: "hidden",
backgroundColor: "transparent",
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 = {
@ -767,7 +725,16 @@ export function MemoryGraph({
<div style={emptyStateStyle}>{children}</div>
)}
<div style={canvasContainerStyle} ref={containerRef}>
<div
style={canvasContainerStyle}
ref={containerRef}
onPointerDownCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) stopFollowing()
}}
onWheelCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) stopFollowing()
}}
>
{hasContainerSize && (
<GraphCanvas
colors={colors}

View file

@ -44,6 +44,10 @@ export const FORCE_CONFIG = {
alphaMin: 0.001,
velocityDecay: 0.45,
alphaTarget: 0.3,
settleMeanVelocity: 1,
settleMaxVelocity: 3,
settleStableTicks: 12,
settleMaxTicks: 360,
preSettleTicks: 150,
densePreSettleTicks: 12,
}

View file

@ -461,6 +461,7 @@ export function useGraphData(
canvasWidth: number,
canvasHeight: number,
colors: GraphThemeColors,
colorMode: "theme" | "cluster" = "cluster",
) {
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
@ -510,6 +511,7 @@ export function useGraphData(
for (let docIdx = 0; docIdx < docCount; docIdx++) {
const doc = documents[docIdx]
const docCluster = getDocumentClusterAssignment(doc, clusterAssignments)
const docColor = colorMode === "cluster" ? docCluster.color : null
const angle = docIdx * goldenAngle
const radius = spiralScale * Math.sqrt((docIdx + 1) / docCount)
const initialX = cx + Math.cos(angle) * radius
@ -531,9 +533,9 @@ export function useGraphData(
docNode = {
...previousDocNode,
data: docData,
borderColor: docCluster.color,
borderColor: docColor ?? colors.docStroke,
clusterKey: docCluster.key,
clusterColor: docCluster.color,
clusterColor: docColor,
isDragging: draggingNodeId === doc.id,
}
} else {
@ -556,9 +558,9 @@ export function useGraphData(
y: appendPosition?.y ?? initialY,
data: docData,
size: 50,
borderColor: docCluster.color,
borderColor: docColor ?? colors.docStroke,
clusterKey: docCluster.key,
clusterColor: docCluster.color,
clusterColor: docColor,
isHovered: false,
isDragging: false,
}
@ -581,15 +583,16 @@ export function useGraphData(
content: mem.memory,
}
const cluster = clusterAssignments.get(mem.id)
const memoryColor = colorMode === "cluster" ? cluster?.color : undefined
let memNode: GraphNode
if (previousMemNode) {
memNode = {
...previousMemNode,
data: memData,
borderColor: getMemoryNodeBorderColor(mem, colors, cluster?.color),
borderColor: getMemoryNodeBorderColor(mem, colors, memoryColor),
clusterKey: cluster?.key ?? null,
clusterColor: cluster?.color ?? null,
clusterColor: memoryColor ?? null,
isDragging: draggingNodeId === mem.id,
}
} else {
@ -601,9 +604,9 @@ export function useGraphData(
y: docNode.y + memOffset.y,
data: memData,
size: 36,
borderColor: getMemoryNodeBorderColor(mem, colors, cluster?.color),
borderColor: getMemoryNodeBorderColor(mem, colors, memoryColor),
clusterKey: cluster?.key ?? null,
clusterColor: cluster?.color ?? null,
clusterColor: memoryColor ?? null,
isHovered: false,
isDragging: false,
}
@ -618,7 +621,7 @@ export function useGraphData(
}
return { nodes: result, cache: nextCache }
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors, colorMode])
useEffect(() => {
nodeCache.current = graphData.cache

View file

@ -13,6 +13,7 @@ function readCssVar(name: string, fallback: string): string {
function resolveColors(): GraphThemeColors {
return {
bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg),
dotColor: readCssVar("--graph-dot", "") || undefined,
docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill),
docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke),
docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill),

View file

@ -0,0 +1,101 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react"
import type { ViewportState } from "../canvas/viewport"
import type { ForceSimulation } from "../canvas/simulation"
import type { GraphApiDocument, GraphNode } from "../types"
export function useInitialGraphFit({
documents,
nodes,
viewportRef,
simulationRef,
width,
height,
isLoading,
isLoadingMore,
hasMore,
}: {
documents: GraphApiDocument[]
nodes: GraphNode[]
viewportRef: RefObject<ViewportState | null>
simulationRef?: RefObject<ForceSimulation | null>
width: number
height: number
isLoading: boolean
isLoadingMore: boolean
hasMore: boolean
}) {
const session = useRef({
documentIds: new Set<string>(),
following: true,
userMoved: false,
fitted: false,
width: 0,
height: 0,
})
const stopFollowing = useCallback(() => {
session.current.following = false
session.current.userMoved = true
viewportRef.current?.cancelAnimation()
}, [viewportRef])
useLayoutEffect(() => {
const current = session.current
const documentIds = new Set(documents.map((document) => document.id))
const replaced = [...current.documentIds].some((id) => !documentIds.has(id))
if (nodes.length === 0 || replaced) {
current.following = true
current.userMoved = false
current.fitted = false
}
if (
(width !== current.width || height !== current.height) &&
!current.userMoved
) {
current.following = true
}
current.documentIds = documentIds
current.width = width
current.height = height
if (
!current.following ||
isLoading ||
nodes.length === 0 ||
width <= 0 ||
height <= 0
)
return
let timer: ReturnType<typeof setTimeout> | undefined
const fit = (animate = true) => {
const viewport = viewportRef.current
if (!viewport || !session.current.following) return
viewport.setMinZoomForNodes(nodes, width, height)
viewport.fitToNodes(nodes, width, height, { animate })
if (simulationRef?.current?.isActive()) {
timer = setTimeout(fit, 200)
} else if (!hasMore && !isLoadingMore) {
session.current.following = false
}
}
if (!current.fitted) {
current.fitted = true
fit(false)
} else {
timer = setTimeout(fit, 100)
}
return () => clearTimeout(timer)
}, [
documents,
nodes,
viewportRef,
simulationRef,
width,
height,
isLoading,
isLoadingMore,
hasMore,
])
return stopFollowing
}

View file

@ -105,6 +105,7 @@ export interface GraphEdge {
export interface GraphThemeColors {
bg: string
dotColor?: string
docFill: string
docStroke: string
docInnerFill: string