Building the Renderer System From Scratch

So you've got shapes from the interpreter. Now you need to draw them on screen. That's what the renderer does. Let's build it step by step.

The Big Picture

The renderer takes shape objects (from the interpreter) and draws them to an HTML5 canvas. But it's not just drawing - it handles coordinate systems, selection, interaction, styling, and more.

The flow:

Interpreter creates shapes
    ↓
renderer.setShapes(result.shapes)
    ↓
renderer.redraw()
    ↓
Canvas shows shapes

Starting With the Canvas

First, you need a canvas element in your HTML:

<canvas id="canvas"></canvas>

Then in JavaScript, get the canvas and its 2D context:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

The context (ctx) is what you draw with. All canvas drawing methods are on this object: ctx.fillRect(), ctx.arc(), ctx.stroke(), etc.

Important: Set the canvas size explicitly. Don't rely on CSS:

canvas.width = 800;   // Actual pixel width
canvas.height = 600;  // Actual pixel height

If you don't set these, the canvas defaults to 300x150, which is tiny. CSS width and height just scale the canvas - they don't change the resolution.

If you see an error at this step:

Error: TypeError: Cannot read property 'getContext' of null

  • What this means: Canvas element is null
  • Common causes:
    1. Canvas element doesn't exist: <canvas id="canvas"></canvas> missing from HTML
    2. Wrong ID: getElementById('canvas') but HTML has id="myCanvas"
    3. Script runs before HTML loads: canvas doesn't exist yet
  • Fix: Check HTML has canvas element, verify ID matches, use DOMContentLoaded or put script at end of body

Error: TypeError: ctx is null or getContext('2d') returns null

  • What this means: getContext failed
  • Common causes:
    1. Typo in context name: getContext('2D') (uppercase D) instead of getContext('2d')
    2. Canvas element is null (see above)
    3. Very old browser that doesn't support canvas
  • Fix: Use lowercase '2d', check canvas exists first: if (!canvas) throw new Error('Canvas not found');

Error: Canvas appears 300×150 pixels even though CSS shows different size

  • What this means: Only CSS size set, not canvas resolution
  • Common causes:
    1. Only set CSS: canvas.style.width = '800px' but forgot canvas.width = 800
    2. CSS scales the canvas element, but canvas.width/height set the actual resolution
  • Fix: Set both: canvas.width = 800; canvas.height = 600; (resolution) AND CSS for display size

Error: Canvas is blurry or pixelated when drawing

  • What this means: Canvas resolution too low for display size
  • Common causes:
    1. canvas.width/height smaller than CSS size → canvas stretched, looks blurry
    2. High-DPI screen (Retina) needs higher resolution
  • Fix: Set canvas.width/height to match or exceed CSS size, or use: canvas.width = 800 * window.devicePixelRatio;

Building the Basic Renderer Structure From Scratch

Start with a class that holds the canvas and context. This is the foundation of the entire renderer system.

How to Build It Step by Step:

Step 1: Create the Renderer Class Start with a class that stores the canvas and initializes core properties.

The Renderer class is the main entry point for all rendering operations. It manages the canvas element, drawing context, and coordinates all rendering subsystems. The canvas parameter is the HTML5 <canvas> element from the DOM, which serves as the drawing surface. We store it as an instance variable so all methods can access it for dimensions, context retrieval, and event handling.

export class Renderer {
    constructor(canvas) {
        // Validate canvas element
        if (!canvas) {
            throw new Error('Renderer requires a canvas element');
        }
        if (!(canvas instanceof HTMLCanvasElement)) {
            throw new Error('Renderer constructor expects HTMLCanvasElement');
        }

        // Store canvas element for later use (dimensions, context, events)
        this.canvas = canvas;

        // Get the 2D rendering context - this is what we actually draw with
        // Canvas supports multiple contexts (2D, WebGL), we use '2d' for 2D graphics
        this.ctx = canvas.getContext('2d');
        if (!this.ctx) {
            throw new Error('Failed to get 2D context. Browser may not support canvas.');
        }

        // Store shapes in a Map for fast O(1) lookups by name
        // Key: shape name (string), Value: shape object
        this.shapes = new Map();

        // Track selection state for user interaction
        // Stored as names (strings) rather than objects for stability
        this.selectedShape = null;
        this.hoveredShape = null;

        // Initialize subsystems (coordinate system, style manager, shape renderer)
        this.initializeComponents();

        // Set up canvas (dimensions, coordinate system defaults)
        this.setupCanvas();
    }
}

Why a Map for Shapes:

We use a Map data structure to store shapes because it provides O(1) lookup time by shape name. When you need to find a shape, you simply do this.shapes.get(name), which is much faster than searching through an array (O(n)).

Why Map instead of Object or Array?

  • Object: Good for key-value pairs, but keys are always strings and iteration order wasn't guaranteed until ES6
  • Array: O(n) lookup - must search through all elements to find a shape, but preserves order
  • Map: O(1) lookup, preserves insertion order, and can use any type as key (though we use strings for shape names)

Shape Object Structure: Each shape in the Map follows this structure:

{
  type: 'circle',
  name: 'c1',
  params: { radius: 50, x: 100, y: 100 },
  transform: { position: [100, 100], rotation: 0, scale: [1, 1] }
}

Map Operations We'll Use:

  • shapes.set(name, shapeObject) - Add or update a shape
  • shapes.get(name) - Retrieve a shape by name (returns undefined if not found)
  • shapes.has(name) - Check if a shape exists
  • shapes.delete(name) - Remove a shape
  • for (const [name, shape] of shapes) - Iterate over all shapes
  • shapes.size - Get the number of shapes

Selection and Hover State:

Selection state tracks user interaction with shapes:

  • selectedShape: The shape name currently selected (for highlighting, editing, moving)
  • hoveredShape: The shape name under the mouse cursor (for hover effects, tooltips)

We store shape names (strings) rather than shape objects because:

  1. Stability: Names are stable identifiers even if shape objects are replaced
  2. Lookup: Can easily look up the shape when needed: shapes.get(selectedShape)
  3. Comparison: Easier to check: if (this.selectedShape === 'c1')
  4. Memory: Lighter weight than storing object references

Selection Lifecycle:

  1. User clicks shape → selectedShape = 'c1'
  2. Shape highlighted (outline drawn)
  3. User clicks elsewhere → selectedShape = null
  4. User clicks different shape → selectedShape = 'r1'

Hover Lifecycle:

  1. Mouse moves over shape → hoveredShape = 'c1'
  2. Cursor changes, hover effect shown
  3. Mouse leaves shape → hoveredShape = null
  4. Mouse moves over different shape → hoveredShape = 'r1'

Step 2: Initialize Subsystems

The Renderer is composed of multiple subsystems, each with a single responsibility. This separation follows the Single Responsibility Principle, making the code easier to understand, test, and maintain. Each subsystem can be tested independently, and changes to one don't affect others.

Subsystem Responsibilities:

  1. CoordinateSystem: Converts between world coordinates (millimeters, real units) and screen coordinates (pixels). It handles zoom, pan, and grid rendering. Shapes exist in world space (e.g., a 50mm circle), but the canvas uses pixels (e.g., 800px wide). The coordinate system maps between these: 1mm in world = X pixels on screen, accounting for zoom and pan.

  2. StyleManager: Handles all styling concerns including color resolution (named colors like "red", hex colors like "#FF0000", RGB/RGBA strings), fill styles, stroke styles, and opacity. It provides a centralized place to resolve colors to canvas-compatible formats.

  3. ShapeRenderer: Actually draws shapes to the canvas. It takes shape objects, extracts their geometry, applies transforms, converts to screen coordinates, and uses canvas API methods to draw. Different shape types may use optimized drawing methods (e.g., circles use arc(), polygons use beginPath() and lineTo()).

Initialization Order:

The initialization order matters because some subsystems depend on others:

  • CoordinateSystem needs the canvas (for dimensions: canvas.width, canvas.height)
  • ShapeRenderer needs the context (for drawing: ctx.fill(), ctx.stroke(), etc.)
  • StyleManager is independent (no dependencies, pure utility class)
initializeComponents() {
    // Create coordinate system - handles world ↔ screen coordinate conversion
    this.coordinateSystem = new CoordinateSystem(this.canvas);

    // Create style manager - handles colors, fills, strokes, opacity
    this.styleManager = new ShapeStyleManager();

    // Create shape renderer - actually draws shapes to canvas
    this.shapeRenderer = new ShapeRenderer(this.ctx);
}

Why Separate Components:

This architecture provides several benefits:

Single Responsibility: Each component does one thing well. CoordinateSystem only handles coordinate conversion, StyleManager only handles styling, and ShapeRenderer only handles drawing. This makes each component easier to understand.

Testability: Each subsystem can be tested independently. You can test coordinate conversion without needing the renderer, or test color resolution without needing the canvas.

Maintainability: Changes to one component don't affect others. If you need to change how colors are resolved, you only modify StyleManager. The renderer doesn't need to know about color resolution details.

Reusability: Components can be reused in other contexts. The StyleManager could be used in other parts of the application, or the CoordinateSystem could be used for a different rendering system.

Performance: This separation allows for optimization. Each component can be optimized independently - for example, the ShapeRenderer can use optimized drawing methods for specific shape types without affecting coordinate conversion logic.

The Complete Basic Structure:

export class Renderer {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.shapes = new Map();  // Shape name → shape object
        this.selectedShape = null;
        this.hoveredShape = null;

        // Initialize subsystems
        this.initializeComponents();
        this.setupCanvas();
    }

    initializeComponents() {
        // We'll add these as we build them
        this.coordinateSystem = new CoordinateSystem(this.canvas);
        this.styleManager = new ShapeStyleManager();
        this.shapeRenderer = new ShapeRenderer(this.ctx);
    }
}

Building This Step by Step:

  1. Create Renderer class with constructor(canvas) parameter
  2. Store canvas element as this.canvas
  3. Get 2D context as this.ctx
  4. Create shapes Map for storing shapes
  5. Initialize selectedShape and hoveredShape to null
  6. Add initializeComponents() method
  7. Create coordinate system, style manager, and shape renderer
  8. Add setupCanvas() method (implement later)

Building the Shape Receiving Method From Scratch

When code runs, the interpreter returns shapes. You need to give them to the renderer so they can be drawn.

How to Build It Step by Step:

Step 1: Create the setShapes() Method This method receives shapes from the interpreter and updates the renderer:

setShapes(shapes) {
    // Step 1.1: Store the reference (don't copy)
    // shapes is a Map from the interpreter
    // We store the reference, not a copy
    // This means both interpreter and renderer point to the same objects
    //
    // DETAILED EXPLANATION:
    // The shapes parameter is a Map<string, ShapeObject> from the interpreter.
    // We store the reference directly, not a copy. This creates shared state.
    //
    // Why store reference instead of copy?
    // - Efficiency: Copying large Maps is expensive (O(n) time and memory)
    // - Synchronization: Changes to shapes are automatically reflected
    //   Example: If parameter slider modifies shape.params.radius,
    //   renderer immediately sees the change (same object reference)
    // - Memory: Only one copy exists, not two
    //
    // What happens:
    // Interpreter creates: const shapes = new Map();
    // Interpreter adds: shapes.set('c1', circleObject);
    // Renderer stores: this.shapes = shapes; (same Map object)
    // Both point to same Map and same shape objects:
    //   interpreter.shapes → Map { 'c1' → circleObject }
    //   renderer.shapes   → Map { 'c1' → circleObject } (same object!)
    //
    // If we copied (WRONG):
    //   this.shapes = new Map(shapes);
    //   → Creates new Map, but shape objects still shared (shallow copy)
    //   → Better than deep copy, but new Map is unnecessary overhead
    //
    // Memory diagram:
    //   interpreter.shapes → Map object at memory address 0x1000
    //   renderer.shapes   → Map object at memory address 0x1000 (same!)
    //   Both point to same Map in memory
    //
    // Shape object sharing:
    //   Both Maps contain reference to same shape object:
    //   'c1' → ShapeObject at 0x2000
    //   If interpreter modifies shapeObject.params.radius = 100,
    //   renderer sees radius = 100 immediately (same object)
    //
    this.shapes = shapes;

    // Step 1.2: Clear selection/hover state
    // When shapes are updated, old selection might be invalid
    // Clear it so user doesn't see stale selection
    //
    // DETAILED EXPLANATION:
    // When shapes are replaced (new code runs), the previous selection might:
    // - Reference a shape that no longer exists (shape was deleted)
    // - Reference a shape that was renamed (old name invalid)
    // - Be pointing to wrong shape (shapes Map replaced completely)
    //
    // Why clear selection?
    // - Prevents errors: Trying to draw selection highlight on non-existent shape
    // - Prevents confusion: User sees selection on wrong shape
    // - Clean state: Fresh start after code execution
    //
    // Example scenario:
    // 1. User selects shape 'c1', draws circle
    // 2. User changes code: deletes circle, creates rectangle 'r1'
    // 3. setShapes() called with new Map (no 'c1', has 'r1')
    // 4. If we don't clear: selectedShape = 'c1' (invalid!)
    // 5. Redraw tries to highlight 'c1' → shape not found → error
    // 6. Solution: Clear selection before redraw
    //
    // Selection state lifecycle:
    // - Before setShapes(): selectedShape = 'c1' (old shape)
    // - After setShapes(): selectedShape = null (cleared)
    // - User can select new shape: selectedShape = 'r1' (if they click it)
    //
    // Alternative approach (validate instead of clear):
    //   if (this.selectedShape && !this.shapes.has(this.selectedShape)) {
    //       this.selectedShape = null;
    //   }
    // This preserves selection if shape still exists, but clearing is simpler.
    //
    this.selectedShape = null;
    this.hoveredShape = null;

    // Step 1.3: Redraw everything
    // New shapes means we need to redraw the canvas
    // This makes the new shapes appear immediately
    //
    // DETAILED EXPLANATION:
    // After updating shapes, the canvas needs to be redrawn to show the changes.
    // redraw() is the method that actually draws all shapes to the canvas.
    //
    // Why redraw immediately?
    // - User expects to see shapes right after code runs
    // - If we don't redraw, old shapes remain on canvas (stale visual state)
    // - Immediate feedback improves user experience
    //
    // What redraw() does:
    // 1. Clears the canvas (removes old drawing)
    // 2. Draws background (grid, rulers, etc.)
    // 3. Iterates through this.shapes Map
    // 4. For each shape: calls shapeRenderer.drawShape()
    // 5. Draws selection highlights (if shape is selected)
    // 6. Draws hover effects (if shape is hovered)
    //
    // Performance consideration:
    // - redraw() might be called multiple times rapidly (during dragging, etc.)
    // - Could use requestAnimationFrame to throttle redraws
    // - For now, immediate redraw is fine (simpler, works for most cases)
    //
    // Redraw triggers:
    // - setShapes() - new shapes added (this case)
    // - Shape modified (parameter changed)
    // - Selection changed (user clicks shape)
    // - Zoom/pan changed (view changed)
    // - Style changed (color, fill, etc.)
    //
    this.redraw();
}

Why Store a Reference: The interpreter and renderer share the same shape objects. If something modifies a shape (like a parameter slider), both systems see the change. No need to sync - they're the same objects. This is efficient and prevents synchronization issues.

If you see an error at this step:

Error: TypeError: Cannot read property 'shapes' of undefined

  • What this means: result is undefined or doesn't have shapes property
  • Common causes:
    1. Interpreter didn't return result: interpreter.interpret(ast) returns undefined
    2. Result doesn't have shapes: result exists but no shapes property
    3. Wrong property name: result.shape instead of result.shapes
  • Fix: Check interpreter returns { shapes: ... }, add check: if (!result?.shapes) throw new Error('No shapes');

Error: TypeError: shapes.get is not a function or shapes.size is undefined

  • What this means: shapes is not a Map
  • Common causes:
    1. Interpreter returns object instead of Map: { shapes: {} } instead of { shapes: new Map() }
    2. shapes is an array: { shapes: [] } instead of Map
    3. shapes is null or undefined
  • Fix: Check interpreter creates Map: result.shapes = new Map();, or convert: this.shapes = new Map(Object.entries(shapes));

Error: Nothing appears on canvas after setShapes()

  • What this means: redraw() not working or shapes empty
  • Common causes:
    1. redraw() not implemented yet
    2. shapes Map is empty: shapes.size === 0
    3. Error in redraw() method (check console)
    4. Shapes don't have required properties (type, params)
  • Fix: Check shapes.size > 0, implement redraw(), check console for errors, verify shape structure

Error: Shapes don't update when changed (stale data)

  • What this means: Storing copy instead of reference
  • Common causes:
    1. Copying Map: this.shapes = new Map(shapes) creates new Map (loses reference to shape objects)
    2. Deep copying shape objects
  • Fix: Store reference: this.shapes = shapes; (not a copy)

The Complete Method:

setShapes(shapes) {
    // Store the reference (don't copy)
    this.shapes = shapes;

    // Clear selection/hover state
    this.selectedShape = null;
    this.hoveredShape = null;

    // Redraw everything
    this.redraw();
}

How It's Used: In app.js, after code runs:

function runCode() {
    // ... lexer, parser, interpreter ...
    const result = interpreter.interpret(ast);

    // Pass shapes to renderer
    renderer.setShapes(result.shapes);
}

The interpreter's result.shapes is a Map. We pass it directly. The renderer stores the reference and redraws.

Building This Step by Step:

  1. Create setShapes(shapes) method
  2. Store shapes reference (not copy)
  3. Clear selectedShape and hoveredShape
  4. Call redraw() to update the canvas

In app.js:

function runCode() {
    // ... lexer, parser, interpreter ...
    const result = interpreter.interpret(ast);

    // Pass shapes to renderer
    renderer.setShapes(result.shapes);
}

The interpreter's result.shapes is a Map. We pass it directly. The renderer stores the reference and redraws.

Building the Coordinate System From Scratch

The coordinate system is crucial. Shapes exist in "world coordinates" (millimeters, real units). The canvas is in "screen coordinates" (pixels). You need to convert between them.

Why Two Coordinate Systems:

  • Shapes think in real units (50mm circle)
  • Canvas thinks in pixels (maybe 200px wide)
  • You need to map between them, handle zoom, pan, etc.

How to Build It Step by Step:

Step 1: Create the CoordinateSystem Class

The CoordinateSystem class manages the conversion between two coordinate spaces: world coordinates (millimeters, real units) and screen coordinates (pixels). It needs access to the canvas element to get dimensions and the context for drawing the grid.

Understanding Scale:

Scale is the zoom factor that converts between world units and pixels. It's a multiplier that determines how large shapes appear on screen:

  • scale = 1: 1 pixel = 1 millimeter (true scale, no zoom)
  • scale = 2: 1 pixel = 0.5 millimeter (zoomed in 2x, shapes appear 2x larger)
  • scale = 0.5: 1 pixel = 2 millimeters (zoomed out 2x, shapes appear 2x smaller)

Scale Usage in Formulas:

  • worldToScreen: screenX = worldX * scale + offset
  • screenToWorld: worldX = (screenX - offset) / scale

Understanding Pan Offset:

Pan offset shifts the view to show different parts of world space. It's added to world coordinates before scaling to screen coordinates. When the user drags the canvas to pan, the pan offset changes to reflect which part of world space is currently visible at the canvas center.

Understanding Canvas Center:

The canvas center is where the world origin (0, 0) is drawn. For a canvas that's 800×600 pixels, the center is at (400, 300). We center the origin because world space extends equally in all directions, making it intuitive for users.

export class CoordinateSystem {
    constructor(canvas) {
        if (!canvas) {
            throw new Error('CoordinateSystem requires canvas element');
        }

        // Store canvas and context for dimensions and drawing
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');

        // Initialize scale: 1 = 1:1 mapping (1 pixel = 1mm)
        // Higher values = zoomed in, lower values = zoomed out
        this.scale = 1;

        // Initialize pan offset (for user panning/dragging the view)
        // { x: 0, y: 0 } means no panning, world origin at canvas center
        this.panOffset = { x: 0, y: 0 };

        // Calculate canvas center (where world origin (0, 0) is drawn)
        // Center moves when canvas is resized, so we recalculate it in setupCanvas()
        this.offsetX = canvas.width / 2;
        this.offsetY = canvas.height / 2;

        // Initialize grid settings (visual aid for alignment)
        this.isGridEnabled = true;
        this.gridSize = 10;  // 10mm grid spacing
    }
}

Why Store offsetX/offsetY Separately:

The canvas center (offsetX, offsetY) and pan offset (panOffset.x, panOffset.y) serve different purposes:

  • offsetX/offsetY: Always represents the canvas center - where world origin (0, 0) is drawn. This changes when the canvas is resized (window resize event), so we recalculate it.
  • panOffset: Represents user panning - which part of world space is currently visible at the canvas center. This changes when the user drags to pan the view.

When converting world coordinates to screen coordinates, we need both: screenX = worldX * scale + offsetX - panOffset.x * scale. The offset centers the coordinate system, while panOffset shifts what's visible.

Step 2: Build the setupCanvas() Method

This method configures the canvas size and recalculates the center offset. It's called during initialization and whenever the canvas needs to be resized (e.g., window resize event).

Why Set Canvas Size Directly:

Setting canvas.width and canvas.height directly is critical. CSS width and height properties just scale the canvas element visually, but don't change the actual pixel resolution. The canvas defaults to 300×150 pixels unless you set these properties explicitly. This affects both rendering quality (blurry if resolution is too low) and coordinate calculations (wrong dimensions).

setupCanvas() {
    // Get the container element (canvas's parent, usually a div)
    const container = this.canvas.parentElement;

    // Set canvas pixel resolution to match container size
    // This is critical - CSS width/height just scale, don't change resolution
    this.canvas.width = container.clientWidth;
    this.canvas.height = container.clientHeight;

    // Recalculate center offset (center moves when canvas size changes)
    this.offsetX = this.canvas.width / 2;
    this.offsetY = this.canvas.height / 2;
}

The Complete Basic Structure:

export class CoordinateSystem {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');

        // TRUE 1:1 scale - 1 pixel = 1mm (no scaling)
        this.scale = 1;
        this.panOffset = { x: 0, y: 0 };

        // Canvas center (where world origin (0,0) appears)
        this.offsetX = canvas.width / 2;
        this.offsetY = canvas.height / 2;

        // Grid settings
        this.isGridEnabled = true;
        this.gridSize = 10;  // 10mm grid
    }

    setupCanvas() {
        const container = this.canvas.parentElement;
        this.canvas.width = container.clientWidth;
        this.canvas.height = container.clientHeight;

        // Center the coordinate system
        this.offsetX = this.canvas.width / 2;
        this.offsetY = this.canvas.height / 2;
    }
}

Building This Step by Step:

  1. Create CoordinateSystem class with constructor(canvas)
  2. Store canvas and context
  3. Initialize scale to 1 (1:1 mapping)
  4. Initialize panOffset to { x: 0, y: 0 }
  5. Calculate initial center offsets (offsetX, offsetY)
  6. Initialize grid settings
  7. Add setupCanvas() method to resize canvas and recalculate center

Building World to Screen Conversion From Scratch

You need to convert world coordinates (millimeters) to screen coordinates (pixels). This is essential for drawing shapes.

How to Build It Step by Step:

Step 1: Build the transformX() Method

The transformX() method converts a world X coordinate (millimeters) to a screen X coordinate (pixels). The conversion involves three steps: accounting for panning, applying scale, and adding the center offset.

The Conversion Process:

The full conversion formula with scale is:

screenX = (worldX - panOffset.x) * scale + offsetX

Step-by-step transformation:

  1. Apply pan offset: worldX - panOffset.x - This shifts the world coordinate relative to the panned origin
  2. Apply scale: (worldX - panOffset.x) * scale - This converts millimeters to pixels
  3. Add center offset: + offsetX - This positions the point relative to the canvas center

Example walkthrough:

  • World point: x = 100mm
  • Scale: 2 (zoomed in 2x)
  • Pan offset: x = 50mm (panned right 50mm)
  • Canvas center: offsetX = 400px

Step-by-step:

  1. Apply pan: 100 - 50 = 50mm (relative to panned origin)
  2. Apply scale: 50 * 2 = 100px (convert to pixels)
  3. Add center: 100 + 400 = 500px (position on canvas)

Result: screenX = 500px

transformX(x) {
    // Convert world X (mm) to screen X (pixels)
    // Formula: screenX = (worldX - panOffset.x) * scale + offsetX
    // Simplified version (scale handled elsewhere or = 1):
    return x + this.offsetX + this.panOffset.x;
}

Understanding World vs Screen Coordinates:

World Coordinates (Millimeters): Shapes exist in world space with real units (millimeters, inches, etc.). The origin (0, 0) is at the center of the "world", and coordinates can be positive or negative, extending infinitely in all directions. For example, a circle might be at (100mm, 50mm) from the world origin.

Screen Coordinates (Pixels): The canvas uses pixel coordinates with the origin at top-left (0, 0). X increases to the right, Y increases downward (standard screen convention). Coordinates range from 0 to canvas.width/canvas.height. For example, a circle might be drawn at (500px, 300px) from the canvas top-left.

The Complete Conversion Process:

  1. Account for Panning: Pan offset shifts what part of world space is visible. If panned right 100mm, a world point at 100mm appears at screen center. Formula: worldX - panOffset.x

  2. Apply Scale (Zoom): Scale converts millimeters to pixels. Scale = 2 means 1mm = 2 pixels (zoomed in). Formula: (worldX - panOffset.x) * scale

  3. Add Center Offset: Canvas center is where world origin (0, 0) is drawn. Center offset positions the scaled point relative to canvas. Formula: + offsetX

Complete Formula:

screenX = (worldX - panOffset.x) * scale + offsetX

Why This Formula Works:

  • World origin (0, 0) maps to screen center (offsetX, offsetY)
  • Panning shifts which world point appears at center
  • Scaling changes how many pixels represent 1mm
  • Combining all three gives the correct screen position

Step 2: Build the transformY() Method

Y-axis conversion is more complex than X-axis because the canvas Y-axis is inverted compared to world space. Canvas uses standard screen coordinates where Y increases downward (top is 0, bottom is height), while world space uses mathematical coordinates where Y increases upward.

The Problem:

  • World point at (0, 50) should appear above center on screen
  • But in canvas coordinates, (0, 50) would be below the top of the canvas
  • We need to flip the Y-axis to convert between these conventions

Solution: Negate World Y

By negating the world Y coordinate, we flip the axis direction:

  • World Y = +50 (up) → Negated: -50 → Added to center: 250px (above center) ✓
  • World Y = -30 (down) → Negated: 30 → Added to center: 330px (below center) ✓

The Formula:

screenY = -worldY + offsetY + panOffset.y

Step-by-step:

  1. Negate worldY: -worldY (flips the axis direction)
  2. Add center offset: + offsetY (positions relative to canvas center)
  3. Add pan offset: + panOffset.y (accounts for user panning)
transformY(y) {
    // Convert world Y (mm) to screen Y (pixels)
    // Must negate Y because canvas Y increases downward, world Y increases upward
    // Formula: screenY = -worldY + offsetY + panOffset.y
    return -y + this.offsetY + this.panOffset.y;
}

DETAILED EXPLANATION OF Y-AXIS FLIPPING:

Why Canvas Y-Axis is Flipped: Canvas uses the standard screen coordinate system where:

  • Origin (0, 0) is at the top-left corner
  • X increases to the right (same as world space)
  • Y increases downward (opposite to world space)

This is different from mathematical coordinates where Y increases upward.

The Coordinate System Difference:

Canvas (Screen Space):          World Space (Mathematical):
┌─────────────────┐            ┌─────────────────┐
│  (0, 0)         │            │                 │
│                 │            │      +Y ↑       │
│                 │            │        │        │
│        ●        │            │   -X ←─●─→ +X   │
│    (center)     │            │        │        │
│                 │            │      -Y ↓       │
│          (w, h) │            │                 │
└─────────────────┘            └─────────────────┘

Complete Example:

World point: (100, 50)  → Should appear 100mm right, 50mm UP from center
Canvas: 800×600, center at (400, 300)

Transform X:
  screenX = 100 + 400 + 0 = 500px  (100px right of center) ✓

Transform Y:
  screenY = -50 + 300 + 0 = 250px  (50px above center) ✓

Result: (500, 250) on screen
- X = 500: 100px right of center (400) ✓
- Y = 250: 50px above center (300) ✓

Mathematical Proof: To verify the formula is correct, let's check round-trip conversion:

World point: (0, 50)

Forward transform (world → screen):
  screenY = -50 + 300 + 0 = 250

Reverse transform (screen → world):
  worldY = -(250 - 300 - 0) = -(-50) = 50 ✓

Original: 50, After round-trip: 50 ✓ Formula is correct!

If you see an error at this step:

Error: TypeError: Cannot read property 'offsetX' of undefined

  • What this means: coordinateSystem is undefined
  • Common causes:
    1. coordinateSystem not created in renderer constructor
    2. Typo: this.coordinateSystem vs this.coordinate or this.coords
    3. Not initialized before use
  • Fix: Check constructor creates it: this.coordinateSystem = new CoordinateSystem(this.canvas);

Error: NaN values from worldToScreen()

  • What this means: Math calculation resulted in NaN
  • Common causes:
    1. this.offsetX or offsetY is undefined → worldX + undefined = NaN
    2. canvas.width is undefined → canvas.width / 2 = NaN
    3. this.scale is undefined (if using scale)
  • Fix: Check all properties initialized in constructor, verify canvas.width exists

Error: Shapes appear at wrong position (not at canvas center)

  • What this means: Offset calculation wrong
  • Common causes:
    1. offsetX/Y calculated before canvas size is set: this.offsetX = canvas.width / 2 when canvas.width is still default 300
    2. Canvas size not set: canvas.width still 300, but CSS makes it look bigger
    3. Wrong calculation: canvas.width / 2 typo or wrong property
  • Fix: Set canvas size first, then calculate offset, or recalculate offset when canvas resizes

Error: Y coordinates inverted (shapes appear upside down)

  • What this means: Forgot to negate Y in transformY
  • Common causes:
    1. Using y instead of -y: return y + this.offsetY instead of return -y + this.offsetY
    2. Wrong sign in screenToWorld conversion
  • Fix: Check transformY uses -y for world→screen, screenToWorld also negates for reverse

Step 3: Build the worldToScreen() Method

This is a convenience method that converts a complete 2D point from world coordinates to screen coordinates. It combines transformX() and transformY() into a single call, making the API cleaner and ensuring both coordinates are always converted together.

Understanding the Method Structure:

The worldToScreen() method takes a point in world coordinates (millimeters) and returns a point in screen coordinates (pixels). This method is the primary interface for coordinate conversion - most code will call this method rather than transformX() and transformY() separately.

Why Combine Into One Method - Detailed Analysis:

Convenience: When converting a point, you always need both X and Y coordinates. Having a single method that returns both coordinates in one call is much more convenient than calling two separate methods:

// Without combined method (cumbersome):
const screenX = coordinateSystem.transformX(worldX);
const screenY = coordinateSystem.transformY(worldY);
const screenPoint = { x: screenX, y: screenY };

// With combined method (clean):
const screenPoint = coordinateSystem.worldToScreen(worldX, worldY);

The combined method reduces code from 3 lines to 1 line, making the code more readable and less error-prone.

Consistency: When you call transformX() and transformY() separately, there's a risk that they might be called at different times or with different coordinate system states (if scale or pan changes between calls). The combined method ensures both coordinates are converted using the same state, guaranteeing consistency.

Example of Inconsistency Problem:

// BAD: Separate calls might use different states
const screenX = coordinateSystem.transformX(worldX);  // Uses scale = 1.0
// ... user zooms in (scale changes to 2.0) ...
const screenY = coordinateSystem.transformY(worldY);  // Uses scale = 2.0
// Result: X and Y converted with different scales = wrong!

With combined method:

// GOOD: Both coordinates converted atomically
const screenPoint = coordinateSystem.worldToScreen(worldX, worldY);
// Both X and Y use the same scale/pan state = correct!

Efficiency: While the performance difference is minimal, calling one method instead of two reduces:

  • Function call overhead (one call vs two)
  • Code size (one method call vs two)
  • Stack usage (one function call frame vs two)

Parameter Handling: The method accepts coordinates in two ways:

  1. Two separate parameters: worldToScreen(x, y) - clear and explicit
  2. Single point object: worldToScreen({x, y}) - convenient when you already have a point object

Both formats are supported for flexibility:

// Format 1: Separate parameters
const screen = worldToScreen(100, 50);

// Format 2: Point object
const worldPoint = { x: 100, y: 50 };
const screen = worldToScreen(worldPoint.x, worldPoint.y);
// Or if method supports: worldToScreen(worldPoint)

Return Value Structure: The method returns an object with x and y properties. This matches common JavaScript conventions and is easy to work with:

const screen = worldToScreen(100, 50);
console.log(screen.x);  // Access X coordinate
console.log(screen.y);  // Access Y coordinate

// Can be destructured:
const { x, y } = worldToScreen(100, 50);

// Can be used directly:
ctx.moveTo(screen.x, screen.y);

Error Handling: The method should validate inputs and handle edge cases:

  • Invalid inputs: null, undefined, NaN values
  • Missing parameters: What if only one coordinate is provided?
  • Non-numeric values: Strings, objects, etc.

Proper error handling prevents runtime errors and provides clear feedback when something is wrong.

Complete Implementation Pattern:

worldToScreen(worldX, worldY) {
    // Step 3.1: Validate inputs
    // Check for null, undefined, or non-numeric values
    if (worldX == null || worldY == null) {
        throw new Error('worldToScreen requires both x and y coordinates');
    }

    // Convert to numbers (handles string inputs like "100")
    const x = Number(worldX);
    const y = Number(worldY);

    // Check for NaN after conversion
    if (isNaN(x) || isNaN(y)) {
        throw new Error(`Invalid coordinates: (${worldX}, ${worldY})`);
    }

    // Step 3.2: Convert X coordinate
    // Use the transformX method we built earlier
    const screenX = this.transformX(x);

    // Step 3.3: Convert Y coordinate
    // Use the transformY method we built earlier
    const screenY = this.transformY(y);

    // Step 3.4: Return as object
    // Return an object with x and y properties
    // This matches the input format and is easy to work with
    return {
        x: screenX,
        y: screenY
    };
}

Why Return an Object: Returning an object { x, y } instead of an array [x, y] has several advantages:

  • Named properties: result.x and result.y are clearer than result[0] and result[1]
  • Self-documenting: The property names make it obvious what each value represents
  • Destructuring friendly: const { x, y } = worldToScreen(...) is intuitive
  • Type safety: Objects with properties are easier to validate and type-check

Alternative Return Formats: Some implementations return arrays or tuples:

// Array format (less readable):
return [screenX, screenY];

// Usage:
const [x, y] = worldToScreen(100, 50);

But object format is generally preferred for clarity and maintainability.

Performance Optimization: The method can be optimized by:

  • Caching calculations: If scale/pan don't change, cache intermediate results
  • Inlining: For hot paths, inline transformX/Y code directly
  • Validation: Only validate in debug mode, skip in production

However, for most use cases, the simple implementation above is sufficient and clear.

Usage Examples:

// Example 1: Convert a single point
const worldPoint = { x: 100, y: 50 };
const screenPoint = coordinateSystem.worldToScreen(worldPoint.x, worldPoint.y);
ctx.fillRect(screenPoint.x, screenPoint.y, 5, 5);  // Draw small square

// Example 2: Convert multiple points (loop)
for (const point of shapePoints) {
    const screen = coordinateSystem.worldToScreen(point.x, point.y);
    ctx.lineTo(screen.x, screen.y);
}

// Example 3: Destructuring for cleaner code
for (const worldPoint of shapePoints) {
    const { x, y } = coordinateSystem.worldToScreen(worldPoint.x, worldPoint.y);
    ctx.lineTo(x, y);
}

// Example 4: Check if point is visible on screen
const screen = coordinateSystem.worldToScreen(shapeX, shapeY);
const isVisible = screen.x >= 0 && screen.x < canvas.width &&
                  screen.y >= 0 && screen.y < canvas.height;

Integration with Renderer: The renderer uses this method extensively:

// In renderer.redraw():
for (const [name, shape] of shapes) {
    const points = shape.getPoints();
    for (const worldPoint of points) {
        const screenPoint = coordinateSystem.worldToScreen(worldPoint.x, worldPoint.y);
        ctx.lineTo(screenPoint.x, screenPoint.y);
    }
}

This ensures all shape points are correctly converted from world coordinates to screen coordinates for drawing.

  • Cleaner API: worldToScreen(100, 50) is clearer than separate calls

Return Format: Returns an object with x and y properties containing screen coordinates in pixels. We use object format instead of array because:

  • Self-documenting: screen.x is clearer than screen[0]
  • Named properties make the code more readable
  • Consistent with common point representation patterns
worldToScreen(wx, wy) {
    // Convert both X and Y coordinates using individual transform methods
    return {
        x: this.transformX(wx),
        y: this.transformY(wy)
    };
}

DETAILED USAGE EXAMPLES:

Example 1: Converting Shape Center

// Shape at world position (100mm, 50mm)
const worldCenter = { x: 100, y: 50 };

// Convert to screen coordinates
const screenCenter = coordSystem.worldToScreen(worldCenter.x, worldCenter.y);
// Result: { x: 500, y: 250 }  (example, depends on scale/pan)

// Use to draw shape at correct position
ctx.beginPath();
ctx.arc(screenCenter.x, screenCenter.y, radiusInPixels, 0, Math.PI * 2);
ctx.fill();

Example 2: Converting All Points of a Shape

// Shape has points in world coordinates
const worldPoints = [
    { x: 0, y: 0 },
    { x: 100, y: 0 },
    { x: 100, y: 50 },
    { x: 0, y: 50 }
];

// Convert all points to screen coordinates
const screenPoints = worldPoints.map(point => 
    coordSystem.worldToScreen(point.x, point.y)
);
// Result: Array of screen coordinate points

// Draw as path
ctx.beginPath();
screenPoints.forEach((point, i) => {
    if (i === 0) {
        ctx.moveTo(point.x, point.y);
    } else {
        ctx.lineTo(point.x, point.y);
    }
});
ctx.closePath();
ctx.stroke();

Example 3: With Scale and Pan

// Setup: scale = 2, pan = (50, 25), center = (400, 300)
coordSystem.scale = 2;
coordSystem.panOffset = { x: 50, y: 25 };
coordSystem.offsetX = 400;
coordSystem.offsetY = 300;

// World point: (100, 50)
const screen = coordSystem.worldToScreen(100, 50);

// Calculation:
// X: (100 - 50) * 2 + 400 = 50 * 2 + 400 = 500px
// Y: -((50 - 25) * 2) + 300 = -50 + 300 = 250px
// Result: { x: 500, y: 250 }

// Verify visually:
// - World point is 100mm right of origin, 50mm up
// - After pan: 50mm right of panned origin, 50mm up
// - After scale: 100px right, 100px up (from panned origin)
// - After center offset: 500px from left, 250px from top

Why Return Object Instead of Array:

// Object format: { x: 500, y: 250 }
const screen = coordSystem.worldToScreen(100, 50);
console.log(screen.x, screen.y);  // Clear: 500 250

// Array format: [500, 250] (alternative)
const screen = coordSystem.worldToScreen(100, 50);  // Would return [500, 250]
console.log(screen[0], screen[1]);  // Less clear: what is [0] and [1]?

Object format benefits:

  • Self-documenting: screen.x is clearer than screen[0]
  • Named properties: Can see what each value represents
  • Consistent with input format: Often receive points as {x, y} objects
  • Type safety: Easier to validate structure

The Complete Methods:

transformX(x) {
    // World X → Screen X
    // Add pan offset, then add canvas center
    return x + this.offsetX + this.panOffset.x;
}

transformY(y) {
    // World Y → Screen Y
    // Flip Y (canvas Y increases down, world Y increases up)
    // Add pan offset, then add canvas center
    return -y + this.offsetY + this.panOffset.y;
}

worldToScreen(wx, wy) {
    return {
        x: this.transformX(wx),
        y: this.transformY(wy)
    };
}

The Coordinate Transformation Math: For X, it's simple addition - world coordinate plus center offset plus any panning. For Y, we negate the world coordinate because canvas Y axis is flipped (increases downward) compared to mathematical coordinates (increase upward). A world point at (0, 0) should appear at the canvas center, so transformX(0) = 0 + offsetX + 0 = offsetX (center X), and transformY(0) = -0 + offsetY + 0 = offsetY (center Y). A world point at (100, 50) appears 100px right and 50px up from center (since we negate Y, positive world Y becomes negative screen Y, which means higher on screen).

Building This Step by Step:

  1. Create transformX(x) method that adds offsetX and panOffset.x
  2. Create transformY(y) method that negates Y, then adds offsetY and panOffset.y
  3. Create worldToScreen(wx, wy) method that calls both transform methods
  4. Return object with transformed x and y coordinates

Building Screen to World Conversion From Scratch

You also need to convert screen coordinates (pixels) back to world coordinates (millimeters). This is essential for mouse interaction.

How to Build It Step by Step:

Step 1: Create the screenToWorld() Method

This method reverses the world-to-screen transformation. When users interact with the canvas (clicks, drags, hovers), we get screen coordinates from mouse events. But shapes exist in world space, so we need to convert back to determine which shape was clicked or where to position shapes during dragging.

Why We Need screenToWorld():

Mouse events provide screen coordinates (pixels from top-left), but all our shape operations work in world coordinates (millimeters). We need to convert screen → world for:

  • Mouse click detection: Convert click position to world, check which shape contains that point
  • Drag operations: Convert mouse position to world, update shape position
  • Hit testing: Convert screen coordinates to world, test if point is inside shape bounds
  • Coordinate display: Show world coordinates at mouse cursor position

Mathematical Derivation of Reverse Transform:

To reverse the forward transform, we undo each operation in reverse order.

For X:

  • Forward: screenX = worldX + offsetX + panOffset.x
  • Solve for worldX: worldX = screenX - offsetX - panOffset.x
  • We subtract instead of add (opposite operation)

For Y:

  • Forward: screenY = -worldY + offsetY + panOffset.y
  • Step 1: Subtract offsets: screenY - offsetY - panOffset.y = -worldY
  • Step 2: Negate both sides: -(screenY - offsetY - panOffset.y) = worldY
  • Result: worldY = -screenY + offsetY + panOffset.y
  • We negate again to flip the axis back

Complete Round-Trip Verification:

To verify the formula is correct, we can test round-trip conversion:

  • Original world point: (100, 50)
  • Forward: world → screen → (500, 250)
  • Reverse: screen → world → (100, 50) ✓ (matches original!)
screenToWorld(sx, sy) {
    // Reverse X transform: subtract offsets instead of adding
    const worldX = sx - this.offsetX - this.panOffset.x;

    // Reverse Y transform: subtract offsets, then negate to flip axis back
    const worldY = -(sy - this.offsetY - this.panOffset.y);

    // Return world coordinates in millimeters
    return { x: worldX, y: worldY };
}

DETAILED EXPLANATION OF REVERSE TRANSFORM - Step-by-Step Breakdown:

The reverse transform is crucial for converting user interactions (mouse clicks, drags) from screen coordinates back to world coordinates. Understanding how to reverse the forward transform requires understanding each step in detail.

Understanding the Forward Transform (Reminder):

Before we can reverse it, let's recall what the forward transform does:

screenX = worldX + offsetX + panOffset.x
screenY = -worldY + offsetY + panOffset.y

Breaking Down the Reverse Transform:

For X Coordinate (Simpler Case):

The forward transform for X is:

screenX = worldX + offsetX + panOffset.x

To reverse this, we need to solve for worldX:

screenX = worldX + offsetX + panOffset.x
screenX - offsetX - panOffset.x = worldX
worldX = screenX - offsetX - panOffset.x

Step-by-Step Reverse X Calculation:

  1. Start with screen X: screenX = 500px (example)
  2. Subtract canvas center offset: 500 - 400 = 100px (removes center offset)
  3. Subtract pan offset: 100 - 50 = 50px (removes pan offset)
  4. Result: worldX = 50mm (back to world coordinates)

For Y Coordinate (More Complex - Requires Negation):

The forward transform for Y is:

screenY = -worldY + offsetY + panOffset.y

To reverse this, we need to solve for worldY:

Step 1: Isolate the worldY term

screenY = -worldY + offsetY + panOffset.y
screenY - offsetY - panOffset.y = -worldY

Step 2: Negate both sides to get worldY

-(screenY - offsetY - panOffset.y) = -(-worldY)
-worldY = -(screenY - offsetY - panOffset.y)
worldY = -(screenY - offsetY - panOffset.y)

Step 3: Expand the negation

worldY = -screenY + offsetY + panOffset.y

Why Y Requires Negation:

The forward transform negates world Y to flip the axis (canvas Y increases down, world Y increases up). To reverse this, we must negate again, which flips the axis back:

  • Forward: screenY = -worldY + ... (flips Y: positive world Y → negative screen Y)
  • Reverse: worldY = -screenY + ... (flips Y back: negative screen Y → positive world Y)

Complete Step-by-Step Reverse Y Calculation:

  1. Start with screen Y: screenY = 250px (example)
  2. Subtract offsets: 250 - 300 - 25 = -75px
  3. Negate: -(-75) = 75mm (flips axis back)
  4. Result: worldY = 75mm (back to world coordinates)

Mathematical Verification:

Let's verify the reverse transform works correctly by testing a round-trip conversion:

Test Case 1: World Point (100, 50)

Forward Transform (world → screen):
  screenX = 100 + 400 + 0 = 500px
  screenY = -50 + 300 + 0 = 250px
  Result: (500, 250)

Reverse Transform (screen → world):
  worldX = 500 - 400 - 0 = 100mm ✓
  worldY = -(250 - 300 - 0) = -(-50) = 50mm ✓
  Result: (100, 50) ✓ (matches original!)

Test Case 2: World Point (0, 0) - The Origin

Forward Transform:
  screenX = 0 + 400 + 0 = 400px (canvas center X) ✓
  screenY = -0 + 300 + 0 = 300px (canvas center Y) ✓
  Result: (400, 300)

Reverse Transform:
  worldX = 400 - 400 - 0 = 0mm ✓
  worldY = -(300 - 300 - 0) = -0 = 0mm ✓
  Result: (0, 0) ✓ (matches original!)

Test Case 3: With Panning (panOffset = {x: 50, y: 25})

Forward Transform:
  screenX = 100 + 400 + 50 = 550px
  screenY = -50 + 300 + 25 = 275px
  Result: (550, 275)

Reverse Transform:
  worldX = 550 - 400 - 50 = 100mm ✓
  worldY = -(275 - 300 - 25) = -(-50) = 50mm ✓
  Result: (100, 50) ✓ (matches original!)

Understanding the Y-Axis Flip in Reverse:

The Y-axis flip is the trickiest part. Let's trace through what happens:

Forward Transform (World → Screen):

  • World point at (100, 50): 50mm UP from origin
  • Forward transform: screenY = -50 + 300 = 250px
  • Screen Y = 250px: This is 50px ABOVE center (300 - 250 = 50) ✓
  • The negation flipped: positive world Y → negative screen Y offset → appears above center ✓

Reverse Transform (Screen → World):

  • Screen point at (500, 250): 50px ABOVE center
  • Reverse transform: worldY = -(250 - 300) = -(-50) = 50mm
  • World Y = 50mm: This is 50mm UP from origin ✓
  • The negation flipped back: negative screen Y offset → positive world Y ✓

Visual Diagram of Y-Axis Conversion:

World Space (Y increases UP):        Screen Space (Y increases DOWN):
┌──────────────────┐                ┌──────────────────┐
│                  │                │  Y=0 (top)       │
│      Y=+50       │  Forward:      │      ┌──┐        │
│        ↑         │  -50 + 300     │      │  │ Y=250  │
│        │         │  = 250         │      │  │        │
│   ●────┼────●    │  ===========>  │      ●──┼──●     │
│        │         │                │      │  │ Y=300  │
│        ↓         │  Reverse:      │      │  │(center)│
│      Y=-50       │  -(250-300)    │      │  │        │
│                  │  = 50          │      └──┘ Y=600  │
└──────────────────┘  <===========  │  Y=600 (bottom)  │
                                    └──────────────────┘

Common Mistakes in Reverse Transform:

Mistake 1: Forgetting to Negate Y

// WRONG:
worldY = screenY - offsetY - panOffset.y;
// This doesn't flip the axis back - Y will be inverted!

Result: World Y will have wrong sign (positive becomes negative, vice versa)

Mistake 2: Negating the Wrong Part

// WRONG:
worldY = -screenY - offsetY - panOffset.y;
// Negating screenY but not accounting for offset subtraction

Result: Incorrect calculation, doesn't properly reverse the transform

Mistake 3: Wrong Order of Operations

// WRONG:
worldY = -(screenY - offsetY) - panOffset.y;
// Doesn't handle panOffset correctly

Result: Pan offset not properly reversed

Correct Implementation:

worldY = -(screenY - offsetY - panOffset.y);
// Negate the entire expression after subtracting both offsets

Edge Cases to Handle:

Edge Case 1: Screen Coordinates Outside Canvas

// Screen point: (-100, 800) - outside canvas bounds
const world = screenToWorld(-100, 800);
// Result: worldX = -100 - 400 - 0 = -500mm (valid, just outside view)
//         worldY = -(800 - 300 - 0) = -500mm (valid)

This is correct - points outside canvas convert to world coordinates outside the visible area.

Edge Case 2: Zero Coordinates

// Screen point: (0, 0) - top-left corner
const world = screenToWorld(0, 0);
// Result: worldX = 0 - 400 - 0 = -400mm (400mm left of origin)
//         worldY = -(0 - 300 - 0) = 300mm (300mm above origin)

Correct - top-left corner maps to world coordinates that are offset by the center distance.

Edge Case 3: Canvas Center

// Screen point: (400, 300) - canvas center
const world = screenToWorld(400, 300);
// Result: worldX = 400 - 400 - 0 = 0mm (world origin)
//         worldY = -(300 - 300 - 0) = 0mm (world origin)

Correct - canvas center always maps to world origin (0, 0).

Performance Considerations:

The reverse transform is called frequently during mouse interactions:

  • Every mouse move event needs screen → world conversion
  • Every click event needs conversion for hit testing
  • Every drag operation needs continuous conversion

Optimizations:

  • Cache offset values if they don't change
  • Inline calculations for hot paths
  • Validate inputs only in debug mode

Integration with Mouse Events:

The reverse transform is essential for mouse interaction:

canvas.addEventListener('click', (event) => {
    // Step 1: Get mouse position in screen coordinates
    const rect = canvas.getBoundingClientRect();
    const screenX = event.clientX - rect.left;
    const screenY = event.clientY - rect.top;

    // Step 2: Convert to world coordinates
    const worldPoint = coordinateSystem.screenToWorld(screenX, screenY);

    // Step 3: Use world coordinates for hit testing
    const clickedShape = findShapeAtPoint(worldPoint.x, worldPoint.y);
});

Complete Implementation:

screenToWorld(sx, sy) {
    // Validate inputs
    if (sx == null || sy == null) {
        throw new Error('screenToWorld requires both x and y coordinates');
    }

    // Convert to numbers
    const screenX = Number(sx);
    const screenY = Number(sy);

    // Check for NaN
    if (isNaN(screenX) || isNaN(screenY)) {
        throw new Error(`Invalid screen coordinates: (${sx}, ${sy})`);
    }

    // Reverse X transform: subtract offsets
    const worldX = screenX - this.offsetX - this.panOffset.x;

    // Reverse Y transform: subtract offsets, then negate to flip axis back
    const worldY = -(screenY - this.offsetY - this.panOffset.y);

    // Return world coordinates
    return { x: worldX, y: worldY };
}

Building This Step by Step:

  1. Validate inputs (check for null, undefined, non-numeric)
  2. Reverse X transform by subtracting offsets
  3. Reverse Y transform by subtracting offsets and negating
  4. Return world coordinates as object
  5. Verify with round-trip tests (world → screen → world)

Why We Need screenToWorld(): When user interacts with canvas (clicks, drags, hovers), we get screen coordinates from mouse events. But shapes exist in world space, so we need to convert back.

Complete Round-Trip Verification:

// Test: Convert world → screen → world (should get original back)

// Original world point
const originalWorld = { x: 100, y: 50 };

// Forward: world → screen
const screen = coordSystem.worldToScreen(originalWorld.x, originalWorld.y);
// Result: { x: 500, y: 250 }

// Reverse: screen → world
const backToWorld = coordSystem.screenToWorld(screen.x, screen.y);
// Result: { x: 100, y: 50 }

// Verification
console.log('Original:', originalWorld);      // { x: 100, y: 50 }
console.log('Round-trip:', backToWorld);      // { x: 100, y: 50 }
console.log('Match:', 
    Math.abs(originalWorld.x - backToWorld.x) < 0.001 &&
    Math.abs(originalWorld.y - backToWorld.y) < 0.001
); // true ✓

Usage in Mouse Click Handler:

canvas.addEventListener('click', (event) => {
    // Step 1: Get mouse position relative to canvas
    const rect = canvas.getBoundingClientRect();
    const screenX = event.clientX - rect.left;
    const screenY = event.clientY - rect.top;

    // Step 2: Convert screen coordinates to world coordinates
    const worldPoint = coordSystem.screenToWorld(screenX, screenY);
    // Result: { x: 100, y: 50 }  (world coordinates)

    // Step 3: Use world coordinates to find clicked shape
    const clickedShape = findShapeAtPoint(worldPoint.x, worldPoint.y);

    // Step 4: Select the shape
    if (clickedShape) {
        renderer.selectedShape = clickedShape.name;
        renderer.redraw();
    }
});

Mathematical Proof of Inverse:

Forward transform:
  screenX = worldX + offsetX + panOffset.x

Solve for worldX:
  screenX - offsetX = worldX + panOffset.x
  screenX - offsetX - panOffset.x = worldX

Reverse transform (matches!):
  worldX = screenX - offsetX - panOffset.x ✓

For Y:
Forward:
  screenY = -worldY + offsetY + panOffset.y

Solve for worldY:
  screenY - offsetY = -worldY + panOffset.y
  screenY - offsetY - panOffset.y = -worldY
  -(screenY - offsetY - panOffset.y) = worldY

Reverse transform (matches!):
  worldY = -(screenY - offsetY - panOffset.y) ✓

If you see an error at this step:

Error: Round-trip conversion fails (world→screen→world ≠ original)

  • What this means: screenToWorld doesn't reverse worldToScreen correctly
  • Common causes:
    1. Wrong formula: adding instead of subtracting offsets
    2. Y axis not negated in reverse: (sy - offsetY) instead of -(sy - offsetY)
    3. Scale not handled (if using scale)
  • Fix: Verify formulas are exact inverse: if worldToScreen uses +, screenToWorld uses -, check Y negation

Error: Click detection finds shapes at wrong positions

  • What this means: screenToWorld conversion wrong
  • Common causes:
    1. Not using screenToWorld() in mouse handler
    2. Wrong mouse coordinates: using event.x/y instead of calculating relative to canvas
    3. canvas.getBoundingClientRect() not used to get canvas position
  • Fix: Get mouse coords: const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; then convert with screenToWorld

The Complete Method:

screenToWorld(sx, sy) {
    // Reverse the transform
    return {
        x: sx - this.offsetX - this.panOffset.x,
        y: -(sy - this.offsetY - this.panOffset.y)  // Flip Y back
    };
}

How It Works:

  • For X: Subtract offsetX and panOffset.x (reverse of adding)
  • For Y: Subtract offsets, then negate (reverse of negating then adding)

Building This Step by Step:

  1. Create screenToWorld(sx, sy) method
  2. Calculate worldX by subtracting offsetX and panOffset.x from screenX
  3. Calculate worldY by subtracting offsets from screenY, then negating
  4. Return object with world x and y coordinates

Drawing the grid:

drawCartesianGrid() {
    if (!this.isGridEnabled) return;

    const ctx = this.ctx;
    ctx.strokeStyle = '#D1D5DB';
    ctx.lineWidth = 0.5;

    // Calculate visible range
    const left = 0;
    const right = this.canvas.width;
    const top = 0;
    const bottom = this.canvas.height;

    // Draw vertical lines
    const startX = Math.floor((left - this.offsetX) / this.gridSize) * this.gridSize;
    const endX = Math.ceil((right - this.offsetX) / this.gridSize) * this.gridSize;

    ctx.beginPath();
    for (let x = startX; x <= endX; x += this.gridSize) {
        const screenX = this.transformX(x);
        ctx.moveTo(screenX, top);
        ctx.lineTo(screenX, bottom);
    }
    ctx.stroke();

    // Draw horizontal lines (similar)
    // ...
}

Grid optimization: Only draw lines that are actually visible. Calculate the range based on canvas size and pan offset, then draw only those lines.

Building the Style Manager From Scratch

Shapes have style properties that need processing. The style manager handles color resolution, fill/stroke decisions, and interaction states (selection, hover).

How to Build It Step by Step:

Step 1: Create the Style Manager Class Start with a class that manages shape styling:

export class ShapeStyleManager {
    constructor() {
        // Step 1.1: Create color system
        // ColorSystem handles color resolution (named colors, hex colors, etc.)
        //
        // DETAILED EXPLANATION:
        // The Style Manager needs a ColorSystem to resolve colors from various formats.
        // Colors can be: named ("red"), hex ("#FF0000"), rgb ("rgb(255,0,0)"), etc.
        // ColorSystem normalizes all colors to a format canvas understands.
        //
        // Why separate ColorSystem?
        // - Colors can come in many formats (named, hex, rgb, rgba, hsl, etc.)
        // - Canvas understands hex and rgb strings, but not all formats
        // - Centralizing color resolution makes it reusable and testable
        // - Can add support for new color formats in one place
        //
        // What ColorSystem does:
        // - Converts "red" → "#FF0000"
        // - Validates hex colors ("#FF0000" → "#FF0000")
        // - Parses rgb/rgba strings
        // - Handles CSS color names
        // - Returns canvas-compatible color strings
        //
        this.colorSystem = new ColorSystem();

        // Step 1.2: Optional - Define default styles
        // Could store default colors, sizes, etc. here
        // For now, defaults are handled in individual methods
        //
        // Example (optional):
        // this.defaultFillColor = '#808080';
        // this.defaultStrokeColor = '#374151';
        // this.defaultStrokeWidth = 2;
    }
}

Step 2: Build the createStyleContext() Method

The createStyleContext() method is the main entry point for style resolution. It creates a computed object containing all styling information needed for drawing a shape. Instead of resolving styles multiple times during drawing (inefficient), we resolve once here and reuse the resolved values.

What is a Style Context?

A style context is a pre-computed object containing all resolved styling information:

  • shouldFill: Boolean indicating if shape should be filled (true) or outline only (false)
  • fillColor: Resolved fill color (canvas-compatible format like "#FF0000")
  • fillOpacity: Fill transparency (0.0 = transparent, 1.0 = opaque)
  • strokeColor: Resolved stroke color (outline color)
  • strokeWidth: Stroke width in pixels

Benefits of Style Context:

  • Efficiency: Resolve colors once, reuse multiple times during drawing
  • Consistency: All style decisions in one place (easier to maintain)
  • Clarity: Renderer just uses resolved values, doesn't need to know resolution logic
  • Testability: Can test style resolution separately from rendering

Style Resolution Priority:

For fill color (highest to lowest priority):

  1. Selection state: Selected shapes get highlight color (orange) for visibility
  2. Hover state: Hovered shapes might get a slightly different color
  3. Explicit fillColor: params.fillColor (shape-specific fill color)
  4. General color: params.color (if fill is not disabled)
  5. Default: Gray (#808080)

For stroke color: Similar priority, but different defaults (dark gray for contrast).

Usage in Renderer:

const style = styleManager.createStyleContext(shape, isSelected, isHovered);
ctx.fillStyle = style.fillColor;  // Use resolved color directly
if (style.shouldFill) ctx.fill(); // Use resolved fill flag
    createStyleContext(shape, isSelected, isHovered) {
        // Extract shape parameters (contains both geometry and styling info)
        const params = shape.params || {};

        // Build style context object with all resolved styling information
        // This object is used by the renderer to draw the shape
        return {
            shouldFill: this.shouldShapeBeFilled(params),
            fillColor: this.getFillColor(params, isSelected, isHovered),
            fillOpacity: this.getFillOpacity(params),
            strokeColor: this.getStrokeColor(params, isSelected, isHovered),
            strokeWidth: this.getStrokeWidth(params, isSelected, isHovered)
        };
    }

Why a Style Context: This object contains all resolved style information in one place. The renderer uses it to draw shapes without needing to resolve colors or check parameters repeatedly.

Step 3: Build Helper Methods

Each property in the style context needs a method to resolve it. These methods handle the logic for determining the value, including priority order and defaults.

shouldShapeBeFilled() Resolution Logic:

This method determines if a shape should be filled or just outlined. The resolution follows this priority:

  1. Explicit false: If fill or filled is explicitly false, don't fill (outline only)
  2. Explicit true: If fill or filled is explicitly true, do fill
  3. Infer from fillColor: If fillColor exists, assume user wants to fill (convenience feature)
  4. Default: If no information provided, default to not filled (outline only, conservative default)

We check false first because explicit user intent should override everything. We support both fill and filled property names to provide flexibility for different coding styles.

shouldShapeBeFilled(params) {
    // Check explicit "don't fill" flags first (user intent is clear)
    // Support both 'fill' and 'filled' property names for flexibility
    if (params.fill === false || params.filled === false) return false;

    // Check explicit "do fill" flags
    if (params.fill === true || params.filled === true) return true;

    // If fillColor exists, infer that user wants to fill
    // (convenience: don't need both fillColor and fill: true)
    if (params.fillColor) return true;

    // Default to not filled (conservative: outline only)
    // User can explicitly set fill: true if they want filling
    return false;
}

getFillColor(params, isSelected, isHovered) {
    // Step 3.5: Selection/hover overrides
    // Selected shapes get special highlight color
    if (isSelected) return '#FF572220';  // Semi-transparent orange
    if (isHovered) return params.fillColor || '#808080';

    // Step 3.6: Check explicit fill color
    if (params.fillColor) {
        return this.colorSystem.resolveColor(params.fillColor);
    }

    // Step 3.7: Check general color property
    if (params.color && params.fill !== false) {
        return this.colorSystem.resolveColor(params.color);
    }

    // Step 3.8: Default color
    return '#808080';  // Default gray
}

getStrokeColor(params, isSelected, isHovered) {
    // Step 3.9: Selection/hover overrides
    if (isSelected) return '#FF5722';  // Orange for selection
    if (isHovered) return '#FF6B35';   // Lighter orange for hover

    // Step 3.10: Check explicit stroke color
    if (params.strokeColor) return this.colorSystem.resolveColor(params.strokeColor);

    // Step 3.11: Check general color (if no fill color)
    if (params.color && !params.fillColor) return this.colorSystem.resolveColor(params.color);

    // Step 3.12: Default stroke color
    return '#374151';  // Default dark gray
}

getStrokeWidth(params, isSelected, isHovered) {
    // Step 3.13: Selection/hover get thicker strokes
    if (isSelected) return 2;
    if (isHovered) return 1.5;

    // Step 3.14: Use shape's stroke width or default
    return params.strokeWidth || params.thickness || 2;
}

getFillOpacity(params) {
    // Step 3.15: Get opacity from params or default to 1 (fully opaque)
    return params.fillOpacity || params.opacity || 1;
}

The Complete Style Manager:

export class ShapeStyleManager {
    constructor() {
        this.colorSystem = new ColorSystem();
    }

    createStyleContext(shape, isSelected, isHovered) {
        const params = shape.params;

        return {
            shouldFill: this.shouldShapeBeFilled(params),
            fillColor: this.getFillColor(params, isSelected, isHovered),
            fillOpacity: this.getFillOpacity(params),
            strokeColor: this.getStrokeColor(params, isSelected, isHovered),
            strokeWidth: this.getStrokeWidth(params, isSelected, isHovered)
        };
    }

    shouldShapeBeFilled(params) {
        // Default to filled unless explicitly false
        if (params.fill === false || params.filled === false) return false;
        if (params.fill === true || params.filled === true) return true;
        if (params.fillColor) return true;  // Has fill color, so fill it
        return false;
    }

    getFillColor(params, isSelected, isHovered) {
        // Selection/hover overrides
        if (isSelected) return '#FF572220';  // Semi-transparent orange
        if (isHovered) return params.fillColor || '#808080';

        // Check various color properties
        if (params.fillColor) {
            return this.colorSystem.resolveColor(params.fillColor);
        }
        if (params.color && params.fill !== false) {
            return this.colorSystem.resolveColor(params.color);
        }

        return '#808080';  // Default gray
    }

    getStrokeColor(params, isSelected, isHovered) {
        if (isSelected) return '#FF5722';  // Orange for selection
        if (isHovered) return '#FF6B35';   // Lighter orange for hover
        if (params.strokeColor) return this.colorSystem.resolveColor(params.strokeColor);
        if (params.color && !params.fillColor) return this.colorSystem.resolveColor(params.color);
        return '#374151';  // Default dark gray
    }

    getStrokeWidth(params, isSelected, isHovered) {
        if (isSelected) return 2;
        if (isHovered) return 1.5;
        return params.strokeWidth || params.thickness || 2;
    }

    getFillOpacity(params) {
        return params.fillOpacity || params.opacity || 1;
    }
}

Building the Color System:

export class ColorSystem {
    constructor() {
        // Step 4.1: Create named color dictionary
        // Maps color names to hex values
        this.namedColors = {
            'red': '#FF0000',
            'green': '#008000',
            'blue': '#0000FF',
            // ... add all named colors
        };
    }

    resolveColor(color) {
        // Step 4.2: Handle non-string colors
        // If color is already a resolved value, return it
        if (typeof color !== 'string') return color;

        // Step 4.3: Check if already hex
        // Hex colors start with '#'
        if (color.startsWith('#')) return color;

        // Step 4.4: Resolve named color
        // Convert to lowercase and look up in dictionary
        const lower = color.toLowerCase();
        return this.namedColors[lower] || color;
        // If not found, return original (might be valid CSS color)
    }
}

Why a Separate Color System: Colors can come in different formats. Users type "red", code might have "#FF0000", some shapes might use rgb(). The color system normalizes everything to hex. This makes the renderer simpler - it always gets hex colors.

Building This Step by Step:

  1. Create ShapeStyleManager class
  2. Add colorSystem in constructor
  3. Add createStyleContext() method that builds style object
  4. Add shouldShapeBeFilled() to determine fill state
  5. Add getFillColor() with selection/hover handling
  6. Add getStrokeColor() with selection/hover handling
  7. Add getStrokeWidth() and getFillOpacity() helper methods
  8. Create ColorSystem class for color resolution
  9. Add resolveColor() method that handles hex and named colors

Building Shape Rendering From Scratch

Each shape type needs its own renderer. Start with a basic shape renderer that draws circles.

How to Build It Step by Step:

Step 1: Create the Shape Renderer Class Start with a class that holds the canvas context:

export class ShapeRenderer {
    constructor(ctx) {
        // Step 1.1: Store the canvas context
        // The context is what we draw with
        this.ctx = ctx;
    }
}

Step 2: Build the renderCircle() Method

This method draws a circle to the canvas. It gets the radius from shape parameters, creates a path using arc(), and then applies fill and stroke according to the style context.

Drawing Order:

The drawing order matters for proper visual result:

  1. Fill first: Fill the interior of the circle
  2. Stroke second: Draw the outline on top of the fill
  3. Selection/hover highlights last: Draw on top so they're always visible

This ensures that outlines are clearly visible and selection highlights are never obscured.

Understanding Canvas Drawing Methods:

  • beginPath(): Starts a new path, clearing any previous path state. Must be called before drawing a new shape.
  • arc(): Adds a circular arc to the current path. To draw a full circle, we use angles from 0 to 2π (360°).
  • fill(): Fills the current path with the current fillStyle color and globalAlpha opacity.
  • stroke(): Draws the outline of the current path using strokeStyle, lineWidth, and globalAlpha.

Important: Property Setting Order

Canvas properties like fillStyle, strokeStyle, and globalAlpha must be set before calling fill() or stroke(). The drawing methods use the current property values at the time they're called.

renderCircle(params, styleContext, isSelected, isHovered) {
    // Get radius from params, default to 50 if not specified
    const radius = params.radius || 50;

    // Start new path (must call before drawing)
    this.ctx.beginPath();

    // Step 2.3: Draw the circle arc
    // arc(x, y, radius, startAngle, endAngle)
    // x, y: center position (0, 0 because we'll transform the canvas)
    // radius: circle radius
    // 0 to 2π: full circle (360 degrees)
    this.ctx.arc(0, 0, radius, 0, Math.PI * 2);

    // Apply fill (if needed)
    if (styleContext.shouldFill) {
        // Set fill style and opacity BEFORE calling fill()
        // fillStyle can be color strings, gradients, or patterns
        // globalAlpha controls transparency (0.0 = transparent, 1.0 = opaque)
        this.ctx.fillStyle = styleContext.fillColor;
        this.ctx.globalAlpha = styleContext.fillOpacity;

        // Fill the current path with current fillStyle
        // Path must be defined first (beginPath() and arc())
        this.ctx.fill();
    }

    // Apply stroke (if needed)
    if (styleContext.strokeWidth > 0) {
        // Set stroke properties BEFORE calling stroke()
        // strokeStyle and fillStyle are independent (can have different colors)
        this.ctx.strokeStyle = styleContext.strokeColor;
        this.ctx.lineWidth = styleContext.strokeWidth;
        this.ctx.globalAlpha = styleContext.fillOpacity;

        // Draw outline of current path
        // Usually fill first, then stroke (outline appears on top)
        this.ctx.stroke();
    }

    // Step 2.6: Draw selection highlight (on top)
    if (isSelected) {
        // Selection gets special blue outline
        this.ctx.strokeStyle = '#4285F4';
        this.ctx.lineWidth = 2;
        this.ctx.globalAlpha = 1;  // Full opacity for selection
        this.ctx.stroke();
    }

    // Step 2.7: Draw hover highlight (if not selected)
    if (isHovered && !isSelected) {
        // Hover gets green outline
        this.ctx.strokeStyle = '#34A853';
        this.ctx.lineWidth = 1;
        this.ctx.globalAlpha = 1;  // Full opacity for hover
        this.ctx.stroke();
    }

    // Step 2.8: Return success
    return true;
}

Why This Order:

  1. Draw the shape (fill, then stroke)
  2. Draw selection highlight on top (so it's visible)
  3. Draw hover highlight on top (if not selected)

This ensures highlights are always visible and don't get covered by the shape.

The Complete Method:

export class ShapeRenderer {
    constructor(ctx) {
        this.ctx = ctx;
    }

    renderCircle(params, styleContext, isSelected, isHovered) {
        const radius = params.radius || 50;

        // Draw the circle
        this.ctx.beginPath();
        this.ctx.arc(0, 0, radius, 0, Math.PI * 2);

        // Apply fill
        if (styleContext.shouldFill) {
            this.ctx.fillStyle = styleContext.fillColor;
            this.ctx.globalAlpha = styleContext.fillOpacity;
            this.ctx.fill();
        }

        // Apply stroke
        if (styleContext.strokeWidth > 0) {
            this.ctx.strokeStyle = styleContext.strokeColor;
            this.ctx.lineWidth = styleContext.strokeWidth;
            this.ctx.globalAlpha = styleContext.fillOpacity;
            this.ctx.stroke();
        }

        // Selection highlight (draw on top)
        if (isSelected) {
            this.ctx.strokeStyle = '#4285F4';
            this.ctx.lineWidth = 2;
            this.ctx.globalAlpha = 1;
            this.ctx.stroke();
        }

        // Hover highlight
        if (isHovered && !isSelected) {
            this.ctx.strokeStyle = '#34A853';
            this.ctx.lineWidth = 1;
            this.ctx.globalAlpha = 1;
            this.ctx.stroke();
        }

        return true;
    }

Building This Step by Step:

  1. Create ShapeRenderer class with constructor(ctx)
  2. Store canvas context
  3. Add renderCircle() method
  4. Get radius from params (with default)
  5. Draw circle path with beginPath() and arc()
  6. Apply fill if shouldFill is true
  7. Apply stroke if strokeWidth > 0
  8. Draw selection highlight if selected
  9. Draw hover highlight if hovered (and not selected)
  10. Return true to indicate success

    renderRectangle(params, styleContext, isSelected, isHovered) {

    const width = params.width || 100;
    const height = params.height || 50;
    
    // Draw rectangle centered at (0, 0)
    this.ctx.beginPath();
    this.ctx.rect(-width/2, -height/2, width, height);
    
    // Apply fill and stroke (same as circle)
    if (styleContext.shouldFill) {
        this.ctx.fillStyle = styleContext.fillColor;
        this.ctx.globalAlpha = styleContext.fillOpacity;
        this.ctx.fill();
    }
    
    if (styleContext.strokeWidth > 0) {
        this.ctx.strokeStyle = styleContext.strokeColor;
        this.ctx.lineWidth = styleContext.strokeWidth;
        this.ctx.globalAlpha = styleContext.fillOpacity;
        this.ctx.stroke();
    }
    
    // Selection/hover highlights
    // ... (same as circle)
    
    return true;
    

    } } ```

Important: Shapes are drawn at (0, 0) because we translate the canvas context first. The coordinate system handles world-to-screen conversion, then we translate to the shape's position, then draw at (0, 0).

Why center shapes? It makes rotation easier. If a rectangle is centered at (0, 0), rotating it rotates around its center. If it's at a corner, rotation is weird.

If you see an error at this step:

Error: TypeError: ctx.arc is not a function

  • What this means: ctx is not a 2D canvas context
  • Common causes:
    1. Wrong context: getContext('webgl') instead of getContext('2d')
    2. ctx is null: canvas doesn't exist or getContext failed
    3. ctx not passed to constructor: new ShapeRenderer() without ctx
  • Fix: Pass 2D context: new ShapeRenderer(canvas.getContext('2d')), check ctx exists

Error: Shapes don't appear on canvas at all

  • What this means: Drawing methods not called or no fill/stroke
  • Common causes:
    1. Forgot to call ctx.fill() or ctx.stroke() - shapes won't appear!
    2. beginPath() not called before drawing
    3. styleContext.shouldFill is false AND no stroke
    4. Methods not being called from drawShape()
  • Fix: Always call ctx.fill() or ctx.stroke() after drawing path, check shouldFill/stroke conditions

Error: TypeError: Cannot read property 'fillColor' of undefined

  • What this means: styleContext is undefined
  • Common causes:
    1. styleManager.createStyleContext() not called or returns undefined
    2. Not passing styleContext to render method
    3. styleManager not initialized
  • Fix: Check styleManager exists, call createStyleContext(), pass result to render method

Error: Shapes are all black (default color)

  • What this means: fillStyle/strokeStyle not set before fill/stroke
  • Common causes:
    1. Setting color after calling fill(): ctx.fill(); ctx.fillStyle = 'red'; (wrong order!)
    2. styleContext.fillColor is undefined
    3. Color resolution failed (named color not found)
  • Fix: Set color BEFORE drawing: ctx.fillStyle = styleContext.fillColor; ctx.fill();

Error: All shapes appear at same position or overlapping

  • What this means: Canvas translate not working or save/restore issue
  • Common causes:
    1. Forgot ctx.save()/restore() - transforms accumulate
    2. translate() not called for each shape
    3. translate() called with same values for all shapes
  • Fix: Call ctx.save() at start, ctx.translate() with correct position, ctx.restore() at end

Error: Shapes rotate around wrong point (orbit instead of spin)

  • What this means: Wrong transform order
  • Common causes:
    1. rotate() called before translate(): order matters!
    2. Rotating around canvas origin instead of shape center
  • Fix: Translate first, then rotate: ctx.translate(x, y); ctx.rotate(angle);

Building the Redraw Loop From Scratch

The main rendering method that draws everything to the canvas. This is called whenever the canvas needs to be updated.

Understanding Canvas Redraw Fundamentals:

Canvas drawing is fundamentally different from DOM rendering. In HTML/CSS, when you change an element's style, the browser automatically updates the display. Canvas doesn't work this way - it's a "stateless" drawing surface. Once you draw something, the canvas only contains pixels - there are no objects representing what was drawn. To update the display, you must:

  1. Clear the entire canvas: Erase all previous pixels
  2. Redraw everything: Draw all shapes, overlays, and UI elements from scratch
  3. Preserve drawing order: Background first, shapes second, overlays last

Why Redraw Everything:

Unlike DOM elements that persist as objects, canvas drawing is "fire and forget" - you draw pixels, and that's it. There's no "update this shape" - you must redraw everything. This might seem inefficient, but modern browsers optimize canvas drawing heavily, and redrawing the entire canvas is often faster than tracking individual shape changes.

When Redraw is Triggered:

The redraw method must be called whenever any visual change occurs:

Shape Changes:

  • New shapes added to the scene
  • Existing shapes modified (position, size, style)
  • Shapes deleted from the scene
  • Shape parameters updated (radius, width, height, etc.)

View Changes:

  • User zooms in or out (scale changes)
  • User pans the canvas (pan offset changes)
  • Canvas is resized (dimensions change)
  • Viewport changes (visible area changes)

Interaction Changes:

  • Shape selected (need to show selection highlight)
  • Shape deselected (need to remove selection highlight)
  • Shape hovered (need to show hover highlight)
  • Mouse moves (cursor changes, hover state updates)

Style Changes:

  • Shape colors change
  • Fill/stroke settings change
  • Opacity changes
  • Line width changes

Understanding the Drawing Pipeline:

The redraw process follows a strict order to ensure correct visual layering:

Layer 1: Background (Bottom)

  • Clear canvas (erase all pixels)
  • Draw grid (if enabled)
  • Draw rulers (if visible)
  • Draw coordinate system indicators

Layer 2: Shapes (Middle)

  • Draw all shapes in order
  • Each shape drawn at its world position
  • Transformations applied (position, rotation, scale)
  • Styles applied (fill, stroke, colors)

Layer 3: Overlays (Top)

  • Selection highlights (draw on top of selected shapes)
  • Hover highlights (draw on top of hovered shapes)
  • Handles (resize/rotate handles for selected shapes)
  • Annotations (labels, measurements, etc.)

Why This Order Matters:

Drawing order determines visual stacking:

  • Background must be drawn first (otherwise shapes cover it)
  • Shapes drawn in middle layer (main content)
  • Overlays drawn last (so they're always visible on top)

If you draw selection highlight before the shape, it will be covered by the shape fill and won't be visible.

Performance Considerations:

Redrawing the entire canvas can be expensive, especially with many shapes. Optimization strategies:

Throttling:

  • Limit redraw frequency to maximum 60fps (16ms between redraws)
  • Skip redraws if no changes occurred
  • Batch multiple updates into a single redraw

Culling:

  • Only draw shapes that are visible on screen
  • Skip shapes that are completely outside viewport
  • Calculate bounding boxes to determine visibility

Optimization Techniques:

  • Cache transform calculations (don't recalculate if transform hasn't changed)
  • Use efficient drawing methods (batch similar shapes together)
  • Minimize style changes (group shapes with same style)
  • Use requestAnimationFrame for smooth animation

How to Build It Step by Step:

Step 1: Create the redraw() Method This is the main entry point for rendering:

redraw() {
    // Step 1.1: Clear and draw background/grid
    // Clear the canvas and draw the grid/background
    // This ensures we start with a clean slate
    //
    // DETAILED EXPLANATION:
    // The redraw() method is the main rendering function that draws everything.
    // It's called whenever the canvas needs to be updated (shape changes, zoom, pan, etc.).
    //
    // Redraw process overview:
    // 1. Clear canvas (remove old drawing)
    // 2. Draw background (grid, rulers, guides)
    // 3. Draw all shapes
    // 4. Draw overlays (selection highlights, handles, etc.)
    //
    // Why clear first?
    // - Canvas is persistent: drawings stay until cleared or overwritten
    // - If we don't clear, old shapes remain visible (ghost images)
    // - Clearing ensures clean slate for new frame
    //
    // What clear() does:
    // - Erases everything on the canvas (sets all pixels to transparent/white)
    // - Optionally draws background color
    // - Optionally draws grid (if enabled)
    // - Resets canvas to initial state
    //
    // CoordinateSystem.clear() method:
    // - Clears the canvas: ctx.clearRect(0, 0, canvas.width, canvas.height)
    // - Optionally fills background: ctx.fillRect(...)
    // - Optionally draws grid (if isGridEnabled is true)
    // - Prepares canvas for new drawing
    //
    this.coordinateSystem.clear();

    // Step 1.2: Handle empty shapes case
    // If there are no shapes, just draw overlays and return
    if (!this.shapes || this.shapes.size === 0) {
        // Draw overlays even if no shapes
        // (handles, grid, etc. might still be visible)
        this.drawOverlays();
        return;
    }

    // Step 1.3: Filter out consumed shapes
    // Some shapes are consumed by boolean operations
    // They shouldn't be rendered (they're part of the result shape)
    const renderableShapes = this.filterRenderableShapes();

    // Step 1.4: Sort by render order
    // Shapes need to be drawn in the correct order
    // (z-index, creation order, etc.)
    const sortedShapes = this.sortShapesByRenderOrder(renderableShapes);

    // Step 1.5: Draw each shape
    // Loop through all shapes and draw them
    for (const { name, shape } of sortedShapes) {
        // Determine selection and hover state
        const isSelected = shape === this.selectedShape;
        const isHovered = name === this.hoveredShape && !isSelected;

        // Draw the shape
        this.drawShape(shape, isSelected, isHovered, name);
    }

    // Step 1.6: Draw overlays on top
    // Selection handles, constraints, etc. go on top of shapes
    this.drawOverlays();
}

Why This Order:

  1. Clear canvas first (clean slate)
  2. Filter shapes (remove consumed ones)
  3. Sort shapes (correct render order)
  4. Draw shapes (main content)
  5. Draw overlays (on top)

Step 2: Build the filterRenderableShapes() Method Filter out shapes that shouldn't be rendered:

filterRenderableShapes() {
    // Step 2.1: Initialize empty array
    const renderable = [];

    // Step 2.2: Loop through all shapes
    for (const [name, shape] of this.shapes.entries()) {
        // Step 2.3: Skip shapes consumed by boolean operations
        // When shapes are used in boolean ops (union, difference, etc.),
        // they're marked as _consumedByBoolean
        // The result shape is rendered instead
        if (!shape._consumedByBoolean) {
            // This shape should be rendered
            renderable.push({ name, shape });
        }
    }

    // Step 2.4: Return filtered list
    return renderable;
}

Why Filter Consumed Shapes: Shapes used in boolean operations are marked as consumed. They shouldn't be rendered because they're part of the result shape. Rendering them would show duplicates.

If you see an error at this step:

Error: TypeError: coordinateSystem.clear is not a function

  • What this means: clear() method doesn't exist in CoordinateSystem
  • Common causes:
    1. Method not implemented yet
    2. Typo: clear() vs clearCanvas() or similar
    3. coordinateSystem is wrong object
  • Fix: Implement clear() method, or use: this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

Error: Canvas is blank after redraw()

  • What this means: Shapes not drawing or cleared immediately
  • Common causes:
    1. shapes Map is empty: this.shapes.size === 0
    2. drawShape() not being called (loop not running)
    3. Error in drawShape() (check console)
    4. Shapes drawn but immediately cleared
  • Fix: Check shapes.size > 0, verify loop runs, check console for errors, verify drawShape() works

Error: TypeError: this.shapes.entries is not a function

  • What this means: shapes is not a Map
  • Common causes:
    1. shapes is object: this.shapes = {} instead of new Map()
    2. shapes is array
    3. shapes is null/undefined
  • Fix: Make sure shapes is a Map: this.shapes = new Map(); or convert before using entries()

Error: Shapes appear twice (duplicates)

  • What this means: Consumed shapes not filtered
  • Common causes:
    1. filterRenderableShapes() not called
    2. _consumedByBoolean flag not checked
    3. Filter logic wrong: if (shape._consumedByBoolean) should be if (!shape._consumedByBoolean)
  • Fix: Check filter logic, verify _consumedByBoolean flag is set correctly

The Complete Redraw Loop:

redraw() {
    // Clear and draw background/grid
    this.coordinateSystem.clear();

    if (!this.shapes || this.shapes.size === 0) {
        // Draw overlays even if no shapes
        this.drawOverlays();
        return;
    }

    // Filter out consumed shapes
    const renderableShapes = this.filterRenderableShapes();

    // Sort by render order (z-index, creation order, etc.)
    const sortedShapes = this.sortShapesByRenderOrder(renderableShapes);

    // Draw each shape
    for (const { name, shape } of sortedShapes) {
        const isSelected = shape === this.selectedShape;
        const isHovered = name === this.hoveredShape && !isSelected;
        this.drawShape(shape, isSelected, isHovered, name);
    }

    // Draw overlays (selection handles, constraints, etc.)
    this.drawOverlays();
}

filterRenderableShapes() {
    const renderable = [];
    for (const [name, shape] of this.shapes.entries()) {
        // Skip shapes consumed by boolean operations
        if (!shape._consumedByBoolean) {
            renderable.push({ name, shape });
        }
    }
    return renderable;
}

Building This Step by Step:

  1. Create redraw() method
  2. Clear canvas with coordinateSystem.clear()
  3. Check if shapes exist, return early if not
  4. Filter renderable shapes (remove consumed ones)
  5. Sort shapes by render order
  6. Loop through shapes and draw each one
  7. Draw overlays on top
  8. Create filterRenderableShapes() method
  9. Loop through shapes and filter out consumed ones
  10. Return filtered array

Building the drawShape Method From Scratch

The drawShape() method is responsible for drawing a single shape. It handles coordinate conversion, transforms, and delegates to the appropriate renderer.

How to Build It Step by Step:

Step 1: Save Canvas State Always save the canvas state before transforming it:

drawShape(shape, isSelected, isHovered, name) {
    // Step 1.1: Save canvas state
    // This saves the current transform, styles, etc.
    // We'll restore it at the end so transforms don't affect other shapes
    //
    // DETAILED EXPLANATION:
    // ctx.save() saves the entire canvas state to a stack.
    // This includes: transformation matrix, fill style, stroke style, line width, global alpha, etc.
    //
    // What gets saved:
    // - Transformation matrix (translate, rotate, scale)
    // - Drawing styles (fillStyle, strokeStyle, lineWidth, lineCap, etc.)
    // - Global properties (globalAlpha, globalCompositeOperation)
    // - Clipping region
    // - Text properties (font, textAlign, textBaseline)
    // - Everything that affects drawing
    //
    // Why save at the start?
    // - Canvas state is modified during drawing (translate, rotate, set colors)
    // - Without save/restore, these changes affect subsequent shapes
    // - By saving first, we can restore later to undo all changes
    //
    // Example of problem without save/restore:
    //   Shape 1: translate(100, 50), rotate(45°), draw
    //   Shape 2: translate(200, 100), draw
    //   Problem: Shape 2 is ALSO rotated 45°! (transform accumulated)
    //
    // With save/restore:
    //   Shape 1: save(), translate(100, 50), rotate(45°), draw, restore()
    //   Shape 2: save(), translate(200, 100), draw, restore()
    //   Result: Each shape has its own transforms, don't affect each other ✓
    //
    // Save/restore stack:
    //   save() pushes state onto stack
    //   restore() pops state from stack
    //   Can nest: save(), save(), restore(), restore() (stack-based)
    //
    // Performance:
    // - save()/restore() are fast operations (state is small)
    // - Worth it to prevent bugs from state leakage
    // - Standard pattern in canvas programming
    //
    this.ctx.save();
}

DETAILED EXPLANATION OF SAVE/RESTORE:

Why Save State: Canvas transforms are cumulative. If you don't save/restore, each shape's transform affects the next shape. By saving before and restoring after, each shape is drawn independently.

Canvas State Stack:

// Canvas maintains a stack of saved states
// Initial state (default)
ctx.save();        // Stack: [initial, saved1]
  // ... modify state ...
  ctx.save();      // Stack: [initial, saved1, saved2]
    // ... modify more ...
    ctx.restore(); // Stack: [initial, saved1] (back to saved2)
  ctx.restore();   // Stack: [initial] (back to saved1)
ctx.restore();     // Stack: [] (back to initial)

Example Without Save/Restore (WRONG):

// Draw circle 1
ctx.translate(100, 50);  // Move origin to (100, 50)
ctx.rotate(Math.PI / 4); // Rotate 45°
ctx.arc(0, 0, 25, 0, Math.PI * 2);
ctx.fill();  // Circle drawn at (100, 50), rotated 45°

// Draw circle 2 (PROBLEM!)
ctx.translate(200, 100); // Move to (200, 100), but still rotated!
ctx.arc(0, 0, 25, 0, Math.PI * 2);
ctx.fill();  // Circle at (200, 100), BUT ALSO rotated 45°! ✗ WRONG!

Example With Save/Restore (CORRECT):

// Draw circle 1
ctx.save();              // Save initial state
ctx.translate(100, 50);  // Move origin
ctx.rotate(Math.PI / 4); // Rotate
ctx.arc(0, 0, 25, 0, Math.PI * 2);
ctx.fill();              // Circle at (100, 50), rotated 45°
ctx.restore();           // Restore to initial state ✓

// Draw circle 2
ctx.save();              // Save initial state again
ctx.translate(200, 100); // Move origin (back to initial, so this is correct)
ctx.arc(0, 0, 25, 0, Math.PI * 2);
ctx.fill();              // Circle at (200, 100), NOT rotated ✓
ctx.restore();           // Restore to initial state ✓

What Happens to State:

// Before save():
//   Transform: identity (no translation, no rotation)
//   fillStyle: black
//   strokeStyle: black

ctx.save();
// After save():
//   Current state saved to stack
//   Can now modify state freely

ctx.translate(100, 50);
ctx.fillStyle = 'red';
// State modified:
//   Transform: translate(100, 50)
//   fillStyle: red

ctx.restore();
// After restore():
//   State restored from stack
//   Transform: identity (back to original)
//   fillStyle: black (back to original)

Step 2: Get Shape Position and Convert to Screen Get the shape's position and convert it from world to screen coordinates:

drawShape(shape, isSelected, isHovered, name) {
    this.ctx.save();

    // Step 2.1: Get shape position in world coordinates
    // Position can be in params or transform
    // Default to [0, 0] if not specified
    //
    // DETAILED EXPLANATION:
    // Shape position can be stored in multiple places:
    // - shape.params.position: Direct position parameter
    // - shape.params.x, shape.params.y: Separate X and Y parameters
    // - shape.transform.position: Position in transform object
    // - Default: [0, 0] (world origin) if not specified
    //
    // Why check multiple locations?
    // - Different code patterns might store position differently
    // - Interpreter might set position in params OR transform
    // - Provides flexibility (supports both patterns)
    //
    // Position format:
    // - Array: [x, y] (e.g., [100, 50])
    // - Object: {x: x, y: y} (alternative format)
    // - We expect array format for consistency
    //
    // Resolution priority:
    // 1. shape.params.position (if exists)
    // 2. shape.transform.position (if params.position doesn't exist)
    // 3. [0, 0] (default: world origin)
    //
    // Alternative: Could also check shape.params.x and shape.params.y:
    //   const worldPos = shape.params.position || 
    //                   (shape.params.x !== undefined ? [shape.params.x, shape.params.y || 0] : null) ||
    //                   shape.transform?.position || 
    //                   [0, 0];
    //
    // Example shapes:
    //   { params: { position: [100, 50] } } → [100, 50]
    //   { transform: { position: [200, 75] } } → [200, 75]
    //   { params: {} } → [0, 0] (default)
    //
    const worldPos = shape.params.position || shape.transform?.position || [0, 0];

    // Step 2.2: Convert to screen coordinates
    // worldToScreen() handles the coordinate conversion
    // This accounts for canvas center, pan offset, etc.
    //
    // DETAILED EXPLANATION:
    // World coordinates are in millimeters (real units).
    // Screen coordinates are in pixels (canvas units).
    // We need to convert before drawing.
    //
    // What worldToScreen() does:
    // - Takes world coordinates (millimeters)
    // - Applies zoom/scale
    // - Accounts for canvas center
    // - Accounts for pan offset
    // - Returns screen coordinates (pixels)
    //
    // Conversion process:
    // 1. Apply pan offset (shift world origin)
    // 2. Apply scale (convert mm to pixels)
    // 3. Add canvas center offset (position on canvas)
    // Result: Screen pixel coordinates
    //
    // Example:
    //   World position: [100, 50] (100mm right, 50mm up)
    //   Scale: 2 (zoomed in 2x)
    //   Pan: [0, 0] (no panning)
    //   Center: [400, 300] (canvas center)
    //   Screen: [600, 200] (calculated by worldToScreen)
    //
    // Why convert here?
    // - Canvas drawing uses screen coordinates (pixels)
    // - Shapes are defined in world coordinates (millimeters)
    // - Must convert before calling ctx.translate()
    //
    // Array access:
    // - worldPos is array [x, y]
    // - worldPos[0] = x coordinate
    // - worldPos[1] = y coordinate
    // - Could also destructure: const [wx, wy] = worldPos;
    //
    const screenPos = this.coordinateSystem.worldToScreen(worldPos[0], worldPos[1]);

    // screenPos is now an object: { x: number, y: number }
    // Contains screen coordinates in pixels, ready for canvas drawing
}

Step 3: Apply Canvas Transforms

Canvas transforms allow us to translate and rotate the canvas coordinate system. By transforming the canvas, we can draw shapes at (0, 0) and they appear at the correct position and rotation. This is simpler than manually calculating transformed coordinates for every point.

Understanding ctx.translate():

ctx.translate(x, y) shifts the canvas coordinate system. After translating, the point (0, 0) in canvas space corresponds to (x, y) in screen space. All subsequent drawing operations are relative to the new origin.

Why translate to shape position?

Shapes are defined centered at (0, 0) in local space. By translating the canvas to the shape's screen position, (0, 0) becomes the shape center. We can then draw the shape at (0, 0) and it appears at the correct screen position.

Understanding ctx.rotate():

ctx.rotate(angle) rotates the canvas coordinate system around the current origin. Since we translate first, the origin is at the shape's position, so rotation happens around the shape's center (correct behavior).

Why Rotate AFTER Translate (Critical Order!):

The order of operations is critical: translate first, then rotate.

  • CORRECT (translate → rotate): Shape rotates around its center ✓
  • WRONG (rotate → translate): Shape orbits around canvas origin ✗

If we rotate first, rotation happens around the canvas origin (top-left corner), causing shapes to orbit around that point instead of rotating in place.

Rotation Conversion:

Rotation is stored in degrees (user-friendly: 90°, 180°), but ctx.rotate() requires radians. We convert using: radians = degrees × (π / 180).

    // Get shape position and convert to screen coordinates
    const worldPos = shape.params.position || shape.transform?.position || [0, 0];
    const screenPos = this.coordinateSystem.worldToScreen(worldPos[0], worldPos[1]);

    // Translate canvas to shape position (move origin to where shape should be)
    // After this, drawing at (0, 0) draws at the shape's position
    this.ctx.translate(screenPos.x, screenPos.y);

    // Apply rotation if shape has rotation
    // Convert degrees to radians: ctx.rotate() uses radians
    if (shape.params.rotation || shape.transform?.rotation) {
        const rotation = (shape.params.rotation || shape.transform.rotation) * Math.PI / 180;
        // Rotate around current origin (shape center, because we translated first)
        this.ctx.rotate(rotation);
}

DETAILED EXPLANATION OF CANVAS TRANSFORMS:

Why Transform Canvas: By translating and rotating the canvas, we can draw shapes at (0, 0) and they appear at the correct position and rotation. This is simpler than calculating transformed coordinates for every point.

Transform Order Matters: The order of operations is critical: translate first, then rotate.

CORRECT Order (translate → rotate):

ctx.translate(500, 300);  // Move origin to shape position
ctx.rotate(45°);          // Rotate around new origin (shape center)
// Draw shape at (0, 0) → appears at (500, 300), rotated 45° around center ✓

WRONG Order (rotate → translate):

ctx.rotate(45°);          // Rotate around canvas origin (top-left)
ctx.translate(500, 300);  // Move origin
// Draw shape at (0, 0) → appears rotated and translated, but rotation is wrong ✗

Visual Comparison:

CORRECT (translate → rotate):
1. Translate to (500, 300):
   Origin moved to shape position

2. Rotate 45°:
   Rotation happens around new origin (shape center)

3. Result: Shape rotates around its center ✓

WRONG (rotate → translate):
1. Rotate 45°:
   Rotation happens around canvas origin (0, 0)

2. Translate to (500, 300):
   Moves the rotated coordinate system

3. Result: Shape orbits around canvas origin, not its center ✗

Coordinate System Transformation:

// Initial state:
// Canvas origin at (0, 0) - top-left corner

// After translate(500, 300):
// Canvas origin at (500, 300) - shape position
// Drawing at canvas (0, 0) → screen (500, 300)
// Drawing at canvas (10, 0) → screen (510, 300)

// After rotate(45°):
// Coordinate system rotated around (500, 300)
// Drawing at canvas (0, 0) still → screen (500, 300) (rotation center)
// Drawing at canvas (10, 0) → screen position rotated 45° from (510, 300)

Why This Approach is Better Than Manual Calculation:

// Alternative: Calculate transformed coordinates manually
// (MORE COMPLEX, ERROR-PRONE)

// For each point in shape:
const point = { x: 10, y: 5 };  // Local space
const angle = rotation * Math.PI / 180;
const cos = Math.cos(angle);
const sin = Math.sin(angle);

// Rotate point
const rotatedX = point.x * cos - point.y * sin;
const rotatedY = point.x * sin + point.y * cos;

// Add position
const screenX = rotatedX + screenPos.x;
const screenY = rotatedY + screenPos.y;

// Draw at (screenX, screenY)
ctx.lineTo(screenX, screenY);

// VS using canvas transforms:
ctx.translate(screenPos.x, screenPos.y);
ctx.rotate(rotation);
ctx.lineTo(point.x, point.y);  // Canvas handles all the math!

Canvas transforms are simpler because:

  • Canvas handles all the math internally
  • Don't need to manually apply rotation matrix to every point
  • Don't need to add position to every point
  • Less code, fewer bugs, more readable

Step 4: Get Style and Draw Shape

After setting up the canvas transforms, we need to get the style context (resolved colors, fills, strokes) and delegate to the appropriate renderer based on shape type. The renderer handles the actual drawing operations.

Shape Type Detection:

We check if the shape is a boolean operation result first (union, difference, intersection), as these have special rendering needs. Otherwise, we use a switch statement to find the appropriate renderer method for regular shapes (circle, rectangle, polygon, etc.).

    // Get style context (resolved colors, fills, strokes, opacity)
    const styleContext = this.styleManager.createStyleContext(shape, isSelected, isHovered);

    // Draw based on shape type
    if (shape.params.operation) {
        // Boolean operation result (union, difference, intersection)
        this.booleanRenderer.renderBooleanResult(shape, styleContext, isSelected, isHovered);
    } else {
        // Regular shape - delegate to appropriate renderer method
        switch (shape.type) {
            case 'circle':
                this.shapeRenderer.renderCircle(shape.params, styleContext, isSelected, isHovered);
                break;
            case 'rectangle':
                this.shapeRenderer.renderRectangle(shape.params, styleContext, isSelected, isHovered);
                break;
            // ... other shape types
        }
    }

    // Restore canvas state (undoes all transforms for next shape)
    this.ctx.restore();

The Complete Method:

drawShape(shape, isSelected, isHovered, name) {
    // Save canvas state (so we can restore it)
    this.ctx.save();

    // Get shape position in world coordinates
    const worldPos = shape.params.position || shape.transform?.position || [0, 0];

    // Convert to screen coordinates
    const screenPos = this.coordinateSystem.worldToScreen(worldPos[0], worldPos[1]);

    // Translate canvas to shape position
    this.ctx.translate(screenPos.x, screenPos.y);

    // Apply rotation if needed
    if (shape.params.rotation || shape.transform?.rotation) {
        const rotation = (shape.params.rotation || shape.transform.rotation) * Math.PI / 180;
        this.ctx.rotate(rotation);
    }

    // Get style context
    const styleContext = this.styleManager.createStyleContext(shape, isSelected, isHovered);

    // Draw based on shape type
    if (shape.params.operation) {
        // Boolean operation result
        this.booleanRenderer.renderBooleanResult(shape, styleContext, isSelected, isHovered);
    } else {
        // Regular shape
        switch (shape.type) {
            case 'circle':
                this.shapeRenderer.renderCircle(shape.params, styleContext, isSelected, isHovered);
                break;
            // ... other cases
        }
    }

    // Restore canvas state
    this.ctx.restore();
}

Building This Step by Step:

  1. Create drawShape() method with shape and state parameters
  2. Save canvas state with ctx.save()
  3. Get shape position from params or transform
  4. Convert world position to screen coordinates
  5. Translate canvas to shape position
  6. Apply rotation if shape has rotation
  7. Get style context from style manager
  8. Check if shape is boolean operation result
  9. Use switch statement to call appropriate renderer
  10. Restore canvas state with ctx.restore()

            break;
        case 'rectangle':
            this.shapeRenderer.renderRectangle(shape.params, styleContext, isSelected, isHovered);
            break;
        case 'triangle':
            this.shapeRenderer.renderTriangle(shape.params, styleContext, isSelected, isHovered);
            break;
        // ... etc
    }
    

    }

    // Restore canvas state (undoes translate/rotate) this.ctx.restore(); } ```

Canvas state management: ctx.save() saves the current transformation matrix, fill style, stroke style, etc. ctx.restore() restores it. This lets you transform for one shape without affecting others.

Why translate then draw at (0,0)? It's easier than calculating all the coordinates. Translate to where you want to draw, then draw relative to (0,0). The canvas handles the math.

If you see an error at this step:

Error: TypeError: Cannot read property 'position' of undefined

  • What this means: shape.params is undefined
  • Common causes:
    1. Shape object doesn't have params property
    2. Wrong property access: shape.position instead of shape.params.position
    3. Shape structure different than expected
  • Fix: Check shape structure, use: shape.params?.position || shape.transform?.position || [0, 0]

Error: TypeError: worldToScreen is not a function

  • What this means: coordinateSystem doesn't have worldToScreen method
  • Common causes:
    1. Method not implemented in CoordinateSystem class
    2. Typo: worldToScreen() vs worldToScreenCoords() or similar
    3. coordinateSystem is wrong object
  • Fix: Implement worldToScreen() in CoordinateSystem, check method name matches

Error: All shapes draw at same position (overlapping)

  • What this means: Canvas state not restored between shapes
  • Common causes:
    1. Forgot ctx.restore() at end of drawShape()
    2. restore() not reached (early return before restore)
    3. save()/restore() mismatched (more saves than restores)
  • Fix: Always call restore() after save(), use try/finally if needed, match save/restore count

Error: TypeError: shape.type is undefined in switch statement

  • What this means: Shape doesn't have type property
  • Common causes:
    1. Shape object structure different
    2. type property not set by interpreter
    3. Wrong property name
  • Fix: Check shape structure, add default case, verify interpreter sets shape.type

Error: Previous shape's rotation/transform affects next shape

  • What this means: ctx.restore() not called or not working
  • Common causes:
    1. restore() not at end of method
    2. restore() skipped by return statement
    3. Wrong restore() scope (restoring wrong save)
  • Fix: Ensure restore() always executes, use try/finally block if needed

Building Boolean Operation Rendering

Boolean operations (union, difference, intersection) create composite shapes. These need special handling:

export class BooleanOperationRenderer {
    constructor(ctx) {
        this.ctx = ctx;
    }

    renderBooleanResult(shape, styleContext, isSelected, isHovered) {
        // Boolean results are stored as paths (arrays of points)
        const points = shape.params.points;
        if (!points || points.length < 3) return false;

        // Check for holes (null separator in points array)
        const nullIndex = points.findIndex(p => p === null);
        const hasHoles = nullIndex !== -1;

        if (hasHoles) {
            return this.renderPathWithHoles(points, nullIndex, styleContext);
        } else {
            return this.renderSimplePath(points, styleContext);
        }
    }

    renderSimplePath(points, styleContext) {
        this.ctx.beginPath();
        this.ctx.moveTo(points[0][0], points[0][1]);

        for (let i = 1; i < points.length; i++) {
            this.ctx.lineTo(points[i][0], points[i][1]);
        }

        this.ctx.closePath();

        if (styleContext.shouldFill) {
            this.ctx.fill();
        }

        if (styleContext.strokeWidth > 0) {
            this.ctx.stroke();
        }

        return true;
    }

    renderPathWithHoles(points, nullIndex, styleContext) {
        const outerPath = points.slice(0, nullIndex);
        const innerPath = points.slice(nullIndex + 1);

        this.ctx.beginPath();

        // Draw outer path
        if (outerPath.length >= 3) {
            this.ctx.moveTo(outerPath[0][0], outerPath[0][1]);
            for (let i = 1; i < outerPath.length; i++) {
                this.ctx.lineTo(outerPath[i][0], outerPath[i][1]);
            }
            this.ctx.closePath();
        }

        // Draw inner path (hole)
        if (innerPath.length >= 3) {
            this.ctx.moveTo(innerPath[0][0], innerPath[0][1]);
            for (let i = 1; i < innerPath.length; i++) {
                this.ctx.lineTo(innerPath[i][0], innerPath[i][1]);
            }
            this.ctx.closePath();
        }

        // Use even-odd fill rule for holes
        if (styleContext.shouldFill) {
            this.ctx.fill('evenodd');
        }

        this.ctx.stroke();
        return true;
    }
}

How boolean operations work:

  1. Interpreter performs the boolean operation (uses ClipperLib)
  2. Result is stored as a path (array of [x, y] points)
  3. Renderer draws the path
  4. If there are holes (difference operations), they're separated by null in the points array

Even-odd fill rule: When you have holes, use ctx.fill('evenodd'). This tells the canvas to fill areas based on how many paths overlap them. Outer path = filled, inner path = hole.

Building Selection and Hit Testing From Scratch

When users click, you need to find which shape they clicked. This requires hit testing - checking if a point is inside a shape.

How to Build It Step by Step:

Step 1: Build the findShapeAt() Method This method finds which shape (if any) is at a given world coordinate:

findShapeAt(worldX, worldY) {
    // Step 1.1: Check shapes in reverse order (top shapes first)
    // Reverse order means shapes drawn last are checked first
    // This ensures clicks hit the topmost shape
    const shapesArray = Array.from(this.shapes.entries()).reverse();

    // Step 1.2: Loop through all shapes
    for (const [name, shape] of shapesArray) {
        // Step 1.3: Skip consumed shapes
        // Shapes consumed by boolean operations shouldn't be hit-testable
        if (shape._consumedByBoolean) continue;

        // Step 1.4: Get shape position
        // Position can be in params or transform
        const shapePos = shape.params.position || shape.transform?.position || [0, 0];

        // Step 1.5: Calculate offset from shape center
        // Convert world point to shape's local coordinate system
        const dx = worldX - shapePos[0];
        const dy = worldY - shapePos[1];

        // Step 1.6: Account for rotation
        // If shape is rotated, we need to rotate the test point back
        // This converts from world space to shape's local space
        let testX = dx;
        let testY = dy;
        if (shape.params.rotation || shape.transform?.rotation) {
            // Get rotation angle (negative because we're rotating back)
            const angle = -(shape.params.rotation || shape.transform.rotation) * Math.PI / 180;

            // Apply inverse rotation matrix
            // This rotates the point back to shape's local coordinate system
            testX = dx * Math.cos(angle) - dy * Math.sin(angle);
            testY = dx * Math.sin(angle) + dy * Math.cos(angle);
        }

        // Step 1.7: Test if point is inside shape
        // testShapeHit() checks if the point (in local space) is inside the shape
        if (this.testShapeHit(shape, testX, testY)) {
            return shape;  // Found a hit!
        }
    }

    // Step 1.8: No shape found
    return null;
}

Why Reverse Order: Shapes drawn later are on top. When clicking, you want the top shape. So check in reverse order. This ensures clicks hit the visible shape, not one hidden underneath.

Why Rotate the Test Point Back: If a shape is rotated, you need to rotate the test point back before testing. Otherwise, you're testing against the unrotated shape. By rotating the point back, we convert from world space to the shape's local space, where hit testing is simpler.

Step 2: Build the testShapeHit() Method This method tests if a point (in local space) is inside a shape:

testShapeHit(shape, x, y) {
    // Step 2.1: Switch based on shape type
    // Each shape type has different hit test logic
    switch (shape.type) {
        case 'circle':
            // Step 2.2: Circle hit test
            // Point is inside if distance from center <= radius
            // Distance formula: sqrt(x² + y²) <= radius
            // We use squared distance to avoid sqrt (faster)
            const r = shape.params.radius || 0;
            return x * x + y * y <= r * r;

        case 'rectangle':
            // Step 2.3: Rectangle hit test
            // Point is inside if within half-width and half-height
            // Shapes are centered at (0, 0), so bounds are ±width/2, ±height/2
            const w = shape.params.width || 0;
            const h = shape.params.height || 0;
            return Math.abs(x) <= w/2 && Math.abs(y) <= h/2;

        case 'triangle':
            // Step 2.4: Triangle hit test
            // Uses point-in-triangle algorithm
            const base = shape.params.base || 0;
            const height = shape.params.height || 0;
            return this.testTriangleHit(x, y, base, height);

        case 'path':
            // Step 2.5: Path hit test
            // Uses point-in-polygon algorithm
            return this.testPathHit(shape.params.points, x, y);

        default:
            // Step 2.6: Unknown shape type
            return false;
    }
}

The Complete Methods:

findShapeAt(worldX, worldY) {
    // Check shapes in reverse order (top shapes first)
    const shapesArray = Array.from(this.shapes.entries()).reverse();

    for (const [name, shape] of shapesArray) {
        if (shape._consumedByBoolean) continue;

        const shapePos = shape.params.position || shape.transform?.position || [0, 0];
        const dx = worldX - shapePos[0];
        const dy = worldY - shapePos[1];

        // Account for rotation
        let testX = dx;
        let testY = dy;
        if (shape.params.rotation || shape.transform?.rotation) {
            const angle = -(shape.params.rotation || shape.transform.rotation) * Math.PI / 180;
            testX = dx * Math.cos(angle) - dy * Math.sin(angle);
            testY = dx * Math.sin(angle) + dy * Math.cos(angle);
        }

        // Test hit based on shape type
        if (this.testShapeHit(shape, testX, testY)) {
            return shape;
        }
    }

    return null;
}

testShapeHit(shape, x, y) {
    switch (shape.type) {
        case 'circle':
            const r = shape.params.radius || 0;
            return x * x + y * y <= r * r;

        case 'rectangle':
            const w = shape.params.width || 0;
            const h = shape.params.height || 0;
            return Math.abs(x) <= w/2 && Math.abs(y) <= h/2;

        case 'triangle':
            const base = shape.params.base || 0;
            const height = shape.params.height || 0;
            // Triangle hit test (point-in-triangle algorithm)
            return this.testTriangleHit(x, y, base, height);

        case 'path':
            // Path hit test (point-in-polygon algorithm)
            return this.testPathHit(shape.params.points, x, y);

        default:
            return false;
    }
}

Building This Step by Step:

  1. Create findShapeAt(worldX, worldY) method
  2. Convert shapes Map to array and reverse (top shapes first)
  3. Loop through shapes, skip consumed ones
  4. Get shape position and calculate offset from center
  5. Rotate test point back if shape is rotated (inverse rotation)
  6. Call testShapeHit() with point in local space
  7. Return first shape that hits, or null
  8. Create testShapeHit(shape, x, y) method
  9. Use switch statement for different shape types
  10. Implement hit tests: circle (distance check), rectangle (bounds check), triangle (point-in-triangle), path (point-in-polygon)

Point-in-polygon: For paths and complex shapes, use a point-in-polygon algorithm. The ray casting algorithm works well - cast a ray from the point, count intersections with edges. Odd = inside, even = outside.

Building Interaction Handlers From Scratch

Mouse events need to be handled. Users click, drag, hover, and scroll. You need to convert screen coordinates to world coordinates and respond appropriately.

How to Build It Step by Step:

Step 1: Set Up Event Listeners Register event handlers for mouse and wheel events:

setupInteractivity() {
    // Step 1.1: Mouse down event (click)
    // Fired when user presses mouse button
    this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));

    // Step 1.2: Mouse move event (hover, drag)
    // Fired continuously as mouse moves
    this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));

    // Step 1.3: Mouse up event (end drag)
    // Fired when user releases mouse button
    this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));

    // Step 1.4: Wheel event (zoom)
    // Fired when user scrolls mouse wheel
    this.canvas.addEventListener('wheel', (e) => this.onWheel(e));
}

Step 2: Build the onMouseDown() Handler Handle mouse clicks - select shapes or start dragging:

onMouseDown(event) {
    // Step 2.1: Get mouse position relative to canvas
    // getBoundingClientRect() gives canvas position on page
    // clientX/clientY are mouse position relative to viewport
    // Subtract to get position relative to canvas
    const rect = this.canvas.getBoundingClientRect();
    const canvasX = event.clientX - rect.left;
    const canvasY = event.clientY - rect.top;

    // Step 2.2: Convert to world coordinates
    // screenToWorld() converts pixel coordinates to world coordinates
    const worldPos = this.coordinateSystem.screenToWorld(canvasX, canvasY);

    // Step 2.3: Find shape at this position
    // findShapeAt() returns the shape (if any) at the world position
    const hitShape = this.findShapeAt(worldPos.x, worldPos.y);

    // Step 2.4: Handle shape click
    if (hitShape) {
        // User clicked on a shape
        this.selectedShape = hitShape;  // Select it
        this.isDragging = true;         // Start dragging
        this.dragStart = { x: worldPos.x, y: worldPos.y };  // Remember start position
        this.redraw();  // Redraw to show selection
    } else {
        // Step 2.5: Handle empty space click
        // User clicked on empty space - deselect
        this.selectedShape = null;
        this.redraw();  // Redraw to clear selection
    }
}

Step 3: Build the onMouseMove() Handler Handle mouse movement - update hover state and handle dragging:

onMouseMove(event) {
    // Step 3.1: Get mouse position and convert to world coordinates
    const rect = this.canvas.getBoundingClientRect();
    const canvasX = event.clientX - rect.left;
    const canvasY = event.clientY - rect.top;
    const worldPos = this.coordinateSystem.screenToWorld(canvasX, canvasY);

    // Step 3.2: Update hover state
    // Find which shape (if any) the mouse is over
    const hitShape = this.findShapeAt(worldPos.x, worldPos.y);

    // Step 3.3: Only redraw if hover changed
    // This prevents unnecessary redraws when mouse moves but stays over same shape
    if (hitShape !== this.hoveredShape) {
        // Get shape name (or null if no shape)
        this.hoveredShape = hitShape ? this.findShapeName(hitShape) : null;
        this.redraw();  // Redraw to show hover highlight
    }

    // Step 3.4: Handle dragging
    if (this.isDragging && this.selectedShape) {
        // Calculate how far mouse moved
        const dx = worldPos.x - this.dragStart.x;
        const dy = worldPos.y - this.dragStart.y;

        // Update shape position
        // ... (implement drag logic)
    }
}

The Complete Interaction Handlers:

setupInteractivity() {
    this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
    this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
    this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
    this.canvas.addEventListener('wheel', (e) => this.onWheel(e));
}

onMouseDown(event) {
    const rect = this.canvas.getBoundingClientRect();
    const canvasX = event.clientX - rect.left;
    const canvasY = event.clientY - rect.top;

    // Convert to world coordinates
    const worldPos = this.coordinateSystem.screenToWorld(canvasX, canvasY);

    // Find shape at this position
    const hitShape = this.findShapeAt(worldPos.x, worldPos.y);

    if (hitShape) {
        this.selectedShape = hitShape;
        this.isDragging = true;
        this.dragStart = { x: worldPos.x, y: worldPos.y };
        this.redraw();
    } else {
        // Clicked empty space - deselect
        this.selectedShape = null;
        this.redraw();
    }
}

onMouseMove(event) {
    const rect = this.canvas.getBoundingClientRect();
    const canvasX = event.clientX - rect.left;
    const canvasY = event.clientY - rect.top;
    const worldPos = this.coordinateSystem.screenToWorld(canvasX, canvasY);

    // Update hover
    const hitShape = this.findShapeAt(worldPos.x, worldPos.y);
    if (hitShape !== this.hoveredShape) {
        this.hoveredShape = hitShape ? this.findShapeName(hitShape) : null;
        this.redraw();
    }

    // Handle dragging
    if (this.isDragging && this.selectedShape) {
        const dx = worldPos.x - this.dragStart.x;
        // ... drag logic
    }
}

Building This Step by Step:

  1. Create setupInteractivity() method
  2. Add event listeners for mousedown, mousemove, mouseup, wheel
  3. Create onMouseDown() handler
  4. Get mouse position relative to canvas
  5. Convert screen coordinates to world coordinates
  6. Find shape at position using findShapeAt()
  7. If shape found, select it and start dragging
  8. If no shape, deselect
  9. Create onMouseMove() handler
  10. Update hover state when mouse moves over different shapes
  11. Handle dragging if mouse is down and shape is selected
  12. Redraw canvas when selection or hover changes

    const dy = worldPos.y - this.dragStart.y;
    
    // Update shape position
    const shapeName = this.findShapeName(this.selectedShape);
    if (shapeName) {
        const currentPos = this.selectedShape.params.position || [0, 0];
        this.selectedShape.params.position = [
            currentPos[0] + dx,
            currentPos[1] + dy
        ];
        this.dragStart = worldPos;
        this.redraw();
    }
    

    } }

onMouseUp(event) { this.isDragging = false; }


**Coordinate conversion:** Mouse events give you screen coordinates (`event.clientX`, `event.clientY`). Convert to canvas coordinates (subtract canvas offset), then to world coordinates (use coordinate system).

**Dragging:** When dragging, calculate the delta (how far the mouse moved), update the shape position, redraw. The shape object is modified directly - the renderer sees the change because it's the same object reference.

### Building Overlay System

Overlays draw on top of everything. Used for constraints, debug info, etc.:

