| name | canvas-mindmap |
| description | Interactive pan/zoom canvas with gesture controls for mindmap visualization. Use when implementing canvas-based UIs, pan/zoom interactions, graph layouts, node rendering, connection lines, or drag-and-drop on canvas. Triggers on canvas, mindmap, pan, zoom, gestures, dagre, graph layout, spring animations. |
Canvas Mindmap with Gestures
Interactive pan/zoom canvas using @use-gesture/react and @react-spring/web for smooth mindmap visualization.
When to Use This Skill
- Building canvas-based UIs with pan/zoom
- Implementing mindmap or graph visualizations
- Adding gesture-based interactions
- Laying out hierarchical data with dagre
- Animating node transitions
- Rendering connection lines between nodes
Canvas Architecture
Core Canvas Component
import { useRef, useMemo, useCallback } from 'react';
import { useGesture } from '@use-gesture/react';
import { useSpring, animated } from '@react-spring/web';
import { useAtom, useAtomValue } from 'jotai';
import { canvasViewStateAtom } from '@/state/ui-atoms';
interface CanvasProps {
children: React.ReactNode;
width?: number;
height?: number;
minZoom?: number;
maxZoom?: number;
}
export function Canvas({
children,
width = 4000,
height = 4000,
minZoom = 0.1,
maxZoom = 2,
}: CanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useAtom(canvasViewStateAtom);
const [springs, api] = useSpring(() => ({
x: viewState.x,
y: viewState.y,
scale: viewState.zoom,
config: { tension: 300, friction: 30 },
}));
const clampZoom = useCallback(
(zoom: number) => Math.min(maxZoom, Math.max(minZoom, zoom)),
[minZoom, maxZoom]
);
useGesture(
{
onDrag: ({ offset: [x, y], memo, first, event }) => {
if ((event.target as HTMLElement).closest('[data-draggable]')) {
return memo;
}
if (first) {
return { startX: viewState.x, startY: viewState.y };
}
api.start({ x, y, immediate: true });
return memo;
},
onDragEnd: ({ offset: [x, y] }) => {
setViewState((prev) => ({ ...prev, x, y }));
},
onPinch: ({ offset: [scale], origin: [ox, oy], memo, first }) => {
if (first) {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
return {
originX: ox - rect.left,
originY: oy - rect.top,
startZoom: viewState.zoom,
startX: viewState.x,
startY: viewState.y,
};
}
const newZoom = clampZoom(scale);
const zoomRatio = newZoom / memo.startZoom;
const newX = memo.originX - (memo.originX - memo.startX) * zoomRatio;
const newY = memo.originY - (memo.originY - memo.startY) * zoomRatio;
api.start({ scale: newZoom, x: newX, y: newY });
return memo;
},
onPinchEnd: () => {
const { scale, x, y } = springs;
setViewState({
zoom: scale.get(),
x: x.get(),
y: y.get(),
});
},
onWheel: ({ delta: [, dy], event, ctrlKey }) => {
event.preventDefault();
if (!ctrlKey) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
setViewState((prev) => {
const zoomDelta = -dy * 0.001;
const newZoom = clampZoom(prev.zoom + prev.zoom * zoomDelta);
const zoomRatio = newZoom / prev.zoom;
return {
zoom: newZoom,
x: cursorX - (cursorX - prev.x) * zoomRatio,
y: cursorY - (cursorY - prev.y) * zoomRatio,
};
});
},
},
{
target: containerRef,
drag: {
from: () => [springs.x.get(), springs.y.get()],
filterTaps: true,
},
pinch: {
scaleBounds: { min: minZoom, max: maxZoom },
from: () => [springs.scale.get(), 0],
},
wheel: {
preventDefault: true,
eventOptions: { passive: false },
},
}
);
return (
<div
ref={containerRef}
className="relative w-full h-full overflow-hidden touch-none"
style={{ cursor: 'grab' }}
>
<animated.div
style={{
transform: springs.x.to(
(x) =>
`translate3d(${x}px, ${springs.y.get()}px, 0) scale(${springs.scale.get()})`
),
transformOrigin: '0 0',
width,
height,
}}
>
{children}
</animated.div>
</div>
);
}
Dagre Graph Layout
import dagre from 'dagre';
import type { Task, Outcome, AgentSession } from '@agentop/core';
export interface LayoutNode {
id: string;
type: 'task' | 'outcome' | 'session';
data: Task | Outcome | AgentSession;
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutEdge {
id: string;
source: string;
target: string;
points: Array<{ x: number; y: number }>;
}
interface GraphData {
tasks: Task[];
outcomes: Outcome[];
sessions: AgentSession[];
}
const NODE_DIMENSIONS = {
task: { width: 200, height: 60 },
outcome: { width: 180, height: 50 },
session: { width: 160, height: 40 },
};
export function computeLayout(data: GraphData): {
nodes: LayoutNode[];
edges: LayoutEdge[];
} {
const g = new dagre.graphlib.Graph();
g.setGraph({
rankdir: 'TB',
nodesep: 40,
ranksep: 60,
marginx: 20,
marginy: 20,
});
g.setDefaultEdgeLabel(() => ({}));
for (const task of data.tasks) {
g.setNode(`task-${task.id}`, {
...NODE_DIMENSIONS.task,
type: 'task',
data: task,
});
}
for (const task of data.tasks) {
if (task.parentId) {
g.setEdge(`task-${task.parentId}`, `task-${task.id}`);
}
}
for (const outcome of data.outcomes) {
g.setNode(`outcome-${outcome.id}`, {
...NODE_DIMENSIONS.outcome,
type: 'outcome',
data: outcome,
});
g.setEdge(`task-${outcome.taskId}`, `outcome-${outcome.id}`);
}
for (const session of data.sessions) {
g.setNode(`session-${session.id}`, {
...NODE_DIMENSIONS.session,
type: 'session',
data: session,
});
g.setEdge(`outcome-${session.outcomeId}`, `session-${session.id}`);
}
dagre.layout(g);
const nodes: LayoutNode[] = g.nodes().map((nodeId) => {
const node = g.node(nodeId);
return {
id: nodeId,
type: node.type,
data: node.data,
x: node.x - node.width / 2,
y: node.y - node.height / 2,
width: node.width,
height: node.height,
};
});
const edges: LayoutEdge[] = g.edges().map((edge) => {
const edgeData = g.edge(edge);
return {
id: `${edge.v}-${edge.w}`,
source: edge.v,
target: edge.w,
points: edgeData.points || [],
};
});
return { nodes, edges };
}
Mindmap Nodes
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { cn } from '@/lib/utils';
import type { LayoutNode } from './layout';
interface MindmapNodeProps {
node: LayoutNode;
selected?: boolean;
onSelect?: () => void;
onDragEnd?: (position: { x: number; y: number }) => void;
}
export function MindmapNode({
node,
selected,
onSelect,
onDragEnd,
}: MindmapNodeProps) {
const [springs, api] = useSpring(() => ({
x: node.x,
y: node.y,
scale: 1,
config: { tension: 400, friction: 30 },
}));
useEffect(() => {
api.start({ x: node.x, y: node.y });
}, [node.x, node.y, api]);
const bind = useDrag(
({ offset: [x, y], active, tap }) => {
if (tap) {
onSelect?.();
return;
}
api.start({
x,
y,
scale: active ? 1.05 : 1,
immediate: active,
});
if (!active) {
onDragEnd?.({ x, y });
}
},
{
from: () => [springs.x.get(), springs.y.get()],
filterTaps: true,
}
);
const nodeStyles = {
task: 'bg-blue-500 border-blue-600',
outcome: 'bg-green-500 border-green-600',
session: 'bg-purple-500 border-purple-600',
};
return (
<animated.div
{...bind()}
data-draggable
style={{
position: 'absolute',
transform: springs.x.to(
(x) =>
`translate3d(${x}px, ${springs.y.get()}px, 0) scale(${springs.scale.get()})`
),
width: node.width,
height: node.height,
touchAction: 'none',
}}
className={cn(
'rounded-lg border-2 cursor-grab active:cursor-grabbing',
'flex items-center justify-center text-white text-sm font-medium',
'shadow-md hover:shadow-lg transition-shadow',
nodeStyles[node.type],
selected && 'ring-2 ring-white ring-offset-2'
)}
>
{node.type === 'task' && (node.data as Task).title}
{node.type === 'outcome' && (node.data as Outcome).title}
{node.type === 'session' && `Session ${(node.data as AgentSession).id.slice(0, 6)}`}
</animated.div>
);
}
Connection Lines (SVG)
import { useMemo } from 'react';
import type { LayoutEdge, LayoutNode } from './layout';
interface ConnectionLinesProps {
edges: LayoutEdge[];
nodes: LayoutNode[];
}
export function ConnectionLines({ edges, nodes }: ConnectionLinesProps) {
const nodeMap = useMemo(
() => new Map(nodes.map((n) => [n.id, n])),
[nodes]
);
return (
<svg
className="absolute inset-0 pointer-events-none"
style={{ width: '100%', height: '100%' }}
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="currentColor"
className="text-gray-400"
/>
</marker>
</defs>
{edges.map((edge) => {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (!source || !target) return null;
// Calculate connection points
const startX = source.x + source.width / 2;
const startY = source.y + source.height;
const endX = target.x + target.width / 2;
const endY = target.y;
// Create curved path
const path = createCurvedPath(startX, startY, endX, endY);
return (
<path
key={edge.id}
d={path}
fill="none"
stroke="currentColor"
strokeWidth={2}
className="text-gray-400"
markerEnd="url(#arrowhead)"
/>
);
})}
</svg>
);
}
function createCurvedPath(
x1: number,
y1: number,
x2: number,
y2: number
): string {
const midY = (y1 + y2) / 2;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
Complete Mindmap Canvas
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { Canvas } from './Canvas';
import { MindmapNode } from './MindmapNode';
import { ConnectionLines } from './ConnectionLines';
import { computeLayout } from './layout';
import {
tasksMapAtom,
outcomesMapAtom,
sessionsMapAtom,
selectedTaskIdAtom,
} from '@/state/atoms';
export function MindmapCanvas() {
const tasks = useAtomValue(tasksMapAtom);
const outcomes = useAtomValue(outcomesMapAtom);
const sessions = useAtomValue(sessionsMapAtom);
const selectedTaskId = useAtomValue(selectedTaskIdAtom);
const { nodes, edges } = useMemo(
() =>
computeLayout({
tasks: Array.from(tasks.values()),
outcomes: Array.from(outcomes.values()),
sessions: Array.from(sessions.values()),
}),
[tasks, outcomes, sessions]
);
return (
<Canvas>
{/* Render connection lines first (under nodes) */}
<ConnectionLines edges={edges} nodes={nodes} />
{/* Render nodes */}
{nodes.map((node) => (
<MindmapNode
key={node.id}
node={node}
selected={
node.type === 'task' &&
(node.data as Task).id === selectedTaskId
}
/>
))}
</Canvas>
);
}
Viewport Controls
import { useAtom } from 'jotai';
import { canvasViewStateAtom } from '@/state/ui-atoms';
import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize } from 'lucide-react';
export function ViewportControls() {
const [viewState, setViewState] = useAtom(canvasViewStateAtom);
const zoomIn = () => {
setViewState((prev) => ({
...prev,
zoom: Math.min(2, prev.zoom * 1.2),
}));
};
const zoomOut = () => {
setViewState((prev) => ({
...prev,
zoom: Math.max(0.1, prev.zoom / 1.2),
}));
};
const fitToView = () => {
setViewState({ x: 0, y: 0, zoom: 1 });
};
return (
<div className="absolute bottom-4 right-4 flex gap-2 bg-background/80 backdrop-blur rounded-lg p-2">
<Button variant="outline" size="icon" onClick={zoomOut}>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="flex items-center px-2 text-sm">
{Math.round(viewState.zoom * 100)}%
</span>
<Button variant="outline" size="icon" onClick={zoomIn}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={fitToView}>
<Maximize className="h-4 w-4" />
</Button>
</div>
);
}
Best Practices
- Use transforms for performance - translate3d and scale trigger GPU acceleration
- Separate interaction from layout - Keep gesture handling in canvas, not nodes
- Memoize layout computation - dagre is expensive, run only when data changes
- Use refs for gesture targets - Avoids re-binding on each render
- Layer SVG under nodes - Connections render behind interactive elements
- Immediate mode for dragging - Set immediate: true during active gestures
Troubleshooting
| Issue | Solution |
|---|
| Laggy panning | Check for layout thrashing, use transform instead of top/left |
| Pinch zoom not working | Ensure touch-action: none on container |
| Nodes not draggable | Check data-draggable attribute and gesture filtering |
| Edges misaligned | Verify node positions account for center vs corner coords |