Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers (#809)

Co-authored-by: Vorflux AI <noreply@vorflux.com>
This commit is contained in:
vorflux[bot] 2026-03-28 19:06:27 -07:00 committed by GitHub
parent 38282a37d6
commit 851b8cfe86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 6791 additions and 12118 deletions

View file

@ -1,79 +0,0 @@
// Standalone TypeScript types for Memory Graph
// These mirror the API response types from @repo/validation/api
export interface MemoryEntry {
id: string
customId?: string | null
documentId: string
content: string | null
summary?: string | null
title?: string | null
url?: string | null
type?: string | null
metadata?: Record<string, string | number | boolean> | null
embedding?: number[] | null
embeddingModel?: string | null
tokenCount?: number | null
createdAt: string | Date
updatedAt: string | Date
// Fields from join relationship
sourceAddedAt?: Date | null
sourceRelevanceScore?: number | null
sourceMetadata?: Record<string, unknown> | null
spaceContainerTag?: string | null
// Version chain fields
updatesMemoryId?: string | null
nextVersionId?: string | null
relation?: "updates" | "extends" | "derives" | null
// Memory status fields
isForgotten?: boolean
forgetAfter?: Date | string | null
isLatest?: boolean
// Space/container fields
spaceId?: string | null
// Legacy fields
memory?: string | null
memoryRelations?: Array<{
relationType: "updates" | "extends" | "derives"
targetMemoryId: string
}> | null
parentMemoryId?: string | null
}
export interface DocumentWithMemories {
id: string
customId?: string | null
contentHash: string | null
orgId: string
userId: string
connectionId?: string | null
title?: string | null
content?: string | null
summary?: string | null
url?: string | null
source?: string | null
type?: string | null
status: "pending" | "processing" | "done" | "failed"
metadata?: Record<string, string | number | boolean> | null
processingMetadata?: Record<string, unknown> | null
raw?: string | null
tokenCount?: number | null
wordCount?: number | null
chunkCount?: number | null
averageChunkSize?: number | null
summaryEmbedding?: number[] | null
summaryEmbeddingModel?: string | null
createdAt: string | Date
updatedAt: string | Date
memoryEntries: MemoryEntry[]
}
export interface DocumentsResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}

View file

@ -1,70 +0,0 @@
import type { GraphNode } from "../types"
export class SpatialIndex {
private grid = new Map<string, GraphNode[]>()
private cellSize = 200
private lastHash = 0
rebuild(nodes: GraphNode[]): boolean {
const hash = this.computeHash(nodes)
if (hash === this.lastHash) return false
this.lastHash = hash
this.grid.clear()
for (const node of nodes) {
const key = `${Math.floor(node.x / this.cellSize)},${Math.floor(node.y / this.cellSize)}`
let cell = this.grid.get(key)
if (!cell) {
cell = []
this.grid.set(key, cell)
}
cell.push(node)
}
return true
}
queryPoint(worldX: number, worldY: number): GraphNode | null {
const cx = Math.floor(worldX / this.cellSize)
const cy = Math.floor(worldY / this.cellSize)
// Check current cell + 8 neighbors
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const cell = this.grid.get(`${cx + dx},${cy + dy}`)
if (!cell) continue
for (let i = cell.length - 1; i >= 0; i--) {
const node = cell[i]!
if (this.hitTest(node, worldX, worldY)) return node
}
}
}
return null
}
private hitTest(node: GraphNode, wx: number, wy: number): boolean {
const halfSize = node.size * 0.5
if (node.type === "document") {
// AABB rectangle hit test (50x50 node)
return (
Math.abs(wx - node.x) <= halfSize && Math.abs(wy - node.y) <= halfSize
)
}
// Circular hit test for hexagon memory nodes
const dx = wx - node.x
const dy = wy - node.y
return dx * dx + dy * dy <= halfSize * halfSize
}
private computeHash(nodes: GraphNode[]): number {
let hash = nodes.length
for (const n of nodes) {
// Round to nearest integer to avoid false rebuilds from tiny physics jitter
hash = (hash * 31 + (Math.round(n.x) | 0)) | 0
hash = (hash * 31 + (Math.round(n.y) | 0)) | 0
}
return hash
}
}

View file

@ -1,324 +0,0 @@
import type { ViewportState } from "./viewport"
import type { SpatialIndex } from "./hit-test"
import type { GraphNode } from "../types"
interface InputCallbacks {
onNodeHover: (id: string | null) => void
onNodeClick: (id: string | null) => void
onNodeDragStart: (id: string, node: GraphNode) => void
onNodeDragEnd: () => void
onRequestRender: () => void
}
export class InputHandler {
private canvas: HTMLCanvasElement
private viewport: ViewportState
private spatialIndex: SpatialIndex
private callbacks: InputCallbacks
private isPanning = false
private lastMouseX = 0
private lastMouseY = 0
// Ring buffer for velocity tracking
private posHistory: Array<{ x: number; y: number; t: number }> = []
private draggingNode: GraphNode | null = null
private dragStartX = 0
private dragStartY = 0
private didDrag = false
private currentHoveredId: string | null = null
// Touch state
private lastTouchDistance = 0
private lastTouchCenter = { x: 0, y: 0 }
private isTouchGesture = false
// Bound handlers for cleanup
private boundMouseDown: (e: MouseEvent) => void
private boundMouseMove: (e: MouseEvent) => void
private boundMouseUp: (e: MouseEvent) => void
private boundWheel: (e: WheelEvent) => void
private boundClick: (e: MouseEvent) => void
private boundDblClick: (e: MouseEvent) => void
private boundTouchStart: (e: TouchEvent) => void
private boundTouchMove: (e: TouchEvent) => void
private boundTouchEnd: (e: TouchEvent) => void
private boundGesture: (e: Event) => void
constructor(
canvas: HTMLCanvasElement,
viewport: ViewportState,
spatialIndex: SpatialIndex,
callbacks: InputCallbacks,
) {
this.canvas = canvas
this.viewport = viewport
this.spatialIndex = spatialIndex
this.callbacks = callbacks
this.boundMouseDown = this.onMouseDown.bind(this)
this.boundMouseMove = this.onMouseMove.bind(this)
this.boundMouseUp = this.onMouseUp.bind(this)
this.boundWheel = this.onWheel.bind(this)
this.boundClick = this.onClick.bind(this)
this.boundDblClick = this.onDblClick.bind(this)
this.boundTouchStart = this.onTouchStart.bind(this)
this.boundTouchMove = this.onTouchMove.bind(this)
this.boundTouchEnd = this.onTouchEnd.bind(this)
this.boundGesture = (e: Event) => e.preventDefault()
canvas.addEventListener("mousedown", this.boundMouseDown)
canvas.addEventListener("mousemove", this.boundMouseMove)
canvas.addEventListener("mouseup", this.boundMouseUp)
canvas.addEventListener("click", this.boundClick)
canvas.addEventListener("dblclick", this.boundDblClick)
canvas.addEventListener("wheel", this.boundWheel, { passive: false })
canvas.addEventListener("touchstart", this.boundTouchStart, {
passive: false,
})
canvas.addEventListener("touchmove", this.boundTouchMove, {
passive: false,
})
canvas.addEventListener("touchend", this.boundTouchEnd)
canvas.addEventListener("gesturestart", this.boundGesture, {
passive: false,
})
canvas.addEventListener("gesturechange", this.boundGesture, {
passive: false,
})
canvas.addEventListener("gestureend", this.boundGesture, { passive: false })
}
destroy(): void {
const c = this.canvas
c.removeEventListener("mousedown", this.boundMouseDown)
c.removeEventListener("mousemove", this.boundMouseMove)
c.removeEventListener("mouseup", this.boundMouseUp)
c.removeEventListener("click", this.boundClick)
c.removeEventListener("dblclick", this.boundDblClick)
c.removeEventListener("wheel", this.boundWheel)
c.removeEventListener("touchstart", this.boundTouchStart)
c.removeEventListener("touchmove", this.boundTouchMove)
c.removeEventListener("touchend", this.boundTouchEnd)
c.removeEventListener("gesturestart", this.boundGesture)
c.removeEventListener("gesturechange", this.boundGesture)
c.removeEventListener("gestureend", this.boundGesture)
}
getDraggingNode(): GraphNode | null {
return this.draggingNode
}
private canvasXY(e: MouseEvent): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
private onMouseDown(e: MouseEvent): void {
const { x, y } = this.canvasXY(e)
const world = this.viewport.screenToWorld(x, y)
const node = this.spatialIndex.queryPoint(world.x, world.y)
this.lastMouseX = x
this.lastMouseY = y
this.posHistory = [{ x, y, t: performance.now() }]
this.didDrag = false
if (node) {
this.draggingNode = node
this.dragStartX = x
this.dragStartY = y
node.fx = node.x
node.fy = node.y
this.callbacks.onNodeDragStart(node.id, node)
this.canvas.style.cursor = "grabbing"
} else {
this.isPanning = true
this.canvas.style.cursor = "grabbing"
}
}
private onMouseMove(e: MouseEvent): void {
const { x, y } = this.canvasXY(e)
if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.fx = world.x
this.draggingNode.fy = world.y
this.draggingNode.x = world.x
this.draggingNode.y = world.y
this.didDrag = true
this.callbacks.onRequestRender()
return
}
if (this.isPanning) {
const dx = x - this.lastMouseX
const dy = y - this.lastMouseY
this.viewport.pan(dx, dy)
this.lastMouseX = x
this.lastMouseY = y
this.didDrag = true
// Track positions for velocity (keep last 4)
const now = performance.now()
this.posHistory.push({ x, y, t: now })
if (this.posHistory.length > 4) this.posHistory.shift()
this.callbacks.onRequestRender()
return
}
// Hover detection
const world = this.viewport.screenToWorld(x, y)
const node = this.spatialIndex.queryPoint(world.x, world.y)
const id = node?.id ?? null
if (id !== this.currentHoveredId) {
this.currentHoveredId = id
this.callbacks.onNodeHover(id)
this.canvas.style.cursor = id ? "grab" : "default"
this.callbacks.onRequestRender()
}
}
private onMouseUp(_e: MouseEvent): void {
if (this.draggingNode) {
this.draggingNode.fx = null
this.draggingNode.fy = null
this.draggingNode = null
this.callbacks.onNodeDragEnd()
this.canvas.style.cursor = this.currentHoveredId ? "grab" : "default"
return
}
if (this.isPanning) {
this.isPanning = false
// Calculate release velocity from position history
if (this.posHistory.length >= 2) {
const newest = this.posHistory[this.posHistory.length - 1]!
const oldest = this.posHistory[0]!
const dt = newest.t - oldest.t
if (dt > 0 && dt < 200) {
const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame
const vy = ((newest.y - oldest.y) / dt) * 16
this.viewport.releaseWithVelocity(vx, vy)
}
}
this.canvas.style.cursor = "default"
this.callbacks.onRequestRender()
}
}
private onClick(e: MouseEvent): void {
if (this.didDrag) return
const { x, y } = this.canvasXY(e)
const world = this.viewport.screenToWorld(x, y)
const node = this.spatialIndex.queryPoint(world.x, world.y)
this.callbacks.onNodeClick(node?.id ?? null)
}
private onDblClick(e: MouseEvent): void {
const { x, y } = this.canvasXY(e)
this.viewport.zoomTo(this.viewport.zoom * 1.5, x, y)
this.callbacks.onRequestRender()
}
private onWheel(e: WheelEvent): void {
e.preventDefault()
const { x, y } = this.canvasXY(e)
// Horizontal scroll -> pan
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
this.viewport.pan(-e.deltaX, 0)
this.callbacks.onRequestRender()
return
}
// Vertical scroll -> zoom
const factor = e.deltaY > 0 ? 0.97 : 1.03
this.viewport.zoomImmediate(factor, x, y)
this.callbacks.onRequestRender()
}
// Touch handling
private onTouchStart(e: TouchEvent): void {
e.preventDefault()
const touches = e.touches
if (touches.length >= 2) {
this.isTouchGesture = true
const t0 = touches[0]!
const t1 = touches[1]!
this.lastTouchDistance = Math.hypot(
t1.clientX - t0.clientX,
t1.clientY - t0.clientY,
)
this.lastTouchCenter = {
x: (t0.clientX + t1.clientX) / 2,
y: (t0.clientY + t1.clientY) / 2,
}
} else if (touches.length === 1) {
this.isTouchGesture = false
const t = touches[0]!
const rect = this.canvas.getBoundingClientRect()
this.lastMouseX = t.clientX - rect.left
this.lastMouseY = t.clientY - rect.top
this.isPanning = true
}
}
private onTouchMove(e: TouchEvent): void {
e.preventDefault()
const touches = e.touches
if (touches.length >= 2 && this.isTouchGesture) {
const t0 = touches[0]!
const t1 = touches[1]!
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY)
const center = {
x: (t0.clientX + t1.clientX) / 2,
y: (t0.clientY + t1.clientY) / 2,
}
const rect = this.canvas.getBoundingClientRect()
const cx = center.x - rect.left
const cy = center.y - rect.top
// Pinch zoom
const scale = dist / this.lastTouchDistance
this.viewport.zoomImmediate(scale, cx, cy)
// Pan from center movement
const dx = center.x - this.lastTouchCenter.x
const dy = center.y - this.lastTouchCenter.y
this.viewport.pan(dx, dy)
this.lastTouchDistance = dist
this.lastTouchCenter = center
this.callbacks.onRequestRender()
} else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) {
const t = touches[0]!
const rect = this.canvas.getBoundingClientRect()
const x = t.clientX - rect.left
const y = t.clientY - rect.top
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
this.lastMouseX = x
this.lastMouseY = y
this.callbacks.onRequestRender()
}
}
private onTouchEnd(e: TouchEvent): void {
if (e.touches.length < 2) {
this.isTouchGesture = false
}
if (e.touches.length === 0) {
this.isPanning = false
}
}
}

View file

@ -1,681 +0,0 @@
import type { ViewportState } from "./viewport"
import type { GraphNode, GraphEdge, DocumentNodeData } from "../types"
export interface RenderState {
selectedNodeId: string | null
hoveredNodeId: string | null
highlightIds: Set<string>
dimProgress: number
}
export function renderFrame(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
): void {
ctx.clearRect(0, 0, width, height)
drawDocDocLines(ctx, nodes, viewport, width, height)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap)
drawNodes(ctx, nodes, viewport, width, height, state)
}
// Connect each visible doc to its 2 nearest neighbors
function drawDocDocLines(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
): void {
const docs: { x: number; y: number }[] = []
for (const n of nodes) {
if (n.type !== "document") continue
const s = viewport.worldToScreen(n.x, n.y)
if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) {
docs.push(s)
}
}
if (docs.length < 2) return
ctx.strokeStyle = "#8DA3F4"
ctx.lineWidth = 1
ctx.globalAlpha = 0.3
ctx.setLineDash([4, 6])
ctx.beginPath()
// Deduplicate: only draw line when i < neighbor index
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
let best1 = -1
let best2 = -1
let dist1 = Number.POSITIVE_INFINITY
let dist2 = Number.POSITIVE_INFINITY
for (let j = 0; j < docs.length; j++) {
if (j === i) continue
const dx = docs[j]!.x - d.x
const dy = docs[j]!.y - d.y
const dist = dx * dx + dy * dy
if (dist < dist1) {
best2 = best1
dist2 = dist1
best1 = j
dist1 = dist
} else if (dist < dist2) {
best2 = j
dist2 = dist
}
}
if (best1 >= 0 && i < best1) {
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best1]!.x, docs[best1]!.y)
}
if (best2 >= 0 && i < best2) {
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best2]!.x, docs[best2]!.y)
}
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
// --- Edges ---
const EDGE_STYLE: Record<string, { color: string; width: number }> = {
"doc-memory": { color: "#4A5568", width: 1.5 },
version: { color: "#8B5CF6", width: 2 },
}
const SIM_STRONG = { color: "#00D4B8", width: 2 } as const
const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const
const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const
function edgeStyle(edge: GraphEdge): { color: string; width: number } {
const preset = EDGE_STYLE[edge.edgeType]
if (preset) return preset
if (edge.similarity >= 0.9) return SIM_STRONG
if (edge.similarity >= 0.8) return SIM_MEDIUM
return SIM_WEAK
}
// Unique key for batching: "color|width"
function batchKey(style: { color: string; width: number }): string {
return `${style.color}|${style.width}`
}
interface PreparedEdge {
startX: number
startY: number
endX: number
endY: number
connected: boolean
style: { color: string; width: number }
isVersion: boolean
arrowSize: number
}
function drawEdges(
ctx: CanvasRenderingContext2D,
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
// Prepare all visible edges
const prepared: PreparedEdge[] = []
for (const edge of edges) {
const src =
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
const tgt =
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
// Skip doc-memory edges when memory dots are too small to see connections
if (edge.edgeType === "doc-memory") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
const s = viewport.worldToScreen(src.x, src.y)
const t = viewport.worldToScreen(tgt.x, tgt.y)
if (
(s.x < -margin && t.x < -margin) ||
(s.x > width + margin && t.x > width + margin) ||
(s.y < -margin && t.y < -margin) ||
(s.y > height + margin && t.y > height + margin)
)
continue
const dx = t.x - s.x
const dy = t.y - s.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 1) continue
const ux = dx / dist
const uy = dy / dist
const sr = src.size * viewport.zoom * 0.5
const tr = tgt.size * viewport.zoom * 0.5
let connected = true
if (hasDim) {
const srcId =
typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId =
typeof edge.target === "string" ? edge.target : edge.target.id
connected =
srcId === state.selectedNodeId || tgtId === state.selectedNodeId
}
prepared.push({
startX: s.x + ux * sr,
startY: s.y + uy * sr,
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge),
isVersion: edge.edgeType === "version",
arrowSize:
edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0,
})
}
// Batch by style + dim state: group into "key|connected" and "key|dimmed"
const batches = new Map<string, PreparedEdge[]>()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = batchKey(e.style) + dimKey
let batch = batches.get(key)
if (!batch) {
batch = []
batches.set(key, batch)
}
batch.push(e)
}
// Draw each batch in a single beginPath/stroke
ctx.setLineDash([])
for (const [key, batch] of batches) {
const first = batch[0]!
const isDimmed = key.endsWith("|d")
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
// Arrow heads for version edges (fill calls — unavoidable per-arrow)
const versionEdges = batch.filter((e) => e.isVersion)
if (versionEdges.length > 0) {
ctx.fillStyle = first.style.color
for (const e of versionEdges) {
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
}
}
}
ctx.globalAlpha = 1
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number,
fromY: number,
toX: number,
toY: number,
size: number,
): void {
const angle = Math.atan2(toY - fromY, toX - fromX)
ctx.beginPath()
ctx.moveTo(toX, toY)
ctx.lineTo(
toX - size * Math.cos(angle - Math.PI / 6),
toY - size * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
toX - size * Math.cos(angle + Math.PI / 6),
toY - size * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
}
// --- Nodes ---
function drawNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
): void {
const margin = 60
const memDots: { x: number; y: number; r: number; color: string }[] = []
const docDots: { x: number; y: number; s: number }[] = []
for (const node of nodes) {
const screen = viewport.worldToScreen(node.x, node.y)
const screenSize = node.size * viewport.zoom
// Frustum cull (use at least 2px so tiny nodes aren't culled)
const cullSize = Math.max(screenSize, 2)
if (
screen.x + cullSize < -margin ||
screen.x - cullSize > width + margin ||
screen.y + cullSize < -margin ||
screen.y - cullSize > height + margin
)
continue
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
const isHighlighted = state.highlightIds.has(node.id)
// LOD: tiny nodes → batched dots (but selected/highlighted always get full detail)
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else {
memDots.push({
x: screen.x,
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || "#3B73B8",
})
}
continue
}
let alpha = 1
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
ctx.globalAlpha = alpha
if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
)
} else {
drawMemoryNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
)
}
if (isSelected || isHighlighted) {
drawGlow(ctx, screen.x, screen.y, screenSize, node.type)
}
}
const dimAlpha =
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
// Batch: document dots as filled squares
if (docDots.length > 0) {
ctx.fillStyle = "#1B1F24"
ctx.strokeStyle = "#2A2F36"
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
}
}
// Batch: memory dots — dark fill, then colored border strokes
if (memDots.length > 0) {
ctx.globalAlpha = dimAlpha
// Pass 1: all dark fills in one batch
ctx.fillStyle = "#0D2034"
ctx.beginPath()
for (const d of memDots) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
// Pass 2: colored strokes grouped by border color
ctx.lineWidth = 1.5
const byColor = new Map<string, typeof memDots>()
for (const d of memDots) {
let batch = byColor.get(d.color)
if (!batch) {
batch = []
byColor.set(d.color, batch)
}
batch.push(d)
}
for (const [color, batch] of byColor) {
ctx.strokeStyle = 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.stroke()
}
}
ctx.globalAlpha = 1
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
// Outer rect
ctx.fillStyle = "#1B1F24"
ctx.strokeStyle =
isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36"
ctx.lineWidth = isSelected || isHighlighted ? 2 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
ctx.stroke()
// Inner rect
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = "#13161A"
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
// Icon
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
drawDocIcon(ctx, sx, sy, iconSize, docType || "text")
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
_isHighlighted: boolean,
): void {
const radius = size * 0.5
// Fill
ctx.fillStyle = isHovered ? "#112840" : "#0D2034"
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
// Stroke with time-based border color
const borderColor = node.borderColor || "#3B73B8"
ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor
ctx.lineWidth = isHovered ? 2 : 1.5
ctx.stroke()
}
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory",
): void {
ctx.strokeStyle = "#3B73B8"
ctx.lineWidth = 2
ctx.setLineDash([3, 3])
ctx.globalAlpha = 0.8
if (nodeType === "document") {
const glowSize = size * 1.15
const half = glowSize * 0.5
const r = 8 * (glowSize / 50)
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
} else {
drawHexagon(ctx, sx, sy, size * 0.5 * 1.15)
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
// --- Shapes ---
function drawHexagon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
): void {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
const x = cx + radius * Math.cos(angle)
const y = cy + radius * Math.sin(angle)
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
// --- Document icons ---
const ICON_COLOR = "#3B73B8"
function drawDocIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
type: string,
): void {
ctx.save()
ctx.fillStyle = ICON_COLOR
ctx.strokeStyle = ICON_COLOR
ctx.lineWidth = Math.max(1, size / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
switch (type) {
case "webpage":
case "url":
drawGlobeIcon(ctx, x, y, size)
break
case "pdf":
drawTextLabel(ctx, x, y, size, "PDF", 0.35)
break
case "md":
case "markdown":
drawTextLabel(ctx, x, y, size, "MD", 0.3)
break
case "doc":
case "docx":
drawTextLabel(ctx, x, y, size, "DOC", 0.28)
break
case "csv":
drawGridIcon(ctx, x, y, size)
break
case "json":
drawBracesIcon(ctx, x, y, size)
break
case "notion":
case "notion_doc":
drawTextLabel(ctx, x, y, size, "N", 0.4)
break
case "google_doc":
case "google_sheet":
case "google_slide":
drawTextLabel(ctx, x, y, size, "G", 0.4)
break
default:
drawDocOutline(ctx, x, y, size)
break
}
ctx.restore()
}
function drawTextLabel(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
text: string,
fontRatio: number,
): void {
ctx.font = `bold ${size * fontRatio}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(text, x, y)
}
function drawGlobeIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const r = size * 0.4
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x - r, y)
ctx.lineTo(x + r, y)
ctx.stroke()
}
function drawGridIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.7
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
ctx.beginPath()
ctx.moveTo(x, y - h / 2)
ctx.lineTo(x, y + h / 2)
ctx.moveTo(x - w / 2, y)
ctx.lineTo(x + w / 2, y)
ctx.stroke()
}
function drawBracesIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.6
const h = size * 0.8
ctx.beginPath()
ctx.moveTo(x - w / 4, y - h / 2)
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x + w / 4, y - h / 2)
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
ctx.stroke()
}
function drawDocOutline(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.85
const fold = size * 0.2
ctx.beginPath()
ctx.moveTo(x - w / 2, y - h / 2)
ctx.lineTo(x + w / 2 - fold, y - h / 2)
ctx.lineTo(x + w / 2, y - h / 2 + fold)
ctx.lineTo(x + w / 2, y + h / 2)
ctx.lineTo(x - w / 2, y + h / 2)
ctx.closePath()
ctx.stroke()
const sp = size * 0.15
const lw = size * 0.4
ctx.beginPath()
ctx.moveTo(x - lw / 2, y - sp)
ctx.lineTo(x + lw / 2, y - sp)
ctx.moveTo(x - lw / 2, y)
ctx.lineTo(x + lw / 2, y)
ctx.moveTo(x - lw / 2, y + sp)
ctx.lineTo(x + lw / 2, y + sp)
ctx.stroke()
}

View file

@ -1,79 +0,0 @@
import * as d3 from "d3-force"
import type { GraphNode, GraphEdge } from "../types"
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy()
try {
this.sim = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(0.03)
.alphaMin(0.001)
.velocityDecay(0.6)
this.sim.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance((link) => (link.edgeType === "doc-memory" ? 150 : 300))
.strength((link) => {
if (link.edgeType === "doc-memory") return 0.8
if (link.edgeType === "version") return 1.0
return link.similarity * 0.3
}),
)
this.sim.force("charge", d3.forceManyBody<GraphNode>().strength(-1000))
this.sim.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) => (d.type === "document" ? 80 : 40))
.strength(0.7),
)
this.sim.force("x", d3.forceX().strength(0.05))
this.sim.force("y", d3.forceY().strength(0.05))
// Pre-settle synchronously, then start the live simulation
this.sim.stop()
this.sim.alpha(1)
for (let i = 0; i < 50; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
} catch (e) {
console.error("ForceSimulation.init failed:", e)
this.destroy()
}
}
update(nodes: GraphNode[], edges: GraphEdge[]): void {
if (!this.sim) return
this.sim.nodes(nodes)
const linkForce = this.sim.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
if (linkForce) linkForce.links(edges)
}
reheat(): void {
this.sim?.alphaTarget(0.3).restart()
}
coolDown(): void {
this.sim?.alphaTarget(0)
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > 0.001
}
destroy(): void {
if (this.sim) {
this.sim.stop()
this.sim = null
}
}
}

View file

@ -1,63 +0,0 @@
import type { GraphApiDocument, GraphApiMemory } from "../types"
export interface ChainEntry {
id: string
version: number
memory: string
isForgotten: boolean
isLatest: boolean
}
export class VersionChainIndex {
private memoryMap = new Map<string, GraphApiMemory>()
private cache = new Map<string, ChainEntry[]>()
private lastDocs: GraphApiDocument[] | null = null
rebuild(documents: GraphApiDocument[]): void {
if (documents === this.lastDocs) return
this.lastDocs = documents
this.memoryMap.clear()
this.cache.clear()
for (const doc of documents) {
for (const m of doc.memories) {
this.memoryMap.set(m.id, m)
}
}
}
getChain(memoryId: string): ChainEntry[] | null {
const cached = this.cache.get(memoryId)
if (cached) return cached
const mem = this.memoryMap.get(memoryId)
if (!mem || mem.version <= 1) return null
// Walk parentMemoryId up to the root
const chain: ChainEntry[] = []
const visited = new Set<string>()
let current: GraphApiMemory | undefined = mem
while (current && !visited.has(current.id)) {
visited.add(current.id)
chain.push({
id: current.id,
version: current.version,
memory: current.memory,
isForgotten: current.isForgotten,
isLatest: current.isLatest,
})
current = current.parentMemoryId
? this.memoryMap.get(current.parentMemoryId)
: undefined
}
chain.reverse()
// Cache for every member in the chain
for (const entry of chain) {
this.cache.set(entry.id, chain)
}
return chain
}
}

View file

@ -1,172 +0,0 @@
export class ViewportState {
panX: number
panY: number
zoom: number
private velocityX = 0
private velocityY = 0
private readonly friction = 0.92
private targetZoom: number
private readonly zoomSpring = 0.15
private zoomAnchorX = 0
private zoomAnchorY = 0
private targetPanX: number | null = null
private targetPanY: number | null = null
private readonly panLerp = 0.12
private static readonly MIN_ZOOM = 0.1
private static readonly MAX_ZOOM = 5.0
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX
this.panY = initialPanY
this.zoom = initialZoom
this.targetZoom = initialZoom
}
worldToScreen(wx: number, wy: number): { x: number; y: number } {
return {
x: wx * this.zoom + this.panX,
y: wy * this.zoom + this.panY,
}
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - this.panX) / this.zoom,
y: (sy - this.panY) / this.zoom,
}
}
pan(dx: number, dy: number): void {
this.panX += dx
this.panY += dy
// Cancel any target pan animation when user drags
this.targetPanX = null
this.targetPanY = null
}
releaseWithVelocity(vx: number, vy: number): void {
this.velocityX = vx
this.velocityY = vy
}
zoomImmediate(delta: number, anchorX: number, anchorY: number): void {
const world = this.screenToWorld(anchorX, anchorY)
this.zoom = clamp(
this.zoom * delta,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.targetZoom = this.zoom
this.panX = anchorX - world.x * this.zoom
this.panY = anchorY - world.y * this.zoom
}
zoomTo(target: number, anchorX: number, anchorY: number): void {
this.targetZoom = clamp(
target,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.zoomAnchorX = anchorX
this.zoomAnchorY = anchorY
}
fitToNodes(
nodes: Array<{ x: number; y: number; size: number }>,
width: number,
height: number,
): void {
if (nodes.length === 0) return
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const n of nodes) {
minX = Math.min(minX, n.x - n.size)
maxX = Math.max(maxX, n.x + n.size)
minY = Math.min(minY, n.y - n.size)
maxY = Math.max(maxY, n.y + n.size)
}
const pad = 0.1
const cw = (maxX - minX) * (1 + pad * 2)
const ch = (maxY - minY) * (1 + pad * 2)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
const fitZoom = Math.min(width / cw, height / ch, 1)
this.targetZoom = clamp(
fitZoom,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.zoomAnchorX = width / 2
this.zoomAnchorY = height / 2
this.targetPanX = width / 2 - cx * this.targetZoom
this.targetPanY = height / 2 - cy * this.targetZoom
}
centerOn(
worldX: number,
worldY: number,
width: number,
height: number,
): void {
this.targetPanX = width / 2 - worldX * this.zoom
this.targetPanY = height / 2 - worldY * this.zoom
}
tick(): boolean {
let moving = false
// Momentum panning
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
this.panX += this.velocityX
this.panY += this.velocityY
this.velocityX *= this.friction
this.velocityY *= this.friction
moving = true
} else {
this.velocityX = 0
this.velocityY = 0
}
// Spring zoom
const zoomDiff = this.targetZoom - this.zoom
if (Math.abs(zoomDiff) > 0.001) {
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
this.zoom += zoomDiff * this.zoomSpring
this.panX = this.zoomAnchorX - world.x * this.zoom
this.panY = this.zoomAnchorY - world.y * this.zoom
moving = true
}
// Lerp pan animation
if (this.targetPanX !== null && this.targetPanY !== null) {
const dx = this.targetPanX - this.panX
const dy = this.targetPanY - this.panY
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
this.panX += dx * this.panLerp
this.panY += dy * this.panLerp
moving = true
} else {
this.panX = this.targetPanX
this.panY = this.targetPanY
this.targetPanX = null
this.targetPanY = null
}
}
return moving
}
}
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v
}

View file

@ -1,62 +0,0 @@
export const colors = {
background: {
primary: "#0f1419",
secondary: "#1a1f29",
accent: "#252a35",
},
hexagon: {
active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 },
inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 },
hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 },
},
document: {
outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 },
inner: { fill: "#13161A", radius: 6 },
iconColor: "#3B73B8",
},
text: {
primary: "#ffffff",
secondary: "#e2e8f0",
muted: "#94a3b8",
},
}
export const MEMORY_BORDER = {
forgotten: "#EF4444",
expiring: "#F59E0B",
recent: "#10B981",
default: "#3B73B8",
} as const
export const EDGE_COLORS = {
docMemory: "#4A5568",
similarityStrong: "#00D4B8",
similarityMedium: "#6B8FBF",
similarityWeak: "#4A6A8A",
version: "#8B5CF6",
} as const
export const FORCE_CONFIG = {
linkStrength: {
docMemory: 0.8,
version: 1.0,
docDocBase: 0.3,
},
linkDistance: 300,
docMemoryDistance: 150,
chargeStrength: -1000,
collisionRadius: { document: 80, memory: 40 },
alphaDecay: 0.03,
alphaMin: 0.001,
velocityDecay: 0.6,
alphaTarget: 0.3,
}
export const GRAPH_SETTINGS = {
console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 },
consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 },
}
export const ANIMATION = {
dimDuration: 1500,
}

View file

@ -1,248 +0,0 @@
"use client"
import { memo, useEffect, useLayoutEffect, useRef } from "react"
import type { GraphCanvasProps, GraphNode } from "./types"
import { ViewportState } from "./canvas/viewport"
import { SpatialIndex } from "./canvas/hit-test"
import { InputHandler } from "./canvas/input-handler"
import { renderFrame } from "./canvas/renderer"
import { GRAPH_SETTINGS } from "./constants"
export const GraphCanvas = memo<GraphCanvasProps>(function GraphCanvas({
nodes,
edges,
width,
height,
highlightDocumentIds,
selectedNodeId = null,
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragEnd,
onViewportChange,
canvasRef: externalCanvasRef,
variant = "console",
simulation,
viewportRef: externalViewportRef,
}) {
const internalCanvasRef = useRef<HTMLCanvasElement>(null)
const canvasRef = externalCanvasRef || internalCanvasRef
// Engine instances — mutable, never trigger re-renders
const viewportRef = useRef<ViewportState | null>(null)
const spatialRef = useRef(new SpatialIndex())
const inputRef = useRef<InputHandler | null>(null)
const rafRef = useRef(0)
const renderNeeded = useRef(true)
const nodeMapRef = useRef(new Map<string, GraphNode>())
// All mutable render state in a single ref — the rAF loop reads from here
const s = useRef({
nodes,
edges,
width,
height,
selectedNodeId,
hoveredNodeId: null as string | null,
highlightIds: new Set(highlightDocumentIds ?? []),
dimProgress: 0,
dimTarget: selectedNodeId ? 1 : 0,
})
// Sync incoming props to mutable state (no re-renders)
s.current.nodes = nodes
s.current.edges = edges
s.current.width = width
s.current.height = height
// Stable callback refs so InputHandler never needs recreation
const cb = useRef({
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragEnd,
onViewportChange,
simulation,
})
cb.current = {
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragEnd,
onViewportChange,
simulation,
}
// Rebuild nodeMap + spatial index when nodes change
useEffect(() => {
const map = nodeMapRef.current
map.clear()
for (const n of nodes) map.set(n.id, n)
spatialRef.current.rebuild(nodes)
renderNeeded.current = true
}, [nodes])
useEffect(() => {
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
renderNeeded.current = true
}, [highlightDocumentIds])
useEffect(() => {
s.current.selectedNodeId = selectedNodeId
s.current.dimTarget = selectedNodeId ? 1 : 0
renderNeeded.current = true
}, [selectedNodeId])
// Create viewport + input handler (once per variant)
useLayoutEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const cfg = GRAPH_SETTINGS[variant]
const vp = new ViewportState(
cfg.initialPanX,
cfg.initialPanY,
cfg.initialZoom,
)
viewportRef.current = vp
if (externalViewportRef) {
;(
externalViewportRef as React.MutableRefObject<ViewportState | null>
).current = vp
}
const handler = new InputHandler(canvas, vp, spatialRef.current, {
onNodeHover: (id) => {
s.current.hoveredNodeId = id
cb.current.onNodeHover(id)
renderNeeded.current = true
},
onNodeClick: (id) => cb.current.onNodeClick(id),
onNodeDragStart: (id, node) => {
cb.current.onNodeDragStart(id)
cb.current.simulation?.reheat()
},
onNodeDragEnd: () => {
cb.current.onNodeDragEnd()
cb.current.simulation?.coolDown()
},
onRequestRender: () => {
renderNeeded.current = true
},
})
inputRef.current = handler
return () => handler.destroy()
}, [variant])
// High-DPI canvas sizing
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
useLayoutEffect(() => {
const canvas = canvasRef.current
if (!canvas || width === 0 || height === 0) return
const MAX = 16384
const d = Math.min(MAX / width, MAX / height, dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
canvas.width = Math.min(width * d, MAX)
canvas.height = Math.min(height * d, MAX)
const ctx = canvas.getContext("2d")
if (ctx) {
ctx.scale(d, d)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
}
renderNeeded.current = true
}, [width, height, dpr])
// Single render loop — runs for component lifetime, reads everything from refs
useEffect(() => {
let lastReportedZoom = 0
const tick = () => {
rafRef.current = requestAnimationFrame(tick)
const vp = viewportRef.current
const canvas = canvasRef.current
if (!vp || !canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const cur = s.current
// 1. Viewport momentum / spring zoom / lerp pan
const vpMoving = vp.tick()
// 2. Dim animation (ease toward target)
const dd = cur.dimTarget - cur.dimProgress
let dimming = false
if (Math.abs(dd) > 0.01) {
cur.dimProgress += dd * 0.1
dimming = true
} else {
cur.dimProgress = cur.dimTarget
}
// 3. Simulation physics
const simActive = cb.current.simulation?.isActive() ?? false
// 4. Spatial index rebuild (only when positions actually move)
const spatialChanged =
simActive || inputRef.current?.getDraggingNode()
? spatialRef.current.rebuild(cur.nodes)
: false
// Skip frame if nothing changed
if (
!vpMoving &&
!simActive &&
!dimming &&
!spatialChanged &&
!renderNeeded.current
)
return
renderNeeded.current = false
// Throttled zoom reporting for NavigationControls
if (
vpMoving &&
cb.current.onViewportChange &&
Math.abs(vp.zoom - lastReportedZoom) > 0.005
) {
lastReportedZoom = vp.zoom
cb.current.onViewportChange(vp.zoom)
}
renderFrame(
ctx,
cur.nodes,
cur.edges,
vp,
cur.width,
cur.height,
{
selectedNodeId: cur.selectedNodeId,
hoveredNodeId: cur.hoveredNodeId,
highlightIds: cur.highlightIds,
dimProgress: cur.dimProgress,
},
nodeMapRef.current,
)
}
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [])
return (
<canvas
ref={canvasRef}
className="absolute inset-0"
style={{ touchAction: "none", userSelect: "none" }}
/>
)
})

View file

@ -85,6 +85,8 @@ function StaticGraphPreview({
height={height}
className="absolute inset-0"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="Memory graph preview"
>
{edges.map((e, i) => (
<line
@ -116,9 +118,8 @@ export const GraphCard = memo<GraphCardProps>(
({ containerTags, width = 216, height = 220, className }) => {
const { setViewMode } = useViewMode()
const { data, isLoading, error } = useGraphApi({
const { documents, isLoading, error } = useGraphApi({
containerTags,
limit: 20,
enabled: true,
})
@ -139,11 +140,8 @@ export const GraphCard = memo<GraphCardProps>(
)
}
const documentCount = data.stats?.documentsWithSpatial ?? 0
const memoryCount = data.documents.reduce(
(sum, d) => sum + d.memories.length,
0,
)
const documentCount = documents.length
const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0)
return (
<button

View file

@ -1,273 +1,154 @@
"use client"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
} from "../types"
GraphApiDocument,
GraphApiMemory,
MemoryRelation,
} from "@supermemory/memory-graph"
interface ViewportParams {
minX: number
maxX: number
minY: number
maxY: number
}
const PAGE_SIZE = 100
interface UseGraphApiOptions {
containerTags?: string[]
limit?: number
enabled?: boolean
documentIds?: string[]
}
interface ApiMemoryEntry {
id: string
memory: string
content?: string | null
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
relation?: MemoryRelation | null
updatesMemoryId?: string | null
nextVersionId?: string | null
memoryRelations?: Record<string, MemoryRelation> | null
spaceContainerTag?: string | null
}
interface ApiDocument {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: ApiMemoryEntry[]
}
interface ApiDocumentsResponse {
documents: ApiDocument[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
memory: mem.memory ?? mem.content ?? "",
isStatic: mem.isStatic ?? false,
spaceId: mem.spaceId ?? "",
isLatest: mem.isLatest ?? true,
isForgotten: mem.isForgotten ?? false,
forgetAfter: mem.forgetAfter ?? null,
forgetReason: mem.forgetReason ?? null,
version: mem.version ?? 1,
parentMemoryId: mem.parentMemoryId ?? null,
rootMemoryId: mem.rootMemoryId ?? null,
createdAt: mem.createdAt,
updatedAt: mem.updatedAt,
relation: mem.relation ?? null,
updatesMemoryId: mem.updatesMemoryId ?? null,
nextVersionId: mem.nextVersionId ?? null,
memoryRelations: mem.memoryRelations ?? null,
spaceContainerTag: mem.spaceContainerTag ?? null,
}
}
function toGraphDocument(doc: ApiDocument): GraphApiDocument {
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.type,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memoryEntries.map(toGraphMemory),
}
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, documentIds, limit = 200, enabled = true } = options
const { containerTags, enabled = true } = options
const queryClient = useQueryClient()
const [viewport, setViewport] = useState<ViewportParams>({
minX: 0,
maxX: 1000,
minY: 0,
maxY: 1000,
})
// Debounce viewport changes
const viewportTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pendingViewportRef = useRef<ViewportParams | null>(null)
const updateViewport = useCallback((newViewport: ViewportParams) => {
pendingViewportRef.current = newViewport
if (viewportTimeoutRef.current) {
clearTimeout(viewportTimeoutRef.current)
}
viewportTimeoutRef.current = setTimeout(() => {
if (pendingViewportRef.current) {
setViewport(pendingViewportRef.current)
pendingViewportRef.current = null
}
}, 150)
}, [])
useEffect(() => {
return () => {
if (viewportTimeoutRef.current) {
clearTimeout(viewportTimeoutRef.current)
}
}
}, [])
const boundsQuery = useQuery({
queryKey: ["graph-bounds", containerTags?.join(",")],
queryFn: async (): Promise<GraphBoundsResponse> => {
const params = new URLSearchParams()
if (containerTags?.length) {
params.set("containerTags", JSON.stringify(containerTags))
}
const response = await $fetch("@get/graph/bounds", {
query: Object.fromEntries(params),
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph bounds",
)
}
return response.data as GraphBoundsResponse
},
staleTime: 5 * 60 * 1000,
enabled,
})
const statsQuery = useQuery({
queryKey: ["graph-stats", containerTags?.join(",")],
queryFn: async (): Promise<GraphStatsResponse> => {
const params = new URLSearchParams()
if (containerTags?.length) {
params.set("containerTags", JSON.stringify(containerTags))
}
const response = await $fetch("@get/graph/stats", {
query: Object.fromEntries(params),
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph stats",
)
}
return response.data as GraphStatsResponse
},
staleTime: 5 * 60 * 1000,
enabled,
})
const viewportQuery = useQuery({
queryKey: [
"graph-viewport",
viewport.minX,
viewport.maxX,
viewport.minY,
viewport.maxY,
containerTags?.join(","),
documentIds?.join(","),
limit,
],
queryFn: async (): Promise<GraphViewportResponse> => {
const response = await $fetch("@post/graph/viewport", {
const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<ApiDocumentsResponse, Error>({
queryKey: ["graph-documents", containerTags?.join(",")],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
viewport: {
minX: viewport.minX,
maxX: viewport.maxX,
minY: viewport.minY,
maxY: viewport.maxY,
},
page: pageParam as number,
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags,
documentIds,
limit,
},
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph viewport",
)
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data as GraphViewportResponse
return response.data as unknown as ApiDocumentsResponse
},
getNextPageParam: (lastPage) => {
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 30 * 1000,
enabled,
})
// Prefetch adjacent viewports for smoother panning
const prefetchAdjacentViewports = useCallback(
(currentViewport: ViewportParams) => {
const viewportWidth = currentViewport.maxX - currentViewport.minX
const viewportHeight = currentViewport.maxY - currentViewport.minY
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.documents.map(toGraphDocument))
}, [data])
const offsets = [
{ dx: viewportWidth * 0.5, dy: 0 },
{ dx: -viewportWidth * 0.5, dy: 0 },
{ dx: 0, dy: viewportHeight * 0.5 },
{ dx: 0, dy: -viewportHeight * 0.5 },
]
offsets.forEach(({ dx, dy }) => {
const prefetchViewport = {
minX: Math.max(0, currentViewport.minX + dx),
maxX: Math.max(0, currentViewport.maxX + dx),
minY: Math.max(0, currentViewport.minY + dy),
maxY: Math.max(0, currentViewport.maxY + dy),
}
queryClient.prefetchQuery({
queryKey: [
"graph-viewport",
prefetchViewport.minX,
prefetchViewport.maxX,
prefetchViewport.minY,
prefetchViewport.maxY,
containerTags?.join(","),
limit,
],
queryFn: async () => {
const response = await $fetch("@post/graph/viewport", {
body: {
viewport: prefetchViewport,
containerTags,
limit,
},
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph viewport",
)
}
return response.data
},
staleTime: 30 * 1000,
})
})
},
[queryClient, containerTags, limit],
)
const data = useMemo(() => {
return {
documents: viewportQuery.data?.documents ?? [],
edges: viewportQuery.data?.edges ?? [],
totalCount: viewportQuery.data?.totalCount ?? 0,
bounds: boundsQuery.data?.bounds ?? null,
stats: statsQuery.data ?? null,
}
}, [viewportQuery.data, boundsQuery.data, statsQuery.data])
const isLoading = viewportQuery.isPending || boundsQuery.isPending
const isRefetching = viewportQuery.isRefetching
const error =
viewportQuery.error || boundsQuery.error || statsQuery.error || null
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0
return {
data,
isLoading,
isRefetching,
error,
viewport,
updateViewport,
prefetchAdjacentViewports,
refetch: viewportQuery.refetch,
}
}
/**
* Scales backend coordinates (0-1000) to graph canvas coordinates
*/
export function scaleBackendToCanvas(
x: number,
y: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const scale = Math.min(canvasWidth, canvasHeight) / 1000
const offsetX = (canvasWidth - 1000 * scale) / 2
const offsetY = (canvasHeight - 1000 * scale) / 2
return {
x: x * scale + offsetX,
y: y * scale + offsetY,
}
}
/**
* Scales canvas coordinates to backend coordinates (0-1000)
*/
export function scaleCanvasToBackend(
x: number,
y: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const scale = Math.min(canvasWidth, canvasHeight) / 1000
const offsetX = (canvasWidth - 1000 * scale) / 2
const offsetY = (canvasHeight - 1000 * scale) / 2
return {
x: (x - offsetX) / scale,
y: (y - offsetY) / scale,
documents,
isLoading: isPending,
isLoadingMore: isFetchingNextPage,
error: error ?? null,
hasMore: hasNextPage ?? false,
loadMore: fetchNextPage,
totalCount,
}
}

View file

@ -1,289 +0,0 @@
"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),
}
}

View file

@ -1,33 +1,18 @@
// Memory Graph components
export { MemoryGraph } from "./memory-graph"
export type { MemoryGraphProps } from "./memory-graph"
// Re-export the wrapper as MemoryGraph (same name, drop-in replacement)
export { MemoryGraph } from "./memory-graph-wrapper"
export type { MemoryGraphWrapperProps as MemoryGraphProps } from "./memory-graph-wrapper"
// Keep GraphCard (app-specific)
export { GraphCard } from "./graph-card"
export type { GraphCardProps } from "./graph-card"
// Hooks
export { useGraphApi } from "./hooks/use-graph-api"
export {
useGraphData,
calculateBackendViewport,
screenToBackendCoords,
} from "./hooks/use-graph-data"
// Canvas engine
export { ViewportState } from "./canvas/viewport"
export { ForceSimulation } from "./canvas/simulation"
// Types
// Re-export useful types from the package
export type {
GraphNode,
GraphEdge,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
DocumentNodeData,
MemoryNodeData,
DocumentWithMemories,
MemoryEntry,
} from "./types"
} from "@supermemory/memory-graph"
// Keep the API hook export
export { useGraphApi } from "./hooks/use-graph-api"

View file

@ -1,599 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@ui/components/collapsible"
import { ChevronDown, ChevronRight } from "lucide-react"
import { memo, useEffect, useState } from "react"
import type { GraphEdge, GraphNode, LegendProps } from "./types"
import { cn } from "@lib/utils"
// Cookie utility functions for legend state
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null
const nameEQ = `${name}=`
const ca = document.cookie.split(";")
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
if (!c) continue
while (c.charAt(0) === " ") c = c.substring(1, c.length)
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
}
return null
}
interface ExtendedLegendProps extends LegendProps {
id?: string
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
}
// Toggle switch component matching Figma design
const SmallToggle = memo(function SmallToggle({
checked,
onChange,
}: {
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={cn(
"box-border flex flex-row justify-center items-center",
"w-6 h-3.5 rounded-full transition-all duration-200",
"border border-white/5",
"shadow-[inset_1px_1px_2px_rgba(0,0,0,0.5)]",
)}
style={{
background: "#0D121A",
}}
>
<div
className={cn(
"w-2.5 h-2.5 rounded-full transition-all duration-200",
checked ? "ml-auto mr-0.5" : "mr-auto ml-0.5",
)}
style={{
background: checked ? "#162E57" : "rgba(115, 115, 115, 0.25)",
}}
/>
</button>
)
})
// Hexagon SVG for memory nodes
const HexagonIcon = memo(function HexagonIcon({
fill = "#0D2034",
stroke = "#3B73B8",
opacity = 1,
size = 12,
}: {
fill?: string
stroke?: string
opacity?: number
size?: number
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 12 12"
style={{ opacity }}
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
fill={fill}
stroke={stroke}
strokeWidth="0.6"
/>
</svg>
)
})
// Document icon (rounded square)
const DocumentIcon = memo(function DocumentIcon() {
return (
<div
className="w-3 h-3 shrink-0 rounded-[2.4px] flex items-center justify-center"
style={{
background: "#1B1F24",
boxShadow:
"0px 0.85px 4.26px rgba(0, 0, 0, 0.25), inset 0.21px 0.21px 0.21px rgba(255, 255, 255, 0.1)",
}}
>
<div
className="w-[10.8px] h-[10.8px] rounded-[1.8px]"
style={{
background: "#262C33",
boxShadow: "inset 0.43px 0.43px 1.28px rgba(11, 15, 21, 0.4)",
}}
/>
</div>
)
})
// Connection icon (graph)
const ConnectionIcon = memo(function ConnectionIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<circle cx="3" cy="3" r="1.5" fill="#90A2B9" />
<circle cx="9" cy="3" r="1.5" fill="#90A2B9" />
<circle cx="6" cy="9" r="1.5" fill="#90A2B9" />
<line x1="3" y1="3" x2="9" y2="3" stroke="#90A2B9" strokeWidth="0.8" />
<line x1="3" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
<line x1="9" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
</svg>
)
})
// Line icon for connections
const LineIcon = memo(function LineIcon({
color,
dashed = false,
}: {
color: string
dashed?: boolean
}) {
return (
<div className="w-3 h-3 flex items-center justify-center shrink-0">
<div
className="w-3 h-0"
style={{
borderTop: `1.6px ${dashed ? "dashed" : "solid"} ${color}`,
}}
/>
</div>
)
})
// Similarity circle icon
const SimilarityCircle = memo(function SimilarityCircle({
variant,
}: {
variant: "strong" | "weak"
}) {
return (
<div
className="w-3 h-3 rounded-full shrink-0"
style={{
background: variant === "strong" ? "#616D7F" : "#313A44",
border: "0.6px solid rgba(255, 255, 255, 0.2)",
}}
/>
)
})
// Accordion row with count
const StatRow = memo(function StatRow({
icon,
label,
count,
expandable = false,
expanded = false,
onToggle,
children,
}: {
icon: React.ReactNode
label: string
count: number
expandable?: boolean
expanded?: boolean
onToggle?: () => void
children?: React.ReactNode
}) {
return (
<div className="flex flex-col">
<button
type="button"
onClick={expandable ? onToggle : undefined}
className={cn(
"flex flex-row justify-between items-center w-full py-0",
expandable && "cursor-pointer",
)}
>
<div className="flex flex-row items-center gap-2">
{icon}
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
{expandable && (
<ChevronDown
className={cn(
"w-3 h-3 text-[#737373] transition-transform",
expanded && "rotate-180",
)}
/>
)}
</div>
<span className="text-xs text-[#737373]">{count}</span>
</button>
{expandable && expanded && children && (
<div className="pl-2.5 pt-1.5 flex flex-col gap-1.5">{children}</div>
)}
</div>
)
})
// Toggle row for relations/similarity
const ToggleRow = memo(function ToggleRow({
icon,
label,
checked,
onChange,
}: {
icon: React.ReactNode
label: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div className="flex flex-row justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{icon}
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
</div>
<SmallToggle checked={checked} onChange={onChange} />
</div>
)
})
export const Legend = memo(function Legend({
variant: _variant = "console",
id,
nodes = [],
edges = [],
isLoading: _isLoading = false,
}: ExtendedLegendProps) {
const isMobile = useIsMobile()
const [isExpanded, setIsExpanded] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
// Toggle states for relations
const [showUpdates, setShowUpdates] = useState(true)
const [showExtends, setShowExtends] = useState(true)
const [showInferences, setShowInferences] = useState(false)
// Toggle states for similarity
const [showStrong, setShowStrong] = useState(true)
const [showWeak, setShowWeak] = useState(true)
// Expanded accordion states
const [memoriesExpanded, setMemoriesExpanded] = useState(false)
const [documentsExpanded, setDocumentsExpanded] = useState(false)
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
// Load saved preference on client side
useEffect(() => {
if (!isInitialized) {
const savedState = getCookie("legendCollapsed")
if (savedState === "true") {
setIsExpanded(false)
} else if (savedState === "false") {
setIsExpanded(true)
} else {
// Default: collapsed on mobile, collapsed on desktop too (per Figma)
setIsExpanded(false)
}
setIsInitialized(true)
}
}, [isInitialized])
// Save to cookie when state changes
const handleToggleExpanded = (expanded: boolean) => {
setIsExpanded(expanded)
setCookie("legendCollapsed", expanded ? "false" : "true")
}
// Calculate stats
const memoryCount = nodes.filter((n) => n.type === "memory").length
const documentCount = nodes.filter((n) => n.type === "document").length
const connectionCount = edges.length
// Hide on mobile
if (isMobile) return null
return (
<div
className={cn("absolute z-20 overflow-hidden", "bottom-4 left-4")}
style={{
width: "214px",
}}
id={id}
>
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
{/* Glass background */}
<div
className="absolute inset-0 rounded-[10px]"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
}}
/>
<div className="relative z-10 p-3">
{/* Header - always visible */}
<CollapsibleTrigger className="flex flex-row items-center gap-1.5 w-full">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-[#FAFAFA]" />
) : (
<ChevronRight className="w-4 h-4 text-[#FAFAFA]" />
)}
<span
className="text-sm text-white font-normal"
style={{
fontFamily: "DM Sans",
letterSpacing: "-0.01em",
}}
>
Legend
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className="mt-4 flex flex-row gap-3 overflow-y-auto max-h-[312px]"
style={{ scrollbarWidth: "thin" }}
>
{/* Main content column */}
<div className="flex flex-col gap-4 flex-1">
{/* STATISTICS Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
STATISTICS
</span>
<div className="flex flex-col gap-1.5">
{/* Memories */}
<StatRow
icon={
<HexagonIcon
fill="#0D2034"
stroke="#3B73B8"
size={12}
/>
}
label="Memories"
count={memoryCount}
expandable
expanded={memoriesExpanded}
onToggle={() => setMemoriesExpanded(!memoriesExpanded)}
>
<div className="flex flex-col gap-1.5 pl-0">
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<HexagonIcon
fill="#0D2034"
stroke="#3B73B8"
size={12}
/>
<span className="text-xs text-[#FAFAFA]">
Memory (latest)
</span>
</div>
<span className="text-xs text-[#737373]">76</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<HexagonIcon
fill="#0D2034"
stroke="#5F7085"
opacity={0.6}
size={12}
/>
<span className="text-xs text-[#FAFAFA]">
Memory (oldest)
</span>
</div>
<span className="text-xs text-[#737373]">182</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-3 h-3 flex items-center justify-center">
<HexagonIcon
fill="#0C1827"
stroke="rgba(54, 155, 253, 0.2)"
size={12}
/>
</div>
<span className="text-xs text-[#FAFAFA]">
Score
</span>
</div>
<span className="text-xs text-[#737373]">23</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,0 11.2,6 6,12 0.8,6"
fill="#00FFA9"
fillOpacity="0.6"
stroke="#00FFA9"
strokeWidth="0.6"
/>
</svg>
<span className="text-xs text-[#FAFAFA]">
New memory
</span>
</div>
<span className="text-xs text-[#737373]">17</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,0 11.2,6 6,12 0.8,6"
fill="#4D2E00"
fillOpacity="0.6"
stroke="#FE9900"
strokeWidth="0.6"
/>
</svg>
<span className="text-xs text-[#FAFAFA]">
Expiring soon
</span>
</div>
<span className="text-xs text-[#737373]">11</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-3 h-3 relative shrink-0">
<HexagonIcon
fill="#60272C"
stroke="#FF6467"
opacity={0.6}
size={12}
/>
</div>
<span className="text-xs text-[#FAFAFA]">
Forgotten
</span>
</div>
<span className="text-xs text-[#737373]">6</span>
</div>
</div>
</StatRow>
{/* Documents */}
<StatRow
icon={<DocumentIcon />}
label="Documents"
count={documentCount}
expandable
expanded={documentsExpanded}
onToggle={() => setDocumentsExpanded(!documentsExpanded)}
/>
{/* Connections */}
<StatRow
icon={<ConnectionIcon />}
label="Connections"
count={connectionCount}
expandable
expanded={connectionsExpanded}
onToggle={() =>
setConnectionsExpanded(!connectionsExpanded)
}
>
<div className="flex flex-col gap-1.5">
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<LineIcon color="#5070A1" />
<span className="text-xs text-[#FAFAFA]">
Doc &gt; Memory
</span>
</div>
</div>
<ToggleRow
icon={<LineIcon color="#5070A1" dashed />}
label="Doc similarity"
checked={showStrong}
onChange={setShowStrong}
/>
</div>
</StatRow>
</div>
</div>
{/* RELATIONS Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
RELATIONS
</span>
<div className="flex flex-col gap-1.5">
<ToggleRow
icon={<LineIcon color="#7800AB" />}
label="Updates"
checked={showUpdates}
onChange={setShowUpdates}
/>
<ToggleRow
icon={<LineIcon color="#00732E" />}
label="Extends"
checked={showExtends}
onChange={setShowExtends}
/>
<ToggleRow
icon={<LineIcon color="#0054D1" />}
label="Inferences"
checked={showInferences}
onChange={setShowInferences}
/>
</div>
</div>
{/* SIMILARITY Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
SIMILARITY
</span>
<div className="flex flex-col gap-1.5">
<ToggleRow
icon={<SimilarityCircle variant="strong" />}
label="Strong"
checked={showStrong}
onChange={setShowStrong}
/>
<ToggleRow
icon={<SimilarityCircle variant="weak" />}
label="Weak"
checked={showWeak}
onChange={setShowWeak}
/>
</div>
</div>
</div>
{/* Scrollbar indicator */}
<div
className="w-0.5 h-12 rounded-sm self-start"
style={{ background: "#737373" }}
/>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
)
})
Legend.displayName = "Legend"

View file

@ -1,32 +0,0 @@
"use client"
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
import { Sparkles } from "lucide-react"
import { memo } from "react"
import type { LoadingIndicatorProps } from "./types"
export const LoadingIndicator = memo<LoadingIndicatorProps>(
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
if (!isLoading && !isLoadingMore) return null
return (
<div className="absolute z-30 rounded-xl overflow-hidden top-[5.5rem] left-4">
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-300 px-4 py-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-300" />
<span className="text-sm">
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
</div>
</div>
</div>
)
},
)
LoadingIndicator.displayName = "LoadingIndicator"

View file

@ -0,0 +1,80 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
children?: React.ReactNode
isLoading?: boolean
error?: Error | null
variant?: "console" | "consumer"
legendId?: string
highlightDocumentIds?: string[]
highlightsVisible?: boolean
containerTags?: string[]
documentIds?: string[]
maxNodes?: number
isSlideshowActive?: boolean
onSlideshowNodeChange?: (nodeId: string | null) => void
onSlideshowStop?: () => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
}
export function MemoryGraph({
children,
isLoading: externalIsLoading = false,
error: externalError = null,
variant = "console",
containerTags,
maxNodes = 200,
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => {
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
})
ro.observe(el)
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
return () => ro.disconnect()
}, [])
const {
documents,
isLoading: apiIsLoading,
isLoadingMore,
error: apiError,
hasMore,
loadMore,
totalCount,
} = useGraphApi({
containerTags,
enabled: containerSize.width > 0 && containerSize.height > 0,
})
return (
<div ref={containerRef} className="w-full h-full">
<MemoryGraphBase
documents={documents}
isLoading={externalIsLoading || apiIsLoading}
isLoadingMore={isLoadingMore}
onLoadMore={hasMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
maxNodes={maxNodes}
canvasRef={canvasRef}
totalCount={totalCount}
{...rest}
>
{children}
</MemoryGraphBase>
</div>
)
}

View file

@ -1,513 +0,0 @@
"use client"
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { GraphCanvas } from "./graph-canvas"
import { useGraphApi } from "./hooks/use-graph-api"
import { useGraphData } from "./hooks/use-graph-data"
import { ForceSimulation } from "./canvas/simulation"
import { VersionChainIndex } from "./canvas/version-chain"
import type { ViewportState } from "./canvas/viewport"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover"
import { colors } from "./constants"
import type { GraphNode } from "./types"
export interface MemoryGraphProps {
children?: React.ReactNode
isLoading?: boolean
error?: Error | null
variant?: "console" | "consumer"
legendId?: string
highlightDocumentIds?: string[]
highlightsVisible?: boolean
containerTags?: string[]
documentIds?: string[]
maxNodes?: number
isSlideshowActive?: boolean
onSlideshowNodeChange?: (nodeId: string | null) => void
onSlideshowStop?: () => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
}
export const MemoryGraph = ({
children,
isLoading: externalIsLoading = false,
error: externalError = null,
variant = "console",
legendId,
highlightDocumentIds = [],
highlightsVisible = true,
containerTags,
documentIds,
maxNodes = 200,
isSlideshowActive = false,
onSlideshowNodeChange,
onSlideshowStop,
canvasRef,
}: MemoryGraphProps) => {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<ViewportState | null>(null)
const simulationRef = useRef<ForceSimulation | null>(null)
const chainIndex = useRef(new VersionChainIndex())
// React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [zoomDisplay, setZoomDisplay] = useState(50)
const {
data: apiData,
isLoading: apiIsLoading,
error: apiError,
} = useGraphApi({
containerTags,
documentIds,
limit: maxNodes,
enabled: containerSize.width > 0 && containerSize.height > 0,
})
const { nodes, edges } = useGraphData(
apiData.documents,
apiData.edges,
null,
containerSize.width,
containerSize.height,
)
// Rebuild version chain index when documents change
useEffect(() => {
chainIndex.current.rebuild(apiData.documents)
}, [apiData.documents])
// Force simulation (created once, updated when data changes)
useEffect(() => {
if (nodes.length === 0) return
if (!simulationRef.current) {
simulationRef.current = new ForceSimulation()
}
simulationRef.current.init(nodes, edges)
return () => {
simulationRef.current?.destroy()
simulationRef.current = null
}
}, [nodes, edges])
// Auto-fit when data first loads
const hasAutoFittedRef = useRef(false)
useEffect(() => {
if (
!hasAutoFittedRef.current &&
nodes.length > 0 &&
viewportRef.current &&
containerSize.width > 0
) {
const timer = setTimeout(() => {
viewportRef.current?.fitToNodes(
nodes,
containerSize.width,
containerSize.height,
)
hasAutoFittedRef.current = true
}, 100)
return () => clearTimeout(timer)
}
}, [nodes, containerSize.width, containerSize.height])
useEffect(() => {
if (nodes.length === 0) hasAutoFittedRef.current = false
}, [nodes.length])
// Container resize observer
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => {
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
setContainerBounds(el.getBoundingClientRect())
})
ro.observe(el)
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
setContainerBounds(el.getBoundingClientRect())
return () => ro.disconnect()
}, [])
// Callbacks for GraphCanvas
const handleNodeHover = useCallback(
(id: string | null) => setHoveredNode(id),
[],
)
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
}, [])
const handleNodeDragEnd = useCallback(() => {
// Drag end handled by InputHandler
}, [])
const handleViewportChange = useCallback((zoom: number) => {
setZoomDisplay(Math.round(zoom * 100))
}, [])
// Navigation
const handleAutoFit = useCallback(() => {
if (nodes.length === 0 || !viewportRef.current) return
viewportRef.current.fitToNodes(
nodes,
containerSize.width,
containerSize.height,
)
}, [nodes, containerSize.width, containerSize.height])
const handleCenter = useCallback(() => {
if (nodes.length === 0 || !viewportRef.current) return
let sx = 0
let sy = 0
for (const n of nodes) {
sx += n.x
sy += n.y
}
viewportRef.current.centerOn(
sx / nodes.length,
sy / nodes.length,
containerSize.width,
containerSize.height,
)
}, [nodes, containerSize.width, containerSize.height])
const handleZoomIn = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2)
}, [containerSize.width, containerSize.height])
const handleZoomOut = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
}, [containerSize.width, containerSize.height])
// Keyboard shortcuts
useHotkeys("z", handleAutoFit, [handleAutoFit])
useHotkeys("c", handleCenter, [handleCenter])
useHotkeys("equal", handleZoomIn, [handleZoomIn])
useHotkeys("minus", handleZoomOut, [handleZoomOut])
useHotkeys("escape", () => setSelectedNode(null), [])
// Arrow key navigation through nodes
const selectAndCenter = useCallback(
(nodeId: string) => {
setSelectedNode(nodeId)
const n = nodes.find((nd) => nd.id === nodeId)
if (n && viewportRef.current)
viewportRef.current.centerOn(
n.x,
n.y,
containerSize.width,
containerSize.height,
)
},
[nodes, containerSize.width, containerSize.height],
)
const navigateUp = useCallback(() => {
if (!selectedNode) return
const chain = chainIndex.current.getChain(selectedNode)
if (chain && chain.length > 1) {
const idx = chain.findIndex((e) => e.id === selectedNode)
if (idx > 0) {
selectAndCenter(chain[idx - 1]!.id)
return
}
}
// At top of chain or no chain — go to parent document
const node = nodes.find((n) => n.id === selectedNode)
if (node?.type === "memory" && "documentId" in node.data) {
selectAndCenter(node.data.documentId)
}
}, [selectedNode, nodes, selectAndCenter])
const navigateDown = useCallback(() => {
if (!selectedNode) return
// Version chain navigation
const chain = chainIndex.current.getChain(selectedNode)
if (chain && chain.length > 1) {
const idx = chain.findIndex((e) => e.id === selectedNode)
if (idx >= 0 && idx < chain.length - 1) {
selectAndCenter(chain[idx + 1]!.id)
return
}
}
// On a document — go to its first memory
const node = nodes.find((n) => n.id === selectedNode)
if (node?.type === "document") {
const child = nodes.find(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === selectedNode,
)
if (child) selectAndCenter(child.id)
}
}, [selectedNode, nodes, selectAndCenter])
const navigateNext = useCallback(() => {
if (!selectedNode) return
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
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]!
setSelectedNode(next.id)
if (viewportRef.current)
viewportRef.current.centerOn(
next.x,
next.y,
containerSize.width,
containerSize.height,
)
} else {
const docId = "documentId" in node.data ? node.data.documentId : null
const siblings = nodes.filter(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === docId,
)
if (siblings.length === 0) return
const idx = siblings.findIndex((n) => n.id === selectedNode)
const next = siblings[(idx + 1) % siblings.length]!
setSelectedNode(next.id)
if (viewportRef.current)
viewportRef.current.centerOn(
next.x,
next.y,
containerSize.width,
containerSize.height,
)
}
}, [selectedNode, nodes, containerSize.width, containerSize.height])
const navigatePrev = useCallback(() => {
if (!selectedNode) return
const node = nodes.find((n) => n.id === selectedNode)
if (!node) return
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]!
setSelectedNode(prev.id)
if (viewportRef.current)
viewportRef.current.centerOn(
prev.x,
prev.y,
containerSize.width,
containerSize.height,
)
} else {
const docId = "documentId" in node.data ? node.data.documentId : null
const siblings = nodes.filter(
(n) =>
n.type === "memory" &&
"documentId" in n.data &&
n.data.documentId === docId,
)
if (siblings.length === 0) return
const idx = siblings.findIndex((n) => n.id === selectedNode)
const prev = siblings[(idx - 1 + siblings.length) % siblings.length]!
setSelectedNode(prev.id)
if (viewportRef.current)
viewportRef.current.centerOn(
prev.x,
prev.y,
containerSize.width,
containerSize.height,
)
}
}, [selectedNode, nodes, containerSize.width, containerSize.height])
useHotkeys("up", navigateUp, [navigateUp])
useHotkeys("down", navigateDown, [navigateDown])
useHotkeys("right", navigateNext, [navigateNext])
useHotkeys("left", navigatePrev, [navigatePrev])
// Slideshow
useEffect(() => {
if (!isSlideshowActive || nodes.length === 0) {
if (!isSlideshowActive) {
setSelectedNode(null)
simulationRef.current?.coolDown()
}
return
}
let lastIdx = -1
const pick = () => {
if (nodes.length === 0) return
let idx: number
if (nodes.length > 1) {
do {
idx = Math.floor(Math.random() * nodes.length)
} while (idx === lastIdx)
} else {
idx = 0
}
lastIdx = idx
const n = nodes[idx]!
setSelectedNode(n.id)
viewportRef.current?.centerOn(
n.x,
n.y,
containerSize.width,
containerSize.height,
)
simulationRef.current?.reheat()
onSlideshowNodeChange?.(n.id)
setTimeout(() => simulationRef.current?.coolDown(), 1000)
}
pick()
const interval = setInterval(pick, 3500)
return () => clearInterval(interval)
}, [
isSlideshowActive,
nodes,
containerSize.width,
containerSize.height,
onSlideshowNodeChange,
])
// Active node: selected takes priority, then hovered
const activeNodeId = selectedNode ?? hoveredNode
const activeNodeData = useMemo(() => {
if (!activeNodeId) return null
return nodes.find((n) => n.id === activeNodeId) ?? null
}, [activeNodeId, nodes])
const activePopoverPosition = useMemo(() => {
if (!activeNodeData || !viewportRef.current) return null
const vp = viewportRef.current
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
return {
screenX: screen.x,
screenY: screen.y,
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
}
}, [activeNodeData])
const activeVersionChain = useMemo(() => {
if (!activeNodeData || activeNodeData.type !== "memory") return null
return chainIndex.current.getChain(activeNodeData.id)
}, [activeNodeData])
const isLoading = externalIsLoading || apiIsLoading
const error = externalError || apiError
if (error) {
return (
<div
className="h-full flex items-center justify-center"
style={{ backgroundColor: colors.background.primary }}
>
<div className="rounded-xl overflow-hidden">
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-300 px-6 py-4">
Error loading graph: {error.message}
</div>
</div>
</div>
)
}
return (
<div className="relative h-full rounded-xl overflow-hidden">
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={false}
totalLoaded={apiData.totalCount}
variant={variant}
/>
{!isLoading && !nodes.some((n) => n.type === "document") && children}
<div
className="w-full h-full relative overflow-hidden touch-none select-none"
ref={containerRef}
>
{containerSize.width > 0 && containerSize.height > 0 && (
<GraphCanvas
nodes={nodes}
edges={edges}
width={containerSize.width}
height={containerSize.height}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
selectedNodeId={selectedNode}
onNodeHover={handleNodeHover}
onNodeClick={handleNodeClick}
onNodeDragStart={handleNodeDragStart}
onNodeDragEnd={handleNodeDragEnd}
onViewportChange={handleViewportChange}
canvasRef={canvasRef}
variant={variant}
simulation={simulationRef.current ?? undefined}
viewportRef={viewportRef}
/>
)}
{activeNodeData && activePopoverPosition && (
<NodeHoverPopover
node={activeNodeData}
screenX={activePopoverPosition.screenX}
screenY={activePopoverPosition.screenY}
nodeRadius={activePopoverPosition.nodeRadius}
containerBounds={containerBounds ?? undefined}
versionChain={activeVersionChain}
onNavigateNext={navigateNext}
onNavigatePrev={navigatePrev}
onNavigateUp={navigateUp}
onNavigateDown={navigateDown}
onSelectNode={handleNodeClick}
/>
)}
<div>
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onAutoFit={handleAutoFit}
nodes={nodes}
className="absolute bottom-18 left-4 z-15"
zoomLevel={zoomDisplay}
/>
)}
<Legend
edges={edges}
id={legendId}
isLoading={isLoading}
nodes={nodes}
variant={variant}
/>
</div>
</div>
</div>
)
}

View file

@ -1,164 +0,0 @@
"use client"
import { memo } from "react"
import type { GraphNode } from "./types"
import { cn } from "@lib/utils"
import { Settings } from "lucide-react"
interface NavigationControlsProps {
onCenter: () => void
onZoomIn: () => void
onZoomOut: () => void
onAutoFit: () => void
nodes: GraphNode[]
className?: string
zoomLevel: number
}
// Keyboard shortcut badge component
const KeyboardShortcut = memo(function KeyboardShortcut({
keys,
}: {
keys: string
}) {
return (
<div
className="flex flex-row items-center gap-1 px-1.5 py-0.5 rounded"
style={{
background: "rgba(33, 33, 33, 0.5)",
border: "1px solid rgba(115, 115, 115, 0.2)",
}}
>
<span className="text-[10px] text-[#737373] font-medium leading-none">
{keys}
</span>
</div>
)
})
// Navigation buttons component
const NavigationButtons = memo(function NavigationButtons({
onAutoFit,
onCenter,
onZoomIn,
onZoomOut,
zoomLevel,
}: {
onAutoFit: () => void
onCenter: () => void
onZoomIn: () => void
onZoomOut: () => void
zoomLevel: number
}) {
return (
<div className="flex flex-col gap-1">
{/* Fit button */}
<button
type="button"
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
onClick={onAutoFit}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">Fit</span>
<KeyboardShortcut keys="Z" />
</button>
{/* Center button */}
<button
type="button"
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
onClick={onCenter}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">Center</span>
<KeyboardShortcut keys="C" />
</button>
{/* Zoom controls */}
<div
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">{zoomLevel}%</span>
<div className="flex flex-row items-center gap-0.5">
<button
type="button"
onClick={onZoomOut}
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<span className="text-xs"></span>
</button>
<button
type="button"
onClick={onZoomIn}
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<span className="text-xs">+</span>
</button>
</div>
</div>
</div>
)
})
const SettingsButton = memo(function SettingsButton() {
return (
<button
type="button"
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-white/10 transition-colors"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<Settings className="w-5 h-5 text-[#737373]" />
</button>
)
})
export const NavigationControls = memo<NavigationControlsProps>(
({
onCenter,
onZoomIn,
onZoomOut,
onAutoFit,
nodes,
className = "",
zoomLevel,
}) => {
if (nodes.length === 0) {
return null
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex flex-row items-end gap-2">
<NavigationButtons
onAutoFit={onAutoFit}
onCenter={onCenter}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
zoomLevel={zoomLevel}
/>
{/* Commented out for now as we are not using this */}
{/*<SettingsButton />*/}
</div>
</div>
)
},
)
NavigationControls.displayName = "NavigationControls"

View file

@ -1,467 +0,0 @@
"use client"
import { memo, useMemo, useCallback, useState } from "react"
import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types"
import type { ChainEntry } from "./canvas/version-chain"
export interface NodeHoverPopoverProps {
node: GraphNode
screenX: number
screenY: number
nodeRadius: number
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
}
function KeyBadge({ children }: { children: React.ReactNode }) {
return (
<span
className="inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-medium"
style={{
backgroundColor: "#181A1E",
border: "1px solid #2A2C2F",
color: "#737373",
}}
>
{children}
</span>
)
}
function NavButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
style={{ background: "none", border: "none", padding: "2px 0" }}
>
<KeyBadge>{icon}</KeyBadge>
<span
className="text-[11px] whitespace-nowrap"
style={{ color: "#525D6E" }}
>
{label}
</span>
</button>
)
}
function CopyableId({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [value])
const short =
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
return (
<button
type="button"
onClick={copy}
className="flex items-center gap-1.5 group cursor-pointer"
style={{ background: "none", border: "none", padding: 0 }}
>
<span className="text-[10px]" style={{ color: "#525D6E" }}>
{label}
</span>
<span
className="text-[10px] font-mono group-hover:text-white transition-colors"
style={{ color: "#737373" }}
>
{copied ? "Copied!" : short}
</span>
{!copied && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="#525D6E"
strokeWidth="2"
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
</button>
)
}
type Quadrant = "right" | "left" | "above" | "below"
function pickBestQuadrant(
screenX: number,
screenY: number,
nodeRadius: number,
containerWidth: number,
containerHeight: number,
popoverWidth: number,
popoverHeight: number,
): Quadrant {
const gap = 24
const spaceRight = containerWidth - (screenX + nodeRadius + gap)
const spaceLeft = screenX - nodeRadius - gap
const spaceAbove = screenY - nodeRadius - gap
const spaceBelow = containerHeight - (screenY + nodeRadius + gap)
const fits: [Quadrant, number][] = [
["right", spaceRight >= popoverWidth ? spaceRight : -1],
["left", spaceLeft >= popoverWidth ? spaceLeft : -1],
["above", spaceAbove >= popoverHeight ? spaceAbove : -1],
["below", spaceBelow >= popoverHeight ? spaceBelow : -1],
]
const preferred: Quadrant[] = ["right", "left", "below", "above"]
for (const q of preferred) {
const entry = fits.find(([dir]) => dir === q)
if (entry && entry[1] > 0) return q
}
return fits.sort((a, b) => b[1] - a[1])[0]![0]
}
function truncate(s: string, max: number) {
return s.length > max ? `${s.substring(0, max)}...` : s
}
function VersionTimeline({
chain,
currentId,
onSelect,
}: {
chain: ChainEntry[]
currentId: string
onSelect?: (id: string) => void
}) {
return (
<div className="flex flex-col gap-0 max-h-[120px] overflow-y-auto">
{chain.map((entry) => {
const isCurrent = entry.id === currentId
return (
<button
key={entry.id}
type="button"
onClick={() => onSelect?.(entry.id)}
className="flex items-start gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors"
style={{
background: isCurrent ? "#0A1825" : "transparent",
border: "none",
borderLeft: isCurrent ? "2px solid #36FDFD" : "2px solid #1A2333",
}}
>
<span
className="text-[10px] font-semibold shrink-0 mt-px"
style={{
color: entry.isForgotten
? "#DC2626"
: isCurrent
? "#36FDFD"
: "#525D6E",
}}
>
v{entry.version}
</span>
<span
className="text-[11px] leading-tight"
style={{
color: isCurrent ? "#9CA3AF" : "#4A5568",
}}
>
{truncate(entry.memory, 60)}
</span>
</button>
)
})}
</div>
)
}
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
function NodeHoverPopover({
node,
screenX,
screenY,
nodeRadius,
containerBounds,
versionChain,
onNavigateNext,
onNavigatePrev,
onNavigateUp,
onNavigateDown,
onSelectNode,
}) {
const CARD_W = 280
const SHORTCUTS_W = 100
const GAP = 24
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
const isMemory = node.type === "memory"
const data = node.data
const memoryMeta = useMemo(() => {
if (!isMemory) return null
const md = data as MemoryNodeData
return {
version: md.version ?? 1,
isLatest: md.isLatest ?? false,
isForgotten: md.isForgotten ?? false,
forgetReason: md.forgetReason ?? null,
forgetAfter: md.forgetAfter ?? null,
}
}, [isMemory, data])
const hasChain = versionChain && versionChain.length > 1
const hasForgetInfo =
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
const TOTAL_H = CARD_H
const { popoverX, popoverY, connectorPath } = useMemo(() => {
const cw = containerBounds?.width ?? 800
const ch = containerBounds?.height ?? 600
const quadrant = pickBestQuadrant(
screenX,
screenY,
nodeRadius,
cw,
ch,
TOTAL_W + GAP,
TOTAL_H,
)
let px: number
let py: number
let connStart: { x: number; y: number }
switch (quadrant) {
case "right":
px = screenX + nodeRadius + GAP
py = screenY - TOTAL_H / 2
connStart = { x: screenX + nodeRadius, y: screenY }
break
case "left":
px = screenX - nodeRadius - GAP - TOTAL_W
py = screenY - TOTAL_H / 2
connStart = { x: screenX - nodeRadius, y: screenY }
break
case "below":
px = screenX - TOTAL_W / 2
py = screenY + nodeRadius + GAP
connStart = { x: screenX, y: screenY + nodeRadius }
break
case "above":
px = screenX - TOTAL_W / 2
py = screenY - nodeRadius - GAP - TOTAL_H
connStart = { x: screenX, y: screenY - nodeRadius }
break
}
px = Math.max(8, Math.min(cw - TOTAL_W - 8, px))
py = Math.max(8, Math.min(ch - TOTAL_H - 8, py))
const cardCenterX = px + CARD_W / 2
const cardCenterY = py + TOTAL_H / 2
const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}`
return { popoverX: px, popoverY: py, connectorPath: path }
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
const content = useMemo(() => {
if (isMemory) {
const md = data as MemoryNodeData
return md.memory || md.content || ""
}
const dd = data as DocumentNodeData
return dd.summary || dd.title || ""
}, [isMemory, data])
const docData = !isMemory ? (data as DocumentNodeData) : null
return (
<div className="pointer-events-none absolute inset-0 z-[100]">
<svg
className="absolute inset-0 w-full h-full overflow-visible"
style={{ pointerEvents: "none" }}
>
<path
d={connectorPath}
stroke="#3B73B8"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
/>
</svg>
<div
className="absolute flex gap-3 pointer-events-auto"
style={{ left: popoverX, top: popoverY }}
>
{/* Card */}
<div
className="flex flex-col rounded-xl overflow-hidden"
style={{ width: CARD_W, backgroundColor: "#0C1829" }}
>
{/* Content — show timeline if chain exists, otherwise plain text */}
{hasChain ? (
<VersionTimeline
chain={versionChain}
currentId={node.id}
onSelect={onSelectNode}
/>
) : (
<div
className="p-3 overflow-hidden"
style={{ backgroundColor: "#060D17" }}
>
<p
className="m-0 leading-[135%]"
style={{
fontFamily: "'DM Sans', sans-serif",
fontSize: 12,
color: "#525D6E",
}}
>
{truncate(content, 100) || "No content"}
</p>
</div>
)}
{/* Forget info (memory-only) */}
{memoryMeta && hasForgetInfo && (
<div
className="px-3 py-1.5 flex flex-col gap-0.5"
style={{
backgroundColor: "#0A1320",
borderTop: "1px solid #1A2333",
}}
>
{memoryMeta.forgetAfter && (
<span className="text-[10px]" style={{ color: "#F59E0B" }}>
Expires:{" "}
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
</span>
)}
{memoryMeta.forgetReason && (
<span className="text-[10px]" style={{ color: "#8B8B8B" }}>
Reason: {memoryMeta.forgetReason}
</span>
)}
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
<span className="text-[10px]" style={{ color: "#EF4444" }}>
Forgotten
</span>
)}
</div>
)}
{/* Bottom bar */}
<div
className="flex items-center justify-between px-3 py-2"
style={{
backgroundColor: "#0C1829",
borderTop: "1px solid #1A2333",
}}
>
{memoryMeta ? (
<>
<span
className="text-xs font-medium"
style={{
color: memoryMeta.isForgotten
? "#DC2626"
: memoryMeta.isLatest
? "#05A376"
: "#525D6E",
}}
>
v{memoryMeta.version}{" "}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest
? "Latest"
: "Superseded"}
</span>
</>
) : (
<>
<span className="text-xs" style={{ color: "#525D6E" }}>
{docData?.type || "document"}
</span>
<span className="text-xs" style={{ color: "#525D6E" }}>
{docData?.memories?.length ?? 0} memories
</span>
</>
)}
</div>
{/* ID row */}
<div
className="px-3 py-1.5 flex items-center"
style={{
backgroundColor: "#080E18",
borderTop: "1px solid #1A2333",
}}
>
{isMemory ? (
<CopyableId label="Memory" value={node.id} />
) : (
<CopyableId label="Document" value={node.id} />
)}
</div>
</div>
{/* Navigation */}
<div
className="flex flex-col justify-center gap-1.5 px-3 py-2 rounded-lg"
style={{ backgroundColor: "#0C1829" }}
>
{isMemory && (
<NavButton
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : true) && (
<NavButton
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
onClick={onNavigateDown}
/>
)}
<NavButton
icon="→"
label={isMemory ? "Next memory" : "Next document"}
onClick={onNavigateNext}
/>
<NavButton
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
onClick={onNavigatePrev}
/>
</div>
</div>
</div>
)
},
)

