mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Improve memory graph (#973)
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
This commit is contained in:
parent
ad5734cdfc
commit
053baa5029
24 changed files with 2034 additions and 306 deletions
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
packages/memory-graph/src/utils/hash.ts
Normal file
7
packages/memory-graph/src/utils/hash.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue