Skip to content

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

  1. User inputs real dimensions (e.g., "Stage is 40ft x 25ft")
  2. System calculates scale: pixelsPerFoot = canvasWidth / stageWidthFeet
  3. 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

  1. Use listening={false} on background layers
  2. Batch updates — Group related shape changes
  3. Avoid re-creating shapes — Use React keys properly
  4. 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