supermemory/apps/web/components/memory-graph/hooks/use-graph-data.ts
2026-02-16 14:30:29 -07:00

289 lines
7 KiB
TypeScript

"use client"
import { useMemo, useRef, useEffect } from "react"
import { MEMORY_BORDER, EDGE_COLORS } from "../constants"
import type {
GraphNode,
GraphEdge,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
DocumentNodeData,
MemoryNodeData,
} from "../types"
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const MEMORY_CLUSTER_SPREAD = 150
function getMemoryBorderColor(mem: GraphApiMemory): string {
if (mem.isForgotten) return MEMORY_BORDER.forgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
return MEMORY_BORDER.default
}
function getEdgeVisualProps(similarity: number) {
return {
opacity: 0.3 + similarity * 0.5,
thickness: 1 + similarity * 1.5,
}
}
function normalizeDocCoordinates(
documents: GraphApiDocument[],
): GraphApiDocument[] {
if (documents.length <= 1) return documents
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const doc of documents) {
minX = Math.min(minX, doc.x)
maxX = Math.max(maxX, doc.x)
minY = Math.min(minY, doc.y)
maxY = Math.max(maxY, doc.y)
}
const rangeX = maxX - minX || 1
const rangeY = maxY - minY || 1
const PAD = 100
return documents.map((doc) => ({
...doc,
x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD),
y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD),
}))
}
export function useGraphData(
documents: GraphApiDocument[],
apiEdges: GraphApiEdge[],
draggingNodeId: string | null,
canvasWidth: number,
canvasHeight: number,
) {
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
useEffect(() => {
if (!documents || documents.length === 0) return
const currentIds = new Set<string>()
for (const doc of documents) {
currentIds.add(doc.id)
for (const mem of doc.memories) currentIds.add(mem.id)
}
for (const [id] of nodeCache.current.entries()) {
if (!currentIds.has(id)) nodeCache.current.delete(id)
}
}, [documents])
const { scale, offsetX, offsetY } = useMemo(() => {
if (canvasWidth === 0 || canvasHeight === 0) {
return { scale: 1, offsetX: 0, offsetY: 0 }
}
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return { scale: s, offsetX: ox, offsetY: oy }
}, [canvasWidth, canvasHeight])
const normalizedDocs = useMemo(
() => normalizeDocCoordinates(documents),
[documents],
)
const nodes = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
const result: GraphNode[] = []
for (const doc of normalizedDocs) {
const initialX = doc.x * scale + offsetX
const initialY = doc.y * scale + offsetY
let docNode = nodeCache.current.get(doc.id)
const docData: DocumentNodeData = {
id: doc.id,
title: doc.title,
summary: doc.summary,
type: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memories,
}
if (docNode) {
docNode.data = docData
docNode.isDragging = draggingNodeId === doc.id
} else {
docNode = {
id: doc.id,
type: "document",
x: initialX,
y: initialY,
data: docData,
size: 50,
borderColor: "#2A2F36",
isHovered: false,
isDragging: false,
}
nodeCache.current.set(doc.id, docNode)
}
result.push(docNode)
const memCount = doc.memories.length
for (let i = 0; i < memCount; i++) {
const mem = doc.memories[i]!
let memNode = nodeCache.current.get(mem.id)
const memData: MemoryNodeData = {
...mem,
documentId: doc.id,
content: mem.memory,
}
if (memNode) {
memNode.data = memData
memNode.borderColor = getMemoryBorderColor(mem)
memNode.isDragging = draggingNodeId === mem.id
} else {
const angle = (i / memCount) * 2 * Math.PI
memNode = {
id: mem.id,
type: "memory",
x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD,
y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD,
data: memData,
size: 36,
borderColor: getMemoryBorderColor(mem),
isHovered: false,
isDragging: false,
}
nodeCache.current.set(mem.id, memNode)
}
result.push(memNode)
}
}
return result
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId])
const edges = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
const result: GraphEdge[] = []
const allNodeIds = new Set(nodes.map((n) => n.id))
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
similarity: 1,
visualProps: { opacity: 0.3, thickness: 1.5 },
edgeType: "doc-memory",
})
}
}
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
result.push({
id: `ver-${mem.parentMemoryId}-${mem.id}`,
source: mem.parentMemoryId,
target: mem.id,
similarity: 1,
visualProps: { opacity: 0.6, thickness: 2 },
edgeType: "version",
})
}
}
}
for (const apiEdge of apiEdges) {
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
continue
}
result.push({
id: `sim-${apiEdge.source}-${apiEdge.target}`,
source: apiEdge.source,
target: apiEdge.target,
similarity: apiEdge.similarity,
visualProps: getEdgeVisualProps(apiEdge.similarity),
edgeType: "similarity",
})
}
return result
}, [normalizedDocs, apiEdges, nodes])
return { nodes, edges, scale, offsetX, offsetY }
}
export function screenToBackendCoords(
screenX: number,
screenY: number,
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const canvasX = (screenX - panX) / zoom
const canvasY = (screenY - panY) / zoom
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return {
x: (canvasX - ox) / s,
y: (canvasY - oy) / s,
}
}
export function calculateBackendViewport(
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
const topLeft = screenToBackendCoords(
0,
0,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
const bottomRight = screenToBackendCoords(
canvasWidth,
canvasHeight,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
return {
minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)),
maxX: Math.max(topLeft.x, bottomRight.x),
minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)),
maxY: Math.max(topLeft.y, bottomRight.y),
}
}