```javascript
constructor(canvas) {
    // ... other initialization
    this._overlayDrawers = [];
}

addOverlayDrawer(drawFn) {
    if (typeof drawFn === 'function' && !this._overlayDrawers.includes(drawFn)) {
        this._overlayDrawers.push(drawFn);
        this.redraw();
    }
}

removeOverlayDrawer(drawFn) {
    const index = this._overlayDrawers.indexOf(drawFn);
    if (index >= 0) {
        this._overlayDrawers.splice(index, 1);
        this.redraw();
    }
}

drawOverlays() {
    if (this._overlayDrawers.length === 0) return;

    // Overlays draw in screen coordinates (not world)
    // They get the context and coordinate system
    this._overlayDrawers.forEach(fn => {
        try {
            fn(this.ctx, this.coordinateSystem);
        } catch (e) {
            console.error('Overlay drawer error:', e);
        }
    });
}

Usage example - constraint overlay:

const constraintOverlay = (ctx, coordinateSystem) => {
    // Draw lines between constrained anchors
    ctx.strokeStyle = '#FF6B35';
    ctx.lineWidth = 1;
    ctx.setLineDash([5, 5]);

    for (const constraint of constraints) {
        const anchor1 = getAnchorWorldPos(constraint.a);
        const anchor2 = getAnchorWorldPos(constraint.b);

        const screen1 = coordinateSystem.worldToScreen(anchor1.x, anchor1.y);
        const screen2 = coordinateSystem.worldToScreen(anchor2.x, anchor2.y);

        ctx.beginPath();
        ctx.moveTo(screen1.x, screen1.y);
        ctx.lineTo(screen2.x, screen2.y);
        ctx.stroke();
    }

    ctx.setLineDash([]);  // Reset dash
};

renderer.addOverlayDrawer(constraintOverlay);

Why overlays? They're separate from shape rendering. Constraints, debug info, UI elements - these draw on top. The overlay system makes it easy to add/remove them without modifying the main renderer.

How It All Ties Together

The complete flow:

  1. Code runs → Interpreter creates shapes in env.shapes (Map)
  2. app.js callsrenderer.setShapes(result.shapes)
  3. Renderer stores referencethis.shapes = shapes
  4. Renderer redrawsthis.redraw()
  5. Coordinate system clears → Draws background, grid, rulers
  6. For each shape:
    • Convert world position to screen
    • Translate canvas context
    • Get style context
    • Call appropriate renderer method
    • Restore canvas context
  7. Draw overlays → Constraints, debug info, etc.

When shapes are modified:

  1. User drags shape → Mouse handler updates shape.params.position
  2. Renderer sees change → Same object reference, so it's already updated
  3. Renderer redraws → Shape appears in new position

When parameters change:

  1. User changes slider → ShapeManager updates shape.params.radius
  2. Renderer sees change → Same object reference
  3. Renderer redraws → Shape appears with new radius

No syncing needed: Because we share object references, changes are immediate. The renderer doesn't need to be notified - it just redraws when needed.

Performance Considerations

Redraw frequency:

  • Every code execution → full redraw
  • Every shape modification → full redraw
  • Mouse move (hover) → full redraw
  • This can be 60+ redraws per second

Optimization strategies:

  • Throttle redraws: Don't redraw on every mouse move, throttle to 60fps
  • Dirty rectangles: Only redraw changed areas (complex, we don't do this)
  • Shape caching: Cache rendered shapes as images (we don't do this either)
  • Skip invisible shapes: Don't draw shapes outside the viewport

Boolean operations:

  • These are expensive. ClipperLib does polygon clipping, which is complex.
  • Cache results if possible
  • Don't recompute on every redraw if shapes haven't changed

Grid drawing:

  • Only draw visible grid lines
  • Calculate range based on viewport
  • Use beginPath() once, draw all lines, then stroke() once (faster than individual strokes)

The renderer is the visual layer. It doesn't know about the language, parsing, or interpretation. It just takes shapes and draws them. This separation makes the system modular - you could swap out the renderer for a different graphics system if needed.

How to Build the Renderer System - Complete Step-by-Step Guide

This section provides a complete, step-by-step guide for building the entire renderer system from scratch.

Prerequisites

Before building the renderer, you need:

  • A working language system (lexer, parser, interpreter) that produces shape objects
  • An HTML page with a <canvas> element
  • Basic understanding of HTML5 Canvas API

Step 1: Create the Basic Renderer Class

File: src/renderer.mjs

What You're Building: The basic Renderer class that manages the HTML5 Canvas and provides the foundation for drawing shapes. This class handles canvas setup, shape storage, and basic drawing operations like clearing the canvas.

Why This Class: The renderer is the bridge between your shape objects and the visual representation on screen. It manages the canvas context, stores shapes to render, and provides methods for drawing. This is the entry point for all rendering operations.

How to Build It Step by Step:

Step 1.1: Create the Renderer Class and Constructor Start with the class definition and canvas setup:

export class Renderer {
  constructor(canvas) {
    // Step 1.1.1: Store canvas element
    // The canvas is the HTML element where we'll draw
    this.canvas = canvas;

    // Step 1.1.2: Get 2D rendering context
    // The context provides all the drawing methods (fillRect, stroke, etc.)
    this.ctx = canvas.getContext('2d');

    // Step 1.1.3: Initialize shapes storage
    // We'll store shapes in a Map (key = shape name, value = shape object)
    // Map is better than object for this because it preserves insertion order
    this.shapes = new Map();

    // Step 1.1.4: Set canvas size explicitly
    // It's important to set width/height explicitly, not just CSS
    // This ensures proper pixel density and prevents blurry rendering
    this.canvas.width = 800;
    this.canvas.height = 600;
  }

Why These Properties:

  • canvas: The HTML canvas element. We need it for size information and to get the context.
  • ctx: The 2D rendering context. This is where all drawing happens (fillRect, stroke, etc.).
  • shapes: A Map storing all shapes to render. Map preserves order and allows easy lookup by name.
  • Setting width/height explicitly: This ensures the canvas has the correct internal resolution, preventing blurry rendering.

Step 1.2: Implement setShapes() Method This method updates the shapes to render:

  setShapes(shapes) {
    // Step 1.2.1: Update shapes Map
    // If shapes is provided, use it; otherwise use empty Map
    this.shapes = shapes || new Map();

    // Step 1.2.2: Trigger redraw
    // Whenever shapes change, we need to redraw the canvas
    this.redraw();
  }

Why This Method: When shapes are updated (from interpreter, user interaction, etc.), we need to update the renderer's shape list and redraw. This method provides a clean interface for that.

Step 1.3: Implement redraw() Method This method orchestrates the drawing process:

  redraw() {
    // Step 1.3.1: Clear the canvas first
    // This removes all previous drawings
    this.clear();

    // Step 1.3.2: Draw shapes (to be implemented)
    // We'll add shape drawing logic here in later steps
  }

Why Clear First: Canvas drawing is additive - new drawings are added on top of old ones. We need to clear first to remove previous frames. This is essential for animation and updates.

Step 1.4: Implement clear() Method This method clears the canvas and draws a white background:

  clear() {
    // Step 1.4.1: Clear the entire canvas
    // clearRect removes all pixels in the specified rectangle
    // We clear the entire canvas (0,0 to width,height)
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // Step 1.4.2: Draw white background
    // Canvas is transparent by default, so we draw white to make it opaque
    this.ctx.fillStyle = '#FFFFFF';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
  }
}

Why White Background: The canvas is transparent by default. Drawing a white background makes it look like a normal drawing surface. Users expect a white canvas, not transparent.

The Complete Class:

export class Renderer {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.shapes = new Map();

    // Set canvas size explicitly
    this.canvas.width = 800;
    this.canvas.height = 600;
  }

  setShapes(shapes) {
    this.shapes = shapes || new Map();
    this.redraw();
  }

  redraw() {
    this.clear();
    // We'll add shape drawing here
  }

  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    // Draw white background
    this.ctx.fillStyle = '#FFFFFF';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
  }
}

Building This Step by Step:

  1. Create new file src/renderer.mjs
  2. Export Renderer class
  3. Create constructor that takes canvas parameter
  4. Store canvas element as instance property
  5. Get 2D context using canvas.getContext('2d')
  6. Store context as ctx property
  7. Initialize shapes as new Map()
  8. Set canvas.width to 800
  9. Set canvas.height to 600
  10. Create setShapes() method that takes shapes parameter
  11. Update this.shapes with provided shapes or empty Map
  12. Call redraw() after updating shapes
  13. Create redraw() method
  14. Call clear() at start of redraw
  15. Create clear() method
  16. Call ctx.clearRect() to clear entire canvas
  17. Set fillStyle to white
  18. Call ctx.fillRect() to draw white background
  19. This basic renderer provides canvas setup and clearing functionality

Test: Create a renderer and verify the canvas clears:

const canvas = document.getElementById('canvas');
const renderer = new Renderer(canvas);
renderer.redraw(); // Should see white canvas

Step 2: Build the Coordinate System

File: src/renderer/coordinateSystem.mjs

What You're Building: A CoordinateSystem class that handles conversion between world coordinates (where shapes exist) and screen coordinates (where pixels are drawn). This class manages the viewport transformation including pan, zoom, and centering.

Why This Class: Shapes exist in "world space" (millimeters, centered at origin). The canvas uses "screen space" (pixels, top-left at 0,0). We need to convert between these coordinate systems. The coordinate system also handles panning (moving the view) and zooming (scaling the view).

How to Build It Step by Step:

Step 2.1: Create the CoordinateSystem Class and Constructor Start with the class definition and initialize transformation parameters:

export class CoordinateSystem {
  constructor(canvas) {
    // Step 2.1.1: Store canvas reference
    // We need canvas to get its dimensions
    this.canvas = canvas;

    // Step 2.1.2: Calculate center offset
    // World coordinates are centered at origin (0,0)
    // Screen coordinates start at top-left (0,0)
    // We offset by half canvas size to center the world origin
    this.offsetX = canvas.width / 2;   // Center X
    this.offsetY = canvas.height / 2;  // Center Y

    // Step 2.1.3: Initialize scale
    // Scale converts world units (mm) to screen units (pixels)
    // Default is 1:1 (1 pixel = 1 mm)
    this.scale = 1;                     // 1 pixel = 1 mm (for now)

    // Step 2.1.4: Initialize pan
    // Pan allows moving the viewport (panning)
    // Default is 0 (no panning)
    this.panX = 0;
    this.panY = 0;
  }

Why These Properties:

  • offsetX/Y: Centers the world origin at the canvas center. World (0,0) maps to screen (width/2, height/2).
  • scale: Converts world units to pixels. Higher scale = zoomed in, lower scale = zoomed out.
  • panX/Y: Allows panning the view. Positive panX moves view right, positive panY moves view down.

Step 2.2: Implement worldToScreen() Method Convert world coordinates to screen coordinates:

  worldToScreen(worldX, worldY) {
    // Step 2.2.1: Apply scale
    // Multiply world coordinates by scale to convert to pixels
    // Step 2.2.2: Add center offset
    // Add offset to center the world origin at canvas center
    // Step 2.2.3: Add pan offset
    // Add pan to allow viewport movement
    const screenX = (worldX * this.scale) + this.offsetX + this.panX;
    const screenY = (worldY * this.scale) + this.offsetY + this.panY;

    // Step 2.2.4: Return screen coordinates
    return { x: screenX, y: screenY };
  }

Why This Formula: The transformation is: screen = (world * scale) + offset + pan. This:

  1. Scales world coordinates to pixels
  2. Centers at canvas center (offset)
  3. Applies panning (pan)

Step 2.3: Implement screenToWorld() Method Convert screen coordinates to world coordinates:

  screenToWorld(screenX, screenY) {
    // Step 2.3.1: Reverse the transformation
    // Subtract pan first (reverse panning)
    // Subtract offset (reverse centering)
    // Divide by scale (reverse scaling)
    const worldX = (screenX - this.offsetX - this.panX) / this.scale;
    const worldY = (screenY - this.offsetY - this.panY) / this.scale;

    // Step 2.3.2: Return world coordinates
    return { x: worldX, y: worldY };
  }
}

Why Reverse Formula: To convert screen → world, we reverse each step:

  1. Subtract pan (reverse panning)
  2. Subtract offset (reverse centering)
  3. Divide by scale (reverse scaling)

The Complete Class:

export class CoordinateSystem {
  constructor(canvas) {
    this.canvas = canvas;
    this.offsetX = canvas.width / 2;   // Center X
    this.offsetY = canvas.height / 2;  // Center Y
    this.scale = 1;                     // 1 pixel = 1 mm (for now)
    this.panX = 0;
    this.panY = 0;
  }

  worldToScreen(worldX, worldY) {
    const screenX = (worldX * this.scale) + this.offsetX + this.panX;
    const screenY = (worldY * this.scale) + this.offsetY + this.panY;
    return { x: screenX, y: screenY };
  }

  screenToWorld(screenX, screenY) {
    const worldX = (screenX - this.offsetX - this.panX) / this.scale;
    const worldY = (screenY - this.offsetY - this.panY) / this.scale;
    return { x: worldX, y: worldY };
  }
}

Building This Step by Step:

  1. Create new file src/renderer/coordinateSystem.mjs
  2. Export CoordinateSystem class
  3. Create constructor that takes canvas parameter
  4. Store canvas as instance property
  5. Calculate offsetX as canvas.width / 2
  6. Calculate offsetY as canvas.height / 2
  7. Initialize scale to 1
  8. Initialize panX to 0
  9. Initialize panY to 0
  10. Create worldToScreen() method with worldX, worldY parameters
  11. Calculate screenX as (worldX * scale) + offsetX + panX
  12. Calculate screenY as (worldY * scale) + offsetY + panY
  13. Return object with x, y properties
  14. Create screenToWorld() method with screenX, screenY parameters
  15. Calculate worldX as (screenX - offsetX - panX) / scale
  16. Calculate worldY as (screenY - offsetY - panY) / scale
  17. Return object with x, y properties
  18. This class provides bidirectional coordinate conversion

Integrate into Renderer:

import { CoordinateSystem } from './renderer/coordinateSystem.mjs';

export class Renderer {
  constructor(canvas) {
    // ... existing code ...
    this.coordinateSystem = new CoordinateSystem(canvas);
  }
}

Test: Convert coordinates:

const screen = renderer.coordinateSystem.worldToScreen(50, 30);
console.log(screen); // Should show screen coordinates

Step 3: Build the Style Manager

File: src/renderer/styleManager.mjs

export class ShapeStyleManager {
  resolveColor(color) {
    if (!color) return '#000000';
    if (color.startsWith('#')) return color;

    // Named colors
    const colorMap = {
      'red': '#FF0000',
      'green': '#008000',
      'blue': '#0000FF',
      // ... add more
    };
    return colorMap[color.toLowerCase()] || color;
  }

  getStyleContext(shape) {
    return {
      fill: shape.params.fill !== false,
      fillColor: this.resolveColor(shape.params.color || shape.params.fillColor),
      stroke: shape.params.stroke !== false,
      strokeColor: this.resolveColor(shape.params.strokeColor || '#000000'),
      strokeWidth: shape.params.strokeWidth || 1,
      opacity: shape.params.opacity || 1.0
    };
  }
}

Integrate into Renderer:

import { ShapeStyleManager } from './renderer/styleManager.mjs';

export class Renderer {
  constructor(canvas) {
    // ... existing code ...
    this.styleManager = new ShapeStyleManager();
  }
}

Step 4: Build Basic Shape Rendering

File: src/renderer/shapeRenderer.mjs

export class ShapeRenderer {
  constructor(ctx) {
    this.ctx = ctx;
  }

  drawCircle(shape, style, screenPos) {
    const radius = (shape.params.radius || 50) * coordinateSystem.scale;

    this.ctx.beginPath();
    this.ctx.arc(screenPos.x, screenPos.y, radius, 0, Math.PI * 2);

    if (style.fill) {
      this.ctx.fillStyle = style.fillColor;
      this.ctx.globalAlpha = style.opacity;
      this.ctx.fill();
    }

    if (style.stroke) {
      this.ctx.strokeStyle = style.strokeColor;
      this.ctx.lineWidth = style.strokeWidth;
      this.ctx.stroke();
    }

    this.ctx.globalAlpha = 1.0;
  }

  drawRectangle(shape, style, screenPos) {
    const width = (shape.params.width || 100) * coordinateSystem.scale;
    const height = (shape.params.height || 100) * coordinateSystem.scale;

    this.ctx.beginPath();
    this.ctx.rect(
      screenPos.x - width/2,
      screenPos.y - height/2,
      width,
      height
    );

    if (style.fill) {
      this.ctx.fillStyle = style.fillColor;
      this.ctx.globalAlpha = style.opacity;
      this.ctx.fill();
    }

    if (style.stroke) {
      this.ctx.strokeStyle = style.strokeColor;
      this.ctx.lineWidth = style.strokeWidth;
      this.ctx.stroke();
    }

    this.ctx.globalAlpha = 1.0;
  }
}

Integrate into Renderer:

import { ShapeRenderer } from './renderer/shapeRenderer.mjs';

export class Renderer {
  constructor(canvas) {
    // ... existing code ...
    this.shapeRenderer = new ShapeRenderer(this.ctx);
  }

  redraw() {
    this.clear();

    for (const [name, shape] of this.shapes) {
      const screenPos = this.coordinateSystem.worldToScreen(
        shape.params.x || 0,
        shape.params.y || 0
      );

      const style = this.styleManager.getStyleContext(shape);

      if (shape.type === 'circle') {
        this.shapeRenderer.drawCircle(shape, style, screenPos);
      } else if (shape.type === 'rectangle') {
        this.shapeRenderer.drawRectangle(shape, style, screenPos);
      }
    }
  }
}

Test: Create shapes and verify they render:

const shapes = new Map();
shapes.set('c1', {
  type: 'circle',
  params: { radius: 50, x: 0, y: 0, color: '#FF0000' }
});
renderer.setShapes(shapes);
// Should see a red circle in center of canvas

Step 5: Add Transform Support

Update shape rendering to handle transforms:

// In renderer.mjs redraw()
for (const [name, shape] of this.shapes) {
  this.ctx.save(); // Save current transform

  // Get position
  const pos = shape.transform?.position || [shape.params.x || 0, shape.params.y || 0];
  const screenPos = this.coordinateSystem.worldToScreen(pos[0], pos[1]);

  // Apply rotation
  const rotation = shape.transform?.rotation || shape.params.rotation || 0;
  this.ctx.translate(screenPos.x, screenPos.y);
  this.ctx.rotate((rotation * Math.PI) / 180);
  this.ctx.translate(-screenPos.x, -screenPos.y);

  // Apply scale
  const scale = shape.transform?.scale || [1, 1];
  this.ctx.scale(scale[0], scale[1]);

  // Draw shape (now at origin, we'll translate in the renderer)
  const style = this.styleManager.getStyleContext(shape);
  // ... draw shape ...

  this.ctx.restore(); // Restore transform
}

Step 6: Add Selection System

File: src/renderer/selectionSystem.mjs

export class SelectionSystem {
  constructor(renderer) {
    this.renderer = renderer;
    this.selectedShape = null;
  }

  drawSelectionOutline(shape, screenPos) {
    const ctx = this.renderer.ctx;
    const bounds = this.getShapeBounds(shape);

    ctx.strokeStyle = '#0066FF';
    ctx.lineWidth = 2;
    ctx.setLineDash([5, 5]);
    ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
    ctx.setLineDash([]);
  }

  getShapeBounds(shape) {
    // Calculate bounding box based on shape type
    // Return { x, y, width, height } in screen coordinates
  }
}

Integrate: Add to renderer and draw selection outline for selected shapes.

Step 7: Add Handle System

File: src/renderer/handleSystem.mjs

export class HandleSystem {
  constructor(renderer) {
    this.renderer = renderer;
    this.handleRadius = 5;
  }

  getHandlesForShape(shape) {
    const handles = [];
    const bounds = this.getShapeBounds(shape);

    // Corner handles
    handles.push({ type: 'corner', x: bounds.x, y: bounds.y });
    handles.push({ type: 'corner', x: bounds.x + bounds.width, y: bounds.y });
    // ... more corners

    return handles;
  }

  drawHandles(handles) {
    const ctx = this.renderer.ctx;
    for (const handle of handles) {
      ctx.fillStyle = '#0066FF';
      ctx.beginPath();
      ctx.arc(handle.x, handle.y, this.handleRadius, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

Step 8: Add Interaction Handler

File: src/renderer/interactionHandler.mjs

export class InteractionHandler {
  constructor(renderer) {
    this.renderer = renderer;
    this.setupEventListeners();
  }

  setupEventListeners() {
    this.renderer.canvas.addEventListener('mousedown', (e) => {
      this.handleMouseDown(e);
    });
    this.renderer.canvas.addEventListener('mousemove', (e) => {
      this.handleMouseMove(e);
    });
    this.renderer.canvas.addEventListener('mouseup', (e) => {
      this.handleMouseUp(e);
    });
  }

  handleMouseDown(e) {
    const worldPos = this.renderer.coordinateSystem.screenToWorld(
      e.offsetX,
      e.offsetY
    );

    // Find shape at position
    const shape = this.findShapeAtPoint(worldPos.x, worldPos.y);
    if (shape) {
      this.renderer.selectedShape = shape;
      this.renderer.redraw();
    }
  }

  findShapeAtPoint(x, y) {
    // Check each shape to see if point is inside
    for (const [name, shape] of this.renderer.shapes) {
      if (this.isPointInShape(x, y, shape)) {
        return shape;
      }
    }
    return null;
  }
}

Step 9: Add Grid and Rulers

In coordinateSystem.mjs:

drawGrid(ctx) {
  const gridSize = 10; // 10mm grid
  const startX = Math.floor((-this.offsetX - this.panX) / (this.scale * gridSize)) * gridSize;
  const endX = Math.ceil((ctx.canvas.width - this.offsetX - this.panX) / (this.scale * gridSize)) * gridSize;

  ctx.strokeStyle = '#E0E0E0';
  ctx.lineWidth = 1;

  ctx.beginPath();
  for (let x = startX; x <= endX; x += gridSize) {
    const screenX = this.worldToScreen(x, 0).x;
    ctx.moveTo(screenX, 0);
    ctx.lineTo(screenX, ctx.canvas.height);
  }
  ctx.stroke();

  // Similar for Y axis
}

Step 10: Put It All Together

Final renderer.mjs structure:

import { CoordinateSystem } from './renderer/coordinateSystem.mjs';
import { ShapeStyleManager } from './renderer/styleManager.mjs';
import { ShapeRenderer } from './renderer/shapeRenderer.mjs';
import { SelectionSystem } from './renderer/selectionSystem.mjs';
import { HandleSystem } from './renderer/handleSystem.mjs';
import { InteractionHandler } from './renderer/interactionHandler.mjs';

export class Renderer {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.shapes = new Map();

    // Initialize subsystems
    this.coordinateSystem = new CoordinateSystem(canvas);
    this.styleManager = new ShapeStyleManager();
    this.shapeRenderer = new ShapeRenderer(this.ctx);
    this.selectionSystem = new SelectionSystem(this);
    this.handleSystem = new HandleSystem(this);
    this.interactionHandler = new InteractionHandler(this);

    // Set canvas size
    this.canvas.width = 800;
    this.canvas.height = 600;
  }

  setShapes(shapes) {
    this.shapes = shapes || new Map();
    this.redraw();
  }

  redraw() {
    this.clear();
    this.coordinateSystem.drawGrid(this.ctx);

    // Draw shapes
    for (const [name, shape] of this.shapes) {
      this.drawShape(shape);
    }

    // Draw selection
    if (this.selectedShape) {
      this.selectionSystem.drawSelectionOutline(this.selectedShape, ...);
      const handles = this.handleSystem.getHandlesForShape(this.selectedShape);
      this.handleSystem.drawHandles(handles);
    }
  }

  drawShape(shape) {
    // Apply transforms, get style, call appropriate renderer method
  }

  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.fillStyle = '#FFFFFF';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
  }
}

Common Issues and Fixes

Issue: Shapes don't appear

  • Check renderer.shapes has shapes
  • Check coordinate conversion (shapes might be off-screen)
  • Check canvas size is set correctly
  • Check shape parameters (radius, width, height)

Issue: Shapes in wrong position

  • Check coordinate system offset (should be canvas center)
  • Check world coordinates (might be negative, off-screen)
  • Check transform order (scale → rotate → translate)

Issue: Colors don't work

  • Check color resolution (named colors → hex)
  • Check fill/stroke flags
  • Check opacity (might be 0)

Issue: Selection doesn't work

  • Check hit testing (point in shape calculation)
  • Check coordinate conversion in mouse handler
  • Check shape bounds calculation

Next Steps

Once the basic renderer works:

  1. Add more shape types (polygons, paths, etc.)
  2. Add pan and zoom
  3. Add more interaction (drag, resize, rotate)
  4. Add debug visualizer
  5. Optimize performance (throttling, dirty rectangles)

results matching ""

    No results matching ""