From 7bfe0b6be4ae9f928c0f619c31970c726fb4b0dc Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 12 Oct 2026 21:42:25 -0700 Subject: [PATCH 01/11] fix(memory-graph): stop hit-testing against stale nodes after resize or theme change (#1284) --- .../src/__tests__/spatial-index.test.ts | 27 +++++++++++++++++++ packages/memory-graph/src/canvas/hit-test.ts | 5 ++-- .../src/components/graph-canvas.tsx | 4 +-- .../src/components/memory-graph.tsx | 10 +------ 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/memory-graph/src/__tests__/spatial-index.test.ts b/packages/memory-graph/src/__tests__/spatial-index.test.ts index 46d7beab..23f72f09 100644 --- a/packages/memory-graph/src/__tests__/spatial-index.test.ts +++ b/packages/memory-graph/src/__tests__/spatial-index.test.ts @@ -54,6 +54,33 @@ describe("SpatialIndex", () => { expect(result).toBe(true) }) + it("hash guard keeps stale objects for a new generation at the same positions", () => { + const idx = new SpatialIndex() + const genA = [makeNode("a", 100, 100)] + idx.rebuild(genA) + + const genB = [makeNode("a", 100, 100)] + expect(idx.rebuild(genB)).toBe(false) + expect(idx.queryPoint(100, 100)).toBe(genA[0]) + }) + + it("force rebuild swaps the grid to the new node generation", () => { + const idx = new SpatialIndex() + const genA = [makeNode("a", 100, 100)] + idx.rebuild(genA) + + const genB = [makeNode("a", 100, 100)] + expect(idx.rebuild(genB, true)).toBe(true) + expect(idx.queryPoint(100, 100)).toBe(genB[0]) + }) + + it("force rebuild still refreshes the hash for subsequent unforced calls", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100, 100)] + idx.rebuild(nodes, true) + expect(idx.rebuild(nodes)).toBe(false) + }) + it("rebuild detects sub-pixel movements (10x granularity)", () => { const idx = new SpatialIndex() const nodes = [makeNode("a", 100.0, 100.0)] diff --git a/packages/memory-graph/src/canvas/hit-test.ts b/packages/memory-graph/src/canvas/hit-test.ts index ac20bca7..7f4d8bd6 100644 --- a/packages/memory-graph/src/canvas/hit-test.ts +++ b/packages/memory-graph/src/canvas/hit-test.ts @@ -5,9 +5,10 @@ export class SpatialIndex { private cellSize = 200 private lastHash = 0 - rebuild(nodes: GraphNode[]): boolean { + // Hash only covers ids/positions, so pass force when the node array identity changes. + rebuild(nodes: GraphNode[], force = false): boolean { const hash = this.computeHash(nodes) - if (hash === this.lastHash) return false + if (!force && hash === this.lastHash) return false this.lastHash = hash this.grid.clear() diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index 2f072c9e..1e6d00ba 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -97,13 +97,13 @@ export const GraphCanvas = memo(function GraphCanvas({ simulation, } - // Rebuild nodeMap + spatial index when nodes change + // Force the spatial rebuild: a new node generation can keep the same ids/positions useEffect(() => { const map = nodeMapRef.current map.clear() for (const n of nodes) map.set(n.id, n) inputRef.current?.syncNodes(map) - spatialRef.current.rebuild(nodes) + spatialRef.current.rebuild(nodes, true) renderNeeded.current = true }, [nodes]) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index b62b10d9..df8e7936 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -20,7 +20,6 @@ import type { GraphApiDocument, GraphNode, MemoryNodeData, - GraphThemeColors, MemoryGraphProps, ResolvedMemoryGraphLabels, } from "../types" @@ -85,14 +84,7 @@ export function MemoryGraph({ ) const hoverPopoverZIndex = layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX - const resolvedColors = useGraphTheme(colorOverrides) - const colors = useMemo( - () => - colorOverrides - ? { ...resolvedColors, ...colorOverrides } - : resolvedColors, - [resolvedColors, colorOverrides], - ) + const colors = useGraphTheme(colorOverrides) const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const [containerBounds, setContainerBounds] = useState(null) From d9284901617afd2f75fc75e7a305dd46ab3990b7 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 12 Oct 2026 21:42:25 -0700 Subject: [PATCH 02/11] fix(memory-graph): accessible names and states for graph controls (#1349) --- .../src/__tests__/controls-a11y.e2e.test.tsx | 89 +++++++++++++++++++ .../memory-graph/src/components/legend.tsx | 2 + .../src/components/loading-indicator.tsx | 4 +- .../src/components/navigation-controls.tsx | 8 +- 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/controls-a11y.e2e.test.tsx diff --git a/packages/memory-graph/src/__tests__/controls-a11y.e2e.test.tsx b/packages/memory-graph/src/__tests__/controls-a11y.e2e.test.tsx new file mode 100644 index 00000000..cc6a8b92 --- /dev/null +++ b/packages/memory-graph/src/__tests__/controls-a11y.e2e.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from "@testing-library/react" +import { afterEach, describe, expect, it } from "vitest" + +afterEach(cleanup) + +import { Legend } from "../components/legend" +import { LoadingIndicator } from "../components/loading-indicator" +import { NavigationControls } from "../components/navigation-controls" +import { DEFAULT_COLORS, DEFAULT_LABELS } from "../constants" +import type { GraphNode } from "../types" + +const node: GraphNode = { + id: "doc-1", + type: "document", + x: 0, + y: 0, + data: { + id: "doc-1", + title: "Doc", + summary: null, + type: "text", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + size: 40, + borderColor: "#fff", + isHovered: false, + isDragging: false, +} + +const noop = () => {} + +describe("NavigationControls accessibility", () => { + it("gives the icon-only zoom buttons accessible names", () => { + const { getByLabelText } = render( + , + ) + + expect(getByLabelText("Zoom in")).toBeTruthy() + expect(getByLabelText("Zoom out")).toBeTruthy() + }) +}) + +describe("Legend accessibility", () => { + it("exposes disclosure state via aria-expanded and toggles it", () => { + const { getByRole } = render( + , + ) + + const toggle = getByRole("button", { name: "Legend" }) + expect(toggle.getAttribute("aria-expanded")).toBe("false") + + fireEvent.click(toggle) + expect(toggle.getAttribute("aria-expanded")).toBe("true") + }) +}) + +describe("LoadingIndicator accessibility", () => { + it("announces loading through a status live region", () => { + const { getByRole } = render( + , + ) + + const status = getByRole("status") + expect(status.getAttribute("aria-live")).toBe("polite") + }) +}) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index acf69c39..272872d0 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -244,6 +244,7 @@ function StatRow({ onClick={expandable ? onToggle : undefined} style={buttonStyle} type="button" + aria-expanded={expandable ? expanded : undefined} >
{icon} @@ -430,6 +431,7 @@ export const Legend = memo(function Legend({ onClick={() => setIsExpanded(!isExpanded)} style={headerBtnStyle} type="button" + aria-expanded={isExpanded} > {isExpanded ? ( diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx index 8d550eb9..596f9dbd 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -76,7 +76,7 @@ export const LoadingIndicator = memo< } return ( -
+
-
+ ) }, ) diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx index fd4497d9..584de4bc 100644 --- a/packages/memory-graph/src/components/navigation-controls.tsx +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -176,7 +176,9 @@ export const NavigationControls = memo( e.currentTarget.style.opacity = "1" }} > - − +
From 3454a22554383df542543c8552d5057d01985932 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 12 Oct 2026 21:42:26 -0700 Subject: [PATCH 03/11] feat(memory-graph): add onNodeSelect callback for selection changes (#1350) --- packages/memory-graph/README.md | 1 + packages/memory-graph/src/components/memory-graph.tsx | 10 ++++++++++ packages/memory-graph/src/types.ts | 2 ++ 3 files changed, 13 insertions(+) diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 9d240d4b..18f8fefc 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -66,6 +66,7 @@ function App() { | `error` | `Error \| null` | Error to display | | `loadMoreDocuments` | `() => Promise` | Function to load more data | | `highlightDocumentIds` | `string[]` | IDs of documents to highlight | +| `onNodeSelect` | `(nodeId: string \| null) => void` | Called when the selected node changes (click, keyboard, slideshow, or cleared) | Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors. diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index df8e7936..276d6066 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -73,6 +73,7 @@ export function MemoryGraph({ colors: colorOverrides, totalCount, onOpenDocument: externalOnOpenDocument, + onNodeSelect, labels: labelOverrides, layering, appearance, @@ -97,6 +98,15 @@ export function MemoryGraph({ // React state only for things that affect DOM const [hoveredNode, setHoveredNode] = useState(null) const [selectedNode, setSelectedNode] = useState(null) + const onNodeSelectRef = useRef(onNodeSelect) + onNodeSelectRef.current = onNodeSelect + const lastSelectedRef = useRef(selectedNode) + // Compares against the last value so mount and StrictMode re-runs do not emit. + useEffect(() => { + if (lastSelectedRef.current === selectedNode) return + lastSelectedRef.current = selectedNode + onNodeSelectRef.current?.(selectedNode) + }, [selectedNode]) const [zoomDisplay, setZoomDisplay] = useState(50) // Monotonic counter that increments on any viewport change (pan or zoom) // Used as a dependency proxy to recalculate popover positions diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index 6a53f78c..e83f1398 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -226,6 +226,8 @@ export interface MemoryGraphProps { totalCount?: number /** Callback when user wants to view full document content */ onOpenDocument?: (documentId: string) => void + /** Called when the selected node changes; null when cleared */ + onNodeSelect?: (nodeId: string | null) => void /** Custom user-facing labels (partial) - merged with defaults */ labels?: MemoryGraphLabels /** Overlay layering controls */ From 35794564e47411c3b4bf2cd7435edcaeb48bfbbd Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:43:19 -0700 Subject: [PATCH 04/11] fix(memory-graph): enable touch and mobile node dragging and interaction (#1639) --- .../src/__tests__/input-handler-touch.test.ts | 49 +++++++++++++++---- .../memory-graph/src/canvas/input-handler.ts | 47 +++++++++++++----- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 12d0e471..11e7e29c 100644 --- a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -78,6 +78,10 @@ describe("InputHandler touch tap-to-select", () => { let clicks: Array let viewport: ViewportState + let dragStarts: Array<{ id: string; node: GraphNode }> + let dragEnds: number + let testNode: GraphNode + const fire = (name: string, e: TouchEvent) => { const fn = listeners.get(name) if (!fn) throw new Error(`no listener registered for ${name}`) @@ -88,19 +92,26 @@ describe("InputHandler touch tap-to-select", () => { const stub = makeStubCanvas() listeners = stub.listeners clicks = [] + dragStarts = [] + dragEnds = 0 // zoom 1 / pan 0 so screen coordinates equal world coordinates viewport = new ViewportState(0, 0, 1) const index = new SpatialIndex() - index.rebuild([makeNode("doc-1", 100, 100)]) + testNode = makeNode("doc-1", 100, 100) + index.rebuild([testNode]) new InputHandler(stub.canvas, viewport, index, { onNodeHover: () => {}, onNodeClick: (id) => { clicks.push(id) }, - onNodeDragStart: () => {}, - onNodeDragEnd: () => {}, + onNodeDragStart: (id, node) => { + dragStarts.push({ id, node }) + }, + onNodeDragEnd: () => { + dragEnds++ + }, onRequestRender: () => {}, }) }) @@ -110,6 +121,8 @@ describe("InputHandler touch tap-to-select", () => { fire("touchend", touchEvent([])) expect(clicks).toEqual(["doc-1"]) + expect(dragStarts).toEqual([]) + expect(testNode.fx).toBeUndefined() }) it("clears selection when tapping empty space", () => { @@ -127,9 +140,28 @@ describe("InputHandler touch tap-to-select", () => { expect(clicks).toEqual(["doc-1"]) }) - it("does not fire a click after a pan", () => { + it("drags a node on touch and updates its fixed coordinates", () => { fire("touchstart", touchEvent([touch(100, 100)])) - fire("touchmove", touchEvent([touch(160, 100)])) + expect(dragStarts).toEqual([]) + + fire("touchmove", touchEvent([touch(160, 120)])) + expect(dragStarts.map((d) => d.id)).toEqual(["doc-1"]) + expect(testNode.x).toBe(160) + expect(testNode.y).toBe(120) + expect(testNode.fx).toBe(160) + expect(testNode.fy).toBe(120) + + fire("touchend", touchEvent([])) + expect(dragEnds).toBe(1) + expect(testNode.fx).toBeNull() + expect(testNode.fy).toBeNull() + // no tap click should fire after a real drag + expect(clicks).toEqual([]) + }) + + it("pans the viewport when dragging empty space", () => { + fire("touchstart", touchEvent([touch(400, 400)])) + fire("touchmove", touchEvent([touch(460, 400)])) fire("touchend", touchEvent([])) expect(clicks).toEqual([]) @@ -149,18 +181,15 @@ describe("InputHandler touch tap-to-select", () => { it("hit-tests a jittery tap against the node under the finger at touchstart", () => { // Zoomed out, a few-pixel finger jitter maps to a large world-space shift. - // The sub-threshold move still pans the viewport, so re-projecting the - // start screen point through the panned transform lands well off the node. // zoom 0.25: world (100, 100) renders at screen (25, 25). viewport.zoomImmediate(0.25, 0, 0) fire("touchstart", touchEvent([touch(25, 25)])) - // 8px screen jitter (below the 10px tap threshold) that pans the viewport + // 8px screen jitter (below the 10px tap threshold) fire("touchmove", touchEvent([touch(33, 25)])) fire("touchend", touchEvent([])) - // the jitter did move the viewport, but the tap still resolves the node - expect(viewport.panX).toBe(8) + // the tap resolves the node under the finger at touchstart expect(clicks).toEqual(["doc-1"]) }) diff --git a/packages/memory-graph/src/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index d3a82f16..9530be93 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -300,6 +300,7 @@ export class InputHandler { if (touches.length >= 2) { this.isTouchGesture = true this.tapCandidate = false + this.endTouchDrag() const t0 = touches[0] const t1 = touches[1] if (!t0 || !t1) return @@ -326,7 +327,13 @@ export class InputHandler { this.touchStartWorldX = startWorld.x this.touchStartWorldY = startWorld.y this.tapCandidate = true - this.isPanning = true + const node = this.spatialIndex.queryPoint(startWorld.x, startWorld.y) + if (node) { + this.pressedNode = node + this.grabOffset = { x: startWorld.x - node.x, y: startWorld.y - node.y } + } else { + this.isPanning = true + } } } @@ -357,12 +364,7 @@ export class InputHandler { this.lastTouchDistance = dist this.lastTouchCenter = center this.callbacks.onRequestRender() - } else if ( - touches.length === 1 && - this.isPanning && - !this.isTouchGesture && - touches[0] - ) { + } else if (touches.length === 1 && !this.isTouchGesture && touches[0]) { const t = touches[0] const rect = this.canvas.getBoundingClientRect() const x = t.clientX - rect.left @@ -374,19 +376,42 @@ export class InputHandler { ) { this.tapCandidate = false } - this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) - this.lastMouseX = x - this.lastMouseY = y - this.callbacks.onRequestRender() + if (this.pressedNode && !this.tapCandidate) { + this.draggingNode = this.pressedNode + this.pressedNode = null + this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode) + } + if (this.draggingNode) { + const world = this.viewport.screenToWorld(x, y) + this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x + this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y + this.callbacks.onRequestRender() + } else if (this.isPanning) { + this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) + this.lastMouseX = x + this.lastMouseY = y + this.callbacks.onRequestRender() + } } } + private endTouchDrag(): void { + this.pressedNode = null + if (!this.draggingNode) return + this.draggingNode.fx = null + this.draggingNode.fy = null + this.draggingNode = null + this.callbacks.onNodeDragEnd() + this.callbacks.onRequestRender() + } + private onTouchEnd(e: TouchEvent): void { if (e.touches.length < 2) { this.isTouchGesture = false } if (e.touches.length === 0) { this.isPanning = false + this.endTouchDrag() if (this.tapCandidate) { this.tapCandidate = false // Use the world point captured at touchstart, not the start screen From 48bd3cc66844210c45d9be79f7f7c3dd30378c68 Mon Sep 17 00:00:00 2001 From: Thanniru Sai Teja Date: Mon, 12 Oct 2026 21:43:20 -0700 Subject: [PATCH 05/11] perf(memory-graph): fix O(n^2) BFS queue drain in computeClusterAssignments (#1714) --- .../src/__tests__/graph-data-utils.test.ts | 18 ++++++++++++++++++ .../memory-graph/src/hooks/use-graph-data.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) 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 f98df074..f29ebc71 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -140,6 +140,24 @@ describe("cluster assignments", () => { expect(assignments.get("a1")?.key).toBe(assignments.get("b1")?.key) }) + + it("merges a wide fan-out of memories relating to one hub into a single cluster", () => { + const hub = makeDocument("doc-hub", [makeMemory({ id: "hub" })]) + const spokes = Array.from({ length: 200 }, (_, i) => + makeDocument(`doc-${i}`, [ + makeMemory({ id: `spoke-${i}`, memoryRelations: { hub: "derives" } }), + ]), + ) + + const assignments = computeClusterAssignments([hub, ...spokes]) + + const hubKey = assignments.get("hub")?.key + expect(hubKey).toBeDefined() + for (let i = 0; i < spokes.length; i++) { + expect(assignments.get(`spoke-${i}`)?.key).toBe(hubKey) + } + expect(assignments.get("hub")?.size).toBe(spokes.length + 1) + }) }) describe("memory orbit placement", () => { diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index e0a3b1b3..deb59b47 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -157,10 +157,12 @@ export function computeClusterAssignments( const component: string[] = [] const queue = [startId] + // index dequeue: shift() is O(n) per call, O(n^2) on wide components + let head = 0 visited.add(startId) - while (queue.length > 0) { - const id = queue.shift() as string + while (head < queue.length) { + const id = queue[head++] as string component.push(id) for (const nextId of adjacency.get(id) ?? []) { if (visited.has(nextId)) continue From 6470b6b05d83ae6c07cffcd6f5b0b34e03ff8b92 Mon Sep 17 00:00:00 2001 From: Abhijeet Sharma Date: Mon, 12 Oct 2026 21:43:21 -0700 Subject: [PATCH 06/11] fix(docs): correct stale @supermemory/memory-graph API references (#1414) --- apps/docs/integrations/memory-graph.mdx | 426 +++++++++++++++++++----- apps/memory-graph-playground/README.md | 22 +- 2 files changed, 359 insertions(+), 89 deletions(-) diff --git a/apps/docs/integrations/memory-graph.mdx b/apps/docs/integrations/memory-graph.mdx index 767b45d2..3b8dbb6d 100644 --- a/apps/docs/integrations/memory-graph.mdx +++ b/apps/docs/integrations/memory-graph.mdx @@ -25,11 +25,11 @@ npm install @supermemory/memory-graph 'use client'; // For Next.js App Router import { MemoryGraph } from '@supermemory/memory-graph'; -import type { DocumentWithMemories } from '@supermemory/memory-graph'; +import type { GraphApiDocument } from '@supermemory/memory-graph'; import { useEffect, useState } from 'react'; export default function GraphPage() { - const [documents, setDocuments] = useState([]); + const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -76,15 +76,97 @@ Existing icon-based document nodes and connection styling remain the defaults. O `openDocumentOnEnter` is off by default. With an `onOpenDocument` callback, `MemoryGraph` exposes a focused Enter action for the selected persisted document or one confirmed memory source. Typing, modifier shortcuts, repeats, composition and keys outside the graph do not open documents. Your callback retains responsibility for routing and access control; consumers without a callback receive no new opening behavior. + + `documents` must be `GraphApiDocument[]`, the shape the API route below returns, with `documentType` and `memories` fields (not the raw API's `type` and `memoryEntries`). + + ## Backend API route -Create an API route to fetch documents from Supermemory: +Create an API route to fetch documents from Supermemory. The `/v3/documents/documents` response uses `type` and `memoryEntries`; `` expects `documentType` and `memories`, so the route normalizes the shape before returning it: ```typescript Next.js App Router // app/api/graph/route.ts import { NextResponse } from 'next/server'; +import type { GraphApiDocument, GraphApiMemory } from '@supermemory/memory-graph'; + +interface RawMemoryEntry { + id: string; + memory?: string | null; + content?: string | null; + spaceId?: string | null; + isStatic?: boolean; + isLatest?: boolean; + isForgotten?: boolean; + forgetAfter?: string | null; + forgetReason?: string | null; + version?: number; + parentMemoryId?: string | null; + rootMemoryId?: string | null; + createdAt: string; + updatedAt: string; + relation?: 'updates' | 'extends' | 'derives' | null; + updatesMemoryId?: string | null; + nextVersionId?: string | null; + memoryRelations?: Record | null; + spaceContainerTag?: string | null; +} + +interface RawDocument { + id: string; + title: string | null; + summary?: string | null; + type: string; + createdAt: string; + updatedAt: string; + memoryEntries: RawMemoryEntry[]; +} + +interface RawDocumentsResponse { + documents: RawDocument[]; + pagination: { + currentPage: number; + limit: number; + totalItems: number; + totalPages: number; + }; +} + +// Maps the raw API document (`type` / `memoryEntries`) onto the shape +// expects (`documentType` / `memories`), filling in the +// defaults for fields the API may omit. +function toGraphDocument(doc: RawDocument): GraphApiDocument { + return { + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.type, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memoryEntries.map((mem): GraphApiMemory => ({ + id: mem.id, + memory: mem.memory ?? mem.content ?? '', + content: mem.content ?? null, + 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 async function GET() { const response = await fetch('https://api.supermemory.ai/v3/documents/documents', { @@ -101,14 +183,95 @@ export async function GET() { }), }); - const data = await response.json(); - return NextResponse.json(data); + const data: RawDocumentsResponse = await response.json(); + return NextResponse.json({ + documents: data.documents.map(toGraphDocument), + pagination: data.pagination, + }); } ``` ```typescript Next.js Pages Router // pages/api/graph.ts import type { NextApiRequest, NextApiResponse } from 'next'; +import type { GraphApiDocument, GraphApiMemory } from '@supermemory/memory-graph'; + +interface RawMemoryEntry { + id: string; + memory?: string | null; + content?: string | null; + spaceId?: string | null; + isStatic?: boolean; + isLatest?: boolean; + isForgotten?: boolean; + forgetAfter?: string | null; + forgetReason?: string | null; + version?: number; + parentMemoryId?: string | null; + rootMemoryId?: string | null; + createdAt: string; + updatedAt: string; + relation?: 'updates' | 'extends' | 'derives' | null; + updatesMemoryId?: string | null; + nextVersionId?: string | null; + memoryRelations?: Record | null; + spaceContainerTag?: string | null; +} + +interface RawDocument { + id: string; + title: string | null; + summary?: string | null; + type: string; + createdAt: string; + updatedAt: string; + memoryEntries: RawMemoryEntry[]; +} + +interface RawDocumentsResponse { + documents: RawDocument[]; + pagination: { + currentPage: number; + limit: number; + totalItems: number; + totalPages: number; + }; +} + +// Maps the raw API document (`type` / `memoryEntries`) onto the shape +// expects (`documentType` / `memories`), filling in the +// defaults for fields the API may omit. +function toGraphDocument(doc: RawDocument): GraphApiDocument { + return { + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.type, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memoryEntries.map((mem): GraphApiMemory => ({ + id: mem.id, + memory: mem.memory ?? mem.content ?? '', + content: mem.content ?? null, + 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 async function handler(req: NextApiRequest, res: NextApiResponse) { const response = await fetch('https://api.supermemory.ai/v3/documents/documents', { @@ -120,8 +283,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) body: JSON.stringify({ page: 1, limit: 500, sort: 'createdAt', order: 'desc' }), }); - const data = await response.json(); - res.json(data); + const data: RawDocumentsResponse = await response.json(); + res.json({ + documents: data.documents.map(toGraphDocument), + pagination: data.pagination, + }); } ``` @@ -137,7 +303,41 @@ app.get('/api/graph', async (req, res) => { }); const data = await response.json(); - res.json(data); + + // Map the raw API document (`type` / `memoryEntries`) onto the shape + // expects (`documentType` / `memories`), filling in the + // defaults for fields the API may omit. + const documents = data.documents.map((doc) => ({ + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.type, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memoryEntries.map((mem) => ({ + id: mem.id, + memory: mem.memory ?? mem.content ?? '', + content: mem.content ?? null, + 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, + })), + })); + + res.json({ documents, pagination: data.pagination }); }); ``` @@ -151,13 +351,13 @@ app.get('/api/graph', async (req, res) => { ## Variants -**Console Variant** - Full-featured dashboard view (0.8x zoom, space selector visible): +**Console Variant** - Full-featured dashboard view (0.8x initial zoom): ```tsx ``` -**Consumer Variant** - Embedded widget view (0.5x zoom, space selector hidden): +**Consumer Variant** - Embedded widget view (0.5x initial zoom): ```tsx @@ -173,10 +373,11 @@ app.get('/api/graph', async (req, res) => { 'use client'; import { MemoryGraph } from '@supermemory/memory-graph'; +import type { GraphApiDocument } from '@supermemory/memory-graph'; import { useCallback, useEffect, useState } from 'react'; export default function PaginatedGraph() { - const [documents, setDocuments] = useState([]); + const [documents, setDocuments] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(true); @@ -210,8 +411,8 @@ export default function PaginatedGraph() { isLoading={isLoading} isLoadingMore={isLoadingMore} hasMore={hasMore} - totalLoaded={documents.length} - loadMoreDocuments={loadMore} + totalCount={documents.length} + onLoadMore={loadMore} /> ); } @@ -227,17 +428,6 @@ export default function PaginatedGraph() { /> ``` -### Controlled space selection - -```tsx - -``` - ### Custom empty state ```tsx @@ -257,80 +447,132 @@ export default function PaginatedGraph() { | Prop | Type | Default | Description | |------|------|---------|-------------| -| `documents` | `DocumentWithMemories[]` | required | Array of documents to display | -| `isLoading` | `boolean` | `false` | Shows loading indicator | -| `error` | `Error \| null` | `null` | Error to display | +| `documents` | `GraphApiDocument[]` | `[]` | Documents to display - pass this for direct data mode | +| `isLoading` | `boolean` | `false` | Whether data is loading | +| `error` | `Error \| null` | `null` | Error from data fetching | | `variant` | `"console" \| "consumer"` | `"console"` | Visual variant | -| `children` | `ReactNode` | - | Custom empty state content | +| `children` | `ReactNode` | - | Rendered when there are no documents | ### Pagination props | Prop | Type | Default | Description | |------|------|---------|-------------| -| `isLoadingMore` | `boolean` | `false` | Shows indicator when loading more | -| `hasMore` | `boolean` | `false` | Whether more documents available | -| `totalLoaded` | `number` | - | Total documents currently loaded | -| `loadMoreDocuments` | `() => Promise` | - | Callback to load more | -| `autoLoadOnViewport` | `boolean` | `true` | Auto-load when 80% visible | +| `isLoadingMore` | `boolean` | `false` | Whether more data is being loaded | +| `hasMore` | `boolean` | `false` | Whether there are more documents to load | +| `onLoadMore` | `() => void` | - | Callback to load more documents | +| `totalCount` | `number` | - | Total count, used by the loading indicator | ### Display props | Prop | Type | Default | Description | |------|------|---------|-------------| -| `showSpacesSelector` | `boolean` | variant-based | Show space filter dropdown | | `highlightDocumentIds` | `string[]` | `[]` | Document IDs to highlight | -| `highlightsVisible` | `boolean` | `true` | Whether highlights shown | -| `occludedRightPx` | `number` | `0` | Pixels occluded on right | +| `highlightsVisible` | `boolean` | `true` | Whether highlights are visible | +| `showFps` | `boolean` | `false` | Show FPS counter overlay | +| `canvasRef` | `RefObject` | - | External ref to the canvas (e.g. for screenshot export) | +| `colors` | `Partial` | - | Custom theme colors, merged with CSS-variable/default values | +| `labels` | `MemoryGraphLabels` | - | Custom user-facing labels, merged with defaults | +| `layering` | `MemoryGraphLayering` | - | Overlay layering controls (e.g. hover popover z-index) | +| `appearance` | `GraphAppearance` | - | Opt-in `documentNodes: 'stacked'` and `connections: 'emphasized'` rendering | -### Controlled state props +### Filtering props -| Prop | Type | Description | -|------|------|-------------| -| `selectedSpace` | `string` | Currently selected space (use `"all"` for all) | -| `onSpaceChange` | `(spaceId: string) => void` | Callback when space changes | -| `memoryLimit` | `number` | Max memories per document when space selected | +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `maxNodes` | `number` | - | Caps the total document plus memory node count | + +`containerTags`, `documentIds` and `legendId` are in `MemoryGraphProps` but `MemoryGraph` does not use them. Filter `documents` before you pass them in. + +### Slideshow props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `isSlideshowActive` | `boolean` | - | Whether slideshow mode is active | +| `onSlideshowNodeChange` | `(nodeId: string \| null) => void` | - | Called as the slideshow moves between nodes | +| `onSlideshowStop` | `() => void` | - | Called when the slideshow ends | + +### Other + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `onOpenDocument` | `(documentId: string) => void` | - | Called when the user wants to view a document's full content | +| `openDocumentOnEnter` | `boolean` | `false` | Let Enter call `onOpenDocument` for the selected document | --- ## Data types -### DocumentWithMemories +### GraphApiDocument / GraphApiMemory + +This is the shape `documents` actually takes: `documentType` and `memories`, matching what the API route in [Backend API Route](#backend-api-route) normalizes into: + +```typescript +interface GraphApiDocument { + id: string; + title: string | null; + summary: string | null; + documentType: string; + createdAt: string; + updatedAt: string; + memories: GraphApiMemory[]; +} + +interface GraphApiMemory { + id: string; + memory: string; + content?: string | null; + isStatic: boolean; + spaceId: string; + isLatest: boolean; + isForgotten: boolean; + forgetAfter: string | null; + forgetReason: string | null; + version: number; + parentMemoryId: string | null; + rootMemoryId: string | null; + createdAt: string; + updatedAt: string; + relation?: 'updates' | 'extends' | 'derives' | null; + updatesMemoryId?: string | null; + nextVersionId?: string | null; + memoryRelations?: Record | null; + spaceContainerTag?: string | null; +} +``` + +### DocumentWithMemories / MemoryEntry + +Also exported for backward compatibility with older API response shapes. Not what `documents` takes today, but still useful for typing a raw fetch response before mapping it to `GraphApiDocument`: ```typescript interface DocumentWithMemories { id: string; - customId?: string | null; - title?: string | null; - content?: string | null; + title: string | null; + url: string | null; + documentType: string; + createdAt: string; + updatedAt: string; summary?: string | null; - url?: string | null; - source?: string | null; - type?: string | null; - status: 'pending' | 'processing' | 'done' | 'failed'; - metadata?: Record | null; - createdAt: string | Date; - updatedAt: string | Date; - memoryEntries: MemoryEntry[]; + memories: MemoryEntry[]; } -``` -### MemoryEntry - -```typescript interface MemoryEntry { id: string; - documentId: string; - content: string | null; - summary?: string | null; - title?: string | null; - type?: string | null; - metadata?: Record | null; - createdAt: string | Date; - updatedAt: string | Date; - spaceContainerTag?: string | null; - relation?: 'updates' | 'extends' | 'derives' | null; - isLatest?: boolean; + memory: string; + content?: string | null; + createdAt: string; + updatedAt: string; spaceId?: string | null; + isStatic?: boolean; + isForgotten?: boolean; + forgetAfter?: string | null; + forgetReason?: string | null; + version?: number; + parentMemoryId?: string | null; + rootMemoryId?: string | null; + isLatest?: boolean; + relation?: 'updates' | 'extends' | 'derives' | null; + spaceContainerTag?: string | null; } ``` @@ -341,26 +583,52 @@ interface MemoryEntry { ### Components ```typescript -import { - MemoryGraph, - GraphCanvas, - Legend, - LoadingIndicator, - NodeDetailPanel, - SpacesDropdown -} from '@supermemory/memory-graph'; +import { MemoryGraph, GraphCanvas } from '@supermemory/memory-graph'; ``` ### Hooks ```typescript -import { useGraphData, useGraphInteractions } from '@supermemory/memory-graph'; +import { useGraphData, useGraphTheme } from '@supermemory/memory-graph'; +``` + +### Engine classes + +For advanced usage: driving the force simulation, viewport, or hit-testing directly: + +```typescript +import { + ForceSimulation, + ViewportState, + SpatialIndex, + VersionChainIndex, +} from '@supermemory/memory-graph'; ``` ### Constants ```typescript -import { colors, GRAPH_SETTINGS, LAYOUT_CONSTANTS } from '@supermemory/memory-graph'; +import { + DEFAULT_COLORS, + DEFAULT_HOVER_POPOVER_Z_INDEX, + DEFAULT_LABELS, + FORCE_CONFIG, + GRAPH_SETTINGS, +} from '@supermemory/memory-graph'; +``` + +### Mock data + +`@supermemory/memory-graph/mock-data` generates fake `GraphApiDocument[]` data for local development and stress testing, without a live API key: + +```typescript +import { generateMockGraphData } from '@supermemory/memory-graph/mock-data'; + +const { documents } = generateMockGraphData({ + documentCount: 100, + memoriesPerDoc: [2, 5], + seed: 12345, +}); ``` --- diff --git a/apps/memory-graph-playground/README.md b/apps/memory-graph-playground/README.md index 5c143d9b..4c116ee4 100644 --- a/apps/memory-graph-playground/README.md +++ b/apps/memory-graph-playground/README.md @@ -14,10 +14,10 @@ Open [http://localhost:3000](http://localhost:3000) and enter your Supermemory A ## Usage Example ```tsx -import { MemoryGraph, type DocumentWithMemories } from '@supermemory/memory-graph' +import { MemoryGraph, type GraphApiDocument } from '@supermemory/memory-graph' function App() { - const [documents, setDocuments] = useState([]) + const [documents, setDocuments] = useState([]) return ( {}} - totalLoaded={documents.length} + onLoadMore={() => {}} + totalCount={documents.length} variant="console" - showSpacesSelector={true} >
No memories found
@@ -37,16 +36,19 @@ function App() { } ``` +See [`src/app/page.tsx`](./src/app/page.tsx) for the full working example, including converting a raw API response into `GraphApiDocument[]`. + ## Props | Prop | Type | Description | |------|------|-------------| -| `documents` | `DocumentWithMemories[]` | Array of documents to display | +| `documents` | `GraphApiDocument[]` | Array of documents to display | | `isLoading` | `boolean` | Initial loading state | | `isLoadingMore` | `boolean` | Loading more documents state | | `error` | `Error \| null` | Error to display | | `hasMore` | `boolean` | Whether more documents can be loaded | -| `loadMoreDocuments` | `() => void` | Callback to load more documents | -| `totalLoaded` | `number` | Total number of loaded documents | -| `variant` | `"default" \| "console"` | Visual theme variant | -| `showSpacesSelector` | `boolean` | Show space filter dropdown | +| `onLoadMore` | `() => void` | Callback to load more documents | +| `totalCount` | `number` | Total number of loaded documents | +| `variant` | `"console" \| "consumer"` | Visual variant | + +See [`packages/memory-graph`](../../packages/memory-graph/src/types.ts) for the full `MemoryGraphProps` reference. From 853782aefc81a66b79cfe0bf0de817807af826a9 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 12 Oct 2026 21:44:25 -0700 Subject: [PATCH 07/11] feat(memory-graph): add an overview minimap with a draggable viewport (#1475) --- packages/memory-graph/README.md | 1 + .../src/__tests__/minimap-render.e2e.test.tsx | 100 +++++++++ .../src/__tests__/minimap.test.ts | 139 +++++++++++++ packages/memory-graph/src/canvas/minimap.ts | 110 ++++++++++ .../src/components/memory-graph.tsx | 26 +++ .../memory-graph/src/components/minimap.tsx | 191 ++++++++++++++++++ packages/memory-graph/src/index.tsx | 1 + packages/memory-graph/src/types.ts | 2 + 8 files changed, 570 insertions(+) create mode 100644 packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx create mode 100644 packages/memory-graph/src/__tests__/minimap.test.ts create mode 100644 packages/memory-graph/src/canvas/minimap.ts create mode 100644 packages/memory-graph/src/components/minimap.tsx diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 18f8fefc..99e3c925 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -67,6 +67,7 @@ function App() { | `loadMoreDocuments` | `() => Promise` | Function to load more data | | `highlightDocumentIds` | `string[]` | IDs of documents to highlight | | `onNodeSelect` | `(nodeId: string \| null) => void` | Called when the selected node changes (click, keyboard, slideshow, or cleared) | +| `showMinimap` | `boolean` | Show an overview minimap with a draggable viewport indicator (default: `false`) | Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors. diff --git a/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx new file mode 100644 index 00000000..91b23617 --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx @@ -0,0 +1,100 @@ +/** + * Mounted-render verification for the Minimap: it should render a canvas and + * survive an environment where the 2D context is unavailable (happy-dom). + */ + +// @vitest-environment happy-dom + +import { createRef } from "react" +import { cleanup, render } from "@testing-library/react" +import { afterEach, describe, expect, it } from "vitest" + +afterEach(cleanup) + +import { Minimap } from "../components/minimap" +import { ViewportState } from "../canvas/viewport" +import { DEFAULT_COLORS } from "../constants" +import type { GraphNode } from "../types" + +const nodes: GraphNode[] = [ + { + id: "doc-1", + type: "document", + x: 0, + y: 0, + data: { + id: "doc-1", + title: "Doc", + summary: "", + type: "", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + size: 40, + borderColor: "#58C7E8", + clusterColor: "#58C7E8", + isHovered: false, + isDragging: false, + }, + { + id: "mem-1", + type: "memory", + x: 120, + y: 80, + data: { + id: "mem-1", + content: "Memory", + documentId: "doc-1", + memory: "Memory", + isForgotten: false, + isLatest: true, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + } as GraphNode["data"], + size: 24, + borderColor: "#74D680", + clusterColor: "#74D680", + isHovered: false, + isDragging: false, + }, +] + +describe("Minimap render", () => { + it("renders a canvas without throwing", () => { + const viewportRef = createRef() + ;(viewportRef as { current: ViewportState | null }).current = + new ViewportState() + + const { container } = render( + , + ) + + const canvas = container.querySelector("canvas") + expect(canvas).not.toBeNull() + }) + + it("renders nothing visible but does not crash with no nodes", () => { + const viewportRef = createRef() + ;(viewportRef as { current: ViewportState | null }).current = + new ViewportState() + + const { container } = render( + , + ) + + expect(container.querySelector("canvas")).not.toBeNull() + }) +}) diff --git a/packages/memory-graph/src/__tests__/minimap.test.ts b/packages/memory-graph/src/__tests__/minimap.test.ts new file mode 100644 index 00000000..2545ccac --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest" +import { + clampToRange, + computeMinimapLayout, + computeViewportRect, + minimapToWorld, + worldToMinimap, +} from "../canvas/minimap" + +describe("computeMinimapLayout", () => { + it("fits a square world into a padded box and centers it", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + 120, + 120, + 10, + ) + // avail = 100, world = 100 => scale 1 + expect(layout.scale).toBeCloseTo(1) + // corners land on the padding edge + expect(worldToMinimap(0, 0, layout)).toEqual({ x: 10, y: 10 }) + expect(worldToMinimap(100, 100, layout)).toEqual({ x: 110, y: 110 }) + }) + + it("preserves aspect ratio for a wide world and centers the short axis", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 200, maxY: 100 }, + 120, + 120, + 10, + ) + // avail 100 x 100; width is the binding dimension => scale 0.5 + expect(layout.scale).toBeCloseTo(0.5) + const topLeft = worldToMinimap(0, 0, layout) + const bottomRight = worldToMinimap(200, 100, layout) + // horizontally flush to padding, vertically centered (content height 50) + expect(topLeft.x).toBeCloseTo(10) + expect(bottomRight.x).toBeCloseTo(110) + expect(topLeft.y).toBeCloseTo(35) + expect(bottomRight.y).toBeCloseTo(85) + }) + + it("does not divide by zero for a degenerate (single-point) bound", () => { + const layout = computeMinimapLayout( + { minX: 5, minY: 5, maxX: 5, maxY: 5 }, + 100, + 100, + ) + expect(Number.isFinite(layout.scale)).toBe(true) + const p = worldToMinimap(5, 5, layout) + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + }) +}) + +describe("worldToMinimap / minimapToWorld round trip", () => { + it("is an exact inverse", () => { + const layout = computeMinimapLayout( + { minX: -50, minY: -20, maxX: 150, maxY: 180 }, + 160, + 110, + 8, + ) + for (const [wx, wy] of [ + [-50, -20], + [0, 0], + [75, 90], + [150, 180], + ]) { + const back = minimapToWorld( + worldToMinimap(wx, wy, layout).x, + worldToMinimap(wx, wy, layout).y, + layout, + ) + expect(back.x).toBeCloseTo(wx) + expect(back.y).toBeCloseTo(wy) + } + }) +}) + +describe("computeViewportRect", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + 120, + 120, + 10, + ) + + it("maps the visible world region to a minimap rectangle", () => { + // zoom 1, no pan => the canvas shows world [0..100] x [0..100] + const rect = computeViewportRect( + { panX: 0, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + expect(rect.x).toBeCloseTo(10) + expect(rect.y).toBeCloseTo(10) + expect(rect.width).toBeCloseTo(100) + expect(rect.height).toBeCloseTo(100) + }) + + it("shrinks the rectangle as the main view zooms in", () => { + // zoom 2 => the canvas shows only half the world span in each axis + const rect = computeViewportRect( + { panX: 0, panY: 0, zoom: 2 }, + 100, + 100, + layout, + ) + expect(rect.width).toBeCloseTo(50) + expect(rect.height).toBeCloseTo(50) + }) + + it("shifts the rectangle when the main view pans", () => { + const base = computeViewportRect( + { panX: 0, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + // panning the world content left by 20 screen px moves the visible region right + const panned = computeViewportRect( + { panX: -20, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + expect(panned.x).toBeGreaterThan(base.x) + }) +}) + +describe("clampToRange", () => { + it("clamps below, within, and above", () => { + expect(clampToRange(-5, 0, 10)).toBe(0) + expect(clampToRange(5, 0, 10)).toBe(5) + expect(clampToRange(50, 0, 10)).toBe(10) + }) +}) diff --git a/packages/memory-graph/src/canvas/minimap.ts b/packages/memory-graph/src/canvas/minimap.ts new file mode 100644 index 00000000..891fadd6 --- /dev/null +++ b/packages/memory-graph/src/canvas/minimap.ts @@ -0,0 +1,110 @@ +// Pure, canvas-free geometry for the graph minimap. + +export interface MinimapBounds { + minX: number + minY: number + maxX: number + maxY: number +} + +export interface MinimapLayout { + scale: number + offsetX: number + offsetY: number + width: number + height: number +} + +export interface MinimapViewport { + panX: number + panY: number + zoom: number +} + +export interface MinimapRect { + x: number + y: number + width: number + height: number +} + +export function computeMinimapLayout( + bounds: MinimapBounds, + width: number, + height: number, + padding = 8, +): MinimapLayout { + const worldWidth = Math.max(bounds.maxX - bounds.minX, 1) + const worldHeight = Math.max(bounds.maxY - bounds.minY, 1) + const availWidth = Math.max(width - padding * 2, 1) + const availHeight = Math.max(height - padding * 2, 1) + + const scale = Math.min(availWidth / worldWidth, availHeight / worldHeight) + + const contentWidth = worldWidth * scale + const contentHeight = worldHeight * scale + const offsetX = + padding + (availWidth - contentWidth) / 2 - bounds.minX * scale + const offsetY = + padding + (availHeight - contentHeight) / 2 - bounds.minY * scale + + return { scale, offsetX, offsetY, width, height } +} + +export function worldToMinimap( + worldX: number, + worldY: number, + layout: MinimapLayout, +): { x: number; y: number } { + return { + x: layout.offsetX + worldX * layout.scale, + y: layout.offsetY + worldY * layout.scale, + } +} + +export function minimapToWorld( + minimapX: number, + minimapY: number, + layout: MinimapLayout, +): { x: number; y: number } { + return { + x: (minimapX - layout.offsetX) / layout.scale, + y: (minimapY - layout.offsetY) / layout.scale, + } +} + +// Can extend past the minimap box when zoomed out; callers clip. +export function computeViewportRect( + viewport: MinimapViewport, + canvasWidth: number, + canvasHeight: number, + layout: MinimapLayout, +): MinimapRect { + const zoom = viewport.zoom || 1 + const topLeftWorld = { + x: (0 - viewport.panX) / zoom, + y: (0 - viewport.panY) / zoom, + } + const bottomRightWorld = { + x: (canvasWidth - viewport.panX) / zoom, + y: (canvasHeight - viewport.panY) / zoom, + } + + const topLeft = worldToMinimap(topLeftWorld.x, topLeftWorld.y, layout) + const bottomRight = worldToMinimap( + bottomRightWorld.x, + bottomRightWorld.y, + layout, + ) + + return { + x: topLeft.x, + y: topLeft.y, + width: bottomRight.x - topLeft.x, + height: bottomRight.y - topLeft.y, + } +} + +export function clampToRange(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value +} diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 276d6066..b0ccc668 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -28,6 +28,7 @@ import { Legend } from "./legend" import { LoadingIndicator } from "./loading-indicator" import { NavigationControls } from "./navigation-controls" import { NodeHoverPopover } from "./node-hover-popover" +import { Minimap } from "./minimap" function documentIdForNode( node: GraphNode | null, @@ -66,6 +67,7 @@ export function MemoryGraph({ highlightDocumentIds = [], highlightsVisible = true, showFps = false, + showMinimap = false, maxNodes, isSlideshowActive = false, onSlideshowNodeChange, @@ -939,6 +941,30 @@ export function MemoryGraph({ /> )} + + {showMinimap && + !isCompactViewport && + containerSize.width > 0 && + nodes.length > 0 && ( + + )} ) diff --git a/packages/memory-graph/src/components/minimap.tsx b/packages/memory-graph/src/components/minimap.tsx new file mode 100644 index 00000000..35f1f2ca --- /dev/null +++ b/packages/memory-graph/src/components/minimap.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useRef } from "react" +import type { ForceSimulation } from "../canvas/simulation" +import type { ViewportState } from "../canvas/viewport" +import { getNodeBounds } from "../hooks/use-graph-data" +import { + clampToRange, + computeMinimapLayout, + computeViewportRect, + minimapToWorld, + worldToMinimap, +} from "../canvas/minimap" +import type { GraphNode, GraphThemeColors } from "../types" + +interface MinimapProps { + nodes: GraphNode[] + colors: GraphThemeColors + viewportRef: React.RefObject + canvasWidth: number + canvasHeight: number + simulation?: ForceSimulation | null + width?: number + height?: number +} + +const PADDING = 8 + +export function Minimap({ + nodes, + colors, + viewportRef, + canvasWidth, + canvasHeight, + simulation, + width = 168, + height = 116, +}: MinimapProps) { + const canvasRef = useRef(null) + const isDraggingRef = useRef(false) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr + canvas.height = height * dpr + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const bounds = getNodeBounds(nodes) + if (!bounds) return + const layout = computeMinimapLayout(bounds, width, height, PADDING) + + ctx.globalAlpha = 0.55 + for (const node of nodes) { + if (node.type !== "memory") continue + const p = worldToMinimap(node.x, node.y, layout) + ctx.fillStyle = node.clusterColor || node.borderColor || colors.memFill + ctx.beginPath() + ctx.arc(p.x, p.y, 1.1, 0, Math.PI * 2) + ctx.fill() + } + ctx.globalAlpha = 1 + for (const node of nodes) { + if (node.type !== "document") continue + const p = worldToMinimap(node.x, node.y, layout) + ctx.fillStyle = node.clusterColor || node.borderColor || colors.docStroke + ctx.beginPath() + ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2) + ctx.fill() + } + + const vp = viewportRef.current + if (vp) { + const rect = computeViewportRect(vp, canvasWidth, canvasHeight, layout) + const x0 = clampToRange(rect.x, 0, width) + const y0 = clampToRange(rect.y, 0, height) + const x1 = clampToRange(rect.x + rect.width, 0, width) + const y1 = clampToRange(rect.y + rect.height, 0, height) + const w = Math.max(x1 - x0, 0) + const h = Math.max(y1 - y0, 0) + + ctx.fillStyle = colors.accent + ctx.globalAlpha = 0.14 + ctx.fillRect(x0, y0, w, h) + ctx.globalAlpha = 1 + ctx.strokeStyle = colors.accent + ctx.lineWidth = 1 + ctx.strokeRect(x0 + 0.5, y0 + 0.5, Math.max(w - 1, 0), Math.max(h - 1, 0)) + } + }, [nodes, colors, viewportRef, canvasWidth, canvasHeight, width, height]) + + // Own loop because the parent's viewportVersion only bumps while a popover is open. + useEffect(() => { + let raf = 0 + let lastPanX = Number.NaN + let lastPanY = Number.NaN + let lastZoom = Number.NaN + let wasSimActive = true + const loop = () => { + const vp = viewportRef.current + const simActive = simulation?.isActive() ?? false + const vpChanged = + !!vp && + (vp.panX !== lastPanX || vp.panY !== lastPanY || vp.zoom !== lastZoom) + if (vpChanged || simActive || wasSimActive) { + draw() + if (vp) { + lastPanX = vp.panX + lastPanY = vp.panY + lastZoom = vp.zoom + } + } + wasSimActive = simActive + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + return () => cancelAnimationFrame(raf) + }, [draw, simulation, viewportRef]) + + const recenterFromEvent = useCallback( + (clientX: number, clientY: number) => { + const canvas = canvasRef.current + const vp = viewportRef.current + if (!canvas || !vp) return + const rect = canvas.getBoundingClientRect() + const bounds = getNodeBounds(nodes) + if (!bounds) return + const layout = computeMinimapLayout(bounds, width, height, PADDING) + const world = minimapToWorld( + clientX - rect.left, + clientY - rect.top, + layout, + ) + vp.centerOn(world.x, world.y, canvasWidth, canvasHeight) + }, + [nodes, viewportRef, canvasWidth, canvasHeight, width, height], + ) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + isDraggingRef.current = true + e.currentTarget.setPointerCapture(e.pointerId) + recenterFromEvent(e.clientX, e.clientY) + }, + [recenterFromEvent], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDraggingRef.current) return + recenterFromEvent(e.clientX, e.clientY) + }, + [recenterFromEvent], + ) + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + isDraggingRef.current = false + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } + }, + [], + ) + + return ( + + ) +} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index bb3b3c6b..d018453e 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,6 +1,7 @@ // Components export { MemoryGraph } from "./components/memory-graph" export { GraphCanvas } from "./components/graph-canvas" +export { Minimap } from "./components/minimap" // Hooks export { useGraphData } from "./hooks/use-graph-data" diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index e83f1398..32a44e6d 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -214,6 +214,8 @@ export interface MemoryGraphProps { maxNodes?: number /** Show FPS counter overlay */ showFps?: boolean + /** Show a minimap overview with a draggable viewport indicator */ + showMinimap?: boolean /** Slideshow mode */ isSlideshowActive?: boolean onSlideshowNodeChange?: (nodeId: string | null) => void From fa4ab9805ae96ebee1e29b0af22aab5254df7c0d Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:44:25 -0700 Subject: [PATCH 08/11] fix(memory-graph): deduplicate shared graph entities (#1525) --- .../__tests__/shared-memory-identity.test.tsx | 256 ++++++++++++++++++ .../src/components/memory-graph.tsx | 46 ++-- .../memory-graph/src/hooks/use-graph-data.ts | 21 +- 3 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx diff --git a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx new file mode 100644 index 00000000..4ba5c728 --- /dev/null +++ b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx @@ -0,0 +1,256 @@ +import { cleanup, render, renderHook, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { MemoryGraph } from "../components/memory-graph" +import { DEFAULT_COLORS } from "../constants" +import { computeEdges, useGraphData } from "../hooks/use-graph-data" +import type { + GraphApiDocument, + GraphApiMemory, + DocumentNodeData, + GraphEdge, + GraphNode, + MemoryNodeData, +} from "../types" + +const graphCanvasCapture = vi.hoisted(() => ({ + nodes: [] as GraphNode[], + edges: [] as GraphEdge[], +})) + +vi.mock("../components/graph-canvas", () => ({ + GraphCanvas: ({ + nodes, + edges, + }: { + nodes: GraphNode[] + edges: GraphEdge[] + }) => { + graphCanvasCapture.nodes = nodes + graphCanvasCapture.edges = edges + return null + }, +})) + +afterEach(() => { + cleanup() + graphCanvasCapture.nodes = [] + graphCanvasCapture.edges = [] + vi.restoreAllMocks() +}) + +function makeMemory(overrides: Partial = {}): GraphApiMemory { + return { + id: "memory", + memory: "A remembered fact", + isStatic: false, + spaceId: "space", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + memoryRelations: null, + ...overrides, + } +} + +function makeDocument( + id: string, + memories: GraphApiMemory[], +): GraphApiDocument { + return { + id, + title: id, + summary: null, + documentType: "text", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + memories, + } +} + +function getMemoryNode( + nodes: GraphNode[], + id: string, +): GraphNode & { data: MemoryNodeData } { + const node = nodes.find((candidate) => candidate.id === id) + expect(node?.type).toBe("memory") + if (!node || !("documentId" in node.data)) { + throw new Error(`Missing memory ${id}`) + } + return node as GraphNode & { data: MemoryNodeData } +} + +function getDocumentNode( + nodes: GraphNode[], + id: string, +): GraphNode & { data: DocumentNodeData } { + const node = nodes.find((candidate) => candidate.id === id) + expect(node?.type).toBe("document") + if (!node || !("memories" in node.data)) { + throw new Error(`Missing document ${id}`) + } + return node as GraphNode & { data: DocumentNodeData } +} + +describe("shared memory identity", () => { + it("materializes the first source occurrence once while retaining every document edge", () => { + const documents = [ + makeDocument("doc-a", [ + makeMemory({ id: "shared-memory", memory: "first source content" }), + ]), + makeDocument("doc-b", [ + makeMemory({ id: "shared-memory", memory: "later source content" }), + ]), + ] + const { result, unmount } = renderHook(() => + useGraphData(documents, null, 800, 600, DEFAULT_COLORS), + ) + + const nodeIds = result.current.nodes.map((node) => node.id) + const sharedNode = getMemoryNode(result.current.nodes, "shared-memory") + const derivesEdges = result.current.edges.filter( + (edge) => edge.edgeType === "document", + ) + + expect(nodeIds).toHaveLength(3) + expect(new Set(nodeIds).size).toBe(nodeIds.length) + expect(sharedNode.data.content).toBe("first source content") + expect(sharedNode.data.documentId).toBe("doc-a") + expect(derivesEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "dm-doc-a-shared-memory", + source: "doc-a", + target: "shared-memory", + }), + expect.objectContaining({ + id: "dm-doc-b-shared-memory", + source: "doc-b", + target: "shared-memory", + }), + ]), + ) + expect(derivesEdges).toHaveLength(2) + + unmount() + }) + + it("preserves cached position when another source occurrence is appended", () => { + const firstDocuments = [ + makeDocument("doc-a", [makeMemory({ id: "shared-memory" })]), + ] + const { result, rerender, unmount } = renderHook( + ({ documents }: { documents: GraphApiDocument[] }) => + useGraphData(documents, null, 800, 600, DEFAULT_COLORS), + { initialProps: { documents: firstDocuments } }, + ) + const firstNode = getMemoryNode(result.current.nodes, "shared-memory") + + rerender({ + documents: [ + ...firstDocuments, + makeDocument("doc-b", [makeMemory({ id: "shared-memory" })]), + ], + }) + + const rerenderedNode = getMemoryNode(result.current.nodes, "shared-memory") + expect({ x: rerenderedNode.x, y: rerenderedNode.y }).toEqual({ + x: firstNode.x, + y: firstNode.y, + }) + expect(rerenderedNode.data.documentId).toBe("doc-a") + + unmount() + }) + + it("unions relation pairs but keeps direction, type, and first occurrence", () => { + const documents = [ + makeDocument("doc-a", [ + makeMemory({ + id: "shared-memory", + memoryRelations: { "target-a": "updates" }, + }), + makeMemory({ id: "target-a" }), + ]), + makeDocument("doc-b", [ + makeMemory({ + id: "shared-memory", + memoryRelations: { + "target-a": "extends", + "target-b": "extends", + }, + }), + makeMemory({ id: "target-b" }), + ]), + ] + + const relationEdges = computeEdges(documents).filter((edge) => + edge.id.startsWith("rel-"), + ) + + expect(relationEdges).toHaveLength(2) + expect(relationEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "rel-target-a-shared-memory", + source: "target-a", + target: "shared-memory", + edgeType: "updates", + }), + expect.objectContaining({ + id: "rel-target-b-shared-memory", + source: "target-b", + target: "shared-memory", + edgeType: "extends", + }), + ]), + ) + }) +}) + +describe("MemoryGraph maxNodes", () => { + it("counts unique memories and retains a later duplicate after saturation", async () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON: () => ({}), + }) + const documents = [ + makeDocument("doc-a", [ + makeMemory({ id: "shared-memory" }), + makeMemory({ id: "unique-a" }), + ]), + makeDocument("doc-b", [ + makeMemory({ id: "over-budget" }), + makeMemory({ id: "shared-memory" }), + ]), + ] + + render() + + await waitFor(() => { + expect(graphCanvasCapture.nodes).toHaveLength(4) + }) + expect(graphCanvasCapture.nodes.map((node) => node.id).sort()).toEqual([ + "doc-a", + "doc-b", + "shared-memory", + "unique-a", + ]) + const docB = getDocumentNode(graphCanvasCapture.nodes, "doc-b") + expect(docB.data.memories.map((memory) => memory.id)).toEqual([ + "shared-memory", + ]) + }) +}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index b0ccc668..218f0bad 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -114,37 +114,37 @@ export function MemoryGraph({ // Used as a dependency proxy to recalculate popover positions const [viewportVersion, setViewportVersion] = useState(0) - // Limit documents so total node count (documents + their memories) stays under maxNodes + // Cap documents + unique memories at maxNodes; shared memories count once. const limitedDocuments = useMemo(() => { if (!maxNodes || documents.length === 0) return documents let totalNodes = 0 + const seenMemoryIds = new Set() const limited: GraphApiDocument[] = [] - for (let i = 0; i < documents.length; i++) { - const doc = documents[i] - if (!doc) continue + for (const doc of documents) { 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 - } - - limited.push({ ...doc, memories: [] }) + const includedMemories: GraphApiDocument["memories"] = [] totalNodes += 1 + + for (const memory of memories) { + if (seenMemoryIds.has(memory.id)) { + includedMemories.push(memory) + continue + } + + if (totalNodes >= maxNodes) continue + + seenMemoryIds.add(memory.id) + includedMemories.push(memory) + totalNodes += 1 + } + + limited.push( + includedMemories.length === memories.length + ? doc + : { ...doc, memories: includedMemories }, + ) } return limited }, [documents, maxNodes]) diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index deb59b47..74a291f7 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -402,10 +402,8 @@ function getAppendSpatialKey(x: number, y: number): string { return `${x}:${y}` } -/** - * Pure function that computes graph edges from documents. - * Extracted from the hook for testability. - */ +// Pure so it can be tested outside the hook. + export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { if (!documents || documents.length === 0) return [] @@ -416,10 +414,14 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { for (const mem of doc.memories) allNodeIds.add(mem.id) } + const documentEdgeIds = new Set() for (const doc of documents) { for (const mem of doc.memories) { + const edgeId = `dm-${doc.id}-${mem.id}` + if (documentEdgeIds.has(edgeId)) continue + documentEdgeIds.add(edgeId) result.push({ - id: `dm-${doc.id}-${mem.id}`, + id: edgeId, source: doc.id, target: mem.id, visualProps: getEdgeVisualProps("document"), @@ -431,12 +433,16 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { // 2. Memory-to-memory relation edges from backend data. // Uses memoryRelations (Record) as primary source, // falls back to parentMemoryId for legacy data. + const relationEdgeIds = new Set() for (const doc of documents) { for (const mem of doc.memories) { const relations = getMemoryRelationTargets(mem) for (const [targetId, relationType] of Object.entries(relations)) { if (!allNodeIds.has(targetId)) continue + const edgeId = `rel-${targetId}-${mem.id}` + if (relationEdgeIds.has(edgeId)) continue + relationEdgeIds.add(edgeId) const edgeType = relationType === "updates" || relationType === "extends" || @@ -444,7 +450,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { ? relationType : "updates" result.push({ - id: `rel-${targetId}-${mem.id}`, + id: edgeId, source: targetId, target: mem.id, visualProps: getEdgeVisualProps(edgeType), @@ -498,6 +504,7 @@ export function useGraphData( const clusterAssignments = computeClusterAssignments(documents) const result: GraphNode[] = [] + const seenMemoryIds = new Set() // Spiral layout: documents form a compact spiral core, memories orbit // around their parent documents. The force simulation then gently // pushes memories outward to create the constellation/starburst effect. @@ -578,6 +585,8 @@ export function useGraphData( for (let i = 0; i < memCount; i++) { const mem = doc.memories[i] if (!mem) continue + if (seenMemoryIds.has(mem.id)) continue + seenMemoryIds.add(mem.id) const previousMemNode = previousCache.get(mem.id) const memData: MemoryNodeData = { ...mem, From cd6ea514d17277ab442b8601398039441a80e64e Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 12 Oct 2026 21:44:26 -0700 Subject: [PATCH 09/11] feat(memory-graph): respect prefers-reduced-motion (#1576) --- .../src/__tests__/reduced-motion.test.ts | 140 ++++++++++++++++++ .../memory-graph/src/canvas/reduced-motion.ts | 9 ++ .../memory-graph/src/canvas/simulation.ts | 11 +- packages/memory-graph/src/canvas/viewport.ts | 23 ++- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/reduced-motion.test.ts create mode 100644 packages/memory-graph/src/canvas/reduced-motion.ts diff --git a/packages/memory-graph/src/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts new file mode 100644 index 00000000..e3847d73 --- /dev/null +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { prefersReducedMotion } from "../canvas/reduced-motion" +import { ForceSimulation } from "../canvas/simulation" +import { ViewportState } from "../canvas/viewport" +import type { GraphEdge, GraphNode } from "../types" + +function stubReducedMotion(matches: boolean) { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("reduce") ? matches : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + })) +} + +afterEach(() => vi.unstubAllGlobals()) + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 50, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +const nodes: GraphNode[] = [ + makeNode("a", 0, 0), + makeNode("b", 100, 0), + makeNode("c", 0, 100), +] +const edges: GraphEdge[] = [ + { + id: "a-b", + source: "a", + target: "b", + edgeType: "derives", + visualProps: { opacity: 1, thickness: 1 }, + }, +] + +describe("prefersReducedMotion", () => { + it("returns false when matchMedia is unavailable", () => { + vi.stubGlobal("matchMedia", undefined) + expect(prefersReducedMotion()).toBe(false) + }) + + it("reflects the matchMedia result", () => { + stubReducedMotion(true) + expect(prefersReducedMotion()).toBe(true) + stubReducedMotion(false) + expect(prefersReducedMotion()).toBe(false) + }) +}) + +describe("ForceSimulation reduced-motion", () => { + it("leaves the layout static after init and ignores reheat", () => { + stubReducedMotion(true) + const sim = new ForceSimulation() + sim.init(nodes, edges) + expect(sim.isActive()).toBe(false) + sim.reheat() + expect(sim.isActive()).toBe(false) + sim.settle() + expect(sim.isActive()).toBe(false) + sim.destroy() + }) + + it("keeps the simulation running after init when motion is allowed", () => { + stubReducedMotion(false) + const sim = new ForceSimulation() + sim.init(nodes, edges) + expect(sim.isActive()).toBe(true) + sim.destroy() + }) +}) + +describe("ViewportState reduced-motion", () => { + it("drops fling momentum under reduced motion", () => { + stubReducedMotion(true) + const vp = new ViewportState(0, 0, 1) + vp.releaseWithVelocity(50, 50) + vp.tick() + expect(vp.panX).toBe(0) + expect(vp.panY).toBe(0) + }) + + it("keeps fling momentum when motion is allowed", () => { + stubReducedMotion(false) + const vp = new ViewportState(0, 0, 1) + vp.releaseWithVelocity(50, 50) + vp.tick() + expect(vp.panX).toBeGreaterThan(0) + }) + + it("snaps zoom to target in a single tick under reduced motion", () => { + stubReducedMotion(true) + const vp = new ViewportState(0, 0, 1) + vp.zoomTo(3, 100, 100) + vp.tick() + expect(vp.zoom).toBe(3) + }) + + it("eases zoom across ticks when motion is allowed", () => { + stubReducedMotion(false) + const vp = new ViewportState(0, 0, 1) + vp.zoomTo(3, 100, 100) + vp.tick() + expect(vp.zoom).toBeGreaterThan(1) + expect(vp.zoom).toBeLessThan(3) + }) + + it("snaps a pan target instantly under reduced motion", () => { + stubReducedMotion(true) + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 500, 800, 600) + const moved = vp.tick() + expect(moved).toBe(true) + // target = width/2 - worldX*zoom = 400 - 500 = -100, etc. + expect(vp.panX).toBe(-100) + expect(vp.panY).toBe(-200) + }) +}) diff --git a/packages/memory-graph/src/canvas/reduced-motion.ts b/packages/memory-graph/src/canvas/reduced-motion.ts new file mode 100644 index 00000000..89b75535 --- /dev/null +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -0,0 +1,9 @@ +// SSR-safe; false when matchMedia is unavailable. +export function prefersReducedMotion(): boolean { + if (typeof globalThis.matchMedia !== "function") return false + try { + return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches + } catch { + return false + } +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 01bca6b0..a221241e 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,6 +1,7 @@ import * as d3 from "d3-force" import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import { FORCE_CONFIG } from "../constants" +import { prefersReducedMotion } from "./reduced-motion" export const DENSE_GRAPH_STATIC_THRESHOLD = 6000 @@ -67,7 +68,11 @@ export class ForceSimulation { : FORCE_CONFIG.preSettleTicks for (let i = 0; i < preSettleTicks; i++) this.sim.tick() - if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) { + // Dense graphs stay static for perf; reduced-motion reuses that path. + if ( + nodes.length > DENSE_GRAPH_STATIC_THRESHOLD || + prefersReducedMotion() + ) { this.stop() } else { this.settle() @@ -87,6 +92,8 @@ export class ForceSimulation { } reheat(): void { + // Dragged node still moves via InputHandler; neighbours stay put. + if (prefersReducedMotion()) return this.holdingHeat = true this.sim?.on("tick.settle", null) this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() @@ -94,7 +101,7 @@ export class ForceSimulation { settle(): void { const sim = this.sim - if (!sim || this.holdingHeat) return + if (!sim || this.holdingHeat || prefersReducedMotion()) return let ticks = 0 let stableTicks = 0 sim diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index 648b6ce8..cb970593 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -21,6 +21,16 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM + // Cached query; `.matches` still tracks live OS changes. + private readonly reducedMotionQuery: MediaQueryList | null = + typeof globalThis.matchMedia === "function" + ? globalThis.matchMedia("(prefers-reduced-motion: reduce)") + : null + + private get reducedMotion(): boolean { + return this.reducedMotionQuery?.matches ?? false + } + constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) { this.panX = initialPanX this.panY = initialPanY @@ -59,6 +69,7 @@ export class ViewportState { } releaseWithVelocity(vx: number, vy: number): void { + if (this.reducedMotion) return this.velocityX = vx this.velocityY = vy } @@ -133,6 +144,7 @@ export class ViewportState { } tick(): boolean { + const reduced = this.reducedMotion let moving = false if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { @@ -149,7 +161,7 @@ export class ViewportState { const zoomDiff = this.targetZoom - this.zoom if (Math.abs(zoomDiff) > this.zoom * 0.001) { const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) - this.zoom += zoomDiff * this.zoomSpring + this.zoom += reduced ? zoomDiff : zoomDiff * this.zoomSpring this.panX = this.zoomAnchorX - world.x * this.zoom this.panY = this.zoomAnchorY - world.y * this.zoom moving = true @@ -158,11 +170,18 @@ export class ViewportState { if (this.targetPanX !== null && this.targetPanY !== null) { const dx = this.targetPanX - this.panX const dy = this.targetPanY - this.panY - if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { + if (!reduced && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) { this.panX += dx * this.panLerp this.panY += dy * this.panLerp moving = true } else { + // Reduced-motion always snaps here, so report the jump as movement. + if ( + reduced && + (this.panX !== this.targetPanX || this.panY !== this.targetPanY) + ) { + moving = true + } this.panX = this.targetPanX this.panY = this.targetPanY this.targetPanX = null From 2dbf02018f41eed8371caf1ea2f4d32e227e0918 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Mon, 12 Oct 2026 21:45:22 -0700 Subject: [PATCH 10/11] chore(memory-graph): trim comments in community fixes --- .../memory-graph/src/__tests__/input-handler-touch.test.ts | 3 --- .../memory-graph/src/__tests__/minimap-render.e2e.test.tsx | 5 ----- packages/memory-graph/src/__tests__/minimap.test.ts | 7 ------- packages/memory-graph/src/__tests__/reduced-motion.test.ts | 1 - packages/memory-graph/src/canvas/minimap.ts | 2 -- packages/memory-graph/src/canvas/simulation.ts | 2 -- packages/memory-graph/src/canvas/viewport.ts | 2 -- packages/memory-graph/src/hooks/use-graph-data.ts | 3 --- 8 files changed, 25 deletions(-) diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 11e7e29c..653255f3 100644 --- a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -155,7 +155,6 @@ describe("InputHandler touch tap-to-select", () => { expect(dragEnds).toBe(1) expect(testNode.fx).toBeNull() expect(testNode.fy).toBeNull() - // no tap click should fire after a real drag expect(clicks).toEqual([]) }) @@ -185,11 +184,9 @@ describe("InputHandler touch tap-to-select", () => { viewport.zoomImmediate(0.25, 0, 0) fire("touchstart", touchEvent([touch(25, 25)])) - // 8px screen jitter (below the 10px tap threshold) fire("touchmove", touchEvent([touch(33, 25)])) fire("touchend", touchEvent([])) - // the tap resolves the node under the finger at touchstart expect(clicks).toEqual(["doc-1"]) }) diff --git a/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx index 91b23617..a1d2960e 100644 --- a/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx +++ b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx @@ -1,8 +1,3 @@ -/** - * Mounted-render verification for the Minimap: it should render a canvas and - * survive an environment where the 2D context is unavailable (happy-dom). - */ - // @vitest-environment happy-dom import { createRef } from "react" diff --git a/packages/memory-graph/src/__tests__/minimap.test.ts b/packages/memory-graph/src/__tests__/minimap.test.ts index 2545ccac..b4eec32f 100644 --- a/packages/memory-graph/src/__tests__/minimap.test.ts +++ b/packages/memory-graph/src/__tests__/minimap.test.ts @@ -15,9 +15,7 @@ describe("computeMinimapLayout", () => { 120, 10, ) - // avail = 100, world = 100 => scale 1 expect(layout.scale).toBeCloseTo(1) - // corners land on the padding edge expect(worldToMinimap(0, 0, layout)).toEqual({ x: 10, y: 10 }) expect(worldToMinimap(100, 100, layout)).toEqual({ x: 110, y: 110 }) }) @@ -29,11 +27,9 @@ describe("computeMinimapLayout", () => { 120, 10, ) - // avail 100 x 100; width is the binding dimension => scale 0.5 expect(layout.scale).toBeCloseTo(0.5) const topLeft = worldToMinimap(0, 0, layout) const bottomRight = worldToMinimap(200, 100, layout) - // horizontally flush to padding, vertically centered (content height 50) expect(topLeft.x).toBeCloseTo(10) expect(bottomRight.x).toBeCloseTo(110) expect(topLeft.y).toBeCloseTo(35) @@ -87,7 +83,6 @@ describe("computeViewportRect", () => { ) it("maps the visible world region to a minimap rectangle", () => { - // zoom 1, no pan => the canvas shows world [0..100] x [0..100] const rect = computeViewportRect( { panX: 0, panY: 0, zoom: 1 }, 100, @@ -101,7 +96,6 @@ describe("computeViewportRect", () => { }) it("shrinks the rectangle as the main view zooms in", () => { - // zoom 2 => the canvas shows only half the world span in each axis const rect = computeViewportRect( { panX: 0, panY: 0, zoom: 2 }, 100, @@ -119,7 +113,6 @@ describe("computeViewportRect", () => { 100, layout, ) - // panning the world content left by 20 screen px moves the visible region right const panned = computeViewportRect( { panX: -20, panY: 0, zoom: 1 }, 100, diff --git a/packages/memory-graph/src/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts index e3847d73..a39dcd69 100644 --- a/packages/memory-graph/src/__tests__/reduced-motion.test.ts +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -133,7 +133,6 @@ describe("ViewportState reduced-motion", () => { vp.centerOn(500, 500, 800, 600) const moved = vp.tick() expect(moved).toBe(true) - // target = width/2 - worldX*zoom = 400 - 500 = -100, etc. expect(vp.panX).toBe(-100) expect(vp.panY).toBe(-200) }) diff --git a/packages/memory-graph/src/canvas/minimap.ts b/packages/memory-graph/src/canvas/minimap.ts index 891fadd6..03f0f48b 100644 --- a/packages/memory-graph/src/canvas/minimap.ts +++ b/packages/memory-graph/src/canvas/minimap.ts @@ -1,5 +1,3 @@ -// Pure, canvas-free geometry for the graph minimap. - export interface MinimapBounds { minX: number minY: number diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index a221241e..67483f10 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -68,7 +68,6 @@ export class ForceSimulation { : FORCE_CONFIG.preSettleTicks for (let i = 0; i < preSettleTicks; i++) this.sim.tick() - // Dense graphs stay static for perf; reduced-motion reuses that path. if ( nodes.length > DENSE_GRAPH_STATIC_THRESHOLD || prefersReducedMotion() @@ -92,7 +91,6 @@ export class ForceSimulation { } reheat(): void { - // Dragged node still moves via InputHandler; neighbours stay put. if (prefersReducedMotion()) return this.holdingHeat = true this.sim?.on("tick.settle", null) diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index cb970593..d58068de 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -21,7 +21,6 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM - // Cached query; `.matches` still tracks live OS changes. private readonly reducedMotionQuery: MediaQueryList | null = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-reduced-motion: reduce)") @@ -175,7 +174,6 @@ export class ViewportState { this.panY += dy * this.panLerp moving = true } else { - // Reduced-motion always snaps here, so report the jump as movement. if ( reduced && (this.panX !== this.targetPanX || this.panY !== this.targetPanY) diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 74a291f7..82ea6b5b 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -157,7 +157,6 @@ export function computeClusterAssignments( const component: string[] = [] const queue = [startId] - // index dequeue: shift() is O(n) per call, O(n^2) on wide components let head = 0 visited.add(startId) @@ -402,8 +401,6 @@ function getAppendSpatialKey(x: number, y: number): string { return `${x}:${y}` } -// Pure so it can be tested outside the hook. - export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { if (!documents || documents.length === 0) return [] From 5f3e8e19026936e8b39f6d3ccd007151f65264ce Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:26:05 +0000 Subject: [PATCH 11/11] fix(memory-graph): release cancelled drags and keep minimaps current Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../src/__tests__/input-handler-touch.test.ts | 97 +++++++-- .../__tests__/minimap-interaction.test.tsx | 193 ++++++++++++++++++ .../src/__tests__/reduced-motion.test.ts | 63 ++++++ .../__tests__/shared-memory-identity.test.tsx | 35 ++++ .../memory-graph/src/canvas/input-handler.ts | 32 ++- .../memory-graph/src/canvas/reduced-motion.ts | 14 +- .../memory-graph/src/canvas/simulation.ts | 24 ++- packages/memory-graph/src/canvas/viewport.ts | 12 +- .../memory-graph/src/components/minimap.tsx | 6 +- .../memory-graph/src/hooks/use-graph-data.ts | 31 +-- 10 files changed, 464 insertions(+), 43 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/minimap-interaction.test.tsx diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 653255f3..65a48506 100644 --- a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -1,19 +1,10 @@ -import { beforeEach, describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { SpatialIndex } from "../canvas/hit-test" import { InputHandler } from "../canvas/input-handler" +import { ForceSimulation } from "../canvas/simulation" import { ViewportState } from "../canvas/viewport" import type { GraphNode } from "../types" -/** - * InputHandler tap-to-select on touch devices. - * - * onTouchStart calls preventDefault() (required to stop scroll/zoom of the - * page), which also suppresses the browser's synthesized click event — so - * node selection must be detected from the raw touch lifecycle. These tests - * drive the handler through a stub canvas that records the listeners it - * registers. - */ - type Listener = (e: Event) => void function makeStubCanvas() { @@ -81,6 +72,8 @@ describe("InputHandler touch tap-to-select", () => { let dragStarts: Array<{ id: string; node: GraphNode }> let dragEnds: number let testNode: GraphNode + let handler: InputHandler + let simulation: ForceSimulation | null const fire = (name: string, e: TouchEvent) => { const fn = listeners.get(name) @@ -94,28 +87,106 @@ describe("InputHandler touch tap-to-select", () => { clicks = [] dragStarts = [] dragEnds = 0 + simulation = null - // zoom 1 / pan 0 so screen coordinates equal world coordinates viewport = new ViewportState(0, 0, 1) const index = new SpatialIndex() testNode = makeNode("doc-1", 100, 100) index.rebuild([testNode]) - new InputHandler(stub.canvas, viewport, index, { + handler = new InputHandler(stub.canvas, viewport, index, { onNodeHover: () => {}, onNodeClick: (id) => { clicks.push(id) }, onNodeDragStart: (id, node) => { dragStarts.push({ id, node }) + simulation?.reheat() }, onNodeDragEnd: () => { dragEnds++ + simulation?.coolDown() }, onRequestRender: () => {}, }) }) + afterEach(() => { + handler.destroy() + simulation?.destroy() + vi.unstubAllGlobals() + }) + + it("cancels a pending tap without selecting and permits a subsequent tap", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual([]) + expect(dragEnds).toBe(0) + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual(["doc-1"]) + }) + + it.each([ + false, + true, + ])("releases cancelled touch drags with reduced motion %s", (reduced) => { + vi.stubGlobal("matchMedia", () => ({ matches: reduced })) + simulation = new ForceSimulation() + simulation.init([testNode], []) + simulation.stop() + testNode.x = 100 + testNode.y = 100 + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + expect(testNode.fx).toBe(160) + expect(simulation.isActive()).toBe(!reduced) + fire("touchcancel", touchEvent([])) + expect(testNode.fx).toBeNull() + expect(testNode.fy).toBeNull() + expect(handler.getDraggingNode()).toBeNull() + expect(simulation.isActive()).toBe(false) + expect(dragEnds).toBe(1) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(dragEnds).toBe(1) + expect(clicks).toEqual([]) + }) + + it("clears cancelled pan and pinch state without selecting", () => { + fire("touchstart", touchEvent([touch(400, 400)])) + fire("touchmove", touchEvent([touch(460, 400)])) + fire("touchcancel", touchEvent([])) + fire("touchmove", touchEvent([touch(500, 400)])) + expect(viewport.panX).toBe(60) + fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)])) + fire("touchcancel", touchEvent([])) + fire("touchend", touchEvent([])) + expect(clicks).toEqual([]) + }) + + it("releases a touch drag on pinch and removes cancellation listeners on destroy", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + fire("touchstart", touchEvent([touch(160, 120), touch(240, 200)])) + expect(testNode.fx).toBeNull() + expect(dragEnds).toBe(1) + handler.destroy() + expect(listeners.has("touchcancel")).toBe(false) + expect(listeners.size).toBe(0) + expect(clicks).toEqual([]) + }) + + it("increments the position revision only when a drag moves the node", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 120)])) + expect(viewport.nodePositionVersion).toBe(1) + fire("touchmove", touchEvent([touch(160, 120)])) + fire("touchend", touchEvent([])) + expect(viewport.nodePositionVersion).toBe(1) + }) + it("selects a node on a single tap", () => { fire("touchstart", touchEvent([touch(100, 100)])) fire("touchend", touchEvent([])) diff --git a/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx b/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx new file mode 100644 index 00000000..59136546 --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap-interaction.test.tsx @@ -0,0 +1,193 @@ +import { act, cleanup, render } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { InputHandler } from "../canvas/input-handler" +import { SpatialIndex } from "../canvas/hit-test" +import { ForceSimulation } from "../canvas/simulation" +import { ViewportState } from "../canvas/viewport" +import { Minimap } from "../components/minimap" +import { DEFAULT_COLORS } from "../constants" +import type { GraphNode } from "../types" + +const handlers: InputHandler[] = [] +const simulations: ForceSimulation[] = [] + +afterEach(() => { + cleanup() + for (const handler of handlers.splice(0)) handler.destroy() + for (const simulation of simulations.splice(0)) simulation.destroy() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 40, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + } +} + +describe("Minimap stopped-layout dragging", () => { + it.each([ + { reduced: false, touch: false }, + { reduced: false, touch: true }, + { reduced: true, touch: false }, + { reduced: true, touch: true }, + ])("refreshes actual dragging with reduced=$reduced and touch=$touch without idle scans", ({ + reduced, + touch, + }) => { + const frames = new Map() + let nextFrame = 0 + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback) + return nextFrame + }) + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)) + vi.stubGlobal("matchMedia", () => ({ matches: reduced })) + const frame = () => { + const callbacks = [...frames.values()] + frames.clear() + act(() => { + for (const callback of callbacks) callback(0) + }) + } + const clearRect = vi.fn() + const arc = vi.fn() + const ctx = new Proxy( + { clearRect, arc }, + { + get: (target, key) => Reflect.get(target, key) ?? (() => {}), + set: () => true, + }, + ) as unknown as CanvasRenderingContext2D + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx) + vi.spyOn( + HTMLCanvasElement.prototype, + "getBoundingClientRect", + ).mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + toJSON: () => ({}), + }) + const node = makeNode("drag", 100, 100) + const nodes = [makeNode("left", 0, 0), node, makeNode("right", 400, 300)] + const viewport = new ViewportState(0, 0, 1) + let simulation: ForceSimulation | undefined + if (reduced) { + simulation = new ForceSimulation() + simulations.push(simulation) + simulation.init(nodes, []) + expect(simulation.isActive()).toBe(false) + } + let x = node.x + const readX = vi.fn(() => x) + Object.defineProperty(node, "x", { + get: readX, + set: (value: number) => { + x = value + }, + }) + const result = render( + , + ) + frame() + expect(clearRect).toHaveBeenCalledTimes(1) + const initialArcs = [...arc.mock.calls] + readX.mockClear() + for (let i = 0; i < 5; i++) frame() + expect(clearRect).toHaveBeenCalledTimes(1) + expect(readX).not.toHaveBeenCalled() + const canvas = document.createElement("canvas") + document.body.appendChild(canvas) + const index = new SpatialIndex() + index.rebuild(nodes, true) + const onClick = vi.fn() + const onEnd = vi.fn(() => simulation?.coolDown()) + const handler = new InputHandler(canvas, viewport, index, { + onNodeHover: () => {}, + onNodeClick: onClick, + onNodeDragStart: () => simulation?.reheat(), + onNodeDragEnd: onEnd, + onRequestRender: () => {}, + }) + handlers.push(handler) + const startX = node.x + const startY = node.y + if (touch) { + canvas.dispatchEvent( + Object.assign(new Event("touchstart"), { + touches: [{ clientX: startX, clientY: startY }], + }), + ) + canvas.dispatchEvent( + Object.assign(new Event("touchmove"), { + touches: [{ clientX: startX + 60, clientY: startY + 30 }], + }), + ) + } else { + canvas.dispatchEvent( + new MouseEvent("mousedown", { + clientX: startX, + clientY: startY, + button: 0, + }), + ) + window.dispatchEvent( + new MouseEvent("mousemove", { + clientX: startX + 60, + clientY: startY + 30, + }), + ) + } + expect(node.x).toBe(startX + 60) + expect(viewport.nodePositionVersion).toBe(1) + frame() + expect(clearRect).toHaveBeenCalledTimes(2) + expect(arc.mock.calls.slice(3)).not.toEqual(initialArcs) + if (touch) + canvas.dispatchEvent( + Object.assign(new Event("touchend"), { touches: [] }), + ) + else window.dispatchEvent(new MouseEvent("mouseup")) + expect(onEnd).toHaveBeenCalledOnce() + expect(onClick).not.toHaveBeenCalled() + readX.mockClear() + for (let i = 0; i < 5; i++) frame() + expect(clearRect).toHaveBeenCalledTimes(2) + expect(readX).not.toHaveBeenCalled() + viewport.pan(1, 0) + frame() + expect(clearRect).toHaveBeenCalledTimes(3) + result.unmount() + expect(frames.size).toBe(0) + canvas.remove() + }) +}) diff --git a/packages/memory-graph/src/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts index a39dcd69..0b4eddaa 100644 --- a/packages/memory-graph/src/__tests__/reduced-motion.test.ts +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -71,6 +71,54 @@ describe("prefersReducedMotion", () => { }) describe("ForceSimulation reduced-motion", () => { + it.each([ + false, + true, + ])("stops live motion and cleans up the %s legacy-query listener", (legacy) => { + const listeners = new Set<(event: MediaQueryListEvent) => void>() + const query = { + matches: false, + addEventListener: legacy + ? undefined + : (_type: string, listener: (event: MediaQueryListEvent) => void) => + listeners.add(listener), + removeEventListener: legacy + ? undefined + : (_type: string, listener: (event: MediaQueryListEvent) => void) => + listeners.delete(listener), + addListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.add(listener), + removeListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.delete(listener), + } + vi.stubGlobal("matchMedia", () => query) + const sim = new ForceSimulation() + sim.init(nodes, edges) + sim.reheat() + expect(sim.isActive()).toBe(true) + expect(listeners.size).toBe(1) + query.matches = true + for (const listener of listeners) + listener({ matches: true } as MediaQueryListEvent) + expect(sim.isActive()).toBe(false) + sim.reheat() + expect(sim.isActive()).toBe(false) + sim.init(nodes, edges) + expect(listeners.size).toBe(1) + sim.destroy() + expect(listeners.size).toBe(0) + }) + + it("preserves allowed-motion layout when matchMedia throws", () => { + vi.stubGlobal("matchMedia", () => { + throw new Error("unavailable") + }) + const sim = new ForceSimulation() + sim.init(nodes, edges) + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + it("leaves the layout static after init and ignores reheat", () => { stubReducedMotion(true) const sim = new ForceSimulation() @@ -93,6 +141,21 @@ describe("ForceSimulation reduced-motion", () => { }) describe("ViewportState reduced-motion", () => { + it("discards an existing fling when the live preference changes", () => { + const query = { matches: false } + vi.stubGlobal("matchMedia", () => query) + const vp = new ViewportState(0, 0, 1) + vp.releaseWithVelocity(50, 25) + vp.tick() + expect(vp.panX).toBe(50) + query.matches = true + vp.tick() + expect(vp.panX).toBe(50) + expect(vp.panY).toBe(25) + query.matches = false + expect(vp.tick()).toBe(false) + }) + it("drops fling momentum under reduced motion", () => { stubReducedMotion(true) const vp = new ViewportState(0, 0, 1) diff --git a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx index 4ba5c728..e4c190c5 100644 --- a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx +++ b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx @@ -98,6 +98,41 @@ function getDocumentNode( } describe("shared memory identity", () => { + it("retains distinct hyphenated document endpoint pairs and deduplicates exact repeats", () => { + const documents = [ + makeDocument("a-b", [makeMemory({ id: "c" }), makeMemory({ id: "c" })]), + makeDocument("a", [makeMemory({ id: "b-c" })]), + ] + expect( + computeEdges(documents).map((edge) => [edge.source, edge.target]), + ).toEqual([ + ["a-b", "c"], + ["a", "b-c"], + ]) + }) + + it("retains distinct hyphenated relation endpoint pairs and the first repeated type", () => { + const documents = [ + makeDocument("doc", [ + makeMemory({ id: "a-b" }), + makeMemory({ id: "a" }), + makeMemory({ id: "c", memoryRelations: { "a-b": "updates" } }), + makeMemory({ id: "b-c", memoryRelations: { a: "extends" } }), + ]), + makeDocument("other", [ + makeMemory({ id: "c", memoryRelations: { "a-b": "extends" } }), + ]), + ] + expect( + computeEdges(documents) + .filter((edge) => edge.edgeType !== "document") + .map((edge) => [edge.source, edge.target, edge.edgeType]), + ).toEqual([ + ["a-b", "c", "updates"], + ["a", "b-c", "extends"], + ]) + }) + it("materializes the first source occurrence once while retaining every document edge", () => { const documents = [ makeDocument("doc-a", [ diff --git a/packages/memory-graph/src/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index 9530be93..05dca878 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -58,6 +58,7 @@ export class InputHandler { private boundTouchStart: (e: TouchEvent) => void private boundTouchMove: (e: TouchEvent) => void private boundTouchEnd: (e: TouchEvent) => void + private boundTouchCancel: () => void private boundGesture: (e: Event) => void constructor( @@ -84,6 +85,7 @@ export class InputHandler { this.boundTouchStart = this.onTouchStart.bind(this) this.boundTouchMove = this.onTouchMove.bind(this) this.boundTouchEnd = this.onTouchEnd.bind(this) + this.boundTouchCancel = this.onTouchCancel.bind(this) this.boundGesture = (e: Event) => e.preventDefault() canvas.addEventListener("mousedown", this.boundMouseDown) @@ -98,6 +100,7 @@ export class InputHandler { passive: false, }) canvas.addEventListener("touchend", this.boundTouchEnd) + canvas.addEventListener("touchcancel", this.boundTouchCancel) canvas.addEventListener("gesturestart", this.boundGesture, { passive: false, }) @@ -118,6 +121,7 @@ export class InputHandler { c.removeEventListener("touchstart", this.boundTouchStart) c.removeEventListener("touchmove", this.boundTouchMove) c.removeEventListener("touchend", this.boundTouchEnd) + c.removeEventListener("touchcancel", this.boundTouchCancel) c.removeEventListener("gesturestart", this.boundGesture) c.removeEventListener("gesturechange", this.boundGesture) c.removeEventListener("gestureend", this.boundGesture) @@ -188,9 +192,7 @@ export class InputHandler { } if (this.draggingNode) { - const world = this.viewport.screenToWorld(x, y) - this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x - this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y + this.moveDraggingNode(x, y) this.didDrag = true this.callbacks.onRequestRender() return @@ -382,9 +384,7 @@ export class InputHandler { this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode) } if (this.draggingNode) { - const world = this.viewport.screenToWorld(x, y) - this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x - this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y + this.moveDraggingNode(x, y) this.callbacks.onRequestRender() } else if (this.isPanning) { this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) @@ -395,6 +395,26 @@ export class InputHandler { } } + private moveDraggingNode(x: number, y: number): void { + const node = this.draggingNode + if (!node) return + const world = this.viewport.screenToWorld(x, y) + const nextX = world.x - this.grabOffset.x + const nextY = world.y - this.grabOffset.y + if (node.x !== nextX || node.y !== nextY) { + this.viewport.nodePositionVersion++ + } + node.x = node.fx = nextX + node.y = node.fy = nextY + } + + private onTouchCancel(): void { + this.tapCandidate = false + this.isTouchGesture = false + this.isPanning = false + this.endTouchDrag() + } + private endTouchDrag(): void { this.pressedNode = null if (!this.draggingNode) return diff --git a/packages/memory-graph/src/canvas/reduced-motion.ts b/packages/memory-graph/src/canvas/reduced-motion.ts index 89b75535..5f6ab3b9 100644 --- a/packages/memory-graph/src/canvas/reduced-motion.ts +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -1,9 +1,13 @@ -// SSR-safe; false when matchMedia is unavailable. -export function prefersReducedMotion(): boolean { - if (typeof globalThis.matchMedia !== "function") return false +// SSR-safe; null when matchMedia is unavailable. +export function getReducedMotionQuery(): MediaQueryList | null { + if (typeof globalThis.matchMedia !== "function") return null try { - return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches + return globalThis.matchMedia("(prefers-reduced-motion: reduce)") } catch { - return false + return null } } + +export function prefersReducedMotion(): boolean { + return getReducedMotionQuery()?.matches ?? false +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 67483f10..92eedcf8 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,18 +1,31 @@ import * as d3 from "d3-force" import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import { FORCE_CONFIG } from "../constants" -import { prefersReducedMotion } from "./reduced-motion" +import { getReducedMotionQuery, prefersReducedMotion } from "./reduced-motion" export const DENSE_GRAPH_STATIC_THRESHOLD = 6000 export class ForceSimulation { private sim: d3.Simulation | null = null private holdingHeat = false + private reducedMotionQuery: MediaQueryList | null = null + private readonly onReducedMotionChange = (event: MediaQueryListEvent) => { + if (event.matches) this.stop() + } init(nodes: GraphNode[], edges: GraphEdge[]): void { this.destroy() try { + this.reducedMotionQuery = getReducedMotionQuery() + if (this.reducedMotionQuery?.addEventListener) { + this.reducedMotionQuery.addEventListener( + "change", + this.onReducedMotionChange, + ) + } else { + this.reducedMotionQuery?.addListener?.(this.onReducedMotionChange) + } const structuralEdges = edges.filter((e) => e.edgeType !== "extends") this.sim = d3 @@ -148,6 +161,15 @@ export class ForceSimulation { destroy(): void { this.holdingHeat = false + if (this.reducedMotionQuery?.removeEventListener) { + this.reducedMotionQuery.removeEventListener( + "change", + this.onReducedMotionChange, + ) + } else { + this.reducedMotionQuery?.removeListener?.(this.onReducedMotionChange) + } + this.reducedMotionQuery = null if (this.sim) { this.sim.stop() this.sim = null diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index d58068de..b6473a76 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -1,7 +1,10 @@ +import { getReducedMotionQuery } from "./reduced-motion" + export class ViewportState { panX: number panY: number zoom: number + nodePositionVersion = 0 private velocityX = 0 private velocityY = 0 @@ -21,10 +24,7 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM - private readonly reducedMotionQuery: MediaQueryList | null = - typeof globalThis.matchMedia === "function" - ? globalThis.matchMedia("(prefers-reduced-motion: reduce)") - : null + private readonly reducedMotionQuery = getReducedMotionQuery() private get reducedMotion(): boolean { return this.reducedMotionQuery?.matches ?? false @@ -145,6 +145,10 @@ export class ViewportState { tick(): boolean { const reduced = this.reducedMotion let moving = false + if (reduced) { + this.velocityX = 0 + this.velocityY = 0 + } if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { this.panX += this.velocityX diff --git a/packages/memory-graph/src/components/minimap.tsx b/packages/memory-graph/src/components/minimap.tsx index 35f1f2ca..f3044957 100644 --- a/packages/memory-graph/src/components/minimap.tsx +++ b/packages/memory-graph/src/components/minimap.tsx @@ -100,6 +100,7 @@ export function Minimap({ let lastPanX = Number.NaN let lastPanY = Number.NaN let lastZoom = Number.NaN + let lastNodePositionVersion = -1 let wasSimActive = true const loop = () => { const vp = viewportRef.current @@ -107,12 +108,15 @@ export function Minimap({ const vpChanged = !!vp && (vp.panX !== lastPanX || vp.panY !== lastPanY || vp.zoom !== lastZoom) - if (vpChanged || simActive || wasSimActive) { + const nodesMoved = + !!vp && vp.nodePositionVersion !== lastNodePositionVersion + if (vpChanged || nodesMoved || simActive || wasSimActive) { draw() if (vp) { lastPanX = vp.panX lastPanY = vp.panY lastZoom = vp.zoom + lastNodePositionVersion = vp.nodePositionVersion } } wasSimActive = simActive diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 82ea6b5b..57b4ae1f 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -411,14 +411,18 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { for (const mem of doc.memories) allNodeIds.add(mem.id) } - const documentEdgeIds = new Set() + const documentTargets = new Map>() for (const doc of documents) { + let targets = documentTargets.get(doc.id) for (const mem of doc.memories) { - const edgeId = `dm-${doc.id}-${mem.id}` - if (documentEdgeIds.has(edgeId)) continue - documentEdgeIds.add(edgeId) + if (targets?.has(mem.id)) continue + if (!targets) { + targets = new Set() + documentTargets.set(doc.id, targets) + } + targets.add(mem.id) result.push({ - id: edgeId, + id: `dm-${doc.id}-${mem.id}`, source: doc.id, target: mem.id, visualProps: getEdgeVisualProps("document"), @@ -427,19 +431,20 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { } } - // 2. Memory-to-memory relation edges from backend data. - // Uses memoryRelations (Record) as primary source, - // falls back to parentMemoryId for legacy data. - const relationEdgeIds = new Set() + const relationSources = new Map>() for (const doc of documents) { for (const mem of doc.memories) { const relations = getMemoryRelationTargets(mem) + let sources = relationSources.get(mem.id) for (const [targetId, relationType] of Object.entries(relations)) { if (!allNodeIds.has(targetId)) continue - const edgeId = `rel-${targetId}-${mem.id}` - if (relationEdgeIds.has(edgeId)) continue - relationEdgeIds.add(edgeId) + if (sources?.has(targetId)) continue + if (!sources) { + sources = new Set() + relationSources.set(mem.id, sources) + } + sources.add(targetId) const edgeType = relationType === "updates" || relationType === "extends" || @@ -447,7 +452,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { ? relationType : "updates" result.push({ - id: edgeId, + id: `rel-${targetId}-${mem.id}`, source: targetId, target: mem.id, visualProps: getEdgeVisualProps(edgeType),