View file

@ -1,362 +0,0 @@
"use client"
import { memo, useEffect } from "react"
import type { GraphNode } from "./types"
import { cn } from "@lib/utils"
export interface NodePopoverProps {
node: GraphNode
x: number // Screen X position
y: number // Screen Y position
onClose: () => void
containerBounds?: DOMRect // Optional container bounds to limit backdrop
onBackdropClick?: () => void // Optional callback when backdrop is clicked
}
export const NodePopover = memo<NodePopoverProps>(function NodePopover({
node,
x,
y,
onClose,
containerBounds,
onBackdropClick,
}) {
// Handle Escape key to close popover
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [onClose])
// Calculate backdrop bounds - use container bounds if provided, otherwise full viewport
const backdropStyle = containerBounds
? {
left: `${containerBounds.left}px`,
top: `${containerBounds.top}px`,
width: `${containerBounds.width}px`,
height: `${containerBounds.height}px`,
}
: undefined
const handleBackdropClick = () => {
onBackdropClick?.()
onClose()
}
return (
<>
{/* Invisible backdrop to catch clicks outside */}
<div
onClick={handleBackdropClick}
className={cn(
"fixed z-[999] pointer-events-auto bg-transparent",
!containerBounds && "inset-0",
)}
style={backdropStyle}
/>
{/* Popover content */}
<div
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]"
style={{
left: `${x}px`,
top: `${y}px`,
}}
>
{node.type === "document" ? (
// Document popover
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-400"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<h3 className="text-base font-bold text-white m-0">Document</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
>
×
</button>
</div>
{/* Sections */}
<div className="flex flex-col gap-3">
{/* Title */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Title
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).title || "Untitled Document"}
</p>
</div>
{/* Summary - truncated to 2 lines */}
{(node.data as any).summary && (
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Summary
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed line-clamp-2">
{(node.data as any).summary}
</p>
</div>
)}
{/* Type */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Type
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).type || "Document"}
</p>
</div>
{/* Memory Count */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Memory Count
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).memoryEntries?.length || 0} memories
</p>
</div>
{/* URL */}
{((node.data as any).url || (node.data as any).customId) && (
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
URL
</div>
<a
href={(() => {
const doc = node.data as any
if (doc.type === "google_doc" && doc.customId) {
return `https://docs.google.com/document/d/${doc.customId}`
}
if (doc.type === "google_sheet" && doc.customId) {
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
}
if (doc.type === "google_slide" && doc.customId) {
return `https://docs.google.com/presentation/d/${doc.customId}`
}
return doc.url ?? undefined
})()}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-400 no-underline flex items-center gap-1 transition-colors hover:text-indigo-300"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Document
</a>
</div>
)}
{/* Footer with metadata */}
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
<div className="flex items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className="overflow-hidden text-ellipsis">
{node.id}
</span>
</div>
</div>
</div>
</div>
) : (
// Memory popover
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-400"
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<h3 className="text-base font-bold text-white m-0">Memory</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
>
×
</button>
</div>
{/* Sections */}
<div className="flex flex-col gap-3">
{/* Memory content */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Memory
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).memory ||
(node.data as any).content ||
"No content"}
</p>
{(node.data as any).isForgotten && (
<div className="mt-2 px-2 py-1 bg-red-600/15 rounded text-xs text-red-400 inline-block">
Forgotten
</div>
)}
{/* Expires (inline with memory if exists) */}
{(node.data as any).forgetAfter && (
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Expires:{" "}
{new Date(
(node.data as any).forgetAfter,
).toLocaleDateString()}
{(node.data as any).forgetReason &&
` - ${(node.data as any).forgetReason}`}
</p>
)}
</div>
{/* Space */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Space
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).spaceId || "Default"}
</p>
</div>
{/* Footer with metadata */}
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
<div className="flex items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className="overflow-hidden text-ellipsis">
{node.id}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</>
)
})

