This commit is contained in:
Mahesh Sanikommu 2026-10-25 11:35:30 +00:00 • committed by GitHub
commit f8b58af25e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2057 additions and 179 deletions

View file

@ -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<DocumentWithMemories[]>([]);
const [documents, setDocuments] = useState<GraphApiDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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.
<Note>
`documents` must be `GraphApiDocument[]`, the shape the API route below returns, with `documentType` and `memories` fields (not the raw API's `type` and `memoryEntries`).
</Note>
## 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`; `<MemoryGraph>` expects `documentType` and `memories`, so the route normalizes the shape before returning it:
<CodeGroup>
```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<string, 'updates' | 'extends' | 'derives'> | 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
// <MemoryGraph> 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<string, 'updates' | 'extends' | 'derives'> | 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
// <MemoryGraph> 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
// <MemoryGraph> 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
<MemoryGraph documents={documents} variant="console" />
```
**Consumer Variant** - Embedded widget view (0.5x zoom, space selector hidden):
**Consumer Variant** - Embedded widget view (0.5x initial zoom):
```tsx
<MemoryGraph documents={documents} variant="consumer" />
@ -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<GraphApiDocument[]>([]);
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
<MemoryGraph
documents={documents}
selectedSpace={selectedSpace}
onSpaceChange={setSelectedSpace}
showSpacesSelector={false}
/>
```
### 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<void>` | - | 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<HTMLCanvasElement \| null>` | - | External ref to the canvas (e.g. for screenshot export) |
| `colors` | `Partial<GraphThemeColors>` | - | 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<string, 'updates' | 'extends' | 'derives'> | 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<string, string | number | boolean> | 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<string, string | number | boolean> | 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,
});
```
---

View file

@ -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<DocumentWithMemories[]>([])
const [documents, setDocuments] = useState<GraphApiDocument[]>([])
return (
<MemoryGraph
@ -26,10 +26,9 @@ function App() {
isLoadingMore={false}
error={null}
hasMore={false}
loadMoreDocuments={() => {}}
totalLoaded={documents.length}
onLoadMore={() => {}}
totalCount={documents.length}
variant="console"
showSpacesSelector={true}
>
<div>No memories found</div>
</MemoryGraph>
@ -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.

View file

@ -66,6 +66,8 @@ function App() {
| `error` | `Error \| null` | Error to display |
| `loadMoreDocuments` | `() => Promise<void>` | 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.

View file

@ -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(
<NavigationControls
onCenter={noop}
onZoomIn={noop}
onZoomOut={noop}
onAutoFit={noop}
nodes={[node]}
zoomLevel={100}
colors={DEFAULT_COLORS}
/>,
)
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(
<Legend
nodes={[node]}
edges={[]}
colors={DEFAULT_COLORS}
labels={DEFAULT_LABELS}
/>,
)
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(
<LoadingIndicator
isLoading
isLoadingMore={false}
totalLoaded={0}
colors={DEFAULT_COLORS}
labels={DEFAULT_LABELS}
/>,
)
const status = getByRole("status")
expect(status.getAttribute("aria-live")).toBe("polite")
})
})

View file

@ -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", () => {

View file

@ -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<string | null>
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"])
})

View file

@ -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<number, FrameRequestCallback>()
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(
<Minimap
nodes={nodes}
colors={DEFAULT_COLORS}
viewportRef={{ current: viewport }}
canvasWidth={800}
canvasHeight={600}
simulation={simulation}
/>,
)
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()
})
})

View file

@ -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<ViewportState | null>()
;(viewportRef as { current: ViewportState | null }).current =
new ViewportState()
const { container } = render(
<Minimap
nodes={nodes}
colors={DEFAULT_COLORS}
viewportRef={viewportRef}
canvasWidth={800}
canvasHeight={600}
/>,
)
const canvas = container.querySelector("canvas")
expect(canvas).not.toBeNull()
})
it("renders nothing visible but does not crash with no nodes", () => {
const viewportRef = createRef<ViewportState | null>()
;(viewportRef as { current: ViewportState | null }).current =
new ViewportState()
const { container } = render(
<Minimap
nodes={[]}
colors={DEFAULT_COLORS}
viewportRef={viewportRef}
canvasWidth={800}
canvasHeight={600}
/>,
)
expect(container.querySelector("canvas")).not.toBeNull()
})
})

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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> = {}): 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(<MemoryGraph documents={documents} maxNodes={4} />)
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",
])
})
})

View file

@ -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)]

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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<GraphNode, GraphEdge> | 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

View file

@ -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

View file

@ -97,13 +97,13 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(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])

View file

@ -244,6 +244,7 @@ function StatRow({
onClick={expandable ? onToggle : undefined}
style={buttonStyle}
type="button"
aria-expanded={expandable ? expanded : undefined}
>
<div style={leftStyle}>
{icon}
@ -430,6 +431,7 @@ export const Legend = memo(function Legend({
onClick={() => setIsExpanded(!isExpanded)}
style={headerBtnStyle}
type="button"
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDownIcon color={colors.textPrimary} />

View file

@ -76,7 +76,7 @@ export const LoadingIndicator = memo<
}
return (
<div style={containerStyle}>
<output style={containerStyle} aria-live="polite">
<div style={flexStyle}>
<svg
aria-hidden="true"
@ -96,7 +96,7 @@ export const LoadingIndicator = memo<
: labels.loadingMoreDocuments(totalLoaded)}
</span>
</div>
</div>
</output>
)
},
)

View file

@ -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<GraphThemeColors>(
() =>
colorOverrides
? { ...resolvedColors, ...colorOverrides }
: resolvedColors,
[resolvedColors, colorOverrides],
)
const colors = useGraphTheme(colorOverrides)
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
@ -105,42 +100,51 @@ export function MemoryGraph({
// React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(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<string>()
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({
/>
</div>
)}
{showMinimap &&
!isCompactViewport &&
containerSize.width > 0 &&
nodes.length > 0 && (
<div
aria-hidden="true"
style={{
position: "absolute",
bottom: 16,
right: 16,
zIndex: 20,
}}
>
<Minimap
nodes={nodes}
colors={colors}
viewportRef={viewportRef}
canvasWidth={containerSize.width}
canvasHeight={containerSize.height}
simulation={simulation}
/>
</div>
)}
</div>
</div>
)

View file

@ -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<ViewportState | null>
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<HTMLCanvasElement>(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<HTMLCanvasElement>) => {
isDraggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
recenterFromEvent(e.clientX, e.clientY)
},
[recenterFromEvent],
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current) return
recenterFromEvent(e.clientX, e.clientY)
},
[recenterFromEvent],
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = false
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
},
[],
)
return (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
width,
height,
display: "block",
cursor: "pointer",
borderRadius: 10,
border: `1px solid ${colors.controlBorder}`,
background: colors.controlBg,
boxShadow: "0 6px 18px rgba(0,0,0,0.22)",
touchAction: "none",
}}
/>
)
}

View file

@ -176,7 +176,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>−</span>
<span aria-hidden="true" style={{ fontSize: 12 }}>
−
</span>
</button>
<button
aria-label="Zoom in"
@ -190,7 +192,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>+</span>
<span aria-hidden="true" style={{ fontSize: 12 }}>
+
</span>
</button>
</div>
</div>

View file

@ -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<string, Set<string>>()
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<string>()
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<targetId, relationType>) as primary source,
// falls back to parentMemoryId for legacy data.
const relationSources = new Map<string, Set<string>>()
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<string>()
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<string>()
// 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,

View file

@ -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"

View file

@ -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 */