Improve memory graph (#973)

Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
This commit is contained in:
Dhravya Shah 2026-06-05 15:48:41 -07:00 committed by GitHub
parent ad5734cdfc
commit 053baa5029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2034 additions and 306 deletions

View file

@ -0,0 +1,45 @@
import { NextResponse } from "next/server"
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
export async function POST(request: Request) {
try {
const { apiKey } = await request.json()
if (!apiKey) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
)
}
const containerTagsUrl = new URL(
"/v3/container-tags/list",
SUPERMEMORY_API_BASE_URL,
)
const response = await fetch(containerTagsUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(
{ error: errorData.message || `API error: ${response.status}` },
{ status: response.status },
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error("Container tags API error:", error)
return NextResponse.json(
{ error: "Failed to fetch container tags" },
{ status: 500 },
)
}
}

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server"
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
export async function POST(request: Request) {
try {
const body = await request.json()
@ -9,6 +11,7 @@ export async function POST(request: Request) {
limit = 500,
sort = "createdAt",
order = "desc",
containerTags,
} = body
if (!apiKey) {
@ -18,23 +21,28 @@ export async function POST(request: Request) {
)
}
const response = await fetch(
"https://api.supermemory.ai/v3/documents/documents",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
page,
limit,
sort,
order,
}),
},
const graphUrl = new URL(
"/v3/documents/documents",
SUPERMEMORY_API_BASE_URL,
)
const response = await fetch(graphUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
page,
limit,
sort,
order,
...(Array.isArray(containerTags) && containerTags.length > 0
? { containerTags }
: {}),
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(

View file

@ -1,16 +1,52 @@
"use client"
import { useState, useCallback, useMemo } from "react"
import { useState, useCallback, useEffect, useMemo } from "react"
import {
MemoryGraph,
type DocumentWithMemories,
type GraphApiDocument,
type GraphApiMemory,
type GraphThemeColors,
type MemoryRelation,
} from "@supermemory/memory-graph"
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
interface PlaygroundApiMemory {
id: string
memory?: string | null
content?: string | null
isStatic?: boolean
spaceId?: string | null
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 PlaygroundApiDocument {
id: string
title: string | null
summary?: string | null
documentType?: string
type?: string
containerTags?: string[]
createdAt: string
updatedAt: string
memories?: PlaygroundApiMemory[]
memoryEntries?: PlaygroundApiMemory[]
}
interface DocumentsResponse {
documents: DocumentWithMemories[]
documents: PlaygroundApiDocument[]
pagination: {
currentPage: number
limit: number
@ -19,42 +55,82 @@ interface DocumentsResponse {
}
}
interface ContainerTagOption {
id: string
name?: string | null
containerTag: string
documentCount?: number
memoryCount?: number
lastActivityAt?: string | null
}
type GraphVariant = "consumer" | "console"
type LoadBehavior = "zoom" | "manual" | "background"
const PAGE_SIZE = 100
const BACKGROUND_LOAD_DELAY_MS = 900
const CONSUMER_GRAPH_COLORS = {
bg: "transparent",
edgeDerives: "#9ca3af",
} satisfies Partial<GraphThemeColors>
/** 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,
}),
),
}))
function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
return docs.map((doc) => {
const memories = doc.memories ?? doc.memoryEntries ?? []
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType ?? doc.type ?? "unknown",
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: memories.map(
(mem): GraphApiMemory => ({
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,
}),
),
}
})
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
const [containerTag, setContainerTag] = useState("")
const [containerTags, setContainerTags] = useState<ContainerTagOption[]>([])
const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
const [containerTagsError, setContainerTagsError] = useState<Error | null>(
null,
)
const [documents, setDocuments] = useState<PlaygroundApiDocument[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [showGraph, setShowGraph] = useState(false)
const [stressTestCount, setStressTestCount] = useState(0)
const [graphVariant, setGraphVariant] = useState<GraphVariant>("consumer")
const [loadBehavior, setLoadBehavior] = useState<LoadBehavior>("zoom")
const [pagination, setPagination] = useState<
DocumentsResponse["pagination"] | null
>(null)
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
@ -64,13 +140,47 @@ export default function Home() {
documents: GraphApiDocument[]
} | null>(null)
const PAGE_SIZE = 500
const selectedContainerTags = useMemo(() => {
const trimmed = containerTag.trim()
return trimmed ? [trimmed] : undefined
}, [containerTag])
const fetchContainerTags = useCallback(async () => {
if (!apiKey || isLoadingContainerTags) return
setIsLoadingContainerTags(true)
setContainerTagsError(null)
try {
const response = await fetch("/api/container-tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to fetch container tags")
}
const data = (await response.json()) as ContainerTagOption[]
setContainerTags(data)
} catch (err) {
setContainerTagsError(
err instanceof Error ? err : new Error("Unknown error"),
)
} finally {
setIsLoadingContainerTags(false)
}
}, [apiKey, isLoadingContainerTags])
const fetchDocuments = useCallback(
async (page: number, append = false) => {
if (!apiKey) return
if (page === 1) {
if (append) {
setIsLoadingMore(true)
} else {
setIsLoading(true)
}
setError(null)
@ -87,6 +197,7 @@ export default function Home() {
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedContainerTags,
}),
})
@ -103,6 +214,7 @@ export default function Home() {
setDocuments(data.documents)
}
setPagination(data.pagination)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
@ -110,19 +222,27 @@ export default function Home() {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
setIsLoadingMore(false)
}
},
[apiKey],
[apiKey, selectedContainerTags],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
setPagination(null)
void fetchContainerTags()
fetchDocuments(1)
}
}
const handleLoadMoreDocuments = useCallback(() => {
if (!pagination || pagination.currentPage >= pagination.totalPages) return
fetchDocuments(pagination.currentPage + 1, true)
}, [fetchDocuments, pagination])
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
@ -131,6 +251,7 @@ export default function Home() {
})
setMockData({ documents: data.documents })
setDocuments([])
setPagination(null)
setStressTestCount(count)
setShowGraph(true)
setError(null)
@ -157,7 +278,67 @@ export default function Home() {
return toGraphDocuments(documents)
}, [documents, mockData])
const availableContainerTags = useMemo(() => {
const options = new Map<string, ContainerTagOption>()
for (const tag of containerTags) {
if (tag.containerTag) options.set(tag.containerTag, tag)
}
for (const doc of documents) {
for (const tag of doc.containerTags ?? []) {
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
const memories = doc.memories ?? doc.memoryEntries ?? []
for (const mem of memories) {
const tag = mem.spaceContainerTag
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
}
return [...options.values()]
}, [containerTags, documents])
const displayCount = mockData ? stressTestCount : documents.length
const hasMore =
!mockData &&
pagination != null &&
pagination.currentPage < pagination.totalPages
const totalCount = mockData
? stressTestCount
: (pagination?.totalItems ?? documents.length)
const maxNodes = mockData ? 1000 : undefined
const graphHandlesLoadMore = loadBehavior === "zoom"
useEffect(() => {
if (
loadBehavior !== "background" ||
!showGraph ||
mockData ||
!hasMore ||
isLoading ||
isLoadingMore ||
error
) {
return
}
const timer = window.setTimeout(
handleLoadMoreDocuments,
BACKGROUND_LOAD_DELAY_MS,
)
return () => window.clearTimeout(timer)
}, [
error,
handleLoadMoreDocuments,
hasMore,
isLoading,
isLoadingMore,
loadBehavior,
mockData,
showGraph,
])
return (
<div className="flex flex-col h-screen bg-zinc-950">
@ -178,9 +359,49 @@ export default function Home() {
type="password"
placeholder="Enter your Supermemory API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(e) => {
setApiKey(e.target.value)
setContainerTags([])
setContainerTagsError(null)
}}
className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center gap-2">
<input
list="container-tag-options"
value={containerTag}
onChange={(e) => setContainerTag(e.target.value)}
onFocus={() => {
if (availableContainerTags.length === 0) {
void fetchContainerTags()
}
}}
disabled={!apiKey}
placeholder={
isLoadingContainerTags
? "Loading container tags..."
: "All container tags"
}
className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<datalist id="container-tag-options">
{availableContainerTags.map((tag) => (
<option key={tag.containerTag} value={tag.containerTag}>
{tag.name && tag.name !== tag.containerTag
? `${tag.name} (${tag.containerTag})`
: tag.containerTag}
</option>
))}
</datalist>
<button
type="button"
onClick={() => void fetchContainerTags()}
disabled={!apiKey || isLoadingContainerTags}
className="rounded-lg border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingContainerTags ? "Loading..." : "Tags"}
</button>
</div>
<button
type="submit"
disabled={!apiKey || isLoading}
@ -205,8 +426,80 @@ export default function Home() {
Stress Test Mode
</span>
)}
{pagination && !mockData && (
<span className="font-mono text-xs text-zinc-500">
Page {pagination.currentPage}/{pagination.totalPages}
</span>
)}
{selectedContainerTags && (
<span className="rounded bg-sky-950/70 px-2 py-0.5 font-mono text-xs text-sky-300">
{selectedContainerTags[0]}
</span>
)}
{containerTagsError && (
<span className="text-xs text-amber-300">
{containerTagsError.message}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-zinc-500 text-xs">Mode:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["consumer", "console"] as const).map((variant) => (
<button
key={variant}
type="button"
onClick={() => setGraphVariant(variant)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
graphVariant === variant
? "bg-blue-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={graphVariant === variant}
>
{variant}
</button>
))}
</div>
<div className="h-6 w-px bg-zinc-700" />
<span className="text-zinc-500 text-xs">Load:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["zoom", "manual", "background"] as const).map((behavior) => (
<button
key={behavior}
type="button"
onClick={() => setLoadBehavior(behavior)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
loadBehavior === behavior
? "bg-emerald-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={loadBehavior === behavior}
>
{behavior}
</button>
))}
</div>
{loadBehavior === "manual" && (
<button
type="button"
onClick={handleLoadMoreDocuments}
disabled={!hasMore || isLoadingMore}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingMore
? "Loading..."
: hasMore
? "Load next"
: "All loaded"}
</button>
)}
{loadBehavior === "background" && !mockData && hasMore && (
<span className="rounded bg-emerald-950/70 px-2 py-0.5 text-xs text-emerald-300">
Auto paging
</span>
)}
<div className="h-6 w-px bg-zinc-700" />
{/* Stress test buttons */}
<span className="text-zinc-500 text-xs">Stress Test:</span>
{[50, 100, 200, 500].map((count) => (
@ -300,13 +593,24 @@ export default function Home() {
<MemoryGraph
documents={graphDocuments}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
hasMore={graphHandlesLoadMore && hasMore}
onLoadMore={
graphHandlesLoadMore && hasMore
? handleLoadMoreDocuments
: undefined
}
error={error}
variant="consumer"
maxNodes={1000}
variant={graphVariant}
maxNodes={maxNodes}
showFps={stressTestCount > 0}
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}
totalCount={totalCount}
colors={
graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
}
>
<div className="flex h-full items-center justify-center">
<p className="text-zinc-400">

View file

@ -684,7 +684,7 @@ export default function NewPage() {
onBack={() => void setViewMode("integrations")}
/>
) : viewMode === "graph" ? (
<div className="min-h-0 min-w-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
</div>
) : viewMode === "list" ? (

View file

@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({
}, [setIsShareModalOpen])
return (
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
<div className="relative flex h-full min-h-0 flex-1 flex-col w-full">
{/* Full-width graph */}
<div className="absolute inset-0">
<div className="relative min-h-0 flex-1">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible
maxNodes={undefined}
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
/>

View file

@ -1,7 +1,7 @@
"use client"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { useEffect, useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphApiDocument,
@ -9,12 +9,13 @@ import type {
MemoryRelation,
} from "@supermemory/memory-graph"
const PAGE_SIZE = 100
const PAGE_SIZE = 500
interface UseGraphApiOptions {
containerTags?: string[]
documentIds?: string[]
enabled?: boolean
maxNodes?: number
}
interface ApiMemoryEntry {
@ -59,6 +60,13 @@ interface ApiDocumentsResponse {
}
}
function getGraphNodeCount(documents: ApiDocument[]): number {
return documents.reduce(
(total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
0,
)
}
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
@ -108,7 +116,7 @@ function toGraphDocument(
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, documentIds, enabled = true } = options
const { containerTags, documentIds, enabled = true, maxNodes } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
containerTags,
[],
filteredDocumentIds,
maxNodes,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
return response.data as unknown as ApiDocumentsResponse
},
getNextPageParam: (lastPage) => {
getNextPageParam: (lastPage, allPages) => {
if (hasDocumentIds) return undefined
if (maxNodes != null) {
const loadedNodes = allPages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
if (loadedNodes >= maxNodes) return undefined
}
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
},
@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
enabled,
})
const loadedNodeCount = useMemo(() => {
if (!data?.pages) return 0
return data.pages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
}, [data])
useEffect(() => {
if (!enabled || hasDocumentIds) return
if (!hasNextPage || isFetchingNextPage) return
if (maxNodes != null && loadedNodeCount >= maxNodes) return
fetchNextPage()
}, [
enabled,
hasDocumentIds,
hasNextPage,
isFetchingNextPage,
loadedNodeCount,
maxNodes,
fetchNextPage,
])
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>

View file

@ -1,6 +1,5 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphThemeColors } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
@ -34,20 +33,6 @@ export function MemoryGraph({
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,
@ -59,11 +44,11 @@ export function MemoryGraph({
} = useGraphApi({
containerTags,
documentIds,
enabled: containerSize.width > 0 && containerSize.height > 0,
maxNodes,
})
return (
<div ref={containerRef} className="size-full [&>div]:!bg-none">
<div className="absolute inset-0 [&>div]:!h-full [&>div]:!bg-none">
<MemoryGraphBase
documents={documents}
isLoading={externalIsLoading || apiIsLoading}

View file

@ -633,16 +633,18 @@ describe("getEdgeVisualProps: all MemoryRelation values return valid visual prop
})
}
it("extends edges have higher opacity than derives edges (rare but meaningful)", () => {
it("extends edges have lower opacity than derives edges (visible but quiet)", () => {
const ext = getEdgeVisualProps("extends")
const der = getEdgeVisualProps("derives")
expect(ext.opacity).toBeGreaterThan(der.opacity)
expect(ext.opacity).toBeLessThan(der.opacity)
})
it("updates edges have higher opacity than derives edges (version chains are prominent)", () => {
it("updates edges are more prominent than quiet relation edges", () => {
const upd = getEdgeVisualProps("updates")
const der = getEdgeVisualProps("derives")
const ext = getEdgeVisualProps("extends")
expect(upd.opacity).toBeGreaterThan(der.opacity)
expect(upd.opacity).toBeGreaterThan(ext.opacity)
})
it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => {

View file

@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest"
import {
getMemoryBorderColor,
getEdgeVisualProps,
getMemoryOrbitOffset,
computeClusterAssignments,
getAppendPosition,
getNodeBounds,
} from "../hooks/use-graph-data"
import { DEFAULT_COLORS } from "../constants"
import type { GraphApiMemory } from "../types"
import type { GraphApiDocument, GraphApiMemory, GraphNode } from "../types"
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
return {
@ -25,6 +29,28 @@ function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
}
}
function makeNode(id: string, x: number, y: number, size = 50): GraphNode {
return {
id,
type: "document",
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("getMemoryBorderColor", () => {
const colors = DEFAULT_COLORS
@ -67,14 +93,14 @@ describe("getEdgeVisualProps", () => {
it("returns correct props for updates edges", () => {
const props = getEdgeVisualProps("updates")
expect(props.opacity).toBeCloseTo(0.7)
expect(props.thickness).toBeCloseTo(2)
expect(props.opacity).toBeCloseTo(0.48)
expect(props.thickness).toBeCloseTo(1.45)
})
it("returns correct props for extends edges", () => {
const props = getEdgeVisualProps("extends")
expect(props.opacity).toBeCloseTo(0.55)
expect(props.thickness).toBeCloseTo(1.5)
expect(props.opacity).toBeCloseTo(0.16)
expect(props.thickness).toBeCloseTo(0.8)
})
it("returns default props for unknown edge types", () => {
@ -83,3 +109,113 @@ describe("getEdgeVisualProps", () => {
expect(props.thickness).toBeCloseTo(1.2)
})
})
describe("cluster assignments", () => {
it("keeps memories from the same document in the same visual cluster", () => {
const assignments = computeClusterAssignments([
makeDocument("doc-a", [
makeMemory({ id: "a1" }),
makeMemory({ id: "a2" }),
]),
])
expect(assignments.get("a1")?.key).toBe(assignments.get("a2")?.key)
expect(assignments.get("a1")?.color).toMatch(/^#[0-9A-Fa-f]{6}$/)
})
it("merges cross-document relation clusters", () => {
const assignments = computeClusterAssignments([
makeDocument("doc-a", [makeMemory({ id: "a1" })]),
makeDocument("doc-b", [
makeMemory({ id: "b1", memoryRelations: { a1: "extends" } }),
]),
])
expect(assignments.get("a1")?.key).toBe(assignments.get("b1")?.key)
})
})
describe("memory orbit placement", () => {
it("pushes high-index memories onto wider rings", () => {
const early = getMemoryOrbitOffset(0, 80, "mem-0")
const late = getMemoryOrbitOffset(50, 80, "mem-50")
expect(late.radius).toBeGreaterThan(early.radius)
})
it("is deterministic for the same memory", () => {
const first = getMemoryOrbitOffset(12, 40, "mem-12")
const second = getMemoryOrbitOffset(12, 40, "mem-12")
expect(second).toEqual(first)
})
})
describe("append placement helpers", () => {
it("computes bounds including node radius", () => {
const bounds = getNodeBounds([
makeNode("a", 100, 100, 50),
makeNode("b", 300, 220, 40),
])
expect(bounds).toEqual({
minX: 75,
minY: 75,
maxX: 320,
maxY: 240,
centerX: 197.5,
centerY: 157.5,
})
})
it("places appended nodes outside existing graph bounds", () => {
const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)]
const bounds = getNodeBounds(existing)
const pos = getAppendPosition(existing, 0, 1000, 800)
if (!bounds) throw new Error("Expected bounds")
const outsideBounds =
pos.x < bounds.minX ||
pos.x > bounds.maxX ||
pos.y < bounds.minY ||
pos.y > bounds.maxY
expect(outsideBounds).toBe(true)
})
it("distributes append positions across multiple surrounding areas", () => {
const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)]
const bounds = getNodeBounds(existing)
if (!bounds) throw new Error("Expected bounds")
const areas = new Set(
Array.from({ length: 8 }, (_, index) => {
const pos = getAppendPosition(existing, index, 1000, 800)
if (pos.x < bounds.minX) return "left"
if (pos.x > bounds.maxX) return "right"
if (pos.y < bounds.minY) return "top"
return "bottom"
}),
)
expect(areas.size).toBeGreaterThan(2)
})
it("uses the canvas center when no existing nodes are available", () => {
expect(getAppendPosition([], 0, 1000, 800)).toEqual({ x: 500, y: 400 })
})
})
function makeDocument(
id: string,
memories: GraphApiMemory[],
): GraphApiDocument {
return {
id,
title: id,
summary: null,
documentType: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories,
}
}

View file

@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest"
import { lightenColor } from "../canvas/renderer"
import {
getRelationEdgeStride,
lightenColor,
mixHexColors,
shouldDrawRelationEdge,
} from "../canvas/renderer"
describe("lightenColor", () => {
test("lightens a dark hex color", () => {
@ -55,3 +60,33 @@ describe("lightenColor", () => {
expect(result).toBe("#2f3338")
})
})
describe("mixHexColors", () => {
test("mixes two hex colors", () => {
expect(mixHexColors("#000000", "#ffffff", 0.5)).toBe("#808080")
})
test("returns the base color for unsupported color formats", () => {
expect(mixHexColors("rgb(0,0,0)", "#ffffff", 0.5)).toBe("rgb(0,0,0)")
})
})
describe("relation edge level-of-detail helpers", () => {
test("keeps all relation edges at normal zoom", () => {
expect(getRelationEdgeStride(5000, 0.5)).toBe(1)
})
test("samples dense relation edges at low zoom", () => {
expect(getRelationEdgeStride(1040, 0.1)).toBe(4)
})
test("always draws structural derives edges", () => {
expect(shouldDrawRelationEdge("edge-1", "derives", 10)).toBe(true)
})
test("deterministically samples non-structural relation edges", () => {
const first = shouldDrawRelationEdge("rel-a-b", "updates", 4)
const second = shouldDrawRelationEdge("rel-a-b", "updates", 4)
expect(second).toBe(first)
})
})

View file

@ -61,8 +61,10 @@ describe("ForceSimulation", () => {
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 [first, second] = nodes
if (!first || !second) throw new Error("Expected two nodes")
const dx = first.x - second.x
const dy = first.y - second.y
const dist = Math.sqrt(dx * dx + dy * dy)
expect(dist).toBeGreaterThan(0)
sim.destroy()
@ -74,7 +76,9 @@ describe("ForceSimulation", () => {
sim.init(nodes, [])
// Update with same nodes but different positions
nodes[0]!.x = 50
const [first] = nodes
if (!first) throw new Error("Expected node")
first.x = 50
expect(() => sim.update(nodes, [])).not.toThrow()
expect(sim.isActive()).toBe(true)
sim.destroy()
@ -97,6 +101,15 @@ describe("ForceSimulation", () => {
sim.destroy()
})
it("stop immediately deactivates the simulation", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0)]
sim.init(nodes, [])
sim.stop()
expect(sim.isActive()).toBe(false)
sim.destroy()
})
it("handles empty nodes array", () => {
const sim = new ForceSimulation()
expect(() => sim.init([], [])).not.toThrow()

View file

@ -67,6 +67,52 @@ describe("VersionChainIndex", () => {
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
expect(chain?.map((e) => e.version)).toEqual([1, 2, 3])
})
it("infers display versions when backend repeats v1 across an update chain", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1, isLatest: false }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 1,
memoryRelations: { m1: "updates" },
}),
])
idx.rebuild([doc])
const chain = idx.getChain("m2")
expect(chain).not.toBeNull()
expect(chain?.map((e) => e.id)).toEqual(["m1", "m2"])
expect(chain?.map((e) => e.version)).toEqual([1, 2])
})
it("only infers broken version entries and preserves valid backend versions", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 5,
}),
makeMem({
id: "m3",
parentMemoryId: "m2",
rootMemoryId: "m1",
version: 5,
}),
])
idx.rebuild([doc])
const chain = idx.getChain("m3")
expect(chain).not.toBeNull()
expect(chain?.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
expect(chain?.map((e) => e.version)).toEqual([1, 5, 6])
})
it("getChain from middle element returns full chain (backward + forward)", () => {

View file

@ -122,6 +122,22 @@ describe("ViewportState", () => {
expect(vp.zoom).toBeCloseTo(0.1)
})
it("can lower the minimum zoom to fit a large loaded graph", () => {
const vp = new ViewportState(0, 0, 0.5)
const nodes = [
makeNode("a", 0, 0),
makeNode("b", 10_000, 0),
makeNode("c", 0, 10_000),
makeNode("d", 10_000, 10_000),
]
vp.setMinZoomForNodes(nodes, 800, 600)
vp.zoomImmediate(0.01, 0, 0)
expect(vp.zoom).toBeLessThan(0.1)
expect(vp.zoom).toBeGreaterThan(0.005)
})
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

View file

@ -7,6 +7,7 @@ import type {
} from "../types"
import type { ViewportState } from "./viewport"
import { drawDocIcon, roundRect } from "./document-icons"
import { hashString } from "../utils/hash"
export interface RenderState {
selectedNodeId: string | null
@ -17,6 +18,13 @@ export interface RenderState {
// Module-level reusable batch map cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
const RELATION_LOD_ZOOM = 0.5
const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
const RELATION_LOD_DENSE_COUNT = 180
const DERIVES_LOD_ZOOM = 0.38
const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200
const DENSE_POINT_THRESHOLD = 25000
const DENSE_POINT_ZOOM = 0.42
function nodeMatchesDocumentHighlights(
node: GraphNode,
@ -30,13 +38,21 @@ function nodeMatchesDocumentHighlights(
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor<T extends { color: string }>(
items: T[],
): Map<string, T[]> {
return groupByComputedColor(items, (item) => item.color)
}
function groupByComputedColor<T>(
items: T[],
getColor: (item: T) => string,
): Map<string, T[]> {
const map = new Map<string, T[]>()
for (const item of items) {
let batch = map.get(item.color)
const color = getColor(item)
let batch = map.get(color)
if (!batch) {
batch = []
map.set(item.color, batch)
map.set(color, batch)
}
batch.push(item)
}
@ -70,9 +86,98 @@ function edgeStyle(
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 }
return { color: colors.edgeUpdates, width: 1.45, opacity: 0.48 }
// "extends" and any unknown edge types
return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 }
return { color: colors.edgeExtends, width: 0.8, opacity: 0.16 }
}
export function getRelationEdgeStride(
relationEdgeCount: number,
zoom: number,
): number {
if (
zoom >= RELATION_LOD_ZOOM ||
relationEdgeCount <= RELATION_LOD_MAX_BACKGROUND_EDGES
) {
return 1
}
return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES)
}
function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number {
if (
zoom >= DERIVES_LOD_ZOOM ||
derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES
) {
return 1
}
return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES)
}
export function shouldDrawRelationEdge(
edgeId: string,
edgeType: string,
stride: number,
): boolean {
if (edgeType === "derives" || stride <= 1) return true
return hashString(edgeId) % stride === 0
}
function shouldDrawSampledEdge(edgeId: string, stride: number): boolean {
return stride <= 1 || hashString(edgeId) % stride === 0
}
function applyRelationLevelOfDetail(
style: { color: string; width: number; opacity: number },
edgeType: string,
relationEdgeCount: number,
zoom: number,
hasFocus: boolean,
hasActiveHover: boolean,
) {
if (edgeType === "derives") return { style, glow: true }
if (hasFocus || hasActiveHover) {
const isUpdate = edgeType === "updates"
const minOpacity = hasActiveHover ? 0.9 : 0.76
const minWidth = hasActiveHover ? 2.35 : 1.8
return {
style: isUpdate
? {
...style,
width: Math.max(style.width, minWidth),
opacity: Math.max(style.opacity, minOpacity),
}
: style,
glow: isUpdate,
}
}
if (
zoom >= RELATION_LOD_ZOOM ||
relationEdgeCount <= RELATION_LOD_DENSE_COUNT
) {
return { style, glow: edgeType === "updates" }
}
const densityFactor = Math.min(
1,
RELATION_LOD_DENSE_COUNT / relationEdgeCount,
)
const zoomFactor = clampNumber(zoom / RELATION_LOD_ZOOM, 0.25, 1)
const opacityFactor = clampNumber(densityFactor * zoomFactor, 0.06, 0.24)
const widthFactor = clampNumber(zoomFactor * 0.65, 0.22, 0.7)
return {
style: {
...style,
width: Math.max(0.45, style.width * widthFactor),
opacity: style.opacity * opacityFactor,
},
glow: false,
}
}
function clampNumber(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}
function batchKey(style: {
@ -92,6 +197,7 @@ interface PreparedEdge {
style: { color: string; width: number; opacity: number }
edgeType: string
arrowSize: number
glow: boolean
}
function drawEdges(
@ -106,13 +212,36 @@ function drawEdges(
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
const relationEdgeCount = edges.reduce(
(count, edge) => count + (edge.edgeType === "derives" ? 0 : 1),
0,
)
const derivesEdgeCount = edges.length - relationEdgeCount
const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom)
const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom)
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 edgeType = edge.edgeType ?? "derives"
const srcId = typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id
const hoverConnected =
state.hoveredNodeId != null &&
(srcId === state.hoveredNodeId || tgtId === state.hoveredNodeId)
const selectedConnected =
state.selectedNodeId != null &&
(srcId === state.selectedNodeId || tgtId === state.selectedNodeId)
const activeConnected = hoverConnected || selectedConnected
const shouldAlwaysDrawActiveUpdate =
edgeType === "updates" && activeConnected
const edgeStride = edgeType === "derives" ? derivesStride : relationStride
if (
!shouldAlwaysDrawActiveUpdate &&
!hasDim &&
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
const src =
@ -121,7 +250,7 @@ function drawEdges(
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
if (edge.edgeType === "derives") {
if (edgeType === "derives") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
@ -149,12 +278,39 @@ function drawEdges(
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
connected = selectedConnected
}
if (
!shouldAlwaysDrawActiveUpdate &&
hasDim &&
!connected &&
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
const edgeDetail = applyRelationLevelOfDetail(
edgeStyle(edge, colors),
edgeType,
relationEdgeCount,
viewport.zoom,
hasDim && connected,
edgeType === "updates" && hoverConnected,
)
let style = edgeDetail.style
let glow = edgeDetail.glow
if (edgeType === "derives" && derivesStride > 1 && !activeConnected) {
const zoomFactor = clampNumber(
viewport.zoom / DERIVES_LOD_ZOOM,
0.08,
0.32,
)
style = {
...style,
width: Math.max(0.35, style.width * 0.45),
opacity: style.opacity * zoomFactor,
}
glow = false
}
prepared.push({
@ -163,10 +319,16 @@ function drawEdges(
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge, colors),
edgeType: edge.edgeType ?? "derives",
style,
edgeType,
arrowSize:
edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0,
edgeType === "updates"
? Math.max(
shouldAlwaysDrawActiveUpdate ? 8 : 6,
(shouldAlwaysDrawActiveUpdate ? 11 : 8) * viewport.zoom,
)
: 0,
glow,
})
}
@ -174,7 +336,7 @@ function drawEdges(
edgeBatches.clear()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}`
const key = `${e.edgeType}|${batchKey(e.style)}|${e.glow ? "g" : "f"}${dimKey}`
let batch = edgeBatches.get(key)
if (!batch) {
batch = []
@ -190,8 +352,9 @@ function drawEdges(
const isDimmed = key.endsWith("|d")
const batchEdgeType = first.edgeType
// Draw glow pass behind all edge types for luminous aesthetic
if (!isDimmed) {
// Draw glow pass behind structural/revision edges. Cross-cluster
// extends edges stay flat so dense graphs do not become a mesh.
if (!isDimmed && first.glow && batchEdgeType !== "extends") {
const glowAlpha =
batchEdgeType === "updates"
? first.style.opacity * 0.4
@ -204,7 +367,6 @@ function drawEdges(
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)
@ -221,9 +383,6 @@ function drawEdges(
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)
@ -231,8 +390,6 @@ function drawEdges(
}
ctx.stroke()
if (batchEdgeType === "extends") ctx.setLineDash([])
// Arrowheads for updates edges
if (batchEdgeType === "updates") {
ctx.globalAlpha = isDimmed
@ -281,12 +438,26 @@ function drawNodes(
colors: GraphThemeColors,
): void {
const margin = 60
const densePointMode =
nodes.length > DENSE_POINT_THRESHOLD &&
viewport.zoom < DENSE_POINT_ZOOM &&
!state.selectedNodeId &&
state.highlightIds.size === 0
const pointDots: {
x: number
y: number
r: number
color: string
}[] = []
const memDots: {
x: number
y: number
r: number
color: string
fillColor: string
haloColor: string
dimmed: boolean
updateChain: boolean
}[] = []
const docDots: { x: number; y: number; s: number }[] = []
@ -316,6 +487,14 @@ function drawNodes(
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else if (densePointMode) {
pointDots.push({
x: screen.x,
y: screen.y,
r: Math.max(1.1, screenSize * 0.42),
color:
node.clusterColor || node.borderColor || colors.memStrokeDefault,
})
} else {
const md = node.data as MemoryNodeData
memDots.push({
@ -323,7 +502,10 @@ function drawNodes(
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || colors.memStrokeDefault,
fillColor: getMemoryNodeFillColor(node, colors, false),
haloColor: node.clusterColor || node.borderColor || colors.glowColor,
dimmed: md.isLatest === false,
updateChain: isMemoryInUpdateChain(md),
})
}
continue
@ -383,6 +565,19 @@ function drawNodes(
: 1
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
if (pointDots.length > 0) {
ctx.globalAlpha = dimAlpha * 0.78
for (const [color, batch] of groupByColor(pointDots)) {
ctx.fillStyle = 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.fill()
}
}
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
@ -403,7 +598,10 @@ function drawNodes(
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
for (const [color, batch] of groupByColor(normalDots)) {
for (const [color, batch] of groupByComputedColor(
normalDots,
(d) => d.haloColor,
)) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
@ -415,13 +613,18 @@ function drawNodes(
// Filled dot
ctx.globalAlpha = dimAlpha * hlBatchMult
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)
for (const [fillColor, batch] of groupByComputedColor(
normalDots,
(d) => d.fillColor,
)) {
ctx.fillStyle = fillColor
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.fill()
}
ctx.fill()
// Colored border
ctx.lineWidth = 1.5
@ -434,18 +637,37 @@ function drawNodes(
}
ctx.stroke()
}
const updateDots = normalDots.filter((d) => d.updateChain)
if (updateDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.85
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = 1.2
ctx.beginPath()
for (const d of updateDots) {
const r = d.r * 1.65
ctx.moveTo(d.x + r, d.y)
ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 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)
for (const [fillColor, batch] of groupByComputedColor(
dimmedDots,
(d) => d.fillColor,
)) {
ctx.fillStyle = fillColor
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.fill()
}
ctx.fill()
ctx.lineWidth = 1
for (const [color, batch] of groupByColor(dimmedDots)) {
@ -457,6 +679,20 @@ function drawNodes(
}
ctx.stroke()
}
const updateDots = dimmedDots.filter((d) => d.updateChain)
if (updateDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.55
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = 1
ctx.beginPath()
for (const d of updateDots) {
const r = d.r * 1.65
ctx.moveTo(d.x + r, d.y)
ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
}
@ -476,11 +712,12 @@ function drawDocumentNode(
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
const clusterColor = node.clusterColor ?? colors.docStroke
// Drop shadow for selected/hovered nodes
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = colors.accent
ctx.shadowColor = isSelected ? colors.accent : clusterColor
ctx.shadowBlur = isSelected ? 16 : 10
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
@ -493,12 +730,16 @@ function drawDocumentNode(
sx + half,
sy + half,
)
grad.addColorStop(0, colors.docFill)
grad.addColorStop(1, lightenColor(colors.docFill, 0.08))
grad.addColorStop(0, mixHexColors(colors.docFill, clusterColor, 0.1))
grad.addColorStop(1, mixHexColors(colors.docFill, clusterColor, 0.22))
ctx.fillStyle = grad
ctx.strokeStyle =
isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke
isSelected || isHighlighted
? colors.accent
: isHovered
? clusterColor
: node.borderColor || clusterColor
ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
@ -511,14 +752,14 @@ function drawDocumentNode(
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = colors.docInnerFill
ctx.fillStyle = mixHexColors(colors.docInnerFill, clusterColor, 0.08)
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)
drawDocIcon(ctx, sx, sy, iconSize, docType || "text", clusterColor)
}
function drawMemoryNode(
@ -535,13 +776,14 @@ function drawMemoryNode(
const memData = node.data as MemoryNodeData
const isSuperseded = memData.isLatest === false
const isForgotten = memData.isForgotten
const isUpdateChain = isMemoryInUpdateChain(memData)
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
ctx.fillStyle = getMemoryNodeFillColor(node, colors, false)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
ctx.strokeStyle = node.borderColor || colors.memStrokeDefault
@ -559,6 +801,8 @@ function drawMemoryNode(
ctx.lineWidth = 1.5
ctx.stroke()
drawUpdateMarker(ctx, sx, sy, radius, colors, 0.85)
ctx.globalAlpha = prevAlpha
return
}
@ -573,7 +817,7 @@ function drawMemoryNode(
ctx.shadowOffsetY = 0
}
ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill
ctx.fillStyle = getMemoryNodeFillColor(node, colors, isHovered)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
@ -582,6 +826,17 @@ function drawMemoryNode(
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
if (isUpdateChain) {
drawUpdateMarker(
ctx,
sx,
sy,
radius,
colors,
isSelected || isHovered ? 1 : 0.86,
)
}
if (isSelected || isHovered) {
ctx.restore()
}
@ -604,6 +859,60 @@ function drawMemoryNode(
}
}
function isMemoryInUpdateChain(memData: MemoryNodeData): boolean {
if (memData.isLatest === false || memData.parentMemoryId) return true
if (!memData.memoryRelations) return false
return Object.values(memData.memoryRelations).some(
(relation) => relation === "updates",
)
}
function drawUpdateMarker(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
radius: number,
colors: GraphThemeColors,
alpha: number,
) {
const markerR = Math.max(3.5, radius * 0.22)
const cx = sx + radius * 0.48
const cy = sy - radius * 0.48
ctx.save()
ctx.globalAlpha *= alpha
ctx.fillStyle = colors.popoverBg
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = Math.max(1.2, radius * 0.08)
ctx.beginPath()
ctx.arc(cx, cy, markerR, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.strokeStyle = colors.edgeUpdates
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.lineWidth = Math.max(1.2, radius * 0.07)
ctx.beginPath()
ctx.moveTo(cx - markerR * 0.45, cy)
ctx.lineTo(cx + markerR * 0.12, cy)
ctx.lineTo(cx - markerR * 0.06, cy - markerR * 0.2)
ctx.moveTo(cx + markerR * 0.12, cy)
ctx.lineTo(cx - markerR * 0.06, cy + markerR * 0.2)
ctx.stroke()
ctx.restore()
}
function getMemoryNodeFillColor(
node: GraphNode,
colors: GraphThemeColors,
isHovered: boolean,
): string {
const base = isHovered ? colors.memFillHover : colors.memFill
if (!node.clusterColor) return base
return mixHexColors(base, node.clusterColor, isHovered ? 0.42 : 0.32)
}
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
@ -678,3 +987,33 @@ export function lightenColor(hex: string, amount: number): string {
_lightenCache = { input: hex, amount, result }
return result
}
export function mixHexColors(
base: string,
overlay: string,
amount: number,
): string {
const baseRgb = parseHexColor(base)
const overlayRgb = parseHexColor(overlay)
if (!baseRgb || !overlayRgb) return base
const t = clampNumber(amount, 0, 1)
const r = Math.round(baseRgb.r + (overlayRgb.r - baseRgb.r) * t)
const g = Math.round(baseRgb.g + (overlayRgb.g - baseRgb.g) * t)
const b = Math.round(baseRgb.b + (overlayRgb.b - baseRgb.b) * t)
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function parseHexColor(hex: string) {
const raw = hex.startsWith("#") ? hex.slice(1) : hex
if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null
return {
r: Number.parseInt(raw.slice(0, 2), 16),
g: Number.parseInt(raw.slice(2, 4), 16),
b: Number.parseInt(raw.slice(4, 6), 16),
}
}
function toHex(value: number): string {
return value.toString(16).padStart(2, "0")
}

View file

@ -1,7 +1,9 @@
import * as d3 from "d3-force"
import type { GraphEdge, GraphNode } from "../types"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
@ -27,7 +29,7 @@ export class ForceSimulation {
.id((d) => d.id)
.distance((link) =>
link.edgeType === "derives"
? FORCE_CONFIG.docMemoryDistance
? getDocMemoryDistance(link)
: FORCE_CONFIG.linkDistance,
)
.strength((link) => {
@ -61,8 +63,17 @@ export class ForceSimulation {
this.sim.stop()
this.sim.alpha(1)
for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
const preSettleTicks =
nodes.length > DENSE_GRAPH_STATIC_THRESHOLD
? FORCE_CONFIG.densePreSettleTicks
: FORCE_CONFIG.preSettleTicks
for (let i = 0; i < preSettleTicks; i++) this.sim.tick()
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
this.stop()
} else {
this.sim.alphaTarget(0).restart()
}
} catch (e) {
console.error("ForceSimulation.init failed:", e)
this.destroy()
@ -85,6 +96,10 @@ export class ForceSimulation {
this.sim?.alphaTarget(0)
}
stop(): void {
this.sim?.alpha(0).alphaTarget(0).stop()
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
}
@ -96,3 +111,24 @@ export class ForceSimulation {
}
}
}
function getDocMemoryDistance(link: GraphEdge): number {
const source = resolveNode(link.source)
const target = resolveNode(link.target)
const docNode =
source?.type === "document"
? source
: target?.type === "document"
? target
: null
const memoryCount =
docNode != null ? (docNode.data as DocumentNodeData).memories.length : 1
const distance =
FORCE_CONFIG.docMemoryDistance +
Math.sqrt(Math.max(1, memoryCount)) * FORCE_CONFIG.docMemoryDistanceScale
return Math.min(FORCE_CONFIG.docMemoryDistanceMax, distance)
}
function resolveNode(endpoint: string | GraphNode): GraphNode | null {
return typeof endpoint === "string" ? null : endpoint
}

View file

@ -80,13 +80,22 @@ export class VersionChainIndex {
// 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,
}))
let lastVersion = 0
const chain: ChainEntry[] = all.map((m) => {
const version =
Number.isFinite(m.version) && m.version > lastVersion
? m.version
: lastVersion + 1
lastVersion = version
return {
id: m.id,
version,
memory: m.memory,
isForgotten: m.isForgotten,
isLatest: m.isLatest,
}
})
for (const entry of chain) {
this.cache.set(entry.id, chain)

View file

@ -16,8 +16,10 @@ export class ViewportState {
private targetPanY: number | null = null
private readonly panLerp = 0.12
private static readonly MIN_ZOOM = 0.1
private static readonly DEFAULT_MIN_ZOOM = 0.1
private static readonly ABSOLUTE_MIN_ZOOM = 0.005
private static readonly MAX_ZOOM = 5.0
private minZoom = ViewportState.DEFAULT_MIN_ZOOM
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX
@ -54,22 +56,14 @@ export class ViewportState {
zoomImmediate(delta: number, anchorX: number, anchorY: number): void {
const world = this.screenToWorld(anchorX, anchorY)
this.zoom = clamp(
this.zoom * delta,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.zoom = clamp(this.zoom * delta, this.minZoom, ViewportState.MAX_ZOOM)
this.targetZoom = this.zoom
this.panX = anchorX - world.x * this.zoom
this.panY = anchorY - world.y * this.zoom
}
zoomTo(target: number, anchorX: number, anchorY: number): void {
this.targetZoom = clamp(
target,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.targetZoom = clamp(target, this.minZoom, ViewportState.MAX_ZOOM)
this.zoomAnchorX = anchorX
this.zoomAnchorY = anchorY
}
@ -79,38 +73,40 @@ export class ViewportState {
width: number,
height: number,
): void {
if (nodes.length === 0) return
const fit = computeFit(nodes, width, height)
if (!fit) return
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
const { cx, cy, fitZoom } = fit
for (const n of nodes) {
minX = Math.min(minX, n.x - n.size)
maxX = Math.max(maxX, n.x + n.size)
minY = Math.min(minY, n.y - n.size)
maxY = Math.max(maxY, n.y + n.size)
}
const pad = 0.1
const cw = (maxX - minX) * (1 + pad * 2)
const ch = (maxY - minY) * (1 + pad * 2)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
const fitZoom = Math.min(width / cw, height / ch, 1)
this.targetZoom = clamp(
fitZoom,
ViewportState.MIN_ZOOM,
ViewportState.MAX_ZOOM,
)
this.targetZoom = clamp(fitZoom, this.minZoom, ViewportState.MAX_ZOOM)
this.zoomAnchorX = width / 2
this.zoomAnchorY = height / 2
this.targetPanX = width / 2 - cx * this.targetZoom
this.targetPanY = height / 2 - cy * this.targetZoom
}
setMinZoomForNodes(
nodes: Array<{ x: number; y: number; size: number }>,
width: number,
height: number,
): void {
const fit = computeFit(nodes, width, height)
const nextMinZoom = fit
? Math.min(ViewportState.DEFAULT_MIN_ZOOM, fit.fitZoom)
: ViewportState.DEFAULT_MIN_ZOOM
this.minZoom = clamp(
nextMinZoom,
ViewportState.ABSOLUTE_MIN_ZOOM,
ViewportState.DEFAULT_MIN_ZOOM,
)
this.zoom = clamp(this.zoom, this.minZoom, ViewportState.MAX_ZOOM)
this.targetZoom = clamp(
this.targetZoom,
this.minZoom,
ViewportState.MAX_ZOOM,
)
}
centerOn(
worldX: number,
worldY: number,
@ -163,6 +159,38 @@ export class ViewportState {
}
}
function computeFit(
nodes: Array<{ x: number; y: number; size: number }>,
width: number,
height: number,
): { cx: number; cy: number; fitZoom: number } | null {
if (nodes.length === 0 || width <= 0 || height <= 0) return null
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const n of nodes) {
minX = Math.min(minX, n.x - n.size)
maxX = Math.max(maxX, n.x + n.size)
minY = Math.min(minY, n.y - n.size)
maxY = Math.max(maxY, n.y + n.size)
}
const pad = 0.1
const cw = Math.max((maxX - minX) * (1 + pad * 2), 1)
const ch = Math.max((maxY - minY) * (1 + pad * 2), 1)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
return {
cx,
cy,
fitZoom: Math.min(width / cw, height / ch, 1),
}
}
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v
}

View file

@ -6,6 +6,7 @@ interface LegendProps {
edges?: GraphEdge[]
isLoading?: boolean
colors: GraphThemeColors
hoveredNode?: string | null
compact?: boolean
maxHeight?: number
}
@ -40,32 +41,100 @@ function HexagonIcon({
function LineIcon({
color,
dashed = false,
arrow = false,
}: {
color: string
dashed?: boolean
arrow?: boolean
}) {
return (
<div
<svg
aria-hidden="true"
height="12"
viewBox="0 0 16 12"
width="16"
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}`,
}}
<line
stroke={color}
strokeDasharray={dashed ? "3 2" : undefined}
strokeLinecap="round"
strokeWidth="2"
x1="1.5"
x2={arrow ? "12" : "14.5"}
y1="6"
y2="6"
/>
{arrow && (
<path
d="M10 3l4 3-4 3"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
)}
</svg>
)
}
function ClusterSwatches() {
const swatches = ["#58C7E8", "#E7BC52", "#74D680", "#D47B75", "#A789E8"]
return (
<div style={{ display: "flex", gap: 2, flexShrink: 0 }}>
{swatches.map((color) => (
<span
key={color}
style={{
width: 7,
height: 12,
borderRadius: 999,
backgroundColor: color,
}}
/>
))}
</div>
)
}
function UpdateMarkerIcon({ colors }: { colors: GraphThemeColors }) {
return (
<svg
aria-hidden="true"
height="14"
viewBox="0 0 14 14"
width="14"
style={{ flexShrink: 0 }}
>
<polygon
fill={colors.memFill}
points="7,1.5 12,4.25 12,9.75 7,12.5 2,9.75 2,4.25"
stroke={colors.memStrokeDefault}
strokeWidth="0.7"
/>
<circle
cx="10.2"
cy="3.8"
fill={colors.popoverBg}
r="2.4"
stroke={colors.edgeUpdates}
strokeWidth="1"
/>
<path
d="M9.1 3.8h1.7l-.5-.5m.5.5-.5.5"
fill="none"
stroke={colors.edgeUpdates}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.8"
/>
</svg>
)
}
function ChevronDownIcon({ color }: { color: string }) {
return (
<svg
@ -188,11 +257,41 @@ function StatRow({
)
}
function countEdgesByType(edges: GraphEdge[], edgeType: GraphEdge["edgeType"]) {
return edges.filter((edge) => edge.edgeType === edgeType).length
}
function getActiveUpdateCount(
edges: GraphEdge[],
hoveredNode: string | null | undefined,
) {
if (!hoveredNode) return 0
return edges.filter((edge) => {
if (edge.edgeType !== "updates") return false
const sourceId =
typeof edge.source === "string" ? edge.source : edge.source.id
const targetId =
typeof edge.target === "string" ? edge.target : edge.target.id
return sourceId === hoveredNode || targetId === hoveredNode
}).length
}
function isUpdateMemoryNode(node: GraphNode) {
if (node.type !== "memory") return false
const data = node.data
if (!("isLatest" in data)) return false
if (data.isLatest === false || data.parentMemoryId) return true
return Object.values(data.memoryRelations ?? {}).some(
(relation) => relation === "updates",
)
}
export const Legend = memo(function Legend({
nodes = [],
edges = [],
isLoading: _isLoading = false,
colors,
hoveredNode,
compact = false,
maxHeight,
}: LegendProps) {
@ -202,6 +301,17 @@ export const Legend = memo(function Legend({
const memoryCount = nodes.filter((n) => n.type === "memory").length
const documentCount = nodes.filter((n) => n.type === "document").length
const connectionCount = edges.length
const derivesCount = countEdgesByType(edges, "derives")
const updatesCount = countEdgesByType(edges, "updates")
const extendsCount = countEdgesByType(edges, "extends")
const activeUpdateCount = getActiveUpdateCount(edges, hoveredNode)
const clusterCount = new Set(
nodes
.filter((node) => node.type === "memory")
.map((node) => node.clusterKey)
.filter(Boolean),
).size
const updateNodeCount = nodes.filter(isUpdateMemoryNode).length
const outerStyle: React.CSSProperties = {
overflow: "hidden",
@ -263,6 +373,17 @@ export const Legend = memo(function Legend({
color: colors.textPrimary,
}
const countStyle: React.CSSProperties = {
fontSize: 12,
color: colors.textMuted,
}
const detailTextStyle: React.CSSProperties = {
fontSize: 11,
lineHeight: 1.35,
color: colors.textMuted,
}
const statusRowStyle: React.CSSProperties = {
display: "flex",
flexDirection: "row",
@ -270,6 +391,12 @@ export const Legend = memo(function Legend({
gap: 8,
}
const edgeDescriptionStyle: React.CSSProperties = {
...detailTextStyle,
marginLeft: 24,
marginTop: 2,
}
const expandedContentStyle: React.CSSProperties = {
marginTop: 16,
display: "flex",
@ -389,25 +516,43 @@ export const Legend = memo(function Legend({
style={{
display: "flex",
flexDirection: "column",
gap: 6,
gap: 8,
}}
>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDerives} />
<span style={edgeLabelStyle}>Derives</span>
<div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDerives} />
<span style={edgeLabelStyle}>Document source</span>
</div>
<span style={countStyle}>{derivesCount}</span>
</div>
<div style={edgeDescriptionStyle}>
Document to memory
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeUpdates} />
<span style={edgeLabelStyle}>Updates</span>
<div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeUpdates} arrow />
<span style={edgeLabelStyle}>Updates</span>
</div>
<span style={countStyle}>{updatesCount}</span>
</div>
<div style={edgeDescriptionStyle}>
Older memory to newer memory
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeExtends} dashed />
<span style={edgeLabelStyle}>Extends</span>
<div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeExtends} />
<span style={edgeLabelStyle}>Related</span>
</div>
<span style={countStyle}>{extendsCount}</span>
</div>
<div style={edgeDescriptionStyle}>
Supporting or extended memory
</div>
</div>
</div>
@ -415,12 +560,66 @@ export const Legend = memo(function Legend({
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={sectionLabelStyle}>Color</span>
<div style={statusRowStyle}>
<ClusterSwatches />
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={edgeLabelStyle}>Cluster</span>
<span style={detailTextStyle}>
Same document or connected memory group
</span>
</div>
</div>
<div style={rowStyle}>
<span style={detailTextStyle}>Visible clusters</span>
<span style={countStyle}>{clusterCount}</span>
</div>
{activeUpdateCount > 0 && (
<div
style={{
borderRadius: 8,
border: `1px solid ${colors.edgeUpdates}`,
backgroundColor: `${colors.edgeUpdates}22`,
padding: 8,
color: colors.textPrimary,
fontSize: 12,
lineHeight: 1.35,
}}
>
{activeUpdateCount} update link
{activeUpdateCount === 1 ? "" : "s"} connected to hover
</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}>
<UpdateMarkerIcon colors={colors} />
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={edgeLabelStyle}>Update chain</span>
<span style={detailTextStyle}>
{updateNodeCount} memories have versions
</span>
</div>
</div>
<div style={statusRowStyle}>
<HexagonIcon
fill={colors.memFill}

View file

@ -1,10 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ForceSimulation } from "../canvas/simulation"
import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
} from "../canvas/simulation"
import { VersionChainIndex } from "../canvas/version-chain"
import type { ViewportState } from "../canvas/viewport"
import { useGraphData } from "../hooks/use-graph-data"
import { useGraphTheme } from "../hooks/use-graph-theme"
import type { GraphThemeColors, MemoryGraphProps } from "../types"
import type {
GraphApiDocument,
GraphThemeColors,
MemoryGraphProps,
} from "../types"
import { GraphCanvas } from "./graph-canvas"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
@ -60,20 +67,41 @@ export function MemoryGraph({
const limitedDocuments = useMemo(() => {
if (!maxNodes || documents.length === 0) return documents
let totalNodes = 0
let cutoff = documents.length
const limited: GraphApiDocument[] = []
for (let i = 0; i < documents.length; i++) {
const docNodes = 1 + (documents[i]?.memories?.length ?? 0)
if (totalNodes + docNodes > maxNodes) {
cutoff = i
const doc = documents[i]
if (!doc) continue
if (totalNodes >= maxNodes) break
const remainingNodes = maxNodes - totalNodes
const memories = doc.memories ?? []
const docNodes = 1 + memories.length
if (docNodes <= remainingNodes) {
limited.push(doc)
totalNodes += docNodes
continue
}
if (remainingNodes > 1) {
limited.push({
...doc,
memories: memories.slice(0, remainingNodes - 1),
})
totalNodes = maxNodes
break
}
totalNodes += docNodes
limited.push({ ...doc, memories: [] })
totalNodes += 1
}
return cutoff === documents.length ? documents : documents.slice(0, cutoff)
return limited
}, [documents, maxNodes])
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
const { nodes, edges } = useGraphData(
limitedDocuments,
hasContainerSize ? limitedDocuments : [],
null,
containerSize.width,
containerSize.height,
@ -93,21 +121,34 @@ export function MemoryGraph({
// that makes this a no-op on re-renders where limitedDocuments hasn't changed.
chainIndex.current.rebuild(limitedDocuments)
// Smart simulation re-init: track node ID set, only init() when IDs change
const prevSimIdsRef = useRef<string>("")
// Initial loads get a full force settle. Append-only pagination keeps
// existing coordinates stable and renders new nodes in nearby open areas.
const prevSimIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (nodes.length === 0) {
simulationRef.current?.destroy()
simulationRef.current = null
setSimulation(null)
prevSimIdsRef.current = ""
prevSimIdsRef.current = new Set()
return
}
const idKey = nodes
.map((n) => n.id)
.sort()
.join(",")
const currentIds = new Set(nodes.map((n) => n.id))
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
simulationRef.current?.destroy()
simulationRef.current = null
setSimulation(null)
prevSimIdsRef.current = currentIds
return
}
const previousIds = prevSimIdsRef.current
const hasPreviousIds = previousIds.size > 0
const idsChanged =
currentIds.size !== previousIds.size ||
[...currentIds].some((id) => !previousIds.has(id))
const isAppendOnly =
hasPreviousIds && [...previousIds].every((id) => currentIds.has(id))
if (!simulationRef.current) {
const sim = new ForceSimulation()
@ -115,12 +156,14 @@ export function MemoryGraph({
setSimulation(sim)
}
if (idKey !== prevSimIdsRef.current) {
// IDs changed - full re-init
prevSimIdsRef.current = idKey
if (!hasPreviousIds || (idsChanged && !isAppendOnly)) {
prevSimIdsRef.current = currentIds
simulationRef.current.init(nodes, edges)
} else if (idsChanged && isAppendOnly) {
prevSimIdsRef.current = currentIds
simulationRef.current.update(nodes, edges)
simulationRef.current.stop()
} else {
// Only metadata changed - update existing simulation
simulationRef.current.update(nodes, edges)
}
}, [nodes, edges])
@ -134,20 +177,30 @@ export function MemoryGraph({
}
}, [])
useEffect(() => {
viewportRef.current?.setMinZoomForNodes(
nodes,
containerSize.width,
graphFitHeight,
)
}, [nodes, containerSize.width, graphFitHeight])
// Auto-fit when data first loads. Mobile needs a few passes because the
// force simulation can move nodes after the first layout frame.
const hasAutoFittedRef = useRef(false)
const hadValidContainerSizeRef = useRef(false)
useEffect(() => {
if (
!hasAutoFittedRef.current &&
nodes.length > 0 &&
viewportRef.current &&
containerSize.width > 0
hasContainerSize
) {
const fitDelays = isCompactViewport ? [100, 450, 900] : [100]
const fitDelays = isCompactViewport ? [100, 450, 900] : [100, 300]
const timers = fitDelays.map((delay, index) =>
setTimeout(() => {
viewportRef.current?.fitToNodes(
if (!viewportRef.current || !hasContainerSize) return
viewportRef.current.fitToNodes(
nodes,
containerSize.width,
graphFitHeight,
@ -161,10 +214,17 @@ export function MemoryGraph({
for (const timer of timers) clearTimeout(timer)
}
}
}, [nodes, containerSize.width, graphFitHeight, isCompactViewport])
}, [
nodes,
containerSize.width,
graphFitHeight,
isCompactViewport,
hasContainerSize,
])
useEffect(() => {
if (!isCompactViewport || nodes.length === 0 || !viewportRef.current) return
if (!hasContainerSize) return
const timer = setTimeout(() => {
viewportRef.current?.fitToNodes(
nodes,
@ -173,7 +233,13 @@ export function MemoryGraph({
)
}, 120)
return () => clearTimeout(timer)
}, [isCompactViewport, nodes, containerSize.width, graphFitHeight])
}, [
isCompactViewport,
nodes,
containerSize.width,
graphFitHeight,
hasContainerSize,
])
useEffect(() => {
if (nodes.length === 0) hasAutoFittedRef.current = false
@ -185,20 +251,39 @@ export function MemoryGraph({
}
}, [isCompactViewport])
useEffect(() => {
if (hasContainerSize && !hadValidContainerSizeRef.current) {
hadValidContainerSizeRef.current = true
hasAutoFittedRef.current = false
}
if (!hasContainerSize) {
hadValidContainerSizeRef.current = false
}
}, [hasContainerSize])
// 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())
const measure = () => {
const rect = el.getBoundingClientRect()
const width = Math.round(rect.width) || el.clientWidth
const height = Math.round(rect.height) || el.clientHeight
setContainerSize({ width, height })
setContainerBounds(rect)
}
return () => ro.disconnect()
const ro = new ResizeObserver(measure)
ro.observe(el)
const parent = el.parentElement
if (parent) ro.observe(parent)
measure()
const raf = requestAnimationFrame(measure)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
// Callbacks for GraphCanvas
@ -588,7 +673,8 @@ export function MemoryGraph({
return chainIndex.current.getChain(activeNodeData.id)
}, [activeNodeData, limitedDocuments])
const isLoading = externalIsLoading
const isLayoutPending = !hasContainerSize && limitedDocuments.length > 0
const isLoading = externalIsLoading || isLayoutPending
if (externalError) {
const errorContainerStyle: React.CSSProperties = {
@ -671,7 +757,7 @@ export function MemoryGraph({
)}
<div style={canvasContainerStyle} ref={containerRef}>
{containerSize.width > 0 && containerSize.height > 0 && (
{hasContainerSize && (
<GraphCanvas
colors={colors}
edges={edges}
@ -726,6 +812,7 @@ export function MemoryGraph({
<Legend
colors={colors}
edges={edges}
hoveredNode={hoveredNode}
compact={isCompactViewport}
isLoading={isLoading}
maxHeight={compactLegendMaxHeight}

View file

@ -324,20 +324,23 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
const isMemory = node.type === "memory"
const data = node.data
const hasChain = Boolean(versionChain && versionChain.length > 1)
const memoryMeta = useMemo(() => {
if (!isMemory) return null
const md = data as MemoryNodeData
const chainVersion = hasChain
? versionChain?.find((entry) => entry.id === node.id)?.version
: undefined
return {
version: md.version ?? 1,
version: chainVersion ?? md.version ?? 1,
isLatest: md.isLatest ?? false,
isForgotten: md.isForgotten ?? false,
forgetReason: md.forgetReason ?? null,
forgetAfter: md.forgetAfter ?? null,
}
}, [isMemory, data])
}, [isMemory, data, hasChain, versionChain, node.id])
const hasChain = versionChain && versionChain.length > 1
const hasForgetInfo =
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
@ -516,7 +519,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<div style={cardStyle}>
{hasChain ? (
<VersionTimeline
chain={versionChain}
chain={versionChain ?? []}
colors={colors}
currentId={node.id}
onSelect={onSelectNode}
@ -587,7 +590,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
: colors.popoverTextSecondary,
}}
>
v{memoryMeta.version}{" "}
{hasChain ? `v${memoryMeta.version} ` : ""}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest

View file

@ -15,16 +15,19 @@ export const FORCE_CONFIG = {
fallback: 0.05,
},
linkDistance: 300,
docMemoryDistance: 180,
chargeStrength: -2000,
collisionRadius: { document: 70, memory: 35 },
collisionStrength: 0.7,
docMemoryDistance: 240,
docMemoryDistanceScale: 36,
docMemoryDistanceMax: 560,
chargeStrength: -2400,
collisionRadius: { document: 80, memory: 48 },
collisionStrength: 0.82,
centeringStrength: 0.06,
alphaDecay: 0.025,
alphaMin: 0.001,
velocityDecay: 0.45,
alphaTarget: 0.3,
preSettleTicks: 150,
densePreSettleTicks: 12,
}
export const GRAPH_SETTINGS = {
@ -49,8 +52,8 @@ export const DEFAULT_COLORS: GraphThemeColors = {
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
edgeDerives: "#FBBF24",
edgeUpdates: "#A78BFA",
edgeExtends: "#38BDF8",
edgeUpdates: "#9B8AE6",
edgeExtends: "#94A3B8",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",

View file

@ -8,10 +8,37 @@ import type {
GraphThemeColors,
MemoryNodeData,
} from "../types"
import { hashString } from "../utils/hash"
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const MEMORY_ORBIT_BASE = 200
const MEMORY_ORBIT_BASE = 260
const MEMORY_ORBIT_GAP = 110
const MEMORY_ORBIT_SPACING = 84
const APPEND_CLUSTER_RADIUS = MEMORY_ORBIT_BASE + 180
const APPEND_AREA_GAP = 160
const APPEND_CANDIDATES_PER_RING = 18
const APPEND_MAX_RINGS = 8
const APPEND_SPATIAL_CELL_SIZE = APPEND_CLUSTER_RADIUS + APPEND_AREA_GAP + 120
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
const CLUSTER_COLORS = [
"#58C7E8",
"#E7BC52",
"#74D680",
"#D47B75",
"#A789E8",
"#62C5A8",
"#74ABD8",
"#C78AC8",
"#D18A58",
"#8BCB6F",
]
export interface ClusterAssignment {
key: string
color: string
size: number
}
export function getMemoryBorderColor(
mem: GraphApiMemory,
@ -32,25 +59,344 @@ export function getEdgeVisualProps(edgeType: string) {
case "derives":
return { opacity: 0.4, thickness: 1.2 }
case "updates":
return { opacity: 0.7, thickness: 2 }
return { opacity: 0.48, thickness: 1.45 }
case "extends":
return { opacity: 0.55, thickness: 1.5 }
return { opacity: 0.16, thickness: 0.8 }
default:
return { opacity: 0.4, thickness: 1.2 }
}
}
export function getMemoryOrbitOffset(
index: number,
count: number,
memoryId: string,
) {
const safeCount = Math.max(1, count)
let remaining = index
let ring = 0
let ringStart = 0
let ringCapacity = getMemoryRingCapacity(ring)
while (remaining >= ringCapacity) {
remaining -= ringCapacity
ringStart += ringCapacity
ring++
ringCapacity = getMemoryRingCapacity(ring)
}
const radius =
MEMORY_ORBIT_BASE +
ring * MEMORY_ORBIT_GAP +
hashToUnit(`${memoryId}-r`) * Math.min(54, MEMORY_ORBIT_GAP * 0.45)
const angleStep =
(2 * Math.PI) / Math.max(1, Math.min(ringCapacity, safeCount))
const phase = hashToUnit(`${memoryId}-phase`) * angleStep * 0.75
const angle =
(remaining + ringStart * 0.17) * angleStep + ring * GOLDEN_ANGLE + phase
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
radius,
}
}
function getMemoryRingCapacity(ring: number): number {
const radius = MEMORY_ORBIT_BASE + ring * MEMORY_ORBIT_GAP
return Math.max(8, Math.floor((2 * Math.PI * radius) / MEMORY_ORBIT_SPACING))
}
export function getClusterColor(key: string): string {
return CLUSTER_COLORS[hashString(key) % CLUSTER_COLORS.length] as string
}
export function computeClusterAssignments(
documents: GraphApiDocument[],
): Map<string, ClusterAssignment> {
const adjacency = new Map<string, Set<string>>()
const docByMemory = new Map<string, string>()
const orderByMemory = new Map<string, number>()
const allMemoryIds = new Set<string>()
let order = 0
for (const doc of documents) {
let firstMemoryId: string | null = null
for (const mem of doc.memories) {
allMemoryIds.add(mem.id)
docByMemory.set(mem.id, doc.id)
orderByMemory.set(mem.id, order++)
ensureAdjacency(adjacency, mem.id)
if (!firstMemoryId) {
firstMemoryId = mem.id
} else {
connect(adjacency, firstMemoryId, mem.id)
}
}
}
for (const doc of documents) {
for (const mem of doc.memories) {
for (const targetId of Object.keys(getMemoryRelationTargets(mem))) {
if (!allMemoryIds.has(targetId)) continue
connect(adjacency, mem.id, targetId)
}
}
}
const assignments = new Map<string, ClusterAssignment>()
const visited = new Set<string>()
const memoryIdsByOrder = [...allMemoryIds].sort(
(a, b) => (orderByMemory.get(a) ?? 0) - (orderByMemory.get(b) ?? 0),
)
for (const startId of memoryIdsByOrder) {
if (visited.has(startId)) continue
const component: string[] = []
const queue = [startId]
visited.add(startId)
while (queue.length > 0) {
const id = queue.shift() as string
component.push(id)
for (const nextId of adjacency.get(id) ?? []) {
if (visited.has(nextId)) continue
visited.add(nextId)
queue.push(nextId)
}
}
component.sort(
(a, b) => (orderByMemory.get(a) ?? 0) - (orderByMemory.get(b) ?? 0),
)
const firstId = component[0] ?? startId
const docIds = new Set(component.map((id) => docByMemory.get(id)))
const firstDocId = docByMemory.get(firstId) ?? "unknown"
const key =
docIds.size <= 1
? `doc:${firstDocId}`
: `relation:${firstDocId}:${firstId}`
const assignment = {
key,
color: getClusterColor(key),
size: component.length,
}
for (const id of component) assignments.set(id, assignment)
}
return assignments
}
function getMemoryRelationTargets(mem: GraphApiMemory): Record<string, string> {
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
return mem.memoryRelations
}
if (mem.parentMemoryId) return { [mem.parentMemoryId]: "updates" }
return {}
}
function getDocumentClusterAssignment(
doc: GraphApiDocument,
assignments: Map<string, ClusterAssignment>,
): ClusterAssignment {
const counts = new Map<
string,
{ assignment: ClusterAssignment; count: number }
>()
for (const mem of doc.memories) {
const assignment = assignments.get(mem.id)
if (!assignment) continue
const entry = counts.get(assignment.key)
if (entry) {
entry.count++
} else {
counts.set(assignment.key, { assignment, count: 1 })
}
}
let best: { assignment: ClusterAssignment; count: number } | null = null
for (const entry of counts.values()) {
if (!best || entry.count > best.count) best = entry
}
return (
best?.assignment ?? {
key: `doc:${doc.id}`,
color: getClusterColor(`doc:${doc.id}`),
size: 1,
}
)
}
function getMemoryNodeBorderColor(
mem: GraphApiMemory,
colors: GraphThemeColors,
clusterColor?: string,
): string {
const semanticColor = getMemoryBorderColor(mem, colors)
return semanticColor === colors.memStrokeDefault && clusterColor
? clusterColor
: semanticColor
}
function ensureAdjacency(map: Map<string, Set<string>>, id: string) {
if (!map.has(id)) map.set(id, new Set())
}
function connect(map: Map<string, Set<string>>, a: string, b: string) {
ensureAdjacency(map, a)
ensureAdjacency(map, b)
map.get(a)?.add(b)
map.get(b)?.add(a)
}
/**
* 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 (hashString(str) % 10000) / 10000
}
export function getNodeBounds(nodes: GraphNode[]) {
if (nodes.length === 0) return null
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes) {
const radius = node.size / 2
minX = Math.min(minX, node.x - radius)
minY = Math.min(minY, node.y - radius)
maxX = Math.max(maxX, node.x + radius)
maxY = Math.max(maxY, node.y + radius)
}
return ((h >>> 0) % 10000) / 10000
return {
minX,
minY,
maxX,
maxY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
}
}
type NodeBounds = NonNullable<ReturnType<typeof getNodeBounds>>
export function getAppendPosition(
existingNodes: GraphNode[],
appendIndex: number,
canvasWidth: number,
canvasHeight: number,
baseBounds?: NodeBounds | null,
spatialGrid?: Map<string, GraphNode[]>,
) {
const bounds = baseBounds ?? getNodeBounds(existingNodes)
if (!bounds) {
return { x: canvasWidth / 2, y: canvasHeight / 2 }
}
const candidateGrid = spatialGrid ?? buildAppendSpatialGrid(existingNodes)
const boundsWidth = bounds.maxX - bounds.minX
const boundsHeight = bounds.maxY - bounds.minY
const baseRadiusX = boundsWidth / 2 + APPEND_CLUSTER_RADIUS + APPEND_AREA_GAP
const baseRadiusY = boundsHeight / 2 + APPEND_CLUSTER_RADIUS + APPEND_AREA_GAP
const ringStep = APPEND_CLUSTER_RADIUS + APPEND_AREA_GAP
const seed = existingNodes.length + appendIndex
for (let ring = 0; ring < APPEND_MAX_RINGS; ring++) {
const radiusX = baseRadiusX + ring * ringStep
const radiusY = baseRadiusY + ring * ringStep
for (let attempt = 0; attempt < APPEND_CANDIDATES_PER_RING; attempt++) {
const angle =
(seed + attempt + ring * APPEND_CANDIDATES_PER_RING) * GOLDEN_ANGLE
const candidate = {
x: bounds.centerX + Math.cos(angle) * radiusX,
y: bounds.centerY + Math.sin(angle) * radiusY,
}
if (isAppendCandidateOpen(candidate, candidateGrid)) {
return candidate
}
}
}
const fallbackAngle = seed * GOLDEN_ANGLE
const fallbackRadiusX = baseRadiusX + APPEND_MAX_RINGS * ringStep
const fallbackRadiusY = baseRadiusY + APPEND_MAX_RINGS * ringStep
return {
x: bounds.centerX + Math.cos(fallbackAngle) * fallbackRadiusX,
y: bounds.centerY + Math.sin(fallbackAngle) * fallbackRadiusY,
}
}
function isAppendCandidateOpen(
candidate: { x: number; y: number },
spatialGrid: Map<string, GraphNode[]>,
) {
const cellX = getAppendSpatialCell(candidate.x)
const cellY = getAppendSpatialCell(candidate.y)
for (let x = cellX - 1; x <= cellX + 1; x++) {
for (let y = cellY - 1; y <= cellY + 1; y++) {
const nodes = spatialGrid.get(getAppendSpatialKey(x, y))
if (!nodes) continue
for (const node of nodes) {
const minDistance =
APPEND_CLUSTER_RADIUS + node.size / 2 + APPEND_AREA_GAP
const dx = candidate.x - node.x
if (Math.abs(dx) > minDistance) continue
const dy = candidate.y - node.y
if (Math.abs(dy) > minDistance) continue
if (dx * dx + dy * dy < minDistance * minDistance) return false
}
}
}
return true
}
function buildAppendSpatialGrid(nodes: GraphNode[]): Map<string, GraphNode[]> {
const grid = new Map<string, GraphNode[]>()
for (const node of nodes) {
addAppendSpatialNode(grid, node)
}
return grid
}
function addAppendSpatialNode(
grid: Map<string, GraphNode[]>,
node: GraphNode,
): void {
const key = getAppendSpatialKey(
getAppendSpatialCell(node.x),
getAppendSpatialCell(node.y),
)
const bucket = grid.get(key)
if (bucket) {
bucket.push(node)
} else {
grid.set(key, [node])
}
}
function getAppendSpatialCell(value: number): number {
return Math.floor(value / APPEND_SPATIAL_CELL_SIZE)
}
function getAppendSpatialKey(x: number, y: number): string {
return `${x}:${y}`
}
/**
@ -85,19 +431,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
// 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" }
}
const relations = getMemoryRelationTargets(mem)
for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue
@ -130,8 +464,13 @@ export function useGraphData(
) {
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
useEffect(() => {
if (!documents || documents.length === 0) return
const graphData = useMemo<{
nodes: GraphNode[]
cache: Map<string, GraphNode>
}>(() => {
if (!documents || documents.length === 0) {
return { nodes: [], cache: new Map<string, GraphNode>() }
}
const currentIds = new Set<string>()
for (const doc of documents) {
@ -139,13 +478,21 @@ export function useGraphData(
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 nodes = useMemo(() => {
if (!documents || documents.length === 0) return []
const previousCache = nodeCache.current
const nextCache = new Map<string, GraphNode>()
const appendPlacementNodes = Array.from(previousCache.values()).filter(
(node) => currentIds.has(node.id),
)
const shouldAppendNewNodes = appendPlacementNodes.length > 0
const appendBaseBounds = shouldAppendNewNodes
? getNodeBounds(appendPlacementNodes)
: null
const appendSpatialGrid =
shouldAppendNewNodes && appendBaseBounds
? buildAppendSpatialGrid(appendPlacementNodes)
: null
let appendIndex = 0
const clusterAssignments = computeClusterAssignments(documents)
const result: GraphNode[] = []
// Spiral layout: documents form a compact spiral core, memories orbit
@ -162,12 +509,13 @@ export function useGraphData(
for (let docIdx = 0; docIdx < docCount; docIdx++) {
const doc = documents[docIdx]
const docCluster = getDocumentClusterAssignment(doc, clusterAssignments)
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
let docNode = nodeCache.current.get(doc.id)
const previousDocNode = previousCache.get(doc.id)
const docData: DocumentNodeData = {
id: doc.id,
title: doc.title,
@ -178,68 +526,105 @@ export function useGraphData(
memories: doc.memories,
}
if (docNode) {
docNode.data = docData
docNode.borderColor = colors.docStroke
docNode.isDragging = draggingNodeId === doc.id
let docNode: GraphNode
if (previousDocNode) {
docNode = {
...previousDocNode,
data: docData,
borderColor: docCluster.color,
clusterKey: docCluster.key,
clusterColor: docCluster.color,
isDragging: draggingNodeId === doc.id,
}
} else {
const appendPosition =
shouldAppendNewNodes && appendBaseBounds && appendSpatialGrid
? getAppendPosition(
appendPlacementNodes,
appendIndex++,
canvasWidth,
canvasHeight,
appendBaseBounds,
appendSpatialGrid,
)
: null
docNode = {
id: doc.id,
type: "document",
x: initialX,
y: initialY,
x: appendPosition?.x ?? initialX,
y: appendPosition?.y ?? initialY,
data: docData,
size: 50,
borderColor: colors.docStroke,
borderColor: docCluster.color,
clusterKey: docCluster.key,
clusterColor: docCluster.color,
isHovered: false,
isDragging: false,
}
nodeCache.current.set(doc.id, docNode)
if (appendSpatialGrid) {
appendPlacementNodes.push(docNode)
addAppendSpatialNode(appendSpatialGrid, docNode)
}
}
nextCache.set(doc.id, docNode)
result.push(docNode)
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 previousMemNode = previousCache.get(mem.id)
const memData: MemoryNodeData = {
...mem,
documentId: doc.id,
content: mem.memory,
}
const cluster = clusterAssignments.get(mem.id)
if (memNode) {
memNode.data = memData
memNode.borderColor = getMemoryBorderColor(mem, colors)
memNode.isDragging = draggingNodeId === mem.id
let memNode: GraphNode
if (previousMemNode) {
memNode = {
...previousMemNode,
data: memData,
borderColor: getMemoryNodeBorderColor(mem, colors, cluster?.color),
clusterKey: cluster?.key ?? null,
clusterColor: cluster?.color ?? null,
isDragging: draggingNodeId === mem.id,
}
} else {
// 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
const memOffset = getMemoryOrbitOffset(i, memCount, mem.id)
memNode = {
id: mem.id,
type: "memory",
x: docNode.x + Math.cos(memAngle) * memRadius,
y: docNode.y + Math.sin(memAngle) * memRadius,
x: docNode.x + memOffset.x,
y: docNode.y + memOffset.y,
data: memData,
size: 36,
borderColor: getMemoryBorderColor(mem, colors),
borderColor: getMemoryNodeBorderColor(mem, colors, cluster?.color),
clusterKey: cluster?.key ?? null,
clusterColor: cluster?.color ?? null,
isHovered: false,
isDragging: false,
}
nodeCache.current.set(mem.id, memNode)
if (appendSpatialGrid) {
appendPlacementNodes.push(memNode)
addAppendSpatialNode(appendSpatialGrid, memNode)
}
}
nextCache.set(mem.id, memNode)
result.push(memNode)
}
}
return result
return { nodes: result, cache: nextCache }
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
useEffect(() => {
nodeCache.current = graphData.cache
}, [graphData.cache])
const edges = useMemo(() => computeEdges(documents), [documents])
return { nodes, edges }
return { nodes: graphData.nodes, edges }
}

View file

@ -81,6 +81,8 @@ export interface GraphNode {
data: DocumentNodeData | MemoryNodeData
size: number
borderColor: string
clusterKey?: string | null
clusterColor?: string | null
isHovered: boolean
isDragging: boolean
// D3-force simulation properties

View file

@ -0,0 +1,7 @@
export function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0
}
return hash >>> 0
}