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. diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 9d240d4b..99e3c925 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -66,6 +66,8 @@ 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) | +| `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__/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/__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/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 12d0e471..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() { @@ -78,6 +69,12 @@ 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 + let handler: InputHandler + let simulation: ForceSimulation | null + const fire = (name: string, e: TouchEvent) => { const fn = listeners.get(name) if (!fn) throw new Error(`no listener registered for ${name}`) @@ -88,28 +85,115 @@ describe("InputHandler touch tap-to-select", () => { const stub = makeStubCanvas() listeners = stub.listeners 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() - index.rebuild([makeNode("doc-1", 100, 100)]) + 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: () => {}, - onNodeDragEnd: () => {}, + 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([])) expect(clicks).toEqual(["doc-1"]) + expect(dragStarts).toEqual([]) + expect(testNode.fx).toBeUndefined() }) it("clears selection when tapping empty space", () => { @@ -127,9 +211,27 @@ 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() + 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 +251,13 @@ 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 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) expect(clicks).toEqual(["doc-1"]) }) 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__/minimap-render.e2e.test.tsx b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx new file mode 100644 index 00000000..a1d2960e --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx @@ -0,0 +1,95 @@ +// @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..b4eec32f --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap.test.ts @@ -0,0 +1,132 @@ +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, + ) + expect(layout.scale).toBeCloseTo(1) + 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, + ) + expect(layout.scale).toBeCloseTo(0.5) + const topLeft = worldToMinimap(0, 0, layout) + const bottomRight = worldToMinimap(200, 100, layout) + 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", () => { + 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", () => { + 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, + ) + 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/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts new file mode 100644 index 00000000..0b4eddaa --- /dev/null +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -0,0 +1,202 @@ +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.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() + 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("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) + 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) + expect(vp.panX).toBe(-100) + expect(vp.panY).toBe(-200) + }) +}) 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..e4c190c5 --- /dev/null +++ b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx @@ -0,0 +1,291 @@ +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("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", [ + 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/__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/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index d3a82f16..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 @@ -300,6 +302,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 +329,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 +366,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 +378,60 @@ 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) { + this.moveDraggingNode(x, 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 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 + 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 diff --git a/packages/memory-graph/src/canvas/minimap.ts b/packages/memory-graph/src/canvas/minimap.ts new file mode 100644 index 00000000..03f0f48b --- /dev/null +++ b/packages/memory-graph/src/canvas/minimap.ts @@ -0,0 +1,108 @@ +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/canvas/reduced-motion.ts b/packages/memory-graph/src/canvas/reduced-motion.ts new file mode 100644 index 00000000..5f6ab3b9 --- /dev/null +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -0,0 +1,13 @@ +// 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)") + } catch { + 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 01bca6b0..92eedcf8 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,17 +1,31 @@ import * as d3 from "d3-force" import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import { FORCE_CONFIG } from "../constants" +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 @@ -67,7 +81,10 @@ export class ForceSimulation { : FORCE_CONFIG.preSettleTicks for (let i = 0; i < preSettleTicks; i++) this.sim.tick() - if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) { + if ( + nodes.length > DENSE_GRAPH_STATIC_THRESHOLD || + prefersReducedMotion() + ) { this.stop() } else { this.settle() @@ -87,6 +104,7 @@ export class ForceSimulation { } reheat(): void { + if (prefersReducedMotion()) return this.holdingHeat = true this.sim?.on("tick.settle", null) this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() @@ -94,7 +112,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 @@ -143,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 648b6ce8..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,6 +24,12 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM + private readonly reducedMotionQuery = getReducedMotionQuery() + + 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 +68,7 @@ export class ViewportState { } releaseWithVelocity(vx: number, vy: number): void { + if (this.reducedMotion) return this.velocityX = vx this.velocityY = vy } @@ -133,7 +143,12 @@ 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 @@ -149,7 +164,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 +173,17 @@ 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 { + if ( + reduced && + (this.panX !== this.targetPanX || this.panY !== this.targetPanY) + ) { + moving = true + } this.panX = this.targetPanX this.panY = this.targetPanY this.targetPanX = null 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/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/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index b62b10d9..218f0bad 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" @@ -29,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, @@ -67,6 +67,7 @@ export function MemoryGraph({ highlightDocumentIds = [], highlightsVisible = true, showFps = false, + showMinimap = false, maxNodes, isSlideshowActive = false, onSlideshowNodeChange, @@ -74,6 +75,7 @@ export function MemoryGraph({ colors: colorOverrides, totalCount, onOpenDocument: externalOnOpenDocument, + onNodeSelect, labels: labelOverrides, layering, appearance, @@ -85,14 +87,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) @@ -105,42 +100,51 @@ 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 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]) @@ -937,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..f3044957 --- /dev/null +++ b/packages/memory-graph/src/components/minimap.tsx @@ -0,0 +1,195 @@ +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 lastNodePositionVersion = -1 + 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) + 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 + 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/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" }} > - − + diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index e0a3b1b3..57b4ae1f 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -157,10 +157,11 @@ export function computeClusterAssignments( const component: string[] = [] const queue = [startId] + 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 @@ -400,10 +401,6 @@ function getAppendSpatialKey(x: number, y: number): string { return `${x}:${y}` } -/** - * Pure function that computes graph edges from documents. - * Extracted from the hook for testability. - */ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { if (!documents || documents.length === 0) return [] @@ -414,8 +411,16 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { for (const mem of doc.memories) allNodeIds.add(mem.id) } + const documentTargets = new Map>() for (const doc of documents) { + let targets = documentTargets.get(doc.id) for (const mem of doc.memories) { + if (targets?.has(mem.id)) continue + if (!targets) { + targets = new Set() + documentTargets.set(doc.id, targets) + } + targets.add(mem.id) result.push({ id: `dm-${doc.id}-${mem.id}`, source: doc.id, @@ -426,15 +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 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 + if (sources?.has(targetId)) continue + if (!sources) { + sources = new Set() + relationSources.set(mem.id, sources) + } + sources.add(targetId) const edgeType = relationType === "updates" || relationType === "extends" || @@ -496,6 +506,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. @@ -576,6 +587,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, 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 6a53f78c..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 @@ -226,6 +228,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 */