From c0d895a9c70ab589d7472ffc94697028e612dddf Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sat, 15 Aug 2026 05:17:21 +0530 Subject: [PATCH] feat(memory-graph): add an overview minimap with a draggable viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large graphs are easy to get lost in once you zoom in — there is no sense of where the visible region sits in the whole cloud. This adds an opt-in minimap that renders the entire node cloud in a small box with a rectangle marking the part the main canvas is showing. Click or drag anywhere on it to recenter the main view there. - `canvas/minimap.ts`: pure, side-effect-free geometry (fit layout, world<-> minimap projection, visible-region rectangle) so it is unit testable. - `components/minimap.tsx`: a small canvas that draws the nodes and the viewport rectangle, redraws on data/viewport changes via the existing viewportVersion counter (no extra rAF loop), and recenters through viewport.centerOn on pointer input. Hidden from assistive tech since it duplicates the graph, which already has keyboard-accessible navigation controls. - Gated behind a new `showMinimap` prop (default false) on MemoryGraph, and turned on for the full-page graph view in the web app. Tests: 8 unit tests for the geometry (fit/aspect/round-trip/viewport rect) and 2 mounted-render smoke tests. Full package suite: 206 passing. Package builds and type-checks clean. --- apps/web/components/graph-layout-view.tsx | 1 + .../memory-graph/memory-graph-wrapper.tsx | 1 + packages/memory-graph/README.md | 1 + .../src/__tests__/minimap-render.e2e.test.tsx | 102 ++++++++++ .../src/__tests__/minimap.test.ts | 139 ++++++++++++++ packages/memory-graph/src/canvas/minimap.ts | 130 +++++++++++++ .../src/components/memory-graph.tsx | 26 +++ .../memory-graph/src/components/minimap.tsx | 176 ++++++++++++++++++ packages/memory-graph/src/index.tsx | 1 + packages/memory-graph/src/types.ts | 2 + 10 files changed, 579 insertions(+) create mode 100644 packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx create mode 100644 packages/memory-graph/src/__tests__/minimap.test.ts create mode 100644 packages/memory-graph/src/canvas/minimap.ts create mode 100644 packages/memory-graph/src/components/minimap.tsx 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