feat(memory-graph): add an overview minimap with a draggable viewport

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.
This commit is contained in:
abhay-codes07 2026-08-15 05:17:21 +05:30
parent 5ecbc26345
commit c0d895a9c7
No known key found for this signature in database
10 changed files with 579 additions and 0 deletions

View file

@ -45,6 +45,7 @@ export const GraphLayoutView = memo(function GraphLayoutView({
highlightsVisible
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
showMinimap
/>
</div>

View file

@ -21,6 +21,7 @@ export interface MemoryGraphWrapperProps {
onSlideshowStop?: () => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
onOpenDocument?: (documentId: string) => void
showMinimap?: boolean
}
export function MemoryGraph({

View file

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

View file

@ -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<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}
viewportVersion={0}
/>,
)
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}
viewportVersion={1}
/>,
)
expect(container.querySelector("canvas")).not.toBeNull()
})
})

View file

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

View file

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

View file

@ -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({
/>
</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}
viewportVersion={viewportVersion}
/>
</div>
)}
</div>
</div>
)

View file

@ -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<ViewportState | null>
/** 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<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)
// 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<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}
style={{
width,
height,
display: "block",
cursor: "pointer",
borderRadius: 10,
border: `1px solid ${colors.controlBorder}`,
background: colors.controlBg,
boxShadow: "0 6px 18px rgba(0,0,0,0.22)",
touchAction: "none",
}}
/>
)
}

View file

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

View file

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