mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-04 22:50:08 +00:00
chore: remove the new folder and fix imports (#740)
This commit is contained in:
parent
15613c2421
commit
1b1b34fb66
106 changed files with 442 additions and 163 deletions
79
apps/web/components/memory-graph/api-types.ts
Normal file
79
apps/web/components/memory-graph/api-types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
70
apps/web/components/memory-graph/canvas/hit-test.ts
Normal file
70
apps/web/components/memory-graph/canvas/hit-test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
324
apps/web/components/memory-graph/canvas/input-handler.ts
Normal file
324
apps/web/components/memory-graph/canvas/input-handler.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
681
apps/web/components/memory-graph/canvas/renderer.ts
Normal file
681
apps/web/components/memory-graph/canvas/renderer.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
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()
|
||||
}
|
||||
79
apps/web/components/memory-graph/canvas/simulation.ts
Normal file
79
apps/web/components/memory-graph/canvas/simulation.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/web/components/memory-graph/canvas/version-chain.ts
Normal file
63
apps/web/components/memory-graph/canvas/version-chain.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
172
apps/web/components/memory-graph/canvas/viewport.ts
Normal file
172
apps/web/components/memory-graph/canvas/viewport.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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
|
||||
}
|
||||
62
apps/web/components/memory-graph/constants.ts
Normal file
62
apps/web/components/memory-graph/constants.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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,
|
||||
}
|
||||
248
apps/web/components/memory-graph/graph-canvas.tsx
Normal file
248
apps/web/components/memory-graph/graph-canvas.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"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" }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
202
apps/web/components/memory-graph/graph-card.tsx
Normal file
202
apps/web/components/memory-graph/graph-card.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Expand } from "lucide-react"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
export interface GraphCardProps {
|
||||
containerTags?: string[]
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Simple seeded random for deterministic node positions
|
||||
function seededRandom(seed: number) {
|
||||
let s = seed
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647
|
||||
return s / 2147483647
|
||||
}
|
||||
}
|
||||
|
||||
function StaticGraphPreview({
|
||||
documentCount,
|
||||
memoryCount,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
documentCount: number
|
||||
memoryCount: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const nodes = useMemo(() => {
|
||||
const rand = seededRandom(42)
|
||||
const count = Math.min(documentCount + memoryCount, 30)
|
||||
const docCount = Math.min(documentCount, 12)
|
||||
const result: {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
color: string
|
||||
opacity: number
|
||||
}[] = []
|
||||
|
||||
const pad = 20
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isDoc = i < docCount
|
||||
result.push({
|
||||
x: pad + rand() * (width - pad * 2),
|
||||
y: pad + rand() * (height - pad * 2),
|
||||
r: isDoc ? 4 + rand() * 3 : 2 + rand() * 2,
|
||||
color: isDoc ? "#4BA0FA" : "#36FDFD",
|
||||
opacity: 0.4 + rand() * 0.4,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [documentCount, memoryCount, width, height])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (nodes.length < 2) return []
|
||||
const rand = seededRandom(123)
|
||||
const result: { x1: number; y1: number; x2: number; y2: number }[] = []
|
||||
const edgeCount = Math.min(nodes.length - 1, 20)
|
||||
for (let i = 0; i < edgeCount; i++) {
|
||||
const a = Math.floor(rand() * nodes.length)
|
||||
let b = Math.floor(rand() * nodes.length)
|
||||
if (b === a) b = (a + 1) % nodes.length
|
||||
result.push({
|
||||
x1: nodes[a]!.x,
|
||||
y1: nodes[a]!.y,
|
||||
x2: nodes[b]!.x,
|
||||
y2: nodes[b]!.y,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="absolute inset-0"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{edges.map((e, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={e.x1}
|
||||
y1={e.y1}
|
||||
x2={e.x2}
|
||||
y2={e.y2}
|
||||
stroke="#4BA0FA"
|
||||
strokeOpacity={0.15}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{nodes.map((n, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.r}
|
||||
fill={n.color}
|
||||
opacity={n.opacity}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphCard = memo<GraphCardProps>(
|
||||
({ containerTags, width = 216, height = 220, className }) => {
|
||||
const { setViewMode } = useViewMode()
|
||||
|
||||
const { data, isLoading, error } = useGraphApi({
|
||||
containerTags,
|
||||
limit: 20,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col items-center justify-center",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<p className="text-[10px] text-red-400 text-center">
|
||||
Failed to load graph
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const documentCount = data.stats?.documentsWithSpatial ?? 0
|
||||
const memoryCount = data.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col cursor-pointer transition-all hover:border-[rgba(255,255,255,0.1)] hover:bg-[#0f1419] group relative overflow-hidden",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="flex-1 w-full relative overflow-hidden rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : documentCount > 0 || memoryCount > 0 ? (
|
||||
<StaticGraphPreview
|
||||
documentCount={documentCount}
|
||||
memoryCount={memoryCount}
|
||||
width={width - 24}
|
||||
height={height - 56}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-[10px] text-[#737373] text-center">
|
||||
No documents yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-colors">
|
||||
<Expand className="w-5 h-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
{documentCount} docs
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA]">
|
||||
{memoryCount} memories
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#737373] group-hover:text-white transition-colors">
|
||||
View graph
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
GraphCard.displayName = "GraphCard"
|
||||
273
apps/web/components/memory-graph/hooks/use-graph-api.ts
Normal file
273
apps/web/components/memory-graph/hooks/use-graph-api.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type {
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
} from "../types"
|
||||
|
||||
interface ViewportParams {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
interface UseGraphApiOptions {
|
||||
containerTags?: string[]
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
documentIds?: string[]
|
||||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, limit = 200, 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", {
|
||||
body: {
|
||||
viewport: {
|
||||
minX: viewport.minX,
|
||||
maxX: viewport.maxX,
|
||||
minY: viewport.minY,
|
||||
maxY: viewport.maxY,
|
||||
},
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphViewportResponse
|
||||
},
|
||||
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 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
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
289
apps/web/components/memory-graph/hooks/use-graph-data.ts
Normal file
289
apps/web/components/memory-graph/hooks/use-graph-data.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"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),
|
||||
}
|
||||
}
|
||||
33
apps/web/components/memory-graph/index.ts
Normal file
33
apps/web/components/memory-graph/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Memory Graph components
|
||||
export { MemoryGraph } from "./memory-graph"
|
||||
export type { MemoryGraphProps } from "./memory-graph"
|
||||
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
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./types"
|
||||
599
apps/web/components/memory-graph/legend.tsx
Normal file
599
apps/web/components/memory-graph/legend.tsx
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
"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 > 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"
|
||||
32
apps/web/components/memory-graph/loading-indicator.tsx
Normal file
32
apps/web/components/memory-graph/loading-indicator.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"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"
|
||||
515
apps/web/components/memory-graph/memory-graph.tsx
Normal file
515
apps/web/components/memory-graph/memory-graph.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
"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.filter((n) => n.type === "document").length === 0 &&
|
||||
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>
|
||||
)
|
||||
}
|
||||
164
apps/web/components/memory-graph/navigation-controls.tsx
Normal file
164
apps/web/components/memory-graph/navigation-controls.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"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"
|
||||
467
apps/web/components/memory-graph/node-hover-popover.tsx
Normal file
467
apps/web/components/memory-graph/node-hover-popover.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"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>
|
||||
)
|
||||
},
|
||||
)
|
||||
362
apps/web/components/memory-graph/node-popover.tsx
Normal file
362
apps/web/components/memory-graph/node-popover.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
163
apps/web/components/memory-graph/types.ts
Normal file
163
apps/web/components/memory-graph/types.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue