From 87eef93fd1b38df0bfe4f3d6ec9f0682b645aba3 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 17:21:31 +0530 Subject: [PATCH] Render dense memory graphs without cluster mode --- apps/web/components/graph-layout-view.tsx | 3 - .../memory-graph/hooks/use-graph-api.ts | 8 +- .../src/__tests__/cluster-level-graph.test.ts | 133 ------ packages/memory-graph/src/canvas/renderer.ts | 177 ++++---- .../memory-graph/src/canvas/simulation.ts | 13 +- .../memory-graph/src/components/legend.tsx | 36 +- .../src/components/memory-graph.tsx | 94 +---- .../src/components/node-hover-popover.tsx | 60 +-- .../src/hooks/cluster-level-graph.ts | 382 ------------------ packages/memory-graph/src/types.ts | 16 +- 10 files changed, 121 insertions(+), 801 deletions(-) delete mode 100644 packages/memory-graph/src/__tests__/cluster-level-graph.test.ts delete mode 100644 packages/memory-graph/src/hooks/cluster-level-graph.ts diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index 664f297b..0e9f5d67 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -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} /> diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts index dd295d45..30b47672 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -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, diff --git a/packages/memory-graph/src/__tests__/cluster-level-graph.test.ts b/packages/memory-graph/src/__tests__/cluster-level-graph.test.ts deleted file mode 100644 index 5e32bfe2..00000000 --- a/packages/memory-graph/src/__tests__/cluster-level-graph.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index f25f93e2..17c73f7b 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -1,5 +1,4 @@ import type { - ClusterNodeData, DocumentNodeData, GraphEdge, GraphNode, @@ -22,6 +21,10 @@ const edgeBatches = new Map() 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) } diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 1348f80e..b04cee27 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -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 | null = null @@ -50,12 +50,11 @@ export class ForceSimulation { "collide", d3 .forceCollide() - .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), ) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 8e252e42..d007ac56 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -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 = { diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 69ddd17c..1bb60aca 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -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(null) const [selectedNode, setSelectedNode] = useState(null) - const [expandedClusterId, setExpandedClusterId] = useState( - 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] diff --git a/packages/memory-graph/src/components/node-hover-popover.tsx b/packages/memory-graph/src/components/node-hover-popover.tsx index 53a1645f..97ec06e8 100644 --- a/packages/memory-graph/src/components/node-hover-popover.tsx +++ b/packages/memory-graph/src/components/node-hover-popover.tsx @@ -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( 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( }, [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( )}
- {clusterData ? ( - <> - - {clusterData.documentCount} documents - - - {clusterData.memoryCount} memories - - - ) : memoryMeta ? ( + {memoryMeta ? ( (
- {isCluster ? ( - - ) : isMemory ? ( + {isMemory ? ( ) : ( @@ -677,7 +645,7 @@ export const NodeHoverPopover = memo( onClick={onNavigateUp} /> )} - {(isMemory ? hasChain : !isCluster) && ( + {(isMemory ? hasChain : true) && ( (
diff --git a/packages/memory-graph/src/hooks/cluster-level-graph.ts b/packages/memory-graph/src/hooks/cluster-level-graph.ts deleted file mode 100644 index 9e0b2e42..00000000 --- a/packages/memory-graph/src/hooks/cluster-level-graph.ts +++ /dev/null @@ -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 -} - -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() - for (const group of groups) { - for (const node of group.rawNodes) { - rawNodeToClusterId.set(node.id, group.id) - } - } - - const visibleNodes: GraphNode[] = [] - const visibleIds = new Set() - const clusterNodeById = new Map() - - 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() - 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, - rawNodeToClusterId: Map, - clusterNodeById: Map, -): 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)) -} diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index 4f48f968..b446a023 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -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