Canvas/Konva Patterns
Shared patterns for Blocking Tracker and Set Builder canvas features.
Overview
On Book Pro uses Konva.js with react-konva for 2D stage graphics in:
- Blocking Tracker — Actor positions and movement paths
- Set Builder — Stage layout with draggable furniture
Basic Stage Setup
tsx
import { Stage, Layer, Rect, Circle, Group } from 'react-konva';
import { useRef, useState } from 'react';
import Konva from 'konva';
export const CanvasStage = () => {
const stageRef = useRef<Konva.Stage>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
return (
<Stage
ref={stageRef}
width={dimensions.width}
height={dimensions.height}
className="border border-gray-300"
>
<Layer>
{/* Background */}
<Rect
x={0}
y={0}
width={dimensions.width}
height={dimensions.height}
fill="#1a1a2e"
/>
{/* Stage elements */}
<Circle x={100} y={100} radius={20} fill="red" draggable />
</Layer>
</Stage>
);
};Calibration Workflow
The Set Builder uses a calibration system to convert pixels to real-world units (feet/meters).
Calibration State
typescript
interface CalibrationState {
pixelsPerFoot: number; // Conversion factor
stageWidthFeet: number; // Real-world stage width
stageDepthFeet: number; // Real-world stage depth
isCalibrated: boolean;
}Calibration Flow
- User inputs real dimensions (e.g., "Stage is 40ft x 25ft")
- System calculates scale:
pixelsPerFoot = canvasWidth / stageWidthFeet - All elements render using scale factor
typescript
// Convert real-world position to canvas pixels
const toPixels = (feet: number) => feet * calibration.pixelsPerFoot;
// Convert canvas pixels to real-world
const toFeet = (pixels: number) => pixels / calibration.pixelsPerFoot;Layer Management
Organize canvas elements into logical layers:
tsx
<Stage>
{/* Background layer (non-interactive) */}
<Layer listening={false}>
<Rect fill="#1a1a2e" width={w} height={h} />
<GridLines spacing={gridSize} />
</Layer>
{/* Set pieces layer */}
<Layer>
{setPieces.map(piece => (
<SetPieceShape key={piece.id} piece={piece} />
))}
</Layer>
{/* Actor positions layer (on top) */}
<Layer>
{actors.map(actor => (
<ActorMarker key={actor.id} actor={actor} />
))}
</Layer>
</Stage>Hit Detection
Use Konva's built-in hit detection for interactions:
tsx
<Circle
x={actor.x}
y={actor.y}
radius={20}
fill={actor.color}
draggable
onClick={(e) => {
const target = e.target;
console.log('Clicked actor:', actor.id);
}}
onDragEnd={(e) => {
const newX = e.target.x();
const newY = e.target.y();
updateActorPosition(actor.id, newX, newY);
}}
/>Responsive Canvas
Make the canvas resize with its container:
tsx
import { useEffect, useRef, useState } from 'react';
export const useResponsiveCanvas = (containerRef: React.RefObject<HTMLDivElement>) => {
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, [containerRef]);
return dimensions;
};Asset Tray Pattern
Both Blocking and Set Builder use a side tray for dragging elements onto the stage:
tsx
import { SideTrayLayout } from '@/components/layout';
export const SetBuilderTab = () => (
<SideTrayLayout
tray={<AssetTray onDragStart={handleDragStart} />}
main={<CanvasStage onDrop={handleDrop} />}
/>
);Performance Tips
- Use
listening={false}on background layers - Batch updates — Group related shape changes
- Avoid re-creating shapes — Use React keys properly
- Cache static elements — Use
cache()for complex groups
tsx
// Cache a complex group
const groupRef = useRef<Konva.Group>(null);
useEffect(() => {
if (groupRef.current) {
groupRef.current.cache();
}
}, []);Further Reading
Last updated: January 2026