mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers (#809)
Co-authored-by: Vorflux AI <noreply@vorflux.com>
This commit is contained in:
parent
38282a37d6
commit
851b8cfe86
92 changed files with 6791 additions and 12118 deletions
|
|
@ -45,52 +45,41 @@ export interface Project {
|
|||
documentCount?: number
|
||||
}
|
||||
|
||||
// Graph API types
|
||||
export interface GraphApiMemory {
|
||||
// Documents API types
|
||||
export interface DocumentMemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
spaceId: string
|
||||
isStatic?: boolean
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
export interface DocumentWithMemories {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
summary?: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
memoryEntries: DocumentMemoryEntry[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
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 DocumentsApiResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getMemoryText(m: Memory): string {
|
||||
|
|
@ -171,9 +160,13 @@ export class SupermemoryClient {
|
|||
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
|
||||
containerTag: this.containerTag,
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// If not 404, it's a real error - re-throw it
|
||||
if (error?.status !== 404) {
|
||||
const status =
|
||||
error && typeof error === "object" && "status" in error
|
||||
? (error as Record<string, unknown>).status
|
||||
: undefined
|
||||
if (status !== 404) {
|
||||
throw error
|
||||
}
|
||||
// Otherwise continue to semantic search fallback
|
||||
|
|
@ -332,53 +325,33 @@ export class SupermemoryClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch graph bounds for coordinate range
|
||||
async getGraphBounds(containerTags?: string[]): Promise<GraphBoundsResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}`
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph bounds"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphBoundsResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch graph data for a viewport region
|
||||
async getGraphViewport(
|
||||
viewport: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
// Fetch documents with their memory entries
|
||||
async getDocuments(
|
||||
containerTags?: string[],
|
||||
page = 1,
|
||||
limit = 200,
|
||||
): Promise<GraphViewportResponse> {
|
||||
): Promise<DocumentsApiResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, {
|
||||
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewport, containerTags, limit }),
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph viewport"), {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphViewportResponse
|
||||
return (await response.json()) as DocumentsApiResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,21 +311,14 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
|
||||
const [bounds, viewport] = await Promise.all([
|
||||
client.getGraphBounds(containerTags),
|
||||
client.getGraphViewport(
|
||||
{ minX: 0, maxX: 1000, minY: 0, maxY: 1000 },
|
||||
containerTags,
|
||||
200,
|
||||
),
|
||||
])
|
||||
const result = await client.getDocuments(containerTags, 1, 200)
|
||||
|
||||
const memoryCount = viewport.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
const memoryCount = result.documents.reduce(
|
||||
(sum, d) => sum + d.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
const textParts = [
|
||||
`Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`,
|
||||
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
|
||||
]
|
||||
if (effectiveContainerTag) {
|
||||
textParts.push(`Project: ${effectiveContainerTag}`)
|
||||
|
|
@ -335,10 +328,8 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
content: [{ type: "text" as const, text: textParts.join(". ") }],
|
||||
structuredContent: {
|
||||
containerTag: effectiveContainerTag,
|
||||
bounds: bounds.bounds,
|
||||
documents: viewport.documents,
|
||||
edges: viewport.edges,
|
||||
totalCount: viewport.totalCount,
|
||||
documents: result.documents,
|
||||
totalCount: result.pagination.totalItems,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -359,20 +350,15 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
},
|
||||
)
|
||||
|
||||
// App-only tool for the UI to fetch additional graph data
|
||||
// App-only tool for the UI to fetch additional documents (pagination)
|
||||
registerAppTool(
|
||||
this.server,
|
||||
"fetch-graph-data",
|
||||
{
|
||||
description: "Fetch graph data for a viewport region",
|
||||
description: "Fetch documents with memories for graph display",
|
||||
inputSchema: z.object({
|
||||
containerTag: z.string().optional(),
|
||||
viewport: z.object({
|
||||
minX: z.number(),
|
||||
maxX: z.number(),
|
||||
minY: z.number(),
|
||||
maxY: z.number(),
|
||||
}),
|
||||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(200),
|
||||
}),
|
||||
_meta: {
|
||||
|
|
@ -385,12 +371,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: {
|
||||
containerTag?: string
|
||||
viewport: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
page?: number
|
||||
limit?: number
|
||||
}) => {
|
||||
try {
|
||||
|
|
@ -400,9 +381,9 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
const containerTags = effectiveContainerTag
|
||||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
const data = await client.getGraphViewport(
|
||||
args.viewport,
|
||||
const data = await client.getDocuments(
|
||||
containerTags,
|
||||
args.page,
|
||||
args.limit,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ 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
|
||||
relation?: "updates" | "extends" | "derives" | null
|
||||
memoryRelations?: Record<string, "updates" | "extends" | "derives"> | null
|
||||
}
|
||||
|
||||
interface GraphApiDocument {
|
||||
|
|
@ -43,22 +48,12 @@ interface GraphApiDocument {
|
|||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ToolResultData {
|
||||
containerTag?: string
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
|
|
@ -91,8 +86,7 @@ type GraphNode = MemoryNode | DocumentNode
|
|||
interface GraphLink extends LinkObject {
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
edgeType: "doc-memory" | "version" | "similarity"
|
||||
similarity?: number
|
||||
edgeType: "derives" | "updates" | "extends"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -107,14 +101,14 @@ const MEMORY_BORDER = {
|
|||
|
||||
const EDGE_COLORS = {
|
||||
dark: {
|
||||
"doc-memory": "#4A5568",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#00D4B8",
|
||||
derives: "#38BDF8",
|
||||
updates: "#A78BFA",
|
||||
extends: "#2DD4BF",
|
||||
},
|
||||
light: {
|
||||
"doc-memory": "#A0AEC0",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#0D9488",
|
||||
derives: "#7DD3FC",
|
||||
updates: "#A78BFA",
|
||||
extends: "#5EEAD4",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -157,33 +151,20 @@ function getMemoryBorderColor(mem: GraphApiMemory): string {
|
|||
return MEMORY_BORDER.default
|
||||
}
|
||||
|
||||
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)
|
||||
/** Simple hash to get deterministic initial positions from doc ID */
|
||||
function hashCode(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
// Small spread so documents start near each other.
|
||||
// The force simulation will naturally separate them.
|
||||
const SPREAD = 50
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
x: ((doc.x - minX) / rangeX - 0.5) * SPREAD,
|
||||
y: ((doc.y - minY) / rangeY - 0.5) * SPREAD,
|
||||
}))
|
||||
function initialPosition(id: string, spread: number): { x: number; y: number } {
|
||||
const h = hashCode(id)
|
||||
const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2
|
||||
const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread
|
||||
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }
|
||||
}
|
||||
|
||||
function transformData(data: ToolResultData): {
|
||||
|
|
@ -192,11 +173,18 @@ function transformData(data: ToolResultData): {
|
|||
} {
|
||||
const nodes: GraphNode[] = []
|
||||
const links: GraphLink[] = []
|
||||
const SPREAD = 50
|
||||
|
||||
// Pre-populate all node IDs so edge targets are always resolvable
|
||||
// regardless of iteration order.
|
||||
const nodeIds = new Set<string>()
|
||||
for (const doc of data.documents) {
|
||||
nodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) nodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
const normalizedDocs = normalizeDocCoordinates(data.documents)
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
for (const doc of data.documents) {
|
||||
const pos = initialPosition(doc.id, SPREAD)
|
||||
nodes.push({
|
||||
id: doc.id,
|
||||
nodeType: "document",
|
||||
|
|
@ -205,10 +193,9 @@ function transformData(data: ToolResultData): {
|
|||
docType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
memoryCount: doc.memories.length,
|
||||
x: doc.x,
|
||||
y: doc.y,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
} as DocumentNode)
|
||||
nodeIds.add(doc.id)
|
||||
|
||||
const memCount = doc.memories.length
|
||||
for (let i = 0; i < memCount; i++) {
|
||||
|
|
@ -227,34 +214,38 @@ function transformData(data: ToolResultData): {
|
|||
parentMemoryId: mem.parentMemoryId,
|
||||
createdAt: mem.createdAt,
|
||||
borderColor: getMemoryBorderColor(mem),
|
||||
x: doc.x + Math.cos(angle) * CLUSTER_SPREAD,
|
||||
y: doc.y + Math.sin(angle) * CLUSTER_SPREAD,
|
||||
x: pos.x + Math.cos(angle) * CLUSTER_SPREAD,
|
||||
y: pos.y + Math.sin(angle) * CLUSTER_SPREAD,
|
||||
} as MemoryNode)
|
||||
nodeIds.add(mem.id)
|
||||
|
||||
// Doc-memory link
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" })
|
||||
// Derives link (doc -> memory)
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
|
||||
|
||||
// Version chain link
|
||||
if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) {
|
||||
links.push({
|
||||
source: mem.parentMemoryId,
|
||||
target: mem.id,
|
||||
edgeType: "version",
|
||||
})
|
||||
// Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations as primary source, falls back to parentMemoryId.
|
||||
// Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts
|
||||
let relations: Record<string, string> = {}
|
||||
if (
|
||||
// Defensive: data comes from structuredContent cast, may be unexpected type
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similarity edges from API
|
||||
for (const edge of data.edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
links.push({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
edgeType: "similarity",
|
||||
similarity: edge.similarity,
|
||||
})
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!nodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
links.push({ source: targetId, target: mem.id, edgeType })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +308,7 @@ function drawDocumentNode(
|
|||
// =============================================================================
|
||||
function getLinkColor(link: GraphLink): string {
|
||||
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
|
||||
return palette[link.edgeType] || palette["doc-memory"]
|
||||
return palette[link.edgeType] || palette["derives"]
|
||||
}
|
||||
|
||||
const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
||||
|
|
@ -370,18 +361,17 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
|||
},
|
||||
)
|
||||
.linkWidth((link: GraphLink) => {
|
||||
if (link.edgeType === "version") return 2
|
||||
if (link.edgeType === "similarity")
|
||||
return 0.5 + (link.similarity || 0) * 1.5
|
||||
if (link.edgeType === "updates") return 2
|
||||
if (link.edgeType === "extends") return 0.5
|
||||
return 1
|
||||
})
|
||||
.linkColor(getLinkColor)
|
||||
.linkLineDash((link: GraphLink) => {
|
||||
if (link.edgeType === "similarity") return [4, 2]
|
||||
if (link.edgeType === "extends") return [4, 2]
|
||||
return null as unknown as number[]
|
||||
})
|
||||
.linkDirectionalArrowLength((link: GraphLink) =>
|
||||
link.edgeType === "version" ? 4 : 0,
|
||||
link.edgeType === "updates" ? 4 : 0,
|
||||
)
|
||||
.linkDirectionalArrowRelPos(1)
|
||||
.onNodeClick(handleNodeClick)
|
||||
|
|
@ -395,11 +385,11 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
|||
.d3Force(
|
||||
"link",
|
||||
forceLink()
|
||||
.distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80))
|
||||
.distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80))
|
||||
.strength((l: GraphLink) => {
|
||||
if (l.edgeType === "doc-memory") return 0.8
|
||||
if (l.edgeType === "version") return 1.0
|
||||
return (l.similarity || 0.3) * 0.3
|
||||
if (l.edgeType === "derives") return 0.8
|
||||
if (l.edgeType === "updates") return 1.0
|
||||
return 0.15 // extends
|
||||
}),
|
||||
)
|
||||
.d3Force("collide", forceCollide(18))
|
||||
|
|
|
|||
2
apps/memory-graph-playground/next-env.d.ts
vendored
2
apps/memory-graph-playground/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import {
|
||||
MemoryGraph,
|
||||
type DocumentWithMemories,
|
||||
type GraphApiDocument,
|
||||
type GraphApiMemory,
|
||||
} from "@supermemory/memory-graph"
|
||||
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
|
||||
|
||||
interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
|
|
@ -16,24 +19,50 @@ interface DocumentsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/** Convert the external API format to the internal graph format */
|
||||
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.content,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [showGraph, setShowGraph] = useState(false)
|
||||
|
||||
// State for controlled space selection
|
||||
const [selectedSpace, setSelectedSpace] = useState<string>("all")
|
||||
const [stressTestCount, setStressTestCount] = useState(0)
|
||||
|
||||
// State for slideshow
|
||||
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
|
||||
const [currentSlideshowNode, setCurrentSlideshowNode] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
||||
// Mock data for stress testing
|
||||
const [mockData, setMockData] = useState<{
|
||||
documents: GraphApiDocument[]
|
||||
} | null>(null)
|
||||
|
||||
const PAGE_SIZE = 500
|
||||
|
||||
|
|
@ -43,8 +72,6 @@ export default function Home() {
|
|||
|
||||
if (page === 1) {
|
||||
setIsLoading(true)
|
||||
} else {
|
||||
setIsLoadingMore(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
|
|
@ -76,43 +103,37 @@ export default function Home() {
|
|||
setDocuments(data.documents)
|
||||
}
|
||||
|
||||
setCurrentPage(data.pagination.currentPage)
|
||||
setHasMore(data.pagination.currentPage < data.pagination.totalPages)
|
||||
setShowGraph(true)
|
||||
setMockData(null)
|
||||
setStressTestCount(0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Unknown error"))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
},
|
||||
[apiKey],
|
||||
)
|
||||
|
||||
const loadMoreDocuments = useCallback(async () => {
|
||||
if (hasMore && !isLoadingMore) {
|
||||
await fetchDocuments(currentPage + 1, true)
|
||||
}
|
||||
}, [hasMore, isLoadingMore, currentPage, fetchDocuments])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (apiKey) {
|
||||
setDocuments([])
|
||||
setCurrentPage(0)
|
||||
setSelectedSpace("all")
|
||||
fetchDocuments(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle space change
|
||||
const handleSpaceChange = useCallback((spaceId: string) => {
|
||||
setSelectedSpace(spaceId)
|
||||
}, [])
|
||||
|
||||
// Reset to defaults
|
||||
const handleReset = () => {
|
||||
setSelectedSpace("all")
|
||||
const handleStressTest = (count: number) => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: count,
|
||||
memoriesPerDoc: [2, 5],
|
||||
seed: 12345,
|
||||
})
|
||||
setMockData({ documents: data.documents })
|
||||
setDocuments([])
|
||||
setStressTestCount(count)
|
||||
setShowGraph(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Toggle slideshow
|
||||
|
|
@ -122,16 +143,22 @@ export default function Home() {
|
|||
|
||||
// Handle slideshow node change
|
||||
const handleSlideshowNodeChange = useCallback((nodeId: string | null) => {
|
||||
// Track which node is being shown in slideshow
|
||||
setCurrentSlideshowNode(nodeId)
|
||||
console.log("Slideshow showing node:", nodeId)
|
||||
}, [])
|
||||
|
||||
// Handle slideshow stop (when user clicks outside)
|
||||
// Handle slideshow stop
|
||||
const handleSlideshowStop = useCallback(() => {
|
||||
setIsSlideshowActive(false)
|
||||
}, [])
|
||||
|
||||
// Convert real documents to graph format
|
||||
const graphDocuments = useMemo(() => {
|
||||
if (mockData) return mockData.documents
|
||||
return toGraphDocuments(documents)
|
||||
}, [documents, mockData])
|
||||
|
||||
const displayCount = mockData ? stressTestCount : documents.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
|
|
@ -165,68 +192,65 @@ export default function Home() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{/* State Display Panel - For Testing */}
|
||||
{showGraph && (
|
||||
<div className="shrink-0 border-b border-zinc-800 bg-zinc-900/50 px-6 py-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">Selected Space:</span>
|
||||
<span className="font-mono text-blue-400">{selectedSpace}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">Documents:</span>
|
||||
<span className="font-mono text-emerald-400">
|
||||
{documents.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Controls Panel */}
|
||||
<div className="shrink-0 border-b border-zinc-800 bg-zinc-900/50 px-6 py-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">Documents:</span>
|
||||
<span className="font-mono text-emerald-400">{displayCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{stressTestCount > 0 && (
|
||||
<span className="rounded bg-amber-900/50 px-2 py-0.5 text-xs text-amber-400">
|
||||
Stress Test Mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Stress test buttons */}
|
||||
<span className="text-zinc-500 text-xs">Stress Test:</span>
|
||||
{[50, 100, 200, 500].map((count) => (
|
||||
<button
|
||||
onClick={handleToggleSlideshow}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||
isSlideshowActive
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
key={count}
|
||||
type="button"
|
||||
onClick={() => handleStressTest(count)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
stressTestCount === count
|
||||
? "bg-amber-600 text-white"
|
||||
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{isSlideshowActive ? (
|
||||
<>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
</svg>
|
||||
Slideshow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
Slideshow
|
||||
</>
|
||||
)}
|
||||
{count} docs
|
||||
</button>
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800"
|
||||
))}
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleSlideshow}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||
isSlideshowActive
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
{isSlideshowActive ? (
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
) : (
|
||||
<path d="M8 5v14l11-7z" />
|
||||
)}
|
||||
</svg>
|
||||
Slideshow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
|
|
@ -239,6 +263,7 @@ export default function Home() {
|
|||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
|
@ -252,21 +277,20 @@ export default function Home() {
|
|||
Get Started
|
||||
</h2>
|
||||
<p className="mb-6 text-zinc-400">
|
||||
Enter your Supermemory API key above to visualize your memory
|
||||
graph.
|
||||
Enter your API key above, or click a stress test button to
|
||||
generate mock data.
|
||||
</p>
|
||||
<div className="text-left text-sm text-zinc-500">
|
||||
<p className="mb-2 font-medium text-zinc-400">
|
||||
Features to test:
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>✨ Search and filter by spaces</li>
|
||||
<li>✨ Arrow key navigation in spaces dropdown</li>
|
||||
<li>Pan and zoom the graph</li>
|
||||
<li>Click on nodes to see details</li>
|
||||
<li>Drag nodes around</li>
|
||||
<li>Filter by space</li>
|
||||
<li>Pagination loads more documents</li>
|
||||
<li>Arrow key navigation</li>
|
||||
<li>Stress test with 50-500 documents</li>
|
||||
<li>FPS counter (shown during stress tests)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -274,20 +298,12 @@ export default function Home() {
|
|||
) : (
|
||||
<div className="h-full w-full">
|
||||
<MemoryGraph
|
||||
documents={documents}
|
||||
documents={graphDocuments}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
error={error}
|
||||
hasMore={hasMore}
|
||||
loadMoreDocuments={loadMoreDocuments}
|
||||
totalLoaded={documents.length}
|
||||
variant="consumer"
|
||||
// Controlled space selection
|
||||
selectedSpace={selectedSpace}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
// Node limit - prevents performance issues with large graphs
|
||||
maxNodes={500}
|
||||
// Slideshow control
|
||||
maxNodes={1000}
|
||||
showFps={stressTestCount > 0}
|
||||
isSlideshowActive={isSlideshowActive}
|
||||
onSlideshowNodeChange={handleSlideshowNodeChange}
|
||||
onSlideshowStop={handleSlideshowStop}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { memo, useCallback, useRef } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import Image from "next/image"
|
||||
import { MemoryGraph } from "./memory-graph/memory-graph"
|
||||
import { MemoryGraph } from "./memory-graph"
|
||||
import { useProject } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
// Standalone TypeScript types for Memory Graph
|
||||
// These mirror the API response types from @repo/validation/api
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string
|
||||
customId?: string | null
|
||||
documentId: string
|
||||
content: string | null
|
||||
summary?: string | null
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
type?: string | null
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
embedding?: number[] | null
|
||||
embeddingModel?: string | null
|
||||
tokenCount?: number | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
// Fields from join relationship
|
||||
sourceAddedAt?: Date | null
|
||||
sourceRelevanceScore?: number | null
|
||||
sourceMetadata?: Record<string, unknown> | null
|
||||
spaceContainerTag?: string | null
|
||||
// Version chain fields
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
relation?: "updates" | "extends" | "derives" | null
|
||||
// Memory status fields
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: Date | string | null
|
||||
isLatest?: boolean
|
||||
// Space/container fields
|
||||
spaceId?: string | null
|
||||
// Legacy fields
|
||||
memory?: string | null
|
||||
memoryRelations?: Array<{
|
||||
relationType: "updates" | "extends" | "derives"
|
||||
targetMemoryId: string
|
||||
}> | null
|
||||
parentMemoryId?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentWithMemories {
|
||||
id: string
|
||||
customId?: string | null
|
||||
contentHash: string | null
|
||||
orgId: string
|
||||
userId: string
|
||||
connectionId?: string | null
|
||||
title?: string | null
|
||||
content?: string | null
|
||||
summary?: string | null
|
||||
url?: string | null
|
||||
source?: string | null
|
||||
type?: string | null
|
||||
status: "pending" | "processing" | "done" | "failed"
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
processingMetadata?: Record<string, unknown> | null
|
||||
raw?: string | null
|
||||
tokenCount?: number | null
|
||||
wordCount?: number | null
|
||||
chunkCount?: number | null
|
||||
averageChunkSize?: number | null
|
||||
summaryEmbedding?: number[] | null
|
||||
summaryEmbeddingModel?: string | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
memoryEntries: MemoryEntry[]
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
|
@ -1,681 +0,0 @@
|
|||
import type { ViewportState } from "./viewport"
|
||||
import type { GraphNode, GraphEdge, DocumentNodeData } from "../types"
|
||||
|
||||
export interface RenderState {
|
||||
selectedNodeId: string | null
|
||||
hoveredNodeId: string | null
|
||||
highlightIds: Set<string>
|
||||
dimProgress: number
|
||||
}
|
||||
|
||||
export function renderFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
): void {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
drawDocDocLines(ctx, nodes, viewport, width, height)
|
||||
drawEdges(ctx, edges, viewport, width, height, state, nodeMap)
|
||||
drawNodes(ctx, nodes, viewport, width, height, state)
|
||||
}
|
||||
|
||||
// Connect each visible doc to its 2 nearest neighbors
|
||||
function drawDocDocLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const docs: { x: number; y: number }[] = []
|
||||
for (const n of nodes) {
|
||||
if (n.type !== "document") continue
|
||||
const s = viewport.worldToScreen(n.x, n.y)
|
||||
if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) {
|
||||
docs.push(s)
|
||||
}
|
||||
}
|
||||
if (docs.length < 2) return
|
||||
|
||||
ctx.strokeStyle = "#8DA3F4"
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.setLineDash([4, 6])
|
||||
ctx.beginPath()
|
||||
|
||||
// Deduplicate: only draw line when i < neighbor index
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const d = docs[i]!
|
||||
let best1 = -1
|
||||
let best2 = -1
|
||||
let dist1 = Number.POSITIVE_INFINITY
|
||||
let dist2 = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let j = 0; j < docs.length; j++) {
|
||||
if (j === i) continue
|
||||
const dx = docs[j]!.x - d.x
|
||||
const dy = docs[j]!.y - d.y
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < dist1) {
|
||||
best2 = best1
|
||||
dist2 = dist1
|
||||
best1 = j
|
||||
dist1 = dist
|
||||
} else if (dist < dist2) {
|
||||
best2 = j
|
||||
dist2 = dist
|
||||
}
|
||||
}
|
||||
|
||||
if (best1 >= 0 && i < best1) {
|
||||
ctx.moveTo(d.x, d.y)
|
||||
ctx.lineTo(docs[best1]!.x, docs[best1]!.y)
|
||||
}
|
||||
if (best2 >= 0 && i < best2) {
|
||||
ctx.moveTo(d.x, d.y)
|
||||
ctx.lineTo(docs[best2]!.x, docs[best2]!.y)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// --- Edges ---
|
||||
|
||||
const EDGE_STYLE: Record<string, { color: string; width: number }> = {
|
||||
"doc-memory": { color: "#4A5568", width: 1.5 },
|
||||
version: { color: "#8B5CF6", width: 2 },
|
||||
}
|
||||
|
||||
const SIM_STRONG = { color: "#00D4B8", width: 2 } as const
|
||||
const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const
|
||||
const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const
|
||||
|
||||
function edgeStyle(edge: GraphEdge): { color: string; width: number } {
|
||||
const preset = EDGE_STYLE[edge.edgeType]
|
||||
if (preset) return preset
|
||||
if (edge.similarity >= 0.9) return SIM_STRONG
|
||||
if (edge.similarity >= 0.8) return SIM_MEDIUM
|
||||
return SIM_WEAK
|
||||
}
|
||||
|
||||
// Unique key for batching: "color|width"
|
||||
function batchKey(style: { color: string; width: number }): string {
|
||||
return `${style.color}|${style.width}`
|
||||
}
|
||||
|
||||
interface PreparedEdge {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
connected: boolean
|
||||
style: { color: string; width: number }
|
||||
isVersion: boolean
|
||||
arrowSize: number
|
||||
}
|
||||
|
||||
function drawEdges(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
): void {
|
||||
const margin = 100
|
||||
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
|
||||
|
||||
// Prepare all visible edges
|
||||
const prepared: PreparedEdge[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const src =
|
||||
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
|
||||
const tgt =
|
||||
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
|
||||
if (!src || !tgt) continue
|
||||
|
||||
// Skip doc-memory edges when memory dots are too small to see connections
|
||||
if (edge.edgeType === "doc-memory") {
|
||||
const mem = src.type === "memory" ? src : tgt
|
||||
if (mem.size * viewport.zoom < 3) continue
|
||||
}
|
||||
|
||||
const s = viewport.worldToScreen(src.x, src.y)
|
||||
const t = viewport.worldToScreen(tgt.x, tgt.y)
|
||||
|
||||
if (
|
||||
(s.x < -margin && t.x < -margin) ||
|
||||
(s.x > width + margin && t.x > width + margin) ||
|
||||
(s.y < -margin && t.y < -margin) ||
|
||||
(s.y > height + margin && t.y > height + margin)
|
||||
)
|
||||
continue
|
||||
|
||||
const dx = t.x - s.x
|
||||
const dy = t.y - s.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 1) continue
|
||||
|
||||
const ux = dx / dist
|
||||
const uy = dy / dist
|
||||
const sr = src.size * viewport.zoom * 0.5
|
||||
const tr = tgt.size * viewport.zoom * 0.5
|
||||
|
||||
let connected = true
|
||||
if (hasDim) {
|
||||
const srcId =
|
||||
typeof edge.source === "string" ? edge.source : edge.source.id
|
||||
const tgtId =
|
||||
typeof edge.target === "string" ? edge.target : edge.target.id
|
||||
connected =
|
||||
srcId === state.selectedNodeId || tgtId === state.selectedNodeId
|
||||
}
|
||||
|
||||
prepared.push({
|
||||
startX: s.x + ux * sr,
|
||||
startY: s.y + uy * sr,
|
||||
endX: t.x - ux * tr,
|
||||
endY: t.y - uy * tr,
|
||||
connected,
|
||||
style: edgeStyle(edge),
|
||||
isVersion: edge.edgeType === "version",
|
||||
arrowSize:
|
||||
edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Batch by style + dim state: group into "key|connected" and "key|dimmed"
|
||||
const batches = new Map<string, PreparedEdge[]>()
|
||||
for (const e of prepared) {
|
||||
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
|
||||
const key = batchKey(e.style) + dimKey
|
||||
let batch = batches.get(key)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
batches.set(key, batch)
|
||||
}
|
||||
batch.push(e)
|
||||
}
|
||||
|
||||
// Draw each batch in a single beginPath/stroke
|
||||
ctx.setLineDash([])
|
||||
for (const [key, batch] of batches) {
|
||||
const first = batch[0]!
|
||||
const isDimmed = key.endsWith("|d")
|
||||
|
||||
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = first.style.width
|
||||
|
||||
ctx.beginPath()
|
||||
for (const e of batch) {
|
||||
ctx.moveTo(e.startX, e.startY)
|
||||
ctx.lineTo(e.endX, e.endY)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Arrow heads for version edges (fill calls — unavoidable per-arrow)
|
||||
const versionEdges = batch.filter((e) => e.isVersion)
|
||||
if (versionEdges.length > 0) {
|
||||
ctx.fillStyle = first.style.color
|
||||
for (const e of versionEdges) {
|
||||
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
function drawArrowHead(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number,
|
||||
size: number,
|
||||
): void {
|
||||
const angle = Math.atan2(toY - fromY, toX - fromX)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(toX, toY)
|
||||
ctx.lineTo(
|
||||
toX - size * Math.cos(angle - Math.PI / 6),
|
||||
toY - size * Math.sin(angle - Math.PI / 6),
|
||||
)
|
||||
ctx.lineTo(
|
||||
toX - size * Math.cos(angle + Math.PI / 6),
|
||||
toY - size * Math.sin(angle + Math.PI / 6),
|
||||
)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
function drawNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
): void {
|
||||
const margin = 60
|
||||
const memDots: { x: number; y: number; r: number; color: string }[] = []
|
||||
const docDots: { x: number; y: number; s: number }[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const screen = viewport.worldToScreen(node.x, node.y)
|
||||
const screenSize = node.size * viewport.zoom
|
||||
|
||||
// Frustum cull (use at least 2px so tiny nodes aren't culled)
|
||||
const cullSize = Math.max(screenSize, 2)
|
||||
if (
|
||||
screen.x + cullSize < -margin ||
|
||||
screen.x - cullSize > width + margin ||
|
||||
screen.y + cullSize < -margin ||
|
||||
screen.y - cullSize > height + margin
|
||||
)
|
||||
continue
|
||||
|
||||
const isSelected = node.id === state.selectedNodeId
|
||||
const isHovered = node.id === state.hoveredNodeId
|
||||
const isHighlighted = state.highlightIds.has(node.id)
|
||||
|
||||
// LOD: tiny nodes → batched dots (but selected/highlighted always get full detail)
|
||||
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
|
||||
if (node.type === "document") {
|
||||
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
|
||||
} else {
|
||||
memDots.push({
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
r: Math.max(2, screenSize * 0.45),
|
||||
color: node.borderColor || "#3B73B8",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let alpha = 1
|
||||
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
|
||||
alpha = 1 - state.dimProgress * 0.7
|
||||
}
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
if (node.type === "document") {
|
||||
drawDocumentNode(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
)
|
||||
} else {
|
||||
drawMemoryNode(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected || isHighlighted) {
|
||||
drawGlow(ctx, screen.x, screen.y, screenSize, node.type)
|
||||
}
|
||||
}
|
||||
|
||||
const dimAlpha =
|
||||
state.selectedNodeId && state.dimProgress > 0
|
||||
? 1 - state.dimProgress * 0.7
|
||||
: 1
|
||||
|
||||
// Batch: document dots as filled squares
|
||||
if (docDots.length > 0) {
|
||||
ctx.fillStyle = "#1B1F24"
|
||||
ctx.strokeStyle = "#2A2F36"
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = dimAlpha
|
||||
for (const d of docDots) {
|
||||
const h = d.s * 0.5
|
||||
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
|
||||
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch: memory dots — dark fill, then colored border strokes
|
||||
if (memDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha
|
||||
|
||||
// Pass 1: all dark fills in one batch
|
||||
ctx.fillStyle = "#0D2034"
|
||||
ctx.beginPath()
|
||||
for (const d of memDots) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
// Pass 2: colored strokes grouped by border color
|
||||
ctx.lineWidth = 1.5
|
||||
const byColor = new Map<string, typeof memDots>()
|
||||
for (const d of memDots) {
|
||||
let batch = byColor.get(d.color)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
byColor.set(d.color, batch)
|
||||
}
|
||||
batch.push(d)
|
||||
}
|
||||
for (const [color, batch] of byColor) {
|
||||
ctx.strokeStyle = color
|
||||
ctx.beginPath()
|
||||
for (const d of batch) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
function drawDocumentNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
isHighlighted: boolean,
|
||||
): void {
|
||||
const half = size * 0.5
|
||||
const cornerR = 8 * (size / 50)
|
||||
|
||||
// Outer rect
|
||||
ctx.fillStyle = "#1B1F24"
|
||||
ctx.strokeStyle =
|
||||
isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36"
|
||||
ctx.lineWidth = isSelected || isHighlighted ? 2 : 1
|
||||
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
// Inner rect
|
||||
const innerSize = size * 0.72
|
||||
const innerHalf = innerSize * 0.5
|
||||
const innerR = 6 * (size / 50)
|
||||
ctx.fillStyle = "#13161A"
|
||||
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
|
||||
ctx.fill()
|
||||
|
||||
// Icon
|
||||
const iconSize = size * 0.35
|
||||
const docType =
|
||||
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
|
||||
drawDocIcon(ctx, sx, sy, iconSize, docType || "text")
|
||||
}
|
||||
|
||||
function drawMemoryNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
_isHighlighted: boolean,
|
||||
): void {
|
||||
const radius = size * 0.5
|
||||
|
||||
// Fill
|
||||
ctx.fillStyle = isHovered ? "#112840" : "#0D2034"
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
||||
// Stroke with time-based border color
|
||||
const borderColor = node.borderColor || "#3B73B8"
|
||||
ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor
|
||||
ctx.lineWidth = isHovered ? 2 : 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawGlow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
nodeType: "document" | "memory",
|
||||
): void {
|
||||
ctx.strokeStyle = "#3B73B8"
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.globalAlpha = 0.8
|
||||
|
||||
if (nodeType === "document") {
|
||||
const glowSize = size * 1.15
|
||||
const half = glowSize * 0.5
|
||||
const r = 8 * (glowSize / 50)
|
||||
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
|
||||
} else {
|
||||
drawHexagon(ctx, sx, sy, size * 0.5 * 1.15)
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// --- Shapes ---
|
||||
|
||||
function drawHexagon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6
|
||||
const x = cx + radius * Math.cos(angle)
|
||||
const y = cy + radius * Math.sin(angle)
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.lineTo(x + w - r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||
ctx.lineTo(x + w, y + h - r)
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||
ctx.lineTo(x + r, y + h)
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||
ctx.lineTo(x, y + r)
|
||||
ctx.arcTo(x, y, x + r, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
// --- Document icons ---
|
||||
|
||||
const ICON_COLOR = "#3B73B8"
|
||||
|
||||
function drawDocIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
type: string,
|
||||
): void {
|
||||
ctx.save()
|
||||
ctx.fillStyle = ICON_COLOR
|
||||
ctx.strokeStyle = ICON_COLOR
|
||||
ctx.lineWidth = Math.max(1, size / 12)
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineJoin = "round"
|
||||
|
||||
switch (type) {
|
||||
case "webpage":
|
||||
case "url":
|
||||
drawGlobeIcon(ctx, x, y, size)
|
||||
break
|
||||
case "pdf":
|
||||
drawTextLabel(ctx, x, y, size, "PDF", 0.35)
|
||||
break
|
||||
case "md":
|
||||
case "markdown":
|
||||
drawTextLabel(ctx, x, y, size, "MD", 0.3)
|
||||
break
|
||||
case "doc":
|
||||
case "docx":
|
||||
drawTextLabel(ctx, x, y, size, "DOC", 0.28)
|
||||
break
|
||||
case "csv":
|
||||
drawGridIcon(ctx, x, y, size)
|
||||
break
|
||||
case "json":
|
||||
drawBracesIcon(ctx, x, y, size)
|
||||
break
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
drawTextLabel(ctx, x, y, size, "N", 0.4)
|
||||
break
|
||||
case "google_doc":
|
||||
case "google_sheet":
|
||||
case "google_slide":
|
||||
drawTextLabel(ctx, x, y, size, "G", 0.4)
|
||||
break
|
||||
default:
|
||||
drawDocOutline(ctx, x, y, size)
|
||||
break
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawTextLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
text: string,
|
||||
fontRatio: number,
|
||||
): void {
|
||||
ctx.font = `bold ${size * fontRatio}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
function drawGlobeIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const r = size * 0.4
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - r, y)
|
||||
ctx.lineTo(x + r, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawGridIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.7
|
||||
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y - h / 2)
|
||||
ctx.lineTo(x, y + h / 2)
|
||||
ctx.moveTo(x - w / 2, y)
|
||||
ctx.lineTo(x + w / 2, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawBracesIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.6
|
||||
const h = size * 0.8
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 4, y - h / 2)
|
||||
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
|
||||
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + w / 4, y - h / 2)
|
||||
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
|
||||
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawDocOutline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
const fold = size * 0.2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 2, y - h / 2)
|
||||
ctx.lineTo(x + w / 2 - fold, y - h / 2)
|
||||
ctx.lineTo(x + w / 2, y - h / 2 + fold)
|
||||
ctx.lineTo(x + w / 2, y + h / 2)
|
||||
ctx.lineTo(x - w / 2, y + h / 2)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
const sp = size * 0.15
|
||||
const lw = size * 0.4
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - lw / 2, y - sp)
|
||||
ctx.lineTo(x + lw / 2, y - sp)
|
||||
ctx.moveTo(x - lw / 2, y)
|
||||
ctx.lineTo(x + lw / 2, y)
|
||||
ctx.moveTo(x - lw / 2, y + sp)
|
||||
ctx.lineTo(x + lw / 2, y + sp)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import * as d3 from "d3-force"
|
||||
import type { GraphNode, GraphEdge } from "../types"
|
||||
|
||||
export class ForceSimulation {
|
||||
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
|
||||
|
||||
init(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
this.destroy()
|
||||
|
||||
try {
|
||||
this.sim = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.alphaDecay(0.03)
|
||||
.alphaMin(0.001)
|
||||
.velocityDecay(0.6)
|
||||
|
||||
this.sim.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<GraphNode, GraphEdge>(edges)
|
||||
.id((d) => d.id)
|
||||
.distance((link) => (link.edgeType === "doc-memory" ? 150 : 300))
|
||||
.strength((link) => {
|
||||
if (link.edgeType === "doc-memory") return 0.8
|
||||
if (link.edgeType === "version") return 1.0
|
||||
return link.similarity * 0.3
|
||||
}),
|
||||
)
|
||||
|
||||
this.sim.force("charge", d3.forceManyBody<GraphNode>().strength(-1000))
|
||||
|
||||
this.sim.force(
|
||||
"collide",
|
||||
d3
|
||||
.forceCollide<GraphNode>()
|
||||
.radius((d) => (d.type === "document" ? 80 : 40))
|
||||
.strength(0.7),
|
||||
)
|
||||
|
||||
this.sim.force("x", d3.forceX().strength(0.05))
|
||||
this.sim.force("y", d3.forceY().strength(0.05))
|
||||
|
||||
// Pre-settle synchronously, then start the live simulation
|
||||
this.sim.stop()
|
||||
this.sim.alpha(1)
|
||||
for (let i = 0; i < 50; i++) this.sim.tick()
|
||||
this.sim.alphaTarget(0).restart()
|
||||
} catch (e) {
|
||||
console.error("ForceSimulation.init failed:", e)
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
update(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
if (!this.sim) return
|
||||
this.sim.nodes(nodes)
|
||||
const linkForce = this.sim.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
|
||||
if (linkForce) linkForce.links(edges)
|
||||
}
|
||||
|
||||
reheat(): void {
|
||||
this.sim?.alphaTarget(0.3).restart()
|
||||
}
|
||||
|
||||
coolDown(): void {
|
||||
this.sim?.alphaTarget(0)
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return (this.sim?.alpha() ?? 0) > 0.001
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.sim) {
|
||||
this.sim.stop()
|
||||
this.sim = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
|
||||
export interface ChainEntry {
|
||||
id: string
|
||||
version: number
|
||||
memory: string
|
||||
isForgotten: boolean
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
export class VersionChainIndex {
|
||||
private memoryMap = new Map<string, GraphApiMemory>()
|
||||
private cache = new Map<string, ChainEntry[]>()
|
||||
private lastDocs: GraphApiDocument[] | null = null
|
||||
|
||||
rebuild(documents: GraphApiDocument[]): void {
|
||||
if (documents === this.lastDocs) return
|
||||
this.lastDocs = documents
|
||||
this.memoryMap.clear()
|
||||
this.cache.clear()
|
||||
|
||||
for (const doc of documents) {
|
||||
for (const m of doc.memories) {
|
||||
this.memoryMap.set(m.id, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getChain(memoryId: string): ChainEntry[] | null {
|
||||
const cached = this.cache.get(memoryId)
|
||||
if (cached) return cached
|
||||
|
||||
const mem = this.memoryMap.get(memoryId)
|
||||
if (!mem || mem.version <= 1) return null
|
||||
|
||||
// Walk parentMemoryId up to the root
|
||||
const chain: ChainEntry[] = []
|
||||
const visited = new Set<string>()
|
||||
let current: GraphApiMemory | undefined = mem
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id)
|
||||
chain.push({
|
||||
id: current.id,
|
||||
version: current.version,
|
||||
memory: current.memory,
|
||||
isForgotten: current.isForgotten,
|
||||
isLatest: current.isLatest,
|
||||
})
|
||||
current = current.parentMemoryId
|
||||
? this.memoryMap.get(current.parentMemoryId)
|
||||
: undefined
|
||||
}
|
||||
|
||||
chain.reverse()
|
||||
|
||||
// Cache for every member in the chain
|
||||
for (const entry of chain) {
|
||||
this.cache.set(entry.id, chain)
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
export const colors = {
|
||||
background: {
|
||||
primary: "#0f1419",
|
||||
secondary: "#1a1f29",
|
||||
accent: "#252a35",
|
||||
},
|
||||
hexagon: {
|
||||
active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 },
|
||||
inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 },
|
||||
hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 },
|
||||
},
|
||||
document: {
|
||||
outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 },
|
||||
inner: { fill: "#13161A", radius: 6 },
|
||||
iconColor: "#3B73B8",
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff",
|
||||
secondary: "#e2e8f0",
|
||||
muted: "#94a3b8",
|
||||
},
|
||||
}
|
||||
|
||||
export const MEMORY_BORDER = {
|
||||
forgotten: "#EF4444",
|
||||
expiring: "#F59E0B",
|
||||
recent: "#10B981",
|
||||
default: "#3B73B8",
|
||||
} as const
|
||||
|
||||
export const EDGE_COLORS = {
|
||||
docMemory: "#4A5568",
|
||||
similarityStrong: "#00D4B8",
|
||||
similarityMedium: "#6B8FBF",
|
||||
similarityWeak: "#4A6A8A",
|
||||
version: "#8B5CF6",
|
||||
} as const
|
||||
|
||||
export const FORCE_CONFIG = {
|
||||
linkStrength: {
|
||||
docMemory: 0.8,
|
||||
version: 1.0,
|
||||
docDocBase: 0.3,
|
||||
},
|
||||
linkDistance: 300,
|
||||
docMemoryDistance: 150,
|
||||
chargeStrength: -1000,
|
||||
collisionRadius: { document: 80, memory: 40 },
|
||||
alphaDecay: 0.03,
|
||||
alphaMin: 0.001,
|
||||
velocityDecay: 0.6,
|
||||
alphaTarget: 0.3,
|
||||
}
|
||||
|
||||
export const GRAPH_SETTINGS = {
|
||||
console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 },
|
||||
consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 },
|
||||
}
|
||||
|
||||
export const ANIMATION = {
|
||||
dimDuration: 1500,
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useEffect, useLayoutEffect, useRef } from "react"
|
||||
import type { GraphCanvasProps, GraphNode } from "./types"
|
||||
import { ViewportState } from "./canvas/viewport"
|
||||
import { SpatialIndex } from "./canvas/hit-test"
|
||||
import { InputHandler } from "./canvas/input-handler"
|
||||
import { renderFrame } from "./canvas/renderer"
|
||||
import { GRAPH_SETTINGS } from "./constants"
|
||||
|
||||
export const GraphCanvas = memo<GraphCanvasProps>(function GraphCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
highlightDocumentIds,
|
||||
selectedNodeId = null,
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
canvasRef: externalCanvasRef,
|
||||
variant = "console",
|
||||
simulation,
|
||||
viewportRef: externalViewportRef,
|
||||
}) {
|
||||
const internalCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasRef = externalCanvasRef || internalCanvasRef
|
||||
|
||||
// Engine instances — mutable, never trigger re-renders
|
||||
const viewportRef = useRef<ViewportState | null>(null)
|
||||
const spatialRef = useRef(new SpatialIndex())
|
||||
const inputRef = useRef<InputHandler | null>(null)
|
||||
const rafRef = useRef(0)
|
||||
const renderNeeded = useRef(true)
|
||||
const nodeMapRef = useRef(new Map<string, GraphNode>())
|
||||
|
||||
// All mutable render state in a single ref — the rAF loop reads from here
|
||||
const s = useRef({
|
||||
nodes,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
selectedNodeId,
|
||||
hoveredNodeId: null as string | null,
|
||||
highlightIds: new Set(highlightDocumentIds ?? []),
|
||||
dimProgress: 0,
|
||||
dimTarget: selectedNodeId ? 1 : 0,
|
||||
})
|
||||
|
||||
// Sync incoming props to mutable state (no re-renders)
|
||||
s.current.nodes = nodes
|
||||
s.current.edges = edges
|
||||
s.current.width = width
|
||||
s.current.height = height
|
||||
|
||||
// Stable callback refs so InputHandler never needs recreation
|
||||
const cb = useRef({
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
simulation,
|
||||
})
|
||||
cb.current = {
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
simulation,
|
||||
}
|
||||
|
||||
// Rebuild nodeMap + spatial index when nodes change
|
||||
useEffect(() => {
|
||||
const map = nodeMapRef.current
|
||||
map.clear()
|
||||
for (const n of nodes) map.set(n.id, n)
|
||||
spatialRef.current.rebuild(nodes)
|
||||
renderNeeded.current = true
|
||||
}, [nodes])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
|
||||
renderNeeded.current = true
|
||||
}, [highlightDocumentIds])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.selectedNodeId = selectedNodeId
|
||||
s.current.dimTarget = selectedNodeId ? 1 : 0
|
||||
renderNeeded.current = true
|
||||
}, [selectedNodeId])
|
||||
|
||||
// Create viewport + input handler (once per variant)
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const cfg = GRAPH_SETTINGS[variant]
|
||||
const vp = new ViewportState(
|
||||
cfg.initialPanX,
|
||||
cfg.initialPanY,
|
||||
cfg.initialZoom,
|
||||
)
|
||||
viewportRef.current = vp
|
||||
if (externalViewportRef) {
|
||||
;(
|
||||
externalViewportRef as React.MutableRefObject<ViewportState | null>
|
||||
).current = vp
|
||||
}
|
||||
|
||||
const handler = new InputHandler(canvas, vp, spatialRef.current, {
|
||||
onNodeHover: (id) => {
|
||||
s.current.hoveredNodeId = id
|
||||
cb.current.onNodeHover(id)
|
||||
renderNeeded.current = true
|
||||
},
|
||||
onNodeClick: (id) => cb.current.onNodeClick(id),
|
||||
onNodeDragStart: (id, node) => {
|
||||
cb.current.onNodeDragStart(id)
|
||||
cb.current.simulation?.reheat()
|
||||
},
|
||||
onNodeDragEnd: () => {
|
||||
cb.current.onNodeDragEnd()
|
||||
cb.current.simulation?.coolDown()
|
||||
},
|
||||
onRequestRender: () => {
|
||||
renderNeeded.current = true
|
||||
},
|
||||
})
|
||||
inputRef.current = handler
|
||||
|
||||
return () => handler.destroy()
|
||||
}, [variant])
|
||||
|
||||
// High-DPI canvas sizing
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || width === 0 || height === 0) return
|
||||
|
||||
const MAX = 16384
|
||||
const d = Math.min(MAX / width, MAX / height, dpr)
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
canvas.width = Math.min(width * d, MAX)
|
||||
canvas.height = Math.min(height * d, MAX)
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (ctx) {
|
||||
ctx.scale(d, d)
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = "high"
|
||||
}
|
||||
renderNeeded.current = true
|
||||
}, [width, height, dpr])
|
||||
|
||||
// Single render loop — runs for component lifetime, reads everything from refs
|
||||
useEffect(() => {
|
||||
let lastReportedZoom = 0
|
||||
|
||||
const tick = () => {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
const vp = viewportRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!vp || !canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const cur = s.current
|
||||
|
||||
// 1. Viewport momentum / spring zoom / lerp pan
|
||||
const vpMoving = vp.tick()
|
||||
|
||||
// 2. Dim animation (ease toward target)
|
||||
const dd = cur.dimTarget - cur.dimProgress
|
||||
let dimming = false
|
||||
if (Math.abs(dd) > 0.01) {
|
||||
cur.dimProgress += dd * 0.1
|
||||
dimming = true
|
||||
} else {
|
||||
cur.dimProgress = cur.dimTarget
|
||||
}
|
||||
|
||||
// 3. Simulation physics
|
||||
const simActive = cb.current.simulation?.isActive() ?? false
|
||||
|
||||
// 4. Spatial index rebuild (only when positions actually move)
|
||||
const spatialChanged =
|
||||
simActive || inputRef.current?.getDraggingNode()
|
||||
? spatialRef.current.rebuild(cur.nodes)
|
||||
: false
|
||||
|
||||
// Skip frame if nothing changed
|
||||
if (
|
||||
!vpMoving &&
|
||||
!simActive &&
|
||||
!dimming &&
|
||||
!spatialChanged &&
|
||||
!renderNeeded.current
|
||||
)
|
||||
return
|
||||
renderNeeded.current = false
|
||||
|
||||
// Throttled zoom reporting for NavigationControls
|
||||
if (
|
||||
vpMoving &&
|
||||
cb.current.onViewportChange &&
|
||||
Math.abs(vp.zoom - lastReportedZoom) > 0.005
|
||||
) {
|
||||
lastReportedZoom = vp.zoom
|
||||
cb.current.onViewportChange(vp.zoom)
|
||||
}
|
||||
|
||||
renderFrame(
|
||||
ctx,
|
||||
cur.nodes,
|
||||
cur.edges,
|
||||
vp,
|
||||
cur.width,
|
||||
cur.height,
|
||||
{
|
||||
selectedNodeId: cur.selectedNodeId,
|
||||
hoveredNodeId: cur.hoveredNodeId,
|
||||
highlightIds: cur.highlightIds,
|
||||
dimProgress: cur.dimProgress,
|
||||
},
|
||||
nodeMapRef.current,
|
||||
)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ touchAction: "none", userSelect: "none" }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
@ -85,6 +85,8 @@ function StaticGraphPreview({
|
|||
height={height}
|
||||
className="absolute inset-0"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label="Memory graph preview"
|
||||
>
|
||||
{edges.map((e, i) => (
|
||||
<line
|
||||
|
|
@ -116,9 +118,8 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
({ containerTags, width = 216, height = 220, className }) => {
|
||||
const { setViewMode } = useViewMode()
|
||||
|
||||
const { data, isLoading, error } = useGraphApi({
|
||||
const { documents, isLoading, error } = useGraphApi({
|
||||
containerTags,
|
||||
limit: 20,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
|
|
@ -139,11 +140,8 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
)
|
||||
}
|
||||
|
||||
const documentCount = data.stats?.documentsWithSpatial ?? 0
|
||||
const memoryCount = data.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
0,
|
||||
)
|
||||
const documentCount = documents.length
|
||||
const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0)
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,273 +1,154 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type {
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
} from "../types"
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
|
||||
interface ViewportParams {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
interface UseGraphApiOptions {
|
||||
containerTags?: string[]
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
documentIds?: string[]
|
||||
}
|
||||
|
||||
interface ApiMemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
content?: string | null
|
||||
spaceId: string
|
||||
isStatic?: boolean
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
spaceContainerTag?: string | null
|
||||
}
|
||||
|
||||
interface ApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary?: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memoryEntries: ApiMemoryEntry[]
|
||||
}
|
||||
|
||||
interface ApiDocumentsResponse {
|
||||
documents: ApiDocument[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
|
||||
return {
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? "",
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphDocument(doc: ApiDocument): GraphApiDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memoryEntries.map(toGraphMemory),
|
||||
}
|
||||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, limit = 200, enabled = true } = options
|
||||
const { containerTags, enabled = true } = options
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [viewport, setViewport] = useState<ViewportParams>({
|
||||
minX: 0,
|
||||
maxX: 1000,
|
||||
minY: 0,
|
||||
maxY: 1000,
|
||||
})
|
||||
|
||||
// Debounce viewport changes
|
||||
const viewportTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pendingViewportRef = useRef<ViewportParams | null>(null)
|
||||
|
||||
const updateViewport = useCallback((newViewport: ViewportParams) => {
|
||||
pendingViewportRef.current = newViewport
|
||||
|
||||
if (viewportTimeoutRef.current) {
|
||||
clearTimeout(viewportTimeoutRef.current)
|
||||
}
|
||||
|
||||
viewportTimeoutRef.current = setTimeout(() => {
|
||||
if (pendingViewportRef.current) {
|
||||
setViewport(pendingViewportRef.current)
|
||||
pendingViewportRef.current = null
|
||||
}
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewportTimeoutRef.current) {
|
||||
clearTimeout(viewportTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const boundsQuery = useQuery({
|
||||
queryKey: ["graph-bounds", containerTags?.join(",")],
|
||||
queryFn: async (): Promise<GraphBoundsResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
|
||||
const response = await $fetch("@get/graph/bounds", {
|
||||
query: Object.fromEntries(params),
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph bounds",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphBoundsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["graph-stats", containerTags?.join(",")],
|
||||
queryFn: async (): Promise<GraphStatsResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
|
||||
const response = await $fetch("@get/graph/stats", {
|
||||
query: Object.fromEntries(params),
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph stats",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphStatsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const viewportQuery = useQuery({
|
||||
queryKey: [
|
||||
"graph-viewport",
|
||||
viewport.minX,
|
||||
viewport.maxX,
|
||||
viewport.minY,
|
||||
viewport.maxY,
|
||||
containerTags?.join(","),
|
||||
documentIds?.join(","),
|
||||
limit,
|
||||
],
|
||||
queryFn: async (): Promise<GraphViewportResponse> => {
|
||||
const response = await $fetch("@post/graph/viewport", {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<ApiDocumentsResponse, Error>({
|
||||
queryKey: ["graph-documents", containerTags?.join(",")],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
viewport: {
|
||||
minX: viewport.minX,
|
||||
maxX: viewport.maxX,
|
||||
minY: viewport.minY,
|
||||
maxY: viewport.maxY,
|
||||
},
|
||||
page: pageParam as number,
|
||||
limit: PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
throw new Error(response.error?.message || "Failed to fetch documents")
|
||||
}
|
||||
|
||||
return response.data as GraphViewportResponse
|
||||
return response.data as unknown as ApiDocumentsResponse
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
if (currentPage < totalPages) {
|
||||
return currentPage + 1
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
// Prefetch adjacent viewports for smoother panning
|
||||
const prefetchAdjacentViewports = useCallback(
|
||||
(currentViewport: ViewportParams) => {
|
||||
const viewportWidth = currentViewport.maxX - currentViewport.minX
|
||||
const viewportHeight = currentViewport.maxY - currentViewport.minY
|
||||
const documents = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) => page.documents.map(toGraphDocument))
|
||||
}, [data])
|
||||
|
||||
const offsets = [
|
||||
{ dx: viewportWidth * 0.5, dy: 0 },
|
||||
{ dx: -viewportWidth * 0.5, dy: 0 },
|
||||
{ dx: 0, dy: viewportHeight * 0.5 },
|
||||
{ dx: 0, dy: -viewportHeight * 0.5 },
|
||||
]
|
||||
|
||||
offsets.forEach(({ dx, dy }) => {
|
||||
const prefetchViewport = {
|
||||
minX: Math.max(0, currentViewport.minX + dx),
|
||||
maxX: Math.max(0, currentViewport.maxX + dx),
|
||||
minY: Math.max(0, currentViewport.minY + dy),
|
||||
maxY: Math.max(0, currentViewport.maxY + dy),
|
||||
}
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: [
|
||||
"graph-viewport",
|
||||
prefetchViewport.minX,
|
||||
prefetchViewport.maxX,
|
||||
prefetchViewport.minY,
|
||||
prefetchViewport.maxY,
|
||||
containerTags?.join(","),
|
||||
limit,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/graph/viewport", {
|
||||
body: {
|
||||
viewport: prefetchViewport,
|
||||
containerTags,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
})
|
||||
},
|
||||
[queryClient, containerTags, limit],
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
return {
|
||||
documents: viewportQuery.data?.documents ?? [],
|
||||
edges: viewportQuery.data?.edges ?? [],
|
||||
totalCount: viewportQuery.data?.totalCount ?? 0,
|
||||
bounds: boundsQuery.data?.bounds ?? null,
|
||||
stats: statsQuery.data ?? null,
|
||||
}
|
||||
}, [viewportQuery.data, boundsQuery.data, statsQuery.data])
|
||||
|
||||
const isLoading = viewportQuery.isPending || boundsQuery.isPending
|
||||
const isRefetching = viewportQuery.isRefetching
|
||||
const error =
|
||||
viewportQuery.error || boundsQuery.error || statsQuery.error || null
|
||||
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
error,
|
||||
viewport,
|
||||
updateViewport,
|
||||
prefetchAdjacentViewports,
|
||||
refetch: viewportQuery.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales backend coordinates (0-1000) to graph canvas coordinates
|
||||
*/
|
||||
export function scaleBackendToCanvas(
|
||||
x: number,
|
||||
y: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const scale = Math.min(canvasWidth, canvasHeight) / 1000
|
||||
const offsetX = (canvasWidth - 1000 * scale) / 2
|
||||
const offsetY = (canvasHeight - 1000 * scale) / 2
|
||||
|
||||
return {
|
||||
x: x * scale + offsetX,
|
||||
y: y * scale + offsetY,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales canvas coordinates to backend coordinates (0-1000)
|
||||
*/
|
||||
export function scaleCanvasToBackend(
|
||||
x: number,
|
||||
y: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const scale = Math.min(canvasWidth, canvasHeight) / 1000
|
||||
const offsetX = (canvasWidth - 1000 * scale) / 2
|
||||
const offsetY = (canvasHeight - 1000 * scale) / 2
|
||||
|
||||
return {
|
||||
x: (x - offsetX) / scale,
|
||||
y: (y - offsetY) / scale,
|
||||
documents,
|
||||
isLoading: isPending,
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
error: error ?? null,
|
||||
hasMore: hasNextPage ?? false,
|
||||
loadMore: fetchNextPage,
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,289 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useRef, useEffect } from "react"
|
||||
import { MEMORY_BORDER, EDGE_COLORS } from "../constants"
|
||||
import type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const MEMORY_CLUSTER_SPREAD = 150
|
||||
|
||||
function getMemoryBorderColor(mem: GraphApiMemory): string {
|
||||
if (mem.isForgotten) return MEMORY_BORDER.forgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
|
||||
return MEMORY_BORDER.default
|
||||
}
|
||||
|
||||
function getEdgeVisualProps(similarity: number) {
|
||||
return {
|
||||
opacity: 0.3 + similarity * 0.5,
|
||||
thickness: 1 + similarity * 1.5,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDocCoordinates(
|
||||
documents: GraphApiDocument[],
|
||||
): GraphApiDocument[] {
|
||||
if (documents.length <= 1) return documents
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const doc of documents) {
|
||||
minX = Math.min(minX, doc.x)
|
||||
maxX = Math.max(maxX, doc.x)
|
||||
minY = Math.min(minY, doc.y)
|
||||
maxY = Math.max(maxY, doc.y)
|
||||
}
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
const PAD = 100
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD),
|
||||
y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD),
|
||||
}))
|
||||
}
|
||||
|
||||
export function useGraphData(
|
||||
documents: GraphApiDocument[],
|
||||
apiEdges: GraphApiEdge[],
|
||||
draggingNodeId: string | null,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
) {
|
||||
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!documents || documents.length === 0) return
|
||||
|
||||
const currentIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
currentIds.add(doc.id)
|
||||
for (const mem of doc.memories) currentIds.add(mem.id)
|
||||
}
|
||||
|
||||
for (const [id] of nodeCache.current.entries()) {
|
||||
if (!currentIds.has(id)) nodeCache.current.delete(id)
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
const { scale, offsetX, offsetY } = useMemo(() => {
|
||||
if (canvasWidth === 0 || canvasHeight === 0) {
|
||||
return { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
}
|
||||
const paddingFactor = 0.8
|
||||
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
|
||||
const ox = (canvasWidth - 1000 * s) / 2
|
||||
const oy = (canvasHeight - 1000 * s) / 2
|
||||
return { scale: s, offsetX: ox, offsetY: oy }
|
||||
}, [canvasWidth, canvasHeight])
|
||||
|
||||
const normalizedDocs = useMemo(
|
||||
() => normalizeDocCoordinates(documents),
|
||||
[documents],
|
||||
)
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
if (!normalizedDocs || normalizedDocs.length === 0) return []
|
||||
|
||||
const result: GraphNode[] = []
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
const initialX = doc.x * scale + offsetX
|
||||
const initialY = doc.y * scale + offsetY
|
||||
|
||||
let docNode = nodeCache.current.get(doc.id)
|
||||
const docData: DocumentNodeData = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary,
|
||||
type: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memories,
|
||||
}
|
||||
|
||||
if (docNode) {
|
||||
docNode.data = docData
|
||||
docNode.isDragging = draggingNodeId === doc.id
|
||||
} else {
|
||||
docNode = {
|
||||
id: doc.id,
|
||||
type: "document",
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
data: docData,
|
||||
size: 50,
|
||||
borderColor: "#2A2F36",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(doc.id, docNode)
|
||||
}
|
||||
result.push(docNode)
|
||||
|
||||
const memCount = doc.memories.length
|
||||
for (let i = 0; i < memCount; i++) {
|
||||
const mem = doc.memories[i]!
|
||||
let memNode = nodeCache.current.get(mem.id)
|
||||
const memData: MemoryNodeData = {
|
||||
...mem,
|
||||
documentId: doc.id,
|
||||
content: mem.memory,
|
||||
}
|
||||
|
||||
if (memNode) {
|
||||
memNode.data = memData
|
||||
memNode.borderColor = getMemoryBorderColor(mem)
|
||||
memNode.isDragging = draggingNodeId === mem.id
|
||||
} else {
|
||||
const angle = (i / memCount) * 2 * Math.PI
|
||||
memNode = {
|
||||
id: mem.id,
|
||||
type: "memory",
|
||||
x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD,
|
||||
y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD,
|
||||
data: memData,
|
||||
size: 36,
|
||||
borderColor: getMemoryBorderColor(mem),
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(mem.id, memNode)
|
||||
}
|
||||
result.push(memNode)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (!normalizedDocs || normalizedDocs.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
const allNodeIds = new Set(nodes.map((n) => n.id))
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
similarity: 1,
|
||||
visualProps: { opacity: 0.3, thickness: 1.5 },
|
||||
edgeType: "doc-memory",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
for (const mem of doc.memories) {
|
||||
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
|
||||
result.push({
|
||||
id: `ver-${mem.parentMemoryId}-${mem.id}`,
|
||||
source: mem.parentMemoryId,
|
||||
target: mem.id,
|
||||
similarity: 1,
|
||||
visualProps: { opacity: 0.6, thickness: 2 },
|
||||
edgeType: "version",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const apiEdge of apiEdges) {
|
||||
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `sim-${apiEdge.source}-${apiEdge.target}`,
|
||||
source: apiEdge.source,
|
||||
target: apiEdge.target,
|
||||
similarity: apiEdge.similarity,
|
||||
visualProps: getEdgeVisualProps(apiEdge.similarity),
|
||||
edgeType: "similarity",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [normalizedDocs, apiEdges, nodes])
|
||||
|
||||
return { nodes, edges, scale, offsetX, offsetY }
|
||||
}
|
||||
|
||||
export function screenToBackendCoords(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const canvasX = (screenX - panX) / zoom
|
||||
const canvasY = (screenY - panY) / zoom
|
||||
|
||||
const paddingFactor = 0.8
|
||||
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
|
||||
const ox = (canvasWidth - 1000 * s) / 2
|
||||
const oy = (canvasHeight - 1000 * s) / 2
|
||||
|
||||
return {
|
||||
x: (canvasX - ox) / s,
|
||||
y: (canvasY - oy) / s,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateBackendViewport(
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { minX: number; maxX: number; minY: number; maxY: number } {
|
||||
const topLeft = screenToBackendCoords(
|
||||
0,
|
||||
0,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
)
|
||||
const bottomRight = screenToBackendCoords(
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
)
|
||||
|
||||
return {
|
||||
minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)),
|
||||
maxX: Math.max(topLeft.x, bottomRight.x),
|
||||
minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)),
|
||||
maxY: Math.max(topLeft.y, bottomRight.y),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,18 @@
|
|||
// Memory Graph components
|
||||
export { MemoryGraph } from "./memory-graph"
|
||||
export type { MemoryGraphProps } from "./memory-graph"
|
||||
// Re-export the wrapper as MemoryGraph (same name, drop-in replacement)
|
||||
export { MemoryGraph } from "./memory-graph-wrapper"
|
||||
export type { MemoryGraphWrapperProps as MemoryGraphProps } from "./memory-graph-wrapper"
|
||||
|
||||
// Keep GraphCard (app-specific)
|
||||
export { GraphCard } from "./graph-card"
|
||||
export type { GraphCardProps } from "./graph-card"
|
||||
|
||||
// Hooks
|
||||
export { useGraphApi } from "./hooks/use-graph-api"
|
||||
export {
|
||||
useGraphData,
|
||||
calculateBackendViewport,
|
||||
screenToBackendCoords,
|
||||
} from "./hooks/use-graph-data"
|
||||
|
||||
// Canvas engine
|
||||
export { ViewportState } from "./canvas/viewport"
|
||||
export { ForceSimulation } from "./canvas/simulation"
|
||||
|
||||
// Types
|
||||
// Re-export useful types from the package
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./types"
|
||||
} from "@supermemory/memory-graph"
|
||||
|
||||
// Keep the API hook export
|
||||
export { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
|
|
|||
|
|
@ -1,599 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@ui/components/collapsible"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import type { GraphEdge, GraphNode, LegendProps } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
// Cookie utility functions for legend state
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === "undefined") return
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
|
||||
}
|
||||
|
||||
const getCookie = (name: string): string | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
const nameEQ = `${name}=`
|
||||
const ca = document.cookie.split(";")
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
if (!c) continue
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length)
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface ExtendedLegendProps extends LegendProps {
|
||||
id?: string
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
// Toggle switch component matching Figma design
|
||||
const SmallToggle = memo(function SmallToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"box-border flex flex-row justify-center items-center",
|
||||
"w-6 h-3.5 rounded-full transition-all duration-200",
|
||||
"border border-white/5",
|
||||
"shadow-[inset_1px_1px_2px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 rounded-full transition-all duration-200",
|
||||
checked ? "ml-auto mr-0.5" : "mr-auto ml-0.5",
|
||||
)}
|
||||
style={{
|
||||
background: checked ? "#162E57" : "rgba(115, 115, 115, 0.25)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
// Hexagon SVG for memory nodes
|
||||
const HexagonIcon = memo(function HexagonIcon({
|
||||
fill = "#0D2034",
|
||||
stroke = "#3B73B8",
|
||||
opacity = 1,
|
||||
size = 12,
|
||||
}: {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
opacity?: number
|
||||
size?: number
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 12 12"
|
||||
style={{ opacity }}
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
// Document icon (rounded square)
|
||||
const DocumentIcon = memo(function DocumentIcon() {
|
||||
return (
|
||||
<div
|
||||
className="w-3 h-3 shrink-0 rounded-[2.4px] flex items-center justify-center"
|
||||
style={{
|
||||
background: "#1B1F24",
|
||||
boxShadow:
|
||||
"0px 0.85px 4.26px rgba(0, 0, 0, 0.25), inset 0.21px 0.21px 0.21px rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[10.8px] h-[10.8px] rounded-[1.8px]"
|
||||
style={{
|
||||
background: "#262C33",
|
||||
boxShadow: "inset 0.43px 0.43px 1.28px rgba(11, 15, 21, 0.4)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Connection icon (graph)
|
||||
const ConnectionIcon = memo(function ConnectionIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="3" cy="3" r="1.5" fill="#90A2B9" />
|
||||
<circle cx="9" cy="3" r="1.5" fill="#90A2B9" />
|
||||
<circle cx="6" cy="9" r="1.5" fill="#90A2B9" />
|
||||
<line x1="3" y1="3" x2="9" y2="3" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
<line x1="3" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
<line x1="9" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
// Line icon for connections
|
||||
const LineIcon = memo(function LineIcon({
|
||||
color,
|
||||
dashed = false,
|
||||
}: {
|
||||
color: string
|
||||
dashed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="w-3 h-3 flex items-center justify-center shrink-0">
|
||||
<div
|
||||
className="w-3 h-0"
|
||||
style={{
|
||||
borderTop: `1.6px ${dashed ? "dashed" : "solid"} ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Similarity circle icon
|
||||
const SimilarityCircle = memo(function SimilarityCircle({
|
||||
variant,
|
||||
}: {
|
||||
variant: "strong" | "weak"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{
|
||||
background: variant === "strong" ? "#616D7F" : "#313A44",
|
||||
border: "0.6px solid rgba(255, 255, 255, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// Accordion row with count
|
||||
const StatRow = memo(function StatRow({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
expandable = false,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count: number
|
||||
expandable?: boolean
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center w-full py-0",
|
||||
expandable && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3 h-3 text-[#737373] transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">{count}</span>
|
||||
</button>
|
||||
{expandable && expanded && children && (
|
||||
<div className="pl-2.5 pt-1.5 flex flex-col gap-1.5">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Toggle row for relations/similarity
|
||||
const ToggleRow = memo(function ToggleRow({
|
||||
icon,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
|
||||
</div>
|
||||
<SmallToggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Legend = memo(function Legend({
|
||||
variant: _variant = "console",
|
||||
id,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
isLoading: _isLoading = false,
|
||||
}: ExtendedLegendProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Toggle states for relations
|
||||
const [showUpdates, setShowUpdates] = useState(true)
|
||||
const [showExtends, setShowExtends] = useState(true)
|
||||
const [showInferences, setShowInferences] = useState(false)
|
||||
|
||||
// Toggle states for similarity
|
||||
const [showStrong, setShowStrong] = useState(true)
|
||||
const [showWeak, setShowWeak] = useState(true)
|
||||
|
||||
// Expanded accordion states
|
||||
const [memoriesExpanded, setMemoriesExpanded] = useState(false)
|
||||
const [documentsExpanded, setDocumentsExpanded] = useState(false)
|
||||
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
|
||||
|
||||
// Load saved preference on client side
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const savedState = getCookie("legendCollapsed")
|
||||
if (savedState === "true") {
|
||||
setIsExpanded(false)
|
||||
} else if (savedState === "false") {
|
||||
setIsExpanded(true)
|
||||
} else {
|
||||
// Default: collapsed on mobile, collapsed on desktop too (per Figma)
|
||||
setIsExpanded(false)
|
||||
}
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized])
|
||||
|
||||
// Save to cookie when state changes
|
||||
const handleToggleExpanded = (expanded: boolean) => {
|
||||
setIsExpanded(expanded)
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true")
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length
|
||||
const connectionCount = edges.length
|
||||
|
||||
// Hide on mobile
|
||||
if (isMobile) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute z-20 overflow-hidden", "bottom-4 left-4")}
|
||||
style={{
|
||||
width: "214px",
|
||||
}}
|
||||
id={id}
|
||||
>
|
||||
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
|
||||
{/* Glass background */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[10px]"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 p-3">
|
||||
{/* Header - always visible */}
|
||||
<CollapsibleTrigger className="flex flex-row items-center gap-1.5 w-full">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-[#FAFAFA]" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span
|
||||
className="text-sm text-white font-normal"
|
||||
style={{
|
||||
fontFamily: "DM Sans",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Legend
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className="mt-4 flex flex-row gap-3 overflow-y-auto max-h-[312px]"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
{/* Main content column */}
|
||||
<div className="flex flex-col gap-4 flex-1">
|
||||
{/* STATISTICS Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className="text-xs text-[#737373] font-normal"
|
||||
style={{ fontFamily: "Space Grotesk" }}
|
||||
>
|
||||
STATISTICS
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Memories */}
|
||||
<StatRow
|
||||
icon={
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#3B73B8"
|
||||
size={12}
|
||||
/>
|
||||
}
|
||||
label="Memories"
|
||||
count={memoryCount}
|
||||
expandable
|
||||
expanded={memoriesExpanded}
|
||||
onToggle={() => setMemoriesExpanded(!memoriesExpanded)}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 pl-0">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#3B73B8"
|
||||
size={12}
|
||||
/>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Memory (latest)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">76</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#5F7085"
|
||||
opacity={0.6}
|
||||
size={12}
|
||||
/>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Memory (oldest)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">182</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<HexagonIcon
|
||||
fill="#0C1827"
|
||||
stroke="rgba(54, 155, 253, 0.2)"
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Score
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">23</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,0 11.2,6 6,12 0.8,6"
|
||||
fill="#00FFA9"
|
||||
fillOpacity="0.6"
|
||||
stroke="#00FFA9"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
New memory
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">17</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,0 11.2,6 6,12 0.8,6"
|
||||
fill="#4D2E00"
|
||||
fillOpacity="0.6"
|
||||
stroke="#FE9900"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Expiring soon
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">11</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 relative shrink-0">
|
||||
<HexagonIcon
|
||||
fill="#60272C"
|
||||
stroke="#FF6467"
|
||||
opacity={0.6}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Forgotten
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">6</span>
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
|
||||
{/* Documents */}
|
||||
<StatRow
|
||||
icon={<DocumentIcon />}
|
||||
label="Documents"
|
||||
count={documentCount}
|
||||
expandable
|
||||
expanded={documentsExpanded}
|
||||
onToggle={() => setDocumentsExpanded(!documentsExpanded)}
|
||||
/>
|
||||
|
||||
{/* Connections */}
|
||||
<StatRow
|
||||
icon={<ConnectionIcon />}
|
||||
label="Connections"
|
||||
count={connectionCount}
|
||||
expandable
|
||||
expanded={connectionsExpanded}
|
||||
onToggle={() =>
|
||||
setConnectionsExpanded(!connectionsExpanded)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<LineIcon color="#5070A1" />
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Doc > 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"
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import type { LoadingIndicatorProps } from "./types"
|
||||
|
||||
export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
||||
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
return (
|
||||
<div className="absolute z-30 rounded-xl overflow-hidden top-[5.5rem] left-4">
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 text-slate-300 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-300" />
|
||||
<span className="text-sm">
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator"
|
||||
80
apps/web/components/memory-graph/memory-graph-wrapper.tsx
Normal file
80
apps/web/components/memory-graph/memory-graph-wrapper.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
||||
export interface MemoryGraphWrapperProps {
|
||||
children?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
error?: Error | null
|
||||
variant?: "console" | "consumer"
|
||||
legendId?: string
|
||||
highlightDocumentIds?: string[]
|
||||
highlightsVisible?: boolean
|
||||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
maxNodes?: number
|
||||
isSlideshowActive?: boolean
|
||||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
}
|
||||
|
||||
export function MemoryGraph({
|
||||
children,
|
||||
isLoading: externalIsLoading = false,
|
||||
error: externalError = null,
|
||||
variant = "console",
|
||||
containerTags,
|
||||
maxNodes = 200,
|
||||
canvasRef,
|
||||
...rest
|
||||
}: MemoryGraphWrapperProps) {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
documents,
|
||||
isLoading: apiIsLoading,
|
||||
isLoadingMore,
|
||||
error: apiError,
|
||||
hasMore,
|
||||
loadMore,
|
||||
totalCount,
|
||||
} = useGraphApi({
|
||||
containerTags,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full">
|
||||
<MemoryGraphBase
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={hasMore ? () => loadMore() : undefined}
|
||||
hasMore={hasMore}
|
||||
error={externalError || apiError}
|
||||
variant={variant}
|
||||
maxNodes={maxNodes}
|
||||
canvasRef={canvasRef}
|
||||
totalCount={totalCount}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</MemoryGraphBase>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
import { useGraphData } from "./hooks/use-graph-data"
|
||||
import { ForceSimulation } from "./canvas/simulation"
|
||||
import { VersionChainIndex } from "./canvas/version-chain"
|
||||
import type { ViewportState } from "./canvas/viewport"
|
||||
import { Legend } from "./legend"
|
||||
import { LoadingIndicator } from "./loading-indicator"
|
||||
import { NavigationControls } from "./navigation-controls"
|
||||
import { NodeHoverPopover } from "./node-hover-popover"
|
||||
import { colors } from "./constants"
|
||||
import type { GraphNode } from "./types"
|
||||
|
||||
export interface MemoryGraphProps {
|
||||
children?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
error?: Error | null
|
||||
variant?: "console" | "consumer"
|
||||
legendId?: string
|
||||
highlightDocumentIds?: string[]
|
||||
highlightsVisible?: boolean
|
||||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
maxNodes?: number
|
||||
isSlideshowActive?: boolean
|
||||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
}
|
||||
|
||||
export const MemoryGraph = ({
|
||||
children,
|
||||
isLoading: externalIsLoading = false,
|
||||
error: externalError = null,
|
||||
variant = "console",
|
||||
legendId,
|
||||
highlightDocumentIds = [],
|
||||
highlightsVisible = true,
|
||||
containerTags,
|
||||
documentIds,
|
||||
maxNodes = 200,
|
||||
isSlideshowActive = false,
|
||||
onSlideshowNodeChange,
|
||||
onSlideshowStop,
|
||||
canvasRef,
|
||||
}: MemoryGraphProps) => {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<ViewportState | null>(null)
|
||||
const simulationRef = useRef<ForceSimulation | null>(null)
|
||||
const chainIndex = useRef(new VersionChainIndex())
|
||||
|
||||
// React state only for things that affect DOM
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [zoomDisplay, setZoomDisplay] = useState(50)
|
||||
|
||||
const {
|
||||
data: apiData,
|
||||
isLoading: apiIsLoading,
|
||||
error: apiError,
|
||||
} = useGraphApi({
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit: maxNodes,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
})
|
||||
|
||||
const { nodes, edges } = useGraphData(
|
||||
apiData.documents,
|
||||
apiData.edges,
|
||||
null,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
|
||||
// Rebuild version chain index when documents change
|
||||
useEffect(() => {
|
||||
chainIndex.current.rebuild(apiData.documents)
|
||||
}, [apiData.documents])
|
||||
|
||||
// Force simulation (created once, updated when data changes)
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
if (!simulationRef.current) {
|
||||
simulationRef.current = new ForceSimulation()
|
||||
}
|
||||
simulationRef.current.init(nodes, edges)
|
||||
|
||||
return () => {
|
||||
simulationRef.current?.destroy()
|
||||
simulationRef.current = null
|
||||
}
|
||||
}, [nodes, edges])
|
||||
|
||||
// Auto-fit when data first loads
|
||||
const hasAutoFittedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasAutoFittedRef.current &&
|
||||
nodes.length > 0 &&
|
||||
viewportRef.current &&
|
||||
containerSize.width > 0
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
hasAutoFittedRef.current = true
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) hasAutoFittedRef.current = false
|
||||
}, [nodes.length])
|
||||
|
||||
// Container resize observer
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
setContainerBounds(el.getBoundingClientRect())
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
setContainerBounds(el.getBoundingClientRect())
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Callbacks for GraphCanvas
|
||||
const handleNodeHover = useCallback(
|
||||
(id: string | null) => setHoveredNode(id),
|
||||
[],
|
||||
)
|
||||
|
||||
const handleNodeClick = useCallback((id: string | null) => {
|
||||
setSelectedNode((prev) => (id === null ? null : prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const handleNodeDragStart = useCallback((_id: string) => {
|
||||
// Drag is handled imperatively by InputHandler
|
||||
}, [])
|
||||
|
||||
const handleNodeDragEnd = useCallback(() => {
|
||||
// Drag end handled by InputHandler
|
||||
}, [])
|
||||
|
||||
const handleViewportChange = useCallback((zoom: number) => {
|
||||
setZoomDisplay(Math.round(zoom * 100))
|
||||
}, [])
|
||||
|
||||
// Navigation
|
||||
const handleAutoFit = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
viewportRef.current.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const handleCenter = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (const n of nodes) {
|
||||
sx += n.x
|
||||
sy += n.y
|
||||
}
|
||||
viewportRef.current.centerOn(
|
||||
sx / nodes.length,
|
||||
sy / nodes.length,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useHotkeys("z", handleAutoFit, [handleAutoFit])
|
||||
useHotkeys("c", handleCenter, [handleCenter])
|
||||
useHotkeys("equal", handleZoomIn, [handleZoomIn])
|
||||
useHotkeys("minus", handleZoomOut, [handleZoomOut])
|
||||
useHotkeys("escape", () => setSelectedNode(null), [])
|
||||
|
||||
// Arrow key navigation through nodes
|
||||
const selectAndCenter = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
const n = nodes.find((nd) => nd.id === nodeId)
|
||||
if (n && viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
},
|
||||
[nodes, containerSize.width, containerSize.height],
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const chain = chainIndex.current.getChain(selectedNode)
|
||||
if (chain && chain.length > 1) {
|
||||
const idx = chain.findIndex((e) => e.id === selectedNode)
|
||||
if (idx > 0) {
|
||||
selectAndCenter(chain[idx - 1]!.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
// At top of chain or no chain — go to parent document
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (node?.type === "memory" && "documentId" in node.data) {
|
||||
selectAndCenter(node.data.documentId)
|
||||
}
|
||||
}, [selectedNode, nodes, selectAndCenter])
|
||||
|
||||
const navigateDown = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
// Version chain navigation
|
||||
const chain = chainIndex.current.getChain(selectedNode)
|
||||
if (chain && chain.length > 1) {
|
||||
const idx = chain.findIndex((e) => e.id === selectedNode)
|
||||
if (idx >= 0 && idx < chain.length - 1) {
|
||||
selectAndCenter(chain[idx + 1]!.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
// On a document — go to its first memory
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (node?.type === "document") {
|
||||
const child = nodes.find(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === selectedNode,
|
||||
)
|
||||
if (child) selectAndCenter(child.id)
|
||||
}
|
||||
}, [selectedNode, nodes, selectAndCenter])
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === "document") {
|
||||
const docs = nodes.filter((n) => n.type === "document")
|
||||
const idx = docs.findIndex((n) => n.id === selectedNode)
|
||||
const next = docs[(idx + 1) % docs.length]!
|
||||
setSelectedNode(next.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
next.x,
|
||||
next.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
} else {
|
||||
const docId = "documentId" in node.data ? node.data.documentId : null
|
||||
const siblings = nodes.filter(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === docId,
|
||||
)
|
||||
if (siblings.length === 0) return
|
||||
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
||||
const next = siblings[(idx + 1) % siblings.length]!
|
||||
setSelectedNode(next.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
next.x,
|
||||
next.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}
|
||||
}, [selectedNode, nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === "document") {
|
||||
const docs = nodes.filter((n) => n.type === "document")
|
||||
const idx = docs.findIndex((n) => n.id === selectedNode)
|
||||
const prev = docs[(idx - 1 + docs.length) % docs.length]!
|
||||
setSelectedNode(prev.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
prev.x,
|
||||
prev.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
} else {
|
||||
const docId = "documentId" in node.data ? node.data.documentId : null
|
||||
const siblings = nodes.filter(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === docId,
|
||||
)
|
||||
if (siblings.length === 0) return
|
||||
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
||||
const prev = siblings[(idx - 1 + siblings.length) % siblings.length]!
|
||||
setSelectedNode(prev.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
prev.x,
|
||||
prev.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}
|
||||
}, [selectedNode, nodes, containerSize.width, containerSize.height])
|
||||
|
||||
useHotkeys("up", navigateUp, [navigateUp])
|
||||
useHotkeys("down", navigateDown, [navigateDown])
|
||||
useHotkeys("right", navigateNext, [navigateNext])
|
||||
useHotkeys("left", navigatePrev, [navigatePrev])
|
||||
|
||||
// Slideshow
|
||||
useEffect(() => {
|
||||
if (!isSlideshowActive || nodes.length === 0) {
|
||||
if (!isSlideshowActive) {
|
||||
setSelectedNode(null)
|
||||
simulationRef.current?.coolDown()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let lastIdx = -1
|
||||
const pick = () => {
|
||||
if (nodes.length === 0) return
|
||||
let idx: number
|
||||
if (nodes.length > 1) {
|
||||
do {
|
||||
idx = Math.floor(Math.random() * nodes.length)
|
||||
} while (idx === lastIdx)
|
||||
} else {
|
||||
idx = 0
|
||||
}
|
||||
lastIdx = idx
|
||||
const n = nodes[idx]!
|
||||
setSelectedNode(n.id)
|
||||
viewportRef.current?.centerOn(
|
||||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
simulationRef.current?.reheat()
|
||||
onSlideshowNodeChange?.(n.id)
|
||||
setTimeout(() => simulationRef.current?.coolDown(), 1000)
|
||||
}
|
||||
|
||||
pick()
|
||||
const interval = setInterval(pick, 3500)
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
isSlideshowActive,
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
onSlideshowNodeChange,
|
||||
])
|
||||
|
||||
// Active node: selected takes priority, then hovered
|
||||
const activeNodeId = selectedNode ?? hoveredNode
|
||||
const activeNodeData = useMemo(() => {
|
||||
if (!activeNodeId) return null
|
||||
return nodes.find((n) => n.id === activeNodeId) ?? null
|
||||
}, [activeNodeId, nodes])
|
||||
|
||||
const activePopoverPosition = useMemo(() => {
|
||||
if (!activeNodeData || !viewportRef.current) return null
|
||||
const vp = viewportRef.current
|
||||
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
|
||||
return {
|
||||
screenX: screen.x,
|
||||
screenY: screen.y,
|
||||
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
|
||||
}
|
||||
}, [activeNodeData])
|
||||
|
||||
const activeVersionChain = useMemo(() => {
|
||||
if (!activeNodeData || activeNodeData.type !== "memory") return null
|
||||
return chainIndex.current.getChain(activeNodeData.id)
|
||||
}, [activeNodeData])
|
||||
|
||||
const isLoading = externalIsLoading || apiIsLoading
|
||||
const error = externalError || apiError
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center"
|
||||
style={{ backgroundColor: colors.background.primary }}
|
||||
>
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
<div className="relative z-10 text-slate-300 px-6 py-4">
|
||||
Error loading graph: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full rounded-xl overflow-hidden">
|
||||
<LoadingIndicator
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={false}
|
||||
totalLoaded={apiData.totalCount}
|
||||
variant={variant}
|
||||
/>
|
||||
|
||||
{!isLoading && !nodes.some((n) => n.type === "document") && children}
|
||||
|
||||
<div
|
||||
className="w-full h-full relative overflow-hidden touch-none select-none"
|
||||
ref={containerRef}
|
||||
>
|
||||
{containerSize.width > 0 && containerSize.height > 0 && (
|
||||
<GraphCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
||||
selectedNodeId={selectedNode}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDragEnd={handleNodeDragEnd}
|
||||
onViewportChange={handleViewportChange}
|
||||
canvasRef={canvasRef}
|
||||
variant={variant}
|
||||
simulation={simulationRef.current ?? undefined}
|
||||
viewportRef={viewportRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeNodeData && activePopoverPosition && (
|
||||
<NodeHoverPopover
|
||||
node={activeNodeData}
|
||||
screenX={activePopoverPosition.screenX}
|
||||
screenY={activePopoverPosition.screenY}
|
||||
nodeRadius={activePopoverPosition.nodeRadius}
|
||||
containerBounds={containerBounds ?? undefined}
|
||||
versionChain={activeVersionChain}
|
||||
onNavigateNext={navigateNext}
|
||||
onNavigatePrev={navigatePrev}
|
||||
onNavigateUp={navigateUp}
|
||||
onNavigateDown={navigateDown}
|
||||
onSelectNode={handleNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{containerSize.width > 0 && (
|
||||
<NavigationControls
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onAutoFit={handleAutoFit}
|
||||
nodes={nodes}
|
||||
className="absolute bottom-18 left-4 z-15"
|
||||
zoomLevel={zoomDisplay}
|
||||
/>
|
||||
)}
|
||||
<Legend
|
||||
edges={edges}
|
||||
id={legendId}
|
||||
isLoading={isLoading}
|
||||
nodes={nodes}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import type { GraphNode } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Settings } from "lucide-react"
|
||||
|
||||
interface NavigationControlsProps {
|
||||
onCenter: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onAutoFit: () => void
|
||||
nodes: GraphNode[]
|
||||
className?: string
|
||||
zoomLevel: number
|
||||
}
|
||||
|
||||
// Keyboard shortcut badge component
|
||||
const KeyboardShortcut = memo(function KeyboardShortcut({
|
||||
keys,
|
||||
}: {
|
||||
keys: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-1 px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "rgba(33, 33, 33, 0.5)",
|
||||
border: "1px solid rgba(115, 115, 115, 0.2)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-[#737373] font-medium leading-none">
|
||||
{keys}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Navigation buttons component
|
||||
const NavigationButtons = memo(function NavigationButtons({
|
||||
onAutoFit,
|
||||
onCenter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
zoomLevel,
|
||||
}: {
|
||||
onAutoFit: () => void
|
||||
onCenter: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
zoomLevel: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Fit button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={onAutoFit}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">Fit</span>
|
||||
<KeyboardShortcut keys="Z" />
|
||||
</button>
|
||||
|
||||
{/* Center button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={onCenter}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">Center</span>
|
||||
<KeyboardShortcut keys="C" />
|
||||
</button>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">{zoomLevel}%</span>
|
||||
<div className="flex flex-row items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomOut}
|
||||
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-xs">−</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomIn}
|
||||
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-xs">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SettingsButton = memo(function SettingsButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<Settings className="w-5 h-5 text-[#737373]" />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const NavigationControls = memo<NavigationControlsProps>(
|
||||
({
|
||||
onCenter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onAutoFit,
|
||||
nodes,
|
||||
className = "",
|
||||
zoomLevel,
|
||||
}) => {
|
||||
if (nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className="flex flex-row items-end gap-2">
|
||||
<NavigationButtons
|
||||
onAutoFit={onAutoFit}
|
||||
onCenter={onCenter}
|
||||
onZoomIn={onZoomIn}
|
||||
onZoomOut={onZoomOut}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
{/* Commented out for now as we are not using this */}
|
||||
{/*<SettingsButton />*/}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
NavigationControls.displayName = "NavigationControls"
|
||||
|
|
@ -1,467 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useMemo, useCallback, useState } from "react"
|
||||
import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types"
|
||||
import type { ChainEntry } from "./canvas/version-chain"
|
||||
|
||||
export interface NodeHoverPopoverProps {
|
||||
node: GraphNode
|
||||
screenX: number
|
||||
screenY: number
|
||||
nodeRadius: number
|
||||
containerBounds?: DOMRect
|
||||
versionChain?: ChainEntry[] | null
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
onNavigateUp?: () => void
|
||||
onNavigateDown?: () => void
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function KeyBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "#181A1E",
|
||||
border: "1px solid #2A2C2F",
|
||||
color: "#737373",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ background: "none", border: "none", padding: "2px 0" }}
|
||||
>
|
||||
<KeyBadge>{icon}</KeyBadge>
|
||||
<span
|
||||
className="text-[11px] whitespace-nowrap"
|
||||
style={{ color: "#525D6E" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyableId({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [value])
|
||||
|
||||
const short =
|
||||
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1.5 group cursor-pointer"
|
||||
style={{ background: "none", border: "none", padding: 0 }}
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: "#525D6E" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-mono group-hover:text-white transition-colors"
|
||||
style={{ color: "#737373" }}
|
||||
>
|
||||
{copied ? "Copied!" : short}
|
||||
</span>
|
||||
{!copied && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#525D6E"
|
||||
strokeWidth="2"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type Quadrant = "right" | "left" | "above" | "below"
|
||||
|
||||
function pickBestQuadrant(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
nodeRadius: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
popoverWidth: number,
|
||||
popoverHeight: number,
|
||||
): Quadrant {
|
||||
const gap = 24
|
||||
const spaceRight = containerWidth - (screenX + nodeRadius + gap)
|
||||
const spaceLeft = screenX - nodeRadius - gap
|
||||
const spaceAbove = screenY - nodeRadius - gap
|
||||
const spaceBelow = containerHeight - (screenY + nodeRadius + gap)
|
||||
|
||||
const fits: [Quadrant, number][] = [
|
||||
["right", spaceRight >= popoverWidth ? spaceRight : -1],
|
||||
["left", spaceLeft >= popoverWidth ? spaceLeft : -1],
|
||||
["above", spaceAbove >= popoverHeight ? spaceAbove : -1],
|
||||
["below", spaceBelow >= popoverHeight ? spaceBelow : -1],
|
||||
]
|
||||
|
||||
const preferred: Quadrant[] = ["right", "left", "below", "above"]
|
||||
for (const q of preferred) {
|
||||
const entry = fits.find(([dir]) => dir === q)
|
||||
if (entry && entry[1] > 0) return q
|
||||
}
|
||||
|
||||
return fits.sort((a, b) => b[1] - a[1])[0]![0]
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number) {
|
||||
return s.length > max ? `${s.substring(0, max)}...` : s
|
||||
}
|
||||
|
||||
function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onSelect,
|
||||
}: {
|
||||
chain: ChainEntry[]
|
||||
currentId: string
|
||||
onSelect?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 max-h-[120px] overflow-y-auto">
|
||||
{chain.map((entry) => {
|
||||
const isCurrent = entry.id === currentId
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(entry.id)}
|
||||
className="flex items-start gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: isCurrent ? "#0A1825" : "transparent",
|
||||
border: "none",
|
||||
borderLeft: isCurrent ? "2px solid #36FDFD" : "2px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-semibold shrink-0 mt-px"
|
||||
style={{
|
||||
color: entry.isForgotten
|
||||
? "#DC2626"
|
||||
: isCurrent
|
||||
? "#36FDFD"
|
||||
: "#525D6E",
|
||||
}}
|
||||
>
|
||||
v{entry.version}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] leading-tight"
|
||||
style={{
|
||||
color: isCurrent ? "#9CA3AF" : "#4A5568",
|
||||
}}
|
||||
>
|
||||
{truncate(entry.memory, 60)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
||||
function NodeHoverPopover({
|
||||
node,
|
||||
screenX,
|
||||
screenY,
|
||||
nodeRadius,
|
||||
containerBounds,
|
||||
versionChain,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
onNavigateUp,
|
||||
onNavigateDown,
|
||||
onSelectNode,
|
||||
}) {
|
||||
const CARD_W = 280
|
||||
const SHORTCUTS_W = 100
|
||||
const GAP = 24
|
||||
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
|
||||
|
||||
const isMemory = node.type === "memory"
|
||||
const data = node.data
|
||||
|
||||
const memoryMeta = useMemo(() => {
|
||||
if (!isMemory) return null
|
||||
const md = data as MemoryNodeData
|
||||
return {
|
||||
version: md.version ?? 1,
|
||||
isLatest: md.isLatest ?? false,
|
||||
isForgotten: md.isForgotten ?? false,
|
||||
forgetReason: md.forgetReason ?? null,
|
||||
forgetAfter: md.forgetAfter ?? null,
|
||||
}
|
||||
}, [isMemory, data])
|
||||
|
||||
const hasChain = versionChain && versionChain.length > 1
|
||||
const hasForgetInfo =
|
||||
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
|
||||
|
||||
const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
|
||||
const TOTAL_H = CARD_H
|
||||
|
||||
const { popoverX, popoverY, connectorPath } = useMemo(() => {
|
||||
const cw = containerBounds?.width ?? 800
|
||||
const ch = containerBounds?.height ?? 600
|
||||
|
||||
const quadrant = pickBestQuadrant(
|
||||
screenX,
|
||||
screenY,
|
||||
nodeRadius,
|
||||
cw,
|
||||
ch,
|
||||
TOTAL_W + GAP,
|
||||
TOTAL_H,
|
||||
)
|
||||
|
||||
let px: number
|
||||
let py: number
|
||||
let connStart: { x: number; y: number }
|
||||
|
||||
switch (quadrant) {
|
||||
case "right":
|
||||
px = screenX + nodeRadius + GAP
|
||||
py = screenY - TOTAL_H / 2
|
||||
connStart = { x: screenX + nodeRadius, y: screenY }
|
||||
break
|
||||
case "left":
|
||||
px = screenX - nodeRadius - GAP - TOTAL_W
|
||||
py = screenY - TOTAL_H / 2
|
||||
connStart = { x: screenX - nodeRadius, y: screenY }
|
||||
break
|
||||
case "below":
|
||||
px = screenX - TOTAL_W / 2
|
||||
py = screenY + nodeRadius + GAP
|
||||
connStart = { x: screenX, y: screenY + nodeRadius }
|
||||
break
|
||||
case "above":
|
||||
px = screenX - TOTAL_W / 2
|
||||
py = screenY - nodeRadius - GAP - TOTAL_H
|
||||
connStart = { x: screenX, y: screenY - nodeRadius }
|
||||
break
|
||||
}
|
||||
|
||||
px = Math.max(8, Math.min(cw - TOTAL_W - 8, px))
|
||||
py = Math.max(8, Math.min(ch - TOTAL_H - 8, py))
|
||||
|
||||
const cardCenterX = px + CARD_W / 2
|
||||
const cardCenterY = py + TOTAL_H / 2
|
||||
const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}`
|
||||
|
||||
return { popoverX: px, popoverY: py, connectorPath: path }
|
||||
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (isMemory) {
|
||||
const md = data as MemoryNodeData
|
||||
return md.memory || md.content || ""
|
||||
}
|
||||
const dd = data as DocumentNodeData
|
||||
return dd.summary || dd.title || ""
|
||||
}, [isMemory, data])
|
||||
|
||||
const docData = !isMemory ? (data as DocumentNodeData) : null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[100]">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full overflow-visible"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<path
|
||||
d={connectorPath}
|
||||
stroke="#3B73B8"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute flex gap-3 pointer-events-auto"
|
||||
style={{ left: popoverX, top: popoverY }}
|
||||
>
|
||||
{/* Card */}
|
||||
<div
|
||||
className="flex flex-col rounded-xl overflow-hidden"
|
||||
style={{ width: CARD_W, backgroundColor: "#0C1829" }}
|
||||
>
|
||||
{/* Content — show timeline if chain exists, otherwise plain text */}
|
||||
{hasChain ? (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={node.id}
|
||||
onSelect={onSelectNode}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="p-3 overflow-hidden"
|
||||
style={{ backgroundColor: "#060D17" }}
|
||||
>
|
||||
<p
|
||||
className="m-0 leading-[135%]"
|
||||
style={{
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontSize: 12,
|
||||
color: "#525D6E",
|
||||
}}
|
||||
>
|
||||
{truncate(content, 100) || "No content"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forget info (memory-only) */}
|
||||
{memoryMeta && hasForgetInfo && (
|
||||
<div
|
||||
className="px-3 py-1.5 flex flex-col gap-0.5"
|
||||
style={{
|
||||
backgroundColor: "#0A1320",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{memoryMeta.forgetAfter && (
|
||||
<span className="text-[10px]" style={{ color: "#F59E0B" }}>
|
||||
Expires:{" "}
|
||||
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.forgetReason && (
|
||||
<span className="text-[10px]" style={{ color: "#8B8B8B" }}>
|
||||
Reason: {memoryMeta.forgetReason}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
|
||||
<span className="text-[10px]" style={{ color: "#EF4444" }}>
|
||||
Forgotten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: "#0C1829",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{memoryMeta ? (
|
||||
<>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: memoryMeta.isForgotten
|
||||
? "#DC2626"
|
||||
: memoryMeta.isLatest
|
||||
? "#05A376"
|
||||
: "#525D6E",
|
||||
}}
|
||||
>
|
||||
v{memoryMeta.version}{" "}
|
||||
{memoryMeta.isForgotten
|
||||
? "Forgotten"
|
||||
: memoryMeta.isLatest
|
||||
? "Latest"
|
||||
: "Superseded"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs" style={{ color: "#525D6E" }}>
|
||||
{docData?.type || "document"}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "#525D6E" }}>
|
||||
{docData?.memories?.length ?? 0} memories
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ID row */}
|
||||
<div
|
||||
className="px-3 py-1.5 flex items-center"
|
||||
style={{
|
||||
backgroundColor: "#080E18",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{isMemory ? (
|
||||
<CopyableId label="Memory" value={node.id} />
|
||||
) : (
|
||||
<CopyableId label="Document" value={node.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div
|
||||
className="flex flex-col justify-center gap-1.5 px-3 py-2 rounded-lg"
|
||||
style={{ backgroundColor: "#0C1829" }}
|
||||
>
|
||||
{isMemory && (
|
||||
<NavButton
|
||||
icon="↑"
|
||||
label={hasChain ? "Older version" : "Go to document"}
|
||||
onClick={onNavigateUp}
|
||||
/>
|
||||
)}
|
||||
{(isMemory ? hasChain : true) && (
|
||||
<NavButton
|
||||
icon="↓"
|
||||
label={isMemory ? "Newer version" : "Go to memory"}
|
||||
onClick={onNavigateDown}
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
icon="→"
|
||||
label={isMemory ? "Next memory" : "Next document"}
|
||||
onClick={onNavigateNext}
|
||||
/>
|
||||
<NavButton
|
||||
icon="←"
|
||||
label={isMemory ? "Prev memory" : "Prev document"}
|
||||
onClick={onNavigatePrev}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useEffect } from "react"
|
||||
import type { GraphNode } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export interface NodePopoverProps {
|
||||
node: GraphNode
|
||||
x: number // Screen X position
|
||||
y: number // Screen Y position
|
||||
onClose: () => void
|
||||
containerBounds?: DOMRect // Optional container bounds to limit backdrop
|
||||
onBackdropClick?: () => void // Optional callback when backdrop is clicked
|
||||
}
|
||||
|
||||
export const NodePopover = memo<NodePopoverProps>(function NodePopover({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
containerBounds,
|
||||
onBackdropClick,
|
||||
}) {
|
||||
// Handle Escape key to close popover
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
// Calculate backdrop bounds - use container bounds if provided, otherwise full viewport
|
||||
const backdropStyle = containerBounds
|
||||
? {
|
||||
left: `${containerBounds.left}px`,
|
||||
top: `${containerBounds.top}px`,
|
||||
width: `${containerBounds.width}px`,
|
||||
height: `${containerBounds.height}px`,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
onBackdropClick?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible backdrop to catch clicks outside */}
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
className={cn(
|
||||
"fixed z-[999] pointer-events-auto bg-transparent",
|
||||
!containerBounds && "inset-0",
|
||||
)}
|
||||
style={backdropStyle}
|
||||
/>
|
||||
|
||||
{/* Popover content */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
|
||||
className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
}}
|
||||
>
|
||||
{node.type === "document" ? (
|
||||
// Document popover
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<h3 className="text-base font-bold text-white m-0">Document</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Title
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary - truncated to 2 lines */}
|
||||
{(node.data as any).summary && (
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Summary
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed line-clamp-2">
|
||||
{(node.data as any).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Type
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).type || "Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Memory Count */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Memory Count
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).memoryEntries?.length || 0} memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
{((node.data as any).url || (node.data as any).customId) && (
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
URL
|
||||
</div>
|
||||
<a
|
||||
href={(() => {
|
||||
const doc = node.data as any
|
||||
if (doc.type === "google_doc" && doc.customId) {
|
||||
return `https://docs.google.com/document/d/${doc.customId}`
|
||||
}
|
||||
if (doc.type === "google_sheet" && doc.customId) {
|
||||
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
|
||||
}
|
||||
if (doc.type === "google_slide" && doc.customId) {
|
||||
return `https://docs.google.com/presentation/d/${doc.customId}`
|
||||
}
|
||||
return doc.url ?? undefined
|
||||
})()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-indigo-400 no-underline flex items-center gap-1 transition-colors hover:text-indigo-300"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with metadata */}
|
||||
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>
|
||||
{new Date(
|
||||
(node.data as any).createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Memory popover
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-400"
|
||||
>
|
||||
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
||||
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
||||
</svg>
|
||||
<h3 className="text-base font-bold text-white m-0">Memory</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Memory content */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Memory
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).memory ||
|
||||
(node.data as any).content ||
|
||||
"No content"}
|
||||
</p>
|
||||
{(node.data as any).isForgotten && (
|
||||
<div className="mt-2 px-2 py-1 bg-red-600/15 rounded text-xs text-red-400 inline-block">
|
||||
Forgotten
|
||||
</div>
|
||||
)}
|
||||
{/* Expires (inline with memory if exists) */}
|
||||
{(node.data as any).forgetAfter && (
|
||||
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
|
||||
Expires:{" "}
|
||||
{new Date(
|
||||
(node.data as any).forgetAfter,
|
||||
).toLocaleDateString()}
|
||||
{(node.data as any).forgetReason &&
|
||||
` - ${(node.data as any).forgetReason}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Space */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Space
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>
|
||||
{new Date(
|
||||
(node.data as any).createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./api-types"
|
||||
|
||||
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
|
||||
|
||||
// Graph API types matching backend response
|
||||
|
||||
export interface GraphApiMemory {
|
||||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
spaceId: string
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
forgetReason: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
rootMemoryId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number // backend coordinates (dynamic range)
|
||||
y: number // backend coordinates (dynamic range)
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number // 0-1
|
||||
}
|
||||
|
||||
export interface GraphViewportResponse {
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
viewport: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface GraphBoundsResponse {
|
||||
bounds: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface GraphStatsResponse {
|
||||
totalDocuments: number
|
||||
documentsWithSpatial: number
|
||||
totalDocumentEdges: number
|
||||
}
|
||||
|
||||
// Typed node data
|
||||
|
||||
export interface DocumentNodeData {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface MemoryNodeData {
|
||||
id: string
|
||||
memory: string
|
||||
content: string
|
||||
documentId: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
forgetReason: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
spaceId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: "document" | "memory"
|
||||
x: number
|
||||
y: number
|
||||
data: DocumentNodeData | MemoryNodeData
|
||||
size: number
|
||||
borderColor: string
|
||||
isHovered: boolean
|
||||
isDragging: boolean
|
||||
// D3-force simulation properties
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
similarity: number
|
||||
visualProps: {
|
||||
opacity: number
|
||||
thickness: number
|
||||
}
|
||||
edgeType: "doc-memory" | "similarity" | "version"
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
width: number
|
||||
height: number
|
||||
highlightDocumentIds?: string[]
|
||||
selectedNodeId?: string | null
|
||||
onNodeHover: (nodeId: string | null) => void
|
||||
onNodeClick: (nodeId: string | null) => void
|
||||
onNodeDragStart: (nodeId: string) => void
|
||||
onNodeDragEnd: () => void
|
||||
onViewportChange?: (zoom: number) => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
variant?: "console" | "consumer"
|
||||
simulation?: import("./canvas/simulation").ForceSimulation
|
||||
viewportRef?: React.RefObject<
|
||||
import("./canvas/viewport").ViewportState | null
|
||||
>
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
variant?: "console" | "consumer"
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
hoveredNode?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingIndicatorProps {
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
totalLoaded: number
|
||||
variant?: "console" | "consumer"
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
|
|||
ignoreBuildErrors: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@supermemory/memory-graph",
|
||||
"@tiptap/core",
|
||||
"@tiptap/react",
|
||||
"@tiptap/pm",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,8 @@
|
|||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.7",
|
||||
"@repo/lib": "workspace:*",
|
||||
"@repo/validation": "workspace:*"
|
||||
"@repo/validation": "workspace:*",
|
||||
"@supermemory/memory-graph": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.2.2",
|
||||
|
|
|
|||
85
bun.lock
85
bun.lock
|
|
@ -161,6 +161,7 @@
|
|||
"@repo/lib": "workspace:*",
|
||||
"@repo/validation": "workspace:*",
|
||||
"@sentry/nextjs": "^10.33.0",
|
||||
"@supermemory/memory-graph": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-form": "^1.12.4",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
|
|
@ -294,26 +295,18 @@
|
|||
},
|
||||
"packages/memory-graph": {
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"@vanilla-extract/recipes": "^0.5.7",
|
||||
"@vanilla-extract/sprinkles": "^1.6.5",
|
||||
"d3-force": "^3.0.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"motion": "^12.23.24",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.1",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
|
|
@ -2059,22 +2052,12 @@
|
|||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vanilla-extract/babel-plugin-debug-ids": ["@vanilla-extract/babel-plugin-debug-ids@1.2.2", "", { "dependencies": { "@babel/core": "^7.23.9" } }, "sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw=="],
|
||||
|
||||
"@vanilla-extract/compiler": ["@vanilla-extract/compiler@0.3.4", "", { "dependencies": { "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/integration": "^8.0.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite-node": "^3.2.2" } }, "sha512-W9HXf9EAccpE1vEIATvSoBVj/bQnmHfYHfDJjUN8dcOHW6oMcnoGTqweDM9I66BHqlNH4d0IsaeZKSViOv7K4w=="],
|
||||
|
||||
"@vanilla-extract/css": ["@vanilla-extract/css@1.18.0", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ=="],
|
||||
|
||||
"@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.7", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.18.0", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg=="],
|
||||
|
||||
"@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="],
|
||||
|
||||
"@vanilla-extract/recipes": ["@vanilla-extract/recipes@0.5.7", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A=="],
|
||||
|
||||
"@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="],
|
||||
|
||||
"@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.1.4", "", { "dependencies": { "@vanilla-extract/compiler": "^0.3.4", "@vanilla-extract/integration": "^8.0.7" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-fTYNKUK3n4ApkUf2FEcO7mpqNKEHf9kDGg8DXlkqHtPxgwPhjuaajmDfQCSBsNgnA2SLI+CB5EO6kLQuKsw2Rw=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
|
||||
|
|
@ -2829,8 +2812,6 @@
|
|||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="],
|
||||
|
||||
"event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
|
@ -3299,8 +3280,6 @@
|
|||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
|
@ -4177,8 +4156,6 @@
|
|||
|
||||
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||
|
||||
"require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="],
|
||||
|
||||
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
|
@ -5169,8 +5146,6 @@
|
|||
|
||||
"@supermemory/ai-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@supermemory/memory-graph/lucide-react": ["lucide-react@0.552.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw=="],
|
||||
|
||||
"@supermemory/memory-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"@supermemory/tools/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q=="],
|
||||
|
|
@ -5199,8 +5174,6 @@
|
|||
|
||||
"@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
|
@ -6043,58 +6016,6 @@
|
|||
|
||||
"@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||
|
||||
"@vanilla-extract/integration/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"agents/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"agents/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
|
|
|||
|
|
@ -1,37 +1,27 @@
|
|||
{
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
|
||||
"type": "module",
|
||||
"main": "./dist/memory-graph.cjs",
|
||||
"module": "./dist/memory-graph.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"main": "./src/index.tsx",
|
||||
"module": "./src/index.tsx",
|
||||
"types": "./src/index.tsx",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/memory-graph.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/memory-graph.cjs"
|
||||
}
|
||||
},
|
||||
"./styles.css": "./dist/memory-graph.css",
|
||||
".": "./src/index.tsx",
|
||||
"./mock-data": "./src/mock-data.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build && tsc --emitDeclarationOnly",
|
||||
"check-types": "tsc --noEmit",
|
||||
"prepublishOnly": "bun run build"
|
||||
"prepack": "bun run build && bun run scripts/swap-exports.ts pack",
|
||||
"postpack": "bun run scripts/swap-exports.ts unpack",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"supermemory",
|
||||
|
|
@ -59,23 +49,15 @@
|
|||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"@vanilla-extract/recipes": "^0.5.7",
|
||||
"@vanilla-extract/sprinkles": "^1.6.5",
|
||||
"d3-force": "^3.0.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"motion": "^12.23.24"
|
||||
"d3-force": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.1"
|
||||
"vite": "^7.2.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
packages/memory-graph/scripts/swap-exports.ts
Normal file
50
packages/memory-graph/scripts/swap-exports.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Swaps package.json exports between source (for workspace/monorepo use)
|
||||
* and dist (for npm publishing).
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/swap-exports.ts pack # switch to dist exports
|
||||
* bun run scripts/swap-exports.ts unpack # switch back to source exports
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const pkgPath = join(import.meta.dirname, "..", "package.json")
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
|
||||
|
||||
const mode = process.argv[2]
|
||||
|
||||
if (mode === "pack") {
|
||||
// Switch to dist exports for npm publishing
|
||||
pkg.main = "./dist/memory-graph.cjs"
|
||||
pkg.module = "./dist/memory-graph.js"
|
||||
pkg.types = "./dist/index.d.ts"
|
||||
pkg.exports = {
|
||||
".": {
|
||||
types: "./dist/index.d.ts",
|
||||
import: "./dist/memory-graph.js",
|
||||
require: "./dist/memory-graph.cjs",
|
||||
},
|
||||
"./mock-data": {
|
||||
types: "./dist/mock-data.d.ts",
|
||||
import: "./dist/mock-data.js",
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
}
|
||||
} else if (mode === "unpack") {
|
||||
// Switch back to source exports for workspace use
|
||||
pkg.main = "./src/index.tsx"
|
||||
pkg.module = "./src/index.tsx"
|
||||
pkg.types = "./src/index.tsx"
|
||||
pkg.exports = {
|
||||
".": "./src/index.tsx",
|
||||
"./mock-data": "./src/mock-data.ts",
|
||||
"./package.json": "./package.json",
|
||||
}
|
||||
} else {
|
||||
console.error("Usage: bun run scripts/swap-exports.ts [pack|unpack]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
console.log(`Exports swapped to ${mode === "pack" ? "dist" : "source"} mode`)
|
||||
655
packages/memory-graph/src/__tests__/edge-logic.test.ts
Normal file
655
packages/memory-graph/src/__tests__/edge-logic.test.ts
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
/**
|
||||
* Adversarial tests for the three core changes in graph-perf-consolidation.
|
||||
*
|
||||
* Focuses on the exact logic that was changed, NOT on surrounding scaffolding:
|
||||
*
|
||||
* (1) use-graph-data.ts — edges useMemo now uses memoryRelations Record as
|
||||
* primary source, falls back to parentMemoryId for legacy data.
|
||||
* The old code expected an ARRAY [{relationType, targetMemoryId}].
|
||||
* The new code expects a Record<targetId, relationType>.
|
||||
*
|
||||
* (2) MCP mcp-app.ts — transformData now pre-populates nodeIds before edge
|
||||
* computation, fixing a forward-reference bug where edges to memories
|
||||
* that appeared later in the iteration order were silently dropped.
|
||||
*
|
||||
* (3) Invalid relationType values now default to "updates" instead of blowing up.
|
||||
*
|
||||
* The edges useMemo is a pure function of `documents` — no React hook machinery
|
||||
* is needed. We extract the identical logic here and test it directly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
import { getEdgeVisualProps } from "../hooks/use-graph-data"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure extraction of edges useMemo from use-graph-data.ts
|
||||
// This is a verbatim copy of the logic so a regression in the source will
|
||||
// cause this test to diverge — but more importantly, we can verify the exact
|
||||
// semantics match the spec described in the commit messages.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ComputedEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
edgeType: string
|
||||
}
|
||||
|
||||
function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: ComputedEdge[] = []
|
||||
|
||||
// Pre-populate all node IDs (the key step from the forward-reference fix)
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
// 1. Derives edges: document -> memory (structural)
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
edgeType: "derives",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations Record<targetId, relationType> as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
let relations: Record<string, string> = {}
|
||||
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
// Legacy fallback: parentMemoryId implies "updates"
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
result.push({
|
||||
id: `rel-${targetId}-${mem.id}`,
|
||||
source: targetId,
|
||||
target: mem.id,
|
||||
edgeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP transformData logic — pure extraction from mcp-app.ts transformData().
|
||||
// Includes the forward-reference fix (nodeIds pre-populated before edges).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface McpLink {
|
||||
source: string
|
||||
target: string
|
||||
edgeType: "derives" | "updates" | "extends"
|
||||
}
|
||||
|
||||
function mcpComputeLinks(
|
||||
documents: Array<{
|
||||
id: string
|
||||
memories: Array<{
|
||||
id: string
|
||||
parentMemoryId: string | null
|
||||
memoryRelations?: Record<string, string> | null
|
||||
}>
|
||||
}>,
|
||||
): McpLink[] {
|
||||
const links: McpLink[] = []
|
||||
|
||||
// Pre-populate all node IDs (the forward-reference fix)
|
||||
const nodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
nodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) nodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
// Derives link (doc -> memory)
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
|
||||
|
||||
let relations: Record<string, string> = {}
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!nodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
links.push({ source: targetId, target: mem.id, edgeType })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory)
|
||||
* rather than upfront. Used to prove the regression test would catch the bug. */
|
||||
function mcpComputeLinks_BUGGY(
|
||||
documents: Array<{
|
||||
id: string
|
||||
memories: Array<{
|
||||
id: string
|
||||
parentMemoryId: string | null
|
||||
memoryRelations?: Record<string, string> | null
|
||||
}>
|
||||
}>,
|
||||
): McpLink[] {
|
||||
const links: McpLink[] = []
|
||||
const nodeIds = new Set<string>() // NOT pre-populated — reproduces the old bug
|
||||
|
||||
for (const doc of documents) {
|
||||
nodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) {
|
||||
nodeIds.add(mem.id) // only added as we reach this memory
|
||||
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
|
||||
|
||||
let relations: Record<string, string> = {}
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!nodeIds.has(targetId)) continue // forward refs silently dropped here!
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
links.push({ source: targetId, target: mem.id, edgeType })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMem(
|
||||
overrides: Partial<GraphApiMemory> & { id: string },
|
||||
): GraphApiMemory {
|
||||
return {
|
||||
memory: `Memory ${overrides.id}`,
|
||||
isStatic: false,
|
||||
spaceId: "default",
|
||||
isLatest: true,
|
||||
isForgotten: false,
|
||||
forgetAfter: null,
|
||||
forgetReason: null,
|
||||
version: 1,
|
||||
parentMemoryId: null,
|
||||
rootMemoryId: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
|
||||
return {
|
||||
id,
|
||||
title: `Doc ${id}`,
|
||||
summary: null,
|
||||
documentType: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (1) memoryRelations Record as PRIMARY source
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: memoryRelations Record as primary source", () => {
|
||||
it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "extends" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("extends")
|
||||
})
|
||||
|
||||
it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "updates" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "derives" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("derives")
|
||||
})
|
||||
|
||||
it("creates multiple edges when memoryRelations has multiple targets", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2" }),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
memoryRelations: { m1: "updates", m2: "extends" },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdgesToM3 = edges.filter(
|
||||
(e) => e.target === "m3" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdgesToM3.length).toBe(2)
|
||||
const types = new Set(relEdgesToM3.map((e) => e.edgeType))
|
||||
expect(types.has("updates")).toBe(true)
|
||||
expect(types.has("extends")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => {
|
||||
/**
|
||||
* Critical regression test: if a memory has BOTH memoryRelations and
|
||||
* parentMemoryId, only memoryRelations should be used. The fallback
|
||||
* parentMemoryId path must NOT fire when memoryRelations is present.
|
||||
*/
|
||||
it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "mParent" }),
|
||||
makeMem({ id: "mOther" }),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
// parentMemoryId would imply an "updates" edge to mParent
|
||||
parentMemoryId: "mParent",
|
||||
// memoryRelations says extends to mOther — this should win
|
||||
memoryRelations: { mOther: "extends" },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdges = edges.filter(
|
||||
(e) => e.id.startsWith("rel-") && e.target === "m3",
|
||||
)
|
||||
|
||||
// Should be exactly ONE rel edge (extends to mOther), NOT two
|
||||
expect(relEdges.length).toBe(1)
|
||||
expect(relEdges[0]?.edgeType).toBe("extends")
|
||||
expect(relEdges[0]?.source).toBe("mOther")
|
||||
|
||||
// The parentMemoryId-implied updates edge to mParent must NOT exist
|
||||
const badEdge = relEdges.find((e) => e.source === "mParent")
|
||||
expect(badEdge).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: parentMemoryId fallback", () => {
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is absent", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is null", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("drops the parentMemoryId edge when the parent is not in the dataset", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs")
|
||||
expect(ghostEdges.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (4) Invalid relationType defaults to "updates"
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: invalid relationType defaults to updates", () => {
|
||||
it("maps an unknown relationType string to 'updates' edge type", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test
|
||||
memoryRelations: { m1: "totally-bogus-type" as any },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (5) MCP forward-reference fix — THE CRITICAL BUG
|
||||
// ===========================================================================
|
||||
|
||||
describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => {
|
||||
/**
|
||||
* SETUP: Two memories in the same document. m1 is processed first.
|
||||
* m1 has memoryRelations pointing at m2, which appears AFTER m1.
|
||||
*
|
||||
* OLD BUG: nodeIds was built lazily — when m1 was processed, m2 had not
|
||||
* yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the
|
||||
* edge was silently skipped.
|
||||
*
|
||||
* FIX: All node IDs are pre-populated before any edge is evaluated, so
|
||||
* m2 is always in nodeIds when m1's relations are checked.
|
||||
*/
|
||||
it("creates edge for forward-referenced memory within same document", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
// m1 comes FIRST and references m2 which comes SECOND
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "extends" },
|
||||
},
|
||||
{ id: "m2", parentMemoryId: null, memoryRelations: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Fixed version creates the edge
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
|
||||
expect(edge).toBeDefined()
|
||||
expect(edge?.edgeType).toBe("extends")
|
||||
})
|
||||
|
||||
it("creates edge for forward-referenced memory in a LATER document", () => {
|
||||
// m1 in doc1 references m2 in doc2. m2 is only encountered during
|
||||
// doc2's iteration, AFTER m1's relations are evaluated.
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "updates" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
|
||||
},
|
||||
]
|
||||
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
|
||||
expect(edge).toBeDefined()
|
||||
expect(edge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
/**
|
||||
* REGRESSION PROOF: Run the same scenario through the BUGGY implementation.
|
||||
* The buggy version MUST drop the edge. If this test fails (i.e. buggy code
|
||||
* creates the edge too), the forward-reference scenario doesn't actually
|
||||
* demonstrate the bug, and our fix tests prove nothing.
|
||||
*/
|
||||
it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "extends" },
|
||||
},
|
||||
{ id: "m2", parentMemoryId: null, memoryRelations: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// The buggy version MUST drop the m1->m2 edge (forward reference)
|
||||
const buggyLinks = mcpComputeLinks_BUGGY(docs)
|
||||
const buggyEdge = buggyLinks.find(
|
||||
(l) => l.source === "m2" && l.target === "m1",
|
||||
)
|
||||
expect(buggyEdge).toBeUndefined() // confirms the bug was real
|
||||
|
||||
// The fixed version MUST create it
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const fixedEdge = fixedLinks.find(
|
||||
(l) => l.source === "m2" && l.target === "m1",
|
||||
)
|
||||
expect(fixedEdge).toBeDefined() // confirms the fix works
|
||||
})
|
||||
|
||||
it("proves the bug: cross-document forward reference also dropped by lazy code", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "updates" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
|
||||
},
|
||||
]
|
||||
|
||||
// Buggy: m2 is not in nodeIds when m1 is processed — edge dropped
|
||||
const buggyLinks = mcpComputeLinks_BUGGY(docs)
|
||||
expect(
|
||||
buggyLinks.find((l) => l.source === "m2" && l.target === "m1"),
|
||||
).toBeUndefined()
|
||||
|
||||
// Fixed: m2 is pre-populated — edge created
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
expect(
|
||||
fixedLinks.find((l) => l.source === "m2" && l.target === "m1"),
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (6) Structural correctness: derives edges always created, doc->mem
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => {
|
||||
it("creates exactly one derives edge per memory across multiple documents", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]),
|
||||
makeDoc("d2", [
|
||||
makeMem({ id: "m3" }),
|
||||
makeMem({ id: "m4" }),
|
||||
makeMem({ id: "m5" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const derivesEdges = edges.filter((e) => e.edgeType === "derives")
|
||||
// 2 memories in d1 + 3 in d2 = 5 derives edges
|
||||
expect(derivesEdges.length).toBe(5)
|
||||
expect(derivesEdges.map((e) => e.target).sort()).toEqual([
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
])
|
||||
})
|
||||
|
||||
it("returns empty array for empty documents input", () => {
|
||||
expect(computeEdges([])).toEqual([])
|
||||
})
|
||||
|
||||
it("creates no relation edges when all memories are standalone", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations
|
||||
makeMem({ id: "m2" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdges = edges.filter((e) => e.id.startsWith("rel-"))
|
||||
expect(relEdges.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source
|
||||
// ===========================================================================
|
||||
|
||||
describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => {
|
||||
const relations = ["updates", "extends", "derives"] as const
|
||||
|
||||
for (const rel of relations) {
|
||||
it(`returns positive opacity and thickness for '${rel}'`, () => {
|
||||
const props = getEdgeVisualProps(rel)
|
||||
expect(props.opacity).toBeGreaterThan(0)
|
||||
expect(props.thickness).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
|
||||
it("extends edges have higher opacity than derives edges (rare but meaningful)", () => {
|
||||
const ext = getEdgeVisualProps("extends")
|
||||
const der = getEdgeVisualProps("derives")
|
||||
expect(ext.opacity).toBeGreaterThan(der.opacity)
|
||||
})
|
||||
|
||||
it("updates edges have higher opacity than derives edges (version chains are prominent)", () => {
|
||||
const upd = getEdgeVisualProps("updates")
|
||||
const der = getEdgeVisualProps("derives")
|
||||
expect(upd.opacity).toBeGreaterThan(der.opacity)
|
||||
})
|
||||
|
||||
it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => {
|
||||
// The default case returns { opacity: 0.4, thickness: 1.2 }.
|
||||
// A safe conservative fallback matching derives (the most common edge type).
|
||||
const unknown = getEdgeVisualProps("nonexistent")
|
||||
expect(unknown.opacity).toBeCloseTo(0.4)
|
||||
expect(unknown.thickness).toBeCloseTo(1.2)
|
||||
})
|
||||
})
|
||||
85
packages/memory-graph/src/__tests__/graph-data-utils.test.ts
Normal file
85
packages/memory-graph/src/__tests__/graph-data-utils.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
getMemoryBorderColor,
|
||||
getEdgeVisualProps,
|
||||
} from "../hooks/use-graph-data"
|
||||
import { DEFAULT_COLORS } from "../constants"
|
||||
import type { GraphApiMemory } from "../types"
|
||||
|
||||
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
|
||||
return {
|
||||
id: "m1",
|
||||
memory: "test",
|
||||
isStatic: false,
|
||||
spaceId: "default",
|
||||
isLatest: true,
|
||||
isForgotten: false,
|
||||
forgetAfter: null,
|
||||
forgetReason: null,
|
||||
version: 1,
|
||||
parentMemoryId: null,
|
||||
rootMemoryId: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getMemoryBorderColor", () => {
|
||||
const colors = DEFAULT_COLORS
|
||||
|
||||
it("returns forgotten color for forgotten memories", () => {
|
||||
const mem = makeMemory({ isForgotten: true })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
|
||||
})
|
||||
|
||||
it("returns expiring color for memories expiring within 7 days", () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const mem = makeMemory({ forgetAfter: soon })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
|
||||
})
|
||||
|
||||
it("returns recent color for memories created within 24 hours", () => {
|
||||
const recent = new Date(Date.now() - 1000).toISOString()
|
||||
const mem = makeMemory({ createdAt: recent })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderRecent)
|
||||
})
|
||||
|
||||
it("returns default color for normal memories", () => {
|
||||
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const mem = makeMemory({ createdAt: old })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
|
||||
})
|
||||
|
||||
it("forgotten takes priority over expiring", () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const mem = makeMemory({ isForgotten: true, forgetAfter: soon })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEdgeVisualProps", () => {
|
||||
it("returns correct props for derives edges", () => {
|
||||
const props = getEdgeVisualProps("derives")
|
||||
expect(props.opacity).toBeCloseTo(0.4)
|
||||
expect(props.thickness).toBeCloseTo(1.2)
|
||||
})
|
||||
|
||||
it("returns correct props for updates edges", () => {
|
||||
const props = getEdgeVisualProps("updates")
|
||||
expect(props.opacity).toBeCloseTo(0.7)
|
||||
expect(props.thickness).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it("returns correct props for extends edges", () => {
|
||||
const props = getEdgeVisualProps("extends")
|
||||
expect(props.opacity).toBeCloseTo(0.55)
|
||||
expect(props.thickness).toBeCloseTo(1.5)
|
||||
})
|
||||
|
||||
it("returns default props for unknown edge types", () => {
|
||||
const props = getEdgeVisualProps("unknown")
|
||||
expect(props.opacity).toBeCloseTo(0.4)
|
||||
expect(props.thickness).toBeCloseTo(1.2)
|
||||
})
|
||||
})
|
||||
87
packages/memory-graph/src/__tests__/mock-data.test.ts
Normal file
87
packages/memory-graph/src/__tests__/mock-data.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { generateMockGraphData } from "../mock-data"
|
||||
|
||||
describe("generateMockGraphData", () => {
|
||||
it("produces deterministic output with same seed", () => {
|
||||
const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
|
||||
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
|
||||
|
||||
expect(data1.documents.length).toBe(data2.documents.length)
|
||||
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
|
||||
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
|
||||
})
|
||||
|
||||
it("produces different output with different seeds", () => {
|
||||
const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
|
||||
const data2 = generateMockGraphData({ documentCount: 10, seed: 99 })
|
||||
|
||||
// At least some documents should differ
|
||||
const titles1 = data1.documents.map((d) => d.title).join(",")
|
||||
const titles2 = data2.documents.map((d) => d.title).join(",")
|
||||
expect(titles1).not.toBe(titles2)
|
||||
})
|
||||
|
||||
it("generates correct number of documents", () => {
|
||||
const data = generateMockGraphData({ documentCount: 25, seed: 1 })
|
||||
expect(data.documents.length).toBe(25)
|
||||
})
|
||||
|
||||
it("documents have required fields", () => {
|
||||
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
|
||||
for (const doc of data.documents) {
|
||||
expect(doc.id).toBeDefined()
|
||||
expect(doc.title).toBeDefined()
|
||||
expect(doc.summary).toBeDefined()
|
||||
expect(doc.documentType).toBeDefined()
|
||||
expect(doc.createdAt).toBeDefined()
|
||||
expect(doc.updatedAt).toBeDefined()
|
||||
expect(Array.isArray(doc.memories)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("memories have required fields", () => {
|
||||
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
|
||||
const doc = data.documents.find((d) => d.memories.length > 0)
|
||||
expect(doc).toBeDefined()
|
||||
|
||||
for (const mem of doc!.memories) {
|
||||
expect(mem.id).toBeDefined()
|
||||
expect(mem.memory).toBeDefined()
|
||||
expect(typeof mem.isStatic).toBe("boolean")
|
||||
expect(typeof mem.isForgotten).toBe("boolean")
|
||||
expect(typeof mem.isLatest).toBe("boolean")
|
||||
expect(typeof mem.version).toBe("number")
|
||||
expect(mem.createdAt).toBeDefined()
|
||||
expect(mem.updatedAt).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("handles zero documents", () => {
|
||||
const data = generateMockGraphData({ documentCount: 0, seed: 1 })
|
||||
expect(data.documents.length).toBe(0)
|
||||
})
|
||||
|
||||
it("respects memoriesPerDoc range", () => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: 50,
|
||||
memoriesPerDoc: [3, 3],
|
||||
seed: 1,
|
||||
})
|
||||
for (const doc of data.documents) {
|
||||
expect(doc.memories.length).toBe(3)
|
||||
}
|
||||
})
|
||||
|
||||
it("generates version chains for some documents", () => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: 50,
|
||||
memoriesPerDoc: [3, 6],
|
||||
seed: 42,
|
||||
})
|
||||
// With 50 docs and 30% chain probability, we should have some chains
|
||||
const hasChain = data.documents.some((doc) =>
|
||||
doc.memories.some((mem) => mem.parentMemoryId !== null),
|
||||
)
|
||||
expect(hasChain).toBe(true)
|
||||
})
|
||||
})
|
||||
57
packages/memory-graph/src/__tests__/renderer-utils.test.ts
Normal file
57
packages/memory-graph/src/__tests__/renderer-utils.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import { lightenColor } from "../canvas/renderer"
|
||||
|
||||
describe("lightenColor", () => {
|
||||
test("lightens a dark hex color", () => {
|
||||
// #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20)
|
||||
const result = lightenColor("#1B1F24", 0.08)
|
||||
// R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38
|
||||
expect(result).toBe("#2f3338")
|
||||
})
|
||||
|
||||
test("clamps channels at 255", () => {
|
||||
// #FFFFFF lightened by 0.1 → all channels clamped at 255
|
||||
const result = lightenColor("#ffffff", 0.1)
|
||||
expect(result).toBe("#ffffff")
|
||||
})
|
||||
|
||||
test("handles zero amount (no change)", () => {
|
||||
const result = lightenColor("#1B1F24", 0)
|
||||
expect(result).toBe("#1b1f24")
|
||||
})
|
||||
|
||||
test("returns input unchanged for 3-digit hex", () => {
|
||||
expect(lightenColor("#abc", 0.1)).toBe("#abc")
|
||||
})
|
||||
|
||||
test("returns input unchanged for rgb() format", () => {
|
||||
expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)")
|
||||
})
|
||||
|
||||
test("returns input unchanged for 8-digit hex with alpha", () => {
|
||||
expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF")
|
||||
})
|
||||
|
||||
test("caches result for repeated calls", () => {
|
||||
const first = lightenColor("#0D2034", 0.08)
|
||||
const second = lightenColor("#0D2034", 0.08)
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
test("cache invalidates on different input", () => {
|
||||
const a = lightenColor("#0D2034", 0.08)
|
||||
const b = lightenColor("#1B1F24", 0.08)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("cache invalidates on different amount", () => {
|
||||
const a = lightenColor("#1B1F24", 0.05)
|
||||
const b = lightenColor("#1B1F24", 0.1)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("handles hex without # prefix", () => {
|
||||
const result = lightenColor("1B1F24", 0.08)
|
||||
expect(result).toBe("#2f3338")
|
||||
})
|
||||
})
|
||||
114
packages/memory-graph/src/__tests__/simulation.test.ts
Normal file
114
packages/memory-graph/src/__tests__/simulation.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { ForceSimulation } from "../canvas/simulation"
|
||||
import type { GraphNode, GraphEdge } from "../types"
|
||||
|
||||
function makeNode(id: string, x: number, y: number): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "document",
|
||||
x,
|
||||
y,
|
||||
size: 50,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
summary: null,
|
||||
type: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeEdge(source: string, target: string): GraphEdge {
|
||||
return {
|
||||
id: `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
visualProps: { opacity: 0.5, thickness: 1.5 },
|
||||
edgeType: "derives",
|
||||
}
|
||||
}
|
||||
|
||||
describe("ForceSimulation", () => {
|
||||
it("init creates simulation and isActive returns true", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
|
||||
const edges = [makeEdge("a", "b")]
|
||||
sim.init(nodes, edges)
|
||||
expect(sim.isActive()).toBe(true)
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("destroy stops simulation", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0)]
|
||||
sim.init(nodes, [])
|
||||
sim.destroy()
|
||||
expect(sim.isActive()).toBe(false)
|
||||
})
|
||||
|
||||
it("init moves nodes from initial positions (pre-tick)", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [
|
||||
makeNode("a", 0, 0),
|
||||
makeNode("b", 0, 0), // Same position - repulsion should move them
|
||||
]
|
||||
sim.init(nodes, [])
|
||||
|
||||
// After init with pre-ticks, nodes at same position should have moved apart
|
||||
const dx = nodes[0]!.x - nodes[1]!.x
|
||||
const dy = nodes[0]!.y - nodes[1]!.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
expect(dist).toBeGreaterThan(0)
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("update hot-swaps nodes without full re-init", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
|
||||
sim.init(nodes, [])
|
||||
|
||||
// Update with same nodes but different positions
|
||||
nodes[0]!.x = 50
|
||||
expect(() => sim.update(nodes, [])).not.toThrow()
|
||||
expect(sim.isActive()).toBe(true)
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("reheat increases simulation energy", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0)]
|
||||
sim.init(nodes, [])
|
||||
// Should not throw
|
||||
expect(() => sim.reheat()).not.toThrow()
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("coolDown reduces simulation energy", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0)]
|
||||
sim.init(nodes, [])
|
||||
expect(() => sim.coolDown()).not.toThrow()
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("handles empty nodes array", () => {
|
||||
const sim = new ForceSimulation()
|
||||
expect(() => sim.init([], [])).not.toThrow()
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("handles edges with missing nodes gracefully", () => {
|
||||
const sim = new ForceSimulation()
|
||||
const nodes = [makeNode("a", 0, 0)]
|
||||
const edges = [makeEdge("a", "nonexistent")]
|
||||
// Should not throw even with dangling edge
|
||||
expect(() => sim.init(nodes, edges)).not.toThrow()
|
||||
sim.destroy()
|
||||
})
|
||||
})
|
||||
132
packages/memory-graph/src/__tests__/spatial-index.test.ts
Normal file
132
packages/memory-graph/src/__tests__/spatial-index.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { SpatialIndex } from "../canvas/hit-test"
|
||||
import type { GraphNode } from "../types"
|
||||
|
||||
function makeNode(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
type: "document" | "memory" = "document",
|
||||
size = 50,
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
summary: null,
|
||||
type: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("SpatialIndex", () => {
|
||||
it("rebuild returns true on first build", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const result = idx.rebuild([makeNode("a", 100, 100)])
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("rebuild returns false when hash unchanged", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [makeNode("a", 100, 100)]
|
||||
idx.rebuild(nodes)
|
||||
const result = idx.rebuild(nodes)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("rebuild returns true when positions change", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [makeNode("a", 100, 100)]
|
||||
idx.rebuild(nodes)
|
||||
nodes[0]!.x = 500
|
||||
const result = idx.rebuild(nodes)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("rebuild detects sub-pixel movements (10x granularity)", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [makeNode("a", 100.0, 100.0)]
|
||||
idx.rebuild(nodes)
|
||||
// Move by 0.2 pixels — should be detected with 10x rounding
|
||||
nodes[0]!.x = 100.2
|
||||
const result = idx.rebuild(nodes)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("queryPoint finds correct node (document - square hit test)", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const node = makeNode("a", 100, 100, "document", 50)
|
||||
idx.rebuild([node])
|
||||
const found = idx.queryPoint(105, 105)
|
||||
expect(found).not.toBeNull()
|
||||
expect(found!.id).toBe("a")
|
||||
})
|
||||
|
||||
it("queryPoint finds correct node (memory - circle hit test)", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const node = makeNode("m1", 200, 200, "memory", 36)
|
||||
idx.rebuild([node])
|
||||
// Inside the circle (radius = 18)
|
||||
const found = idx.queryPoint(210, 210)
|
||||
expect(found).not.toBeNull()
|
||||
expect(found!.id).toBe("m1")
|
||||
})
|
||||
|
||||
it("queryPoint returns null for empty grid", () => {
|
||||
const idx = new SpatialIndex()
|
||||
idx.rebuild([])
|
||||
expect(idx.queryPoint(100, 100)).toBeNull()
|
||||
})
|
||||
|
||||
it("queryPoint returns null for distant coordinates", () => {
|
||||
const idx = new SpatialIndex()
|
||||
idx.rebuild([makeNode("a", 100, 100)])
|
||||
expect(idx.queryPoint(5000, 5000)).toBeNull()
|
||||
})
|
||||
|
||||
it("queryPoint handles overlapping nodes (returns last in render order)", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [
|
||||
makeNode("a", 100, 100, "document", 50),
|
||||
makeNode("b", 110, 110, "document", 50),
|
||||
]
|
||||
idx.rebuild(nodes)
|
||||
// Both nodes overlap at (105, 105), should return the last one (higher z)
|
||||
const found = idx.queryPoint(105, 105)
|
||||
expect(found).not.toBeNull()
|
||||
expect(found!.id).toBe("b")
|
||||
})
|
||||
|
||||
it("queryPoint works across cell boundaries", () => {
|
||||
const idx = new SpatialIndex()
|
||||
// Node at cell boundary (cellSize = 200)
|
||||
const node = makeNode("edge", 199, 199, "document", 50)
|
||||
idx.rebuild([node])
|
||||
// Query from adjacent cell
|
||||
const found = idx.queryPoint(201, 201)
|
||||
expect(found).not.toBeNull()
|
||||
expect(found!.id).toBe("edge")
|
||||
})
|
||||
|
||||
it("handles many nodes without errors", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = Array.from({ length: 1000 }, (_, i) =>
|
||||
makeNode(`n${i}`, Math.random() * 2000, Math.random() * 2000),
|
||||
)
|
||||
expect(() => idx.rebuild(nodes)).not.toThrow()
|
||||
// Should find at least some nodes
|
||||
const found = idx.queryPoint(nodes[0]!.x, nodes[0]!.y)
|
||||
expect(found).not.toBeNull()
|
||||
})
|
||||
})
|
||||
406
packages/memory-graph/src/__tests__/version-chain.test.ts
Normal file
406
packages/memory-graph/src/__tests__/version-chain.test.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { VersionChainIndex } from "../canvas/version-chain"
|
||||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
|
||||
function makeMem(
|
||||
overrides: Partial<GraphApiMemory> & { id: string },
|
||||
): GraphApiMemory {
|
||||
return {
|
||||
memory: `Memory ${overrides.id}`,
|
||||
isStatic: false,
|
||||
spaceId: "default",
|
||||
isLatest: true,
|
||||
isForgotten: false,
|
||||
forgetAfter: null,
|
||||
forgetReason: null,
|
||||
version: 1,
|
||||
parentMemoryId: null,
|
||||
rootMemoryId: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
|
||||
return {
|
||||
id,
|
||||
title: `Doc ${id}`,
|
||||
summary: null,
|
||||
documentType: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories,
|
||||
}
|
||||
}
|
||||
|
||||
describe("VersionChainIndex", () => {
|
||||
it("getChain returns null for standalone memory (no parent, no children)", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [makeMem({ id: "m1", version: 1 })])
|
||||
idx.rebuild([doc])
|
||||
// Single memory with no parent and no children — not a chain
|
||||
expect(idx.getChain("m1")).toBeNull()
|
||||
})
|
||||
|
||||
it("getChain from latest node returns full chain in version order", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
parentMemoryId: "m2",
|
||||
rootMemoryId: "m1",
|
||||
version: 3,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// Query from the latest (version 3) — walks back m3->m2->m1, reverses to [m1,m2,m3]
|
||||
const chain = idx.getChain("m3")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(3)
|
||||
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
|
||||
})
|
||||
|
||||
it("getChain from middle element returns full chain (backward + forward)", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
parentMemoryId: "m2",
|
||||
rootMemoryId: "m1",
|
||||
version: 3,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// Query from m2 (version 2) — walks back to m1, forward to m3
|
||||
const chain = idx.getChain("m2")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(3)
|
||||
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
|
||||
})
|
||||
|
||||
it("caches chain results for all entries in the chain", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
const chain1 = idx.getChain("m2")
|
||||
// After querying m2, m1 should also be cached (same chain object)
|
||||
const chain2 = idx.getChain("m1")
|
||||
// m1 is version 1, but it was cached as part of m2's chain
|
||||
expect(chain2).toBe(chain1) // same reference
|
||||
})
|
||||
|
||||
it("getChain from v1 root with children returns full chain", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
parentMemoryId: "m2",
|
||||
rootMemoryId: "m1",
|
||||
version: 3,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// Query from v1 root — walks forward to m2, m3
|
||||
const chain = idx.getChain("m1")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(3)
|
||||
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
|
||||
})
|
||||
|
||||
it("getChain returns null for unknown ID", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
idx.rebuild([makeDoc("d1", [makeMem({ id: "m1", version: 1 })])])
|
||||
expect(idx.getChain("nonexistent")).toBeNull()
|
||||
})
|
||||
|
||||
it("handles empty documents array", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
expect(() => idx.rebuild([])).not.toThrow()
|
||||
expect(idx.getChain("anything")).toBeNull()
|
||||
})
|
||||
|
||||
it("rebuild clears previous chains (new array reference)", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc1 = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc1])
|
||||
expect(idx.getChain("m2")).not.toBeNull()
|
||||
|
||||
// Rebuild with different data (new array reference)
|
||||
const doc2 = makeDoc("d2", [makeMem({ id: "m3", version: 1 })])
|
||||
idx.rebuild([doc2])
|
||||
expect(idx.getChain("m2")).toBeNull()
|
||||
expect(idx.getChain("m1")).toBeNull()
|
||||
})
|
||||
|
||||
it("rebuild skips if same array reference", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
]),
|
||||
]
|
||||
idx.rebuild(docs)
|
||||
const chain1 = idx.getChain("m2")
|
||||
|
||||
// Same reference — rebuild is a no-op
|
||||
idx.rebuild(docs)
|
||||
const chain2 = idx.getChain("m2")
|
||||
expect(chain2).toBe(chain1)
|
||||
})
|
||||
|
||||
it("handles multiple independent chains across documents", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
]),
|
||||
makeDoc("d2", [
|
||||
makeMem({ id: "m3", version: 1 }),
|
||||
makeMem({
|
||||
id: "m4",
|
||||
parentMemoryId: "m3",
|
||||
rootMemoryId: "m3",
|
||||
version: 2,
|
||||
}),
|
||||
]),
|
||||
]
|
||||
idx.rebuild(docs)
|
||||
|
||||
const chain1 = idx.getChain("m2")
|
||||
const chain2 = idx.getChain("m4")
|
||||
expect(chain1).not.toBeNull()
|
||||
expect(chain2).not.toBeNull()
|
||||
expect(chain1!.map((e) => e.id)).toEqual(["m1", "m2"])
|
||||
expect(chain2!.map((e) => e.id)).toEqual(["m3", "m4"])
|
||||
})
|
||||
|
||||
it("handles circular parent references without infinite loop", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }),
|
||||
makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// Cycle: m1->m2->m1. The visited set prevents infinite loops.
|
||||
const chain = idx.getChain("m1")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(2)
|
||||
})
|
||||
|
||||
it("branching children: follows first child by document order", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2a",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
makeMem({
|
||||
id: "m2b",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// m1 has two children; forward walk picks the first (m2a)
|
||||
const chain = idx.getChain("m1")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(2)
|
||||
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2a"])
|
||||
})
|
||||
|
||||
it("chain entries have correct fields", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1, isForgotten: true, isLatest: false }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
isLatest: true,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
const chain = idx.getChain("m2")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain![0]).toEqual({
|
||||
id: "m1",
|
||||
version: 1,
|
||||
memory: "Memory m1",
|
||||
isForgotten: true,
|
||||
isLatest: false,
|
||||
})
|
||||
expect(chain![1]).toEqual({
|
||||
id: "m2",
|
||||
version: 2,
|
||||
memory: "Memory m2",
|
||||
isForgotten: false,
|
||||
isLatest: true,
|
||||
})
|
||||
})
|
||||
|
||||
// --- Additional edge cases ---
|
||||
|
||||
it("getChain returns null for orphaned non-root memory (v2+, no parent in index, no children)", () => {
|
||||
const idx = new VersionChainIndex()
|
||||
// m2 claims version 2 and has a parentMemoryId, but that parent is not in any document.
|
||||
// The backward walk reaches a dead end after m2 itself (parent not in memoryMap).
|
||||
// all.length === 1 → returns null, same as a standalone v1.
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({
|
||||
id: "m2",
|
||||
version: 2,
|
||||
parentMemoryId: "m_ghost",
|
||||
rootMemoryId: "m_ghost",
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
expect(idx.getChain("m2")).toBeNull()
|
||||
})
|
||||
|
||||
it("cross-document chain: parent in doc1, child in doc2 resolves correctly", () => {
|
||||
// rebuild() walks all documents in one pass, so parentMemoryId references
|
||||
// are resolved across document boundaries. This mirrors production usage where
|
||||
// chainIndex.current.rebuild(limitedDocuments) receives all documents at once.
|
||||
const idx = new VersionChainIndex()
|
||||
const docs = [
|
||||
makeDoc("d1", [makeMem({ id: "m1", version: 1 })]),
|
||||
makeDoc("d2", [
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
]),
|
||||
]
|
||||
idx.rebuild(docs)
|
||||
|
||||
// Querying from child (in d2) should walk back to parent (in d1)
|
||||
const chainFromChild = idx.getChain("m2")
|
||||
expect(chainFromChild).not.toBeNull()
|
||||
expect(chainFromChild!.map((e) => e.id)).toEqual(["m1", "m2"])
|
||||
|
||||
// Querying from parent (in d1) should walk forward to child (in d2)
|
||||
const chainFromParent = idx.getChain("m1")
|
||||
expect(chainFromParent).not.toBeNull()
|
||||
expect(chainFromParent!.map((e) => e.id)).toEqual(["m1", "m2"])
|
||||
})
|
||||
|
||||
it("circular reference: both IDs present in result (order is undefined for malformed cycles)", () => {
|
||||
// The existing circular test asserts length=2 but not membership.
|
||||
// Circular data (m1.parent=m2, m2.parent=m1) is malformed and will never
|
||||
// appear in production — the visited set merely guarantees termination.
|
||||
// Order is intentionally unspecified because the backward-walk start node
|
||||
// determines which ID appears first, which has no semantic meaning for corrupt data.
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }),
|
||||
makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
const chain = idx.getChain("m1")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(2)
|
||||
// Both nodes must appear — order is implementation-defined for cycles
|
||||
const ids = chain!.map((e) => e.id)
|
||||
expect(ids).toContain("m1")
|
||||
expect(ids).toContain("m2")
|
||||
})
|
||||
|
||||
it("getChain from middle node with cold cache exercises real backward+forward traversal", () => {
|
||||
// This test deliberately queries the MIDDLE node first (cache is empty at that point)
|
||||
// to confirm the combined backward+forward traversal is the live code path being
|
||||
// exercised — not merely the cache fast-path from a prior call to another node.
|
||||
const idx = new VersionChainIndex()
|
||||
const doc = makeDoc("d1", [
|
||||
makeMem({ id: "m1", version: 1 }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
parentMemoryId: "m1",
|
||||
rootMemoryId: "m1",
|
||||
version: 2,
|
||||
}),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
parentMemoryId: "m2",
|
||||
rootMemoryId: "m1",
|
||||
version: 3,
|
||||
}),
|
||||
])
|
||||
idx.rebuild([doc])
|
||||
|
||||
// Cold cache: getChain("m2") must walk backward to m1 AND forward to m3.
|
||||
const chain = idx.getChain("m2")
|
||||
expect(chain).not.toBeNull()
|
||||
expect(chain!.length).toBe(3)
|
||||
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
|
||||
|
||||
// After the traversal, neighboring nodes must return the same cached reference —
|
||||
// confirming that the cache-population loop ran for all three entries.
|
||||
expect(idx.getChain("m1")).toBe(chain)
|
||||
expect(idx.getChain("m3")).toBe(chain)
|
||||
})
|
||||
})
|
||||
219
packages/memory-graph/src/__tests__/viewport.test.ts
Normal file
219
packages/memory-graph/src/__tests__/viewport.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { ViewportState } from "../canvas/viewport"
|
||||
import type { GraphNode } from "../types"
|
||||
|
||||
function makeNode(id: string, x: number, y: number): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "document",
|
||||
x,
|
||||
y,
|
||||
size: 50,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
summary: null,
|
||||
type: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Run tick() until animation converges (or max iterations) */
|
||||
function tickUntilSettled(vp: ViewportState, maxIter = 500): void {
|
||||
for (let i = 0; i < maxIter; i++) {
|
||||
if (!vp.tick()) break
|
||||
}
|
||||
}
|
||||
|
||||
describe("ViewportState", () => {
|
||||
it("constructor sets initial values", () => {
|
||||
const vp = new ViewportState()
|
||||
expect(vp.panX).toBe(0)
|
||||
expect(vp.panY).toBe(0)
|
||||
expect(vp.zoom).toBe(0.5) // default initial zoom
|
||||
})
|
||||
|
||||
it("constructor accepts custom initial values", () => {
|
||||
const vp = new ViewportState(10, 20, 1.5)
|
||||
expect(vp.panX).toBe(10)
|
||||
expect(vp.panY).toBe(20)
|
||||
expect(vp.zoom).toBe(1.5)
|
||||
})
|
||||
|
||||
it("worldToScreen and screenToWorld are inverse operations", () => {
|
||||
const vp = new ViewportState(100, 50, 1.5)
|
||||
|
||||
const worldX = 300
|
||||
const worldY = 400
|
||||
const screen = vp.worldToScreen(worldX, worldY)
|
||||
const world = vp.screenToWorld(screen.x, screen.y)
|
||||
|
||||
expect(world.x).toBeCloseTo(worldX, 5)
|
||||
expect(world.y).toBeCloseTo(worldY, 5)
|
||||
})
|
||||
|
||||
it("worldToScreen applies zoom and pan: screen = world * zoom + pan", () => {
|
||||
const vp = new ViewportState(10, 20, 2)
|
||||
|
||||
const screen = vp.worldToScreen(100, 200)
|
||||
expect(screen.x).toBe(100 * 2 + 10) // 210
|
||||
expect(screen.y).toBe(200 * 2 + 20) // 420
|
||||
})
|
||||
|
||||
it("screenToWorld reverses: world = (screen - pan) / zoom", () => {
|
||||
const vp = new ViewportState(10, 20, 2)
|
||||
|
||||
const world = vp.screenToWorld(210, 420)
|
||||
expect(world.x).toBeCloseTo(100, 5)
|
||||
expect(world.y).toBeCloseTo(200, 5)
|
||||
})
|
||||
|
||||
it("pan offsets correctly and accumulates", () => {
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.pan(50, 30)
|
||||
expect(vp.panX).toBe(50)
|
||||
expect(vp.panY).toBe(30)
|
||||
|
||||
vp.pan(10, 20)
|
||||
expect(vp.panX).toBe(60)
|
||||
expect(vp.panY).toBe(50)
|
||||
})
|
||||
|
||||
it("pan cancels any animated pan target", () => {
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.centerOn(500, 500, 800, 600) // sets targetPanX/Y
|
||||
vp.pan(10, 10) // should cancel the target
|
||||
// After pan, tick should return false (no animation)
|
||||
expect(vp.tick()).toBe(false)
|
||||
})
|
||||
|
||||
it("zoomImmediate multiplies current zoom by delta", () => {
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
const initialZoom = vp.zoom
|
||||
vp.zoomImmediate(2, 0, 0)
|
||||
expect(vp.zoom).toBeCloseTo(initialZoom * 2)
|
||||
})
|
||||
|
||||
it("zoomImmediate preserves world point under anchor", () => {
|
||||
const vp = new ViewportState(100, 50, 1)
|
||||
const anchorX = 400
|
||||
const anchorY = 300
|
||||
|
||||
// Get world point under anchor before zoom
|
||||
const worldBefore = vp.screenToWorld(anchorX, anchorY)
|
||||
vp.zoomImmediate(2, anchorX, anchorY)
|
||||
// After zoom, same screen point should map to same world point
|
||||
const worldAfter = vp.screenToWorld(anchorX, anchorY)
|
||||
|
||||
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 3)
|
||||
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 3)
|
||||
})
|
||||
|
||||
it("zoomImmediate clamps to MIN_ZOOM (0.1)", () => {
|
||||
const vp = new ViewportState(0, 0, 0.5)
|
||||
// Try to zoom way down: 0.5 * 0.01 = 0.005, should clamp to 0.1
|
||||
vp.zoomImmediate(0.01, 0, 0)
|
||||
expect(vp.zoom).toBeCloseTo(0.1)
|
||||
})
|
||||
|
||||
it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => {
|
||||
const vp = new ViewportState(0, 0, 2)
|
||||
// Try to zoom way up: 2 * 100 = 200, should clamp to 5
|
||||
vp.zoomImmediate(100, 0, 0)
|
||||
expect(vp.zoom).toBeCloseTo(5.0)
|
||||
})
|
||||
|
||||
it("zoomTo sets target zoom (animated via tick)", () => {
|
||||
const vp = new ViewportState(0, 0, 0.5)
|
||||
vp.zoomTo(2, 400, 300)
|
||||
// Zoom hasn't changed yet — it's animated
|
||||
expect(vp.zoom).toBe(0.5)
|
||||
// After ticking, zoom should approach target
|
||||
tickUntilSettled(vp)
|
||||
expect(vp.zoom).toBeCloseTo(2, 1)
|
||||
})
|
||||
|
||||
it("tick returns false when no animation is active", () => {
|
||||
const vp = new ViewportState()
|
||||
expect(vp.tick()).toBe(false)
|
||||
})
|
||||
|
||||
it("tick returns true during inertia", () => {
|
||||
const vp = new ViewportState()
|
||||
vp.releaseWithVelocity(10, 10)
|
||||
expect(vp.tick()).toBe(true)
|
||||
})
|
||||
|
||||
it("tick returns true during zoom animation", () => {
|
||||
const vp = new ViewportState(0, 0, 0.5)
|
||||
vp.zoomTo(2, 0, 0)
|
||||
expect(vp.tick()).toBe(true)
|
||||
})
|
||||
|
||||
it("tick returns true during pan animation", () => {
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.centerOn(500, 500, 800, 600)
|
||||
expect(vp.tick()).toBe(true)
|
||||
})
|
||||
|
||||
it("fitToNodes centers and scales to fit all nodes", () => {
|
||||
const vp = new ViewportState(0, 0, 0.5)
|
||||
const nodes = [
|
||||
makeNode("a", 0, 0),
|
||||
makeNode("b", 1000, 0),
|
||||
makeNode("c", 0, 1000),
|
||||
makeNode("d", 1000, 1000),
|
||||
]
|
||||
vp.fitToNodes(nodes, 800, 600)
|
||||
tickUntilSettled(vp)
|
||||
|
||||
// After fitting, all nodes should be visible within the viewport
|
||||
for (const node of nodes) {
|
||||
const screen = vp.worldToScreen(node.x, node.y)
|
||||
expect(screen.x).toBeGreaterThan(-100)
|
||||
expect(screen.x).toBeLessThan(900)
|
||||
expect(screen.y).toBeGreaterThan(-100)
|
||||
expect(screen.y).toBeLessThan(700)
|
||||
}
|
||||
})
|
||||
|
||||
it("fitToNodes handles single node without throwing", () => {
|
||||
const vp = new ViewportState()
|
||||
expect(() =>
|
||||
vp.fitToNodes([makeNode("a", 500, 500)], 800, 600),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it("fitToNodes handles empty nodes array without throwing", () => {
|
||||
const vp = new ViewportState()
|
||||
const zoomBefore = vp.zoom
|
||||
vp.fitToNodes([], 800, 600)
|
||||
// Should be a no-op
|
||||
expect(vp.zoom).toBe(zoomBefore)
|
||||
})
|
||||
|
||||
it("centerOn animates pan to center a world point on screen", () => {
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.centerOn(500, 300, 800, 600)
|
||||
tickUntilSettled(vp)
|
||||
|
||||
// After settling, world point (500, 300) should map to screen center (400, 300)
|
||||
const screen = vp.worldToScreen(500, 300)
|
||||
expect(screen.x).toBeCloseTo(400, 0)
|
||||
expect(screen.y).toBeCloseTo(300, 0)
|
||||
})
|
||||
|
||||
it("inertia decays to zero", () => {
|
||||
const vp = new ViewportState()
|
||||
vp.releaseWithVelocity(100, 100)
|
||||
tickUntilSettled(vp)
|
||||
// After settling, tick should return false
|
||||
expect(vp.tick()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,71 +1,42 @@
|
|||
// Standalone TypeScript types for Memory Graph
|
||||
// These mirror the API response types from @repo/validation/api
|
||||
export type MemoryRelation = "updates" | "extends" | "derives"
|
||||
|
||||
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
|
||||
memory: string
|
||||
content?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
spaceId?: string | null
|
||||
embedding?: number[]
|
||||
isStatic?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
isLatest?: boolean
|
||||
// Relation fields from backend
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
// Source/join fields
|
||||
sourceAddedAt?: string | 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
|
||||
title: string | null
|
||||
url: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
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[]
|
||||
memories: MemoryEntry[]
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
export const OneDrive = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 165"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OneDrive</title>
|
||||
<path
|
||||
d="m154.66 110.682l52.842-50.534c-10.976-42.8-54.57-68.597-97.37-57.62a80 80 0 0 0-46.952 33.51c.817-.02 91.48 74.644 91.48 74.644"
|
||||
fill="#0364B8"
|
||||
/>
|
||||
<path
|
||||
d="m97.618 45.552l-.002.009a63.7 63.7 0 0 0-33.619-9.543c-.274 0-.544.017-.818.02C27.852 36.476-.432 65.47.005 100.798a63.97 63.97 0 0 0 11.493 35.798l79.165-9.915l60.694-48.94z"
|
||||
fill="#0078D4"
|
||||
/>
|
||||
<path
|
||||
d="M207.502 60.148a53 53 0 0 0-3.51-.131a51.8 51.8 0 0 0-20.61 4.254l-.002-.005l-32.022 13.475l35.302 43.607l63.11 15.341c13.62-25.283 4.164-56.82-21.12-70.44a52 52 0 0 0-21.148-6.1"
|
||||
fill="#1490DF"
|
||||
/>
|
||||
<path
|
||||
d="M11.498 136.596a63.91 63.91 0 0 0 52.5 27.417h139.994a51.99 51.99 0 0 0 45.778-27.323l-98.413-58.95z"
|
||||
fill="#28A8EA"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleDrive = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 229"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google Drive</title>
|
||||
<path
|
||||
d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"
|
||||
fill="#0066DA"
|
||||
/>
|
||||
<path
|
||||
d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"
|
||||
fill="#00832D"
|
||||
/>
|
||||
<path
|
||||
d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"
|
||||
fill="#2684FC"
|
||||
/>
|
||||
<path
|
||||
d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"
|
||||
fill="#00AC47"
|
||||
/>
|
||||
<path
|
||||
d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"
|
||||
fill="#FFBA00"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Notion = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 268"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Notion</title>
|
||||
<path
|
||||
d="M16.092 11.538L164.09.608c18.179-1.56 22.85-.508 34.28 7.801l47.243 33.282C253.406 47.414 256 48.975 256 55.207v182.527c0 11.439-4.155 18.205-18.696 19.24L65.44 267.378c-10.913.517-16.11-1.043-21.825-8.327L8.826 213.814C2.586 205.487 0 199.254 0 191.97V29.726c0-9.352 4.155-17.153 16.092-18.188"
|
||||
fill="#FFF"
|
||||
/>
|
||||
<path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleDocs = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google Docs</title>
|
||||
<path
|
||||
d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818zm0-3.273H7.091V9.273h9.818zM14.727 6h6l-6-6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleSheets = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google Sheets</title>
|
||||
<path
|
||||
d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6zm1.363 10.636h-3.41v1.91h3.41zm0 3.273h-3.41v1.91h3.41zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleSlides = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google Slides</title>
|
||||
<path
|
||||
d="M16.09 15.273H7.91v-4.637h8.18zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75zm-.363 2.523H6.545v7.363h10.91zm-2.728-5.979V6h6.001l-6-6v3.294z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const NotionDoc = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Notion Doc</title>
|
||||
<path
|
||||
d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514c-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233l4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MicrosoftWord = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Microsoft Word</title>
|
||||
<path
|
||||
d="M23.004 1.5q.41 0 .703.293t.293.703v19.008q0 .41-.293.703t-.703.293H6.996q-.41 0-.703-.293T6 21.504V18H.996q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6H6V2.496q0-.41.293-.703t.703-.293zM6.035 11.203l1.442 4.735h1.64l1.57-7.876H9.036l-.937 4.653l-1.325-4.5H5.38l-1.406 4.523l-.938-4.675H1.312l1.57 7.874h1.641zM22.5 21v-3h-15v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3h-15v3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MicrosoftExcel = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Microsoft Excel</title>
|
||||
<path
|
||||
d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3zM6 13.28l1.42 2.66h2.14l-2.38-3.87l2.34-3.8H7.46l-1.3 2.4l-.05.08l-.04.09l-.64-1.28l-.66-1.29H2.59l2.27 3.82l-2.48 3.85h2.16zM14.25 21v-3H7.5v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3H7.5v3zm8.25 15v-3h-6.75v3zm0-4.5v-3.75h-6.75v3.75zm0-5.25V7.5h-6.75v3.75zm0-5.25V3h-6.75v3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Microsoft PowerPoint</title>
|
||||
<path
|
||||
d="M13.5 1.5q1.453 0 2.795.375t2.508 1.06t2.12 1.641q.956.955 1.641 2.121q.686 1.166 1.061 2.508T24 12t-.375 2.795t-1.06 2.508q-.686 1.166-1.641 2.12q-.955.956-2.121 1.641q-1.166.686-2.508 1.061T13.5 22.5q-1.29 0-2.52-.305q-1.23-.304-2.337-.884T6.58 19.893Q5.625 19.055 4.887 18H.997q-.411 0-.704-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.89q.739-1.055 1.694-1.893q.955-.837 2.063-1.418q1.107-.58 2.337-.884T13.5 1.5m.75 1.535v8.215h8.215q-.14-1.64-.826-3.076t-1.782-2.531q-1.095-1.096-2.537-1.782t-3.07-.826m-5.262 7.57q0-.68-.228-1.166q-.229-.486-.627-.79q-.399-.305-.938-.446q-.539-.14-1.172-.14H2.848v7.863h1.84v-2.742H5.93q.574 0 1.119-.17t.978-.493q.434-.322.698-.802t.263-1.114M13.5 21q1.172 0 2.262-.287t2.056-.82t1.776-1.278q.808-.744 1.418-1.664t.984-1.986q.375-1.067.469-2.227h-9.703V3.035q-1.735.14-3.27.908T6.797 6h4.207q.41 0 .703.293t.293.703v10.008q0 .41-.293.703t-.703.293H6.797q.644.715 1.412 1.271q.768.557 1.623.944t1.781.586T13.5 21M5.812 9.598q.575 0 .915.228q.34.229.34.838q0 .27-.124.44q-.123.17-.31.275q-.188.105-.422.146t-.445.041H4.687V9.598Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MicrosoftOneNote = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Microsoft OneNote</title>
|
||||
<path
|
||||
d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3ZM4.56 11l2.83 4.93h1.79V8.07H7.44v5.03L4.71 8.07H2.82v7.86h1.74ZM22.5 21v-3h-3v3Zm0-4.5v-3h-3v3Zm0-4.5V9h-3v3Zm0-4.5V3h-15v3H11q.41 0 .7.3q.3.29.3.7v10q0 .41-.3.7q-.29.3-.7.3H7.5v3H18V7.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PDF = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 15 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>PDF</title>
|
||||
<path
|
||||
d="M3 13h.86v-.9h.39c.62 0 1.14-.45 1.14-1.06s-.5-1.05-1.14-1.05H3v3Zm.86-1.59v-.72h.3c.2 0 .37.13.37.35s-.16.36-.37.36h-.3ZM6.19 13h1.19c1 0 1.62-.59 1.62-1.52C9 10.61 8.38 10 7.38 10H6.19zm.86-.71V10.7h.29c.33 0 .78.16.78.78c0 .65-.45.81-.78.81zM10 13h.86v-1.07h1.06v-.69h-1.06v-.54h1.21v-.69h-2.06v3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 16h-10c-.83 0-1.5-.67-1.5-1.5v-13C1 .67 1.67 0 2.5 0h7.09c.4 0 .78.16 1.06.44l2.91 2.91c.28.28.44.66.44 1.06V14.5c0 .83-.67 1.5-1.5 1.5M2.5 1c-.28 0-.5.22-.5.5v13c0 .28.22.5.5.5h10c.28 0 .5-.22.5-.5V4.41a.47.47 0 0 0-.15-.35L9.94 1.15A.5.5 0 0 0 9.59 1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.38 5h-2.91C9.66 5 9 4.34 9 3.53V.62c0-.28.22-.5.5-.5s.5.22.5.5v2.91c0 .26.21.47.47.47h2.91c.28 0 .5.22.5.5s-.22.5-.5.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
402
packages/memory-graph/src/canvas/document-icons.ts
Normal file
402
packages/memory-graph/src/canvas/document-icons.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
/**
|
||||
* Canvas-native document type icon drawing functions.
|
||||
*
|
||||
* Each function draws a small vector icon at the given (x, y) center
|
||||
* within a bounding box of `size` pixels. All drawing uses the current
|
||||
* canvas fill/stroke styles set by the caller.
|
||||
*/
|
||||
|
||||
/** Shared rounded-rectangle path helper (also used by the renderer). */
|
||||
export 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the appropriate document-type icon on the canvas.
|
||||
*
|
||||
* Wraps the drawing in save/restore so callers don't need to worry about
|
||||
* state leaking. Only `iconColor` is needed from the theme.
|
||||
*/
|
||||
export function drawDocIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
type: string,
|
||||
iconColor: string,
|
||||
): void {
|
||||
ctx.save()
|
||||
ctx.fillStyle = iconColor
|
||||
ctx.strokeStyle = iconColor
|
||||
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":
|
||||
case "word":
|
||||
case "microsoft_word":
|
||||
drawTextLabel(ctx, x, y, size, "W", 0.4)
|
||||
break
|
||||
case "csv":
|
||||
case "excel":
|
||||
case "microsoft_excel":
|
||||
case "google_sheet":
|
||||
drawGridIcon(ctx, x, y, size)
|
||||
break
|
||||
case "json":
|
||||
drawBracesIcon(ctx, x, y, size)
|
||||
break
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
drawNotionIcon(ctx, x, y, size)
|
||||
break
|
||||
case "google_doc":
|
||||
drawGoogleDocIcon(ctx, x, y, size)
|
||||
break
|
||||
case "google_slide":
|
||||
case "powerpoint":
|
||||
case "microsoft_powerpoint":
|
||||
drawSlidesIcon(ctx, x, y, size)
|
||||
break
|
||||
case "google_drive":
|
||||
case "onedrive":
|
||||
drawCloudIcon(ctx, x, y, size)
|
||||
break
|
||||
case "tweet":
|
||||
drawXIcon(ctx, x, y, size)
|
||||
break
|
||||
case "youtube":
|
||||
case "video":
|
||||
drawPlayIcon(ctx, x, y, size)
|
||||
break
|
||||
case "image":
|
||||
drawImageIcon(ctx, x, y, size)
|
||||
break
|
||||
case "text":
|
||||
case "note":
|
||||
drawTextNoteIcon(ctx, x, y, size)
|
||||
break
|
||||
case "onenote":
|
||||
case "microsoft_onenote":
|
||||
drawTextLabel(ctx, x, y, size, "N", 0.4)
|
||||
break
|
||||
case "mcp":
|
||||
drawTextLabel(ctx, x, y, size, "MCP", 0.25)
|
||||
break
|
||||
default:
|
||||
drawDocOutline(ctx, x, y, size)
|
||||
break
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual icon drawing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/** Draw a simplified Notion "N" logo mark */
|
||||
function drawNotionIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.5
|
||||
const h = size * 0.6
|
||||
const r = size * 0.08
|
||||
ctx.lineWidth = Math.max(1, size / 14)
|
||||
roundRect(ctx, x - w / 2, y - h / 2, w, h, r)
|
||||
ctx.stroke()
|
||||
// Inner "N" shape
|
||||
const inset = size * 0.12
|
||||
const left = x - w / 2 + inset
|
||||
const right = x + w / 2 - inset
|
||||
const top = y - h / 2 + inset
|
||||
const bottom = y + h / 2 - inset
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(left, top)
|
||||
ctx.lineTo(left, bottom)
|
||||
ctx.moveTo(left, top)
|
||||
ctx.lineTo(right, bottom)
|
||||
ctx.moveTo(right, top)
|
||||
ctx.lineTo(right, bottom)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/** Draw a Google Docs icon (document with lines) */
|
||||
function drawGoogleDocIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.55
|
||||
const h = size * 0.7
|
||||
const fold = size * 0.15
|
||||
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 lineW = w * 0.6
|
||||
const sp = size * 0.1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - lineW / 2, y - sp)
|
||||
ctx.lineTo(x + lineW / 2, y - sp)
|
||||
ctx.moveTo(x - lineW / 2, y + sp * 0.3)
|
||||
ctx.lineTo(x + lineW / 2, y + sp * 0.3)
|
||||
ctx.moveTo(x - lineW / 2, y + sp * 1.6)
|
||||
ctx.lineTo(x + lineW / 3, y + sp * 1.6)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/** Draw a slides/presentation icon (rectangle with play triangle) */
|
||||
function drawSlidesIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.5
|
||||
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
|
||||
const triSize = size * 0.15
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - triSize * 0.5, y - triSize * 0.7)
|
||||
ctx.lineTo(x - triSize * 0.5, y + triSize * 0.7)
|
||||
ctx.lineTo(x + triSize * 0.7, y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
/** Draw a cloud icon for drive/storage types */
|
||||
function drawCloudIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const s = size * 0.35
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - s * 0.3, y + s * 0.1, s * 0.5, Math.PI * 0.7, Math.PI * 1.9)
|
||||
ctx.arc(x + s * 0.1, y - s * 0.3, s * 0.55, Math.PI * 1.1, Math.PI * 0.3)
|
||||
ctx.arc(x + s * 0.5, y + s * 0.1, s * 0.4, Math.PI * 1.4, Math.PI * 0.6)
|
||||
ctx.lineTo(x - s * 0.7, y + s * 0.45)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/** Draw an X (formerly Twitter) icon */
|
||||
function drawXIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const s = size * 0.3
|
||||
ctx.lineWidth = Math.max(1.5, size / 10)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - s, y - s)
|
||||
ctx.lineTo(x + s, y + s)
|
||||
ctx.moveTo(x + s, y - s)
|
||||
ctx.lineTo(x - s, y + s)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/** Draw a play button icon for video/youtube */
|
||||
function drawPlayIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const r = size * 0.38
|
||||
const w = r * 2.2
|
||||
const h = r * 1.5
|
||||
const cr = size * 0.08
|
||||
roundRect(ctx, x - w / 2, y - h / 2, w, h, cr)
|
||||
ctx.stroke()
|
||||
const triH = size * 0.22
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - triH * 0.45, y - triH)
|
||||
ctx.lineTo(x - triH * 0.45, y + triH)
|
||||
ctx.lineTo(x + triH * 0.7, y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
/** Draw an image/photo icon (landscape with mountain) */
|
||||
function drawImageIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.55
|
||||
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 2 + w * 0.1, y + h / 2 - h * 0.1)
|
||||
ctx.lineTo(x - w * 0.05, y - h * 0.05)
|
||||
ctx.lineTo(x + w * 0.15, y + h * 0.15)
|
||||
ctx.lineTo(x + w * 0.25, y - h * 0.02)
|
||||
ctx.lineTo(x + w / 2 - w * 0.1, y + h / 2 - h * 0.1)
|
||||
ctx.stroke()
|
||||
const sunR = size * 0.06
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - w * 0.15, y - h * 0.15, sunR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
/** Draw a text/note icon (lines of text) */
|
||||
function drawTextNoteIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.55
|
||||
const h = size * 0.6
|
||||
const sp = h / 5
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const lineY = y - h / 2 + sp * (i + 0.5)
|
||||
const lineW = i === 3 ? w * 0.6 : w
|
||||
ctx.moveTo(x - w / 2, lineY)
|
||||
ctx.lineTo(x - w / 2 + lineW, lineY)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
|
@ -27,15 +27,14 @@ export class SpatialIndex {
|
|||
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
|
||||
const node = cell[i]
|
||||
if (node && this.hitTest(node, worldX, worldY)) return node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,13 +45,11 @@ export class SpatialIndex {
|
|||
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
|
||||
|
|
@ -61,9 +58,16 @@ export class SpatialIndex {
|
|||
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
|
||||
// Use finer granularity (10x) to detect sub-pixel movements
|
||||
// and incorporate a simple string hash of the ID to avoid
|
||||
// false matches when nodes swap positions
|
||||
let idHash = 0
|
||||
for (let i = 0; i < n.id.length; i++) {
|
||||
idHash = ((idHash << 5) - idHash + n.id.charCodeAt(i)) | 0
|
||||
}
|
||||
hash = (hash * 31 + idHash) | 0
|
||||
hash = (hash * 31 + (Math.round(n.x * 10) | 0)) | 0
|
||||
hash = (hash * 31 + (Math.round(n.y * 10) | 0)) | 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ViewportState } from "./viewport"
|
||||
import type { SpatialIndex } from "./hit-test"
|
||||
import type { GraphNode } from "../types"
|
||||
import type { SpatialIndex } from "./hit-test"
|
||||
import type { ViewportState } from "./viewport"
|
||||
|
||||
interface InputCallbacks {
|
||||
onNodeHover: (id: string | null) => void
|
||||
|
|
@ -20,22 +20,17 @@ export class InputHandler {
|
|||
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
|
||||
|
|
@ -128,8 +123,6 @@ export class InputHandler {
|
|||
|
||||
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)
|
||||
|
|
@ -162,7 +155,6 @@ export class InputHandler {
|
|||
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()
|
||||
|
|
@ -171,7 +163,6 @@ export class InputHandler {
|
|||
return
|
||||
}
|
||||
|
||||
// Hover detection
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
||||
const id = node?.id ?? null
|
||||
|
|
@ -196,13 +187,13 @@ export class InputHandler {
|
|||
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 newest = this.posHistory[this.posHistory.length - 1]
|
||||
const oldest = this.posHistory[0]
|
||||
if (!newest || !oldest) return
|
||||
const dt = newest.t - oldest.t
|
||||
if (dt > 0 && dt < 200) {
|
||||
const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame
|
||||
const vx = ((newest.x - oldest.x) / dt) * 16
|
||||
const vy = ((newest.y - oldest.y) / dt) * 16
|
||||
this.viewport.releaseWithVelocity(vx, vy)
|
||||
}
|
||||
|
|
@ -233,28 +224,26 @@ export class InputHandler {
|
|||
|
||||
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]!
|
||||
const t0 = touches[0]
|
||||
const t1 = touches[1]
|
||||
if (!t0 || !t1) return
|
||||
this.lastTouchDistance = Math.hypot(
|
||||
t1.clientX - t0.clientX,
|
||||
t1.clientY - t0.clientY,
|
||||
|
|
@ -263,9 +252,9 @@ export class InputHandler {
|
|||
x: (t0.clientX + t1.clientX) / 2,
|
||||
y: (t0.clientY + t1.clientY) / 2,
|
||||
}
|
||||
} else if (touches.length === 1) {
|
||||
} else if (touches.length === 1 && touches[0]) {
|
||||
this.isTouchGesture = false
|
||||
const t = touches[0]!
|
||||
const t = touches[0]
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
this.lastMouseX = t.clientX - rect.left
|
||||
this.lastMouseY = t.clientY - rect.top
|
||||
|
|
@ -278,8 +267,9 @@ export class InputHandler {
|
|||
const touches = e.touches
|
||||
|
||||
if (touches.length >= 2 && this.isTouchGesture) {
|
||||
const t0 = touches[0]!
|
||||
const t1 = touches[1]!
|
||||
const t0 = touches[0]
|
||||
const t1 = touches[1]
|
||||
if (!t0 || !t1) return
|
||||
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY)
|
||||
const center = {
|
||||
x: (t0.clientX + t1.clientX) / 2,
|
||||
|
|
@ -289,11 +279,9 @@ export class InputHandler {
|
|||
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)
|
||||
|
|
@ -301,8 +289,13 @@ export class InputHandler {
|
|||
this.lastTouchDistance = dist
|
||||
this.lastTouchCenter = center
|
||||
this.callbacks.onRequestRender()
|
||||
} else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) {
|
||||
const t = touches[0]!
|
||||
} else if (
|
||||
touches.length === 1 &&
|
||||
this.isPanning &&
|
||||
!this.isTouchGesture &&
|
||||
touches[0]
|
||||
) {
|
||||
const t = touches[0]
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const x = t.clientX - rect.left
|
||||
const y = t.clientY - rect.top
|
||||
661
packages/memory-graph/src/canvas/renderer.ts
Normal file
661
packages/memory-graph/src/canvas/renderer.ts
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
import type {
|
||||
DocumentNodeData,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphThemeColors,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
import type { ViewportState } from "./viewport"
|
||||
import { drawDocIcon, roundRect } from "./document-icons"
|
||||
|
||||
export interface RenderState {
|
||||
selectedNodeId: string | null
|
||||
hoveredNodeId: string | null
|
||||
highlightIds: Set<string>
|
||||
dimProgress: number
|
||||
}
|
||||
|
||||
// Module-level reusable batch map – cleared each frame instead of reallocating
|
||||
const edgeBatches = new Map<string, PreparedEdge[]>()
|
||||
|
||||
/** Group items by their `color` property into batches for efficient canvas drawing */
|
||||
function groupByColor<T extends { color: string }>(
|
||||
items: T[],
|
||||
): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
let batch = map.get(item.color)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
map.set(item.color, batch)
|
||||
}
|
||||
batch.push(item)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Cache for lightenColor results to avoid per-frame hex parsing
|
||||
let _lightenCache: { input: string; amount: number; result: string } | null =
|
||||
null
|
||||
|
||||
export function renderFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors)
|
||||
drawNodes(ctx, nodes, viewport, width, height, state, colors)
|
||||
}
|
||||
|
||||
function edgeStyle(
|
||||
edge: GraphEdge,
|
||||
colors: GraphThemeColors,
|
||||
): { color: string; width: number; opacity: number } {
|
||||
if (edge.edgeType === "derives")
|
||||
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
|
||||
if (edge.edgeType === "updates")
|
||||
return { color: colors.edgeUpdates, width: 2, opacity: 0.7 }
|
||||
// "extends" and any unknown edge types
|
||||
return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 }
|
||||
}
|
||||
|
||||
function batchKey(style: {
|
||||
color: string
|
||||
width: number
|
||||
opacity: number
|
||||
}): string {
|
||||
return `${style.color}|${style.width}|${style.opacity}`
|
||||
}
|
||||
|
||||
interface PreparedEdge {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
connected: boolean
|
||||
style: { color: string; width: number; opacity: number }
|
||||
edgeType: string
|
||||
arrowSize: number
|
||||
}
|
||||
|
||||
function drawEdges(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
const margin = 100
|
||||
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
|
||||
|
||||
const prepared: PreparedEdge[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
// Zoom-based edge culling for extends edges at very low zoom
|
||||
if (edge.edgeType === "extends") {
|
||||
if (viewport.zoom < 0.08) continue
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (edge.edgeType === "derives") {
|
||||
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, colors),
|
||||
edgeType: edge.edgeType ?? "derives",
|
||||
arrowSize:
|
||||
edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Reuse module-level batch map
|
||||
edgeBatches.clear()
|
||||
for (const e of prepared) {
|
||||
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
|
||||
const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}`
|
||||
let batch = edgeBatches.get(key)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
edgeBatches.set(key, batch)
|
||||
}
|
||||
batch.push(e)
|
||||
}
|
||||
|
||||
ctx.setLineDash([])
|
||||
for (const [key, batch] of edgeBatches) {
|
||||
const first = batch[0]
|
||||
if (!first) continue
|
||||
const isDimmed = key.endsWith("|d")
|
||||
const batchEdgeType = first.edgeType
|
||||
|
||||
// Draw glow pass behind all edge types for luminous aesthetic
|
||||
if (!isDimmed) {
|
||||
const glowAlpha =
|
||||
batchEdgeType === "updates"
|
||||
? first.style.opacity * 0.4
|
||||
: first.style.opacity * 0.3
|
||||
const glowWidth =
|
||||
batchEdgeType === "updates"
|
||||
? first.style.width + 2
|
||||
: first.style.width + 1.5
|
||||
ctx.save()
|
||||
ctx.globalAlpha = glowAlpha
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = glowWidth
|
||||
if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
|
||||
ctx.beginPath()
|
||||
for (const e of batch) {
|
||||
ctx.moveTo(e.startX, e.startY)
|
||||
ctx.lineTo(e.endX, e.endY)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const baseAlpha = first.style.opacity
|
||||
ctx.globalAlpha = isDimmed
|
||||
? baseAlpha * (1 - state.dimProgress * 0.8)
|
||||
: baseAlpha
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = first.style.width
|
||||
|
||||
// Extends edges use dashed lines
|
||||
if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
|
||||
|
||||
ctx.beginPath()
|
||||
for (const e of batch) {
|
||||
ctx.moveTo(e.startX, e.startY)
|
||||
ctx.lineTo(e.endX, e.endY)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
if (batchEdgeType === "extends") ctx.setLineDash([])
|
||||
|
||||
// Arrowheads for updates edges
|
||||
if (batchEdgeType === "updates") {
|
||||
ctx.globalAlpha = isDimmed
|
||||
? first.style.opacity * 0.6 * (1 - state.dimProgress * 0.8)
|
||||
: first.style.opacity * 0.6
|
||||
ctx.fillStyle = first.style.color
|
||||
for (const e of batch) {
|
||||
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()
|
||||
}
|
||||
|
||||
function drawNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
const margin = 60
|
||||
const memDots: {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
color: string
|
||||
dimmed: boolean
|
||||
}[] = []
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
|
||||
if (node.type === "document") {
|
||||
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
|
||||
} else {
|
||||
const md = node.data as MemoryNodeData
|
||||
memDots.push({
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
r: Math.max(2, screenSize * 0.45),
|
||||
color: node.borderColor || colors.memStrokeDefault,
|
||||
dimmed: md.isLatest === false,
|
||||
})
|
||||
}
|
||||
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,
|
||||
colors,
|
||||
)
|
||||
} else {
|
||||
drawMemoryNode(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected || isHighlighted || isHovered) {
|
||||
drawGlow(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node.type,
|
||||
colors,
|
||||
isHovered && !isSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const dimAlpha =
|
||||
state.selectedNodeId && state.dimProgress > 0
|
||||
? 1 - state.dimProgress * 0.7
|
||||
: 1
|
||||
|
||||
if (docDots.length > 0) {
|
||||
ctx.fillStyle = colors.docFill
|
||||
ctx.strokeStyle = colors.docStroke
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (memDots.length > 0) {
|
||||
// Draw normal (latest) memory dots
|
||||
const normalDots = memDots.filter((d) => !d.dimmed)
|
||||
const dimmedDots = memDots.filter((d) => d.dimmed)
|
||||
|
||||
if (normalDots.length > 0) {
|
||||
// Subtle glow behind memory dots for luminous effect
|
||||
ctx.globalAlpha = dimAlpha * 0.25
|
||||
for (const [color, batch] of groupByColor(normalDots)) {
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
for (const d of batch) {
|
||||
ctx.moveTo(d.x + d.r * 2.5, d.y)
|
||||
ctx.arc(d.x, d.y, d.r * 2.5, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Filled dot
|
||||
ctx.globalAlpha = dimAlpha
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of normalDots) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
// Colored border
|
||||
ctx.lineWidth = 1.5
|
||||
for (const [color, batch] of groupByColor(normalDots)) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Draw dimmed (superseded) memory dots at reduced opacity
|
||||
if (dimmedDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of dimmedDots) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
ctx.lineWidth = 1
|
||||
for (const [color, batch] of groupByColor(dimmedDots)) {
|
||||
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,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
const half = size * 0.5
|
||||
const cornerR = 8 * (size / 50)
|
||||
|
||||
// Drop shadow for selected/hovered nodes
|
||||
if (isSelected || isHovered) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = colors.accent
|
||||
ctx.shadowBlur = isSelected ? 16 : 10
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
// Subtle gradient fill for document nodes
|
||||
const grad = ctx.createLinearGradient(
|
||||
sx - half,
|
||||
sy - half,
|
||||
sx + half,
|
||||
sy + half,
|
||||
)
|
||||
grad.addColorStop(0, colors.docFill)
|
||||
grad.addColorStop(1, lightenColor(colors.docFill, 0.08))
|
||||
ctx.fillStyle = grad
|
||||
|
||||
ctx.strokeStyle =
|
||||
isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke
|
||||
ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1
|
||||
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
if (isSelected || isHovered) {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const innerSize = size * 0.72
|
||||
const innerHalf = innerSize * 0.5
|
||||
const innerR = 6 * (size / 50)
|
||||
ctx.fillStyle = colors.docInnerFill
|
||||
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
|
||||
ctx.fill()
|
||||
|
||||
const iconSize = size * 0.35
|
||||
const docType =
|
||||
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
|
||||
drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor)
|
||||
}
|
||||
|
||||
function drawMemoryNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
_isHighlighted: boolean,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
const memData = node.data as MemoryNodeData
|
||||
const isSuperseded = memData.isLatest === false
|
||||
const isForgotten = memData.isForgotten
|
||||
const radius = size * 0.5
|
||||
|
||||
// Dim superseded (non-latest) memory nodes with strikethrough effect
|
||||
if (isSuperseded && !isSelected && !isHovered) {
|
||||
const prevAlpha = ctx.globalAlpha
|
||||
ctx.globalAlpha = prevAlpha * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = node.borderColor || colors.memStrokeDefault
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Draw diagonal strikethrough for superseded nodes (visual clarity)
|
||||
const strikeR = radius * 0.55
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(sx - strikeR, sy - strikeR)
|
||||
ctx.lineTo(sx + strikeR, sy + strikeR)
|
||||
ctx.strokeStyle = colors.textMuted
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
ctx.globalAlpha = prevAlpha
|
||||
return
|
||||
}
|
||||
|
||||
// Drop shadow for selected/hovered memory nodes
|
||||
if (isSelected || isHovered) {
|
||||
ctx.save()
|
||||
const shadowColor = isSelected ? colors.accent : colors.glowColor
|
||||
ctx.shadowColor = shadowColor
|
||||
ctx.shadowBlur = isSelected ? 18 : 12
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
||||
const borderColor = node.borderColor || colors.memStrokeDefault
|
||||
ctx.strokeStyle = isSelected ? colors.accent : borderColor
|
||||
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
|
||||
ctx.stroke()
|
||||
|
||||
if (isSelected || isHovered) {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Draw X icon for forgotten nodes
|
||||
if (isForgotten && size > 14) {
|
||||
const iconR = radius * 0.3
|
||||
ctx.save()
|
||||
ctx.strokeStyle = colors.memBorderForgotten
|
||||
ctx.lineWidth = Math.max(1.5, size / 20)
|
||||
ctx.lineCap = "round"
|
||||
ctx.globalAlpha = 0.9
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(sx - iconR, sy - iconR)
|
||||
ctx.lineTo(sx + iconR, sy + iconR)
|
||||
ctx.moveTo(sx + iconR, sy - iconR)
|
||||
ctx.lineTo(sx - iconR, sy + iconR)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function drawGlow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
nodeType: "document" | "memory",
|
||||
colors: GraphThemeColors,
|
||||
isHoverOnly = false,
|
||||
): void {
|
||||
ctx.strokeStyle = colors.glowColor
|
||||
ctx.lineWidth = isHoverOnly ? 1.5 : 2
|
||||
ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3])
|
||||
ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8
|
||||
|
||||
const scale = isHoverOnly ? 1.1 : 1.15
|
||||
|
||||
if (nodeType === "document") {
|
||||
const glowSize = size * scale
|
||||
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 * scale)
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */
|
||||
export function lightenColor(hex: string, amount: number): string {
|
||||
if (
|
||||
_lightenCache &&
|
||||
_lightenCache.input === hex &&
|
||||
_lightenCache.amount === amount
|
||||
) {
|
||||
return _lightenCache.result
|
||||
}
|
||||
const h = hex.replace("#", "")
|
||||
// Only handle standard 6-digit hex; return input unchanged for other formats
|
||||
if (h.length !== 6) return hex
|
||||
const r = Math.min(
|
||||
255,
|
||||
Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
|
||||
)
|
||||
const g = Math.min(
|
||||
255,
|
||||
Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
|
||||
)
|
||||
const b = Math.min(
|
||||
255,
|
||||
Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
|
||||
)
|
||||
const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
|
||||
_lightenCache = { input: hex, amount, result }
|
||||
return result
|
||||
}
|
||||
98
packages/memory-graph/src/canvas/simulation.ts
Normal file
98
packages/memory-graph/src/canvas/simulation.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import * as d3 from "d3-force"
|
||||
import type { GraphEdge, GraphNode } from "../types"
|
||||
import { FORCE_CONFIG } from "../constants"
|
||||
|
||||
export class ForceSimulation {
|
||||
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
|
||||
|
||||
init(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
this.destroy()
|
||||
|
||||
try {
|
||||
// Only use structural edges (derives, updates) for the force layout.
|
||||
// "extends" edges are visual-only -- they connect documents sharing a
|
||||
// spaceId but should not pull documents together into a single mass.
|
||||
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
|
||||
|
||||
this.sim = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.alphaDecay(FORCE_CONFIG.alphaDecay)
|
||||
.alphaMin(FORCE_CONFIG.alphaMin)
|
||||
.velocityDecay(FORCE_CONFIG.velocityDecay)
|
||||
|
||||
this.sim.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<GraphNode, GraphEdge>(structuralEdges)
|
||||
.id((d) => d.id)
|
||||
.distance((link) =>
|
||||
link.edgeType === "derives"
|
||||
? FORCE_CONFIG.docMemoryDistance
|
||||
: FORCE_CONFIG.linkDistance,
|
||||
)
|
||||
.strength((link) => {
|
||||
if (link.edgeType === "derives")
|
||||
return FORCE_CONFIG.linkStrength.docMemory
|
||||
if (link.edgeType === "updates")
|
||||
return FORCE_CONFIG.linkStrength.version
|
||||
return FORCE_CONFIG.linkStrength.fallback
|
||||
}),
|
||||
)
|
||||
|
||||
this.sim.force(
|
||||
"charge",
|
||||
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
|
||||
)
|
||||
|
||||
this.sim.force(
|
||||
"collide",
|
||||
d3
|
||||
.forceCollide<GraphNode>()
|
||||
.radius((d) =>
|
||||
d.type === "document"
|
||||
? FORCE_CONFIG.collisionRadius.document
|
||||
: FORCE_CONFIG.collisionRadius.memory,
|
||||
)
|
||||
.strength(FORCE_CONFIG.collisionStrength),
|
||||
)
|
||||
|
||||
this.sim.force("x", d3.forceX().strength(FORCE_CONFIG.centeringStrength))
|
||||
this.sim.force("y", d3.forceY().strength(FORCE_CONFIG.centeringStrength))
|
||||
|
||||
this.sim.stop()
|
||||
this.sim.alpha(1)
|
||||
for (let i = 0; i < FORCE_CONFIG.preSettleTicks; 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.filter((e) => e.edgeType !== "extends"))
|
||||
}
|
||||
|
||||
reheat(): void {
|
||||
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
|
||||
}
|
||||
|
||||
coolDown(): void {
|
||||
this.sim?.alphaTarget(0)
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.sim) {
|
||||
this.sim.stop()
|
||||
this.sim = null
|
||||
}
|
||||
}
|
||||
}
|
||||
97
packages/memory-graph/src/canvas/version-chain.ts
Normal file
97
packages/memory-graph/src/canvas/version-chain.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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 childrenMap = new Map<string, string[]>()
|
||||
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.childrenMap.clear()
|
||||
this.cache.clear()
|
||||
|
||||
for (const doc of documents) {
|
||||
for (const m of doc.memories) {
|
||||
this.memoryMap.set(m.id, m)
|
||||
if (m.parentMemoryId) {
|
||||
let children = this.childrenMap.get(m.parentMemoryId)
|
||||
if (!children) {
|
||||
children = []
|
||||
this.childrenMap.set(m.parentMemoryId, children)
|
||||
}
|
||||
children.push(m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getChain(memoryId: string): ChainEntry[] | null {
|
||||
const cached = this.cache.get(memoryId)
|
||||
if (cached) return cached
|
||||
|
||||
const mem = this.memoryMap.get(memoryId)
|
||||
if (!mem) return null
|
||||
|
||||
// Walk backward to root
|
||||
const backward: GraphApiMemory[] = []
|
||||
const visited = new Set<string>()
|
||||
let current: GraphApiMemory | undefined = mem
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id)
|
||||
backward.push(current)
|
||||
current = current.parentMemoryId
|
||||
? this.memoryMap.get(current.parentMemoryId)
|
||||
: undefined
|
||||
}
|
||||
backward.reverse()
|
||||
|
||||
// Walk forward from the selected node to find descendants.
|
||||
// Version chains are linear (each memory has one parent), so we
|
||||
// follow the first child at each step. If branching occurs, only
|
||||
// the first branch (by document order) is included.
|
||||
const forward: GraphApiMemory[] = []
|
||||
let cursor: GraphApiMemory | undefined = mem
|
||||
while (cursor) {
|
||||
const children = this.childrenMap.get(cursor.id)
|
||||
if (!children || children.length === 0) break
|
||||
const firstChildId = children[0]
|
||||
if (!firstChildId) break
|
||||
const child = this.memoryMap.get(firstChildId)
|
||||
if (!child || visited.has(child.id)) break
|
||||
visited.add(child.id)
|
||||
forward.push(child)
|
||||
cursor = child
|
||||
}
|
||||
|
||||
// Combine: backward (root..selected) + forward (selected+1..latest)
|
||||
const all = [...backward, ...forward]
|
||||
|
||||
// A single-entry chain (standalone v1 with no children) is not useful
|
||||
if (all.length <= 1) return null
|
||||
|
||||
const chain: ChainEntry[] = all.map((m) => ({
|
||||
id: m.id,
|
||||
version: m.version,
|
||||
memory: m.memory,
|
||||
isForgotten: m.isForgotten,
|
||||
isLatest: m.isLatest,
|
||||
}))
|
||||
|
||||
for (const entry of chain) {
|
||||
this.cache.set(entry.id, chain)
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ export class ViewportState {
|
|||
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
|
||||
}
|
||||
|
|
@ -125,7 +124,6 @@ export class ViewportState {
|
|||
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
|
||||
|
|
@ -137,7 +135,6 @@ export class ViewportState {
|
|||
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)
|
||||
|
|
@ -147,7 +144,6 @@ export class ViewportState {
|
|||
moving = true
|
||||
}
|
||||
|
||||
// Lerp pan animation
|
||||
if (this.targetPanX !== null && this.targetPanY !== null) {
|
||||
const dx = this.targetPanX - this.panX
|
||||
const dy = this.targetPanY - this.panY
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Canvas wrapper/container that fills its parent
|
||||
* Used by both graph-canvas and graph-webgl-canvas
|
||||
*/
|
||||
export const canvasWrapper = style({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,342 +0,0 @@
|
|||
import { style, styleVariants, globalStyle } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Legend container base
|
||||
*/
|
||||
const legendContainerBase = style({
|
||||
position: "absolute",
|
||||
zIndex: 20, // Above most elements but below node detail panel
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
width: "fit-content",
|
||||
height: "fit-content",
|
||||
maxHeight: "calc(100vh - 2rem)", // Prevent overflow
|
||||
})
|
||||
|
||||
/**
|
||||
* Legend container variants for positioning
|
||||
* Console: Bottom-right (doesn't conflict with anything)
|
||||
* Consumer: Bottom-right (moved from top to avoid conflicts)
|
||||
*/
|
||||
export const legendContainer = styleVariants({
|
||||
consoleDesktop: [
|
||||
legendContainerBase,
|
||||
{
|
||||
bottom: themeContract.space[4],
|
||||
right: themeContract.space[4],
|
||||
},
|
||||
],
|
||||
consoleMobile: [
|
||||
legendContainerBase,
|
||||
{
|
||||
bottom: themeContract.space[4],
|
||||
right: themeContract.space[4],
|
||||
"@media": {
|
||||
"screen and (max-width: 767px)": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
consumerDesktop: [
|
||||
legendContainerBase,
|
||||
{
|
||||
// Changed from top to bottom to avoid overlap with node detail panel
|
||||
bottom: themeContract.space[4],
|
||||
right: themeContract.space[4],
|
||||
},
|
||||
],
|
||||
consumerMobile: [
|
||||
legendContainerBase,
|
||||
{
|
||||
bottom: themeContract.space[4],
|
||||
right: themeContract.space[4],
|
||||
"@media": {
|
||||
"screen and (max-width: 767px)": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Mobile size variants
|
||||
*/
|
||||
export const mobileSize = styleVariants({
|
||||
expanded: {
|
||||
maxWidth: "20rem", // max-w-xs
|
||||
},
|
||||
collapsed: {
|
||||
width: "4rem", // w-16
|
||||
height: "3rem", // h-12
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Legend content wrapper
|
||||
*/
|
||||
export const legendContent = style({
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
})
|
||||
|
||||
/**
|
||||
* Collapsed trigger button
|
||||
*/
|
||||
export const collapsedTrigger = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: themeContract.space[2],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const collapsedContent = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[1],
|
||||
})
|
||||
|
||||
export const collapsedText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
})
|
||||
|
||||
export const collapsedIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
color: themeContract.colors.text.muted,
|
||||
})
|
||||
|
||||
/**
|
||||
* Header
|
||||
*/
|
||||
export const legendHeader = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50
|
||||
})
|
||||
|
||||
export const legendTitle = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
color: themeContract.colors.text.primary,
|
||||
})
|
||||
|
||||
export const headerTrigger = style({
|
||||
padding: themeContract.space[1],
|
||||
borderRadius: themeContract.radii.sm,
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const headerIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.muted,
|
||||
})
|
||||
|
||||
/**
|
||||
* Content sections
|
||||
*/
|
||||
export const sectionsContainer = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.secondary,
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
})
|
||||
|
||||
export const sectionWrapper = style({
|
||||
marginTop: themeContract.space[3],
|
||||
selectors: {
|
||||
"&:first-child": {
|
||||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const sectionTitle = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
color: themeContract.colors.text.secondary,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const itemsList = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem", // gap-1.5
|
||||
})
|
||||
|
||||
export const legendItem = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const legendIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const legendText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
})
|
||||
|
||||
/**
|
||||
* Shape styles
|
||||
*/
|
||||
export const documentNode = style({
|
||||
width: "1rem",
|
||||
height: "0.75rem",
|
||||
background: "rgba(255, 255, 255, 0.21)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.6)",
|
||||
borderRadius: themeContract.radii.sm,
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
// Hexagon shapes using SVG background (matching graph's flat-top hexagon)
|
||||
// Points calculated: angle = (i * 2π / 6) - π/2, center (6,6), radius 4.5
|
||||
const hexagonPoints = "6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
|
||||
|
||||
export const memoryNode = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
flexShrink: 0,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
})
|
||||
|
||||
export const memoryNodeOlder = style({
|
||||
opacity: 0.4,
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
flexShrink: 0,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
})
|
||||
|
||||
export const forgottenNode = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(239,68,68,0.3)' stroke='rgba(239,68,68,0.8)' stroke-width='1'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
})
|
||||
|
||||
export const expiringNode = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
flexShrink: 0,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(245,158,11)' stroke-width='1.5'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
})
|
||||
|
||||
export const newNode = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(16,185,129)' stroke-width='1.5'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
})
|
||||
|
||||
export const forgottenIcon = style({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "rgb(248, 113, 113)",
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
lineHeight: "1",
|
||||
pointerEvents: "none",
|
||||
})
|
||||
|
||||
export const newBadge = style({
|
||||
position: "absolute",
|
||||
top: "-0.25rem",
|
||||
right: "-0.25rem",
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
backgroundColor: "rgb(16, 185, 129)",
|
||||
borderRadius: themeContract.radii.full,
|
||||
})
|
||||
|
||||
export const connectionLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "1px solid rgb(148, 163, 184, 0.5)",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const similarityLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "2px dashed rgba(35, 189, 255, 0.6)",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const relationLine = style({
|
||||
width: "1rem",
|
||||
height: 0,
|
||||
borderTop: "2px solid",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const weakSimilarity = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
borderRadius: themeContract.radii.full,
|
||||
background: "rgba(79, 255, 226, 0.3)",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const strongSimilarity = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
borderRadius: themeContract.radii.full,
|
||||
background: "rgba(79, 255, 226, 0.7)",
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const gradientCircle = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
background:
|
||||
"linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))",
|
||||
borderRadius: themeContract.radii.full,
|
||||
})
|
||||
|
|
@ -1,276 +1,442 @@
|
|||
"use client"
|
||||
import { memo, useState } from "react"
|
||||
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/ui/collapsible"
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { colors } from "@/constants"
|
||||
import type { GraphEdge, GraphNode, LegendProps } from "@/types"
|
||||
import * as styles from "./legend.css"
|
||||
|
||||
// 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
|
||||
interface LegendProps {
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
colors: GraphThemeColors
|
||||
}
|
||||
|
||||
function HexagonIcon({
|
||||
fill,
|
||||
stroke,
|
||||
size = 12,
|
||||
}: {
|
||||
fill: string
|
||||
stroke: string
|
||||
size?: number
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
height={size}
|
||||
viewBox="0 0 12 12"
|
||||
width={size}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<polygon
|
||||
fill={fill}
|
||||
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
|
||||
stroke={stroke}
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LineIcon({
|
||||
color,
|
||||
dashed = false,
|
||||
}: {
|
||||
color: string
|
||||
dashed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 0,
|
||||
borderTop: `2.5px ${dashed ? "dashed" : "solid"} ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronRightIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
expandable = false,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
children,
|
||||
colors,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count: number
|
||||
expandable?: boolean
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
children?: React.ReactNode
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
outline: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: expandable ? "pointer" : "default",
|
||||
}
|
||||
|
||||
const leftStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: 400,
|
||||
}
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
}
|
||||
|
||||
const childrenContainerStyle: React.CSSProperties = {
|
||||
paddingLeft: 10,
|
||||
paddingTop: 6,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<button
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
>
|
||||
<div style={leftStyle}>
|
||||
{icon}
|
||||
<span style={labelStyle}>{label}</span>
|
||||
{expandable &&
|
||||
(expanded ? (
|
||||
<ChevronDownIcon color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronRightIcon color={colors.textMuted} />
|
||||
))}
|
||||
</div>
|
||||
<span style={countStyle}>{count}</span>
|
||||
</button>
|
||||
{expandable && expanded && children && (
|
||||
<div style={childrenContainerStyle}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Legend = memo(function Legend({
|
||||
variant = "console",
|
||||
id,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
isLoading = false,
|
||||
}: ExtendedLegendProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
isLoading: _isLoading = false,
|
||||
colors,
|
||||
}: LegendProps) {
|
||||
const [isExpanded, setIsExpanded] = 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, expanded on desktop
|
||||
setIsExpanded(!isMobile)
|
||||
}
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized, isMobile])
|
||||
|
||||
// Save to cookie when state changes
|
||||
const handleToggleExpanded = (expanded: boolean) => {
|
||||
setIsExpanded(expanded)
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true")
|
||||
}
|
||||
|
||||
// Get container class based on variant and mobile state
|
||||
const getContainerClass = () => {
|
||||
if (variant === "console") {
|
||||
return isMobile
|
||||
? styles.legendContainer.consoleMobile
|
||||
: styles.legendContainer.consoleDesktop
|
||||
}
|
||||
return isMobile
|
||||
? styles.legendContainer.consumerMobile
|
||||
: styles.legendContainer.consumerDesktop
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length
|
||||
const connectionCount = edges.length
|
||||
|
||||
const containerClass =
|
||||
isMobile && !isExpanded
|
||||
? `${getContainerClass()} ${styles.mobileSize.collapsed}`
|
||||
: isMobile
|
||||
? `${getContainerClass()} ${styles.mobileSize.expanded}`
|
||||
: getContainerClass()
|
||||
const outerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 20,
|
||||
overflow: "hidden",
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 214,
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
|
||||
}
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
const headerTextStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: 400,
|
||||
}
|
||||
|
||||
const sectionLabelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontWeight: 400,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const rowLeftStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const edgeLabelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: colors.textPrimary,
|
||||
}
|
||||
|
||||
const statusRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClass} id={id}>
|
||||
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
<div style={outerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={headerBtnStyle}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon color={colors.textPrimary} />
|
||||
) : (
|
||||
<ChevronRightIcon color={colors.textPrimary} />
|
||||
)}
|
||||
<span style={headerTextStyle}>Legend</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.legendContent}>
|
||||
{/* Mobile and Desktop collapsed state */}
|
||||
{!isExpanded && (
|
||||
<CollapsibleTrigger className={styles.collapsedTrigger}>
|
||||
<div className={styles.collapsedContent}>
|
||||
<div className={styles.collapsedText}>?</div>
|
||||
<ChevronUp className={styles.collapsedIcon} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
{/* Expanded state */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Header with toggle */}
|
||||
<div className={styles.legendHeader}>
|
||||
<div className={styles.legendTitle}>Legend</div>
|
||||
<CollapsibleTrigger className={styles.headerTrigger}>
|
||||
<ChevronDown className={styles.headerIcon} />
|
||||
</CollapsibleTrigger>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Statistics section */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={sectionLabelStyle}>Statistics</span>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 6 }}
|
||||
>
|
||||
<StatRow
|
||||
count={memoryCount}
|
||||
icon={
|
||||
<HexagonIcon
|
||||
fill={colors.memFill}
|
||||
stroke={colors.memStrokeDefault}
|
||||
/>
|
||||
}
|
||||
label="Memories"
|
||||
colors={colors}
|
||||
/>
|
||||
<StatRow
|
||||
count={documentCount}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
flexShrink: 0,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Documents"
|
||||
colors={colors}
|
||||
/>
|
||||
<StatRow
|
||||
count={connectionCount}
|
||||
expandable
|
||||
expanded={connectionsExpanded}
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<circle cx="3" cy="3" fill={colors.textMuted} r="1.5" />
|
||||
<circle cx="9" cy="3" fill={colors.textMuted} r="1.5" />
|
||||
<circle cx="6" cy="9" fill={colors.textMuted} r="1.5" />
|
||||
<line
|
||||
stroke={colors.textMuted}
|
||||
strokeWidth="0.8"
|
||||
x1="3"
|
||||
x2="9"
|
||||
y1="3"
|
||||
y2="3"
|
||||
/>
|
||||
<line
|
||||
stroke={colors.textMuted}
|
||||
strokeWidth="0.8"
|
||||
x1="3"
|
||||
x2="6"
|
||||
y1="3"
|
||||
y2="9"
|
||||
/>
|
||||
<line
|
||||
stroke={colors.textMuted}
|
||||
strokeWidth="0.8"
|
||||
x1="9"
|
||||
x2="6"
|
||||
y1="3"
|
||||
y2="9"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
label="Connections"
|
||||
onToggle={() =>
|
||||
setConnectionsExpanded(!connectionsExpanded)
|
||||
}
|
||||
colors={colors}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeDerives} />
|
||||
<span style={edgeLabelStyle}>Derives</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeUpdates} />
|
||||
<span style={edgeLabelStyle}>Updates</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeExtends} dashed />
|
||||
<span style={edgeLabelStyle}>Extends</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className={styles.sectionsContainer}>
|
||||
{/* Stats Section */}
|
||||
{!isLoading && (
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Statistics</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<Brain
|
||||
className={styles.legendIcon}
|
||||
style={{ color: "rgb(96, 165, 250)" }}
|
||||
/>
|
||||
<span className={styles.legendText}>
|
||||
{memoryCount} memories
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<FileText
|
||||
className={styles.legendIcon}
|
||||
style={{ color: "rgb(203, 213, 225)" }}
|
||||
/>
|
||||
<span className={styles.legendText}>
|
||||
{documentCount} documents
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.gradientCircle} />
|
||||
<span className={styles.legendText}>
|
||||
{edges.length} connections
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Nodes</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.documentNode} />
|
||||
<span className={styles.legendText}>Document</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.memoryNode} />
|
||||
<span className={styles.legendText}>
|
||||
Memory (latest)
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.memoryNodeOlder} />
|
||||
<span className={styles.legendText}>
|
||||
Memory (older)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Memory Status section */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={sectionLabelStyle}>Memory Status</span>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 6 }}
|
||||
>
|
||||
<div style={statusRowStyle}>
|
||||
<HexagonIcon
|
||||
fill={colors.memFill}
|
||||
stroke={colors.memBorderRecent}
|
||||
/>
|
||||
<span style={edgeLabelStyle}>Recent (< 24h)</span>
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Status</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.forgottenNode}>
|
||||
<div className={styles.forgottenIcon}>✕</div>
|
||||
</div>
|
||||
<span className={styles.legendText}>Forgotten</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.expiringNode} />
|
||||
<span className={styles.legendText}>Expiring soon</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.newNode}>
|
||||
<div className={styles.newBadge} />
|
||||
</div>
|
||||
<span className={styles.legendText}>New memory</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={statusRowStyle}>
|
||||
<HexagonIcon
|
||||
fill={colors.memFill}
|
||||
stroke={colors.memBorderExpiring}
|
||||
/>
|
||||
<span style={edgeLabelStyle}>Expiring soon</span>
|
||||
</div>
|
||||
|
||||
{/* Connection Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Connections</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.connectionLine} />
|
||||
<span className={styles.legendText}>Doc → Memory</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.similarityLine} />
|
||||
<span className={styles.legendText}>
|
||||
Doc similarity
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relation Types */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Relations</div>
|
||||
<div className={styles.itemsList}>
|
||||
{[
|
||||
["updates", colors.relations.updates],
|
||||
["extends", colors.relations.extends],
|
||||
["derives", colors.relations.derives],
|
||||
].map(([label, color]) => (
|
||||
<div className={styles.legendItem} key={label}>
|
||||
<div
|
||||
className={styles.relationLine}
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
<span
|
||||
className={styles.legendText}
|
||||
style={{
|
||||
color: color,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Strength */}
|
||||
<div className={styles.sectionWrapper}>
|
||||
<div className={styles.sectionTitle}>Similarity</div>
|
||||
<div className={styles.itemsList}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.weakSimilarity} />
|
||||
<span className={styles.legendText}>Weak</span>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.strongSimilarity} />
|
||||
<span className={styles.legendText}>Strong</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={statusRowStyle}>
|
||||
<HexagonIcon
|
||||
fill={colors.memFill}
|
||||
stroke={colors.memBorderForgotten}
|
||||
/>
|
||||
<span style={edgeLabelStyle}>Forgotten</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
import { animations } from "../styles"
|
||||
|
||||
/**
|
||||
* Loading indicator container
|
||||
* Positioned top-left, below spaces dropdown
|
||||
*/
|
||||
export const loadingContainer = style({
|
||||
position: "absolute",
|
||||
zIndex: 30, // High priority so it's visible when loading
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
top: "5.5rem", // Below spaces dropdown (~88px)
|
||||
left: themeContract.space[4],
|
||||
})
|
||||
|
||||
/**
|
||||
* Content wrapper
|
||||
*/
|
||||
export const loadingContent = style({
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
color: themeContract.colors.text.secondary,
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
})
|
||||
|
||||
/**
|
||||
* Flex container for icon and text
|
||||
*/
|
||||
export const loadingFlex = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
})
|
||||
|
||||
/**
|
||||
* Spinning icon
|
||||
*/
|
||||
export const loadingIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
animation: `${animations.spin} 1s linear infinite`,
|
||||
color: themeContract.colors.memory.border,
|
||||
})
|
||||
|
||||
/**
|
||||
* Loading text
|
||||
*/
|
||||
export const loadingText = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
})
|
||||
|
|
@ -1,40 +1,87 @@
|
|||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import type { LoadingIndicatorProps } from "@/types"
|
||||
import {
|
||||
loadingContainer,
|
||||
loadingContent,
|
||||
loadingFlex,
|
||||
loadingIcon,
|
||||
loadingText,
|
||||
} from "./loading-indicator.css"
|
||||
import type { GraphThemeColors, LoadingIndicatorProps } from "../types"
|
||||
|
||||
export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
||||
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
const spinKeyframes = `
|
||||
@keyframes mg-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
|
||||
return (
|
||||
<div className={loadingContainer}>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
let styleInjected = false
|
||||
function injectSpinStyle() {
|
||||
if (styleInjected || typeof document === "undefined") return
|
||||
const style = document.createElement("style")
|
||||
style.textContent = spinKeyframes
|
||||
document.head.appendChild(style)
|
||||
styleInjected = true
|
||||
}
|
||||
|
||||
<div className={loadingContent}>
|
||||
<div className={loadingFlex}>
|
||||
{/*@ts-ignore */}
|
||||
<Sparkles className={loadingIcon} />
|
||||
<span className={loadingText}>
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
export const LoadingIndicator = memo<
|
||||
LoadingIndicatorProps & { colors?: GraphThemeColors }
|
||||
>(({ isLoading, isLoadingMore, totalLoaded, colors }) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
injectSpinStyle()
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 30,
|
||||
overflow: "hidden",
|
||||
top: 16,
|
||||
left: 16,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
|
||||
backgroundColor: colors?.controlBg ?? "#1a1f29",
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
|
||||
}
|
||||
|
||||
const flexStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
animation: "mg-spin 1s linear infinite",
|
||||
color: colors?.accent ?? "#3B73B8",
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: colors?.textSecondary ?? "#e2e8f0",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={flexStyle}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
style={spinnerStyle}
|
||||
>
|
||||
<title>Loading</title>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
<span style={textStyle}>
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator"
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Error state container
|
||||
*/
|
||||
export const errorContainer = style({
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: themeContract.colors.background.primary,
|
||||
})
|
||||
|
||||
export const errorCard = style({
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
})
|
||||
|
||||
export const errorContent = style({
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
color: themeContract.colors.text.secondary,
|
||||
paddingLeft: themeContract.space[6],
|
||||
paddingRight: themeContract.space[6],
|
||||
paddingTop: themeContract.space[4],
|
||||
paddingBottom: themeContract.space[4],
|
||||
})
|
||||
|
||||
/**
|
||||
* Main graph container
|
||||
* Position relative so absolutely positioned children position relative to this container
|
||||
*/
|
||||
export const mainContainer = style({
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
backgroundColor: themeContract.colors.background.primary,
|
||||
})
|
||||
|
||||
/**
|
||||
* Spaces selector positioning
|
||||
* Top-left corner, below most overlays
|
||||
*/
|
||||
export const spacesSelectorContainer = style({
|
||||
position: "absolute",
|
||||
top: themeContract.space[4],
|
||||
left: themeContract.space[4],
|
||||
zIndex: 15, // Above base elements, below loading/panels
|
||||
})
|
||||
|
||||
/**
|
||||
* Graph canvas container
|
||||
*/
|
||||
export const graphContainer = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation controls positioning
|
||||
* Bottom-left corner
|
||||
*/
|
||||
export const navControlsContainer = style({
|
||||
position: "absolute",
|
||||
bottom: themeContract.space[4],
|
||||
left: themeContract.space[4],
|
||||
zIndex: 15, // Same level as spaces dropdown
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,77 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Navigation controls container
|
||||
*/
|
||||
export const navContainer = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: themeContract.space[1],
|
||||
})
|
||||
|
||||
/**
|
||||
* Base button styles for navigation controls
|
||||
*/
|
||||
const navButtonBase = style({
|
||||
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: themeContract.radii.lg,
|
||||
padding: themeContract.space[2],
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
minWidth: "64px",
|
||||
cursor: "pointer",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
color: "rgba(255, 255, 255, 1)",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard navigation button
|
||||
*/
|
||||
export const navButton = navButtonBase
|
||||
|
||||
/**
|
||||
* Zoom controls container
|
||||
*/
|
||||
export const zoomContainer = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})
|
||||
|
||||
/**
|
||||
* Zoom in button (top rounded)
|
||||
*/
|
||||
export const zoomInButton = style([
|
||||
navButtonBase,
|
||||
{
|
||||
borderTopLeftRadius: themeContract.radii.lg,
|
||||
borderTopRightRadius: themeContract.radii.lg,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
])
|
||||
|
||||
/**
|
||||
* Zoom out button (bottom rounded)
|
||||
*/
|
||||
export const zoomOutButton = style([
|
||||
navButtonBase,
|
||||
{
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomLeftRadius: themeContract.radii.lg,
|
||||
borderBottomRightRadius: themeContract.radii.lg,
|
||||
},
|
||||
])
|
||||
|
|
@ -1,14 +1,5 @@
|
|||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import type { GraphNode } from "@/types"
|
||||
import {
|
||||
navContainer,
|
||||
navButton,
|
||||
zoomContainer,
|
||||
zoomInButton,
|
||||
zoomOutButton,
|
||||
} from "./navigation-controls.css"
|
||||
import type { GraphNode, GraphThemeColors } from "../types"
|
||||
|
||||
interface NavigationControlsProps {
|
||||
onCenter: () => void
|
||||
|
|
@ -17,53 +8,183 @@ interface NavigationControlsProps {
|
|||
onAutoFit: () => void
|
||||
nodes: GraphNode[]
|
||||
className?: string
|
||||
zoomLevel: number
|
||||
colors: GraphThemeColors
|
||||
}
|
||||
|
||||
function KeyBadge({
|
||||
keys,
|
||||
colors,
|
||||
}: {
|
||||
keys: string
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
lineHeight: 1,
|
||||
}
|
||||
|
||||
return <span style={style}>{keys}</span>
|
||||
}
|
||||
|
||||
function NavBtn({
|
||||
onClick,
|
||||
children,
|
||||
colors,
|
||||
}: {
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
display: "flex",
|
||||
width: "fit-content",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
borderRadius: 9999,
|
||||
cursor: "pointer",
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)",
|
||||
transition: "background-color 0.15s",
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.85"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const NavigationControls = memo<NavigationControlsProps>(
|
||||
({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => {
|
||||
if (nodes.length === 0) {
|
||||
return null
|
||||
({
|
||||
onCenter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onAutoFit,
|
||||
nodes,
|
||||
className = "",
|
||||
zoomLevel,
|
||||
colors,
|
||||
}) => {
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}
|
||||
|
||||
const containerClassName = className
|
||||
? `${navContainer} ${className}`
|
||||
: navContainer
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: colors.textPrimary,
|
||||
}
|
||||
|
||||
const zoomRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
width: "fit-content",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)",
|
||||
}
|
||||
|
||||
const zoomBtnGroupStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}
|
||||
|
||||
const zoomBtnStyle: React.CSSProperties = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
color: colors.textSecondary,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
padding: 0,
|
||||
transition: "background-color 0.15s, color 0.15s",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAutoFit}
|
||||
className={navButton}
|
||||
title="Auto-fit graph to viewport"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCenter}
|
||||
className={navButton}
|
||||
title="Center view on graph"
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<div className={zoomContainer}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomIn}
|
||||
className={zoomInButton}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomOut}
|
||||
className={zoomOutButton}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className={className} style={containerStyle}>
|
||||
<NavBtn onClick={onAutoFit} colors={colors}>
|
||||
<span style={labelStyle}>Fit</span>
|
||||
<KeyBadge keys="Z" colors={colors} />
|
||||
</NavBtn>
|
||||
|
||||
<NavBtn onClick={onCenter} colors={colors}>
|
||||
<span style={labelStyle}>Center</span>
|
||||
<KeyBadge keys="C" colors={colors} />
|
||||
</NavBtn>
|
||||
|
||||
<div style={zoomRowStyle}>
|
||||
<span style={labelStyle}>{zoomLevel}%</span>
|
||||
<div style={zoomBtnGroupStyle}>
|
||||
<button
|
||||
onClick={onZoomOut}
|
||||
style={zoomBtnStyle}
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.8"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>−</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onZoomIn}
|
||||
style={zoomBtnStyle}
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.8"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Main container (positioned absolutely)
|
||||
* Highest z-index so it appears above everything when open
|
||||
*/
|
||||
export const container = style({
|
||||
position: "absolute",
|
||||
width: "20rem", // w-80 = 320px = 20rem
|
||||
borderRadius: themeContract.radii.xl,
|
||||
overflow: "hidden",
|
||||
zIndex: 40, // Highest priority - always on top when open
|
||||
maxHeight: "calc(100vh - 2rem)", // Leave some breathing room
|
||||
top: themeContract.space[4],
|
||||
right: themeContract.space[4],
|
||||
|
||||
// Add shadow for depth
|
||||
boxShadow:
|
||||
"0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
|
||||
})
|
||||
|
||||
/**
|
||||
* Content wrapper with scrolling
|
||||
*/
|
||||
export const content = style({
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
padding: themeContract.space[4],
|
||||
overflowY: "auto",
|
||||
maxHeight: "80vh",
|
||||
})
|
||||
|
||||
/**
|
||||
* Header section
|
||||
*/
|
||||
export const header = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: themeContract.space[3],
|
||||
})
|
||||
|
||||
export const headerLeft = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const headerIcon = style({
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
})
|
||||
|
||||
export const headerIconMemory = style({
|
||||
width: "1.25rem",
|
||||
height: "1.25rem",
|
||||
color: "rgb(96, 165, 250)", // blue-400
|
||||
})
|
||||
|
||||
export const closeButton = style({
|
||||
height: "32px",
|
||||
width: "32px",
|
||||
padding: 0,
|
||||
color: themeContract.colors.text.secondary,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
color: themeContract.colors.text.primary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const closeIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
})
|
||||
|
||||
/**
|
||||
* Content sections
|
||||
*/
|
||||
export const sections = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: themeContract.space[3],
|
||||
})
|
||||
|
||||
export const section = style({})
|
||||
|
||||
export const sectionLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
})
|
||||
|
||||
export const sectionValue = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
marginTop: themeContract.space[1],
|
||||
})
|
||||
|
||||
export const sectionValueTruncated = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
marginTop: themeContract.space[1],
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
})
|
||||
|
||||
export const link = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: "rgb(129, 140, 248)", // indigo-400
|
||||
marginTop: themeContract.space[1],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[1],
|
||||
textDecoration: "none",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
color: "rgb(165, 180, 252)", // indigo-300
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const linkIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
})
|
||||
|
||||
export const badge = style({
|
||||
marginTop: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const expiryText = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
marginTop: themeContract.space[1],
|
||||
})
|
||||
|
||||
/**
|
||||
* Footer section (metadata)
|
||||
*/
|
||||
export const footer = style({
|
||||
paddingTop: themeContract.space[2],
|
||||
borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50
|
||||
})
|
||||
|
||||
export const metadata = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[4],
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
})
|
||||
|
||||
export const metadataItem = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[1],
|
||||
})
|
||||
|
||||
export const metadataIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
})
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Badge } from "@/ui/badge"
|
||||
import { Button } from "@/ui/button"
|
||||
import { GlassMenuEffect } from "@/ui/glass-effect"
|
||||
import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { memo } from "react"
|
||||
import {
|
||||
GoogleDocs,
|
||||
GoogleDrive,
|
||||
GoogleSheets,
|
||||
GoogleSlides,
|
||||
MicrosoftExcel,
|
||||
MicrosoftOneNote,
|
||||
MicrosoftPowerpoint,
|
||||
MicrosoftWord,
|
||||
NotionDoc,
|
||||
OneDrive,
|
||||
PDF,
|
||||
} from "@/assets/icons"
|
||||
import { HeadingH3Bold } from "@/ui/heading"
|
||||
import type { DocumentWithMemories, MemoryEntry } from "@/types"
|
||||
import type { NodeDetailPanelProps } from "@/types"
|
||||
import * as styles from "./node-detail-panel.css"
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
// Special case for PDF
|
||||
if (type.toLowerCase() === "pdf") return "PDF"
|
||||
|
||||
// Replace underscores with spaces and capitalize each word
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5 text-slate-300" }
|
||||
|
||||
switch (type) {
|
||||
case "google_doc":
|
||||
return <GoogleDocs {...iconProps} />
|
||||
case "google_sheet":
|
||||
return <GoogleSheets {...iconProps} />
|
||||
case "google_slide":
|
||||
return <GoogleSlides {...iconProps} />
|
||||
case "google_drive":
|
||||
return <GoogleDrive {...iconProps} />
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
return <NotionDoc {...iconProps} />
|
||||
case "word":
|
||||
case "microsoft_word":
|
||||
return <MicrosoftWord {...iconProps} />
|
||||
case "excel":
|
||||
case "microsoft_excel":
|
||||
return <MicrosoftExcel {...iconProps} />
|
||||
case "powerpoint":
|
||||
case "microsoft_powerpoint":
|
||||
return <MicrosoftPowerpoint {...iconProps} />
|
||||
case "onenote":
|
||||
case "microsoft_onenote":
|
||||
return <MicrosoftOneNote {...iconProps} />
|
||||
case "onedrive":
|
||||
return <OneDrive {...iconProps} />
|
||||
case "pdf":
|
||||
return <PDF {...iconProps} />
|
||||
default:
|
||||
{
|
||||
}
|
||||
return <FileText {...iconProps} />
|
||||
}
|
||||
}
|
||||
|
||||
export const NodeDetailPanel = memo(function NodeDetailPanel({
|
||||
node,
|
||||
onClose,
|
||||
variant = "console",
|
||||
}: NodeDetailPanelProps) {
|
||||
if (!node) return null
|
||||
|
||||
const isDocument = node.type === "document"
|
||||
const data = node.data
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.container}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="xl" />
|
||||
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.content}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ delay: 0.05, duration: 0.15 }}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
{isDocument ? (
|
||||
getDocumentIcon((data as DocumentWithMemories).type ?? "")
|
||||
) : (
|
||||
<Brain className={styles.headerIconMemory} />
|
||||
)}
|
||||
<HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold>
|
||||
</div>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<X className={styles.closeIcon} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
{isDocument ? (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Title</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(data as DocumentWithMemories).summary && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Summary</span>
|
||||
<p className={styles.sectionValueTruncated}>
|
||||
{(data as DocumentWithMemories).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Type</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{formatDocumentType(
|
||||
(data as DocumentWithMemories).type ?? "",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Memory Count</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as DocumentWithMemories).memoryEntries.length} memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{((data as DocumentWithMemories).url ||
|
||||
(data as DocumentWithMemories).customId) && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>URL</span>
|
||||
<a
|
||||
className={styles.link}
|
||||
href={(() => {
|
||||
const doc = data as DocumentWithMemories
|
||||
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
|
||||
})()}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ExternalLink className={styles.linkIcon} />
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Memory</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).memory}
|
||||
</p>
|
||||
{(data as MemoryEntry).isForgotten && (
|
||||
<Badge className={styles.badge} variant="destructive">
|
||||
Forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{(data as MemoryEntry).forgetAfter && (
|
||||
<p className={styles.expiryText}>
|
||||
Expires:{" "}
|
||||
{(data as MemoryEntry).forgetAfter
|
||||
? new Date(
|
||||
(data as MemoryEntry).forgetAfter!,
|
||||
).toLocaleDateString()
|
||||
: ""}{" "}
|
||||
{"forgetReason" in data && (data as any).forgetReason
|
||||
? `- ${(data as any).forgetReason}`
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Space</span>
|
||||
<p className={styles.sectionValue}>
|
||||
{(data as MemoryEntry).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.metadata}>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Calendar className={styles.metadataIcon} />
|
||||
{new Date(data.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className={styles.metadataItem}>
|
||||
{/* @ts-ignore */}
|
||||
<Hash className={styles.metadataIcon} />
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
NodeDetailPanel.displayName = "NodeDetailPanel"
|
||||
638
packages/memory-graph/src/components/node-hover-popover.tsx
Normal file
638
packages/memory-graph/src/components/node-hover-popover.tsx
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { ChainEntry } from "../canvas/version-chain"
|
||||
import type {
|
||||
DocumentNodeData,
|
||||
GraphNode,
|
||||
GraphThemeColors,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
|
||||
export interface NodeHoverPopoverProps {
|
||||
node: GraphNode
|
||||
screenX: number
|
||||
screenY: number
|
||||
nodeRadius: number
|
||||
containerBounds?: DOMRect
|
||||
versionChain?: ChainEntry[] | null
|
||||
colors: GraphThemeColors
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
onNavigateUp?: () => void
|
||||
onNavigateDown?: () => void
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function useCopyToClipboard(timeout = 2000) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = useCallback(
|
||||
(value: string) => {
|
||||
navigator.clipboard.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), timeout)
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
},
|
||||
[timeout],
|
||||
)
|
||||
|
||||
return { copied, copy }
|
||||
}
|
||||
|
||||
function KeyBadge({
|
||||
children,
|
||||
colors,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
color: colors.popoverTextMuted,
|
||||
lineHeight: 1,
|
||||
}
|
||||
|
||||
return <span style={style}>{children}</span>
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
colors,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: "pointer",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: "2px 0",
|
||||
transition: "opacity 0.15s",
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
whiteSpace: "nowrap",
|
||||
color: colors.popoverTextSecondary,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "0.8"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
<KeyBadge colors={colors}>{icon}</KeyBadge>
|
||||
<span style={labelStyle}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyableId({
|
||||
label,
|
||||
value,
|
||||
colors,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const short =
|
||||
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
cursor: "pointer",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
color: colors.popoverTextSecondary,
|
||||
}
|
||||
|
||||
const valueStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
color: colors.popoverTextMuted,
|
||||
transition: "color 0.15s",
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => copy(value)} style={buttonStyle} type="button">
|
||||
<span style={labelStyle}>{label}</span>
|
||||
<span style={valueStyle}>{copied ? "Copied!" : short}</span>
|
||||
</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] ?? "right"
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number) {
|
||||
return s.length > max ? `${s.substring(0, max)}...` : s
|
||||
}
|
||||
|
||||
function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onSelect,
|
||||
colors,
|
||||
}: {
|
||||
chain: ChainEntry[]
|
||||
currentId: string
|
||||
onSelect?: (id: string) => void
|
||||
colors: GraphThemeColors
|
||||
}) {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
maxHeight: 120,
|
||||
overflowY: "auto",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{chain.map((entry) => {
|
||||
const isCurrent = entry.id === currentId
|
||||
|
||||
const entryStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.15s",
|
||||
background: isCurrent ? `${colors.accent}15` : "transparent",
|
||||
border: "none",
|
||||
borderLeft: isCurrent
|
||||
? `2px solid ${colors.accent}`
|
||||
: `2px solid ${colors.popoverBorder}`,
|
||||
}
|
||||
|
||||
const versionStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
color: entry.isForgotten
|
||||
? colors.memBorderForgotten
|
||||
: isCurrent
|
||||
? colors.accent
|
||||
: colors.popoverTextSecondary,
|
||||
}
|
||||
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
lineHeight: 1.35,
|
||||
color: isCurrent
|
||||
? colors.popoverTextPrimary
|
||||
: colors.popoverTextMuted,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => onSelect?.(entry.id)}
|
||||
style={entryStyle}
|
||||
type="button"
|
||||
>
|
||||
<span style={versionStyle}>v{entry.version}</span>
|
||||
<span style={textStyle}>{truncate(entry.memory, 60)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
||||
function NodeHoverPopover({
|
||||
node,
|
||||
screenX,
|
||||
screenY,
|
||||
nodeRadius,
|
||||
containerBounds,
|
||||
versionChain,
|
||||
colors,
|
||||
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
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
}
|
||||
|
||||
const svgStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "visible",
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
const popoverContainerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
pointerEvents: "auto",
|
||||
left: popoverX,
|
||||
top: popoverY,
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${colors.popoverBorder}`,
|
||||
boxShadow:
|
||||
"0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)",
|
||||
width: CARD_W,
|
||||
backgroundColor: colors.popoverBg,
|
||||
}
|
||||
|
||||
const contentPadStyle: React.CSSProperties = {
|
||||
padding: 12,
|
||||
}
|
||||
|
||||
const contentTextStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
lineHeight: "135%",
|
||||
fontSize: 12,
|
||||
color: colors.popoverTextSecondary,
|
||||
}
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
borderTop: `1px solid ${colors.popoverBorder}`,
|
||||
}
|
||||
|
||||
const footerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
...dividerStyle,
|
||||
}
|
||||
|
||||
const idRowStyle: React.CSSProperties = {
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
...dividerStyle,
|
||||
}
|
||||
|
||||
const shortcutsPanelStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors.popoverBorder}`,
|
||||
backgroundColor: colors.popoverBg,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle}>
|
||||
<svg aria-hidden="true" style={svgStyle}>
|
||||
<path
|
||||
d={connectorPath}
|
||||
fill="none"
|
||||
stroke={colors.accent}
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div style={popoverContainerStyle}>
|
||||
<div style={cardStyle}>
|
||||
{hasChain ? (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
colors={colors}
|
||||
currentId={node.id}
|
||||
onSelect={onSelectNode}
|
||||
/>
|
||||
) : (
|
||||
<div style={contentPadStyle}>
|
||||
<p style={contentTextStyle}>
|
||||
{truncate(content, 100) || "No content"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memoryMeta && hasForgetInfo && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
...dividerStyle,
|
||||
}}
|
||||
>
|
||||
{memoryMeta.forgetAfter && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: colors.memBorderExpiring,
|
||||
}}
|
||||
>
|
||||
Expires:{" "}
|
||||
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.forgetReason && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: colors.popoverTextMuted,
|
||||
}}
|
||||
>
|
||||
Reason: {memoryMeta.forgetReason}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: colors.memBorderForgotten,
|
||||
}}
|
||||
>
|
||||
Forgotten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={footerStyle}>
|
||||
{memoryMeta ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: memoryMeta.isForgotten
|
||||
? colors.memBorderForgotten
|
||||
: memoryMeta.isLatest
|
||||
? colors.memBorderRecent
|
||||
: colors.popoverTextSecondary,
|
||||
}}
|
||||
>
|
||||
v{memoryMeta.version}{" "}
|
||||
{memoryMeta.isForgotten
|
||||
? "Forgotten"
|
||||
: memoryMeta.isLatest
|
||||
? "Latest"
|
||||
: "Superseded"}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.popoverTextSecondary,
|
||||
}}
|
||||
>
|
||||
{docData?.type || "document"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.popoverTextSecondary,
|
||||
}}
|
||||
>
|
||||
{docData?.memories?.length ?? 0} memories
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={idRowStyle}>
|
||||
{isMemory ? (
|
||||
<CopyableId colors={colors} label="Memory" value={node.id} />
|
||||
) : (
|
||||
<CopyableId colors={colors} label="Document" value={node.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={shortcutsPanelStyle}>
|
||||
{isMemory && (
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="↑"
|
||||
label={hasChain ? "Older version" : "Go to document"}
|
||||
onClick={onNavigateUp}
|
||||
/>
|
||||
)}
|
||||
{(isMemory ? hasChain : true) && (
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="↓"
|
||||
label={isMemory ? "Newer version" : "Go to memory"}
|
||||
onClick={onNavigateDown}
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="→"
|
||||
label={isMemory ? "Next memory" : "Next document"}
|
||||
onClick={onNavigateNext}
|
||||
/>
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="←"
|
||||
label={isMemory ? "Prev memory" : "Prev document"}
|
||||
onClick={onNavigatePrev}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
|
||||
// Backdrop styles
|
||||
export const backdrop = style({
|
||||
position: "fixed",
|
||||
zIndex: 999,
|
||||
pointerEvents: "auto",
|
||||
backgroundColor: "transparent",
|
||||
})
|
||||
|
||||
export const backdropFullscreen = style({
|
||||
inset: 0,
|
||||
})
|
||||
|
||||
// Popover container
|
||||
export const popoverContainer = style({
|
||||
position: "fixed",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
width: "320px",
|
||||
zIndex: 1000,
|
||||
pointerEvents: "auto",
|
||||
boxShadow:
|
||||
"0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
|
||||
})
|
||||
|
||||
// Layout
|
||||
export const contentContainer = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
})
|
||||
|
||||
export const header = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "4px",
|
||||
})
|
||||
|
||||
export const headerTitle = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
})
|
||||
|
||||
export const headerIcon = style({
|
||||
color: "rgba(148, 163, 184, 1)",
|
||||
})
|
||||
|
||||
export const headerIconMemory = style({
|
||||
color: "rgb(96, 165, 250)",
|
||||
})
|
||||
|
||||
export const title = style({
|
||||
fontSize: "16px",
|
||||
fontWeight: "700",
|
||||
color: "white",
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
// Close button
|
||||
export const closeButton = style({
|
||||
padding: "4px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "rgba(148, 163, 184, 1)",
|
||||
cursor: "pointer",
|
||||
fontSize: "16px",
|
||||
lineHeight: "1",
|
||||
transition: "color 0.2s",
|
||||
":hover": {
|
||||
color: "white",
|
||||
},
|
||||
})
|
||||
|
||||
// Sections
|
||||
export const sectionsContainer = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
})
|
||||
|
||||
export const fieldLabel = style({
|
||||
fontSize: "11px",
|
||||
color: "rgba(148, 163, 184, 0.8)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: "4px",
|
||||
})
|
||||
|
||||
export const fieldValue = style({
|
||||
fontSize: "14px",
|
||||
color: "rgba(203, 213, 225, 1)",
|
||||
margin: 0,
|
||||
lineHeight: "1.4",
|
||||
})
|
||||
|
||||
export const summaryValue = style({
|
||||
fontSize: "14px",
|
||||
color: "rgba(203, 213, 225, 1)",
|
||||
margin: 0,
|
||||
lineHeight: "1.4",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
})
|
||||
|
||||
// Link
|
||||
export const link = style({
|
||||
fontSize: "14px",
|
||||
color: "rgb(129, 140, 248)",
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
transition: "color 0.2s",
|
||||
":hover": {
|
||||
color: "rgb(165, 180, 252)",
|
||||
},
|
||||
})
|
||||
|
||||
// Footer
|
||||
export const footer = style({
|
||||
paddingTop: "12px",
|
||||
borderTop: "1px solid rgba(71, 85, 105, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
fontSize: "12px",
|
||||
color: "rgba(148, 163, 184, 1)",
|
||||
})
|
||||
|
||||
export const footerItem = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
})
|
||||
|
||||
export const footerItemId = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
})
|
||||
|
||||
export const idText = style({
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
})
|
||||
|
||||
// Memory-specific styles
|
||||
export const forgottenBadge = style({
|
||||
marginTop: "8px",
|
||||
padding: "4px 8px",
|
||||
background: "rgba(220, 38, 38, 0.15)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
color: "rgba(248, 113, 113, 1)",
|
||||
display: "inline-block",
|
||||
})
|
||||
|
||||
export const expiresText = style({
|
||||
fontSize: "12px",
|
||||
color: "rgba(148, 163, 184, 1)",
|
||||
margin: "8px 0 0 0",
|
||||
lineHeight: "1.4",
|
||||
})
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useEffect } from "react"
|
||||
import type { GraphNode } from "@/types"
|
||||
import * as styles from "./node-popover.css"
|
||||
|
||||
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 backdropClassName = containerBounds
|
||||
? styles.backdrop
|
||||
: `${styles.backdrop} ${styles.backdropFullscreen}`
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
onBackdropClick?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible backdrop to catch clicks outside */}
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
className={backdropClassName}
|
||||
style={backdropStyle}
|
||||
/>
|
||||
|
||||
{/* Popover content */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
|
||||
className={styles.popoverContainer}
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
}}
|
||||
>
|
||||
{node.type === "document" ? (
|
||||
// Document popover
|
||||
<div className={styles.contentContainer}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTitle}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={styles.headerIcon}
|
||||
>
|
||||
<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={styles.title}>Document</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className={styles.sectionsContainer}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>Title</div>
|
||||
<p className={styles.fieldValue}>
|
||||
{(node.data as any).title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary - truncated to 2 lines */}
|
||||
{(node.data as any).summary && (
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>Summary</div>
|
||||
<p className={styles.summaryValue}>
|
||||
{(node.data as any).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>Type</div>
|
||||
<p className={styles.fieldValue}>
|
||||
{(node.data as any).type || "Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Memory Count */}
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>Memory Count</div>
|
||||
<p className={styles.fieldValue}>
|
||||
{(node.data as any).memoryEntries?.length || 0} memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
{((node.data as any).url || (node.data as any).customId) && (
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>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={styles.link}
|
||||
>
|
||||
<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={styles.footer}>
|
||||
<div className={styles.footerItem}>
|
||||
<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={styles.footerItemId}>
|
||||
<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={styles.idText}>{node.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Memory popover
|
||||
<div className={styles.contentContainer}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTitle}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={styles.headerIconMemory}
|
||||
>
|
||||
<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={styles.title}>Memory</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className={styles.sectionsContainer}>
|
||||
{/* Memory content */}
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>Memory</div>
|
||||
<p className={styles.fieldValue}>
|
||||
{(node.data as any).memory ||
|
||||
(node.data as any).content ||
|
||||
"No content"}
|
||||
</p>
|
||||
{(node.data as any).isForgotten && (
|
||||
<div className={styles.forgottenBadge}>Forgotten</div>
|
||||
)}
|
||||
{/* Expires (inline with memory if exists) */}
|
||||
{(node.data as any).forgetAfter && (
|
||||
<p className={styles.expiresText}>
|
||||
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={styles.fieldLabel}>Space</div>
|
||||
<p className={styles.fieldValue}>
|
||||
{(node.data as any).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerItem}>
|
||||
<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={styles.footerItemId}>
|
||||
<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={styles.idText}>{node.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
import { style, keyframes } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
const spin = keyframes({
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" },
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown container
|
||||
*/
|
||||
export const container = style({
|
||||
position: "relative",
|
||||
})
|
||||
|
||||
/**
|
||||
* Main trigger button with gradient border effect
|
||||
*/
|
||||
export const trigger = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[3],
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[3],
|
||||
paddingBottom: themeContract.space[3],
|
||||
borderRadius: themeContract.radii.xl,
|
||||
border: "2px solid transparent",
|
||||
backgroundImage:
|
||||
"linear-gradient(#1a1f29, #1a1f29), linear-gradient(150.262deg, #A4E8F5 0%, #267FFA 26%, #464646 49%, #747474 70%, #A4E8F5 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.15)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
transition: themeContract.transitions.normal,
|
||||
cursor: "pointer",
|
||||
minWidth: "15rem", // min-w-60 = 240px = 15rem
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const triggerIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
})
|
||||
|
||||
export const triggerContent = style({
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
})
|
||||
|
||||
export const triggerLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
})
|
||||
|
||||
export const triggerSubtext = style({
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
color: themeContract.colors.text.muted,
|
||||
})
|
||||
|
||||
export const triggerChevron = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
color: themeContract.colors.text.secondary,
|
||||
transition: "transform 200ms ease",
|
||||
})
|
||||
|
||||
export const triggerChevronOpen = style({
|
||||
transform: "rotate(180deg)",
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown menu
|
||||
*/
|
||||
export const dropdown = style({
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: themeContract.space[2],
|
||||
background: "rgba(15, 23, 42, 0.95)", // slate-900/95
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40
|
||||
borderRadius: themeContract.radii.xl,
|
||||
boxShadow:
|
||||
"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl
|
||||
zIndex: 20,
|
||||
overflow: "hidden",
|
||||
})
|
||||
|
||||
export const dropdownInner = style({
|
||||
padding: themeContract.space[1],
|
||||
})
|
||||
|
||||
/**
|
||||
* Search container and form
|
||||
*/
|
||||
export const searchContainer = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
padding: themeContract.space[2],
|
||||
borderBottom: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40
|
||||
})
|
||||
|
||||
export const searchForm = style({
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: themeContract.space[2],
|
||||
})
|
||||
|
||||
export const searchButton = style({
|
||||
color: themeContract.colors.text.muted,
|
||||
padding: themeContract.space[1],
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
"&:disabled": {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const searchIcon = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
})
|
||||
|
||||
export const searchInput = style({
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.secondary,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
|
||||
"::placeholder": {
|
||||
color: themeContract.colors.text.muted,
|
||||
},
|
||||
})
|
||||
|
||||
export const searchSpinner = style({
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid rgba(148, 163, 184, 0.3)", // slate-400 with opacity
|
||||
borderTopColor: "rgb(148, 163, 184)", // slate-400
|
||||
animation: `${spin} 1s linear infinite`,
|
||||
})
|
||||
|
||||
export const searchClearButton = style({
|
||||
color: themeContract.colors.text.muted,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
transition: themeContract.transitions.normal,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown list container
|
||||
*/
|
||||
export const dropdownList = style({
|
||||
maxHeight: "16rem", // max-h-64
|
||||
overflowY: "auto",
|
||||
})
|
||||
|
||||
/**
|
||||
* Dropdown items
|
||||
*/
|
||||
const dropdownItemBase = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: themeContract.space[3],
|
||||
paddingRight: themeContract.space[3],
|
||||
paddingTop: themeContract.space[2],
|
||||
paddingBottom: themeContract.space[2],
|
||||
borderRadius: themeContract.radii.lg,
|
||||
textAlign: "left",
|
||||
transition: themeContract.transitions.normal,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
})
|
||||
|
||||
export const dropdownItem = style([
|
||||
dropdownItemBase,
|
||||
{
|
||||
color: themeContract.colors.text.secondary,
|
||||
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
export const dropdownItemActive = style([
|
||||
dropdownItemBase,
|
||||
{
|
||||
backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20
|
||||
color: "rgb(147, 197, 253)", // blue-300
|
||||
},
|
||||
])
|
||||
|
||||
export const dropdownItemHighlighted = style([
|
||||
dropdownItemBase,
|
||||
{
|
||||
backgroundColor: "rgba(51, 65, 85, 0.7)", // slate-700/70
|
||||
color: themeContract.colors.text.secondary,
|
||||
},
|
||||
])
|
||||
|
||||
export const dropdownItemLabel = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
flex: 1,
|
||||
})
|
||||
|
||||
export const dropdownItemLabelTruncate = style({
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
})
|
||||
|
||||
export const dropdownItemBadge = style({
|
||||
backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50
|
||||
color: themeContract.colors.text.secondary,
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
marginLeft: themeContract.space[2],
|
||||
})
|
||||
|
||||
/**
|
||||
* Empty state message
|
||||
*/
|
||||
export const emptyState = style({
|
||||
paddingLeft: themeContract.space[3],
|
||||
paddingRight: themeContract.space[3],
|
||||
paddingTop: themeContract.space[2],
|
||||
paddingBottom: themeContract.space[2],
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
color: themeContract.colors.text.muted,
|
||||
textAlign: "center",
|
||||
})
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Badge } from "@/ui/badge"
|
||||
import { ChevronDown, Eye, Search, X } from "lucide-react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import type { SpacesDropdownProps } from "@/types"
|
||||
import * as styles from "./spaces-dropdown.css"
|
||||
|
||||
export const SpacesDropdown = memo<SpacesDropdownProps>(
|
||||
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Clear search query and reset highlighted index when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery("")
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Filter spaces based on search query (client-side)
|
||||
const filteredSpaces = searchQuery
|
||||
? availableSpaces.filter((space) =>
|
||||
space.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: availableSpaces
|
||||
|
||||
const totalMemories = Object.values(spaceMemoryCounts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
)
|
||||
|
||||
// Total items including "Latest" option
|
||||
const totalItems = filteredSpaces.length + 1
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && highlightedIndex < totalItems) {
|
||||
const element = itemRefs.current.get(highlightedIndex)
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex, totalItems])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0))
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1))
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
if (highlightedIndex === 0) {
|
||||
onSpaceChange("all")
|
||||
setIsOpen(false)
|
||||
} else if (
|
||||
highlightedIndex > 0 &&
|
||||
highlightedIndex <= filteredSpaces.length
|
||||
) {
|
||||
const selectedSpace = filteredSpaces[highlightedIndex - 1]
|
||||
if (selectedSpace) {
|
||||
onSpaceChange(selectedSpace)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={dropdownRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<button
|
||||
className={styles.trigger}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
{/*@ts-ignore */}
|
||||
<Eye className={styles.triggerIcon} />
|
||||
<div className={styles.triggerContent}>
|
||||
<span className={styles.triggerLabel}>
|
||||
{selectedSpace === "all"
|
||||
? "Latest"
|
||||
: selectedSpace || "Select space"}
|
||||
</span>
|
||||
<div className={styles.triggerSubtext}>
|
||||
{selectedSpace === "all"
|
||||
? ""
|
||||
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
|
||||
</div>
|
||||
</div>
|
||||
{/*@ts-ignore */}
|
||||
<ChevronDown
|
||||
className={`${styles.triggerChevron} ${isOpen ? styles.triggerChevronOpen : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.dropdownInner}>
|
||||
{/* Search Input - Always show for filtering */}
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchForm}>
|
||||
{/*@ts-ignore */}
|
||||
<Search className={styles.searchIcon} />
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className={styles.searchClearButton}
|
||||
onClick={() => setSearchQuery("")}
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
{/*@ts-ignore */}
|
||||
<X className={styles.searchIcon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spaces List */}
|
||||
<div className={styles.dropdownList}>
|
||||
{/* Always show "Latest" option */}
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(0, el)
|
||||
}}
|
||||
className={
|
||||
selectedSpace === "all"
|
||||
? styles.dropdownItemActive
|
||||
: highlightedIndex === 0
|
||||
? styles.dropdownItemHighlighted
|
||||
: styles.dropdownItem
|
||||
}
|
||||
onClick={() => {
|
||||
onSpaceChange("all")
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.dropdownItemLabel}>Latest</span>
|
||||
<Badge className={styles.dropdownItemBadge}>
|
||||
{totalMemories}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Show all spaces, filtered by search query */}
|
||||
{filteredSpaces.length > 0
|
||||
? filteredSpaces.map((space, index) => {
|
||||
const itemIndex = index + 1
|
||||
return (
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(itemIndex, el)
|
||||
}}
|
||||
className={
|
||||
selectedSpace === space
|
||||
? styles.dropdownItemActive
|
||||
: highlightedIndex === itemIndex
|
||||
? styles.dropdownItemHighlighted
|
||||
: styles.dropdownItem
|
||||
}
|
||||
key={space}
|
||||
onClick={() => {
|
||||
onSpaceChange(space)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(itemIndex)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.dropdownItemLabelTruncate}>
|
||||
{space}
|
||||
</span>
|
||||
<Badge className={styles.dropdownItemBadge}>
|
||||
{spaceMemoryCounts[space] || 0}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
: searchQuery && (
|
||||
<div className={styles.emptyState}>
|
||||
No spaces found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SpacesDropdown.displayName = "SpacesDropdown"
|
||||
|
|
@ -1,138 +1,66 @@
|
|||
// Enhanced glass-morphism color palette
|
||||
export const colors = {
|
||||
background: {
|
||||
primary: "#0f1419", // Deep dark blue-gray
|
||||
secondary: "#1a1f29", // Slightly lighter
|
||||
accent: "#252a35", // Card backgrounds
|
||||
},
|
||||
document: {
|
||||
primary: "rgba(255, 255, 255, 0.21)", // Subtle glass white
|
||||
secondary: "rgba(255, 255, 255, 0.31)", // More visible
|
||||
accent: "rgba(255, 255, 255, 0.31)", // Hover state
|
||||
border: "rgba(255, 255, 255, 0.6)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
|
||||
},
|
||||
memory: {
|
||||
primary: "rgba(147, 196, 253, 0.21)", // Subtle glass blue
|
||||
secondary: "rgba(147, 196, 253, 0.31)", // More visible
|
||||
accent: "rgba(147, 197, 253, 0.31)", // Hover state
|
||||
border: "rgba(147, 196, 253, 0.6)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
|
||||
},
|
||||
connection: {
|
||||
weak: "rgba(35, 189, 255, 0.3)", // subtle
|
||||
memory: "rgba(148, 163, 184, 0.35)", // Very subtle
|
||||
medium: "rgba(35, 189, 255, 0.6)", // Medium visibility
|
||||
strong: "rgba(35, 189, 255, 0.9)", // Strong connection
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff", // Pure white
|
||||
secondary: "#e2e8f0", // Light gray
|
||||
muted: "#94a3b8", // Medium gray
|
||||
},
|
||||
accent: {
|
||||
primary: "rgba(59, 130, 246, 0.7)", // Clean blue
|
||||
secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
|
||||
glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
|
||||
amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
|
||||
emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
|
||||
},
|
||||
status: {
|
||||
forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
|
||||
expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
|
||||
new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
|
||||
},
|
||||
relations: {
|
||||
updates: "rgba(147, 77, 253, 0.5)", // purple
|
||||
extends: "rgba(16, 185, 129, 0.5)", // green
|
||||
derives: "rgba(147, 197, 253, 0.5)", // blue
|
||||
},
|
||||
}
|
||||
import type { GraphThemeColors } from "./types"
|
||||
|
||||
export const LAYOUT_CONSTANTS = {
|
||||
centerX: 400,
|
||||
centerY: 300,
|
||||
clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
|
||||
spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
|
||||
documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
|
||||
minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
|
||||
memoryClusterRadius: 300,
|
||||
}
|
||||
export const MEMORY_BORDER_KEYS = {
|
||||
forgotten: "memBorderForgotten",
|
||||
expiring: "memBorderExpiring",
|
||||
recent: "memBorderRecent",
|
||||
default: "memStrokeDefault",
|
||||
} as const
|
||||
|
||||
// Similarity calculation configuration
|
||||
export const SIMILARITY_CONFIG = {
|
||||
threshold: 0.725, // Minimum similarity (72.5%) to create edge
|
||||
maxComparisonsPerDoc: 10, // k-NN: each doc compares with 10 neighbors (optimized for performance)
|
||||
}
|
||||
|
||||
// D3-Force simulation configuration
|
||||
export const FORCE_CONFIG = {
|
||||
// Link force (spring between connected nodes)simil
|
||||
linkStrength: {
|
||||
docMemory: 0.8, // Strong for doc-memory connections
|
||||
version: 1.0, // Strongest for version chains
|
||||
docDocBase: 0.3, // Base for doc-doc similarity
|
||||
docMemory: 0.35,
|
||||
version: 0.6,
|
||||
docDocBase: 0.0,
|
||||
fallback: 0.05,
|
||||
},
|
||||
linkDistance: 300, // Desired spring length
|
||||
|
||||
// Charge force (repulsion between nodes)
|
||||
chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push
|
||||
|
||||
// Collision force (prevents node overlap)
|
||||
collisionRadius: {
|
||||
document: 80, // Collision radius for document nodes
|
||||
memory: 40, // Collision radius for memory nodes
|
||||
},
|
||||
|
||||
// Simulation behavior
|
||||
alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown)
|
||||
alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this)
|
||||
velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement
|
||||
alphaTarget: 0.3, // Target alpha when reheating (on drag start)
|
||||
linkDistance: 300,
|
||||
docMemoryDistance: 180,
|
||||
chargeStrength: -2000,
|
||||
collisionRadius: { document: 70, memory: 35 },
|
||||
collisionStrength: 0.7,
|
||||
centeringStrength: 0.06,
|
||||
alphaDecay: 0.025,
|
||||
alphaMin: 0.001,
|
||||
velocityDecay: 0.45,
|
||||
alphaTarget: 0.3,
|
||||
preSettleTicks: 150,
|
||||
}
|
||||
|
||||
// Graph view settings
|
||||
export const GRAPH_SETTINGS = {
|
||||
console: {
|
||||
initialZoom: 0.8, // Higher zoom for console - better overview
|
||||
initialPanX: 0,
|
||||
initialPanY: 0,
|
||||
},
|
||||
consumer: {
|
||||
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
|
||||
initialPanX: 400, // Pan towards center to compensate for larger layout
|
||||
initialPanY: 300, // Pan towards center to compensate for larger layout
|
||||
},
|
||||
console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 },
|
||||
consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 },
|
||||
}
|
||||
|
||||
// Animation settings
|
||||
export const ANIMATION = {
|
||||
// Dim effect duration - shortened for better UX
|
||||
dimDuration: 1500, // milliseconds
|
||||
dimDuration: 1500,
|
||||
}
|
||||
|
||||
// Responsive positioning for different app variants
|
||||
export const POSITIONING = {
|
||||
console: {
|
||||
legend: {
|
||||
desktop: "bottom-4 right-4",
|
||||
mobile: "bottom-4 right-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "top-4 left-4",
|
||||
viewToggle: "", // Not used in console
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
consumer: {
|
||||
legend: {
|
||||
desktop: "top-18 right-4",
|
||||
mobile: "bottom-[180px] left-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "", // Hidden in consumer
|
||||
viewToggle: "top-4 right-4", // Consumer has view toggle
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
export const DEFAULT_COLORS: GraphThemeColors = {
|
||||
bg: "#0f1419",
|
||||
docFill: "#1B1F24",
|
||||
docStroke: "#2A2F36",
|
||||
docInnerFill: "#13161A",
|
||||
memFill: "#0D2034",
|
||||
memFillHover: "#112840",
|
||||
memStrokeDefault: "#3B73B8",
|
||||
accent: "#3B73B8",
|
||||
textPrimary: "#ffffff",
|
||||
textSecondary: "#e2e8f0",
|
||||
textMuted: "#94a3b8",
|
||||
edgeDerives: "#7094B8",
|
||||
edgeUpdates: "#A78BFA",
|
||||
edgeExtends: "#38BDF8",
|
||||
memBorderForgotten: "#EF4444",
|
||||
memBorderExpiring: "#F59E0B",
|
||||
memBorderRecent: "#10B981",
|
||||
glowColor: "#3B73B8",
|
||||
iconColor: "#3B73B8",
|
||||
popoverBg: "#1a1f29",
|
||||
popoverBorder: "#2A2F36",
|
||||
popoverTextPrimary: "#ffffff",
|
||||
popoverTextSecondary: "#e2e8f0",
|
||||
popoverTextMuted: "#94a3b8",
|
||||
controlBg: "#1a1f29",
|
||||
controlBorder: "#2A2F36",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react"
|
||||
import * as d3 from "d3-force"
|
||||
import { FORCE_CONFIG } from "@/constants"
|
||||
import type { GraphNode, GraphEdge } from "@/types"
|
||||
|
||||
export interface ForceSimulationControls {
|
||||
/** The d3 simulation instance */
|
||||
simulation: d3.Simulation<GraphNode, GraphEdge> | null
|
||||
/** Reheat the simulation (call on drag start) */
|
||||
reheat: () => void
|
||||
/** Cool down the simulation (call on drag end) */
|
||||
coolDown: () => void
|
||||
/** Check if simulation is currently active */
|
||||
isActive: () => boolean
|
||||
/** Stop the simulation completely */
|
||||
stop: () => void
|
||||
/** Get current alpha value */
|
||||
getAlpha: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage d3-force simulation lifecycle
|
||||
* Simulation only runs during interactions (drag) for performance
|
||||
*/
|
||||
export function useForceSimulation(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
onTick: () => void,
|
||||
enabled = true,
|
||||
): ForceSimulationControls {
|
||||
const simulationRef = useRef<d3.Simulation<GraphNode, GraphEdge> | null>(null)
|
||||
|
||||
// Initialize simulation ONCE
|
||||
useEffect(() => {
|
||||
if (!enabled || nodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only create simulation once
|
||||
if (!simulationRef.current) {
|
||||
const simulation = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.alphaDecay(FORCE_CONFIG.alphaDecay)
|
||||
.alphaMin(FORCE_CONFIG.alphaMin)
|
||||
.velocityDecay(FORCE_CONFIG.velocityDecay)
|
||||
.on("tick", () => {
|
||||
// Trigger re-render by calling onTick
|
||||
// D3 has already mutated node.x and node.y
|
||||
onTick()
|
||||
})
|
||||
|
||||
// Configure forces
|
||||
// 1. Link force - spring connections between nodes
|
||||
simulation.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<GraphNode, GraphEdge>(edges)
|
||||
.id((d) => d.id)
|
||||
.distance(FORCE_CONFIG.linkDistance)
|
||||
.strength((link) => {
|
||||
// Different strength based on edge type
|
||||
if (link.edgeType === "doc-memory") {
|
||||
return FORCE_CONFIG.linkStrength.docMemory
|
||||
}
|
||||
if (link.edgeType === "version") {
|
||||
return FORCE_CONFIG.linkStrength.version
|
||||
}
|
||||
// doc-doc: variable strength based on similarity
|
||||
return link.similarity * FORCE_CONFIG.linkStrength.docDocBase
|
||||
}),
|
||||
)
|
||||
|
||||
// 2. Charge force - repulsion between nodes
|
||||
simulation.force(
|
||||
"charge",
|
||||
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
|
||||
)
|
||||
|
||||
// 3. Collision force - prevent node overlap
|
||||
simulation.force(
|
||||
"collide",
|
||||
d3
|
||||
.forceCollide<GraphNode>()
|
||||
.radius((d) =>
|
||||
d.type === "document"
|
||||
? FORCE_CONFIG.collisionRadius.document
|
||||
: FORCE_CONFIG.collisionRadius.memory,
|
||||
)
|
||||
.strength(0.7),
|
||||
)
|
||||
|
||||
// 4. forceX and forceY - weak centering forces (like reference code)
|
||||
simulation.force("x", d3.forceX().strength(0.05))
|
||||
simulation.force("y", d3.forceY().strength(0.05))
|
||||
|
||||
// Store reference
|
||||
simulationRef.current = simulation
|
||||
|
||||
// Quick pre-settle to avoid initial chaos, then animate the rest
|
||||
// This gives best of both worlds: fast initial render + smooth settling
|
||||
simulation.alpha(1)
|
||||
for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms
|
||||
simulation.alphaTarget(0).restart() // Continue animating to full stability
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.stop()
|
||||
simulationRef.current = null
|
||||
}
|
||||
}
|
||||
// Only run on mount/unmount, not when nodes/edges/onTick change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled])
|
||||
|
||||
// Update simulation nodes and edges together to prevent race conditions
|
||||
useEffect(() => {
|
||||
if (!simulationRef.current) return
|
||||
|
||||
// Update nodes
|
||||
if (nodes.length > 0) {
|
||||
simulationRef.current.nodes(nodes)
|
||||
}
|
||||
|
||||
// Update edges
|
||||
if (edges.length > 0) {
|
||||
const linkForce =
|
||||
simulationRef.current.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
|
||||
if (linkForce) {
|
||||
linkForce.links(edges)
|
||||
}
|
||||
}
|
||||
}, [nodes, edges])
|
||||
|
||||
// Reheat simulation (called on drag start)
|
||||
const reheat = useCallback(() => {
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cool down simulation (called on drag end)
|
||||
const coolDown = useCallback(() => {
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.alphaTarget(0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check if simulation is active
|
||||
const isActive = useCallback(() => {
|
||||
if (!simulationRef.current) return false
|
||||
return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin
|
||||
}, [])
|
||||
|
||||
// Stop simulation completely
|
||||
const stop = useCallback(() => {
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get current alpha
|
||||
const getAlpha = useCallback(() => {
|
||||
if (!simulationRef.current) return 0
|
||||
return simulationRef.current.alpha()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
simulation: simulationRef.current,
|
||||
reheat,
|
||||
coolDown,
|
||||
isActive,
|
||||
stop,
|
||||
getAlpha,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,471 +1,245 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
calculateSemanticSimilarity,
|
||||
getConnectionVisualProps,
|
||||
getMagicalConnectionColor,
|
||||
} from "@/lib/similarity"
|
||||
import { useMemo, useRef, useEffect } from "react"
|
||||
import { colors, LAYOUT_CONSTANTS, SIMILARITY_CONFIG } from "@/constants"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
DocumentNodeData,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
MemoryEntry,
|
||||
MemoryRelation,
|
||||
} from "@/types"
|
||||
GraphThemeColors,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const MEMORY_ORBIT_BASE = 200
|
||||
|
||||
export function getMemoryBorderColor(
|
||||
mem: GraphApiMemory,
|
||||
colors: GraphThemeColors,
|
||||
): string {
|
||||
if (mem.isForgotten) return colors.memBorderForgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return colors.memBorderRecent
|
||||
return colors.memStrokeDefault
|
||||
}
|
||||
|
||||
export function getEdgeVisualProps(edgeType: string) {
|
||||
switch (edgeType) {
|
||||
case "derives":
|
||||
return { opacity: 0.4, thickness: 1.2 }
|
||||
case "updates":
|
||||
return { opacity: 0.7, thickness: 2 }
|
||||
case "extends":
|
||||
return { opacity: 0.55, thickness: 1.5 }
|
||||
default:
|
||||
return { opacity: 0.4, thickness: 1.2 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple deterministic hash of a string to a number in [0, 1).
|
||||
* Used for initial node placement so the force simulation has a
|
||||
* deterministic starting layout.
|
||||
*/
|
||||
function hashToUnit(str: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0
|
||||
}
|
||||
return ((h >>> 0) % 10000) / 10000
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes graph edges from documents.
|
||||
* Extracted from the hook for testability.
|
||||
*/
|
||||
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
// 1. Derives edges: document -> memory (structural)
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps("derives"),
|
||||
edgeType: "derives",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
let relations: Record<string, string> = {}
|
||||
|
||||
// Defensive: API may return unexpected types at runtime
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
// Legacy fallback: parentMemoryId implies "updates"
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
result.push({
|
||||
id: `rel-${targetId}-${mem.id}`,
|
||||
source: targetId,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps(edgeType),
|
||||
edgeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function useGraphData(
|
||||
data: DocumentsResponse | null,
|
||||
selectedSpace: string,
|
||||
nodePositions: Map<
|
||||
string,
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
parentDocId?: string
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
}
|
||||
>,
|
||||
documents: GraphApiDocument[],
|
||||
draggingNodeId: string | null,
|
||||
memoryLimit?: number,
|
||||
maxNodes?: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
colors: GraphThemeColors,
|
||||
) {
|
||||
// Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy)
|
||||
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
|
||||
|
||||
// Cleanup nodeCache to prevent memory leak
|
||||
useEffect(() => {
|
||||
if (!data?.documents) return
|
||||
if (!documents || documents.length === 0) return
|
||||
|
||||
// Build set of current node IDs
|
||||
const currentNodeIds = new Set<string>()
|
||||
data.documents.forEach((doc) => {
|
||||
currentNodeIds.add(doc.id)
|
||||
doc.memoryEntries.forEach((mem) => {
|
||||
currentNodeIds.add(`${mem.id}`)
|
||||
})
|
||||
})
|
||||
const currentIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
currentIds.add(doc.id)
|
||||
for (const mem of doc.memories) currentIds.add(mem.id)
|
||||
}
|
||||
|
||||
// Remove stale nodes from cache
|
||||
for (const [id] of nodeCache.current.entries()) {
|
||||
if (!currentNodeIds.has(id)) {
|
||||
nodeCache.current.delete(id)
|
||||
}
|
||||
if (!currentIds.has(id)) nodeCache.current.delete(id)
|
||||
}
|
||||
}, [data, selectedSpace])
|
||||
}, [documents])
|
||||
|
||||
// Memo 1: Filter documents by selected space and apply node limits
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!data?.documents) return []
|
||||
const nodes = useMemo(() => {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
// Sort documents by most recent first
|
||||
const sortedDocs = [...data.documents].sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
|
||||
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
|
||||
return dateB - dateA // Most recent first
|
||||
})
|
||||
const result: GraphNode[] = []
|
||||
// Spiral layout: documents form a compact spiral core, memories orbit
|
||||
// around their parent documents. The force simulation then gently
|
||||
// pushes memories outward to create the constellation/starburst effect.
|
||||
const cx = canvasWidth / 2
|
||||
const cy = canvasHeight / 2
|
||||
const docCount = documents.length
|
||||
// Wide spiral so documents start well-separated. The simulation
|
||||
// refines positions but the initial spread prevents clustering.
|
||||
const spiralScale = Math.sqrt(docCount) * 60
|
||||
// Golden angle (~137.5 deg) produces optimal packing in a spiral
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
|
||||
|
||||
// Filter by space and prepare documents
|
||||
let processedDocs = sortedDocs.map((doc) => {
|
||||
let memories =
|
||||
selectedSpace === "all"
|
||||
? doc.memoryEntries
|
||||
: doc.memoryEntries.filter(
|
||||
(memory) =>
|
||||
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
|
||||
selectedSpace,
|
||||
)
|
||||
for (let docIdx = 0; docIdx < docCount; docIdx++) {
|
||||
const doc = documents[docIdx]
|
||||
const angle = docIdx * goldenAngle
|
||||
const radius = spiralScale * Math.sqrt((docIdx + 1) / docCount)
|
||||
const initialX = cx + Math.cos(angle) * radius
|
||||
const initialY = cy + Math.sin(angle) * radius
|
||||
|
||||
// Sort memories by relevance score (if available) or recency
|
||||
memories = memories.sort((a, b) => {
|
||||
// Prioritize sourceRelevanceScore if available
|
||||
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
|
||||
return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first
|
||||
}
|
||||
// Fall back to most recent
|
||||
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
|
||||
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
|
||||
return dateB - dateA // Most recent first
|
||||
})
|
||||
|
||||
return {
|
||||
...doc,
|
||||
memoryEntries: memories,
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
// Apply maxNodes limit using Option B (dynamic cap per document)
|
||||
if (maxNodes && maxNodes > 0) {
|
||||
const totalDocs = processedDocs.length
|
||||
if (totalDocs > 0) {
|
||||
// Calculate memories per document to stay within maxNodes budget
|
||||
const memoriesPerDoc = Math.floor(maxNodes / totalDocs)
|
||||
if (docNode) {
|
||||
docNode.data = docData
|
||||
docNode.borderColor = colors.docStroke
|
||||
docNode.isDragging = draggingNodeId === doc.id
|
||||
} else {
|
||||
docNode = {
|
||||
id: doc.id,
|
||||
type: "document",
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
data: docData,
|
||||
size: 50,
|
||||
borderColor: colors.docStroke,
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(doc.id, docNode)
|
||||
}
|
||||
result.push(docNode)
|
||||
|
||||
// If we need to limit, slice memories for each document
|
||||
if (memoriesPerDoc > 0) {
|
||||
let totalNodes = 0
|
||||
processedDocs = processedDocs.map((doc) => {
|
||||
// Limit memories to calculated amount per doc
|
||||
const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc)
|
||||
totalNodes += limitedMemories.length
|
||||
return {
|
||||
...doc,
|
||||
memoryEntries: limitedMemories,
|
||||
}
|
||||
})
|
||||
const memCount = doc.memories.length
|
||||
for (let i = 0; i < memCount; i++) {
|
||||
const mem = doc.memories[i]
|
||||
if (!mem) continue
|
||||
let memNode = nodeCache.current.get(mem.id)
|
||||
const memData: MemoryNodeData = {
|
||||
...mem,
|
||||
documentId: doc.id,
|
||||
content: mem.memory,
|
||||
}
|
||||
|
||||
// If we still have budget left, distribute remaining nodes to first docs
|
||||
let remainingBudget = maxNodes - totalNodes
|
||||
if (remainingBudget > 0) {
|
||||
for (
|
||||
let i = 0;
|
||||
i < processedDocs.length && remainingBudget > 0;
|
||||
i++
|
||||
) {
|
||||
const doc = processedDocs[i]
|
||||
if (!doc) continue
|
||||
const originalDoc = sortedDocs.find((d) => d.id === doc.id)
|
||||
if (!originalDoc) continue
|
||||
|
||||
const currentMemCount = doc.memoryEntries.length
|
||||
const originalMemCount = originalDoc.memoryEntries.filter(
|
||||
(m) =>
|
||||
selectedSpace === "all" ||
|
||||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
|
||||
selectedSpace,
|
||||
).length
|
||||
|
||||
// Can we add more memories to this doc?
|
||||
const canAdd = originalMemCount - currentMemCount
|
||||
if (canAdd > 0) {
|
||||
const toAdd = Math.min(canAdd, remainingBudget)
|
||||
const additionalMems = doc.memoryEntries.slice(
|
||||
0,
|
||||
currentMemCount + toAdd,
|
||||
)
|
||||
processedDocs[i] = {
|
||||
...doc,
|
||||
memoryEntries: originalDoc.memoryEntries
|
||||
.filter(
|
||||
(m) =>
|
||||
selectedSpace === "all" ||
|
||||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
|
||||
selectedSpace,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (
|
||||
a.sourceRelevanceScore != null &&
|
||||
b.sourceRelevanceScore != null
|
||||
) {
|
||||
return b.sourceRelevanceScore - a.sourceRelevanceScore
|
||||
}
|
||||
const dateA = new Date(
|
||||
a.updatedAt || a.createdAt,
|
||||
).getTime()
|
||||
const dateB = new Date(
|
||||
b.updatedAt || b.createdAt,
|
||||
).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, currentMemCount + toAdd),
|
||||
}
|
||||
remainingBudget -= toAdd
|
||||
}
|
||||
}
|
||||
}
|
||||
if (memNode) {
|
||||
memNode.data = memData
|
||||
memNode.borderColor = getMemoryBorderColor(mem, colors)
|
||||
memNode.isDragging = draggingNodeId === mem.id
|
||||
} else {
|
||||
// If memoriesPerDoc is 0, we need to limit the number of documents shown
|
||||
// Show at least 1 memory per document, up to maxNodes documents
|
||||
processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries: doc.memoryEntries.slice(0, 1),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply legacy memoryLimit if provided and a specific space is selected
|
||||
else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
|
||||
processedDocs = processedDocs.map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries: doc.memoryEntries.slice(0, memoryLimit),
|
||||
}))
|
||||
}
|
||||
|
||||
return processedDocs
|
||||
}, [data, selectedSpace, memoryLimit, maxNodes])
|
||||
|
||||
// Memo 2: Calculate similarity edges using k-NN approach
|
||||
const similarityEdges = useMemo(() => {
|
||||
const edges: GraphEdge[] = []
|
||||
|
||||
// k-NN: Each document compares with k neighbors (configurable)
|
||||
const { maxComparisonsPerDoc, threshold } = SIMILARITY_CONFIG
|
||||
|
||||
for (let i = 0; i < filteredDocuments.length; i++) {
|
||||
const docI = filteredDocuments[i]
|
||||
if (!docI) continue
|
||||
|
||||
// Only compare with next k documents (k-nearest neighbors approach)
|
||||
const endIdx = Math.min(
|
||||
i + maxComparisonsPerDoc + 1,
|
||||
filteredDocuments.length,
|
||||
)
|
||||
|
||||
for (let j = i + 1; j < endIdx; j++) {
|
||||
const docJ = filteredDocuments[j]
|
||||
if (!docJ) continue
|
||||
|
||||
const sim = calculateSemanticSimilarity(
|
||||
docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
|
||||
docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
|
||||
)
|
||||
|
||||
if (sim > threshold) {
|
||||
edges.push({
|
||||
id: `doc-doc-${docI.id}-${docJ.id}`,
|
||||
source: docI.id,
|
||||
target: docJ.id,
|
||||
similarity: sim,
|
||||
visualProps: getConnectionVisualProps(sim),
|
||||
color: getMagicalConnectionColor(sim, 200),
|
||||
edgeType: "doc-doc",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}, [filteredDocuments])
|
||||
|
||||
// Memo 3: Build full graph data (nodes + edges)
|
||||
return useMemo(() => {
|
||||
if (!data?.documents || filteredDocuments.length === 0) {
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
|
||||
const allNodes: GraphNode[] = []
|
||||
const allEdges: GraphEdge[] = []
|
||||
|
||||
// Group documents by space for better clustering
|
||||
const documentsBySpace = new Map<string, typeof filteredDocuments>()
|
||||
filteredDocuments.forEach((doc) => {
|
||||
const docSpace =
|
||||
doc.memoryEntries[0]?.spaceContainerTag ??
|
||||
doc.memoryEntries[0]?.spaceId ??
|
||||
"default"
|
||||
if (!documentsBySpace.has(docSpace)) {
|
||||
documentsBySpace.set(docSpace, [])
|
||||
}
|
||||
const spaceDocsArr = documentsBySpace.get(docSpace)
|
||||
if (spaceDocsArr) {
|
||||
spaceDocsArr.push(doc)
|
||||
}
|
||||
})
|
||||
|
||||
// Enhanced Layout with Space Separation
|
||||
const { centerX, centerY, clusterRadius } = LAYOUT_CONSTANTS
|
||||
|
||||
/* 1. Build DOCUMENT nodes with space-aware clustering */
|
||||
const documentNodes: GraphNode[] = []
|
||||
let spaceIndex = 0
|
||||
|
||||
documentsBySpace.forEach((spaceDocs) => {
|
||||
spaceDocs.forEach((doc, docIndex) => {
|
||||
// Simple grid-like layout that physics will naturally organize
|
||||
// Start documents near the center with some random offset
|
||||
const gridSize = Math.ceil(Math.sqrt(spaceDocs.length))
|
||||
const row = Math.floor(docIndex / gridSize)
|
||||
const col = docIndex % gridSize
|
||||
|
||||
// Loose grid spacing - physics will organize it better
|
||||
const spacing = 200
|
||||
const defaultX =
|
||||
centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
|
||||
const defaultY =
|
||||
centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50
|
||||
|
||||
const customPos = nodePositions.get(doc.id)
|
||||
|
||||
// Check if node exists in cache (preserves d3-force mutations)
|
||||
let node = nodeCache.current.get(doc.id)
|
||||
if (node) {
|
||||
// Update existing node's data, preserve physics properties (x, y, vx, vy, fx, fy)
|
||||
node.data = doc
|
||||
node.isDragging = draggingNodeId === doc.id
|
||||
// Don't reset x/y - they're managed by d3-force
|
||||
} else {
|
||||
// Create new node with initial position
|
||||
node = {
|
||||
id: doc.id,
|
||||
type: "document",
|
||||
x: customPos?.x ?? defaultX,
|
||||
y: customPos?.y ?? defaultY,
|
||||
data: doc,
|
||||
size: 58,
|
||||
color: colors.document.primary,
|
||||
// Place memories in a ring around their parent document,
|
||||
// with slight randomness from hash for organic feel
|
||||
const memAngle =
|
||||
(i / memCount) * 2 * Math.PI + hashToUnit(mem.id) * 0.5
|
||||
const memRadius = MEMORY_ORBIT_BASE + hashToUnit(`${mem.id}-r`) * 120
|
||||
memNode = {
|
||||
id: mem.id,
|
||||
type: "memory",
|
||||
x: docNode.x + Math.cos(memAngle) * memRadius,
|
||||
y: docNode.y + Math.sin(memAngle) * memRadius,
|
||||
data: memData,
|
||||
size: 36,
|
||||
borderColor: getMemoryBorderColor(mem, colors),
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === doc.id,
|
||||
} satisfies GraphNode
|
||||
nodeCache.current.set(doc.id, node)
|
||||
}
|
||||
|
||||
documentNodes.push(node)
|
||||
})
|
||||
|
||||
spaceIndex++
|
||||
})
|
||||
|
||||
/* 2. Manual collision avoidance removed - now handled by d3-force simulation */
|
||||
// The initial circular layout provides good starting positions
|
||||
// D3-force will handle collision avoidance and spacing dynamically
|
||||
|
||||
allNodes.push(...documentNodes)
|
||||
|
||||
/* 3. Add memories around documents WITH doc-memory connections */
|
||||
documentNodes.forEach((docNode) => {
|
||||
const memoryNodeMap = new Map<string, GraphNode>()
|
||||
const doc = docNode.data as DocumentWithMemories
|
||||
|
||||
doc.memoryEntries.forEach((memory, memIndex) => {
|
||||
const memoryId = `${memory.id}`
|
||||
const customMemPos = nodePositions.get(memoryId)
|
||||
|
||||
// Simple circular positioning around parent doc
|
||||
// Physics will naturally cluster them better
|
||||
const angle = (memIndex / doc.memoryEntries.length) * Math.PI * 2
|
||||
const distance = clusterRadius * 1 // Closer to parent, let physics separate
|
||||
|
||||
const defaultMemX = docNode.x + Math.cos(angle) * distance
|
||||
const defaultMemY = docNode.y + Math.sin(angle) * distance
|
||||
|
||||
// Calculate final position
|
||||
let finalMemX = defaultMemX
|
||||
let finalMemY = defaultMemY
|
||||
|
||||
if (customMemPos) {
|
||||
// If memory was manually positioned and has stored offset relative to parent
|
||||
if (
|
||||
customMemPos.parentDocId === docNode.id &&
|
||||
customMemPos.offsetX !== undefined &&
|
||||
customMemPos.offsetY !== undefined
|
||||
) {
|
||||
// Apply the stored offset to the current document position
|
||||
finalMemX = docNode.x + customMemPos.offsetX
|
||||
finalMemY = docNode.y + customMemPos.offsetY
|
||||
} else {
|
||||
// Fallback: use absolute position (for backward compatibility or if parent changed)
|
||||
finalMemX = customMemPos.x
|
||||
finalMemY = customMemPos.y
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(mem.id, memNode)
|
||||
}
|
||||
|
||||
if (!memoryNodeMap.has(memoryId)) {
|
||||
// Check if memory node exists in cache (preserves d3-force mutations)
|
||||
let memoryNode = nodeCache.current.get(memoryId)
|
||||
if (memoryNode) {
|
||||
// Update existing node's data, preserve physics properties
|
||||
memoryNode.data = memory
|
||||
memoryNode.isDragging = draggingNodeId === memoryId
|
||||
// Don't reset x/y - they're managed by d3-force
|
||||
} else {
|
||||
// Create new node with initial position
|
||||
memoryNode = {
|
||||
id: memoryId,
|
||||
type: "memory",
|
||||
x: finalMemX,
|
||||
y: finalMemY,
|
||||
data: memory,
|
||||
size: Math.max(
|
||||
32,
|
||||
Math.min(48, (memory.memory?.length || 50) * 0.5),
|
||||
),
|
||||
color: colors.memory.primary,
|
||||
isHovered: false,
|
||||
isDragging: draggingNodeId === memoryId,
|
||||
}
|
||||
nodeCache.current.set(memoryId, memoryNode)
|
||||
}
|
||||
memoryNodeMap.set(memoryId, memoryNode)
|
||||
allNodes.push(memoryNode)
|
||||
}
|
||||
|
||||
// Create doc-memory edge with similarity
|
||||
allEdges.push({
|
||||
id: `edge-${docNode.id}-${memory.id}`,
|
||||
source: docNode.id,
|
||||
target: memoryId,
|
||||
similarity: 1,
|
||||
visualProps: getConnectionVisualProps(1),
|
||||
color: colors.connection.memory,
|
||||
edgeType: "doc-memory",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Build mapping of memoryId -> nodeId for version chains
|
||||
const memNodeIdMap = new Map<string, string>()
|
||||
allNodes.forEach((n) => {
|
||||
if (n.type === "memory") {
|
||||
memNodeIdMap.set((n.data as MemoryEntry).id, n.id)
|
||||
result.push(memNode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add version-chain edges (old -> new)
|
||||
data.documents.forEach((doc) => {
|
||||
doc.memoryEntries.forEach((mem: MemoryEntry) => {
|
||||
// Support both new object structure and legacy array/single parent fields
|
||||
let parentRelations: Record<string, MemoryRelation> =
|
||||
(mem.memoryRelations ?? {}) as Record<string, MemoryRelation>
|
||||
return result
|
||||
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
|
||||
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
Array.isArray(mem.memoryRelations) &&
|
||||
mem.memoryRelations.length > 0
|
||||
) {
|
||||
// Convert array to Record
|
||||
parentRelations = mem.memoryRelations.reduce(
|
||||
(acc, rel) => {
|
||||
acc[rel.targetMemoryId] = rel.relationType
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, MemoryRelation>,
|
||||
)
|
||||
} else if (mem.parentMemoryId) {
|
||||
parentRelations = {
|
||||
[mem.parentMemoryId]: "updates" as MemoryRelation,
|
||||
}
|
||||
}
|
||||
Object.entries(parentRelations).forEach(([pid, relationType]) => {
|
||||
const fromId = memNodeIdMap.get(pid)
|
||||
const toId = memNodeIdMap.get(mem.id)
|
||||
if (fromId && toId) {
|
||||
allEdges.push({
|
||||
id: `version-${fromId}-${toId}`,
|
||||
source: fromId,
|
||||
target: toId,
|
||||
similarity: 1,
|
||||
visualProps: {
|
||||
opacity: 0.8,
|
||||
thickness: 1,
|
||||
glow: 0,
|
||||
pulseDuration: 3000,
|
||||
},
|
||||
// choose color based on relation type
|
||||
color: colors.relations[relationType] ?? colors.relations.updates,
|
||||
edgeType: "version",
|
||||
relationType: relationType as MemoryRelation,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
const edges = useMemo(() => computeEdges(documents), [documents])
|
||||
|
||||
// Append similarity edges (calculated in separate memo)
|
||||
allEdges.push(...similarityEdges)
|
||||
|
||||
return { nodes: allNodes, edges: allEdges }
|
||||
}, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges])
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,606 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { GRAPH_SETTINGS } from "@/constants"
|
||||
import type { GraphNode } from "@/types"
|
||||
|
||||
export function useGraphInteractions(
|
||||
variant: "console" | "consumer" = "console",
|
||||
) {
|
||||
const settings = GRAPH_SETTINGS[variant]
|
||||
|
||||
const [panX, setPanX] = useState(settings.initialPanX)
|
||||
const [panY, setPanY] = useState(settings.initialPanY)
|
||||
const [zoom, setZoom] = useState(settings.initialZoom)
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 })
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
|
||||
const [dragStart, setDragStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeX: 0,
|
||||
nodeY: 0,
|
||||
})
|
||||
const [nodePositions, setNodePositions] = useState<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
parentDocId?: string
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
}
|
||||
>
|
||||
>(new Map())
|
||||
|
||||
// Touch gesture state
|
||||
const [touchState, setTouchState] = useState<{
|
||||
touches: { id: number; x: number; y: number }[]
|
||||
lastDistance: number
|
||||
lastCenter: { x: number; y: number }
|
||||
isGesturing: boolean
|
||||
}>({
|
||||
touches: [],
|
||||
lastDistance: 0,
|
||||
lastCenter: { x: 0, y: 0 },
|
||||
isGesturing: false,
|
||||
})
|
||||
|
||||
// Animation state for smooth transitions
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
// Smooth animation helper
|
||||
const animateToViewState = useCallback(
|
||||
(
|
||||
targetPanX: number,
|
||||
targetPanY: number,
|
||||
targetZoom: number,
|
||||
duration = 300,
|
||||
) => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
|
||||
const startPanX = panX
|
||||
const startPanY = panY
|
||||
const startZoom = zoom
|
||||
const startTime = Date.now()
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Ease out cubic function for smooth transitions
|
||||
const easeOut = 1 - (1 - progress) ** 3
|
||||
|
||||
const currentPanX = startPanX + (targetPanX - startPanX) * easeOut
|
||||
const currentPanY = startPanY + (targetPanY - startPanY) * easeOut
|
||||
const currentZoom = startZoom + (targetZoom - startZoom) * easeOut
|
||||
|
||||
setPanX(currentPanX)
|
||||
setPanY(currentPanY)
|
||||
setZoom(currentZoom)
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setIsAnimating(false)
|
||||
animationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
},
|
||||
[panX, panY, zoom],
|
||||
)
|
||||
|
||||
// Node drag handlers
|
||||
const handleNodeDragStart = useCallback(
|
||||
(nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
|
||||
const node = nodes?.find((n) => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
setDraggingNodeId(nodeId)
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
nodeX: node.x,
|
||||
nodeY: node.y,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleNodeDragMove = useCallback(
|
||||
(e: React.MouseEvent, nodes?: GraphNode[]) => {
|
||||
if (!draggingNodeId) return
|
||||
|
||||
const deltaX = (e.clientX - dragStart.x) / zoom
|
||||
const deltaY = (e.clientY - dragStart.y) / zoom
|
||||
|
||||
const newX = dragStart.nodeX + deltaX
|
||||
const newY = dragStart.nodeY + deltaY
|
||||
|
||||
// Find the node being dragged to determine if it's a memory
|
||||
const draggedNode = nodes?.find((n) => n.id === draggingNodeId)
|
||||
|
||||
if (draggedNode?.type === "memory") {
|
||||
// For memory nodes, find the parent document and store relative offset
|
||||
const memoryData = draggedNode.data as any // MemoryEntry type
|
||||
const parentDoc = nodes?.find(
|
||||
(n) =>
|
||||
n.type === "document" &&
|
||||
(n.data as any).memoryEntries?.some(
|
||||
(m: any) => m.id === memoryData.id,
|
||||
),
|
||||
)
|
||||
|
||||
if (parentDoc) {
|
||||
// Store the offset from the parent document
|
||||
const offsetX = newX - parentDoc.x
|
||||
const offsetY = newY - parentDoc.y
|
||||
|
||||
setNodePositions((prev) =>
|
||||
new Map(prev).set(draggingNodeId, {
|
||||
x: newX,
|
||||
y: newY,
|
||||
parentDocId: parentDoc.id,
|
||||
offsetX,
|
||||
offsetY,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For document nodes or if parent not found, just store absolute position
|
||||
setNodePositions((prev) =>
|
||||
new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
|
||||
)
|
||||
},
|
||||
[draggingNodeId, dragStart, zoom],
|
||||
)
|
||||
|
||||
const handleNodeDragEnd = useCallback(() => {
|
||||
setDraggingNodeId(null)
|
||||
}, [])
|
||||
|
||||
// Pan handlers
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsPanning(true)
|
||||
setPanStart({ x: e.clientX - panX, y: e.clientY - panY })
|
||||
},
|
||||
[panX, panY],
|
||||
)
|
||||
|
||||
const handlePanMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanning || draggingNodeId) return
|
||||
|
||||
const newPanX = e.clientX - panStart.x
|
||||
const newPanY = e.clientY - panStart.y
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
},
|
||||
[isPanning, panStart, draggingNodeId],
|
||||
)
|
||||
|
||||
const handlePanEnd = useCallback(() => {
|
||||
setIsPanning(false)
|
||||
}, [])
|
||||
|
||||
// Zoom handlers
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
// Always prevent default to stop browser navigation
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Handle horizontal scrolling (trackpad swipe) by converting to pan
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Horizontal scroll - pan the graph instead of zooming
|
||||
const panDelta = e.deltaX * 0.5
|
||||
setPanX((prev) => prev - panDelta)
|
||||
return
|
||||
}
|
||||
|
||||
// Vertical scroll - zoom behavior
|
||||
const delta = e.deltaY > 0 ? 0.97 : 1.03
|
||||
const newZoom = Math.max(0.05, Math.min(3, zoom * delta))
|
||||
|
||||
// Get mouse position relative to the viewport
|
||||
let mouseX = e.clientX
|
||||
let mouseY = e.clientY
|
||||
|
||||
// Try to get the container bounds to make coordinates relative to the graph container
|
||||
const target = e.currentTarget
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
mouseX = e.clientX - rect.left
|
||||
mouseY = e.clientY - rect.top
|
||||
}
|
||||
|
||||
// Calculate the world position of the mouse cursor
|
||||
const worldX = (mouseX - panX) / zoom
|
||||
const worldY = (mouseY - panY) / zoom
|
||||
|
||||
// Calculate new pan to keep the mouse position stationary
|
||||
const newPanX = mouseX - worldX * newZoom
|
||||
const newPanY = mouseY - worldY * newZoom
|
||||
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
)
|
||||
|
||||
const zoomIn = useCallback(
|
||||
(centerX?: number, centerY?: number, animate = true) => {
|
||||
const zoomFactor = 1.2
|
||||
const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x
|
||||
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
// Mouse-centered zoom for programmatic zoom in
|
||||
const worldX = (centerX - panX) / zoom
|
||||
const worldY = (centerY - panY) / zoom
|
||||
const newPanX = centerX - worldX * newZoom
|
||||
const newPanY = centerY - worldY * newZoom
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY, isAnimating, animateToViewState],
|
||||
)
|
||||
|
||||
const zoomOut = useCallback(
|
||||
(centerX?: number, centerY?: number, animate = true) => {
|
||||
const zoomFactor = 0.8
|
||||
const newZoom = Math.max(0.05, zoom * zoomFactor) // Decreased min zoom to 0.05x
|
||||
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
// Mouse-centered zoom for programmatic zoom out
|
||||
const worldX = (centerX - panX) / zoom
|
||||
const worldY = (centerY - panY) / zoom
|
||||
const newPanX = centerX - worldX * newZoom
|
||||
const newPanY = centerY - worldY * newZoom
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
} else {
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(panX, panY, newZoom, 200)
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY, isAnimating, animateToViewState],
|
||||
)
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setPanX(settings.initialPanX)
|
||||
setPanY(settings.initialPanY)
|
||||
setZoom(settings.initialZoom)
|
||||
setNodePositions(new Map())
|
||||
}, [settings])
|
||||
|
||||
// Auto-fit graph to viewport
|
||||
const autoFitToViewport = useCallback(
|
||||
(
|
||||
nodes: GraphNode[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
options?: { occludedRightPx?: number; animate?: boolean },
|
||||
) => {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
// Find the bounds of all nodes
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
nodes.forEach((node) => {
|
||||
minX = Math.min(minX, node.x - node.size / 2)
|
||||
maxX = Math.max(maxX, node.x + node.size / 2)
|
||||
minY = Math.min(minY, node.y - node.size / 2)
|
||||
maxY = Math.max(maxY, node.y + node.size / 2)
|
||||
})
|
||||
|
||||
// Calculate the center of the content
|
||||
const contentCenterX = (minX + maxX) / 2
|
||||
const contentCenterY = (minY + maxY) / 2
|
||||
|
||||
// Calculate the size of the content
|
||||
const contentWidth = maxX - minX
|
||||
const contentHeight = maxY - minY
|
||||
|
||||
// Add padding (20% on each side)
|
||||
const paddingFactor = 1.4
|
||||
const paddedWidth = contentWidth * paddingFactor
|
||||
const paddedHeight = contentHeight * paddingFactor
|
||||
|
||||
// Account for occluded area on the right (e.g., chat panel)
|
||||
const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0)
|
||||
const availableWidth = Math.max(1, viewportWidth - occludedRightPx)
|
||||
|
||||
// Calculate the zoom needed to fit the content within available width
|
||||
const zoomX = availableWidth / paddedWidth
|
||||
const zoomY = viewportHeight / paddedHeight
|
||||
const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3)
|
||||
|
||||
// Calculate pan to center the content within available area
|
||||
const availableCenterX = availableWidth / 2
|
||||
const newPanX = availableCenterX - contentCenterX * newZoom
|
||||
const newPanY = viewportHeight / 2 - contentCenterY * newZoom
|
||||
|
||||
// Apply the new view (optional animation)
|
||||
if (options?.animate) {
|
||||
const steps = 8
|
||||
const durationMs = 160 // snappy
|
||||
const intervalMs = Math.max(1, Math.floor(durationMs / steps))
|
||||
const startZoom = zoom
|
||||
const startPanX = panX
|
||||
const startPanY = panY
|
||||
let i = 0
|
||||
const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad
|
||||
const timer = setInterval(() => {
|
||||
i++
|
||||
const t = ease(i / steps)
|
||||
setZoom(startZoom + (newZoom - startZoom) * t)
|
||||
setPanX(startPanX + (newPanX - startPanX) * t)
|
||||
setPanY(startPanY + (newPanY - startPanY) * t)
|
||||
if (i >= steps) clearInterval(timer)
|
||||
}, intervalMs)
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
)
|
||||
|
||||
// Touch gesture handlers for mobile pinch-to-zoom
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}))
|
||||
|
||||
if (touches.length >= 2) {
|
||||
// Start gesture with two or more fingers
|
||||
const touch1 = touches[0]!
|
||||
const touch2 = touches[1]!
|
||||
|
||||
const distance = Math.sqrt(
|
||||
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
|
||||
)
|
||||
|
||||
const center = {
|
||||
x: (touch1.x + touch2.x) / 2,
|
||||
y: (touch1.y + touch2.y) / 2,
|
||||
}
|
||||
|
||||
setTouchState({
|
||||
touches,
|
||||
lastDistance: distance,
|
||||
lastCenter: center,
|
||||
isGesturing: true,
|
||||
})
|
||||
} else {
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}))
|
||||
|
||||
if (touches.length >= 2 && touchState.isGesturing) {
|
||||
const touch1 = touches[0]!
|
||||
const touch2 = touches[1]!
|
||||
|
||||
const distance = Math.sqrt(
|
||||
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
|
||||
)
|
||||
|
||||
const center = {
|
||||
x: (touch1.x + touch2.x) / 2,
|
||||
y: (touch1.y + touch2.y) / 2,
|
||||
}
|
||||
|
||||
// Calculate zoom change based on pinch distance change
|
||||
const distanceChange = distance / touchState.lastDistance
|
||||
const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange))
|
||||
|
||||
// Get canvas bounds for center calculation
|
||||
const canvas = e.currentTarget as HTMLElement
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const centerX = center.x - rect.left
|
||||
const centerY = center.y - rect.top
|
||||
|
||||
// Calculate the world position of the pinch center
|
||||
const worldX = (centerX - panX) / zoom
|
||||
const worldY = (centerY - panY) / zoom
|
||||
|
||||
// Calculate new pan to keep the pinch center stationary
|
||||
const newPanX = centerX - worldX * newZoom
|
||||
const newPanY = centerY - worldY * newZoom
|
||||
|
||||
// Calculate pan change based on center movement
|
||||
const centerDx = center.x - touchState.lastCenter.x
|
||||
const centerDy = center.y - touchState.lastCenter.y
|
||||
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX + centerDx)
|
||||
setPanY(newPanY + centerDy)
|
||||
|
||||
setTouchState({
|
||||
touches,
|
||||
lastDistance: distance,
|
||||
lastCenter: center,
|
||||
isGesturing: true,
|
||||
})
|
||||
} else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
|
||||
// Single finger pan (only if not in gesture mode)
|
||||
const touch = touches[0]!
|
||||
const newPanX = touch.x - panStart.x
|
||||
const newPanY = touch.y - panStart.y
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
},
|
||||
[touchState, zoom, panX, panY, isPanning, panStart],
|
||||
)
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
const touches = Array.from(e.touches).map((touch) => ({
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
}))
|
||||
|
||||
if (touches.length < 2) {
|
||||
setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
|
||||
} else {
|
||||
setTouchState((prev) => ({ ...prev, touches }))
|
||||
}
|
||||
|
||||
if (touches.length === 0) {
|
||||
setIsPanning(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Center viewport on a specific world position (with animation)
|
||||
const centerViewportOn = useCallback(
|
||||
(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
animate = true,
|
||||
) => {
|
||||
const newPanX = viewportWidth / 2 - worldX * zoom
|
||||
const newPanY = viewportHeight / 2 - worldY * zoom
|
||||
|
||||
if (animate && !isAnimating) {
|
||||
animateToViewState(newPanX, newPanY, zoom, 400)
|
||||
} else {
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
}
|
||||
},
|
||||
[zoom, isAnimating, animateToViewState],
|
||||
)
|
||||
|
||||
// Node interaction handlers
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
setHoveredNode(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedNode(selectedNode === nodeId ? null : nodeId)
|
||||
},
|
||||
[selectedNode],
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Calculate new zoom (zoom in by 1.5x)
|
||||
const zoomFactor = 1.5
|
||||
const newZoom = Math.min(3, zoom * zoomFactor)
|
||||
|
||||
// Get mouse position relative to the container
|
||||
let mouseX = e.clientX
|
||||
let mouseY = e.clientY
|
||||
|
||||
// Try to get the container bounds to make coordinates relative to the graph container
|
||||
const target = e.currentTarget
|
||||
if (target && "getBoundingClientRect" in target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
mouseX = e.clientX - rect.left
|
||||
mouseY = e.clientY - rect.top
|
||||
}
|
||||
|
||||
// Calculate the world position of the clicked point
|
||||
const worldX = (mouseX - panX) / zoom
|
||||
const worldY = (mouseY - panY) / zoom
|
||||
|
||||
// Calculate new pan to keep the clicked point in the same screen position
|
||||
const newPanX = mouseX - worldX * newZoom
|
||||
const newPanY = mouseY - worldY * newZoom
|
||||
|
||||
setZoom(newZoom)
|
||||
setPanX(newPanX)
|
||||
setPanY(newPanY)
|
||||
},
|
||||
[zoom, panX, panY],
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
hoveredNode,
|
||||
selectedNode,
|
||||
draggingNodeId,
|
||||
nodePositions,
|
||||
// Handlers
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
handleWheel,
|
||||
handleNodeHover,
|
||||
handleNodeClick,
|
||||
handleNodeDragStart,
|
||||
handleNodeDragMove,
|
||||
handleNodeDragEnd,
|
||||
handleDoubleClick,
|
||||
// Touch handlers
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
// Controls
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetView,
|
||||
autoFitToViewport,
|
||||
centerViewportOn,
|
||||
setSelectedNode,
|
||||
}
|
||||
}
|
||||
121
packages/memory-graph/src/hooks/use-graph-theme.ts
Normal file
121
packages/memory-graph/src/hooks/use-graph-theme.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { GraphThemeColors } from "../types"
|
||||
import { DEFAULT_COLORS } from "../constants"
|
||||
|
||||
function readCssVar(name: string, fallback: string): string {
|
||||
if (typeof document === "undefined") return fallback
|
||||
const val = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
return val || fallback
|
||||
}
|
||||
|
||||
function resolveColors(): GraphThemeColors {
|
||||
return {
|
||||
bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg),
|
||||
docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill),
|
||||
docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke),
|
||||
docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill),
|
||||
memFill: readCssVar("--graph-mem-fill", DEFAULT_COLORS.memFill),
|
||||
memFillHover: readCssVar(
|
||||
"--graph-mem-fill-hover",
|
||||
DEFAULT_COLORS.memFillHover,
|
||||
),
|
||||
memStrokeDefault: readCssVar(
|
||||
"--graph-mem-stroke",
|
||||
DEFAULT_COLORS.memStrokeDefault,
|
||||
),
|
||||
accent: readCssVar("--graph-accent", DEFAULT_COLORS.accent),
|
||||
textPrimary: readCssVar("--graph-text-primary", DEFAULT_COLORS.textPrimary),
|
||||
textSecondary: readCssVar(
|
||||
"--graph-text-secondary",
|
||||
DEFAULT_COLORS.textSecondary,
|
||||
),
|
||||
textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted),
|
||||
edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives),
|
||||
edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates),
|
||||
edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends),
|
||||
memBorderForgotten: readCssVar(
|
||||
"--graph-mem-border-forgotten",
|
||||
DEFAULT_COLORS.memBorderForgotten,
|
||||
),
|
||||
memBorderExpiring: readCssVar(
|
||||
"--graph-mem-border-expiring",
|
||||
DEFAULT_COLORS.memBorderExpiring,
|
||||
),
|
||||
memBorderRecent: readCssVar(
|
||||
"--graph-mem-border-recent",
|
||||
DEFAULT_COLORS.memBorderRecent,
|
||||
),
|
||||
glowColor: readCssVar("--graph-glow", DEFAULT_COLORS.glowColor),
|
||||
iconColor: readCssVar("--graph-icon", DEFAULT_COLORS.iconColor),
|
||||
popoverBg: readCssVar("--graph-popover-bg", DEFAULT_COLORS.popoverBg),
|
||||
popoverBorder: readCssVar(
|
||||
"--graph-popover-border",
|
||||
DEFAULT_COLORS.popoverBorder,
|
||||
),
|
||||
popoverTextPrimary: readCssVar(
|
||||
"--graph-popover-text-primary",
|
||||
DEFAULT_COLORS.popoverTextPrimary,
|
||||
),
|
||||
popoverTextSecondary: readCssVar(
|
||||
"--graph-popover-text-secondary",
|
||||
DEFAULT_COLORS.popoverTextSecondary,
|
||||
),
|
||||
popoverTextMuted: readCssVar(
|
||||
"--graph-popover-text-muted",
|
||||
DEFAULT_COLORS.popoverTextMuted,
|
||||
),
|
||||
controlBg: readCssVar("--graph-control-bg", DEFAULT_COLORS.controlBg),
|
||||
controlBorder: readCssVar(
|
||||
"--graph-control-border",
|
||||
DEFAULT_COLORS.controlBorder,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useGraphTheme(
|
||||
overrides?: Partial<GraphThemeColors>,
|
||||
): GraphThemeColors {
|
||||
const [colors, setColors] = useState<GraphThemeColors>(() => resolveColors())
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setColors(resolveColors())
|
||||
|
||||
// Re-read on theme class change
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === "attributes" && m.attributeName === "class") {
|
||||
update()
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true })
|
||||
|
||||
// Also listen for media query changes (system theme)
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
mq.addEventListener("change", update)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
mq.removeEventListener("change", update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Serialize overrides to a stable string key so useMemo only recomputes
|
||||
// when the actual override values change, not on every render.
|
||||
const overrideKey = overrides
|
||||
? Object.entries(overrides)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(",")
|
||||
: ""
|
||||
|
||||
const merged = useMemo(
|
||||
() => (overrides ? { ...colors, ...overrides } : colors),
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: overrideKey tracks overrides by value
|
||||
[colors, overrideKey],
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
|
|
@ -1,25 +1,39 @@
|
|||
// Export the main component
|
||||
// Components
|
||||
export { MemoryGraph } from "./components/memory-graph"
|
||||
export { GraphCanvas } from "./components/graph-canvas"
|
||||
|
||||
// Export style injector for manual use if needed
|
||||
export { injectStyles } from "./lib/inject-styles"
|
||||
// Hooks
|
||||
export { useGraphData } from "./hooks/use-graph-data"
|
||||
export { useGraphTheme } from "./hooks/use-graph-theme"
|
||||
|
||||
// Export types for consumers
|
||||
export type { MemoryGraphProps } from "./types"
|
||||
// Engine classes (for advanced usage)
|
||||
export { ForceSimulation } from "./canvas/simulation"
|
||||
export { ViewportState } from "./canvas/viewport"
|
||||
export { SpatialIndex } from "./canvas/hit-test"
|
||||
export { VersionChainIndex } from "./canvas/version-chain"
|
||||
|
||||
// Constants
|
||||
export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants"
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MemoryGraphProps,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphThemeColors,
|
||||
GraphCanvasProps,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
ChainEntry,
|
||||
} from "./types"
|
||||
|
||||
// Backward-compatible API types
|
||||
export type {
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
DocumentsResponse,
|
||||
} from "./api-types"
|
||||
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
MemoryRelation,
|
||||
} from "./types"
|
||||
|
||||
// Export theme system for custom theming
|
||||
export { themeContract, defaultTheme } from "./styles/theme.css"
|
||||
export { sprinkles } from "./styles/sprinkles.css"
|
||||
export type { Sprinkles } from "./styles/sprinkles.css"
|
||||
} from "./api-types"
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
/**
|
||||
* Runtime CSS injection for universal bundler support
|
||||
* The CSS content is injected by the build plugin
|
||||
*/
|
||||
|
||||
// This will be replaced by the build plugin with the actual CSS content
|
||||
declare const __MEMORY_GRAPH_CSS__: string
|
||||
|
||||
// Track injection state
|
||||
let injected = false
|
||||
|
||||
/**
|
||||
* Inject memory-graph styles into the document head.
|
||||
* Safe to call multiple times - will only inject once.
|
||||
*/
|
||||
export function injectStyles(): void {
|
||||
// Only run in browser
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
// Only inject once
|
||||
if (injected) return
|
||||
|
||||
// Check if already injected (e.g., by another instance)
|
||||
if (document.querySelector("style[data-memory-graph]")) {
|
||||
injected = true
|
||||
return
|
||||
}
|
||||
|
||||
injected = true
|
||||
|
||||
// Create and inject style element
|
||||
const style = document.createElement("style")
|
||||
style.setAttribute("data-memory-graph", "")
|
||||
style.textContent = __MEMORY_GRAPH_CSS__
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
// Utility functions for calculating semantic similarity between documents and memories
|
||||
|
||||
/**
|
||||
* Calculate cosine similarity between two normalized vectors (unit vectors)
|
||||
* Since all embeddings in this system are normalized using normalizeEmbeddingFast,
|
||||
* cosine similarity equals dot product for unit vectors.
|
||||
*/
|
||||
export const cosineSimilarity = (
|
||||
vectorA: number[],
|
||||
vectorB: number[],
|
||||
): number => {
|
||||
if (vectorA.length !== vectorB.length) {
|
||||
throw new Error("Vectors must have the same length")
|
||||
}
|
||||
|
||||
let dotProduct = 0
|
||||
|
||||
for (let i = 0; i < vectorA.length; i++) {
|
||||
const vectorAi = vectorA[i]
|
||||
const vectorBi = vectorB[i]
|
||||
if (
|
||||
typeof vectorAi !== "number" ||
|
||||
typeof vectorBi !== "number" ||
|
||||
isNaN(vectorAi) ||
|
||||
isNaN(vectorBi)
|
||||
) {
|
||||
throw new Error("Vectors must contain only numbers")
|
||||
}
|
||||
dotProduct += vectorAi * vectorBi
|
||||
}
|
||||
|
||||
return dotProduct
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate semantic similarity between two documents
|
||||
* Returns a value between 0 and 1, where 1 is most similar
|
||||
*/
|
||||
export const calculateSemanticSimilarity = (
|
||||
document1Embedding: number[] | null,
|
||||
document2Embedding: number[] | null,
|
||||
): number => {
|
||||
// If we have both embeddings, use cosine similarity
|
||||
if (
|
||||
document1Embedding &&
|
||||
document2Embedding &&
|
||||
document1Embedding.length > 0 &&
|
||||
document2Embedding.length > 0
|
||||
) {
|
||||
const similarity = cosineSimilarity(document1Embedding, document2Embedding)
|
||||
// Convert from [-1, 1] to [0, 1] range
|
||||
return similarity >= 0 ? similarity : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate semantic similarity between a document and memory entry
|
||||
* Returns a value between 0 and 1, where 1 is most similar
|
||||
*/
|
||||
export const calculateDocumentMemorySimilarity = (
|
||||
documentEmbedding: number[] | null,
|
||||
memoryEmbedding: number[] | null,
|
||||
relevanceScore?: number | null,
|
||||
): number => {
|
||||
// If we have both embeddings, use cosine similarity
|
||||
if (
|
||||
documentEmbedding &&
|
||||
memoryEmbedding &&
|
||||
documentEmbedding.length > 0 &&
|
||||
memoryEmbedding.length > 0
|
||||
) {
|
||||
const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding)
|
||||
// Convert from [-1, 1] to [0, 1] range
|
||||
return similarity >= 0 ? similarity : 0
|
||||
}
|
||||
|
||||
// Fall back to relevance score from database (0-100 scale)
|
||||
if (relevanceScore !== null && relevanceScore !== undefined) {
|
||||
return Math.max(0, Math.min(1, relevanceScore / 100))
|
||||
}
|
||||
|
||||
// Default similarity for connections without embeddings or relevance scores
|
||||
return 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual properties for connection based on similarity
|
||||
*/
|
||||
export const getConnectionVisualProps = (similarity: number) => {
|
||||
// Ensure similarity is between 0 and 1
|
||||
const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
|
||||
|
||||
return {
|
||||
opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range
|
||||
thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels
|
||||
glow: normalizedSimilarity * 0.6, // Glow intensity
|
||||
pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate magical color based on similarity and connection type
|
||||
*/
|
||||
export const getMagicalConnectionColor = (
|
||||
similarity: number,
|
||||
hue = 220,
|
||||
): string => {
|
||||
const normalizedSimilarity = Math.max(0, Math.min(1, similarity))
|
||||
const saturation = 60 + normalizedSimilarity * 40 // 60% to 100%
|
||||
const lightness = 40 + normalizedSimilarity * 30 // 40% to 70%
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||
}
|
||||
414
packages/memory-graph/src/mock-data.ts
Normal file
414
packages/memory-graph/src/mock-data.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import type { GraphApiDocument, GraphApiMemory } from "./types"
|
||||
|
||||
export interface MockGraphOptions {
|
||||
documentCount?: number
|
||||
memoriesPerDoc?: number | [number, number]
|
||||
seed?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple seeded PRNG (Mulberry32).
|
||||
* Returns a function that produces values in [0, 1).
|
||||
*/
|
||||
function createSeededRandom(seed: number): () => number {
|
||||
let s = seed | 0
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
"webpage",
|
||||
"pdf",
|
||||
"md",
|
||||
"doc",
|
||||
"csv",
|
||||
"json",
|
||||
"notion",
|
||||
"text",
|
||||
"google_doc",
|
||||
"google_sheet",
|
||||
"google_slide",
|
||||
"tweet",
|
||||
"youtube",
|
||||
"image",
|
||||
"note",
|
||||
"google_drive",
|
||||
"video",
|
||||
"mcp",
|
||||
"word",
|
||||
] as const
|
||||
|
||||
const TITLE_PREFIXES = [
|
||||
"Project Planning",
|
||||
"API Documentation",
|
||||
"Meeting Minutes",
|
||||
"Architecture Design",
|
||||
"Sprint Retrospective",
|
||||
"User Research",
|
||||
"Product Roadmap",
|
||||
"Technical Spec",
|
||||
"Onboarding Guide",
|
||||
"Release Notes",
|
||||
"Performance Report",
|
||||
"Security Audit",
|
||||
"Database Schema",
|
||||
"Deployment Guide",
|
||||
"Team Standup",
|
||||
"Bug Triage",
|
||||
"Feature Request",
|
||||
"Code Review",
|
||||
"Integration Test",
|
||||
"Data Pipeline",
|
||||
]
|
||||
|
||||
const TITLE_SUFFIXES = [
|
||||
"Notes",
|
||||
"Q3",
|
||||
"Q4",
|
||||
"v2",
|
||||
"v3",
|
||||
"Final",
|
||||
"Draft",
|
||||
"2024",
|
||||
"2025",
|
||||
"Summary",
|
||||
"Overview",
|
||||
"Detailed",
|
||||
"Analysis",
|
||||
"Report",
|
||||
"Update",
|
||||
]
|
||||
|
||||
const MEMORY_TEMPLATES = [
|
||||
"The user prefers {preference} when working with {topic}",
|
||||
"Key decision: {topic} will be implemented using {approach}",
|
||||
"Important constraint: {topic} must handle {requirement}",
|
||||
"The {component} module depends on {dependency} for {purpose}",
|
||||
"Performance target: {metric} should be under {threshold}",
|
||||
"The team agreed to {action} before {deadline}",
|
||||
"User feedback indicates {observation} about {feature}",
|
||||
"Best practice: always {action} when {condition}",
|
||||
"Known issue: {component} has {issue} under {condition}",
|
||||
"Migration plan: move from {oldThing} to {newThing} by {deadline}",
|
||||
]
|
||||
|
||||
const TEMPLATE_FILLS: Record<string, string[]> = {
|
||||
preference: [
|
||||
"TypeScript",
|
||||
"dark mode",
|
||||
"keyboard shortcuts",
|
||||
"minimal UI",
|
||||
"detailed logs",
|
||||
"async patterns",
|
||||
],
|
||||
topic: [
|
||||
"authentication",
|
||||
"caching",
|
||||
"data sync",
|
||||
"graph rendering",
|
||||
"search indexing",
|
||||
"memory management",
|
||||
],
|
||||
approach: [
|
||||
"event sourcing",
|
||||
"CQRS",
|
||||
"microservices",
|
||||
"edge functions",
|
||||
"WebSocket streams",
|
||||
"batch processing",
|
||||
],
|
||||
requirement: [
|
||||
"10k concurrent users",
|
||||
"sub-100ms latency",
|
||||
"offline mode",
|
||||
"real-time updates",
|
||||
"GDPR compliance",
|
||||
],
|
||||
component: [
|
||||
"auth",
|
||||
"graph",
|
||||
"search",
|
||||
"storage",
|
||||
"notification",
|
||||
"analytics",
|
||||
],
|
||||
dependency: [
|
||||
"Redis",
|
||||
"PostgreSQL",
|
||||
"S3",
|
||||
"Cloudflare Workers",
|
||||
"D3-force",
|
||||
"WebGL",
|
||||
],
|
||||
purpose: [
|
||||
"caching",
|
||||
"persistence",
|
||||
"rendering",
|
||||
"indexing",
|
||||
"scheduling",
|
||||
"routing",
|
||||
],
|
||||
metric: [
|
||||
"p99 latency",
|
||||
"time to interactive",
|
||||
"memory usage",
|
||||
"CPU utilization",
|
||||
"bundle size",
|
||||
],
|
||||
threshold: ["200ms", "500ms", "50MB", "3 seconds", "100KB", "1GB"],
|
||||
action: [
|
||||
"run benchmarks",
|
||||
"update dependencies",
|
||||
"write migration scripts",
|
||||
"review PRs",
|
||||
"deploy to staging",
|
||||
],
|
||||
deadline: ["end of sprint", "next release", "Q4", "Friday", "the demo"],
|
||||
observation: [
|
||||
"confusion",
|
||||
"delight",
|
||||
"frustration",
|
||||
"efficiency gains",
|
||||
"unexpected usage patterns",
|
||||
],
|
||||
feature: [
|
||||
"the graph view",
|
||||
"search filters",
|
||||
"memory chains",
|
||||
"document upload",
|
||||
"sharing",
|
||||
],
|
||||
condition: [
|
||||
"high load",
|
||||
"cold start",
|
||||
"large datasets",
|
||||
"slow networks",
|
||||
"concurrent edits",
|
||||
],
|
||||
issue: [
|
||||
"memory leaks",
|
||||
"race conditions",
|
||||
"stale data",
|
||||
"layout thrashing",
|
||||
"connection drops",
|
||||
],
|
||||
oldThing: [
|
||||
"REST API",
|
||||
"MongoDB",
|
||||
"class components",
|
||||
"Webpack",
|
||||
"manual testing",
|
||||
],
|
||||
newThing: ["GraphQL", "PostgreSQL", "hooks", "Vite", "automated CI"],
|
||||
}
|
||||
|
||||
const SUMMARY_TEMPLATES = [
|
||||
"Documentation covering {topic} implementation details and best practices for the team.",
|
||||
"Notes from the recent discussion about {topic} and the decisions made regarding {approach}.",
|
||||
"Technical overview of the {component} system, including architecture and key design choices.",
|
||||
"Analysis of {metric} performance data, with recommendations for optimization.",
|
||||
"Guide for setting up and configuring {component} in development and production environments.",
|
||||
"Collection of user feedback and research findings related to {feature}.",
|
||||
]
|
||||
|
||||
function fillTemplate(template: string, random: () => number): string {
|
||||
return template.replace(/{(\w+)}/g, (_match, key: string) => {
|
||||
const options = TEMPLATE_FILLS[key]
|
||||
if (!options || options.length === 0) return key
|
||||
return options[Math.floor(random() * options.length)]
|
||||
})
|
||||
}
|
||||
|
||||
function generateTitle(random: () => number): string {
|
||||
const prefix = TITLE_PREFIXES[Math.floor(random() * TITLE_PREFIXES.length)]
|
||||
if (random() > 0.5) {
|
||||
const suffix = TITLE_SUFFIXES[Math.floor(random() * TITLE_SUFFIXES.length)]
|
||||
return `${prefix} ${suffix}`
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
function generateSummary(random: () => number): string | null {
|
||||
if (random() < 0.15) return null
|
||||
const template =
|
||||
SUMMARY_TEMPLATES[Math.floor(random() * SUMMARY_TEMPLATES.length)]
|
||||
return fillTemplate(template, random)
|
||||
}
|
||||
|
||||
function generateMemoryContent(random: () => number): string {
|
||||
const template =
|
||||
MEMORY_TEMPLATES[Math.floor(random() * MEMORY_TEMPLATES.length)]
|
||||
return fillTemplate(template, random)
|
||||
}
|
||||
|
||||
function generateISODate(
|
||||
random: () => number,
|
||||
baseMs: number,
|
||||
rangeMs: number,
|
||||
): string {
|
||||
const ms = baseMs + Math.floor(random() * rangeMs)
|
||||
return new Date(ms).toISOString()
|
||||
}
|
||||
|
||||
export function generateMockGraphData(options: MockGraphOptions = {}): {
|
||||
documents: GraphApiDocument[]
|
||||
} {
|
||||
const {
|
||||
documentCount = 100,
|
||||
memoriesPerDoc = [2, 6] as [number, number],
|
||||
seed = 42,
|
||||
} = options
|
||||
|
||||
const random = createSeededRandom(seed)
|
||||
|
||||
// Time range: roughly Jan 2024 to Jun 2025
|
||||
const baseTime = new Date("2024-01-01T00:00:00Z").getTime()
|
||||
const timeRange = 1000 * 60 * 60 * 24 * 540 // ~540 days
|
||||
|
||||
const spaceIds = [
|
||||
"space-default",
|
||||
"space-work",
|
||||
"space-personal",
|
||||
"space-research",
|
||||
]
|
||||
|
||||
const documents: GraphApiDocument[] = []
|
||||
|
||||
for (let d = 0; d < documentCount; d++) {
|
||||
const docId = `doc-${String(d).padStart(4, "0")}`
|
||||
const docCreatedAt = generateISODate(random, baseTime, timeRange)
|
||||
const docUpdatedAt = generateISODate(
|
||||
random,
|
||||
new Date(docCreatedAt).getTime(),
|
||||
1000 * 60 * 60 * 24 * 30, // up to 30 days after creation
|
||||
)
|
||||
|
||||
// Determine memory count
|
||||
let memCount: number
|
||||
if (typeof memoriesPerDoc === "number") {
|
||||
memCount = memoriesPerDoc
|
||||
} else {
|
||||
const [min, max] = memoriesPerDoc
|
||||
memCount = min + Math.floor(random() * (max - min + 1))
|
||||
}
|
||||
|
||||
const spaceId = spaceIds[Math.floor(random() * spaceIds.length)]
|
||||
const memories: GraphApiMemory[] = []
|
||||
|
||||
// Decide if this document has a version chain (30% chance)
|
||||
const hasVersionChain = random() < 0.3 && memCount >= 3
|
||||
let chainRootId: string | null = null
|
||||
let chainPrevId: string | null = null
|
||||
let chainVersion = 1
|
||||
|
||||
for (let m = 0; m < memCount; m++) {
|
||||
const memId = `mem-${docId}-${String(m).padStart(3, "0")}`
|
||||
const memCreatedAt = generateISODate(
|
||||
random,
|
||||
new Date(docCreatedAt).getTime(),
|
||||
1000 * 60 * 60 * 24 * 14,
|
||||
)
|
||||
const memUpdatedAt = generateISODate(
|
||||
random,
|
||||
new Date(memCreatedAt).getTime(),
|
||||
1000 * 60 * 60 * 24 * 7,
|
||||
)
|
||||
|
||||
let parentMemoryId: string | null = null
|
||||
let rootMemoryId: string | null = null
|
||||
let version = 1
|
||||
let isLatest = true
|
||||
let isForgotten = false
|
||||
let memoryRelations: Record<string, "updates" | "extends" | "derives"> =
|
||||
{}
|
||||
|
||||
// Build version chain for first few memories if applicable
|
||||
if (hasVersionChain && m < 3) {
|
||||
if (m === 0) {
|
||||
// Root of the chain
|
||||
chainRootId = memId
|
||||
chainPrevId = memId
|
||||
chainVersion = 1
|
||||
version = 1
|
||||
isLatest = false
|
||||
isForgotten = random() < 0.3
|
||||
} else {
|
||||
parentMemoryId = chainPrevId
|
||||
rootMemoryId = chainRootId
|
||||
chainVersion++
|
||||
version = chainVersion
|
||||
chainPrevId = memId
|
||||
isLatest = m === 2 // last in the 3-memory chain
|
||||
isForgotten = !isLatest && random() < 0.2
|
||||
// Add "updates" relation to parent
|
||||
if (parentMemoryId) {
|
||||
memoryRelations = { [parentMemoryId]: "updates" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standalone memory
|
||||
isForgotten = random() < 0.1
|
||||
isLatest = true
|
||||
version = 1
|
||||
// Randomly add extends/derives relations to earlier memories in this doc
|
||||
if (m > 0 && random() < 0.2) {
|
||||
const targetIdx = Math.floor(random() * m)
|
||||
const targetMem = memories[targetIdx]
|
||||
if (targetMem) {
|
||||
const relType = random() < 0.5 ? "extends" : "derives"
|
||||
memoryRelations = {
|
||||
[targetMem.id]: relType,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine forgetAfter (for some non-forgotten memories, set a future expiry)
|
||||
let forgetAfter: string | null = null
|
||||
let forgetReason: string | null = null
|
||||
if (isForgotten) {
|
||||
forgetReason = random() < 0.5 ? "superseded" : "user-requested"
|
||||
} else if (random() < 0.1) {
|
||||
// Expiring memory
|
||||
const expiryMs =
|
||||
Date.now() + Math.floor(random() * 1000 * 60 * 60 * 24 * 30)
|
||||
forgetAfter = new Date(expiryMs).toISOString()
|
||||
}
|
||||
|
||||
memories.push({
|
||||
id: memId,
|
||||
memory: generateMemoryContent(random),
|
||||
isStatic: random() < 0.15,
|
||||
spaceId,
|
||||
isLatest,
|
||||
isForgotten,
|
||||
forgetAfter,
|
||||
forgetReason,
|
||||
version,
|
||||
parentMemoryId,
|
||||
rootMemoryId,
|
||||
createdAt: memCreatedAt,
|
||||
updatedAt: memUpdatedAt,
|
||||
memoryRelations:
|
||||
Object.keys(memoryRelations).length > 0 ? memoryRelations : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
documents.push({
|
||||
id: docId,
|
||||
title: generateTitle(random),
|
||||
summary: generateSummary(random),
|
||||
documentType:
|
||||
DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)],
|
||||
createdAt: docCreatedAt,
|
||||
updatedAt: docUpdatedAt,
|
||||
memories,
|
||||
})
|
||||
}
|
||||
|
||||
return { documents }
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { keyframes } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Animation keyframes
|
||||
* Used throughout the component library for consistent motion
|
||||
*/
|
||||
|
||||
export const fadeIn = keyframes({
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
})
|
||||
|
||||
export const fadeOut = keyframes({
|
||||
from: { opacity: 1 },
|
||||
to: { opacity: 0 },
|
||||
})
|
||||
|
||||
export const slideInFromRight = keyframes({
|
||||
from: {
|
||||
transform: "translateX(100%)",
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: "translateX(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export const slideInFromLeft = keyframes({
|
||||
from: {
|
||||
transform: "translateX(-100%)",
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: "translateX(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export const slideInFromTop = keyframes({
|
||||
from: {
|
||||
transform: "translateY(-100%)",
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: "translateY(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export const slideInFromBottom = keyframes({
|
||||
from: {
|
||||
transform: "translateY(100%)",
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: "translateY(0)",
|
||||
opacity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export const spin = keyframes({
|
||||
from: { transform: "rotate(0deg)" },
|
||||
to: { transform: "rotate(360deg)" },
|
||||
})
|
||||
|
||||
export const pulse = keyframes({
|
||||
"0%, 100%": {
|
||||
opacity: 1,
|
||||
},
|
||||
"50%": {
|
||||
opacity: 0.5,
|
||||
},
|
||||
})
|
||||
|
||||
export const bounce = keyframes({
|
||||
"0%, 100%": {
|
||||
transform: "translateY(-25%)",
|
||||
animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)",
|
||||
},
|
||||
"50%": {
|
||||
transform: "translateY(0)",
|
||||
animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
|
||||
},
|
||||
})
|
||||
|
||||
export const scaleIn = keyframes({
|
||||
from: {
|
||||
transform: "scale(0.95)",
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
transform: "scale(1)",
|
||||
opacity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export const scaleOut = keyframes({
|
||||
from: {
|
||||
transform: "scale(1)",
|
||||
opacity: 1,
|
||||
},
|
||||
to: {
|
||||
transform: "scale(0.95)",
|
||||
opacity: 0,
|
||||
},
|
||||
})
|
||||
|
||||
export const shimmer = keyframes({
|
||||
"0%": {
|
||||
backgroundPosition: "-1000px 0",
|
||||
},
|
||||
"100%": {
|
||||
backgroundPosition: "1000px 0",
|
||||
},
|
||||
})
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { style, styleVariants } from "@vanilla-extract/css"
|
||||
import { themeContract } from "./theme.css"
|
||||
|
||||
/**
|
||||
* Base glass-morphism effect
|
||||
* Provides the signature frosted glass look
|
||||
*/
|
||||
const glassBase = style({
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: `1px solid ${themeContract.colors.document.border}`,
|
||||
borderRadius: themeContract.radii.lg,
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass effect variants
|
||||
*/
|
||||
export const glass = styleVariants({
|
||||
/**
|
||||
* Light glass effect - subtle background
|
||||
*/
|
||||
light: [
|
||||
glassBase,
|
||||
{
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Medium glass effect - more visible
|
||||
*/
|
||||
medium: [
|
||||
glassBase,
|
||||
{
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Dark glass effect - prominent
|
||||
*/
|
||||
dark: [
|
||||
glassBase,
|
||||
{
|
||||
background: "rgba(15, 20, 25, 0.8)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass panel styles for larger containers
|
||||
*/
|
||||
export const glassPanel = styleVariants({
|
||||
default: {
|
||||
background: "rgba(15, 20, 25, 0.8)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
border: `1px solid ${themeContract.colors.document.border}`,
|
||||
borderRadius: themeContract.radii.xl,
|
||||
},
|
||||
bordered: {
|
||||
background: "rgba(15, 20, 25, 0.8)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
border: `2px solid ${themeContract.colors.document.border}`,
|
||||
borderRadius: themeContract.radii.xl,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Focus ring styles for accessibility
|
||||
*/
|
||||
export const focusRing = style({
|
||||
outline: "none",
|
||||
selectors: {
|
||||
"&:focus-visible": {
|
||||
outline: `2px solid ${themeContract.colors.accent.primary}`,
|
||||
outlineOffset: "2px",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Transition presets
|
||||
*/
|
||||
export const transition = styleVariants({
|
||||
fast: {
|
||||
transition: themeContract.transitions.fast,
|
||||
},
|
||||
normal: {
|
||||
transition: themeContract.transitions.normal,
|
||||
},
|
||||
slow: {
|
||||
transition: themeContract.transitions.slow,
|
||||
},
|
||||
all: {
|
||||
transition: `all ${themeContract.transitions.normal}`,
|
||||
},
|
||||
colors: {
|
||||
transition: `background-color ${themeContract.transitions.normal}, color ${themeContract.transitions.normal}, border-color ${themeContract.transitions.normal}`,
|
||||
},
|
||||
transform: {
|
||||
transition: `transform ${themeContract.transitions.normal}`,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Hover glow effect
|
||||
*/
|
||||
export const hoverGlow = style({
|
||||
position: "relative",
|
||||
transition: themeContract.transitions.normal,
|
||||
selectors: {
|
||||
"&:hover": {
|
||||
boxShadow: `0 0 20px ${themeContract.colors.document.glow}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { globalStyle } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Global CSS reset and base styles
|
||||
*/
|
||||
|
||||
// Box sizing reset
|
||||
globalStyle("*, *::before, *::after", {
|
||||
boxSizing: "border-box",
|
||||
})
|
||||
|
||||
// Remove default margins
|
||||
globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", {
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
// Remove list styles
|
||||
globalStyle("ul[role='list'], ol[role='list']", {
|
||||
listStyle: "none",
|
||||
})
|
||||
|
||||
// Core body defaults
|
||||
globalStyle("html, body", {
|
||||
height: "100%",
|
||||
})
|
||||
|
||||
globalStyle("body", {
|
||||
lineHeight: 1.5,
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
MozOsxFontSmoothing: "grayscale",
|
||||
})
|
||||
|
||||
// Typography defaults
|
||||
globalStyle("h1, h2, h3, h4, h5, h6", {
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.25,
|
||||
})
|
||||
|
||||
// Inherit fonts for inputs and buttons
|
||||
globalStyle("input, button, textarea, select", {
|
||||
font: "inherit",
|
||||
})
|
||||
|
||||
// Remove default button styles
|
||||
globalStyle("button", {
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
})
|
||||
|
||||
// Improve media defaults
|
||||
globalStyle("img, picture, video, canvas, svg", {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
})
|
||||
|
||||
// Remove built-in form typography styles
|
||||
globalStyle("input, button, textarea, select", {
|
||||
font: "inherit",
|
||||
})
|
||||
|
||||
// Avoid text overflows
|
||||
globalStyle("p, h1, h2, h3, h4, h5, h6", {
|
||||
overflowWrap: "break-word",
|
||||
})
|
||||
|
||||
// Improve text rendering
|
||||
globalStyle("#root, #__next", {
|
||||
isolation: "isolate",
|
||||
})
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* Style system exports
|
||||
* Provides theme, sprinkles, animations, and effects for the memory-graph package
|
||||
*/
|
||||
|
||||
// Import global styles (side effect)
|
||||
import "./global.css"
|
||||
|
||||
// Theme
|
||||
export { themeContract, defaultTheme } from "./theme.css"
|
||||
|
||||
// Sprinkles utilities
|
||||
export { sprinkles } from "./sprinkles.css"
|
||||
export type { Sprinkles } from "./sprinkles.css"
|
||||
|
||||
// Animations
|
||||
export * as animations from "./animations.css"
|
||||
|
||||
// Glass-morphism effects
|
||||
export {
|
||||
glass,
|
||||
glassPanel,
|
||||
focusRing,
|
||||
transition,
|
||||
hoverGlow,
|
||||
} from "./effects.css"
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"
|
||||
import { themeContract } from "./theme.css"
|
||||
|
||||
/**
|
||||
* Responsive conditions for mobile-first design
|
||||
*/
|
||||
const responsiveProperties = defineProperties({
|
||||
conditions: {
|
||||
mobile: {},
|
||||
tablet: { "@media": "screen and (min-width: 768px)" },
|
||||
desktop: { "@media": "screen and (min-width: 1024px)" },
|
||||
},
|
||||
defaultCondition: "mobile",
|
||||
properties: {
|
||||
// Display
|
||||
display: ["none", "flex", "block", "inline", "inline-flex", "grid"],
|
||||
|
||||
// Flexbox
|
||||
flexDirection: ["row", "column", "row-reverse", "column-reverse"],
|
||||
justifyContent: [
|
||||
"stretch",
|
||||
"flex-start",
|
||||
"center",
|
||||
"flex-end",
|
||||
"space-between",
|
||||
"space-around",
|
||||
"space-evenly",
|
||||
],
|
||||
alignItems: ["stretch", "flex-start", "center", "flex-end", "baseline"],
|
||||
flexWrap: ["nowrap", "wrap", "wrap-reverse"],
|
||||
gap: themeContract.space,
|
||||
|
||||
// Spacing
|
||||
padding: themeContract.space,
|
||||
paddingTop: themeContract.space,
|
||||
paddingBottom: themeContract.space,
|
||||
paddingLeft: themeContract.space,
|
||||
paddingRight: themeContract.space,
|
||||
margin: themeContract.space,
|
||||
marginTop: themeContract.space,
|
||||
marginBottom: themeContract.space,
|
||||
marginLeft: themeContract.space,
|
||||
marginRight: themeContract.space,
|
||||
|
||||
// Sizing
|
||||
width: {
|
||||
auto: "auto",
|
||||
full: "100%",
|
||||
screen: "100vw",
|
||||
min: "min-content",
|
||||
max: "max-content",
|
||||
fit: "fit-content",
|
||||
},
|
||||
height: {
|
||||
auto: "auto",
|
||||
full: "100%",
|
||||
screen: "100vh",
|
||||
min: "min-content",
|
||||
max: "max-content",
|
||||
fit: "fit-content",
|
||||
},
|
||||
minWidth: {
|
||||
0: "0",
|
||||
full: "100%",
|
||||
min: "min-content",
|
||||
max: "max-content",
|
||||
fit: "fit-content",
|
||||
},
|
||||
minHeight: {
|
||||
0: "0",
|
||||
full: "100%",
|
||||
screen: "100vh",
|
||||
},
|
||||
maxWidth: {
|
||||
none: "none",
|
||||
full: "100%",
|
||||
min: "min-content",
|
||||
max: "max-content",
|
||||
fit: "fit-content",
|
||||
},
|
||||
maxHeight: {
|
||||
none: "none",
|
||||
full: "100%",
|
||||
screen: "100vh",
|
||||
},
|
||||
|
||||
// Position
|
||||
position: ["static", "relative", "absolute", "fixed", "sticky"],
|
||||
top: themeContract.space,
|
||||
bottom: themeContract.space,
|
||||
left: themeContract.space,
|
||||
right: themeContract.space,
|
||||
inset: themeContract.space,
|
||||
|
||||
// Border radius
|
||||
borderRadius: themeContract.radii,
|
||||
borderTopLeftRadius: themeContract.radii,
|
||||
borderTopRightRadius: themeContract.radii,
|
||||
borderBottomLeftRadius: themeContract.radii,
|
||||
borderBottomRightRadius: themeContract.radii,
|
||||
|
||||
// Text
|
||||
fontSize: themeContract.typography.fontSize,
|
||||
fontWeight: themeContract.typography.fontWeight,
|
||||
lineHeight: themeContract.typography.lineHeight,
|
||||
textAlign: ["left", "center", "right", "justify"],
|
||||
|
||||
// Overflow
|
||||
overflow: ["visible", "hidden", "scroll", "auto"],
|
||||
overflowX: ["visible", "hidden", "scroll", "auto"],
|
||||
overflowY: ["visible", "hidden", "scroll", "auto"],
|
||||
|
||||
// Z-index
|
||||
zIndex: themeContract.zIndex,
|
||||
|
||||
// Cursor
|
||||
cursor: ["auto", "pointer", "not-allowed", "grab", "grabbing"],
|
||||
|
||||
// Pointer events
|
||||
pointerEvents: ["auto", "none"],
|
||||
|
||||
// User select
|
||||
userSelect: ["auto", "none", "text", "all"],
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Color properties (non-responsive)
|
||||
*/
|
||||
const colorProperties = defineProperties({
|
||||
properties: {
|
||||
color: {
|
||||
primary: themeContract.colors.text.primary,
|
||||
secondary: themeContract.colors.text.secondary,
|
||||
muted: themeContract.colors.text.muted,
|
||||
},
|
||||
backgroundColor: {
|
||||
transparent: "transparent",
|
||||
primary: themeContract.colors.background.primary,
|
||||
secondary: themeContract.colors.background.secondary,
|
||||
accent: themeContract.colors.background.accent,
|
||||
documentPrimary: themeContract.colors.document.primary,
|
||||
documentSecondary: themeContract.colors.document.secondary,
|
||||
documentAccent: themeContract.colors.document.accent,
|
||||
memoryPrimary: themeContract.colors.memory.primary,
|
||||
memorySecondary: themeContract.colors.memory.secondary,
|
||||
memoryAccent: themeContract.colors.memory.accent,
|
||||
},
|
||||
borderColor: {
|
||||
transparent: "transparent",
|
||||
documentBorder: themeContract.colors.document.border,
|
||||
memoryBorder: themeContract.colors.memory.border,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Border properties
|
||||
*/
|
||||
const borderProperties = defineProperties({
|
||||
properties: {
|
||||
borderWidth: {
|
||||
0: "0",
|
||||
1: "1px",
|
||||
2: "2px",
|
||||
4: "4px",
|
||||
},
|
||||
borderStyle: ["none", "solid", "dashed", "dotted"],
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Opacity properties
|
||||
*/
|
||||
const opacityProperties = defineProperties({
|
||||
properties: {
|
||||
opacity: {
|
||||
0: "0",
|
||||
10: "0.1",
|
||||
20: "0.2",
|
||||
30: "0.3",
|
||||
40: "0.4",
|
||||
50: "0.5",
|
||||
60: "0.6",
|
||||
70: "0.7",
|
||||
80: "0.8",
|
||||
90: "0.9",
|
||||
100: "1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Combined sprinkles system
|
||||
* Provides Tailwind-like utility classes with full type safety
|
||||
*/
|
||||
export const sprinkles = createSprinkles(
|
||||
responsiveProperties,
|
||||
colorProperties,
|
||||
borderProperties,
|
||||
opacityProperties,
|
||||
)
|
||||
|
||||
export type Sprinkles = Parameters<typeof sprinkles>[0]
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
import { createTheme, createThemeContract } from "@vanilla-extract/css"
|
||||
|
||||
/**
|
||||
* Theme contract defines the structure of the design system.
|
||||
* Consumers can provide custom themes that match this contract.
|
||||
*/
|
||||
export const themeContract = createThemeContract({
|
||||
colors: {
|
||||
// Background colors
|
||||
background: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
accent: null,
|
||||
},
|
||||
// Document node colors
|
||||
document: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
accent: null,
|
||||
border: null,
|
||||
glow: null,
|
||||
},
|
||||
// Memory node colors
|
||||
memory: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
accent: null,
|
||||
border: null,
|
||||
glow: null,
|
||||
},
|
||||
// Connection strengths
|
||||
connection: {
|
||||
weak: null,
|
||||
memory: null,
|
||||
medium: null,
|
||||
strong: null,
|
||||
},
|
||||
// Text colors
|
||||
text: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
muted: null,
|
||||
},
|
||||
// Accent colors
|
||||
accent: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
glow: null,
|
||||
amber: null,
|
||||
emerald: null,
|
||||
},
|
||||
// Status indicators
|
||||
status: {
|
||||
forgotten: null,
|
||||
expiring: null,
|
||||
new: null,
|
||||
},
|
||||
// Relation types
|
||||
relations: {
|
||||
updates: null,
|
||||
extends: null,
|
||||
derives: null,
|
||||
},
|
||||
},
|
||||
space: {
|
||||
0: null,
|
||||
1: null,
|
||||
2: null,
|
||||
3: null,
|
||||
4: null,
|
||||
5: null,
|
||||
6: null,
|
||||
8: null,
|
||||
10: null,
|
||||
12: null,
|
||||
16: null,
|
||||
20: null,
|
||||
24: null,
|
||||
32: null,
|
||||
40: null,
|
||||
48: null,
|
||||
64: null,
|
||||
},
|
||||
radii: {
|
||||
none: null,
|
||||
sm: null,
|
||||
md: null,
|
||||
lg: null,
|
||||
xl: null,
|
||||
"2xl": null,
|
||||
full: null,
|
||||
},
|
||||
typography: {
|
||||
fontSize: {
|
||||
xs: null,
|
||||
sm: null,
|
||||
base: null,
|
||||
lg: null,
|
||||
xl: null,
|
||||
"2xl": null,
|
||||
"3xl": null,
|
||||
},
|
||||
fontWeight: {
|
||||
normal: null,
|
||||
medium: null,
|
||||
semibold: null,
|
||||
bold: null,
|
||||
},
|
||||
lineHeight: {
|
||||
tight: null,
|
||||
normal: null,
|
||||
relaxed: null,
|
||||
},
|
||||
},
|
||||
transitions: {
|
||||
fast: null,
|
||||
normal: null,
|
||||
slow: null,
|
||||
},
|
||||
zIndex: {
|
||||
base: null,
|
||||
dropdown: null,
|
||||
overlay: null,
|
||||
modal: null,
|
||||
tooltip: null,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Default theme implementation based on the original constants.ts colors
|
||||
* This provides the glass-morphism dark theme used throughout the app.
|
||||
*/
|
||||
export const defaultTheme = createTheme(themeContract, {
|
||||
colors: {
|
||||
background: {
|
||||
primary: "#0f1419", // Deep dark blue-gray
|
||||
secondary: "#1a1f29", // Slightly lighter
|
||||
accent: "#252a35", // Card backgrounds
|
||||
},
|
||||
document: {
|
||||
primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
|
||||
secondary: "rgba(255, 255, 255, 0.12)", // More visible
|
||||
accent: "rgba(255, 255, 255, 0.18)", // Hover state
|
||||
border: "rgba(255, 255, 255, 0.25)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
|
||||
},
|
||||
memory: {
|
||||
primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
|
||||
secondary: "rgba(147, 197, 253, 0.16)", // More visible
|
||||
accent: "rgba(147, 197, 253, 0.24)", // Hover state
|
||||
border: "rgba(147, 197, 253, 0.35)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
|
||||
},
|
||||
connection: {
|
||||
weak: "rgba(148, 163, 184, 0)", // Very subtle
|
||||
memory: "rgba(148, 163, 184, 0.3)", // Very subtle
|
||||
medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
|
||||
strong: "rgba(148, 163, 184, 0.4)", // Strong connection
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff", // Pure white
|
||||
secondary: "#e2e8f0", // Light gray
|
||||
muted: "#94a3b8", // Medium gray
|
||||
},
|
||||
accent: {
|
||||
primary: "rgba(59, 130, 246, 0.7)", // Clean blue
|
||||
secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
|
||||
glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
|
||||
amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
|
||||
emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
|
||||
},
|
||||
status: {
|
||||
forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
|
||||
expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
|
||||
new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
|
||||
},
|
||||
relations: {
|
||||
updates: "rgba(147, 77, 253, 0.5)", // purple
|
||||
extends: "rgba(16, 185, 129, 0.5)", // green
|
||||
derives: "rgba(147, 197, 253, 0.5)", // blue
|
||||
},
|
||||
},
|
||||
space: {
|
||||
0: "0",
|
||||
1: "0.25rem", // 4px
|
||||
2: "0.5rem", // 8px
|
||||
3: "0.75rem", // 12px
|
||||
4: "1rem", // 16px
|
||||
5: "1.25rem", // 20px
|
||||
6: "1.5rem", // 24px
|
||||
8: "2rem", // 32px
|
||||
10: "2.5rem", // 40px
|
||||
12: "3rem", // 48px
|
||||
16: "4rem", // 64px
|
||||
20: "5rem", // 80px
|
||||
24: "6rem", // 96px
|
||||
32: "8rem", // 128px
|
||||
40: "10rem", // 160px
|
||||
48: "12rem", // 192px
|
||||
64: "16rem", // 256px
|
||||
},
|
||||
radii: {
|
||||
none: "0",
|
||||
sm: "0.125rem", // 2px
|
||||
md: "0.375rem", // 6px
|
||||
lg: "0.5rem", // 8px
|
||||
xl: "0.75rem", // 12px
|
||||
"2xl": "1rem", // 16px
|
||||
full: "9999px",
|
||||
},
|
||||
typography: {
|
||||
fontSize: {
|
||||
xs: "0.75rem", // 12px
|
||||
sm: "0.875rem", // 14px
|
||||
base: "1rem", // 16px
|
||||
lg: "1.125rem", // 18px
|
||||
xl: "1.25rem", // 20px
|
||||
"2xl": "1.5rem", // 24px
|
||||
"3xl": "1.875rem", // 30px
|
||||
},
|
||||
fontWeight: {
|
||||
normal: "400",
|
||||
medium: "500",
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
},
|
||||
lineHeight: {
|
||||
tight: "1.25",
|
||||
normal: "1.5",
|
||||
relaxed: "1.75",
|
||||
},
|
||||
},
|
||||
transitions: {
|
||||
fast: "150ms ease-in-out",
|
||||
normal: "200ms ease-in-out",
|
||||
slow: "300ms ease-in-out",
|
||||
},
|
||||
zIndex: {
|
||||
base: "0",
|
||||
dropdown: "10",
|
||||
overlay: "20",
|
||||
modal: "30",
|
||||
tooltip: "40",
|
||||
},
|
||||
})
|
||||
|
|
@ -1,148 +1,204 @@
|
|||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./api-types"
|
||||
// Graph API types matching backend response
|
||||
|
||||
// Re-export for convenience
|
||||
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
|
||||
import type { MemoryRelation } from "./api-types"
|
||||
|
||||
export interface GraphApiMemory {
|
||||
id: string
|
||||
memory: string
|
||||
content?: string | null
|
||||
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
|
||||
// Relation fields from backend
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
// Source/join fields
|
||||
spaceContainerTag?: string | null
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
edgeType: MemoryRelation
|
||||
}
|
||||
|
||||
// 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
|
||||
relation?: MemoryRelation | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: "document" | "memory"
|
||||
x: number
|
||||
y: number
|
||||
data: DocumentWithMemories | MemoryEntry
|
||||
data: DocumentNodeData | MemoryNodeData
|
||||
size: number
|
||||
color: string
|
||||
borderColor: string
|
||||
isHovered: boolean
|
||||
isDragging: boolean
|
||||
// D3-force simulation properties
|
||||
vx?: number // velocity x
|
||||
vy?: number // velocity y
|
||||
fx?: number | null // fixed x position (for pinning during drag)
|
||||
fy?: number | null // fixed y position (for pinning during drag)
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
|
||||
export type MemoryRelation = "updates" | "extends" | "derives"
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string
|
||||
// D3-force mutates source/target from string IDs to node references during simulation
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
similarity: number
|
||||
visualProps: {
|
||||
opacity: number
|
||||
thickness: number
|
||||
glow: number
|
||||
pulseDuration: number
|
||||
}
|
||||
color: string
|
||||
edgeType: "doc-memory" | "doc-doc" | "version"
|
||||
relationType?: MemoryRelation
|
||||
edgeType: MemoryRelation
|
||||
}
|
||||
|
||||
export interface SpacesDropdownProps {
|
||||
selectedSpace: string
|
||||
availableSpaces: string[]
|
||||
spaceMemoryCounts: Record<string, number>
|
||||
onSpaceChange: (space: string) => void
|
||||
}
|
||||
|
||||
export interface NodeDetailPanelProps {
|
||||
node: GraphNode | null
|
||||
onClose: () => void
|
||||
variant?: "console" | "consumer"
|
||||
export interface GraphThemeColors {
|
||||
bg: string
|
||||
docFill: string
|
||||
docStroke: string
|
||||
docInnerFill: string
|
||||
memFill: string
|
||||
memFillHover: string
|
||||
memStrokeDefault: string
|
||||
accent: string
|
||||
textPrimary: string
|
||||
textSecondary: string
|
||||
textMuted: string
|
||||
edgeDerives: string
|
||||
edgeUpdates: string
|
||||
edgeExtends: string
|
||||
memBorderForgotten: string
|
||||
memBorderExpiring: string
|
||||
memBorderRecent: string
|
||||
glowColor: string
|
||||
iconColor: string
|
||||
popoverBg: string
|
||||
popoverBorder: string
|
||||
popoverTextPrimary: string
|
||||
popoverTextSecondary: string
|
||||
popoverTextMuted: string
|
||||
controlBg: string
|
||||
controlBorder: string
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
panX: number
|
||||
panY: number
|
||||
zoom: number
|
||||
width: number
|
||||
height: number
|
||||
onNodeHover: (nodeId: string | null) => void
|
||||
onNodeClick: (nodeId: string) => void
|
||||
onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void
|
||||
onNodeDragMove: (e: React.MouseEvent) => void
|
||||
onNodeDragEnd: () => void
|
||||
onPanStart: (e: React.MouseEvent) => void
|
||||
onPanMove: (e: React.MouseEvent) => void
|
||||
onPanEnd: () => void
|
||||
onWheel: (e: React.WheelEvent) => void
|
||||
onDoubleClick: (e: React.MouseEvent) => void
|
||||
onTouchStart?: (e: React.TouchEvent) => void
|
||||
onTouchMove?: (e: React.TouchEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
draggingNodeId: string | null
|
||||
// Optional list of document IDs (customId or internal id) to highlight
|
||||
colors: GraphThemeColors
|
||||
highlightDocumentIds?: string[]
|
||||
// Physics simulation state
|
||||
isSimulationActive?: boolean
|
||||
// Selected node ID - dims all other nodes and edges
|
||||
selectedNodeId?: string | null
|
||||
onNodeHover: (nodeId: string | null) => void
|
||||
onNodeClick: (nodeId: string | null) => void
|
||||
onNodeDragStart: (nodeId: string) => void
|
||||
onNodeDragEnd: () => void
|
||||
onViewportChange?: (zoom: number, popoverVisible: boolean) => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
simulation?: import("./canvas/simulation").ForceSimulation
|
||||
viewportRef?: React.RefObject<
|
||||
import("./canvas/viewport").ViewportState | null
|
||||
>
|
||||
}
|
||||
|
||||
export interface MemoryGraphProps {
|
||||
/** The documents to display in the graph */
|
||||
documents: DocumentWithMemories[]
|
||||
/** Whether the initial data is loading */
|
||||
/** Documents to display - pass this for direct data mode */
|
||||
documents?: GraphApiDocument[]
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean
|
||||
/** Error that occurred during data fetching */
|
||||
error?: Error | null
|
||||
/** Optional children to render when no documents exist */
|
||||
children?: React.ReactNode
|
||||
/** Whether more data is being loaded (for pagination) */
|
||||
/** Whether more data is being loaded */
|
||||
isLoadingMore?: boolean
|
||||
/** Total number of documents loaded */
|
||||
totalLoaded?: number
|
||||
/** Callback to load more documents */
|
||||
onLoadMore?: () => void
|
||||
/** Whether there are more documents to load */
|
||||
hasMore?: boolean
|
||||
/** Callback to load more documents (for pagination) */
|
||||
loadMoreDocuments?: () => Promise<void>
|
||||
/** Show/hide the spaces filter dropdown */
|
||||
showSpacesSelector?: boolean
|
||||
/** Visual variant - "console" for full view, "consumer" for embedded */
|
||||
/** Error from data fetching */
|
||||
error?: Error | null
|
||||
/** Children to render when no documents */
|
||||
children?: React.ReactNode
|
||||
/** Visual variant */
|
||||
variant?: "console" | "consumer"
|
||||
/** Optional ID for the legend component */
|
||||
/** Optional legend ID */
|
||||
legendId?: string
|
||||
/** Document IDs to highlight in the graph */
|
||||
/** Document IDs to highlight */
|
||||
highlightDocumentIds?: string[]
|
||||
/** Whether highlights are currently visible */
|
||||
/** Whether highlights are visible */
|
||||
highlightsVisible?: boolean
|
||||
/** Pixels occluded on the right side of the viewport */
|
||||
occludedRightPx?: number
|
||||
/** Whether to auto-load more documents based on viewport visibility */
|
||||
autoLoadOnViewport?: boolean
|
||||
/** Theme class name to apply */
|
||||
themeClassName?: string
|
||||
|
||||
// External space control
|
||||
/** Currently selected space (for controlled component) */
|
||||
selectedSpace?: string
|
||||
/** Callback when space selection changes (for controlled component) */
|
||||
onSpaceChange?: (spaceId: string) => void
|
||||
|
||||
// Memory limit control
|
||||
/** Maximum number of memories to display per document when a space is selected */
|
||||
memoryLimit?: number
|
||||
/** Maximum total number of memory nodes to display across all documents (default: unlimited) */
|
||||
/** Container tags for filtering (used by apps with their own API hooks) */
|
||||
containerTags?: string[]
|
||||
/** Specific document IDs to show */
|
||||
documentIds?: string[]
|
||||
/** Max nodes to display */
|
||||
maxNodes?: number
|
||||
|
||||
// Feature flags
|
||||
/** Enable experimental features */
|
||||
isExperimental?: boolean
|
||||
|
||||
// Slideshow control
|
||||
/** Whether slideshow mode is currently active */
|
||||
/** Show FPS counter overlay */
|
||||
showFps?: boolean
|
||||
/** Slideshow mode */
|
||||
isSlideshowActive?: boolean
|
||||
/** Callback when slideshow selects a new node (provides node ID) */
|
||||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
/** Callback when user clicks outside during slideshow (to stop it) */
|
||||
onSlideshowStop?: () => void
|
||||
/** Canvas ref for external access (e.g. screenshot export) */
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
/** Custom theme colors - if not provided, reads from CSS variables */
|
||||
colors?: GraphThemeColors
|
||||
/** Total count for loading indicator */
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
id: string
|
||||
version: number
|
||||
memory: string
|
||||
isForgotten: boolean
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
|
|
@ -160,9 +216,9 @@ export interface LoadingIndicatorProps {
|
|||
variant?: "console" | "consumer"
|
||||
}
|
||||
|
||||
export interface ControlsProps {
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onResetView: () => void
|
||||
variant?: "console" | "consumer"
|
||||
}
|
||||
// Re-export api-types for backward compatibility
|
||||
export type {
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
DocumentsResponse,
|
||||
} from "./api-types"
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
|
||||
import { style, globalStyle } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Base styles for SVG icons inside badges
|
||||
*/
|
||||
export const badgeIcon = style({
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
pointerEvents: "none",
|
||||
})
|
||||
|
||||
/**
|
||||
* Badge recipe with variants
|
||||
* Replaces CVA-based badge variants with vanilla-extract recipes
|
||||
*/
|
||||
const badgeBase = style({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: themeContract.radii.md,
|
||||
border: "1px solid",
|
||||
paddingLeft: themeContract.space[2],
|
||||
paddingRight: themeContract.space[2],
|
||||
paddingTop: "0.125rem",
|
||||
paddingBottom: "0.125rem",
|
||||
fontSize: themeContract.typography.fontSize.xs,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
width: "fit-content",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
gap: themeContract.space[1],
|
||||
transition: "color 200ms ease-in-out, box-shadow 200ms ease-in-out",
|
||||
overflow: "hidden",
|
||||
|
||||
selectors: {
|
||||
"&:focus-visible": {
|
||||
borderColor: themeContract.colors.accent.primary,
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`,
|
||||
},
|
||||
"&[aria-invalid='true']": {
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`,
|
||||
borderColor: themeContract.colors.status.forgotten,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Global style for SVG children
|
||||
globalStyle(`${badgeBase} > svg`, {
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
pointerEvents: "none",
|
||||
})
|
||||
|
||||
export const badge = recipe({
|
||||
base: badgeBase,
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: {
|
||||
borderColor: "transparent",
|
||||
backgroundColor: themeContract.colors.accent.primary,
|
||||
color: themeContract.colors.text.primary,
|
||||
|
||||
selectors: {
|
||||
"a&:hover": {
|
||||
opacity: 0.9,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
secondary: {
|
||||
borderColor: "transparent",
|
||||
backgroundColor: themeContract.colors.background.secondary,
|
||||
color: themeContract.colors.text.secondary,
|
||||
|
||||
selectors: {
|
||||
"a&:hover": {
|
||||
backgroundColor: themeContract.colors.background.accent,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
destructive: {
|
||||
borderColor: "transparent",
|
||||
backgroundColor: themeContract.colors.status.forgotten,
|
||||
color: themeContract.colors.text.primary,
|
||||
|
||||
selectors: {
|
||||
"a&:hover": {
|
||||
opacity: 0.9,
|
||||
},
|
||||
"&:focus-visible": {
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
outline: {
|
||||
borderColor: themeContract.colors.document.border,
|
||||
backgroundColor: "transparent",
|
||||
color: themeContract.colors.text.primary,
|
||||
|
||||
selectors: {
|
||||
"a&:hover": {
|
||||
backgroundColor: themeContract.colors.document.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
})
|
||||
|
||||
export type BadgeVariants = RecipeVariants<typeof badge>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import type * as React from "react"
|
||||
import { badge, type BadgeVariants } from "./badge.css"
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & BadgeVariants & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${badge({ variant })} ${className}`
|
||||
: badge({ variant })
|
||||
|
||||
return <Comp className={combinedClassName} data-slot="badge" {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badge as badgeVariants }
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
|
||||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Base styles for SVG icons inside buttons
|
||||
*/
|
||||
export const buttonIcon = style({
|
||||
pointerEvents: "none",
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
"&:not([class*='size-'])": {
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Button recipe with variants
|
||||
* Replaces CVA-based button variants with vanilla-extract recipes
|
||||
*/
|
||||
export const button = recipe({
|
||||
base: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: themeContract.space[2],
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: themeContract.radii.md,
|
||||
fontSize: themeContract.typography.fontSize.sm,
|
||||
fontWeight: themeContract.typography.fontWeight.medium,
|
||||
transition: themeContract.transitions.normal,
|
||||
flexShrink: 0,
|
||||
outline: "none",
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
|
||||
// SVG sizing
|
||||
selectors: {
|
||||
[`&:has(${buttonIcon})`]: {
|
||||
// Buttons with icons get adjusted padding
|
||||
},
|
||||
"&:disabled": {
|
||||
pointerEvents: "none",
|
||||
opacity: 0.5,
|
||||
},
|
||||
"&:focus-visible": {
|
||||
borderColor: themeContract.colors.accent.primary,
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`,
|
||||
},
|
||||
"&[aria-invalid='true']": {
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}`,
|
||||
borderColor: themeContract.colors.status.forgotten,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: {
|
||||
backgroundColor: themeContract.colors.accent.primary,
|
||||
color: themeContract.colors.text.primary,
|
||||
boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
backgroundColor: themeContract.colors.accent.secondary,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
destructive: {
|
||||
backgroundColor: themeContract.colors.status.forgotten,
|
||||
color: themeContract.colors.text.primary,
|
||||
boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
opacity: 0.9,
|
||||
},
|
||||
"&:focus-visible": {
|
||||
boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
outline: {
|
||||
backgroundColor: themeContract.colors.background.primary,
|
||||
borderColor: themeContract.colors.document.border,
|
||||
color: themeContract.colors.text.primary,
|
||||
boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
backgroundColor: themeContract.colors.document.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
secondary: {
|
||||
backgroundColor: themeContract.colors.background.secondary,
|
||||
color: themeContract.colors.text.secondary,
|
||||
boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
backgroundColor: themeContract.colors.background.accent,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ghost: {
|
||||
backgroundColor: "transparent",
|
||||
color: themeContract.colors.text.primary,
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
backgroundColor: themeContract.colors.document.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
link: {
|
||||
backgroundColor: "transparent",
|
||||
color: themeContract.colors.accent.primary,
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: "4px",
|
||||
|
||||
selectors: {
|
||||
"&:hover:not(:disabled)": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
settingsNav: {
|
||||
cursor: "pointer",
|
||||
borderRadius: themeContract.radii.sm,
|
||||
backgroundColor: "transparent",
|
||||
color: themeContract.colors.text.primary,
|
||||
},
|
||||
},
|
||||
|
||||
size: {
|
||||
default: {
|
||||
height: "36px",
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
paddingTop: themeContract.space[2],
|
||||
paddingBottom: themeContract.space[2],
|
||||
|
||||
selectors: {
|
||||
"&:has(svg)": {
|
||||
paddingLeft: themeContract.space[3],
|
||||
paddingRight: themeContract.space[3],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
sm: {
|
||||
height: "32px",
|
||||
borderRadius: themeContract.radii.md,
|
||||
gap: themeContract.space[1],
|
||||
paddingLeft: themeContract.space[3],
|
||||
paddingRight: themeContract.space[3],
|
||||
|
||||
selectors: {
|
||||
"&:has(svg)": {
|
||||
paddingLeft: themeContract.space[2],
|
||||
paddingRight: themeContract.space[2],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
lg: {
|
||||
height: "40px",
|
||||
borderRadius: themeContract.radii.md,
|
||||
paddingLeft: themeContract.space[6],
|
||||
paddingRight: themeContract.space[6],
|
||||
|
||||
selectors: {
|
||||
"&:has(svg)": {
|
||||
paddingLeft: themeContract.space[4],
|
||||
paddingRight: themeContract.space[4],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
icon: {
|
||||
width: "36px",
|
||||
height: "36px",
|
||||
padding: 0,
|
||||
},
|
||||
|
||||
settingsNav: {
|
||||
height: "32px",
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
})
|
||||
|
||||
export type ButtonVariants = RecipeVariants<typeof button>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import type * as React from "react"
|
||||
import { button, type ButtonVariants } from "./button.css"
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
ButtonVariants & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${button({ variant, size })} ${className}`
|
||||
: button({ variant, size })
|
||||
|
||||
return <Comp className={combinedClassName} data-slot="button" {...props} />
|
||||
}
|
||||
|
||||
export { Button, button as buttonVariants }
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { recipe } from "@vanilla-extract/recipes"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Glass menu effect container
|
||||
*/
|
||||
export const glassMenuContainer = style({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Glass menu effect with customizable border radius
|
||||
*/
|
||||
export const glassMenuEffect = recipe({
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: `1px solid ${themeContract.colors.document.border}`,
|
||||
},
|
||||
|
||||
variants: {
|
||||
rounded: {
|
||||
none: {
|
||||
borderRadius: themeContract.radii.none,
|
||||
},
|
||||
sm: {
|
||||
borderRadius: themeContract.radii.sm,
|
||||
},
|
||||
md: {
|
||||
borderRadius: themeContract.radii.md,
|
||||
},
|
||||
lg: {
|
||||
borderRadius: themeContract.radii.lg,
|
||||
},
|
||||
xl: {
|
||||
borderRadius: themeContract.radii.xl,
|
||||
},
|
||||
"2xl": {
|
||||
borderRadius: themeContract.radii["2xl"],
|
||||
},
|
||||
"3xl": {
|
||||
borderRadius: "1.5rem", // Tailwind's rounded-3xl
|
||||
},
|
||||
full: {
|
||||
borderRadius: themeContract.radii.full,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
rounded: "3xl",
|
||||
},
|
||||
})
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css"
|
||||
|
||||
interface GlassMenuEffectProps {
|
||||
rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlassMenuEffect({
|
||||
rounded = "3xl",
|
||||
className = "",
|
||||
}: GlassMenuEffectProps) {
|
||||
return (
|
||||
<div className={`${glassMenuContainer} ${className}`}>
|
||||
{/* Frosted glass effect with translucent border */}
|
||||
<div className={glassMenuEffect({ rounded })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css"
|
||||
import { themeContract } from "../styles/theme.css"
|
||||
|
||||
/**
|
||||
* Responsive heading style with bold weight
|
||||
*/
|
||||
export const headingH3Bold = style({
|
||||
fontSize: "0.625rem", // 10px
|
||||
fontWeight: themeContract.typography.fontWeight.bold,
|
||||
lineHeight: "28px",
|
||||
letterSpacing: "-0.4px",
|
||||
|
||||
"@media": {
|
||||
"screen and (min-width: 640px)": {
|
||||
fontSize: themeContract.typography.fontSize.xs, // 12px
|
||||
},
|
||||
"screen and (min-width: 768px)": {
|
||||
fontSize: themeContract.typography.fontSize.sm, // 14px
|
||||
},
|
||||
"screen and (min-width: 1024px)": {
|
||||
fontSize: themeContract.typography.fontSize.base, // 16px
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { Root } from "@radix-ui/react-slot"
|
||||
import { headingH3Bold } from "./heading.css"
|
||||
|
||||
export function HeadingH3Bold({
|
||||
className,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Root : "h3"
|
||||
|
||||
const combinedClassName = className
|
||||
? `${headingH3Bold} ${className}`
|
||||
: headingH3Bold
|
||||
|
||||
return <Comp className={combinedClassName} {...props} />
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
/**
|
||||
* Canvas-based document type icon rendering utilities
|
||||
* Simplified to match supported file types: PDF, TXT, MD, DOCX, DOC, RTF, CSV, JSON
|
||||
*/
|
||||
|
||||
export type DocumentIconType =
|
||||
| "text"
|
||||
| "pdf"
|
||||
| "md"
|
||||
| "markdown"
|
||||
| "docx"
|
||||
| "doc"
|
||||
| "rtf"
|
||||
| "csv"
|
||||
| "json"
|
||||
|
||||
/**
|
||||
* Draws a document type icon on canvas
|
||||
* @param ctx - Canvas 2D rendering context
|
||||
* @param x - X position (center of icon)
|
||||
* @param y - Y position (center of icon)
|
||||
* @param size - Icon size (width/height)
|
||||
* @param type - Document type
|
||||
* @param color - Icon color (default: white)
|
||||
*/
|
||||
export function drawDocumentIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
type: string,
|
||||
color = "rgba(255, 255, 255, 0.9)",
|
||||
): void {
|
||||
ctx.save()
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = Math.max(1, size / 12)
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineJoin = "round"
|
||||
|
||||
switch (type) {
|
||||
case "pdf":
|
||||
drawPdfIcon(ctx, x, y, size)
|
||||
break
|
||||
case "md":
|
||||
case "markdown":
|
||||
drawMarkdownIcon(ctx, x, y, size)
|
||||
break
|
||||
case "doc":
|
||||
case "docx":
|
||||
drawWordIcon(ctx, x, y, size)
|
||||
break
|
||||
case "rtf":
|
||||
drawRtfIcon(ctx, x, y, size)
|
||||
break
|
||||
case "csv":
|
||||
drawCsvIcon(ctx, x, y, size)
|
||||
break
|
||||
case "json":
|
||||
drawJsonIcon(ctx, x, y, size)
|
||||
break
|
||||
case "txt":
|
||||
case "text":
|
||||
default:
|
||||
drawTextIcon(ctx, x, y, size)
|
||||
break
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Individual icon drawing functions
|
||||
|
||||
function drawTextIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Simple document outline with lines
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
const cornerFold = size * 0.2
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 2, y - h / 2)
|
||||
ctx.lineTo(x + w / 2 - cornerFold, y - h / 2)
|
||||
ctx.lineTo(x + w / 2, y - h / 2 + cornerFold)
|
||||
ctx.lineTo(x + w / 2, y + h / 2)
|
||||
ctx.lineTo(x - w / 2, y + h / 2)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
|
||||
// Text lines
|
||||
const lineSpacing = size * 0.15
|
||||
const lineWidth = size * 0.4
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - lineWidth / 2, y - lineSpacing)
|
||||
ctx.lineTo(x + lineWidth / 2, y - lineSpacing)
|
||||
ctx.moveTo(x - lineWidth / 2, y)
|
||||
ctx.lineTo(x + lineWidth / 2, y)
|
||||
ctx.moveTo(x - lineWidth / 2, y + lineSpacing)
|
||||
ctx.lineTo(x + lineWidth / 2, y + lineSpacing)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawPdfIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Document with "PDF" text
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.rect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.stroke()
|
||||
|
||||
// "PDF" letters (simplified)
|
||||
ctx.font = `bold ${size * 0.35}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText("PDF", x, y)
|
||||
}
|
||||
|
||||
function drawMarkdownIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Document with "MD" text
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.rect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.stroke()
|
||||
|
||||
// "MD" letters
|
||||
ctx.font = `bold ${size * 0.3}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText("MD", x, y)
|
||||
}
|
||||
|
||||
function drawWordIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Document with "DOC" text
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.rect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.stroke()
|
||||
|
||||
// "DOC" letters
|
||||
ctx.font = `bold ${size * 0.28}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText("DOC", x, y)
|
||||
}
|
||||
|
||||
function drawRtfIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Document with "RTF" text
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.rect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.stroke()
|
||||
|
||||
// "RTF" letters
|
||||
ctx.font = `bold ${size * 0.3}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText("RTF", x, y)
|
||||
}
|
||||
|
||||
function drawCsvIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Grid table for CSV
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
|
||||
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
|
||||
|
||||
// Grid lines (2x2)
|
||||
ctx.beginPath()
|
||||
// Vertical line
|
||||
ctx.moveTo(x, y - h / 2)
|
||||
ctx.lineTo(x, y + h / 2)
|
||||
// Horizontal line
|
||||
ctx.moveTo(x - w / 2, y)
|
||||
ctx.lineTo(x + w / 2, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawJsonIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
// Curly braces for JSON
|
||||
const w = size * 0.6
|
||||
const h = size * 0.8
|
||||
|
||||
// Left brace
|
||||
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()
|
||||
|
||||
// Right brace
|
||||
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()
|
||||
}
|
||||
|
|
@ -1,26 +1,22 @@
|
|||
{
|
||||
"extends": "@total-typescript/tsconfig/bundler/dom/library",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noEmit": false
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,36 @@
|
|||
import { defineConfig, type Plugin } from "vite"
|
||||
import { defineConfig } from "vitest/config"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { resolve } from "path"
|
||||
import { readFileSync } from "fs"
|
||||
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
/**
|
||||
* Custom plugin to embed CSS content into the JS bundle for runtime injection.
|
||||
* This allows the package to work with any bundler (Vite, webpack, Next.js, etc.)
|
||||
*/
|
||||
function injectCssPlugin(): Plugin {
|
||||
let cssContent = ""
|
||||
|
||||
return {
|
||||
name: "inject-css-content",
|
||||
enforce: "post",
|
||||
generateBundle(_, bundle) {
|
||||
// Find the generated CSS file
|
||||
for (const [fileName, chunk] of Object.entries(bundle)) {
|
||||
if (fileName.endsWith(".css") && chunk.type === "asset") {
|
||||
cssContent = chunk.source as string
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Replace placeholder in JS files with actual CSS content
|
||||
for (const [fileName, chunk] of Object.entries(bundle)) {
|
||||
if (
|
||||
(fileName.endsWith(".js") || fileName.endsWith(".cjs")) &&
|
||||
chunk.type === "chunk"
|
||||
) {
|
||||
// Escape the CSS for embedding in JS string
|
||||
const escapedCss = JSON.stringify(cssContent)
|
||||
chunk.code = chunk.code.replace(/__MEMORY_GRAPH_CSS__/g, escapedCss)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.tsx"),
|
||||
name: "MemoryGraph",
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format) => {
|
||||
if (format === "es") return "memory-graph.js"
|
||||
if (format === "cjs") return "memory-graph.cjs"
|
||||
return "memory-graph.js"
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
// Externalize only peer dependencies (React)
|
||||
external: ["react", "react-dom", "react/jsx-runtime"],
|
||||
output: {
|
||||
// Provide global variables for UMD build (if needed later)
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
"react/jsx-runtime": "react/jsx-runtime",
|
||||
},
|
||||
// Preserve CSS as separate file (for manual import fallback)
|
||||
assetFileNames: (assetInfo) => {
|
||||
// Vanilla-extract generates index.css, rename to memory-graph.css
|
||||
if (
|
||||
assetInfo.name === "index.css" ||
|
||||
assetInfo.name === "style.css"
|
||||
) {
|
||||
return "memory-graph.css"
|
||||
}
|
||||
return assetInfo.name || "asset"
|
||||
},
|
||||
// Don't preserve modules - bundle everything except externals
|
||||
preserveModules: false,
|
||||
},
|
||||
},
|
||||
// Ensure CSS is extracted
|
||||
cssCodeSplit: false,
|
||||
// Generate sourcemaps for debugging
|
||||
sourcemap: true,
|
||||
// Optimize deps
|
||||
minify: "esbuild",
|
||||
target: "esnext",
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
"memory-graph": resolve(__dirname, "src/index.tsx"),
|
||||
"mock-data": resolve(__dirname, "src/mock-data.ts"),
|
||||
},
|
||||
formats: ["es", "cjs"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "react-dom", "react/jsx-runtime"],
|
||||
output: {
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue