mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge c0d895a9c7 into 3f7b9667c6
This commit is contained in:
commit
a68865b1af
10 changed files with 579 additions and 0 deletions
|
|
@ -45,6 +45,7 @@ export const GraphLayoutView = memo(function GraphLayoutView({
|
|||
highlightsVisible
|
||||
canvasRef={canvasRef}
|
||||
onOpenDocument={onOpenDocument}
|
||||
showMinimap
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface MemoryGraphWrapperProps {
|
|||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
showMinimap?: boolean
|
||||
}
|
||||
|
||||
export function MemoryGraph({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
102
packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx
Normal file
102
packages/memory-graph/src/__tests__/minimap-render.e2e.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
139
packages/memory-graph/src/__tests__/minimap.test.ts
Normal file
139
packages/memory-graph/src/__tests__/minimap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
130
packages/memory-graph/src/canvas/minimap.ts
Normal file
130
packages/memory-graph/src/canvas/minimap.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
176
packages/memory-graph/src/components/minimap.tsx
Normal file
176
packages/memory-graph/src/components/minimap.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue