diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index 0e9f5d67..5a6bafba 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -45,6 +45,7 @@ export const GraphLayoutView = memo(function GraphLayoutView({ highlightsVisible canvasRef={canvasRef} onOpenDocument={onOpenDocument} + showMinimap /> diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx index 0757b0c2..acf28e51 100644 --- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -21,6 +21,7 @@ export interface MemoryGraphWrapperProps { onSlideshowStop?: () => void canvasRef?: React.RefObject onOpenDocument?: (documentId: string) => void + showMinimap?: boolean } export function MemoryGraph({ diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 83710f5a..6557d06e 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -66,6 +66,7 @@ function App() { | `error` | `Error \| null` | Error to display | | `loadMoreDocuments` | `() => Promise` | Function to load more data | | `highlightDocumentIds` | `string[]` | IDs of documents to highlight | +| `showMinimap` | `boolean` | Show an overview minimap with a draggable viewport indicator (default: `false`) | ## Documentation diff --git a/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx new file mode 100644 index 00000000..9b220683 --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx @@ -0,0 +1,102 @@ +/** + * Mounted-render verification for the Minimap: it should render a canvas and + * survive an environment where the 2D context is unavailable (happy-dom). + */ + +// @vitest-environment happy-dom + +import { createRef } from "react" +import { cleanup, render } from "@testing-library/react" +import { afterEach, describe, expect, it } from "vitest" + +afterEach(cleanup) + +import { Minimap } from "../components/minimap" +import { ViewportState } from "../canvas/viewport" +import { DEFAULT_COLORS } from "../constants" +import type { GraphNode } from "../types" + +const nodes: GraphNode[] = [ + { + id: "doc-1", + type: "document", + x: 0, + y: 0, + data: { + id: "doc-1", + title: "Doc", + summary: "", + type: "", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + size: 40, + borderColor: "#58C7E8", + clusterColor: "#58C7E8", + isHovered: false, + isDragging: false, + }, + { + id: "mem-1", + type: "memory", + x: 120, + y: 80, + data: { + id: "mem-1", + content: "Memory", + documentId: "doc-1", + memory: "Memory", + isForgotten: false, + isLatest: true, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + } as GraphNode["data"], + size: 24, + borderColor: "#74D680", + clusterColor: "#74D680", + isHovered: false, + isDragging: false, + }, +] + +describe("Minimap render", () => { + it("renders a canvas without throwing", () => { + const viewportRef = createRef() + ;(viewportRef as { current: ViewportState | null }).current = + new ViewportState() + + const { container } = render( + , + ) + + const canvas = container.querySelector("canvas") + expect(canvas).not.toBeNull() + }) + + it("renders nothing visible but does not crash with no nodes", () => { + const viewportRef = createRef() + ;(viewportRef as { current: ViewportState | null }).current = + new ViewportState() + + const { container } = render( + , + ) + + expect(container.querySelector("canvas")).not.toBeNull() + }) +}) diff --git a/packages/memory-graph/src/__tests__/minimap.test.ts b/packages/memory-graph/src/__tests__/minimap.test.ts new file mode 100644 index 00000000..2545ccac --- /dev/null +++ b/packages/memory-graph/src/__tests__/minimap.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest" +import { + clampToRange, + computeMinimapLayout, + computeViewportRect, + minimapToWorld, + worldToMinimap, +} from "../canvas/minimap" + +describe("computeMinimapLayout", () => { + it("fits a square world into a padded box and centers it", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + 120, + 120, + 10, + ) + // avail = 100, world = 100 => scale 1 + expect(layout.scale).toBeCloseTo(1) + // corners land on the padding edge + expect(worldToMinimap(0, 0, layout)).toEqual({ x: 10, y: 10 }) + expect(worldToMinimap(100, 100, layout)).toEqual({ x: 110, y: 110 }) + }) + + it("preserves aspect ratio for a wide world and centers the short axis", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 200, maxY: 100 }, + 120, + 120, + 10, + ) + // avail 100 x 100; width is the binding dimension => scale 0.5 + expect(layout.scale).toBeCloseTo(0.5) + const topLeft = worldToMinimap(0, 0, layout) + const bottomRight = worldToMinimap(200, 100, layout) + // horizontally flush to padding, vertically centered (content height 50) + expect(topLeft.x).toBeCloseTo(10) + expect(bottomRight.x).toBeCloseTo(110) + expect(topLeft.y).toBeCloseTo(35) + expect(bottomRight.y).toBeCloseTo(85) + }) + + it("does not divide by zero for a degenerate (single-point) bound", () => { + const layout = computeMinimapLayout( + { minX: 5, minY: 5, maxX: 5, maxY: 5 }, + 100, + 100, + ) + expect(Number.isFinite(layout.scale)).toBe(true) + const p = worldToMinimap(5, 5, layout) + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + }) +}) + +describe("worldToMinimap / minimapToWorld round trip", () => { + it("is an exact inverse", () => { + const layout = computeMinimapLayout( + { minX: -50, minY: -20, maxX: 150, maxY: 180 }, + 160, + 110, + 8, + ) + for (const [wx, wy] of [ + [-50, -20], + [0, 0], + [75, 90], + [150, 180], + ]) { + const back = minimapToWorld( + worldToMinimap(wx, wy, layout).x, + worldToMinimap(wx, wy, layout).y, + layout, + ) + expect(back.x).toBeCloseTo(wx) + expect(back.y).toBeCloseTo(wy) + } + }) +}) + +describe("computeViewportRect", () => { + const layout = computeMinimapLayout( + { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + 120, + 120, + 10, + ) + + it("maps the visible world region to a minimap rectangle", () => { + // zoom 1, no pan => the canvas shows world [0..100] x [0..100] + const rect = computeViewportRect( + { panX: 0, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + expect(rect.x).toBeCloseTo(10) + expect(rect.y).toBeCloseTo(10) + expect(rect.width).toBeCloseTo(100) + expect(rect.height).toBeCloseTo(100) + }) + + it("shrinks the rectangle as the main view zooms in", () => { + // zoom 2 => the canvas shows only half the world span in each axis + const rect = computeViewportRect( + { panX: 0, panY: 0, zoom: 2 }, + 100, + 100, + layout, + ) + expect(rect.width).toBeCloseTo(50) + expect(rect.height).toBeCloseTo(50) + }) + + it("shifts the rectangle when the main view pans", () => { + const base = computeViewportRect( + { panX: 0, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + // panning the world content left by 20 screen px moves the visible region right + const panned = computeViewportRect( + { panX: -20, panY: 0, zoom: 1 }, + 100, + 100, + layout, + ) + expect(panned.x).toBeGreaterThan(base.x) + }) +}) + +describe("clampToRange", () => { + it("clamps below, within, and above", () => { + expect(clampToRange(-5, 0, 10)).toBe(0) + expect(clampToRange(5, 0, 10)).toBe(5) + expect(clampToRange(50, 0, 10)).toBe(10) + }) +}) diff --git a/packages/memory-graph/src/canvas/minimap.ts b/packages/memory-graph/src/canvas/minimap.ts new file mode 100644 index 00000000..16be3b26 --- /dev/null +++ b/packages/memory-graph/src/canvas/minimap.ts @@ -0,0 +1,130 @@ +/** + * Pure geometry for the graph minimap. + * + * The minimap projects the whole node cloud into a small fixed-size box and + * draws a rectangle for the region the main canvas is currently showing. + * Everything here is side-effect free so it can be unit tested without a canvas. + */ + +export interface MinimapBounds { + minX: number + minY: number + maxX: number + maxY: number +} + +export interface MinimapLayout { + /** World units -> minimap pixels. */ + scale: number + /** Added after scaling to place the content inside the padded box. */ + offsetX: number + offsetY: number + width: number + height: number +} + +/** A structural subset of ViewportState so the math stays canvas-agnostic. */ +export interface MinimapViewport { + panX: number + panY: number + zoom: number +} + +export interface MinimapRect { + x: number + y: number + width: number + height: number +} + +/** + * Fit the world `bounds` into a `width` x `height` box (minus `padding` on each + * side), preserving aspect ratio and centering the content. + */ +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 } +} + +/** World point -> minimap pixel. */ +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, + } +} + +/** Minimap pixel -> world point (inverse of {@link worldToMinimap}). */ +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, + } +} + +/** + * The rectangle, in minimap pixels, covering the world region currently visible + * on a `canvasWidth` x `canvasHeight` main canvas. May extend past the minimap + * box when the user has zoomed out past the node cloud; callers clip as needed. + */ +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, + } +} + +/** Clamp a value into the inclusive [min, max] range. */ +export function clampToRange(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value +} diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index d165ac71..45def183 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -19,6 +19,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" export function MemoryGraph({ documents = [], @@ -32,6 +33,7 @@ export function MemoryGraph({ highlightDocumentIds = [], highlightsVisible = true, showFps = false, + showMinimap = false, maxNodes, isSlideshowActive = false, onSlideshowNodeChange, @@ -834,6 +836,30 @@ export function MemoryGraph({ /> )} + + {showMinimap && + !isCompactViewport && + containerSize.width > 0 && + nodes.length > 0 && ( + + )} ) diff --git a/packages/memory-graph/src/components/minimap.tsx b/packages/memory-graph/src/components/minimap.tsx new file mode 100644 index 00000000..b6c575d9 --- /dev/null +++ b/packages/memory-graph/src/components/minimap.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef } from "react" +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 + /** Main canvas dimensions, used to draw and move the viewport rectangle. */ + canvasWidth: number + canvasHeight: number + /** Monotonic counter that bumps on every pan/zoom so the minimap redraws. */ + viewportVersion: number + width?: number + height?: number +} + +const PADDING = 8 + +/** + * A compact overview of the whole graph with a rectangle marking the region the + * main canvas is showing. Click or drag anywhere on it to recenter the main + * view there. + */ +export function Minimap({ + nodes, + colors, + viewportRef, + canvasWidth, + canvasHeight, + viewportVersion, + width = 168, + height = 116, +}: MinimapProps) { + const canvasRef = useRef(null) + const isDraggingRef = useRef(false) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr + canvas.height = height * dpr + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const bounds = getNodeBounds(nodes) + if (!bounds) return + const layout = computeMinimapLayout(bounds, width, height, PADDING) + + // Nodes: memories first (dim), documents on top (brighter, larger). + 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() + } + + // Viewport rectangle, clipped to the minimap box. + 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]) + + // Redraw whenever the data or the viewport changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion is the redraw trigger for viewport changes read via ref + useEffect(() => { + draw() + }, [draw, viewportVersion]) + + const recenterFromEvent = useCallback( + (clientX: number, clientY: number) => { + const canvas = canvasRef.current + const vp = viewportRef.current + if (!canvas || !vp) return + const rect = canvas.getBoundingClientRect() + const bounds = getNodeBounds(nodes) + if (!bounds) return + const layout = computeMinimapLayout(bounds, width, height, PADDING) + const world = minimapToWorld( + clientX - rect.left, + clientY - rect.top, + layout, + ) + vp.centerOn(world.x, world.y, canvasWidth, canvasHeight) + }, + [nodes, viewportRef, canvasWidth, canvasHeight, width, height], + ) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + isDraggingRef.current = true + e.currentTarget.setPointerCapture(e.pointerId) + recenterFromEvent(e.clientX, e.clientY) + }, + [recenterFromEvent], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDraggingRef.current) return + recenterFromEvent(e.clientX, e.clientY) + }, + [recenterFromEvent], + ) + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + isDraggingRef.current = false + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } + }, + [], + ) + + return ( + + ) +} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index edeeb31c..84bbca77 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,6 +1,7 @@ // Components export { MemoryGraph } from "./components/memory-graph" export { GraphCanvas } from "./components/graph-canvas" +export { Minimap } from "./components/minimap" // Hooks export { useGraphData } from "./hooks/use-graph-data" diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index f871b3da..ce72caba 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -204,6 +204,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