Render dense memory graphs without cluster mode

This commit is contained in:
Ishaan Gupta 2026-05-30 17:21:31 +05:30
parent 0cb7acf33b
commit 87eef93fd1
10 changed files with 121 additions and 801 deletions

View file

@ -13,8 +13,6 @@ import { dmSansClassName } from "@/lib/fonts"
import { ShareModal } from "./share-modal"
import { shareParam } from "@/lib/search-params"
const GRAPH_MAX_NODES = 15000
export const GraphLayoutView = memo(function GraphLayoutView({
onOpenDocument,
}: {
@ -45,7 +43,6 @@ export const GraphLayoutView = memo(function GraphLayoutView({
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible
maxNodes={GRAPH_MAX_NODES}
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
/>

View file

@ -9,7 +9,7 @@ import type {
MemoryRelation,
} from "@supermemory/memory-graph"
const PAGE_SIZE = 100
const PAGE_SIZE = 500
interface UseGraphApiOptions {
containerTags?: string[]
@ -190,9 +190,9 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
}, [data])
useEffect(() => {
if (!enabled || hasDocumentIds || maxNodes == null) return
if (!hasNextPage || isFetchingNextPage || loadedNodeCount >= maxNodes)
return
if (!enabled || hasDocumentIds) return
if (!hasNextPage || isFetchingNextPage) return
if (maxNodes != null && loadedNodeCount >= maxNodes) return
fetchNextPage()
}, [
enabled,

View file

@ -1,133 +0,0 @@
import { describe, expect, it } from "vitest"
import { buildClusterLevelGraph } from "../hooks/cluster-level-graph"
import type { GraphEdge, GraphNode, MemoryNodeData } from "../types"
function makeDocumentNode(index: number): GraphNode {
const id = `doc-${index}`
return {
id,
type: "document",
x: index * 120,
y: 0,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
size: 50,
borderColor: "#58C7E8",
clusterColor: "#58C7E8",
isHovered: false,
isDragging: false,
}
}
function makeMemoryNode(documentId: string, index: number): GraphNode {
const id = `${documentId}-mem-${index}`
const data: MemoryNodeData = {
id,
memory: id,
content: id,
documentId,
isStatic: false,
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
spaceId: "space",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
}
return {
id,
type: "memory",
x: index * 80,
y: 120,
data,
size: 36,
borderColor: "#58C7E8",
clusterColor: "#58C7E8",
isHovered: false,
isDragging: false,
}
}
function makeGraph(documentCount: number, memoriesPerDocument: number) {
const nodes: GraphNode[] = []
const edges: GraphEdge[] = []
for (let docIndex = 0; docIndex < documentCount; docIndex++) {
const doc = makeDocumentNode(docIndex)
nodes.push(doc)
for (let memIndex = 0; memIndex < memoriesPerDocument; memIndex++) {
const memory = makeMemoryNode(doc.id, memIndex)
nodes.push(memory)
edges.push({
id: `dm-${doc.id}-${memory.id}`,
source: doc.id,
target: memory.id,
edgeType: "derives",
visualProps: { opacity: 0.4, thickness: 1.2 },
})
}
}
return { nodes, edges }
}
describe("buildClusterLevelGraph", () => {
it("collapses dense raw graphs into aggregate cluster nodes", () => {
const graph = makeGraph(500, 2)
const clustered = buildClusterLevelGraph({
...graph,
enabled: true,
expandedClusterId: null,
revealRawGraph: false,
canvasWidth: 1200,
canvasHeight: 800,
})
expect(clustered.isClustered).toBe(true)
expect(clustered.nodes.length).toBeLessThan(graph.nodes.length)
expect(clustered.nodes.every((node) => node.type === "cluster")).toBe(true)
expect(clustered.nodes[0]?.data).toMatchObject({
documentCount: 50,
memoryCount: 100,
nodeCount: 150,
})
})
it("expands a selected cluster while keeping other groups collapsed", () => {
const graph = makeGraph(120, 2)
const collapsed = buildClusterLevelGraph({
...graph,
enabled: true,
expandedClusterId: null,
revealRawGraph: false,
canvasWidth: 1200,
canvasHeight: 800,
})
const clusterId = collapsed.nodes[0]?.id ?? null
const expanded = buildClusterLevelGraph({
...graph,
enabled: true,
expandedClusterId: clusterId,
revealRawGraph: false,
canvasWidth: 1200,
canvasHeight: 800,
})
expect(expanded.nodes.some((node) => node.type === "document")).toBe(true)
expect(expanded.nodes.some((node) => node.type === "memory")).toBe(true)
expect(expanded.nodes.some((node) => node.type === "cluster")).toBe(true)
})
})

View file

@ -1,5 +1,4 @@
import type {
ClusterNodeData,
DocumentNodeData,
GraphEdge,
GraphNode,
@ -22,6 +21,10 @@ const edgeBatches = new Map<string, PreparedEdge[]>()
const RELATION_LOD_ZOOM = 0.5
const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
const RELATION_LOD_DENSE_COUNT = 180
const DERIVES_LOD_ZOOM = 0.38
const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200
const DENSE_POINT_THRESHOLD = 25000
const DENSE_POINT_ZOOM = 0.42
function nodeMatchesDocumentHighlights(
node: GraphNode,
@ -29,11 +32,6 @@ function nodeMatchesDocumentHighlights(
): boolean {
if (highlightIds.size === 0) return false
if (node.type === "document") return highlightIds.has(node.id)
if (node.type === "cluster") {
return (node.data as ClusterNodeData).sampleDocumentIds.some((id) =>
highlightIds.has(id),
)
}
return highlightIds.has((node.data as MemoryNodeData).documentId)
}
@ -106,6 +104,16 @@ export function getRelationEdgeStride(
return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES)
}
function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number {
if (
zoom >= DERIVES_LOD_ZOOM ||
derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES
) {
return 1
}
return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES)
}
export function shouldDrawRelationEdge(
edgeId: string,
edgeType: string,
@ -115,6 +123,10 @@ export function shouldDrawRelationEdge(
return hashString(edgeId) % stride === 0
}
function shouldDrawSampledEdge(edgeId: string, stride: number): boolean {
return stride <= 1 || hashString(edgeId) % stride === 0
}
function applyRelationLevelOfDetail(
style: { color: string; width: number; opacity: number },
edgeType: string,
@ -204,7 +216,9 @@ function drawEdges(
(count, edge) => count + (edge.edgeType === "derives" ? 0 : 1),
0,
)
const derivesEdgeCount = edges.length - relationEdgeCount
const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom)
const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom)
const prepared: PreparedEdge[] = []
@ -221,10 +235,11 @@ function drawEdges(
const activeConnected = hoverConnected || selectedConnected
const shouldAlwaysDrawActiveUpdate =
edgeType === "updates" && activeConnected
const edgeStride = edgeType === "derives" ? derivesStride : relationStride
if (
!shouldAlwaysDrawActiveUpdate &&
!hasDim &&
!shouldDrawRelationEdge(edge.id, edgeType, relationStride)
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
@ -269,12 +284,12 @@ function drawEdges(
!shouldAlwaysDrawActiveUpdate &&
hasDim &&
!connected &&
!shouldDrawRelationEdge(edge.id, edgeType, relationStride)
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
const { style, glow } = applyRelationLevelOfDetail(
const edgeDetail = applyRelationLevelOfDetail(
edgeStyle(edge, colors),
edgeType,
relationEdgeCount,
@ -282,6 +297,21 @@ function drawEdges(
hasDim && connected,
edgeType === "updates" && hoverConnected,
)
let style = edgeDetail.style
let glow = edgeDetail.glow
if (edgeType === "derives" && derivesStride > 1 && !activeConnected) {
const zoomFactor = clampNumber(
viewport.zoom / DERIVES_LOD_ZOOM,
0.08,
0.32,
)
style = {
...style,
width: Math.max(0.35, style.width * 0.45),
opacity: style.opacity * zoomFactor,
}
glow = false
}
prepared.push({
startX: s.x + ux * sr,
@ -408,6 +438,17 @@ function drawNodes(
colors: GraphThemeColors,
): void {
const margin = 60
const densePointMode =
nodes.length > DENSE_POINT_THRESHOLD &&
viewport.zoom < DENSE_POINT_ZOOM &&
!state.selectedNodeId &&
state.highlightIds.size === 0
const pointDots: {
x: number
y: number
r: number
color: string
}[] = []
const memDots: {
x: number
y: number
@ -444,8 +485,16 @@ function drawNodes(
highlightFocus && !isSelected && !isHovered && !isHighlighted
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document" || node.type === "cluster") {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else if (densePointMode) {
pointDots.push({
x: screen.x,
y: screen.y,
r: Math.max(1.1, screenSize * 0.42),
color:
node.clusterColor || node.borderColor || colors.memStrokeDefault,
})
} else {
const md = node.data as MemoryNodeData
memDots.push({
@ -471,19 +520,7 @@ function drawNodes(
}
ctx.globalAlpha = alpha
if (node.type === "cluster") {
drawClusterNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
} else if (node.type === "document") {
if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
@ -528,6 +565,19 @@ function drawNodes(
: 1
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
if (pointDots.length > 0) {
ctx.globalAlpha = dimAlpha * 0.78
for (const [color, batch] of groupByColor(pointDots)) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
}
}
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
@ -649,82 +699,6 @@ function drawNodes(
ctx.globalAlpha = 1
}
function drawClusterNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const data = node.data as ClusterNodeData
const radius = size * 0.5
const clusterColor = node.clusterColor ?? colors.accent
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = isSelected ? colors.accent : clusterColor
ctx.shadowBlur = isSelected ? 24 : 16
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const gradient = ctx.createRadialGradient(
sx - radius * 0.25,
sy - radius * 0.3,
radius * 0.1,
sx,
sy,
radius,
)
gradient.addColorStop(
0,
mixHexColors(colors.memFillHover, clusterColor, 0.36),
)
gradient.addColorStop(0.62, mixHexColors(colors.docFill, clusterColor, 0.24))
gradient.addColorStop(1, mixHexColors(colors.bg, clusterColor, 0.58))
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(sx, sy, radius, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = isSelected || isHighlighted ? colors.accent : clusterColor
ctx.lineWidth = isSelected || isHighlighted ? 3 : isHovered ? 2.5 : 1.8
ctx.stroke()
ctx.globalAlpha *= 0.45
ctx.strokeStyle = clusterColor
ctx.lineWidth = Math.max(1, size * 0.018)
for (const scale of [0.68, 0.44]) {
ctx.beginPath()
ctx.arc(sx, sy, radius * scale, 0, Math.PI * 2)
ctx.stroke()
}
ctx.globalAlpha /= 0.45
if (isSelected || isHovered) {
ctx.restore()
}
if (size >= 58) {
const count = data.documentCount.toLocaleString()
const label = data.memoryCount.toLocaleString()
ctx.save()
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = colors.textPrimary
ctx.font = `600 ${Math.max(12, Math.min(18, size * 0.18))}px system-ui, sans-serif`
ctx.fillText(count, sx, sy - Math.max(5, size * 0.07))
ctx.fillStyle = colors.textSecondary
ctx.font = `500 ${Math.max(9, Math.min(12, size * 0.11))}px system-ui, sans-serif`
ctx.fillText(`${label} mem`, sx, sy + Math.max(9, size * 0.1))
ctx.restore()
}
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
@ -944,7 +918,7 @@ function drawGlow(
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory" | "cluster",
nodeType: "document" | "memory",
colors: GraphThemeColors,
isHoverOnly = false,
): void {
@ -960,9 +934,6 @@ function drawGlow(
const half = glowSize * 0.5
const r = 8 * (glowSize / 50)
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
} else if (nodeType === "cluster") {
ctx.beginPath()
ctx.arc(sx, sy, size * 0.5 * scale, 0, Math.PI * 2)
} else {
drawHexagon(ctx, sx, sy, size * 0.5 * scale)
}

View file

@ -2,7 +2,7 @@ import * as d3 from "d3-force"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
@ -50,12 +50,11 @@ export class ForceSimulation {
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) => {
if (d.type === "document")
return FORCE_CONFIG.collisionRadius.document
if (d.type === "cluster") return d.size * 0.62
return FORCE_CONFIG.collisionRadius.memory
})
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.strength(FORCE_CONFIG.collisionStrength),
)

View file

@ -1,10 +1,5 @@
import { memo, useState } from "react"
import type {
ClusterNodeData,
GraphEdge,
GraphNode,
GraphThemeColors,
} from "../types"
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
interface LegendProps {
nodes?: GraphNode[]
@ -299,32 +294,19 @@ export const Legend = memo(function Legend({
const [isExpanded, setIsExpanded] = useState(false)
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
const clusterNodes = nodes.filter((n) => n.type === "cluster")
const clusterMemoryCount = clusterNodes.reduce(
(total, node) => total + (node.data as ClusterNodeData).memoryCount,
0,
)
const clusterDocumentCount = clusterNodes.reduce(
(total, node) => total + (node.data as ClusterNodeData).documentCount,
0,
)
const memoryCount =
nodes.filter((n) => n.type === "memory").length + clusterMemoryCount
const documentCount =
nodes.filter((n) => n.type === "document").length + clusterDocumentCount
const memoryCount = nodes.filter((n) => n.type === "memory").length
const documentCount = nodes.filter((n) => n.type === "document").length
const connectionCount = edges.length
const derivesCount = countEdgesByType(edges, "derives")
const updatesCount = countEdgesByType(edges, "updates")
const extendsCount = countEdgesByType(edges, "extends")
const activeUpdateCount = getActiveUpdateCount(edges, hoveredNode)
const clusterCount =
clusterNodes.length ||
new Set(
nodes
.filter((node) => node.type === "memory")
.map((node) => node.clusterKey)
.filter(Boolean),
).size
const clusterCount = new Set(
nodes
.filter((node) => node.type === "memory")
.map((node) => node.clusterKey)
.filter(Boolean),
).size
const updateNodeCount = nodes.filter(isUpdateMemoryNode).length
const outerStyle: React.CSSProperties = {

View file

@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ForceSimulation } from "../canvas/simulation"
import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
} from "../canvas/simulation"
import { VersionChainIndex } from "../canvas/version-chain"
import type { ViewportState } from "../canvas/viewport"
import { buildClusterLevelGraph } from "../hooks/cluster-level-graph"
import { useGraphData } from "../hooks/use-graph-data"
import { useGraphTheme } from "../hooks/use-graph-theme"
import type {
@ -16,9 +18,6 @@ import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover"
const CLUSTER_MODE_NODE_THRESHOLD = 3000
const RAW_GRAPH_REVEAL_ZOOM = 0.62
export function MemoryGraph({
documents = [],
isLoading: externalIsLoading = false,
@ -59,9 +58,6 @@ export function MemoryGraph({
// React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [expandedClusterId, setExpandedClusterId] = 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
@ -104,38 +100,13 @@ export function MemoryGraph({
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
const { nodes: rawNodes, edges: rawEdges } = useGraphData(
const { nodes, edges } = useGraphData(
hasContainerSize ? limitedDocuments : [],
null,
containerSize.width,
containerSize.height,
colors,
)
const revealRawGraph =
rawNodes.length <= CLUSTER_MODE_NODE_THRESHOLD ||
zoomDisplay / 100 >= RAW_GRAPH_REVEAL_ZOOM
const clusterGraph = useMemo(
() =>
buildClusterLevelGraph({
nodes: rawNodes,
edges: rawEdges,
enabled: rawNodes.length > CLUSTER_MODE_NODE_THRESHOLD,
expandedClusterId,
revealRawGraph,
canvasWidth: containerSize.width,
canvasHeight: containerSize.height,
}),
[
rawNodes,
rawEdges,
expandedClusterId,
revealRawGraph,
containerSize.width,
containerSize.height,
],
)
const nodes = clusterGraph.nodes
const edges = clusterGraph.edges
const isCompactViewport = containerSize.width > 0 && containerSize.width < 640
const graphFitHeight = isCompactViewport
? Math.max(containerSize.height - 170, 240)
@ -160,6 +131,14 @@ export function MemoryGraph({
}
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 =
@ -263,10 +242,6 @@ export function MemoryGraph({
if (nodes.length === 0) hasAutoFittedRef.current = false
}, [nodes.length])
useEffect(() => {
if (!clusterGraph.isClustered) setExpandedClusterId(null)
}, [clusterGraph.isClustered])
useEffect(() => {
if (isCompactViewport) {
hasAutoFittedRef.current = false
@ -314,32 +289,9 @@ export function MemoryGraph({
[],
)
const handleNodeClick = useCallback(
(id: string | null) => {
if (id === null) {
setSelectedNode(null)
setExpandedClusterId(null)
return
}
const node = nodes.find((n) => n.id === id)
if (node?.type === "cluster") {
setSelectedNode(null)
setHoveredNode(null)
setExpandedClusterId((prev) => (prev === id ? null : id))
viewportRef.current?.centerOn(
node.x,
node.y,
containerSize.width,
graphFitHeight,
)
return
}
setSelectedNode((prev) => (prev === id ? null : id))
},
[nodes, containerSize.width, graphFitHeight],
)
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
@ -556,12 +508,7 @@ export function MemoryGraph({
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "cluster") {
const clusters = nodes.filter((n) => n.type === "cluster")
const idx = clusters.findIndex((n) => n.id === selectedNode)
const next = clusters[(idx + 1) % clusters.length]
if (next) selectAndCenter(next.id)
} else if (node.type === "document") {
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]
@ -586,12 +533,7 @@ export function MemoryGraph({
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "cluster") {
const clusters = nodes.filter((n) => n.type === "cluster")
const idx = clusters.findIndex((n) => n.id === selectedNode)
const prev = clusters[(idx - 1 + clusters.length) % clusters.length]
if (prev) selectAndCenter(prev.id)
} else if (node.type === "document") {
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]

View file

@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import type {
ClusterNodeData,
DocumentNodeData,
GraphNode,
GraphThemeColors,
@ -324,7 +323,6 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
const isMemory = node.type === "memory"
const isCluster = node.type === "cluster"
const data = node.data
const hasChain = Boolean(versionChain && versionChain.length > 1)
@ -401,27 +399,18 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
const content = useMemo(() => {
if (isCluster) {
const cd = data as ClusterNodeData
return cd.summary || cd.title
}
if (isMemory) {
const md = data as MemoryNodeData
return md.memory || md.content || ""
}
const dd = data as DocumentNodeData
return dd.summary || dd.title || ""
}, [isCluster, isMemory, data])
}, [isMemory, data])
const docData = node.type === "document" ? (data as DocumentNodeData) : null
const clusterData = isCluster ? (data as ClusterNodeData) : null
const docData = !isMemory ? (data as DocumentNodeData) : null
// For document nodes, node.id IS the document ID
const documentId = isCluster
? null
: isMemory
? (data as MemoryNodeData).documentId
: node.id
const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id
const overlayStyle: React.CSSProperties = {
pointerEvents: "none",
@ -589,26 +578,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
)}
<div style={footerStyle}>
{clusterData ? (
<>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{clusterData.documentCount} documents
</span>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{clusterData.memoryCount} memories
</span>
</>
) : memoryMeta ? (
{memoryMeta ? (
<span
style={{
fontSize: 12,
@ -650,9 +620,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
</div>
<div style={idRowStyle}>
{isCluster ? (
<CopyableId colors={colors} label="Cluster" value={node.id} />
) : isMemory ? (
{isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
@ -677,7 +645,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : !isCluster) && (
{(isMemory ? hasChain : true) && (
<NavButton
colors={colors}
icon="↓"
@ -688,25 +656,13 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="→"
label={
isCluster
? "Next cluster"
: isMemory
? "Next memory"
: "Next document"
}
label={isMemory ? "Next memory" : "Next document"}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={
isCluster
? "Prev cluster"
: isMemory
? "Prev memory"
: "Prev document"
}
label={isMemory ? "Prev memory" : "Prev document"}
onClick={onNavigatePrev}
/>
</div>

View file

@ -1,382 +0,0 @@
import type {
ClusterNodeData,
GraphEdge,
GraphNode,
MemoryNodeData,
} from "../types"
const CLUSTER_TARGET_RAW_NODES = 150
const CLUSTER_MIN_SIZE = 68
const CLUSTER_MAX_SIZE = 142
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
export interface ClusterLevelGraph {
nodes: GraphNode[]
edges: GraphEdge[]
isClustered: boolean
clusterCount: number
rawNodeToClusterId: Map<string, string>
}
interface ClusterGroup {
id: string
key: string
color: string
rawNodes: GraphNode[]
documentCount: number
memoryCount: number
sampleDocumentIds: string[]
sampleMemoryIds: string[]
}
export function buildClusterLevelGraph({
nodes,
edges,
enabled,
expandedClusterId,
revealRawGraph,
canvasWidth,
canvasHeight,
}: {
nodes: GraphNode[]
edges: GraphEdge[]
enabled: boolean
expandedClusterId: string | null
revealRawGraph: boolean
canvasWidth: number
canvasHeight: number
}): ClusterLevelGraph {
if (!enabled || revealRawGraph || nodes.length === 0) {
return {
nodes,
edges,
isClustered: false,
clusterCount: 0,
rawNodeToClusterId: new Map(),
}
}
const groups = buildClusterGroups(nodes)
if (groups.length === 0) {
return {
nodes,
edges,
isClustered: false,
clusterCount: 0,
rawNodeToClusterId: new Map(),
}
}
const rawNodeToClusterId = new Map<string, string>()
for (const group of groups) {
for (const node of group.rawNodes) {
rawNodeToClusterId.set(node.id, group.id)
}
}
const visibleNodes: GraphNode[] = []
const visibleIds = new Set<string>()
const clusterNodeById = new Map<string, GraphNode>()
for (let index = 0; index < groups.length; index++) {
const group = groups[index]
if (!group) continue
const clusterNode = createClusterNode(
group,
index,
groups.length,
canvasWidth,
canvasHeight,
)
if (expandedClusterId === group.id) {
for (const rawNode of cloneExpandedNodes(group.rawNodes, clusterNode)) {
visibleNodes.push(rawNode)
visibleIds.add(rawNode.id)
}
continue
}
clusterNodeById.set(group.id, clusterNode)
visibleNodes.push(clusterNode)
visibleIds.add(clusterNode.id)
}
const visibleEdges = buildVisibleEdges(
edges,
visibleIds,
rawNodeToClusterId,
clusterNodeById,
)
return {
nodes: visibleNodes,
edges: visibleEdges,
isClustered: true,
clusterCount: groups.length,
rawNodeToClusterId,
}
}
function cloneExpandedNodes(
nodes: GraphNode[],
clusterNode: GraphNode,
): GraphNode[] {
if (nodes.length === 0) return []
let centerX = 0
let centerY = 0
for (const node of nodes) {
centerX += node.x
centerY += node.y
}
centerX /= nodes.length
centerY /= nodes.length
let maxDistance = 1
for (const node of nodes) {
const dx = node.x - centerX
const dy = node.y - centerY
maxDistance = Math.max(maxDistance, Math.sqrt(dx * dx + dy * dy))
}
const scale = Math.min(0.72, 620 / maxDistance)
return nodes.map((node) => ({
...node,
x: clusterNode.x + (node.x - centerX) * scale,
y: clusterNode.y + (node.y - centerY) * scale,
}))
}
function buildClusterGroups(nodes: GraphNode[]): ClusterGroup[] {
const memoriesByDocumentId = new Map<string, GraphNode[]>()
const documentNodes: GraphNode[] = []
const looseNodes: GraphNode[] = []
for (const node of nodes) {
if (node.type === "document") {
documentNodes.push(node)
continue
}
if (node.type === "memory") {
const documentId = (node.data as MemoryNodeData).documentId
const bucket = memoriesByDocumentId.get(documentId)
if (bucket) {
bucket.push(node)
} else {
memoriesByDocumentId.set(documentId, [node])
}
continue
}
looseNodes.push(node)
}
if (documentNodes.length === 0) return chunkLooseNodes(looseNodes)
const groups: ClusterGroup[] = []
let current: GraphNode[] = []
let currentDocuments = 0
let currentMemories = 0
const flush = () => {
if (current.length === 0) return
groups.push(
createGroup(groups.length, current, currentDocuments, currentMemories),
)
current = []
currentDocuments = 0
currentMemories = 0
}
for (const doc of documentNodes) {
const memories = memoriesByDocumentId.get(doc.id) ?? []
const docGroupSize = 1 + memories.length
if (
current.length > 0 &&
current.length + docGroupSize > CLUSTER_TARGET_RAW_NODES
) {
flush()
}
current.push(doc, ...memories)
currentDocuments++
currentMemories += memories.length
if (current.length >= CLUSTER_TARGET_RAW_NODES) flush()
}
flush()
if (looseNodes.length > 0) {
for (const looseGroup of chunkLooseNodes(looseNodes, groups.length)) {
groups.push(looseGroup)
}
}
return groups
}
function chunkLooseNodes(nodes: GraphNode[], offset = 0): ClusterGroup[] {
const groups: ClusterGroup[] = []
for (let i = 0; i < nodes.length; i += CLUSTER_TARGET_RAW_NODES) {
const chunk = nodes.slice(i, i + CLUSTER_TARGET_RAW_NODES)
groups.push(
createGroup(
offset + groups.length,
chunk,
chunk.filter((node) => node.type === "document").length,
chunk.filter((node) => node.type === "memory").length,
),
)
}
return groups
}
function createGroup(
index: number,
rawNodes: GraphNode[],
documentCount: number,
memoryCount: number,
): ClusterGroup {
const firstNode = rawNodes[0]
const key = `supercluster:${index}:${firstNode?.clusterKey ?? firstNode?.id ?? "empty"}`
const sampleDocumentIds = rawNodes
.filter((node) => node.type === "document")
.slice(0, 5)
.map((node) => node.id)
const sampleMemoryIds = rawNodes
.filter((node) => node.type === "memory")
.slice(0, 5)
.map((node) => node.id)
const color =
firstNode?.clusterColor ?? firstNode?.borderColor ?? "rgba(88, 199, 232, 1)"
return {
id: `cluster:${index}`,
key,
color,
rawNodes,
documentCount,
memoryCount,
sampleDocumentIds,
sampleMemoryIds,
}
}
function createClusterNode(
group: ClusterGroup,
index: number,
total: number,
canvasWidth: number,
canvasHeight: number,
): GraphNode {
const centerX = canvasWidth / 2
const centerY = canvasHeight / 2
const spread = Math.max(420, Math.sqrt(total) * 210)
const angle = index * GOLDEN_ANGLE
const radius = spread * Math.sqrt((index + 1) / Math.max(1, total))
const nodeCount = group.rawNodes.length
const size = clamp(
CLUSTER_MIN_SIZE + Math.sqrt(nodeCount) * 3.8,
CLUSTER_MIN_SIZE,
CLUSTER_MAX_SIZE,
)
const data: ClusterNodeData = {
id: group.id,
title: `${group.documentCount} documents`,
summary: `${group.memoryCount} memories grouped for fast graph browsing`,
clusterKey: group.key,
documentCount: group.documentCount,
memoryCount: group.memoryCount,
nodeCount,
sampleDocumentIds: group.sampleDocumentIds,
sampleMemoryIds: group.sampleMemoryIds,
}
return {
id: group.id,
type: "cluster",
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
data,
size,
borderColor: group.color,
clusterKey: group.key,
clusterColor: group.color,
isHovered: false,
isDragging: false,
}
}
function buildVisibleEdges(
edges: GraphEdge[],
visibleIds: Set<string>,
rawNodeToClusterId: Map<string, string>,
clusterNodeById: Map<string, GraphNode>,
): GraphEdge[] {
const result = new Map<
string,
{ edge: GraphEdge; count: number; opacity: number; thickness: number }
>()
for (const edge of edges) {
const sourceId = getEndpointId(edge.source)
const targetId = getEndpointId(edge.target)
const visibleSource = visibleIds.has(sourceId)
const visibleTarget = visibleIds.has(targetId)
const sourceClusterId = rawNodeToClusterId.get(sourceId)
const targetClusterId = rawNodeToClusterId.get(targetId)
const displaySource = visibleSource ? sourceId : sourceClusterId
const displayTarget = visibleTarget ? targetId : targetClusterId
if (!displaySource || !displayTarget || displaySource === displayTarget)
continue
const source = clusterNodeById.get(displaySource) ?? displaySource
const target = clusterNodeById.get(displayTarget) ?? displayTarget
const key = `${displaySource}->${displayTarget}:${edge.edgeType}`
const existing = result.get(key)
if (existing) {
existing.count++
existing.opacity = Math.max(existing.opacity, edge.visualProps.opacity)
existing.thickness = Math.max(
existing.thickness,
edge.visualProps.thickness,
)
continue
}
result.set(key, {
count: 1,
opacity: edge.visualProps.opacity,
thickness: edge.visualProps.thickness,
edge: {
id: `cluster-edge:${key}`,
source,
target,
edgeType: edge.edgeType,
visualProps: { ...edge.visualProps },
},
})
}
return [...result.values()].map(({ edge, count, opacity, thickness }) => ({
...edge,
visualProps: {
opacity: clamp(opacity + Math.log2(count) * 0.025, 0.12, 0.62),
thickness: clamp(thickness + Math.log2(count) * 0.2, 0.8, 4),
},
}))
}
function getEndpointId(endpoint: string | GraphNode): string {
return typeof endpoint === "string" ? endpoint : endpoint.id
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}

View file

@ -75,10 +75,10 @@ export interface MemoryNodeData {
export interface GraphNode {
id: string
type: "document" | "memory" | "cluster"
type: "document" | "memory"
x: number
y: number
data: DocumentNodeData | MemoryNodeData | ClusterNodeData
data: DocumentNodeData | MemoryNodeData
size: number
borderColor: string
clusterKey?: string | null
@ -92,18 +92,6 @@ export interface GraphNode {
fy?: number | null
}
export interface ClusterNodeData {
id: string
title: string
summary: string
clusterKey: string
documentCount: number
memoryCount: number
nodeCount: number
sampleDocumentIds: string[]
sampleMemoryIds: string[]
}
export interface GraphEdge {
id: string
source: string | GraphNode