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

This commit is contained in:
abhay-codes07 2026-10-12 21:44:25 -07:00 • committed by Mahesh Sanikommu
parent 6470b6b05d
commit 853782aefc
8 changed files with 570 additions and 0 deletions

View file

@ -67,6 +67,7 @@ function App() {
| `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.

View file

@ -0,0 +1,100 @@
/**
* 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}
/>,
)
const canvas = container.querySelector("canvas")
expect(canvas).not.toBeNull()
})
it("renders nothing visible but does not crash with no nodes", () => {
const viewportRef = createRef<ViewportState | null>()
;(viewportRef as { current: ViewportState | null }).current =
new ViewportState()
const { container } = render(
<Minimap
nodes={[]}
colors={DEFAULT_COLORS}
viewportRef={viewportRef}
canvasWidth={800}
canvasHeight={600}
/>,
)
expect(container.querySelector("canvas")).not.toBeNull()
})
})

View file

@ -0,0 +1,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,110 @@
// Pure, canvas-free geometry for the graph minimap.
export interface MinimapBounds {
minX: number
minY: number
maxX: number
maxY: number
}
export interface MinimapLayout {
scale: number
offsetX: number
offsetY: number
width: number
height: number
}
export interface MinimapViewport {
panX: number
panY: number
zoom: number
}
export interface MinimapRect {
x: number
y: number
width: number
height: number
}
export function computeMinimapLayout(
bounds: MinimapBounds,
width: number,
height: number,
padding = 8,
): MinimapLayout {
const worldWidth = Math.max(bounds.maxX - bounds.minX, 1)
const worldHeight = Math.max(bounds.maxY - bounds.minY, 1)
const availWidth = Math.max(width - padding * 2, 1)
const availHeight = Math.max(height - padding * 2, 1)
const scale = Math.min(availWidth / worldWidth, availHeight / worldHeight)
const contentWidth = worldWidth * scale
const contentHeight = worldHeight * scale
const offsetX =
padding + (availWidth - contentWidth) / 2 - bounds.minX * scale
const offsetY =
padding + (availHeight - contentHeight) / 2 - bounds.minY * scale
return { scale, offsetX, offsetY, width, height }
}
export function worldToMinimap(
worldX: number,
worldY: number,
layout: MinimapLayout,
): { x: number; y: number } {
return {
x: layout.offsetX + worldX * layout.scale,
y: layout.offsetY + worldY * layout.scale,
}
}
export function minimapToWorld(
minimapX: number,
minimapY: number,
layout: MinimapLayout,
): { x: number; y: number } {
return {
x: (minimapX - layout.offsetX) / layout.scale,
y: (minimapY - layout.offsetY) / layout.scale,
}
}
// Can extend past the minimap box when zoomed out; callers clip.
export function computeViewportRect(
viewport: MinimapViewport,
canvasWidth: number,
canvasHeight: number,
layout: MinimapLayout,
): MinimapRect {
const zoom = viewport.zoom || 1
const topLeftWorld = {
x: (0 - viewport.panX) / zoom,
y: (0 - viewport.panY) / zoom,
}
const bottomRightWorld = {
x: (canvasWidth - viewport.panX) / zoom,
y: (canvasHeight - viewport.panY) / zoom,
}
const topLeft = worldToMinimap(topLeftWorld.x, topLeftWorld.y, layout)
const bottomRight = worldToMinimap(
bottomRightWorld.x,
bottomRightWorld.y,
layout,
)
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
}
}
export function clampToRange(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}

View file

@ -28,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,
@ -66,6 +67,7 @@ export function MemoryGraph({
highlightDocumentIds = [],
highlightsVisible = true,
showFps = false,
showMinimap = false,
maxNodes,
isSlideshowActive = false,
onSlideshowNodeChange,
@ -939,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>
)

View file

@ -0,0 +1,191 @@
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 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)
if (vpChanged || simActive || wasSimActive) {
draw()
if (vp) {
lastPanX = vp.panX
lastPanY = vp.panY
lastZoom = vp.zoom
}
}
wasSimActive = simActive
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
return () => cancelAnimationFrame(raf)
}, [draw, simulation, viewportRef])
const recenterFromEvent = useCallback(
(clientX: number, clientY: number) => {
const canvas = canvasRef.current
const vp = viewportRef.current
if (!canvas || !vp) return
const rect = canvas.getBoundingClientRect()
const bounds = getNodeBounds(nodes)
if (!bounds) return
const layout = computeMinimapLayout(bounds, width, height, PADDING)
const world = minimapToWorld(
clientX - rect.left,
clientY - rect.top,
layout,
)
vp.centerOn(world.x, world.y, canvasWidth, canvasHeight)
},
[nodes, viewportRef, canvasWidth, canvasHeight, width, height],
)
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
recenterFromEvent(e.clientX, e.clientY)
},
[recenterFromEvent],
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current) return
recenterFromEvent(e.clientX, e.clientY)
},
[recenterFromEvent],
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = false
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
},
[],
)
return (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
width,
height,
display: "block",
cursor: "pointer",
borderRadius: 10,
border: `1px solid ${colors.controlBorder}`,
background: colors.controlBg,
boxShadow: "0 6px 18px rgba(0,0,0,0.22)",
touchAction: "none",
}}
/>
)
}

View file

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

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