refactor: simplify graph API - remove backend computation, use client-side layout

- Remove x/y from GraphApiDocument (client computes via force simulation)
- Remove similarity from GraphApiEdge, add edgeType enum
- Replace viewport/bounds/stats API calls with single paginated documents endpoint
- Derive edges client-side from document structure (doc-memory, version, same-space)
- Update MCP client/server to use documents endpoint
- Update MCP UI app for new type shapes
- Update all tests for new API contract
- Progressive loading via useInfiniteQuery (100 docs/page)
This commit is contained in:
Vorflux AI 2026-03-28 05:50:22 +00:00
parent 76f5eaa446
commit 429c7e97ad
23 changed files with 384 additions and 866 deletions

View file

@ -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 {
@ -332,53 +321,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)
}

View file

@ -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,
)

View file

@ -27,11 +27,14 @@ 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
}
@ -43,22 +46,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 +84,7 @@ type GraphNode = MemoryNode | DocumentNode
interface GraphLink extends LinkObject {
source: string | GraphNode
target: string | GraphNode
edgeType: "doc-memory" | "version" | "similarity"
similarity?: number
edgeType: "doc-memory" | "version" | "same-space"
}
// =============================================================================
@ -109,12 +101,12 @@ const EDGE_COLORS = {
dark: {
"doc-memory": "#4A5568",
version: "#8B5CF6",
similarity: "#00D4B8",
"same-space": "#00D4B8",
},
light: {
"doc-memory": "#A0AEC0",
version: "#8B5CF6",
similarity: "#0D9488",
"same-space": "#0D9488",
},
}
@ -157,33 +149,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): {
@ -193,10 +172,13 @@ function transformData(data: ToolResultData): {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
const nodeIds = new Set<string>()
const SPREAD = 50
const normalizedDocs = normalizeDocCoordinates(data.documents)
// Group documents by spaceId for same-space edges
const spaceGroups = new Map<string, string[]>()
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,8 +187,8 @@ 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)
@ -227,8 +209,8 @@ 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)
@ -243,18 +225,32 @@ function transformData(data: ToolResultData): {
edgeType: "version",
})
}
// Track space groups for same-space edges
if (mem.spaceId) {
const group = spaceGroups.get(mem.spaceId)
if (group) group.push(doc.id)
else spaceGroups.set(mem.spaceId, [doc.id])
}
}
}
// 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,
})
// Same-space edges between documents sharing a space
const addedEdges = new Set<string>()
for (const docIds of spaceGroups.values()) {
const unique = [...new Set(docIds)]
for (let i = 0; i < unique.length; i++) {
for (let j = i + 1; j < unique.length; j++) {
const key = `${unique[i]}:${unique[j]}`
if (!addedEdges.has(key)) {
addedEdges.add(key)
links.push({
source: unique[i]!,
target: unique[j]!,
edgeType: "same-space",
})
}
}
}
}
@ -371,13 +367,12 @@ 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 === "same-space") return 0.5
return 1
})
.linkColor(getLinkColor)
.linkLineDash((link: GraphLink) => {
if (link.edgeType === "similarity") return [4, 2]
if (link.edgeType === "same-space") return [4, 2]
return null as unknown as number[]
})
.linkDirectionalArrowLength((link: GraphLink) =>
@ -399,7 +394,7 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
.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
return 0.15 // same-space
}),
)
.d3Force("collide", forceCollide(18))

View file

