This commit is contained in:
Mahesh Sanikommu 2026-10-28 00:00:26 +05:30 • committed by GitHub
commit 4a7bddcf64
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 'use client'; // For Next.js App Router
import { MemoryGraph } from '@supermemory/memory-graph'; 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'; import { useEffect, useState } from 'react';
export default function GraphPage() { export default function GraphPage() {
const [documents, setDocuments] = useState<DocumentWithMemories[]>([]); const [documents, setDocuments] = useState<GraphApiDocument[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); 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. `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 ## 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> <CodeGroup>
```typescript Next.js App Router ```typescript Next.js App Router
// app/api/graph/route.ts // app/api/graph/route.ts
import { NextResponse } from 'next/server'; 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() { export async function GET() {
const response = await fetch('https://api.supermemory.ai/v3/documents/documents', { const response = await fetch('https://api.supermemory.ai/v3/documents/documents', {
@ -101,14 +183,95 @@ export async function GET() {
}), }),
}); });
const data = await response.json(); const data: RawDocumentsResponse = await response.json();
return NextResponse.json(data); return NextResponse.json({
documents: data.documents.map(toGraphDocument),
pagination: data.pagination,
});
} }
``` ```
```typescript Next.js Pages Router ```typescript Next.js Pages Router
// pages/api/graph.ts // pages/api/graph.ts
import type { NextApiRequest, NextApiResponse } from 'next'; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await fetch('https://api.supermemory.ai/v3/documents/documents', { 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' }), body: JSON.stringify({ page: 1, limit: 500, sort: 'createdAt', order: 'desc' }),
}); });
const data = await response.json(); const data: RawDocumentsResponse = await response.json();
res.json(data); 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(); 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 ## Variants
**Console Variant** - Full-featured dashboard view (0.8x zoom, space selector visible): **Console Variant** - Full-featured dashboard view (0.8x initial zoom):
```tsx ```tsx
<MemoryGraph documents={documents} variant="console" /> <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 ```tsx
<MemoryGraph documents={documents} variant="consumer" /> <MemoryGraph documents={documents} variant="consumer" />
@ -173,10 +373,11 @@ app.get('/api/graph', async (req, res) => {
'use client'; 'use client';
import { MemoryGraph } from '@supermemory/memory-graph'; import { MemoryGraph } from '@supermemory/memory-graph';
import type { GraphApiDocument } from '@supermemory/memory-graph';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
export default function PaginatedGraph() { export default function PaginatedGraph() {
const [documents, setDocuments] = useState([]); const [documents, setDocuments] = useState<GraphApiDocument[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -210,8 +411,8 @@ export default function PaginatedGraph() {
isLoading={isLoading} isLoading={isLoading}
isLoadingMore={isLoadingMore} isLoadingMore={isLoadingMore}
hasMore={hasMore} hasMore={hasMore}
totalLoaded={documents.length} totalCount={documents.length}
loadMoreDocuments={loadMore} 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 ### Custom empty state
```tsx ```tsx
@ -257,80 +447,132 @@ export default function PaginatedGraph() {
| Prop | Type | Default | Description | | Prop | Type | Default | Description |
|------|------|---------|-------------| |------|------|---------|-------------|
| `documents` | `DocumentWithMemories[]` | required | Array of documents to display | | `documents` | `GraphApiDocument[]` | `[]` | Documents to display - pass this for direct data mode |
| `isLoading` | `boolean` | `false` | Shows loading indicator | | `isLoading` | `boolean` | `false` | Whether data is loading |
| `error` | `Error \| null` | `null` | Error to display | | `error` | `Error \| null` | `null` | Error from data fetching |
| `variant` | `"console" \| "consumer"` | `"console"` | Visual variant | | `variant` | `"console" \| "consumer"` | `"console"` | Visual variant |
| `children` | `ReactNode` | - | Custom empty state content | | `children` | `ReactNode` | - | Rendered when there are no documents |
### Pagination props ### Pagination props
| Prop | Type | Default | Description | | Prop | Type | Default | Description |
|------|------|---------|-------------| |------|------|---------|-------------|
| `isLoadingMore` | `boolean` | `false` | Shows indicator when loading more | | `isLoadingMore` | `boolean` | `false` | Whether more data is being loaded |
| `hasMore` | `boolean` | `false` | Whether more documents available | | `hasMore` | `boolean` | `false` | Whether there are more documents to load |
| `totalLoaded` | `number` | - | Total documents currently loaded | | `onLoadMore` | `() => void` | - | Callback to load more documents |
| `loadMoreDocuments` | `() => Promise<void>` | - | Callback to load more | | `totalCount` | `number` | - | Total count, used by the loading indicator |
| `autoLoadOnViewport` | `boolean` | `true` | Auto-load when 80% visible |
### Display props ### Display props
| Prop | Type | Default | Description | | Prop | Type | Default | Description |
|------|------|---------|-------------| |------|------|---------|-------------|
| `showSpacesSelector` | `boolean` | variant-based | Show space filter dropdown |
| `highlightDocumentIds` | `string[]` | `[]` | Document IDs to highlight | | `highlightDocumentIds` | `string[]` | `[]` | Document IDs to highlight |
| `highlightsVisible` | `boolean` | `true` | Whether highlights shown | | `highlightsVisible` | `boolean` | `true` | Whether highlights are visible |
| `occludedRightPx` | `number` | `0` | Pixels occluded on right | | `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 | | Prop | Type | Default | Description |
|------|------|-------------| |------|------|---------|-------------|
| `selectedSpace` | `string` | Currently selected space (use `"all"` for all) | | `maxNodes` | `number` | - | Caps the total document plus memory node count |
| `onSpaceChange` | `(spaceId: string) => void` | Callback when space changes |
| `memoryLimit` | `number` | Max memories per document when space selected | `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 ## 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 ```typescript
interface DocumentWithMemories { interface DocumentWithMemories {
id: string; id: string;
customId?: string | null; title: string | null;
title?: string | null; url: string | null;
content?: string | null; documentType: string;
createdAt: string;
updatedAt: string;
summary?: string | null; summary?: string | null;
url?: string | null; memories: MemoryEntry[];
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[];
} }
```
### MemoryEntry
```typescript
interface MemoryEntry { interface MemoryEntry {
id: string; id: string;
documentId: string; memory: string;
content: string | null; content?: string | null;
summary?: string | null; createdAt: string;
title?: string | null; updatedAt: string;
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;
spaceId?: string | null; 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 ### Components
```typescript ```typescript
import { import { MemoryGraph, GraphCanvas } from '@supermemory/memory-graph';
MemoryGraph,
GraphCanvas,
Legend,
LoadingIndicator,
NodeDetailPanel,
SpacesDropdown
} from '@supermemory/memory-graph';
``` ```
### Hooks ### Hooks
```typescript ```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 ### Constants
```typescript ```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 ## Usage Example
```tsx ```tsx
import { MemoryGraph, type DocumentWithMemories } from '@supermemory/memory-graph' import { MemoryGraph, type GraphApiDocument } from '@supermemory/memory-graph'
function App() { function App() {
const [documents, setDocuments] = useState<DocumentWithMemories[]>([]) const [documents, setDocuments] = useState<GraphApiDocument[]>([])
return ( return (
<MemoryGraph <MemoryGraph
@ -26,10 +26,9 @@ function App() {
isLoadingMore={false} isLoadingMore={false}
error={null} error={null}
hasMore={false} hasMore={false}
loadMoreDocuments={() => {}} onLoadMore={() => {}}
totalLoaded={documents.length} totalCount={documents.length}
variant="console" variant="console"
showSpacesSelector={true}
> >
<div>No memories found</div> <div>No memories found</div>
</MemoryGraph> </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 ## Props
| Prop | Type | Description | | Prop | Type | Description |
|------|------|-------------| |------|------|-------------|
| `documents` | `DocumentWithMemories[]` | Array of documents to display | | `documents` | `GraphApiDocument[]` | Array of documents to display |
| `isLoading` | `boolean` | Initial loading state | | `isLoading` | `boolean` | Initial loading state |
| `isLoadingMore` | `boolean` | Loading more documents state | | `isLoadingMore` | `boolean` | Loading more documents state |
| `error` | `Error \| null` | Error to display | | `error` | `Error \| null` | Error to display |
| `hasMore` | `boolean` | Whether more documents can be loaded | | `hasMore` | `boolean` | Whether more documents can be loaded |
| `loadMoreDocuments` | `() => void` | Callback to load more documents | | `onLoadMore` | `() => void` | Callback to load more documents |
| `totalLoaded` | `number` | Total number of loaded documents | | `totalCount` | `number` | Total number of loaded documents |
| `variant` | `"default" \| "console"` | Visual theme variant | | `variant` | `"console" \| "consumer"` | Visual variant |
| `showSpacesSelector` | `boolean` | Show space filter dropdown |
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 | | `error` | `Error \| null` | Error to display |
| `loadMoreDocuments` | `() => Promise<void>` | Function to load more data | | `loadMoreDocuments` | `() => Promise<void>` | Function to load more data |
| `highlightDocumentIds` | `string[]` | IDs of documents to highlight | | `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. 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) 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", () => { 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 { SpatialIndex } from "../canvas/hit-test"
import { InputHandler } from "../canvas/input-handler" import { InputHandler } from "../canvas/input-handler"
import { ForceSimulation } from "../canvas/simulation"
import { ViewportState } from "../canvas/viewport" import { ViewportState } from "../canvas/viewport"
import type { GraphNode } from "../types" 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 type Listener = (e: Event) => void
function makeStubCanvas() { function makeStubCanvas() {
@ -78,6 +69,12 @@ describe("InputHandler touch tap-to-select", () => {
let clicks: Array<string | null> let clicks: Array<string | null>
let viewport: ViewportState 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 fire = (name: string, e: TouchEvent) => {
const fn = listeners.get(name) const fn = listeners.get(name)
if (!fn) throw new Error(`no listener registered for ${name}`) if (!fn) throw new Error(`no listener registered for ${name}`)
@ -88,28 +85,115 @@ describe("InputHandler touch tap-to-select", () => {
const stub = makeStubCanvas() const stub = makeStubCanvas()
listeners = stub.listeners listeners = stub.listeners
clicks = [] clicks = []
dragStarts = []
dragEnds = 0
simulation = null
// zoom 1 / pan 0 so screen coordinates equal world coordinates
viewport = new ViewportState(0, 0, 1) viewport = new ViewportState(0, 0, 1)
const index = new SpatialIndex() 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: () => {}, onNodeHover: () => {},
onNodeClick: (id) => { onNodeClick: (id) => {
clicks.push(id) clicks.push(id)
}, },
onNodeDragStart: () => {}, onNodeDragStart: (id, node) => {
onNodeDragEnd: () => {}, dragStarts.push({ id, node })
simulation?.reheat()
},
onNodeDragEnd: () => {
dragEnds++
simulation?.coolDown()
},
onRequestRender: () => {}, 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", () => { it("selects a node on a single tap", () => {
fire("touchstart", touchEvent([touch(100, 100)])) fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchend", touchEvent([])) fire("touchend", touchEvent([]))
expect(clicks).toEqual(["doc-1"]) expect(clicks).toEqual(["doc-1"])
expect(dragStarts).toEqual([])
expect(testNode.fx).toBeUndefined()
}) })
it("clears selection when tapping empty space", () => { it("clears selection when tapping empty space", () => {
@ -127,9 +211,27 @@ describe("InputHandler touch tap-to-select", () => {
expect(clicks).toEqual(["doc-1"]) 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("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([])) fire("touchend", touchEvent([]))
expect(clicks).toEqual([]) 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", () => { 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. // 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). // zoom 0.25: world (100, 100) renders at screen (25, 25).
viewport.zoomImmediate(0.25, 0, 0) viewport.zoomImmediate(0.25, 0, 0)
fire("touchstart", touchEvent([touch(25, 25)])) 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("touchmove", touchEvent([touch(33, 25)]))
fire("touchend", touchEvent([])) 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"]) 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) 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)", () => { it("rebuild detects sub-pixel movements (10x granularity)", () => {
const idx = new SpatialIndex() const idx = new SpatialIndex()
const nodes = [makeNode("a", 100.0, 100.0)] const nodes = [makeNode("a", 100.0, 100.0)]

View file

@ -5,9 +5,10 @@ export class SpatialIndex {
private cellSize = 200 private cellSize = 200
private lastHash = 0 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) const hash = this.computeHash(nodes)
if (hash === this.lastHash) return false if (!force && hash === this.lastHash) return false
this.lastHash = hash this.lastHash = hash
this.grid.clear() this.grid.clear()

View file

@ -58,6 +58,7 @@ export class InputHandler {
private boundTouchStart: (e: TouchEvent) => void private boundTouchStart: (e: TouchEvent) => void
private boundTouchMove: (e: TouchEvent) => void private boundTouchMove: (e: TouchEvent) => void
private boundTouchEnd: (e: TouchEvent) => void private boundTouchEnd: (e: TouchEvent) => void
private boundTouchCancel: () => void
private boundGesture: (e: Event) => void private boundGesture: (e: Event) => void
constructor( constructor(
@ -84,6 +85,7 @@ export class InputHandler {
this.boundTouchStart = this.onTouchStart.bind(this) this.boundTouchStart = this.onTouchStart.bind(this)
this.boundTouchMove = this.onTouchMove.bind(this) this.boundTouchMove = this.onTouchMove.bind(this)
this.boundTouchEnd = this.onTouchEnd.bind(this) this.boundTouchEnd = this.onTouchEnd.bind(this)
this.boundTouchCancel = this.onTouchCancel.bind(this)
this.boundGesture = (e: Event) => e.preventDefault() this.boundGesture = (e: Event) => e.preventDefault()
canvas.addEventListener("mousedown", this.boundMouseDown) canvas.addEventListener("mousedown", this.boundMouseDown)
@ -98,6 +100,7 @@ export class InputHandler {
passive: false, passive: false,
}) })
canvas.addEventListener("touchend", this.boundTouchEnd) canvas.addEventListener("touchend", this.boundTouchEnd)
canvas.addEventListener("touchcancel", this.boundTouchCancel)
canvas.addEventListener("gesturestart", this.boundGesture, { canvas.addEventListener("gesturestart", this.boundGesture, {
passive: false, passive: false,
}) })
@ -118,6 +121,7 @@ export class InputHandler {
c.removeEventListener("touchstart", this.boundTouchStart) c.removeEventListener("touchstart", this.boundTouchStart)
c.removeEventListener("touchmove", this.boundTouchMove) c.removeEventListener("touchmove", this.boundTouchMove)
c.removeEventListener("touchend", this.boundTouchEnd) c.removeEventListener("touchend", this.boundTouchEnd)
c.removeEventListener("touchcancel", this.boundTouchCancel)
c.removeEventListener("gesturestart", this.boundGesture) c.removeEventListener("gesturestart", this.boundGesture)
c.removeEventListener("gesturechange", this.boundGesture) c.removeEventListener("gesturechange", this.boundGesture)
c.removeEventListener("gestureend", this.boundGesture) c.removeEventListener("gestureend", this.boundGesture)
@ -188,9 +192,7 @@ export class InputHandler {
} }
if (this.draggingNode) { if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y) this.moveDraggingNode(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.didDrag = true this.didDrag = true
this.callbacks.onRequestRender() this.callbacks.onRequestRender()
return return
@ -300,6 +302,7 @@ export class InputHandler {
if (touches.length >= 2) { if (touches.length >= 2) {
this.isTouchGesture = true this.isTouchGesture = true
this.tapCandidate = false this.tapCandidate = false
this.endTouchDrag()
const t0 = touches[0] const t0 = touches[0]
const t1 = touches[1] const t1 = touches[1]
if (!t0 || !t1) return if (!t0 || !t1) return
@ -326,7 +329,13 @@ export class InputHandler {
this.touchStartWorldX = startWorld.x this.touchStartWorldX = startWorld.x
this.touchStartWorldY = startWorld.y this.touchStartWorldY = startWorld.y
this.tapCandidate = true 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.lastTouchDistance = dist
this.lastTouchCenter = center this.lastTouchCenter = center
this.callbacks.onRequestRender() this.callbacks.onRequestRender()
} else if ( } else if (touches.length === 1 && !this.isTouchGesture && touches[0]) {
touches.length === 1 &&
this.isPanning &&
!this.isTouchGesture &&
touches[0]
) {
const t = touches[0] const t = touches[0]
const rect = this.canvas.getBoundingClientRect() const rect = this.canvas.getBoundingClientRect()
const x = t.clientX - rect.left const x = t.clientX - rect.left
@ -374,19 +378,60 @@ export class InputHandler {
) { ) {
this.tapCandidate = false this.tapCandidate = false
} }
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) if (this.pressedNode && !this.tapCandidate) {
this.lastMouseX = x this.draggingNode = this.pressedNode
this.lastMouseY = y this.pressedNode = null
this.callbacks.onRequestRender() 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 { private onTouchEnd(e: TouchEvent): void {
if (e.touches.length < 2) { if (e.touches.length < 2) {
this.isTouchGesture = false this.isTouchGesture = false
} }
if (e.touches.length === 0) { if (e.touches.length === 0) {
this.isPanning = false this.isPanning = false
this.endTouchDrag()
if (this.tapCandidate) { if (this.tapCandidate) {
this.tapCandidate = false this.tapCandidate = false
// Use the world point captured at touchstart, not the start screen // 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 * as d3 from "d3-force"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants" import { FORCE_CONFIG } from "../constants"
import { getReducedMotionQuery, prefersReducedMotion } from "./reduced-motion"
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000 export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export class ForceSimulation { export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
private holdingHeat = false private holdingHeat = false
private reducedMotionQuery: MediaQueryList | null = null
private readonly onReducedMotionChange = (event: MediaQueryListEvent) => {
if (event.matches) this.stop()
}
init(nodes: GraphNode[], edges: GraphEdge[]): void { init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy() this.destroy()
try { 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") const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
this.sim = d3 this.sim = d3
@ -67,7 +81,10 @@ export class ForceSimulation {
: FORCE_CONFIG.preSettleTicks : FORCE_CONFIG.preSettleTicks
for (let i = 0; i < preSettleTicks; i++) this.sim.tick() 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() this.stop()
} else { } else {
this.settle() this.settle()
@ -87,6 +104,7 @@ export class ForceSimulation {
} }
reheat(): void { reheat(): void {
if (prefersReducedMotion()) return
this.holdingHeat = true this.holdingHeat = true
this.sim?.on("tick.settle", null) this.sim?.on("tick.settle", null)
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
@ -94,7 +112,7 @@ export class ForceSimulation {
settle(): void { settle(): void {
const sim = this.sim const sim = this.sim
if (!sim || this.holdingHeat) return if (!sim || this.holdingHeat || prefersReducedMotion()) return
let ticks = 0 let ticks = 0
let stableTicks = 0 let stableTicks = 0
sim sim
@ -143,6 +161,15 @@ export class ForceSimulation {
destroy(): void { destroy(): void {
this.holdingHeat = false 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) { if (this.sim) {
this.sim.stop() this.sim.stop()
this.sim = null this.sim = null

View file

@ -1,7 +1,10 @@
import { getReducedMotionQuery } from "./reduced-motion"
export class ViewportState { export class ViewportState {
panX: number panX: number
panY: number panY: number
zoom: number zoom: number
nodePositionVersion = 0
private velocityX = 0 private velocityX = 0
private velocityY = 0 private velocityY = 0
@ -21,6 +24,12 @@ export class ViewportState {
private static readonly MAX_ZOOM = 5.0 private static readonly MAX_ZOOM = 5.0
private minZoom = ViewportState.DEFAULT_MIN_ZOOM 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) { constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX this.panX = initialPanX
this.panY = initialPanY this.panY = initialPanY
@ -59,6 +68,7 @@ export class ViewportState {
} }
releaseWithVelocity(vx: number, vy: number): void { releaseWithVelocity(vx: number, vy: number): void {
if (this.reducedMotion) return
this.velocityX = vx this.velocityX = vx
this.velocityY = vy this.velocityY = vy
} }
@ -133,7 +143,12 @@ export class ViewportState {
} }
tick(): boolean { tick(): boolean {
const reduced = this.reducedMotion
let moving = false let moving = false
if (reduced) {
this.velocityX = 0
this.velocityY = 0
}
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
this.panX += this.velocityX this.panX += this.velocityX
@ -149,7 +164,7 @@ export class ViewportState {
const zoomDiff = this.targetZoom - this.zoom const zoomDiff = this.targetZoom - this.zoom
if (Math.abs(zoomDiff) > this.zoom * 0.001) { if (Math.abs(zoomDiff) > this.zoom * 0.001) {
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) 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.panX = this.zoomAnchorX - world.x * this.zoom
this.panY = this.zoomAnchorY - world.y * this.zoom this.panY = this.zoomAnchorY - world.y * this.zoom
moving = true moving = true
@ -158,11 +173,17 @@ export class ViewportState {
if (this.targetPanX !== null && this.targetPanY !== null) { if (this.targetPanX !== null && this.targetPanY !== null) {
const dx = this.targetPanX - this.panX const dx = this.targetPanX - this.panX
const dy = this.targetPanY - this.panY 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.panX += dx * this.panLerp
this.panY += dy * this.panLerp this.panY += dy * this.panLerp
moving = true moving = true
} else { } else {
if (
reduced &&
(this.panX !== this.targetPanX || this.panY !== this.targetPanY)
) {
moving = true
}
this.panX = this.targetPanX this.panX = this.targetPanX
this.panY = this.targetPanY this.panY = this.targetPanY
this.targetPanX = null this.targetPanX = null

View file

@ -97,13 +97,13 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
simulation, simulation,
} }
// Rebuild nodeMap + spatial index when nodes change // Force the spatial rebuild: a new node generation can keep the same ids/positions
useEffect(() => { useEffect(() => {
const map = nodeMapRef.current const map = nodeMapRef.current
map.clear() map.clear()
for (const n of nodes) map.set(n.id, n) for (const n of nodes) map.set(n.id, n)
inputRef.current?.syncNodes(map) inputRef.current?.syncNodes(map)
spatialRef.current.rebuild(nodes) spatialRef.current.rebuild(nodes, true)
renderNeeded.current = true renderNeeded.current = true
}, [nodes]) }, [nodes])

View file

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

View file

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

View file

@ -20,7 +20,6 @@ import type {
GraphApiDocument, GraphApiDocument,
GraphNode, GraphNode,
MemoryNodeData, MemoryNodeData,
GraphThemeColors,
MemoryGraphProps, MemoryGraphProps,
ResolvedMemoryGraphLabels, ResolvedMemoryGraphLabels,
} from "../types" } from "../types"
@ -29,6 +28,7 @@ import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator" import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls" import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover" import { NodeHoverPopover } from "./node-hover-popover"
import { Minimap } from "./minimap"
function documentIdForNode( function documentIdForNode(
node: GraphNode | null, node: GraphNode | null,
@ -67,6 +67,7 @@ export function MemoryGraph({
highlightDocumentIds = [], highlightDocumentIds = [],
highlightsVisible = true, highlightsVisible = true,
showFps = false, showFps = false,
showMinimap = false,
maxNodes, maxNodes,
isSlideshowActive = false, isSlideshowActive = false,
onSlideshowNodeChange, onSlideshowNodeChange,
@ -74,6 +75,7 @@ export function MemoryGraph({
colors: colorOverrides, colors: colorOverrides,
totalCount, totalCount,
onOpenDocument: externalOnOpenDocument, onOpenDocument: externalOnOpenDocument,
onNodeSelect,
labels: labelOverrides, labels: labelOverrides,
layering, layering,
appearance, appearance,
@ -85,14 +87,7 @@ export function MemoryGraph({
) )
const hoverPopoverZIndex = const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
const resolvedColors = useGraphTheme(colorOverrides) const colors = useGraphTheme(colorOverrides)
const colors = useMemo<GraphThemeColors>(
() =>
colorOverrides
? { ...resolvedColors, ...colorOverrides }
: resolvedColors,
[resolvedColors, colorOverrides],
)
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null) const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
@ -105,42 +100,51 @@ export function MemoryGraph({
// React state only for things that affect DOM // React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null) const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = 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) const [zoomDisplay, setZoomDisplay] = useState(50)
// Monotonic counter that increments on any viewport change (pan or zoom) // Monotonic counter that increments on any viewport change (pan or zoom)
// Used as a dependency proxy to recalculate popover positions // Used as a dependency proxy to recalculate popover positions
const [viewportVersion, setViewportVersion] = useState(0) 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(() => { const limitedDocuments = useMemo(() => {
if (!maxNodes || documents.length === 0) return documents if (!maxNodes || documents.length === 0) return documents
let totalNodes = 0 let totalNodes = 0
const seenMemoryIds = new Set<string>()
const limited: GraphApiDocument[] = [] const limited: GraphApiDocument[] = []
for (let i = 0; i < documents.length; i++) { for (const doc of documents) {
const doc = documents[i]
if (!doc) continue
if (totalNodes >= maxNodes) break if (totalNodes >= maxNodes) break
const remainingNodes = maxNodes - totalNodes
const memories = doc.memories ?? [] const memories = doc.memories ?? []
const docNodes = 1 + memories.length const includedMemories: GraphApiDocument["memories"] = []
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: [] })
totalNodes += 1 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 return limited
}, [documents, maxNodes]) }, [documents, maxNodes])
@ -937,6 +941,30 @@ export function MemoryGraph({
/> />
</div> </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>
</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" e.currentTarget.style.opacity = "1"
}} }}
> >
<span style={{ fontSize: 12 }}>−</span> <span aria-hidden="true" style={{ fontSize: 12 }}>
−
</span>
</button> </button>
<button <button
aria-label="Zoom in" aria-label="Zoom in"
@ -190,7 +192,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
e.currentTarget.style.opacity = "1" e.currentTarget.style.opacity = "1"
}} }}
> >
<span style={{ fontSize: 12 }}>+</span> <span aria-hidden="true" style={{ fontSize: 12 }}>
+
</span>
</button> </button>
</div> </div>
</div> </div>

View file

@ -157,10 +157,11 @@ export function computeClusterAssignments(
const component: string[] = [] const component: string[] = []
const queue = [startId] const queue = [startId]
let head = 0
visited.add(startId) visited.add(startId)
while (queue.length > 0) { while (head < queue.length) {
const id = queue.shift() as string const id = queue[head++] as string
component.push(id) component.push(id)
for (const nextId of adjacency.get(id) ?? []) { for (const nextId of adjacency.get(id) ?? []) {
if (visited.has(nextId)) continue if (visited.has(nextId)) continue
@ -400,10 +401,6 @@ function getAppendSpatialKey(x: number, y: number): string {
return `${x}:${y}` return `${x}:${y}`
} }
/**
* Pure function that computes graph edges from documents.
* Extracted from the hook for testability.
*/
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
if (!documents || documents.length === 0) return [] 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) for (const mem of doc.memories) allNodeIds.add(mem.id)
} }
const documentTargets = new Map<string, Set<string>>()
for (const doc of documents) { for (const doc of documents) {
let targets = documentTargets.get(doc.id)
for (const mem of doc.memories) { 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({ result.push({
id: `dm-${doc.id}-${mem.id}`, id: `dm-${doc.id}-${mem.id}`,
source: doc.id, source: doc.id,
@ -426,15 +431,20 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
} }
} }
// 2. Memory-to-memory relation edges from backend data. const relationSources = new Map<string, Set<string>>()
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
// falls back to parentMemoryId for legacy data.
for (const doc of documents) { for (const doc of documents) {
for (const mem of doc.memories) { for (const mem of doc.memories) {
const relations = getMemoryRelationTargets(mem) const relations = getMemoryRelationTargets(mem)
let sources = relationSources.get(mem.id)
for (const [targetId, relationType] of Object.entries(relations)) { for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue 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 = const edgeType =
relationType === "updates" || relationType === "updates" ||
relationType === "extends" || relationType === "extends" ||
@ -496,6 +506,7 @@ export function useGraphData(
const clusterAssignments = computeClusterAssignments(documents) const clusterAssignments = computeClusterAssignments(documents)
const result: GraphNode[] = [] const result: GraphNode[] = []
const seenMemoryIds = new Set<string>()
// Spiral layout: documents form a compact spiral core, memories orbit // Spiral layout: documents form a compact spiral core, memories orbit
// around their parent documents. The force simulation then gently // around their parent documents. The force simulation then gently
// pushes memories outward to create the constellation/starburst effect. // pushes memories outward to create the constellation/starburst effect.
@ -576,6 +587,8 @@ export function useGraphData(
for (let i = 0; i < memCount; i++) { for (let i = 0; i < memCount; i++) {
const mem = doc.memories[i] const mem = doc.memories[i]
if (!mem) continue if (!mem) continue
if (seenMemoryIds.has(mem.id)) continue
seenMemoryIds.add(mem.id)
const previousMemNode = previousCache.get(mem.id) const previousMemNode = previousCache.get(mem.id)
const memData: MemoryNodeData = { const memData: MemoryNodeData = {
...mem, ...mem,

View file

@ -1,6 +1,7 @@
// Components // Components
export { MemoryGraph } from "./components/memory-graph" export { MemoryGraph } from "./components/memory-graph"
export { GraphCanvas } from "./components/graph-canvas" export { GraphCanvas } from "./components/graph-canvas"
export { Minimap } from "./components/minimap"
// Hooks // Hooks
export { useGraphData } from "./hooks/use-graph-data" export { useGraphData } from "./hooks/use-graph-data"

View file

@ -214,6 +214,8 @@ export interface MemoryGraphProps {
maxNodes?: number maxNodes?: number
/** Show FPS counter overlay */ /** Show FPS counter overlay */
showFps?: boolean showFps?: boolean
/** Show a minimap overview with a draggable viewport indicator */
showMinimap?: boolean
/** Slideshow mode */ /** Slideshow mode */
isSlideshowActive?: boolean isSlideshowActive?: boolean
onSlideshowNodeChange?: (nodeId: string | null) => void onSlideshowNodeChange?: (nodeId: string | null) => void
@ -226,6 +228,8 @@ export interface MemoryGraphProps {
totalCount?: number totalCount?: number
/** Callback when user wants to view full document content */ /** Callback when user wants to view full document content */
onOpenDocument?: (documentId: string) => void 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 */ /** Custom user-facing labels (partial) - merged with defaults */
labels?: MemoryGraphLabels labels?: MemoryGraphLabels
/** Overlay layering controls */ /** Overlay layering controls */