Add cluster-level mode for dense memory graphs

This commit is contained in:
Ishaan Gupta 2026-05-29 21:45:46 +05:30
parent 93c2b7baa2
commit 0cb7acf33b
8 changed files with 789 additions and 33 deletions

View file

@ -0,0 +1,133 @@
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,4 +1,5 @@
import type {
ClusterNodeData,
DocumentNodeData,
GraphEdge,
GraphNode,
@ -28,6 +29,11 @@ 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)
}
@ -438,7 +444,7 @@ function drawNodes(
highlightFocus && !isSelected && !isHovered && !isHighlighted
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
if (node.type === "document" || node.type === "cluster") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else {
const md = node.data as MemoryNodeData
@ -465,7 +471,19 @@ function drawNodes(
}
ctx.globalAlpha = alpha
if (node.type === "document") {
if (node.type === "cluster") {
drawClusterNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
} else if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
@ -631,6 +649,82 @@ 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,
@ -850,7 +944,7 @@ function drawGlow(
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory",
nodeType: "document" | "memory" | "cluster",
colors: GraphThemeColors,
isHoverOnly = false,
): void {
@ -866,6 +960,9 @@ 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

@ -50,11 +50,12 @@ export class ForceSimulation {
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.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
})
.strength(FORCE_CONFIG.collisionStrength),
)

View file

@ -1,5 +1,10 @@
import { memo, useState } from "react"
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
import type {
ClusterNodeData,
GraphEdge,
GraphNode,
GraphThemeColors,
} from "../types"
interface LegendProps {
nodes?: GraphNode[]
@ -294,19 +299,32 @@ export const Legend = memo(function Legend({
const [isExpanded, setIsExpanded] = useState(false)
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
const memoryCount = nodes.filter((n) => n.type === "memory").length
const documentCount = nodes.filter((n) => n.type === "document").length
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 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 = new Set(
nodes
.filter((node) => node.type === "memory")
.map((node) => node.clusterKey)
.filter(Boolean),
).size
const clusterCount =
clusterNodes.length ||
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

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { 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 {
@ -15,6 +16,9 @@ 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,
@ -55,6 +59,9 @@ 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
@ -97,13 +104,38 @@ export function MemoryGraph({
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
const { nodes, edges } = useGraphData(
const { nodes: rawNodes, edges: rawEdges } = 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)
@ -231,6 +263,10 @@ 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
@ -278,9 +314,32 @@ export function MemoryGraph({
[],
)
const handleNodeClick = useCallback((id: string | null) => {
setSelectedNode((prev) => (id === null ? null : prev === id ? null : id))
}, [])
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 handleNodeDragStart = useCallback((_id: string) => {
// Drag is handled imperatively by InputHandler
@ -497,7 +556,12 @@ export function MemoryGraph({
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "document") {
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") {
const docs = nodes.filter((n) => n.type === "document")
const idx = docs.findIndex((n) => n.id === selectedNode)
const next = docs[(idx + 1) % docs.length]
@ -522,7 +586,12 @@ export function MemoryGraph({
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
if (node.type === "document") {
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") {
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,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import type {
ClusterNodeData,
DocumentNodeData,
GraphNode,
GraphThemeColors,
@ -323,6 +324,7 @@ 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)
@ -399,18 +401,27 @@ 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 || ""
}, [isMemory, data])
}, [isCluster, isMemory, data])
const docData = !isMemory ? (data as DocumentNodeData) : null
const docData = node.type === "document" ? (data as DocumentNodeData) : null
const clusterData = isCluster ? (data as ClusterNodeData) : null
// For document nodes, node.id IS the document ID
const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id
const documentId = isCluster
? null
: isMemory
? (data as MemoryNodeData).documentId
: node.id
const overlayStyle: React.CSSProperties = {
pointerEvents: "none",
@ -578,7 +589,26 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
)}
<div style={footerStyle}>
{memoryMeta ? (
{clusterData ? (
<>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{clusterData.documentCount} documents
</span>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{clusterData.memoryCount} memories
</span>
</>
) : memoryMeta ? (
<span
style={{
fontSize: 12,
@ -620,7 +650,9 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
</div>
<div style={idRowStyle}>
{isMemory ? (
{isCluster ? (
<CopyableId colors={colors} label="Cluster" value={node.id} />
) : isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
@ -645,7 +677,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : true) && (
{(isMemory ? hasChain : !isCluster) && (
<NavButton
colors={colors}
icon="↓"
@ -656,13 +688,25 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="→"
label={isMemory ? "Next memory" : "Next document"}
label={
isCluster
? "Next cluster"
: isMemory
? "Next memory"
: "Next document"
}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
label={
isCluster
? "Prev cluster"
: isMemory
? "Prev memory"
: "Prev document"
}
onClick={onNavigatePrev}
/>
</div>

View file

@ -0,0 +1,382 @@
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"
type: "document" | "memory" | "cluster"
x: number
y: number
data: DocumentNodeData | MemoryNodeData
data: DocumentNodeData | MemoryNodeData | ClusterNodeData
size: number
borderColor: string
clusterKey?: string | null
@ -92,6 +92,18 @@ 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