diff --git a/apps/memory-graph-playground/src/app/api/container-tags/route.ts b/apps/memory-graph-playground/src/app/api/container-tags/route.ts new file mode 100644 index 000000000..324e3bc58 --- /dev/null +++ b/apps/memory-graph-playground/src/app/api/container-tags/route.ts @@ -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 }, + ) + } +} diff --git a/apps/memory-graph-playground/src/app/api/graph/route.ts b/apps/memory-graph-playground/src/app/api/graph/route.ts index c722c625b..67b62d342 100644 --- a/apps/memory-graph-playground/src/app/api/graph/route.ts +++ b/apps/memory-graph-playground/src/app/api/graph/route.ts @@ -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( diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 68a6f9450..8131ef69c 100644 --- a/apps/memory-graph-playground/src/app/page.tsx +++ b/apps/memory-graph-playground/src/app/page.tsx @@ -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 | 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 + /** 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([]) + const [containerTag, setContainerTag] = useState("") + const [containerTags, setContainerTags] = useState([]) + const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false) + const [containerTagsError, setContainerTagsError] = useState( + null, + ) + const [documents, setDocuments] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) const [showGraph, setShowGraph] = useState(false) const [stressTestCount, setStressTestCount] = useState(0) + const [graphVariant, setGraphVariant] = useState("consumer") + const [loadBehavior, setLoadBehavior] = useState("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() + 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 (
@@ -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" /> +
+ 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" + /> + + {availableContainerTags.map((tag) => ( + + ))} + + +
+ Mode: +
+ {(["consumer", "console"] as const).map((variant) => ( + + ))} +
+
+ Load: +
+ {(["zoom", "manual", "background"] as const).map((behavior) => ( + + ))} +
+ {loadBehavior === "manual" && ( + + )} + {loadBehavior === "background" && !mockData && hasMore && ( + + Auto paging + + )} +
{/* Stress test buttons */} Stress Test: {[50, 100, 200, 500].map((count) => ( @@ -300,13 +593,24 @@ export default function Home() { 0} isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} onSlideshowStop={handleSlideshowStop} + totalCount={totalCount} + colors={ + graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined + } >

diff --git a/packages/memory-graph/src/__tests__/edge-logic.test.ts b/packages/memory-graph/src/__tests__/edge-logic.test.ts index ff9003f4a..8426a7d30 100644 --- a/packages/memory-graph/src/__tests__/edge-logic.test.ts +++ b/packages/memory-graph/src/__tests__/edge-logic.test.ts @@ -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)", () => { diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts index 9c643925a..4f728f97b 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -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 { return { @@ -25,6 +29,28 @@ function makeMemory(overrides: Partial = {}): 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, + } +} diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts index 481256a2b..026300378 100644 --- a/packages/memory-graph/src/__tests__/renderer-utils.test.ts +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -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) + }) +}) diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts index 61d46752e..cd0035a25 100644 --- a/packages/memory-graph/src/__tests__/simulation.test.ts +++ b/packages/memory-graph/src/__tests__/simulation.test.ts @@ -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() diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts index eb1bf0298..e208bb1e6 100644 --- a/packages/memory-graph/src/__tests__/version-chain.test.ts +++ b/packages/memory-graph/src/__tests__/version-chain.test.ts @@ -67,6 +67,27 @@ 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("getChain from middle element returns full chain (backward + forward)", () => { diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts index aee0b14b0..b2164e25d 100644 --- a/packages/memory-graph/src/__tests__/viewport.test.ts +++ b/packages/memory-graph/src/__tests__/viewport.test.ts @@ -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 diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index a3e1be300..f208bc4d0 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -17,6 +17,9 @@ export interface RenderState { // Module-level reusable batch map – cleared each frame instead of reallocating const edgeBatches = new Map() +const RELATION_LOD_ZOOM = 0.5 +const RELATION_LOD_MAX_BACKGROUND_EDGES = 260 +const RELATION_LOD_DENSE_COUNT = 180 function nodeMatchesDocumentHighlights( node: GraphNode, @@ -30,13 +33,21 @@ function nodeMatchesDocumentHighlights( /** Group items by their `color` property into batches for efficient canvas drawing */ function groupByColor( items: T[], +): Map { + return groupByComputedColor(items, (item) => item.color) +} + +function groupByComputedColor( + items: T[], + getColor: (item: T) => string, ): Map { const map = new Map() 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 +81,92 @@ 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) +} + +export function shouldDrawRelationEdge( + edgeId: string, + edgeType: string, + stride: number, +): boolean { + if (edgeType === "derives" || stride <= 1) return true + return 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 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 +} + +function clampNumber(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value } function batchKey(style: { @@ -92,6 +186,7 @@ interface PreparedEdge { style: { color: string; width: number; opacity: number } edgeType: string arrowSize: number + glow: boolean } function drawEdges( @@ -106,13 +201,33 @@ 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 relationStride = getRelationEdgeStride(relationEdgeCount, 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 + if ( + !shouldAlwaysDrawActiveUpdate && + !hasDim && + !shouldDrawRelationEdge(edge.id, edgeType, relationStride) + ) { + continue } const src = @@ -121,7 +236,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,24 +264,42 @@ 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 && + !shouldDrawRelationEdge(edge.id, edgeType, relationStride) + ) { + continue } + const { style, glow } = applyRelationLevelOfDetail( + edgeStyle(edge, colors), + edgeType, + relationEdgeCount, + viewport.zoom, + hasDim && connected, + edgeType === "updates" && hoverConnected, + ) + prepared.push({ startX: s.x + ux * sr, startY: s.y + uy * sr, endX: t.x - ux * tr, endY: t.y - uy * tr, connected, - style: edgeStyle(edge, colors), - edgeType: edge.edgeType ?? "derives", + 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 +307,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 +323,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 +338,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 +354,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 +361,6 @@ function drawEdges( } ctx.stroke() - if (batchEdgeType === "extends") ctx.setLineDash([]) - // Arrowheads for updates edges if (batchEdgeType === "updates") { ctx.globalAlpha = isDimmed @@ -286,7 +414,10 @@ function drawNodes( y: number r: number color: string + fillColor: string + haloColor: string dimmed: boolean + updateChain: boolean }[] = [] const docDots: { x: number; y: number; s: number }[] = [] @@ -323,7 +454,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 @@ -403,7 +537,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 +552,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 +576,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 +618,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 +651,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 +669,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 +691,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 +715,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 +740,8 @@ function drawMemoryNode( ctx.lineWidth = 1.5 ctx.stroke() + drawUpdateMarker(ctx, sx, sy, radius, colors, 0.85) + ctx.globalAlpha = prevAlpha return } @@ -573,7 +756,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 +765,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 +798,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 +926,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") +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 442717933..9726a7df0 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,5 +1,5 @@ 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 class ForceSimulation { @@ -27,7 +27,7 @@ export class ForceSimulation { .id((d) => d.id) .distance((link) => link.edgeType === "derives" - ? FORCE_CONFIG.docMemoryDistance + ? getDocMemoryDistance(link) : FORCE_CONFIG.linkDistance, ) .strength((link) => { @@ -85,6 +85,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 +100,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 +} diff --git a/packages/memory-graph/src/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts index 6c692fa7c..c7dcc4a75 100644 --- a/packages/memory-graph/src/canvas/version-chain.ts +++ b/packages/memory-graph/src/canvas/version-chain.ts @@ -80,9 +80,15 @@ 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) => ({ + const shouldInferVersions = all.some((m, index) => { + if (!Number.isFinite(m.version) || m.version < 1) return true + const previous = all[index - 1] + return previous != null && m.version <= previous.version + }) + + const chain: ChainEntry[] = all.map((m, index) => ({ id: m.id, - version: m.version, + version: shouldInferVersions ? index + 1 : m.version, memory: m.memory, isForgotten: m.isForgotten, isLatest: m.isLatest, diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index b741097ba..c33a16810 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -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 - - 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 fit = computeFit(nodes, width, height) + if (!fit) return - 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 { cx, cy, fitZoom } = fit - 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 } diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 0290226e7..a1e888285 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -6,6 +6,7 @@ interface LegendProps { edges?: GraphEdge[] isLoading?: boolean colors: GraphThemeColors + hoveredNode?: string | null } function HexagonIcon({ @@ -38,32 +39,100 @@ function HexagonIcon({ function LineIcon({ color, dashed = false, + arrow = false, }: { color: string dashed?: boolean + arrow?: boolean }) { return ( -