mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-26 06:04:08 +00:00
Merge 5f3e8e1902 into f355dc8039
This commit is contained in:
commit
f8b58af25e
27 changed files with 2057 additions and 179 deletions
|
|
@ -25,11 +25,11 @@ npm install @supermemory/memory-graph
|
|||
'use client'; // For Next.js App Router
|
||||
|
||||
import { MemoryGraph } from '@supermemory/memory-graph';
|
||||
import type { DocumentWithMemories } from '@supermemory/memory-graph';
|
||||
import type { GraphApiDocument } from '@supermemory/memory-graph';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function GraphPage() {
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([]);
|
||||
const [documents, setDocuments] = useState<GraphApiDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
|
|
@ -76,15 +76,97 @@ Existing icon-based document nodes and connection styling remain the defaults. O
|
|||
|
||||
`openDocumentOnEnter` is off by default. With an `onOpenDocument` callback, `MemoryGraph` exposes a focused Enter action for the selected persisted document or one confirmed memory source. Typing, modifier shortcuts, repeats, composition and keys outside the graph do not open documents. Your callback retains responsibility for routing and access control; consumers without a callback receive no new opening behavior.
|
||||
|
||||
<Note>
|
||||
`documents` must be `GraphApiDocument[]`, the shape the API route below returns, with `documentType` and `memories` fields (not the raw API's `type` and `memoryEntries`).
|
||||
</Note>
|
||||
|
||||
## Backend API route
|
||||
|
||||
Create an API route to fetch documents from Supermemory:
|
||||
Create an API route to fetch documents from Supermemory. The `/v3/documents/documents` response uses `type` and `memoryEntries`; `<MemoryGraph>` expects `documentType` and `memories`, so the route normalizes the shape before returning it:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Next.js App Router
|
||||
// app/api/graph/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { GraphApiDocument, GraphApiMemory } from '@supermemory/memory-graph';
|
||||
|
||||
interface RawMemoryEntry {
|
||||
id: string;
|
||||
memory?: string | null;
|
||||
content?: string | null;
|
||||
spaceId?: string | null;
|
||||
isStatic?: boolean;
|
||||
isLatest?: boolean;
|
||||
isForgotten?: boolean;
|
||||
forgetAfter?: string | null;
|
||||
forgetReason?: string | null;
|
||||
version?: number;
|
||||
parentMemoryId?: string | null;
|
||||
rootMemoryId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
relation?: 'updates' | 'extends' | 'derives' | null;
|
||||
updatesMemoryId?: string | null;
|
||||
nextVersionId?: string | null;
|
||||
memoryRelations?: Record<string, 'updates' | 'extends' | 'derives'> | null;
|
||||
spaceContainerTag?: string | null;
|
||||
}
|
||||
|
||||
interface RawDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
summary?: string | null;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memoryEntries: RawMemoryEntry[];
|
||||
}
|
||||
|
||||
interface RawDocumentsResponse {
|
||||
documents: RawDocument[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Maps the raw API document (`type` / `memoryEntries`) onto the shape
|
||||
// <MemoryGraph> expects (`documentType` / `memories`), filling in the
|
||||
// defaults for fields the API may omit.
|
||||
function toGraphDocument(doc: RawDocument): GraphApiDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memoryEntries.map((mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? '',
|
||||
content: mem.content ?? null,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? '',
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const response = await fetch('https://api.supermemory.ai/v3/documents/documents', {
|
||||
|
|
@ -101,14 +183,95 @@ export async function GET() {
|
|||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
const data: RawDocumentsResponse = await response.json();
|
||||
return NextResponse.json({
|
||||
documents: data.documents.map(toGraphDocument),
|
||||
pagination: data.pagination,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```typescript Next.js Pages Router
|
||||
// pages/api/graph.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { GraphApiDocument, GraphApiMemory } from '@supermemory/memory-graph';
|
||||
|
||||
interface RawMemoryEntry {
|
||||
id: string;
|
||||
memory?: string | null;
|
||||
content?: string | null;
|
||||
spaceId?: string | null;
|
||||
isStatic?: boolean;
|
||||
isLatest?: boolean;
|
||||
isForgotten?: boolean;
|
||||
forgetAfter?: string | null;
|
||||
forgetReason?: string | null;
|
||||
version?: number;
|
||||
parentMemoryId?: string | null;
|
||||
rootMemoryId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
relation?: 'updates' | 'extends' | 'derives' | null;
|
||||
updatesMemoryId?: string | null;
|
||||
nextVersionId?: string | null;
|
||||
memoryRelations?: Record<string, 'updates' | 'extends' | 'derives'> | null;
|
||||
spaceContainerTag?: string | null;
|
||||
}
|
||||
|
||||
interface RawDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
summary?: string | null;
|
||||
type: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memoryEntries: RawMemoryEntry[];
|
||||
}
|
||||
|
||||
interface RawDocumentsResponse {
|
||||
documents: RawDocument[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Maps the raw API document (`type` / `memoryEntries`) onto the shape
|
||||
// <MemoryGraph> expects (`documentType` / `memories`), filling in the
|
||||
// defaults for fields the API may omit.
|
||||
function toGraphDocument(doc: RawDocument): GraphApiDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memoryEntries.map((mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? '',
|
||||
content: mem.content ?? null,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? '',
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const response = await fetch('https://api.supermemory.ai/v3/documents/documents', {
|
||||
|
|
@ -120,8 +283,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
body: JSON.stringify({ page: 1, limit: 500, sort: 'createdAt', order: 'desc' }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
const data: RawDocumentsResponse = await response.json();
|
||||
res.json({
|
||||
documents: data.documents.map(toGraphDocument),
|
||||
pagination: data.pagination,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -137,7 +303,41 @@ app.get('/api/graph', async (req, res) => {
|
|||
});
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
|
||||
// Map the raw API document (`type` / `memoryEntries`) onto the shape
|
||||
// <MemoryGraph> expects (`documentType` / `memories`), filling in the
|
||||
// defaults for fields the API may omit.
|
||||
const documents = data.documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memoryEntries.map((mem) => ({
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? '',
|
||||
content: mem.content ?? null,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? '',
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
res.json({ documents, pagination: data.pagination });
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -151,13 +351,13 @@ app.get('/api/graph', async (req, res) => {
|
|||
|
||||
## Variants
|
||||
|
||||
**Console Variant** - Full-featured dashboard view (0.8x zoom, space selector visible):
|
||||
**Console Variant** - Full-featured dashboard view (0.8x initial zoom):
|
||||
|
||||
```tsx
|
||||
<MemoryGraph documents={documents} variant="console" />
|
||||
```
|
||||
|
||||
**Consumer Variant** - Embedded widget view (0.5x zoom, space selector hidden):
|
||||
**Consumer Variant** - Embedded widget view (0.5x initial zoom):
|
||||
|
||||
```tsx
|
||||
<MemoryGraph documents={documents} variant="consumer" />
|
||||
|
|
@ -173,10 +373,11 @@ app.get('/api/graph', async (req, res) => {
|
|||
'use client';
|
||||
|
||||
import { MemoryGraph } from '@supermemory/memory-graph';
|
||||
import type { GraphApiDocument } from '@supermemory/memory-graph';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function PaginatedGraph() {
|
||||
const [documents, setDocuments] = useState([]);
|
||||
const [documents, setDocuments] = useState<GraphApiDocument[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -210,8 +411,8 @@ export default function PaginatedGraph() {
|
|||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
totalLoaded={documents.length}
|
||||
loadMoreDocuments={loadMore}
|
||||
totalCount={documents.length}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -227,17 +428,6 @@ export default function PaginatedGraph() {
|
|||
/>
|
||||
```
|
||||
|
||||
### Controlled space selection
|
||||
|
||||
```tsx
|
||||
<MemoryGraph
|
||||
documents={documents}
|
||||
selectedSpace={selectedSpace}
|
||||
onSpaceChange={setSelectedSpace}
|
||||
showSpacesSelector={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom empty state
|
||||
|
||||
```tsx
|
||||
|
|
@ -257,80 +447,132 @@ export default function PaginatedGraph() {
|
|||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `documents` | `DocumentWithMemories[]` | required | Array of documents to display |
|
||||
| `isLoading` | `boolean` | `false` | Shows loading indicator |
|
||||
| `error` | `Error \| null` | `null` | Error to display |
|
||||
| `documents` | `GraphApiDocument[]` | `[]` | Documents to display - pass this for direct data mode |
|
||||
| `isLoading` | `boolean` | `false` | Whether data is loading |
|
||||
| `error` | `Error \| null` | `null` | Error from data fetching |
|
||||
| `variant` | `"console" \| "consumer"` | `"console"` | Visual variant |
|
||||
| `children` | `ReactNode` | - | Custom empty state content |
|
||||
| `children` | `ReactNode` | - | Rendered when there are no documents |
|
||||
|
||||
### Pagination props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `isLoadingMore` | `boolean` | `false` | Shows indicator when loading more |
|
||||
| `hasMore` | `boolean` | `false` | Whether more documents available |
|
||||
| `totalLoaded` | `number` | - | Total documents currently loaded |
|
||||
| `loadMoreDocuments` | `() => Promise<void>` | - | Callback to load more |
|
||||
| `autoLoadOnViewport` | `boolean` | `true` | Auto-load when 80% visible |
|
||||
| `isLoadingMore` | `boolean` | `false` | Whether more data is being loaded |
|
||||
| `hasMore` | `boolean` | `false` | Whether there are more documents to load |
|
||||
| `onLoadMore` | `() => void` | - | Callback to load more documents |
|
||||
| `totalCount` | `number` | - | Total count, used by the loading indicator |
|
||||
|
||||
### Display props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `showSpacesSelector` | `boolean` | variant-based | Show space filter dropdown |
|
||||
| `highlightDocumentIds` | `string[]` | `[]` | Document IDs to highlight |
|
||||
| `highlightsVisible` | `boolean` | `true` | Whether highlights shown |
|
||||
| `occludedRightPx` | `number` | `0` | Pixels occluded on right |
|
||||
| `highlightsVisible` | `boolean` | `true` | Whether highlights are visible |
|
||||
| `showFps` | `boolean` | `false` | Show FPS counter overlay |
|
||||
| `canvasRef` | `RefObject<HTMLCanvasElement \| null>` | - | External ref to the canvas (e.g. for screenshot export) |
|
||||
| `colors` | `Partial<GraphThemeColors>` | - | Custom theme colors, merged with CSS-variable/default values |
|
||||
| `labels` | `MemoryGraphLabels` | - | Custom user-facing labels, merged with defaults |
|
||||
| `layering` | `MemoryGraphLayering` | - | Overlay layering controls (e.g. hover popover z-index) |
|
||||
| `appearance` | `GraphAppearance` | - | Opt-in `documentNodes: 'stacked'` and `connections: 'emphasized'` rendering |
|
||||
|
||||
### Controlled state props
|
||||
### Filtering props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `selectedSpace` | `string` | Currently selected space (use `"all"` for all) |
|
||||
| `onSpaceChange` | `(spaceId: string) => void` | Callback when space changes |
|
||||
| `memoryLimit` | `number` | Max memories per document when space selected |
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `maxNodes` | `number` | - | Caps the total document plus memory node count |
|
||||
|
||||
`containerTags`, `documentIds` and `legendId` are in `MemoryGraphProps` but `MemoryGraph` does not use them. Filter `documents` before you pass them in.
|
||||
|
||||
### Slideshow props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `isSlideshowActive` | `boolean` | - | Whether slideshow mode is active |
|
||||
| `onSlideshowNodeChange` | `(nodeId: string \| null) => void` | - | Called as the slideshow moves between nodes |
|
||||
| `onSlideshowStop` | `() => void` | - | Called when the slideshow ends |
|
||||
|
||||
### Other
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `onOpenDocument` | `(documentId: string) => void` | - | Called when the user wants to view a document's full content |
|
||||
| `openDocumentOnEnter` | `boolean` | `false` | Let Enter call `onOpenDocument` for the selected document |
|
||||
|
||||
---
|
||||
|
||||
## Data types
|
||||
|
||||
### DocumentWithMemories
|
||||
### GraphApiDocument / GraphApiMemory
|
||||
|
||||
This is the shape `documents` actually takes: `documentType` and `memories`, matching what the API route in [Backend API Route](#backend-api-route) normalizes into:
|
||||
|
||||
```typescript
|
||||
interface GraphApiDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
documentType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memories: GraphApiMemory[];
|
||||
}
|
||||
|
||||
interface GraphApiMemory {
|
||||
id: string;
|
||||
memory: string;
|
||||
content?: string | null;
|
||||
isStatic: boolean;
|
||||
spaceId: string;
|
||||
isLatest: boolean;
|
||||
isForgotten: boolean;
|
||||
forgetAfter: string | null;
|
||||
forgetReason: string | null;
|
||||
version: number;
|
||||
parentMemoryId: string | null;
|
||||
rootMemoryId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
relation?: 'updates' | 'extends' | 'derives' | null;
|
||||
updatesMemoryId?: string | null;
|
||||
nextVersionId?: string | null;
|
||||
memoryRelations?: Record<string, 'updates' | 'extends' | 'derives'> | null;
|
||||
spaceContainerTag?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### DocumentWithMemories / MemoryEntry
|
||||
|
||||
Also exported for backward compatibility with older API response shapes. Not what `documents` takes today, but still useful for typing a raw fetch response before mapping it to `GraphApiDocument`:
|
||||
|
||||
```typescript
|
||||
interface DocumentWithMemories {
|
||||
id: string;
|
||||
customId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
title: string | null;
|
||||
url: string | null;
|
||||
documentType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
summary?: string | null;
|
||||
url?: string | null;
|
||||
source?: string | null;
|
||||
type?: string | null;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed';
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
memoryEntries: MemoryEntry[];
|
||||
memories: MemoryEntry[];
|
||||
}
|
||||
```
|
||||
|
||||
### MemoryEntry
|
||||
|
||||
```typescript
|
||||
interface MemoryEntry {
|
||||
id: string;
|
||||
documentId: string;
|
||||
content: string | null;
|
||||
summary?: string | null;
|
||||
title?: string | null;
|
||||
type?: string | null;
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
spaceContainerTag?: string | null;
|
||||
relation?: 'updates' | 'extends' | 'derives' | null;
|
||||
isLatest?: boolean;
|
||||
memory: string;
|
||||
content?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
spaceId?: string | null;
|
||||
isStatic?: boolean;
|
||||
isForgotten?: boolean;
|
||||
forgetAfter?: string | null;
|
||||
forgetReason?: string | null;
|
||||
version?: number;
|
||||
parentMemoryId?: string | null;
|
||||
rootMemoryId?: string | null;
|
||||
isLatest?: boolean;
|
||||
relation?: 'updates' | 'extends' | 'derives' | null;
|
||||
spaceContainerTag?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -341,26 +583,52 @@ interface MemoryEntry {
|
|||
### Components
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MemoryGraph,
|
||||
GraphCanvas,
|
||||
Legend,
|
||||
LoadingIndicator,
|
||||
NodeDetailPanel,
|
||||
SpacesDropdown
|
||||
} from '@supermemory/memory-graph';
|
||||
import { MemoryGraph, GraphCanvas } from '@supermemory/memory-graph';
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
```typescript
|
||||
import { useGraphData, useGraphInteractions } from '@supermemory/memory-graph';
|
||||
import { useGraphData, useGraphTheme } from '@supermemory/memory-graph';
|
||||
```
|
||||
|
||||
### Engine classes
|
||||
|
||||
For advanced usage: driving the force simulation, viewport, or hit-testing directly:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ForceSimulation,
|
||||
ViewportState,
|
||||
SpatialIndex,
|
||||
VersionChainIndex,
|
||||
} from '@supermemory/memory-graph';
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```typescript
|
||||
import { colors, GRAPH_SETTINGS, LAYOUT_CONSTANTS } from '@supermemory/memory-graph';
|
||||
import {
|
||||
DEFAULT_COLORS,
|
||||
DEFAULT_HOVER_POPOVER_Z_INDEX,
|
||||
DEFAULT_LABELS,
|
||||
FORCE_CONFIG,
|
||||
GRAPH_SETTINGS,
|
||||
} from '@supermemory/memory-graph';
|
||||
```
|
||||
|
||||
### Mock data
|
||||
|
||||
`@supermemory/memory-graph/mock-data` generates fake `GraphApiDocument[]` data for local development and stress testing, without a live API key:
|
||||
|
||||
```typescript
|
||||
import { generateMockGraphData } from '@supermemory/memory-graph/mock-data';
|
||||
|
||||
const { documents } = generateMockGraphData({
|
||||
documentCount: 100,
|
||||
memoriesPerDoc: [2, 5],
|
||||
seed: 12345,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ Open [http://localhost:3000](http://localhost:3000) and enter your Supermemory A
|
|||
## Usage Example
|
||||
|
||||
```tsx
|
||||
import { MemoryGraph, type DocumentWithMemories } from '@supermemory/memory-graph'
|
||||
import { MemoryGraph, type GraphApiDocument } from '@supermemory/memory-graph'
|
||||
|
||||
function App() {
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
|
||||
const [documents, setDocuments] = useState<GraphApiDocument[]>([])
|
||||
|
||||
return (
|
||||
<MemoryGraph
|
||||
|
|
@ -26,10 +26,9 @@ function App() {
|
|||
isLoadingMore={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
loadMoreDocuments={() => {}}
|
||||
totalLoaded={documents.length}
|
||||
onLoadMore={() => {}}
|
||||
totalCount={documents.length}
|
||||
variant="console"
|
||||
showSpacesSelector={true}
|
||||
>
|
||||
<div>No memories found</div>
|
||||
</MemoryGraph>
|
||||
|
|
@ -37,16 +36,19 @@ function App() {
|
|||
}
|
||||
```
|
||||
|
||||
See [`src/app/page.tsx`](./src/app/page.tsx) for the full working example, including converting a raw API response into `GraphApiDocument[]`.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `documents` | `DocumentWithMemories[]` | Array of documents to display |
|
||||
| `documents` | `GraphApiDocument[]` | Array of documents to display |
|
||||
| `isLoading` | `boolean` | Initial loading state |
|
||||
| `isLoadingMore` | `boolean` | Loading more documents state |
|
||||
| `error` | `Error \| null` | Error to display |
|
||||
| `hasMore` | `boolean` | Whether more documents can be loaded |
|
||||
| `loadMoreDocuments` | `() => void` | Callback to load more documents |
|
||||
| `totalLoaded` | `number` | Total number of loaded documents |
|
||||
| `variant` | `"default" \| "console"` | Visual theme variant |
|
||||
| `showSpacesSelector` | `boolean` | Show space filter dropdown |
|
||||
| `onLoadMore` | `() => void` | Callback to load more documents |
|
||||
| `totalCount` | `number` | Total number of loaded documents |
|
||||
| `variant` | `"console" \| "consumer"` | Visual variant |
|
||||
|
||||
See [`packages/memory-graph`](../../packages/memory-graph/src/types.ts) for the full `MemoryGraphProps` reference.
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ function App() {
|
|||
| `error` | `Error \| null` | Error to display |
|
||||
| `loadMoreDocuments` | `() => Promise<void>` | Function to load more data |
|
||||
| `highlightDocumentIds` | `string[]` | IDs of documents to highlight |
|
||||
| `onNodeSelect` | `(nodeId: string \| null) => void` | Called when the selected node changes (click, keyboard, slideshow, or cleared) |
|
||||
| `showMinimap` | `boolean` | Show an overview minimap with a draggable viewport indicator (default: `false`) |
|
||||
|
||||
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -140,6 +140,24 @@ describe("cluster assignments", () => {
|
|||
|
||||
expect(assignments.get("a1")?.key).toBe(assignments.get("b1")?.key)
|
||||
})
|
||||
|
||||
it("merges a wide fan-out of memories relating to one hub into a single cluster", () => {
|
||||
const hub = makeDocument("doc-hub", [makeMemory({ id: "hub" })])
|
||||
const spokes = Array.from({ length: 200 }, (_, i) =>
|
||||
makeDocument(`doc-${i}`, [
|
||||
makeMemory({ id: `spoke-${i}`, memoryRelations: { hub: "derives" } }),
|
||||
]),
|
||||
)
|
||||
|
||||
const assignments = computeClusterAssignments([hub, ...spokes])
|
||||
|
||||
const hubKey = assignments.get("hub")?.key
|
||||
expect(hubKey).toBeDefined()
|
||||
for (let i = 0; i < spokes.length; i++) {
|
||||
expect(assignments.get(`spoke-${i}`)?.key).toBe(hubKey)
|
||||
}
|
||||
expect(assignments.get("hub")?.size).toBe(spokes.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory orbit placement", () => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,10 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { SpatialIndex } from "../canvas/hit-test"
|
||||
import { InputHandler } from "../canvas/input-handler"
|
||||
import { ForceSimulation } from "../canvas/simulation"
|
||||
import { ViewportState } from "../canvas/viewport"
|
||||
import type { GraphNode } from "../types"
|
||||
|
||||
/**
|
||||
* InputHandler tap-to-select on touch devices.
|
||||
*
|
||||
* onTouchStart calls preventDefault() (required to stop scroll/zoom of the
|
||||
* page), which also suppresses the browser's synthesized click event — so
|
||||
* node selection must be detected from the raw touch lifecycle. These tests
|
||||
* drive the handler through a stub canvas that records the listeners it
|
||||
* registers.
|
||||
*/
|
||||
|
||||
type Listener = (e: Event) => void
|
||||
|
||||
function makeStubCanvas() {
|
||||
|
|
@ -78,6 +69,12 @@ describe("InputHandler touch tap-to-select", () => {
|
|||
let clicks: Array<string | null>
|
||||
let viewport: ViewportState
|
||||
|
||||
let dragStarts: Array<{ id: string; node: GraphNode }>
|
||||
let dragEnds: number
|
||||
let testNode: GraphNode
|
||||
let handler: InputHandler
|
||||
let simulation: ForceSimulation | null
|
||||
|
||||
const fire = (name: string, e: TouchEvent) => {
|
||||
const fn = listeners.get(name)
|
||||
if (!fn) throw new Error(`no listener registered for ${name}`)
|
||||
|
|
@ -88,28 +85,115 @@ describe("InputHandler touch tap-to-select", () => {
|
|||
const stub = makeStubCanvas()
|
||||
listeners = stub.listeners
|
||||
clicks = []
|
||||
dragStarts = []
|
||||
dragEnds = 0
|
||||
simulation = null
|
||||
|
||||
// zoom 1 / pan 0 so screen coordinates equal world coordinates
|
||||
viewport = new ViewportState(0, 0, 1)
|
||||
const index = new SpatialIndex()
|
||||
index.rebuild([makeNode("doc-1", 100, 100)])
|
||||
testNode = makeNode("doc-1", 100, 100)
|
||||
index.rebuild([testNode])
|
||||
|
||||
new InputHandler(stub.canvas, viewport, index, {
|
||||
handler = new InputHandler(stub.canvas, viewport, index, {
|
||||
onNodeHover: () => {},
|
||||
onNodeClick: (id) => {
|
||||
clicks.push(id)
|
||||
},
|
||||
onNodeDragStart: () => {},
|
||||
onNodeDragEnd: () => {},
|
||||
onNodeDragStart: (id, node) => {
|
||||
dragStarts.push({ id, node })
|
||||
simulation?.reheat()
|
||||
},
|
||||
onNodeDragEnd: () => {
|
||||
dragEnds++
|
||||
simulation?.coolDown()
|
||||
},
|
||||
onRequestRender: () => {},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handler.destroy()
|
||||
simulation?.destroy()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("cancels a pending tap without selecting and permits a subsequent tap", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchcancel", touchEvent([]))
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(clicks).toEqual([])
|
||||
expect(dragEnds).toBe(0)
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("releases cancelled touch drags with reduced motion %s", (reduced) => {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: reduced }))
|
||||
simulation = new ForceSimulation()
|
||||
simulation.init([testNode], [])
|
||||
simulation.stop()
|
||||
testNode.x = 100
|
||||
testNode.y = 100
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(160, 120)]))
|
||||
expect(testNode.fx).toBe(160)
|
||||
expect(simulation.isActive()).toBe(!reduced)
|
||||
fire("touchcancel", touchEvent([]))
|
||||
expect(testNode.fx).toBeNull()
|
||||
expect(testNode.fy).toBeNull()
|
||||
expect(handler.getDraggingNode()).toBeNull()
|
||||
expect(simulation.isActive()).toBe(false)
|
||||
expect(dragEnds).toBe(1)
|
||||
fire("touchcancel", touchEvent([]))
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(dragEnds).toBe(1)
|
||||
expect(clicks).toEqual([])
|
||||
})
|
||||
|
||||
it("clears cancelled pan and pinch state without selecting", () => {
|
||||
fire("touchstart", touchEvent([touch(400, 400)]))
|
||||
fire("touchmove", touchEvent([touch(460, 400)]))
|
||||
fire("touchcancel", touchEvent([]))
|
||||
fire("touchmove", touchEvent([touch(500, 400)]))
|
||||
expect(viewport.panX).toBe(60)
|
||||
fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)]))
|
||||
fire("touchcancel", touchEvent([]))
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(clicks).toEqual([])
|
||||
})
|
||||
|
||||
it("releases a touch drag on pinch and removes cancellation listeners on destroy", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(160, 120)]))
|
||||
fire("touchstart", touchEvent([touch(160, 120), touch(240, 200)]))
|
||||
expect(testNode.fx).toBeNull()
|
||||
expect(dragEnds).toBe(1)
|
||||
handler.destroy()
|
||||
expect(listeners.has("touchcancel")).toBe(false)
|
||||
expect(listeners.size).toBe(0)
|
||||
expect(clicks).toEqual([])
|
||||
})
|
||||
|
||||
it("increments the position revision only when a drag moves the node", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(160, 120)]))
|
||||
expect(viewport.nodePositionVersion).toBe(1)
|
||||
fire("touchmove", touchEvent([touch(160, 120)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(viewport.nodePositionVersion).toBe(1)
|
||||
})
|
||||
|
||||
it("selects a node on a single tap", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
expect(dragStarts).toEqual([])
|
||||
expect(testNode.fx).toBeUndefined()
|
||||
})
|
||||
|
||||
it("clears selection when tapping empty space", () => {
|
||||
|
|
@ -127,9 +211,27 @@ describe("InputHandler touch tap-to-select", () => {
|
|||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
it("does not fire a click after a pan", () => {
|
||||
it("drags a node on touch and updates its fixed coordinates", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(160, 100)]))
|
||||
expect(dragStarts).toEqual([])
|
||||
|
||||
fire("touchmove", touchEvent([touch(160, 120)]))
|
||||
expect(dragStarts.map((d) => d.id)).toEqual(["doc-1"])
|
||||
expect(testNode.x).toBe(160)
|
||||
expect(testNode.y).toBe(120)
|
||||
expect(testNode.fx).toBe(160)
|
||||
expect(testNode.fy).toBe(120)
|
||||
|
||||
fire("touchend", touchEvent([]))
|
||||
expect(dragEnds).toBe(1)
|
||||
expect(testNode.fx).toBeNull()
|
||||
expect(testNode.fy).toBeNull()
|
||||
expect(clicks).toEqual([])
|
||||
})
|
||||
|
||||
it("pans the viewport when dragging empty space", () => {
|
||||
fire("touchstart", touchEvent([touch(400, 400)]))
|
||||
fire("touchmove", touchEvent([touch(460, 400)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual([])
|
||||
|
|
@ -149,18 +251,13 @@ describe("InputHandler touch tap-to-select", () => {
|
|||
|
||||
it("hit-tests a jittery tap against the node under the finger at touchstart", () => {
|
||||
// Zoomed out, a few-pixel finger jitter maps to a large world-space shift.
|
||||
// The sub-threshold move still pans the viewport, so re-projecting the
|
||||
// start screen point through the panned transform lands well off the node.
|
||||
// zoom 0.25: world (100, 100) renders at screen (25, 25).
|
||||
viewport.zoomImmediate(0.25, 0, 0)
|
||||
|
||||
fire("touchstart", touchEvent([touch(25, 25)]))
|
||||
// 8px screen jitter (below the 10px tap threshold) that pans the viewport
|
||||
fire("touchmove", touchEvent([touch(33, 25)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
// the jitter did move the viewport, but the tap still resolves the node
|
||||
expect(viewport.panX).toBe(8)
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
|
|
|
|||
193
packages/memory-graph/src/__tests__/minimap-interaction.test.tsx
Normal file
193
packages/memory-graph/src/__tests__/minimap-interaction.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
132
packages/memory-graph/src/__tests__/minimap.test.ts
Normal file
132
packages/memory-graph/src/__tests__/minimap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
202
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal file
202
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -54,6 +54,33 @@ describe("SpatialIndex", () => {
|
|||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("hash guard keeps stale objects for a new generation at the same positions", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const genA = [makeNode("a", 100, 100)]
|
||||
idx.rebuild(genA)
|
||||
|
||||
const genB = [makeNode("a", 100, 100)]
|
||||
expect(idx.rebuild(genB)).toBe(false)
|
||||
expect(idx.queryPoint(100, 100)).toBe(genA[0])
|
||||
})
|
||||
|
||||
it("force rebuild swaps the grid to the new node generation", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const genA = [makeNode("a", 100, 100)]
|
||||
idx.rebuild(genA)
|
||||
|
||||
const genB = [makeNode("a", 100, 100)]
|
||||
expect(idx.rebuild(genB, true)).toBe(true)
|
||||
expect(idx.queryPoint(100, 100)).toBe(genB[0])
|
||||
})
|
||||
|
||||
it("force rebuild still refreshes the hash for subsequent unforced calls", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [makeNode("a", 100, 100)]
|
||||
idx.rebuild(nodes, true)
|
||||
expect(idx.rebuild(nodes)).toBe(false)
|
||||
})
|
||||
|
||||
it("rebuild detects sub-pixel movements (10x granularity)", () => {
|
||||
const idx = new SpatialIndex()
|
||||
const nodes = [makeNode("a", 100.0, 100.0)]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ export class SpatialIndex {
|
|||
private cellSize = 200
|
||||
private lastHash = 0
|
||||
|
||||
rebuild(nodes: GraphNode[]): boolean {
|
||||
// Hash only covers ids/positions, so pass force when the node array identity changes.
|
||||
rebuild(nodes: GraphNode[], force = false): boolean {
|
||||
const hash = this.computeHash(nodes)
|
||||
if (hash === this.lastHash) return false
|
||||
if (!force && hash === this.lastHash) return false
|
||||
this.lastHash = hash
|
||||
this.grid.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export class InputHandler {
|
|||
private boundTouchStart: (e: TouchEvent) => void
|
||||
private boundTouchMove: (e: TouchEvent) => void
|
||||
private boundTouchEnd: (e: TouchEvent) => void
|
||||
private boundTouchCancel: () => void
|
||||
private boundGesture: (e: Event) => void
|
||||
|
||||
constructor(
|
||||
|
|
@ -84,6 +85,7 @@ export class InputHandler {
|
|||
this.boundTouchStart = this.onTouchStart.bind(this)
|
||||
this.boundTouchMove = this.onTouchMove.bind(this)
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this)
|
||||
this.boundTouchCancel = this.onTouchCancel.bind(this)
|
||||
this.boundGesture = (e: Event) => e.preventDefault()
|
||||
|
||||
canvas.addEventListener("mousedown", this.boundMouseDown)
|
||||
|
|
@ -98,6 +100,7 @@ export class InputHandler {
|
|||
passive: false,
|
||||
})
|
||||
canvas.addEventListener("touchend", this.boundTouchEnd)
|
||||
canvas.addEventListener("touchcancel", this.boundTouchCancel)
|
||||
canvas.addEventListener("gesturestart", this.boundGesture, {
|
||||
passive: false,
|
||||
})
|
||||
|
|
@ -118,6 +121,7 @@ export class InputHandler {
|
|||
c.removeEventListener("touchstart", this.boundTouchStart)
|
||||
c.removeEventListener("touchmove", this.boundTouchMove)
|
||||
c.removeEventListener("touchend", this.boundTouchEnd)
|
||||
c.removeEventListener("touchcancel", this.boundTouchCancel)
|
||||
c.removeEventListener("gesturestart", this.boundGesture)
|
||||
c.removeEventListener("gesturechange", this.boundGesture)
|
||||
c.removeEventListener("gestureend", this.boundGesture)
|
||||
|
|
@ -188,9 +192,7 @@ export class InputHandler {
|
|||
}
|
||||
|
||||
if (this.draggingNode) {
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x
|
||||
this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y
|
||||
this.moveDraggingNode(x, y)
|
||||
this.didDrag = true
|
||||
this.callbacks.onRequestRender()
|
||||
return
|
||||
|
|
@ -300,6 +302,7 @@ export class InputHandler {
|
|||
if (touches.length >= 2) {
|
||||
this.isTouchGesture = true
|
||||
this.tapCandidate = false
|
||||
this.endTouchDrag()
|
||||
const t0 = touches[0]
|
||||
const t1 = touches[1]
|
||||
if (!t0 || !t1) return
|
||||
|
|
@ -326,7 +329,13 @@ export class InputHandler {
|
|||
this.touchStartWorldX = startWorld.x
|
||||
this.touchStartWorldY = startWorld.y
|
||||
this.tapCandidate = true
|
||||
this.isPanning = true
|
||||
const node = this.spatialIndex.queryPoint(startWorld.x, startWorld.y)
|
||||
if (node) {
|
||||
this.pressedNode = node
|
||||
this.grabOffset = { x: startWorld.x - node.x, y: startWorld.y - node.y }
|
||||
} else {
|
||||
this.isPanning = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,12 +366,7 @@ export class InputHandler {
|
|||
this.lastTouchDistance = dist
|
||||
this.lastTouchCenter = center
|
||||
this.callbacks.onRequestRender()
|
||||
} else if (
|
||||
touches.length === 1 &&
|
||||
this.isPanning &&
|
||||
!this.isTouchGesture &&
|
||||
touches[0]
|
||||
) {
|
||||
} else if (touches.length === 1 && !this.isTouchGesture && touches[0]) {
|
||||
const t = touches[0]
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const x = t.clientX - rect.left
|
||||
|
|
@ -374,19 +378,60 @@ export class InputHandler {
|
|||
) {
|
||||
this.tapCandidate = false
|
||||
}
|
||||
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
this.callbacks.onRequestRender()
|
||||
if (this.pressedNode && !this.tapCandidate) {
|
||||
this.draggingNode = this.pressedNode
|
||||
this.pressedNode = null
|
||||
this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode)
|
||||
}
|
||||
if (this.draggingNode) {
|
||||
this.moveDraggingNode(x, y)
|
||||
this.callbacks.onRequestRender()
|
||||
} else if (this.isPanning) {
|
||||
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private moveDraggingNode(x: number, y: number): void {
|
||||
const node = this.draggingNode
|
||||
if (!node) return
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
const nextX = world.x - this.grabOffset.x
|
||||
const nextY = world.y - this.grabOffset.y
|
||||
if (node.x !== nextX || node.y !== nextY) {
|
||||
this.viewport.nodePositionVersion++
|
||||
}
|
||||
node.x = node.fx = nextX
|
||||
node.y = node.fy = nextY
|
||||
}
|
||||
|
||||
private onTouchCancel(): void {
|
||||
this.tapCandidate = false
|
||||
this.isTouchGesture = false
|
||||
this.isPanning = false
|
||||
this.endTouchDrag()
|
||||
}
|
||||
|
||||
private endTouchDrag(): void {
|
||||
this.pressedNode = null
|
||||
if (!this.draggingNode) return
|
||||
this.draggingNode.fx = null
|
||||
this.draggingNode.fy = null
|
||||
this.draggingNode = null
|
||||
this.callbacks.onNodeDragEnd()
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
|
||||
private onTouchEnd(e: TouchEvent): void {
|
||||
if (e.touches.length < 2) {
|
||||
this.isTouchGesture = false
|
||||
}
|
||||
if (e.touches.length === 0) {
|
||||
this.isPanning = false
|
||||
this.endTouchDrag()
|
||||
if (this.tapCandidate) {
|
||||
this.tapCandidate = false
|
||||
// Use the world point captured at touchstart, not the start screen
|
||||
|
|
|
|||
108
packages/memory-graph/src/canvas/minimap.ts
Normal file
108
packages/memory-graph/src/canvas/minimap.ts
Normal 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
|
||||
}
|
||||
13
packages/memory-graph/src/canvas/reduced-motion.ts
Normal file
13
packages/memory-graph/src/canvas/reduced-motion.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,17 +1,31 @@
|
|||
import * as d3 from "d3-force"
|
||||
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
|
||||
import { FORCE_CONFIG } from "../constants"
|
||||
import { getReducedMotionQuery, prefersReducedMotion } from "./reduced-motion"
|
||||
|
||||
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
|
||||
|
||||
export class ForceSimulation {
|
||||
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
|
||||
private holdingHeat = false
|
||||
private reducedMotionQuery: MediaQueryList | null = null
|
||||
private readonly onReducedMotionChange = (event: MediaQueryListEvent) => {
|
||||
if (event.matches) this.stop()
|
||||
}
|
||||
|
||||
init(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
this.destroy()
|
||||
|
||||
try {
|
||||
this.reducedMotionQuery = getReducedMotionQuery()
|
||||
if (this.reducedMotionQuery?.addEventListener) {
|
||||
this.reducedMotionQuery.addEventListener(
|
||||
"change",
|
||||
this.onReducedMotionChange,
|
||||
)
|
||||
} else {
|
||||
this.reducedMotionQuery?.addListener?.(this.onReducedMotionChange)
|
||||
}
|
||||
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
|
||||
|
||||
this.sim = d3
|
||||
|
|
@ -67,7 +81,10 @@ export class ForceSimulation {
|
|||
: FORCE_CONFIG.preSettleTicks
|
||||
for (let i = 0; i < preSettleTicks; i++) this.sim.tick()
|
||||
|
||||
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
|
||||
if (
|
||||
nodes.length > DENSE_GRAPH_STATIC_THRESHOLD ||
|
||||
prefersReducedMotion()
|
||||
) {
|
||||
this.stop()
|
||||
} else {
|
||||
this.settle()
|
||||
|
|
@ -87,6 +104,7 @@ export class ForceSimulation {
|
|||
}
|
||||
|
||||
reheat(): void {
|
||||
if (prefersReducedMotion()) return
|
||||
this.holdingHeat = true
|
||||
this.sim?.on("tick.settle", null)
|
||||
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
|
||||
|
|
@ -94,7 +112,7 @@ export class ForceSimulation {
|
|||
|
||||
settle(): void {
|
||||
const sim = this.sim
|
||||
if (!sim || this.holdingHeat) return
|
||||
if (!sim || this.holdingHeat || prefersReducedMotion()) return
|
||||
let ticks = 0
|
||||
let stableTicks = 0
|
||||
sim
|
||||
|
|
@ -143,6 +161,15 @@ export class ForceSimulation {
|
|||
|
||||
destroy(): void {
|
||||
this.holdingHeat = false
|
||||
if (this.reducedMotionQuery?.removeEventListener) {
|
||||
this.reducedMotionQuery.removeEventListener(
|
||||
"change",
|
||||
this.onReducedMotionChange,
|
||||
)
|
||||
} else {
|
||||
this.reducedMotionQuery?.removeListener?.(this.onReducedMotionChange)
|
||||
}
|
||||
this.reducedMotionQuery = null
|
||||
if (this.sim) {
|
||||
this.sim.stop()
|
||||
this.sim = null
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { getReducedMotionQuery } from "./reduced-motion"
|
||||
|
||||
export class ViewportState {
|
||||
panX: number
|
||||
panY: number
|
||||
zoom: number
|
||||
nodePositionVersion = 0
|
||||
|
||||
private velocityX = 0
|
||||
private velocityY = 0
|
||||
|
|
@ -21,6 +24,12 @@ export class ViewportState {
|
|||
private static readonly MAX_ZOOM = 5.0
|
||||
private minZoom = ViewportState.DEFAULT_MIN_ZOOM
|
||||
|
||||
private readonly reducedMotionQuery = getReducedMotionQuery()
|
||||
|
||||
private get reducedMotion(): boolean {
|
||||
return this.reducedMotionQuery?.matches ?? false
|
||||
}
|
||||
|
||||
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
|
||||
this.panX = initialPanX
|
||||
this.panY = initialPanY
|
||||
|
|
@ -59,6 +68,7 @@ export class ViewportState {
|
|||
}
|
||||
|
||||
releaseWithVelocity(vx: number, vy: number): void {
|
||||
if (this.reducedMotion) return
|
||||
this.velocityX = vx
|
||||
this.velocityY = vy
|
||||
}
|
||||
|
|
@ -133,7 +143,12 @@ export class ViewportState {
|
|||
}
|
||||
|
||||
tick(): boolean {
|
||||
const reduced = this.reducedMotion
|
||||
let moving = false
|
||||
if (reduced) {
|
||||
this.velocityX = 0
|
||||
this.velocityY = 0
|
||||
}
|
||||
|
||||
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
|
||||
this.panX += this.velocityX
|
||||
|
|
@ -149,7 +164,7 @@ export class ViewportState {
|
|||
const zoomDiff = this.targetZoom - this.zoom
|
||||
if (Math.abs(zoomDiff) > this.zoom * 0.001) {
|
||||
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
|
||||
this.zoom += zoomDiff * this.zoomSpring
|
||||
this.zoom += reduced ? zoomDiff : zoomDiff * this.zoomSpring
|
||||
this.panX = this.zoomAnchorX - world.x * this.zoom
|
||||
this.panY = this.zoomAnchorY - world.y * this.zoom
|
||||
moving = true
|
||||
|
|
@ -158,11 +173,17 @@ export class ViewportState {
|
|||
if (this.targetPanX !== null && this.targetPanY !== null) {
|
||||
const dx = this.targetPanX - this.panX
|
||||
const dy = this.targetPanY - this.panY
|
||||
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
||||
if (!reduced && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
|
||||
this.panX += dx * this.panLerp
|
||||
this.panY += dy * this.panLerp
|
||||
moving = true
|
||||
} else {
|
||||
if (
|
||||
reduced &&
|
||||
(this.panX !== this.targetPanX || this.panY !== this.targetPanY)
|
||||
) {
|
||||
moving = true
|
||||
}
|
||||
this.panX = this.targetPanX
|
||||
this.panY = this.targetPanY
|
||||
this.targetPanX = null
|
||||
|
|
|
|||
|
|
@ -97,13 +97,13 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
simulation,
|
||||
}
|
||||
|
||||
// Rebuild nodeMap + spatial index when nodes change
|
||||
// Force the spatial rebuild: a new node generation can keep the same ids/positions
|
||||
useEffect(() => {
|
||||
const map = nodeMapRef.current
|
||||
map.clear()
|
||||
for (const n of nodes) map.set(n.id, n)
|
||||
inputRef.current?.syncNodes(map)
|
||||
spatialRef.current.rebuild(nodes)
|
||||
spatialRef.current.rebuild(nodes, true)
|
||||
renderNeeded.current = true
|
||||
}, [nodes])
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ function StatRow({
|
|||
onClick={expandable ? onToggle : undefined}
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
>
|
||||
<div style={leftStyle}>
|
||||
{icon}
|
||||
|
|
@ -430,6 +431,7 @@ export const Legend = memo(function Legend({
|
|||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={headerBtnStyle}
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon color={colors.textPrimary} />
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export const LoadingIndicator = memo<
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<output style={containerStyle} aria-live="polite">
|
||||
<div style={flexStyle}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
|
@ -96,7 +96,7 @@ export const LoadingIndicator = memo<
|
|||
: labels.loadingMoreDocuments(totalLoaded)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</output>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import type {
|
|||
GraphApiDocument,
|
||||
GraphNode,
|
||||
MemoryNodeData,
|
||||
GraphThemeColors,
|
||||
MemoryGraphProps,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
|
|
@ -29,6 +28,7 @@ import { Legend } from "./legend"
|
|||
import { LoadingIndicator } from "./loading-indicator"
|
||||
import { NavigationControls } from "./navigation-controls"
|
||||
import { NodeHoverPopover } from "./node-hover-popover"
|
||||
import { Minimap } from "./minimap"
|
||||
|
||||
function documentIdForNode(
|
||||
node: GraphNode | null,
|
||||
|
|
@ -67,6 +67,7 @@ export function MemoryGraph({
|
|||
highlightDocumentIds = [],
|
||||
highlightsVisible = true,
|
||||
showFps = false,
|
||||
showMinimap = false,
|
||||
maxNodes,
|
||||
isSlideshowActive = false,
|
||||
onSlideshowNodeChange,
|
||||
|
|
@ -74,6 +75,7 @@ export function MemoryGraph({
|
|||
colors: colorOverrides,
|
||||
totalCount,
|
||||
onOpenDocument: externalOnOpenDocument,
|
||||
onNodeSelect,
|
||||
labels: labelOverrides,
|
||||
layering,
|
||||
appearance,
|
||||
|
|
@ -85,14 +87,7 @@ export function MemoryGraph({
|
|||
)
|
||||
const hoverPopoverZIndex =
|
||||
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
|
||||
const resolvedColors = useGraphTheme(colorOverrides)
|
||||
const colors = useMemo<GraphThemeColors>(
|
||||
() =>
|
||||
colorOverrides
|
||||
? { ...resolvedColors, ...colorOverrides }
|
||||
: resolvedColors,
|
||||
[resolvedColors, colorOverrides],
|
||||
)
|
||||
const colors = useGraphTheme(colorOverrides)
|
||||
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
|
||||
|
|
@ -105,42 +100,51 @@ export function MemoryGraph({
|
|||
// React state only for things that affect DOM
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const onNodeSelectRef = useRef(onNodeSelect)
|
||||
onNodeSelectRef.current = onNodeSelect
|
||||
const lastSelectedRef = useRef(selectedNode)
|
||||
// Compares against the last value so mount and StrictMode re-runs do not emit.
|
||||
useEffect(() => {
|
||||
if (lastSelectedRef.current === selectedNode) return
|
||||
lastSelectedRef.current = selectedNode
|
||||
onNodeSelectRef.current?.(selectedNode)
|
||||
}, [selectedNode])
|
||||
const [zoomDisplay, setZoomDisplay] = useState(50)
|
||||
// Monotonic counter that increments on any viewport change (pan or zoom)
|
||||
// Used as a dependency proxy to recalculate popover positions
|
||||
const [viewportVersion, setViewportVersion] = useState(0)
|
||||
|
||||
// Limit documents so total node count (documents + their memories) stays under maxNodes
|
||||
// Cap documents + unique memories at maxNodes; shared memories count once.
|
||||
const limitedDocuments = useMemo(() => {
|
||||
if (!maxNodes || documents.length === 0) return documents
|
||||
let totalNodes = 0
|
||||
const seenMemoryIds = new Set<string>()
|
||||
const limited: GraphApiDocument[] = []
|
||||
for (let i = 0; i < documents.length; i++) {
|
||||
const doc = documents[i]
|
||||
if (!doc) continue
|
||||
for (const doc of documents) {
|
||||
if (totalNodes >= maxNodes) break
|
||||
|
||||
const remainingNodes = maxNodes - totalNodes
|
||||
const memories = doc.memories ?? []
|
||||
const docNodes = 1 + memories.length
|
||||
|
||||
if (docNodes <= remainingNodes) {
|
||||
limited.push(doc)
|
||||
totalNodes += docNodes
|
||||
continue
|
||||
}
|
||||
|
||||
if (remainingNodes > 1) {
|
||||
limited.push({
|
||||
...doc,
|
||||
memories: memories.slice(0, remainingNodes - 1),
|
||||
})
|
||||
totalNodes = maxNodes
|
||||
break
|
||||
}
|
||||
|
||||
limited.push({ ...doc, memories: [] })
|
||||
const includedMemories: GraphApiDocument["memories"] = []
|
||||
totalNodes += 1
|
||||
|
||||
for (const memory of memories) {
|
||||
if (seenMemoryIds.has(memory.id)) {
|
||||
includedMemories.push(memory)
|
||||
continue
|
||||
}
|
||||
|
||||
if (totalNodes >= maxNodes) continue
|
||||
|
||||
seenMemoryIds.add(memory.id)
|
||||
includedMemories.push(memory)
|
||||
totalNodes += 1
|
||||
}
|
||||
|
||||
limited.push(
|
||||
includedMemories.length === memories.length
|
||||
? doc
|
||||
: { ...doc, memories: includedMemories },
|
||||
)
|
||||
}
|
||||
return limited
|
||||
}, [documents, maxNodes])
|
||||
|
|
@ -937,6 +941,30 @@ export function MemoryGraph({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMinimap &&
|
||||
!isCompactViewport &&
|
||||
containerSize.width > 0 &&
|
||||
nodes.length > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<Minimap
|
||||
nodes={nodes}
|
||||
colors={colors}
|
||||
viewportRef={viewportRef}
|
||||
canvasWidth={containerSize.width}
|
||||
canvasHeight={containerSize.height}
|
||||
simulation={simulation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
195
packages/memory-graph/src/components/minimap.tsx
Normal file
195
packages/memory-graph/src/components/minimap.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,7 +176,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>−</span>
|
||||
<span aria-hidden="true" style={{ fontSize: 12 }}>
|
||||
−
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Zoom in"
|
||||
|
|
@ -190,7 +192,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>+</span>
|
||||
<span aria-hidden="true" style={{ fontSize: 12 }}>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -157,10 +157,11 @@ export function computeClusterAssignments(
|
|||
|
||||
const component: string[] = []
|
||||
const queue = [startId]
|
||||
let head = 0
|
||||
visited.add(startId)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift() as string
|
||||
while (head < queue.length) {
|
||||
const id = queue[head++] as string
|
||||
component.push(id)
|
||||
for (const nextId of adjacency.get(id) ?? []) {
|
||||
if (visited.has(nextId)) continue
|
||||
|
|
@ -400,10 +401,6 @@ function getAppendSpatialKey(x: number, y: number): string {
|
|||
return `${x}:${y}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes graph edges from documents.
|
||||
* Extracted from the hook for testability.
|
||||
*/
|
||||
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
|
|
@ -414,8 +411,16 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
|||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
const documentTargets = new Map<string, Set<string>>()
|
||||
for (const doc of documents) {
|
||||
let targets = documentTargets.get(doc.id)
|
||||
for (const mem of doc.memories) {
|
||||
if (targets?.has(mem.id)) continue
|
||||
if (!targets) {
|
||||
targets = new Set<string>()
|
||||
documentTargets.set(doc.id, targets)
|
||||
}
|
||||
targets.add(mem.id)
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
|
|
@ -426,15 +431,20 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
const relationSources = new Map<string, Set<string>>()
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
const relations = getMemoryRelationTargets(mem)
|
||||
let sources = relationSources.get(mem.id)
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
if (sources?.has(targetId)) continue
|
||||
if (!sources) {
|
||||
sources = new Set<string>()
|
||||
relationSources.set(mem.id, sources)
|
||||
}
|
||||
sources.add(targetId)
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
|
|
@ -496,6 +506,7 @@ export function useGraphData(
|
|||
const clusterAssignments = computeClusterAssignments(documents)
|
||||
|
||||
const result: GraphNode[] = []
|
||||
const seenMemoryIds = new Set<string>()
|
||||
// Spiral layout: documents form a compact spiral core, memories orbit
|
||||
// around their parent documents. The force simulation then gently
|
||||
// pushes memories outward to create the constellation/starburst effect.
|
||||
|
|
@ -576,6 +587,8 @@ export function useGraphData(
|
|||
for (let i = 0; i < memCount; i++) {
|
||||
const mem = doc.memories[i]
|
||||
if (!mem) continue
|
||||
if (seenMemoryIds.has(mem.id)) continue
|
||||
seenMemoryIds.add(mem.id)
|
||||
const previousMemNode = previousCache.get(mem.id)
|
||||
const memData: MemoryNodeData = {
|
||||
...mem,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Components
|
||||
export { MemoryGraph } from "./components/memory-graph"
|
||||
export { GraphCanvas } from "./components/graph-canvas"
|
||||
export { Minimap } from "./components/minimap"
|
||||
|
||||
// Hooks
|
||||
export { useGraphData } from "./hooks/use-graph-data"
|
||||
|
|
|
|||
|
|
@ -214,6 +214,8 @@ export interface MemoryGraphProps {
|
|||
maxNodes?: number
|
||||
/** Show FPS counter overlay */
|
||||
showFps?: boolean
|
||||
/** Show a minimap overview with a draggable viewport indicator */
|
||||
showMinimap?: boolean
|
||||
/** Slideshow mode */
|
||||
isSlideshowActive?: boolean
|
||||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
|
|
@ -226,6 +228,8 @@ export interface MemoryGraphProps {
|
|||
totalCount?: number
|
||||
/** Callback when user wants to view full document content */
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
/** Called when the selected node changes; null when cleared */
|
||||
onNodeSelect?: (nodeId: string | null) => void
|
||||
/** Custom user-facing labels (partial) - merged with defaults */
|
||||
labels?: MemoryGraphLabels
|
||||
/** Overlay layering controls */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue