This commit is contained in:
Abhay Singh 2026-08-26 04:01:27 +05:30 committed by GitHub
commit a68865b1af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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