# SUBTASK-002: Canvas Navigation
**Parent Task**: TASK-000J Canvas Organization System
**Estimate**: 8-12 hours
**Priority**: 2
**Dependencies**: SUBTASK-001 (Smart Frames) - requires frames to exist as navigation anchors
---
## Overview
A minimap overlay and jump-to navigation system for quickly moving around large canvases. Smart Frames automatically become navigation anchors - no manual bookmark creation needed.
### The Problem
In complex components with many nodes:
- Users pan around aimlessly looking for specific logic
- No way to quickly jump to a known area
- Easy to get "lost" in large canvases
- Zooming out to see everything makes nodes unreadable
### The Solution
- **Minimap**: Small overview in corner showing frame locations and current viewport
- **Jump Menu**: Dropdown list of all Smart Frames for quick navigation
- **Keyboard Shortcuts**: Cmd+1..9 to jump to frames by position
---
## Feature Capabilities
| Capability | Description |
|------------|-------------|
| **Minimap toggle** | Button in canvas toolbar to show/hide minimap |
| **Frame indicators** | Colored rectangles showing Smart Frame positions |
| **Viewport indicator** | Rectangle showing current visible area |
| **Click to navigate** | Click anywhere on minimap to pan canvas there |
| **Jump menu** | Dropdown list of all Smart Frames |
| **Keyboard shortcuts** | Cmd+1..9 to jump to first 9 frames |
| **Persistent state** | Minimap visibility saved in editor settings |
---
## Visual Design
### Minimap Layout
```
┌──────────────────────────────────────────────────┐
│ │
│ [Main Canvas] │
│ │
│ ┌─────┐│
│ │▪ A ││
│ │ ▪B ││ ← Minimap (150x100px)
│ │ ┌─┐ ││ ← Viewport rectangle
│ │ └─┘ ││
│ │▪ C ││
│ └─────┘│
└──────────────────────────────────────────────────┘
```
### Minimap Details
- **Position**: Bottom-right corner, 10px from edges
- **Size**: ~150x100px (aspect ratio matches canvas)
- **Background**: Semi-transparent dark (#1a1a1a at 80% opacity)
- **Border**: 1px solid border (#333)
- **Border radius**: 4px
### Frame Indicators
- Small rectangles (~10-20px depending on scale)
- Color matches frame color
- Optional: First letter of frame name as label
- Slightly rounded corners
### Viewport Rectangle
- Outline rectangle (no fill)
- White or light color (#fff at 50% opacity)
- 1px stroke
- Shows what's currently visible in main canvas
### Jump Menu
```
┌─────────────────────────┐
│ Jump to Frame ⌘G │
├─────────────────────────┤
│ ● Login Flow ⌘1 │
│ ● Data Fetching ⌘2 │
│ ● Authentication ⌘3 │
│ ● Navigation Logic ⌘4 │
└─────────────────────────┘
```
- Color dot matches frame color
- Frame title (truncated if long)
- Keyboard shortcut hint (if within first 9)
---
## Technical Architecture
### Component Structure
```
packages/noodl-editor/src/editor/src/views/CanvasNavigation/
├── CanvasNavigation.tsx # Main container
├── CanvasNavigation.module.scss
├── Minimap.tsx # Minimap rendering
├── Minimap.module.scss
├── JumpMenu.tsx # Dropdown menu
├── JumpMenu.module.scss
├── hooks/
│ └── useCanvasNavigation.ts # Shared state/logic
└── index.ts
```
### Props Interface
```typescript
interface CanvasNavigationProps {
nodeGraph: NodeGraphEditor;
commentsModel: CommentsModel;
visible: boolean;
onToggle: () => void;
}
interface MinimapProps {
frames: SmartFrameInfo[];
canvasBounds: Bounds;
viewport: Viewport;
onNavigate: (x: number, y: number) => void;
}
interface SmartFrameInfo {
id: string;
title: string;
color: string;
bounds: Bounds;
}
interface Viewport {
x: number;
y: number;
width: number;
height: number;
scale: number;
}
```
### Coordinate Transformation
The minimap needs to transform between three coordinate systems:
1. **Canvas coordinates**: Where nodes/frames actually are
2. **Minimap coordinates**: Scaled down to fit minimap
3. **Screen coordinates**: For click handling
```typescript
class CoordinateTransformer {
private canvasBounds: Bounds;
private minimapSize: { width: number; height: number };
private scale: number;
constructor(canvasBounds: Bounds, minimapSize: { width: number; height: number }) {
this.canvasBounds = canvasBounds;
this.minimapSize = minimapSize;
// Calculate scale to fit canvas in minimap
const scaleX = minimapSize.width / (canvasBounds.maxX - canvasBounds.minX);
const scaleY = minimapSize.height / (canvasBounds.maxY - canvasBounds.minY);
this.scale = Math.min(scaleX, scaleY);
}
canvasToMinimap(point: Point): Point {
return {
x: (point.x - this.canvasBounds.minX) * this.scale,
y: (point.y - this.canvasBounds.minY) * this.scale
};
}
minimapToCanvas(point: Point): Point {
return {
x: point.x / this.scale + this.canvasBounds.minX,
y: point.y / this.scale + this.canvasBounds.minY
};
}
}
```
---
## Implementation Sessions
### Session 2.1: Component Structure (2 hours)
**Goal**: Create component files and basic rendering.
**Tasks**:
1. Create directory structure
2. Create `CanvasNavigation.tsx`:
```tsx
import React, { useState } from 'react';
import { Minimap } from './Minimap';
import { JumpMenu } from './JumpMenu';
import styles from './CanvasNavigation.module.scss';
export function CanvasNavigation({ nodeGraph, commentsModel, visible, onToggle }: CanvasNavigationProps) {
const [jumpMenuOpen, setJumpMenuOpen] = useState(false);
if (!visible) return null;
const frames = getSmartFrames(commentsModel);
const canvasBounds = calculateCanvasBounds(nodeGraph);
const viewport = getViewport(nodeGraph);
return (
nodeGraph.panTo(x, y)}
/>
);
}
```
3. Create basic SCSS:
```scss
.container {
position: absolute;
bottom: 10px;
right: 10px;
z-index: 100;
}
```
4. Create placeholder `Minimap.tsx` and `JumpMenu.tsx`
**Files to create**:
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.tsx`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.module.scss`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.module.scss`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.tsx`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/index.ts`
**Success criteria**:
- [ ] Components compile without errors
- [ ] Basic container renders in corner
---
### Session 2.2: Coordinate Transformation & Frame Rendering (2 hours)
**Goal**: Render frames at correct positions on minimap.
**Tasks**:
1. Implement canvas bounds calculation:
```typescript
function calculateCanvasBounds(nodeGraph: NodeGraphEditor): Bounds {
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
// Include all nodes
nodeGraph.forEachNode((node) => {
minX = Math.min(minX, node.global.x);
minY = Math.min(minY, node.global.y);
maxX = Math.max(maxX, node.global.x + node.nodeSize.width);
maxY = Math.max(maxY, node.global.y + node.nodeSize.height);
});
// Include all frames
const comments = nodeGraph.commentLayer.model.getComments();
for (const comment of comments) {
minX = Math.min(minX, comment.x);
minY = Math.min(minY, comment.y);
maxX = Math.max(maxX, comment.x + comment.width);
maxY = Math.max(maxY, comment.y + comment.height);
}
// Add padding
const padding = 50;
return {
minX: minX - padding,
minY: minY - padding,
maxX: maxX + padding,
maxY: maxY + padding
};
}
```
2. Implement `CoordinateTransformer` class
3. Render frame rectangles on minimap:
```tsx
function Minimap({ frames, canvasBounds, viewport, onNavigate }: MinimapProps) {
const minimapSize = { width: 150, height: 100 };
const transformer = new CoordinateTransformer(canvasBounds, minimapSize);
return (
{frames.map((frame) => {
const pos = transformer.canvasToMinimap({ x: frame.bounds.x, y: frame.bounds.y });
const size = {
width: frame.bounds.width * transformer.scale,
height: frame.bounds.height * transformer.scale
};
return (
);
})}
);
}
```
4. Add frame color extraction from comment colors
**Files to modify**:
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx`
**Files to create**:
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CoordinateTransformer.ts`
**Success criteria**:
- [ ] Frames render at proportionally correct positions
- [ ] Frame colors match actual frame colors
- [ ] Minimap scales appropriately for different canvas sizes
---
### Session 2.3: Viewport and Click Navigation (2 hours)
**Goal**: Show viewport rectangle and handle click-to-navigate.
**Tasks**:
1. Get viewport from NodeGraphEditor:
```typescript
function getViewport(nodeGraph: NodeGraphEditor): Viewport {
const panAndScale = nodeGraph.getPanAndScale();
const canvasWidth = nodeGraph.canvas.width / nodeGraph.canvas.ratio;
const canvasHeight = nodeGraph.canvas.height / nodeGraph.canvas.ratio;
return {
x: -panAndScale.x,
y: -panAndScale.y,
width: canvasWidth / panAndScale.scale,
height: canvasHeight / panAndScale.scale,
scale: panAndScale.scale
};
}
```
2. Subscribe to pan/scale changes:
```typescript
useEffect(() => {
const handlePanScaleChange = () => {
setViewport(getViewport(nodeGraph));
};
nodeGraph.on('panAndScaleChanged', handlePanScaleChange);
return () => nodeGraph.off('panAndScaleChanged', handlePanScaleChange);
}, [nodeGraph]);
```
3. Render viewport rectangle:
```tsx
const viewportPos = transformer.canvasToMinimap({ x: viewport.x, y: viewport.y });
const viewportSize = {
width: viewport.width * transformer.scale,
height: viewport.height * transformer.scale
};
```
4. Handle click navigation:
```typescript
const handleMinimapClick = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const canvasPos = transformer.minimapToCanvas({ x: clickX, y: clickY });
onNavigate(canvasPos.x, canvasPos.y);
};
```
5. Add `panTo` method to NodeGraphEditor if not exists:
```typescript
panTo(x: number, y: number, animate: boolean = true) {
const centerX = this.canvas.width / this.canvas.ratio / 2;
const centerY = this.canvas.height / this.canvas.ratio / 2;
const targetPan = {
x: centerX - x,
y: centerY - y,
scale: this.getPanAndScale().scale
};
if (animate) {
this.animatePanTo(targetPan);
} else {
this.setPanAndScale(targetPan);
}
}
```
**Files to modify**:
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx`
- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` (add panTo method)
**Success criteria**:
- [ ] Viewport rectangle shows current visible area
- [ ] Viewport updates when panning/zooming main canvas
- [ ] Clicking minimap pans canvas to that location
---
### Session 2.4: Toggle and Integration (1-2 hours)
**Goal**: Add toggle button and integrate with editor.
**Tasks**:
1. Add minimap toggle to canvas toolbar:
```tsx
// In EditorDocument.tsx or canvas toolbar component
setMinimapVisible(!minimapVisible)}
tooltip="Toggle Minimap"
/>
```
2. Add to EditorSettings:
```typescript
// In editorsettings.ts
interface EditorSettings {
// ... existing
minimapVisible?: boolean;
}
```
3. Mount CanvasNavigation in EditorDocument:
```tsx
// In EditorDocument.tsx
import { CanvasNavigation } from '@noodl-views/CanvasNavigation';
// In render
{nodeGraph && (
setMinimapVisible(!minimapVisible)}
/>
)}
```
4. Persist visibility state:
```typescript
useEffect(() => {
EditorSettings.instance.set('minimapVisible', minimapVisible);
}, [minimapVisible]);
// Initial load
const [minimapVisible, setMinimapVisible] = useState(
EditorSettings.instance.get('minimapVisible') ?? false
);
```
**Files to modify**:
- `packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx`
- `packages/noodl-editor/src/editor/src/utils/editorsettings.ts`
**Success criteria**:
- [ ] Toggle button shows in toolbar
- [ ] Clicking toggle shows/hides minimap
- [ ] Visibility persists across editor sessions
---
### Session 2.5: Jump Menu (2-3 hours)
**Goal**: Create jump menu dropdown and keyboard shortcuts.
**Tasks**:
1. Create JumpMenu component:
```tsx
function JumpMenu({ frames, onSelect, onClose }: JumpMenuProps) {
return (
Jump to Frame
{frames.map((frame, index) => (
{
onSelect(frame);
onClose();
}}
>
{frame.title || 'Untitled'}
{index < 9 && (
⌘{index + 1}
)}
))}
);
}
```
2. Add jump menu trigger (toolbar button or keyboard):
```typescript
// Keyboard shortcut: Cmd+G or Cmd+J
KeyboardHandler.instance.registerCommand('g', { meta: true }, () => {
setJumpMenuOpen(true);
});
```
3. Implement frame jump:
```typescript
const handleFrameSelect = (frame: SmartFrameInfo) => {
const centerX = frame.bounds.x + frame.bounds.width / 2;
const centerY = frame.bounds.y + frame.bounds.height / 2;
nodeGraph.panTo(centerX, centerY);
};
```
4. Add number shortcuts (Cmd+1..9):
```typescript
useEffect(() => {
const frames = getSmartFrames(commentsModel);
for (let i = 0; i < Math.min(frames.length, 9); i++) {
KeyboardHandler.instance.registerCommand(`${i + 1}`, { meta: true }, () => {
handleFrameSelect(frames[i]);
});
}
return () => {
for (let i = 1; i <= 9; i++) {
KeyboardHandler.instance.unregisterCommand(`${i}`, { meta: true });
}
};
}, [commentsModel, frames]);
```
5. Style the menu appropriately
**Files to modify**:
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.tsx`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.module.scss`
- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.tsx`
**Success criteria**:
- [ ] Jump menu opens via toolbar or Cmd+G
- [ ] Menu lists all Smart Frames with colors
- [ ] Selecting frame pans canvas to it
- [ ] Cmd+1..9 shortcuts work for first 9 frames
---
## Testing Checklist
### Minimap Display
- [ ] Minimap appears in bottom-right corner
- [ ] Minimap background is semi-transparent
- [ ] Frame indicators show at correct positions
- [ ] Frame colors match actual frame colors
- [ ] Viewport rectangle visible and correctly sized
### Viewport Tracking
- [ ] Viewport rectangle updates when panning
- [ ] Viewport rectangle updates when zooming
- [ ] Viewport correctly represents visible area
### Navigation
- [ ] Click on minimap pans canvas to that location
- [ ] Pan animation is smooth (not instant)
- [ ] Canvas centers on click location
### Toggle
- [ ] Toggle button visible in toolbar
- [ ] Clicking toggle shows minimap
- [ ] Clicking again hides minimap
- [ ] State persists when switching components
- [ ] State persists when closing/reopening editor
### Jump Menu
- [ ] Menu opens via toolbar button
- [ ] Menu opens via Cmd+G (or Cmd+J)
- [ ] All Smart Frames listed
- [ ] Frame colors displayed correctly
- [ ] Keyboard shortcuts (⌘1-9) shown
- [ ] Selecting frame pans to it
- [ ] Menu closes after selection
- [ ] Esc closes menu
### Keyboard Shortcuts
- [ ] Cmd+1 jumps to first frame
- [ ] Cmd+2 jumps to second frame
- [ ] ...through Cmd+9 for ninth frame
- [ ] Shortcuts only work when canvas focused
### Edge Cases
- [ ] Canvas with no Smart Frames - minimap shows empty, jump menu shows "No frames"
- [ ] Single Smart Frame - minimap and jump work
- [ ] Many frames (20+) - performance acceptable, jump menu scrollable
- [ ] Very large canvas - minimap scales appropriately
- [ ] Very small canvas - minimap shows reasonable size
---
## Files Summary
### Create
```
packages/noodl-editor/src/editor/src/views/CanvasNavigation/
├── CanvasNavigation.tsx
├── CanvasNavigation.module.scss
├── Minimap.tsx
├── Minimap.module.scss
├── JumpMenu.tsx
├── JumpMenu.module.scss
├── CoordinateTransformer.ts
├── hooks/useCanvasNavigation.ts
└── index.ts
```
### Modify
```
packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx
packages/noodl-editor/src/editor/src/utils/editorsettings.ts
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
```
---
## Performance Considerations
### Minimap Updates
- Don't re-render on every mouse move during pan
- Use `requestAnimationFrame` for smooth viewport updates
- Debounce frame position updates (frames don't move often)
```typescript
// Throttled viewport update
const updateViewport = useMemo(
() => throttle(() => {
setViewport(getViewport(nodeGraph));
}, 16), // ~60fps
[nodeGraph]
);
```
### Frame Collection
- Cache frame list, invalidate on comments change
- Don't filter comments on every render
```typescript
const [frames, setFrames] = useState([]);
useEffect(() => {
const updateFrames = () => {
const smartFrames = commentsModel.getComments()
.filter(c => c.containedNodeIds?.length > 0)
.map(c => ({
id: c.id,
title: c.text,
color: getFrameColor(c),
bounds: { x: c.x, y: c.y, width: c.width, height: c.height }
}));
setFrames(smartFrames);
};
commentsModel.on('commentsChanged', updateFrames);
updateFrames();
return () => commentsModel.off('commentsChanged', updateFrames);
}, [commentsModel]);
```
### Canvas Bounds
- Recalculate bounds only when nodes/frames change, not on every pan
- Cache bounds calculation result
---
## Design Notes
### Why Not Manual Bookmarks?
We considered allowing users to drop pin markers anywhere on the canvas. However:
1. **User responsibility**: Users already create Smart Frames for organization
2. **Reduced complexity**: One concept (frames) serves multiple purposes
3. **Automatic updates**: Frames move, and navigation stays in sync
4. **No orphan pins**: Deleted frames = removed from navigation
If users want navigation without visual grouping, they can create small frames with just a label.
### Minimap Position
Bottom-right was chosen because:
- Top area often has toolbars
- Left side has sidebar panels
- Bottom-right is conventionally where minimaps appear (games, IDEs)
Could make position configurable in future.
### Animation on Navigate
Smooth pan animation helps users:
- Understand spatial relationship between areas
- Not feel "teleported" and disoriented
- See path between current view and destination
Animation should be quick (~200-300ms) to not feel sluggish.