View file

@ -1,163 +0,0 @@
import type {
DocumentsResponse,
DocumentWithMemories,
MemoryEntry,
} from "./api-types"
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
// Graph API types matching backend response
export interface GraphApiMemory {
id: string
memory: string
isStatic: boolean
spaceId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
rootMemoryId: string | null
createdAt: string
updatedAt: string
}
export interface GraphApiDocument {
id: string
title: string | null
summary: string | null
documentType: string
createdAt: string
updatedAt: string
x: number // backend coordinates (dynamic range)
y: number // backend coordinates (dynamic range)
memories: GraphApiMemory[]
}
export interface GraphApiEdge {
source: string
target: string
similarity: number // 0-1
}
export interface GraphViewportResponse {
documents: GraphApiDocument[]
edges: GraphApiEdge[]
viewport: {
minX: number
maxX: number
minY: number
maxY: number
}
totalCount: number
}
export interface GraphBoundsResponse {
bounds: {
minX: number
maxX: number
minY: number
maxY: number
} | null
}
export interface GraphStatsResponse {
totalDocuments: number
documentsWithSpatial: number
totalDocumentEdges: number
}
// Typed node data
export interface DocumentNodeData {
id: string
title: string | null
summary: string | null
type: string
createdAt: string
updatedAt: string
memories: GraphApiMemory[]
}
export interface MemoryNodeData {
id: string
memory: string
content: string
documentId: string
isStatic: boolean
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
spaceId: string
createdAt: string
updatedAt: string
}
export interface GraphNode {
id: string
type: "document" | "memory"
x: number
y: number
data: DocumentNodeData | MemoryNodeData
size: number
borderColor: string
isHovered: boolean
isDragging: boolean
// D3-force simulation properties
vx?: number
vy?: number
fx?: number | null
fy?: number | null
}
export interface GraphEdge {
id: string
source: string | GraphNode
target: string | GraphNode
similarity: number
visualProps: {
opacity: number
thickness: number
}
edgeType: "doc-memory" | "similarity" | "version"
}
export interface GraphCanvasProps {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
highlightDocumentIds?: string[]
selectedNodeId?: string | null
onNodeHover: (nodeId: string | null) => void
onNodeClick: (nodeId: string | null) => void
onNodeDragStart: (nodeId: string) => void
onNodeDragEnd: () => void
onViewportChange?: (zoom: number) => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
variant?: "console" | "consumer"
simulation?: import("./canvas/simulation").ForceSimulation
viewportRef?: React.RefObject<
import("./canvas/viewport").ViewportState | null
>
}
export interface LegendProps {
variant?: "console" | "consumer"
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
hoveredNode?: string | null
}
export interface LoadingIndicatorProps {
isLoading: boolean
isLoadingMore: boolean
totalLoaded: number
variant?: "console" | "consumer"
}