@ -21,13 +21,6 @@ interface DocumentsResponse {
/** Convert the external API format to the internal graph format */
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
// Use a seeded random for deterministic positions
let seed = 42
const rand = () => {
seed = (seed * 16807 + 0) % 2147483647
return seed / 2147483647
}
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
@ -35,8 +28,6 @@ function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
documentType: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
x: rand() * 1000,
y: rand() * 1000,
memories: doc.memories.map(
(mem): GraphApiMemory => ({
id: mem.id,
@ -136,7 +127,6 @@ export default function Home() {
const data = generateMockGraphData({
documentCount: count,
memoriesPerDoc: [2, 5],
similarityEdgeRatio: 0.05,
seed: 12345,
})
setMockData({ documents: data.documents })

View file

@ -116,9 +116,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 +138,8 @@ export const GraphCard = memo<GraphCardProps>(
)
}
const documentCount = data.stats?.documentsWithSpatial ?? 0
const memoryCount = data.documents.reduce(
(sum, d) => sum + d.memories.length,
0,
)
const documentCount = documents.length
const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0)
return (
<button

View file

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

View file

@ -12,7 +12,6 @@ export type {
GraphEdge,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
} from "@supermemory/memory-graph"
// Keep the API hook export

View file

@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphApiDocument, GraphApiEdge } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
@ -28,7 +27,6 @@ export function MemoryGraph({
error: externalError = null,
variant = "console",
containerTags,
documentIds,
maxNodes = 200,
canvasRef,
...rest
@ -48,27 +46,31 @@ export function MemoryGraph({
}, [])
const {
data: apiData,
documents,
isLoading: apiIsLoading,
isLoadingMore,
error: apiError,
hasMore,
loadMore,
totalCount,
} = useGraphApi({
containerTags,
documentIds,
limit: maxNodes,
enabled: containerSize.width > 0 && containerSize.height > 0,
})
return (
<div ref={containerRef} className="w-full h-full">
<MemoryGraphBase
documents={apiData.documents as GraphApiDocument[]}
apiEdges={apiData.edges as GraphApiEdge[]}
documents={documents}
isLoading={externalIsLoading || apiIsLoading}
isLoadingMore={isLoadingMore}
onLoadMore={hasMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
maxNodes={maxNodes}
canvasRef={canvasRef}
totalCount={apiData.totalCount}
totalCount={totalCount}
{...rest}
>
{children}

View file

@ -1,27 +1,10 @@
import { describe, it, expect } from "vitest"
import {
normalizeDocCoordinates,
getMemoryBorderColor,
getEdgeVisualProps,
screenToBackendCoords,
calculateBackendViewport,
} from "../hooks/use-graph-data"
import { DEFAULT_COLORS } from "../constants"
import type { GraphApiDocument, GraphApiMemory } from "../types"
function makeDoc(id: string, x: number, y: number): GraphApiDocument {
return {
id,
title: `Doc ${id}`,
summary: null,
documentType: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
x,
y,
memories: [],
}
}
import type { GraphApiMemory } from "../types"
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
return {
@ -42,59 +25,6 @@ function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
}
}
describe("normalizeDocCoordinates", () => {
it("normalizes coordinates to 100-900 range", () => {
const docs = [
makeDoc("a", 0, 0),
makeDoc("b", 100, 100),
makeDoc("c", 50, 50),
]
const result = normalizeDocCoordinates(docs)
for (const doc of result) {
expect(doc.x).toBeGreaterThanOrEqual(100)
expect(doc.x).toBeLessThanOrEqual(900)
expect(doc.y).toBeGreaterThanOrEqual(100)
expect(doc.y).toBeLessThanOrEqual(900)
}
})
it("maps min to 100 and max to 900", () => {
const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 200)]
const result = normalizeDocCoordinates(docs)
expect(result[0]!.x).toBeCloseTo(100)
expect(result[0]!.y).toBeCloseTo(100)
expect(result[1]!.x).toBeCloseTo(900)
expect(result[1]!.y).toBeCloseTo(900)
})
it("handles single document (returns as-is)", () => {
const docs = [makeDoc("a", 500, 300)]
const result = normalizeDocCoordinates(docs)
expect(result).toEqual(docs)
})
it("handles empty array", () => {
const result = normalizeDocCoordinates([])
expect(result).toEqual([])
})
it("handles documents at same position", () => {
const docs = [makeDoc("a", 50, 50), makeDoc("b", 50, 50)]
// Should not throw (rangeX/rangeY fallback to 1)
expect(() => normalizeDocCoordinates(docs)).not.toThrow()
})
it("preserves document data (only x/y change)", () => {
const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 100)]
const result = normalizeDocCoordinates(docs)
expect(result[0]!.id).toBe("a")
expect(result[0]!.title).toBe("Doc a")
expect(result[1]!.id).toBe("b")
})
})
describe("getMemoryBorderColor", () => {
const colors = DEFAULT_COLORS
@ -129,70 +59,27 @@ describe("getMemoryBorderColor", () => {
})
describe("getEdgeVisualProps", () => {
it("returns correct opacity and thickness for similarity 0", () => {
const props = getEdgeVisualProps(0)
it("returns correct props for doc-memory edges", () => {
const props = getEdgeVisualProps("doc-memory")
expect(props.opacity).toBeCloseTo(0.3)
expect(props.thickness).toBeCloseTo(1.5)
})
it("returns correct props for version edges", () => {
const props = getEdgeVisualProps("version")
expect(props.opacity).toBeCloseTo(0.6)
expect(props.thickness).toBeCloseTo(2)
})
it("returns correct props for same-space edges", () => {
const props = getEdgeVisualProps("same-space")
expect(props.opacity).toBeCloseTo(0.15)
expect(props.thickness).toBeCloseTo(1)
})
it("returns correct opacity and thickness for similarity 1", () => {
const props = getEdgeVisualProps(1)
expect(props.opacity).toBeCloseTo(0.8)
expect(props.thickness).toBeCloseTo(2.5)
})
it("returns intermediate values for similarity 0.5", () => {
const props = getEdgeVisualProps(0.5)
expect(props.opacity).toBeCloseTo(0.55)
expect(props.thickness).toBeCloseTo(1.75)
})
})
describe("screenToBackendCoords", () => {
it("converts screen coordinates to backend coordinates", () => {
const result = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
expect(result.x).toBeDefined()
expect(result.y).toBeDefined()
})
it("accounts for pan offset", () => {
const noPan = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
const withPan = screenToBackendCoords(400, 300, 100, 50, 1, 800, 600)
// Panning right means the backend coordinate should be smaller
expect(withPan.x).toBeLessThan(noPan.x)
expect(withPan.y).toBeLessThan(noPan.y)
})
it("accounts for zoom", () => {
const zoom1 = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
const zoom2 = screenToBackendCoords(400, 300, 0, 0, 2, 800, 600)
// At higher zoom, same screen position maps to smaller backend area
expect(zoom2.x).toBeLessThan(zoom1.x)
})
})
describe("calculateBackendViewport", () => {
it("returns min/max bounds", () => {
const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
expect(bounds.minX).toBeDefined()
expect(bounds.maxX).toBeDefined()
expect(bounds.minY).toBeDefined()
expect(bounds.maxY).toBeDefined()
expect(bounds.maxX).toBeGreaterThan(bounds.minX)
expect(bounds.maxY).toBeGreaterThan(bounds.minY)
})
it("higher zoom produces smaller viewport", () => {
const zoom1 = calculateBackendViewport(0, 0, 1, 800, 600)
const zoom2 = calculateBackendViewport(0, 0, 2, 800, 600)
const area1 = (zoom1.maxX - zoom1.minX) * (zoom1.maxY - zoom1.minY)
const area2 = (zoom2.maxX - zoom2.minX) * (zoom2.maxY - zoom2.minY)
expect(area2).toBeLessThan(area1)
})
it("minX is non-negative", () => {
const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
expect(bounds.minX).toBeGreaterThanOrEqual(0)
expect(bounds.minY).toBeGreaterThanOrEqual(0)
it("returns default props for unknown edge types", () => {
const props = getEdgeVisualProps("unknown")
expect(props.opacity).toBeCloseTo(0.3)
expect(props.thickness).toBeCloseTo(1)
})
})

View file

@ -9,8 +9,6 @@ describe("generateMockGraphData", () => {
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)
expect(data1.documents[0]!.x).toBe(data2.documents[0]!.x)
expect(data1.documents[0]!.y).toBe(data2.documents[0]!.y)
})
it("produces different output with different seeds", () => {
@ -37,8 +35,6 @@ describe("generateMockGraphData", () => {
expect(doc.documentType).toBeDefined()
expect(doc.createdAt).toBeDefined()
expect(doc.updatedAt).toBeDefined()
expect(typeof doc.x).toBe("number")
expect(typeof doc.y).toBe("number")
expect(Array.isArray(doc.memories)).toBe(true)
}
})
@ -60,33 +56,9 @@ describe("generateMockGraphData", () => {
}
})
it("generates edges", () => {
const data = generateMockGraphData({
documentCount: 20,
similarityEdgeRatio: 0.1,
seed: 1,
})
expect(data.edges.length).toBeGreaterThan(0)
})
it("edges reference valid document IDs", () => {
const data = generateMockGraphData({
documentCount: 20,
similarityEdgeRatio: 0.1,
seed: 1,
})
const allDocIds = new Set(data.documents.map((d) => d.id))
for (const edge of data.edges) {
expect(allDocIds.has(edge.source)).toBe(true)
expect(allDocIds.has(edge.target)).toBe(true)
}
})
it("handles zero documents", () => {
const data = generateMockGraphData({ documentCount: 0, seed: 1 })
expect(data.documents.length).toBe(0)
expect(data.edges.length).toBe(0)
})
it("respects memoriesPerDoc range", () => {
@ -99,4 +71,17 @@ describe("generateMockGraphData", () => {
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)
})
})

View file

@ -29,7 +29,6 @@ function makeEdge(source: string, target: string): GraphEdge {
id: `e-${source}-${target}`,
source,
target,
similarity: 0.8,
visualProps: { opacity: 0.5, thickness: 1.5 },
edgeType: "doc-memory",
}

View file

@ -30,8 +30,6 @@ function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
documentType: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
x: 0,
y: 0,
memories,
}
}

View file

@ -72,7 +72,7 @@ function drawDocDocLines(
}
// For each doc, find 2 nearest from neighboring cells only
ctx.strokeStyle = colors.edgeDocDoc
ctx.strokeStyle = colors.edgeSameSpace
ctx.lineWidth = 1
ctx.globalAlpha = 0.3
ctx.setLineDash([4, 6])
@ -135,9 +135,9 @@ function edgeStyle(
return { color: colors.edgeDocMemory, width: 1.5 }
if (edge.edgeType === "version")
return { color: colors.edgeVersion, width: 2 }
if (edge.similarity >= 0.9) return { color: colors.edgeSimStrong, width: 2 }
if (edge.similarity >= 0.8) return { color: colors.edgeSimMedium, width: 1.5 }
return { color: colors.edgeSimWeak, width: 1 }
if (edge.edgeType === "same-space")
return { color: colors.edgeSameSpace, width: 1 }
return { color: colors.edgeSameSpace, width: 1 }
}
function batchKey(style: { color: string; width: number }): string {
@ -171,10 +171,9 @@ function drawEdges(
const prepared: PreparedEdge[] = []
for (const edge of edges) {
// Zoom-based edge culling for similarity edges
if (edge.edgeType === "similarity") {
// Zoom-based edge culling for same-space edges (low-priority visual)
if (edge.edgeType === "same-space") {
if (viewport.zoom < 0.3) continue
if (viewport.zoom < 0.5 && edge.similarity < 0.9) continue
}
const src =

View file

@ -23,7 +23,8 @@ export class ForceSimulation {
.strength((link) => {
if (link.edgeType === "doc-memory") return 0.8
if (link.edgeType === "version") return 1.0
return link.similarity * 0.3
if (link.edgeType === "same-space") return 0.1
return 0.3
}),
)

View file

@ -65,7 +65,9 @@ export class VersionChainIndex {
while (cursor) {
const children = this.childrenMap.get(cursor.id)
if (!children || children.length === 0) break
const child = this.memoryMap.get(children[0])
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)

View file

@ -395,14 +395,8 @@ export const Legend = memo(function Legend({
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeSimStrong} />
<span style={edgeLabelStyle}>Similarity</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDocDoc} dashed />
<span style={edgeLabelStyle}>Doc similarity</span>
<LineIcon color={colors.edgeSameSpace} dashed />
<span style={edgeLabelStyle}>Same space</span>
</div>
</div>
</div>

View file

@ -13,8 +13,10 @@ import { NodeHoverPopover } from "./node-hover-popover"
export function MemoryGraph({
documents = [],
apiEdges = [],
isLoading: externalIsLoading = false,
isLoadingMore = false,
onLoadMore,
hasMore = false,
error: externalError = null,
children,
variant = "console",
@ -24,7 +26,6 @@ export function MemoryGraph({
maxNodes,
isSlideshowActive = false,
onSlideshowNodeChange,
onSlideshowStop: _onSlideshowStop,
canvasRef: externalCanvasRef,
colors: colorOverrides,
totalCount,
@ -58,7 +59,6 @@ export function MemoryGraph({
const { nodes, edges } = useGraphData(
limitedDocuments,
apiEdges,
null,
containerSize.width,
containerSize.height,
@ -545,11 +545,36 @@ export function MemoryGraph({
<div style={wrapperStyle}>
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={false}
isLoadingMore={isLoadingMore}
totalLoaded={totalCount ?? documents.length}
colors={colors}
/>
{!isLoading && hasMore && onLoadMore && (
<button
type="button"
onClick={onLoadMore}
style={{
position: "absolute",
top: 16,
right: 16,
zIndex: 30,
borderRadius: 12,
border: `1px solid ${colors.controlBorder}`,
backgroundColor: colors.controlBg,
color: colors.textSecondary,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 8,
paddingBottom: 8,
fontSize: 13,
cursor: "pointer",
}}
>
{isLoadingMore ? "Loading..." : "Load more"}
</button>
)}
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
<div style={emptyStateStyle}>{children}</div>
)}

View file

@ -47,10 +47,7 @@ export const DEFAULT_COLORS: GraphThemeColors = {
textMuted: "#94a3b8",
edgeDocMemory: "#4A5568",
edgeVersion: "#8B5CF6",
edgeSimStrong: "#00D4B8",
edgeSimMedium: "#6B8FBF",
edgeSimWeak: "#4A6A8A",
edgeDocDoc: "#8DA3F4",
edgeSameSpace: "#4A6A8A",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",

View file

@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react"
import type {
DocumentNodeData,
GraphApiDocument,
GraphApiEdge,
GraphApiMemory,
GraphEdge,
GraphNode,
@ -28,44 +27,34 @@ export function getMemoryBorderColor(
return colors.memStrokeDefault
}
export function getEdgeVisualProps(similarity: number) {
return {
opacity: 0.3 + similarity * 0.5,
thickness: 1 + similarity * 1.5,
export function getEdgeVisualProps(edgeType: string) {
switch (edgeType) {
case "doc-memory":
return { opacity: 0.3, thickness: 1.5 }
case "version":
return { opacity: 0.6, thickness: 2 }
case "same-space":
return { opacity: 0.15, thickness: 1 }
default:
return { opacity: 0.3, thickness: 1 }
}
}
export 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 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
}
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),
}))
return ((h >>> 0) % 10000) / 10000
}
export function useGraphData(
documents: GraphApiDocument[],
apiEdges: GraphApiEdge[],
draggingNodeId: string | null,
canvasWidth: number,
canvasHeight: number,
@ -87,30 +76,20 @@ export function useGraphData(
}
}, [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 []
if (!documents || documents.length === 0) return []
const result: GraphNode[] = []
// Place nodes in the canvas space; force simulation will refine positions
const spreadW = Math.max(canvasWidth * 0.8, 400)
const spreadH = Math.max(canvasHeight * 0.8, 400)
const padX = (canvasWidth - spreadW) / 2
const padY = (canvasHeight - spreadH) / 2
for (const doc of normalizedDocs) {
const initialX = doc.x * scale + offsetX
const initialY = doc.y * scale + offsetY
for (const doc of documents) {
// Deterministic initial position based on doc id
const initialX = padX + hashToUnit(doc.id) * spreadW
const initialY = padY + hashToUnit(`${doc.id}-y`) * spreadH
let docNode = nodeCache.current.get(doc.id)
const docData: DocumentNodeData = {
@ -177,122 +156,81 @@ export function useGraphData(
}
return result
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId, colors])
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
const edges = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
if (!documents || documents.length === 0) return []
const result: GraphEdge[] = []
// Build allNodeIds from normalizedDocs directly to avoid depending on `nodes`
// (which changes identity on every render due to draggingNodeId/colors deps)
const allNodeIds = new Set<string>()
for (const doc of normalizedDocs) {
for (const doc of documents) {
allNodeIds.add(doc.id)
for (const mem of doc.memories) allNodeIds.add(mem.id)
}
for (const doc of normalizedDocs) {
// Doc-memory edges
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,
similarity: 1,
visualProps: { opacity: 0.3, thickness: 1.5 },
visualProps: getEdgeVisualProps("doc-memory"),
edgeType: "doc-memory",
})
}
}
for (const doc of normalizedDocs) {
// Version chain edges
for (const doc of documents) {
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 },
visualProps: getEdgeVisualProps("version"),
edgeType: "version",
})
}
}
}
for (const apiEdge of apiEdges) {
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
continue
// Same-space edges: connect documents that share a spaceId
const spaceGroups = new Map<string, string[]>()
for (const doc of documents) {
for (const mem of doc.memories) {
const group = spaceGroups.get(mem.spaceId)
if (group) {
if (!group.includes(doc.id)) group.push(doc.id)
} else {
spaceGroups.set(mem.spaceId, [doc.id])
}
}
}
const addedPairs = new Set<string>()
for (const docIds of spaceGroups.values()) {
for (let i = 0; i < docIds.length; i++) {
for (let j = i + 1; j < docIds.length; j++) {
const a = docIds[i]!
const b = docIds[j]!
const key = a < b ? `${a}:${b}` : `${b}:${a}`
if (!addedPairs.has(key)) {
addedPairs.add(key)
result.push({
id: `ss-${key}`,
source: a,
target: b,
visualProps: getEdgeVisualProps("same-space"),
edgeType: "same-space",
})
}
}
}
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])
}, [documents])
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),
}
return { nodes, edges }
}

View file

@ -37,19 +37,10 @@ function resolveColors(): GraphThemeColors {
DEFAULT_COLORS.edgeDocMemory,
),
edgeVersion: readCssVar("--graph-edge-version", DEFAULT_COLORS.edgeVersion),
edgeSimStrong: readCssVar(
"--graph-edge-sim-strong",
DEFAULT_COLORS.edgeSimStrong,
edgeSameSpace: readCssVar(
"--graph-edge-same-space",
DEFAULT_COLORS.edgeSameSpace,
),
edgeSimMedium: readCssVar(
"--graph-edge-sim-medium",
DEFAULT_COLORS.edgeSimMedium,
),
edgeSimWeak: readCssVar(
"--graph-edge-sim-weak",
DEFAULT_COLORS.edgeSimWeak,
),
edgeDocDoc: readCssVar("--graph-edge-doc-doc", DEFAULT_COLORS.edgeDocDoc),
memBorderForgotten: readCssVar(
"--graph-mem-border-forgotten",
DEFAULT_COLORS.memBorderForgotten,

View file

@ -25,9 +25,6 @@ export type {
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
DocumentNodeData,
MemoryNodeData,
ChainEntry,

View file

@ -1,9 +1,8 @@
import type { GraphApiDocument, GraphApiEdge, GraphApiMemory } from "./types"
import type { GraphApiDocument, GraphApiMemory } from "./types"
export interface MockGraphOptions {
documentCount?: number
memoriesPerDoc?: number | [number, number]
similarityEdgeRatio?: number
seed?: number
}
@ -246,12 +245,10 @@ function generateISODate(
export function generateMockGraphData(options: MockGraphOptions = {}): {
documents: GraphApiDocument[]
edges: GraphApiEdge[]
} {
const {
documentCount = 100,
memoriesPerDoc = [2, 6] as [number, number],
similarityEdgeRatio = 0.1,
seed = 42,
} = options
@ -371,10 +368,6 @@ export function generateMockGraphData(options: MockGraphOptions = {}): {
})
}
// Position documents spread across a 1000x1000 space
const x = random() * 1000
const y = random() * 1000
documents.push({
id: docId,
title: generateTitle(random),
@ -383,69 +376,9 @@ export function generateMockGraphData(options: MockGraphOptions = {}): {
DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)],
createdAt: docCreatedAt,
updatedAt: docUpdatedAt,
x,
y,
memories,
})
}
// Generate similarity edges between random document pairs
const edges: GraphApiEdge[] = []
const totalPossiblePairs = (documentCount * (documentCount - 1)) / 2
const targetEdgeCount = Math.max(
0,
Math.floor(totalPossiblePairs * similarityEdgeRatio),
)
// Use a set to avoid duplicate pairs
const edgeSet = new Set<string>()
// For small document counts, iterate all pairs; for large, sample randomly
if (documentCount <= 50 || targetEdgeCount > totalPossiblePairs * 0.5) {
// Iterate all pairs and include based on probability
for (let i = 0; i < documentCount; i++) {
for (let j = i + 1; j < documentCount; j++) {
if (random() < similarityEdgeRatio) {
const sourceId = documents[i].id
const targetId = documents[j].id
const key = `${sourceId}:${targetId}`
if (!edgeSet.has(key)) {
edgeSet.add(key)
// Similarity weighted towards medium-high values
const similarity = 0.3 + random() * 0.7
edges.push({
source: sourceId,
target: targetId,
similarity: Math.round(similarity * 1000) / 1000,
})
}
}
}
}
} else {
// Random sampling for large document counts
let attempts = 0
const maxAttempts = targetEdgeCount * 5
while (edges.length < targetEdgeCount && attempts < maxAttempts) {
attempts++
const i = Math.floor(random() * documentCount)
const j = Math.floor(random() * documentCount)
if (i === j) continue
const sourceIdx = Math.min(i, j)
const targetIdx = Math.max(i, j)
const sourceId = documents[sourceIdx].id
const targetId = documents[targetIdx].id
const key = `${sourceId}:${targetId}`
if (edgeSet.has(key)) continue
edgeSet.add(key)
const similarity = 0.3 + random() * 0.7
edges.push({
source: sourceId,
target: targetId,
similarity: Math.round(similarity * 1000) / 1000,
})
}
}
return { documents, edges }
return { documents }
}

View file

@ -23,42 +23,13 @@ export interface GraphApiDocument {
documentType: string
createdAt: string
updatedAt: string
x: number
y: number
memories: GraphApiMemory[]
}
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 GraphStatsResponse {
totalDocuments: number
documentsWithSpatial: number
totalDocumentEdges: number
edgeType: "doc-memory" | "version" | "same-space"
}
// Typed node data
@ -111,12 +82,11 @@ export interface GraphEdge {
id: string
source: string | GraphNode
target: string | GraphNode
similarity: number
visualProps: {
opacity: number
thickness: number
}
edgeType: "doc-memory" | "similarity" | "version"
edgeType: "doc-memory" | "version" | "same-space"
}
export interface GraphThemeColors {
@ -133,10 +103,7 @@ export interface GraphThemeColors {
textMuted: string
edgeDocMemory: string
edgeVersion: string
edgeSimStrong: string
edgeSimMedium: string
edgeSimWeak: string
edgeDocDoc: string
edgeSameSpace: string
memBorderForgotten: string
memBorderExpiring: string
memBorderRecent: string
@ -174,10 +141,14 @@ export interface GraphCanvasProps {
export interface MemoryGraphProps {
/** Documents to display - pass this for direct data mode */
documents?: GraphApiDocument[]
/** API edges between documents */
apiEdges?: GraphApiEdge[]
/** Whether data is loading */
isLoading?: boolean
/** Whether more data is being loaded */
isLoadingMore?: boolean
/** Callback to load more documents */
onLoadMore?: () => void
/** Whether there are more documents to load */
hasMore?: boolean
/** Error from data fetching */
error?: Error | null
/** Children to render when no documents */