Building Paths, Handles, and Selection Systems From Scratch - Complete Beginner's Guide
This chapter teaches you how to build three important systems that make your shapes interactive and editable. Think of it like this:
- Paths = Drawing complex shapes (not just circles and rectangles)
- Handles = Little squares/circles you drag to resize/rotate shapes
- Selection = Visual highlighting that shows which shape you're working with
Together, these three systems turn your static shapes into interactive design tools that users can actually edit.
Why These Systems Matter
Imagine you've built shapes and a renderer. Great! But right now, your shapes are like statues - you can look at them, but you can't change them. Users can't resize a circle by dragging its corner, they can't see which shape they've clicked, and they can't draw custom shapes.
These three systems fix that:
Paths let you draw complex shapes - Want to draw a star? A custom polygon? A curved line? You need paths. Think of paths like connect-the-dots - you give it a list of points, and it draws lines connecting them.
Handles let users edit shapes - Those little squares you see when you select something in Photoshop or Figma? Those are handles. Drag a corner handle to resize. Drag the rotation handle to rotate. This is what handles do.
Selection shows what you're working with - When you click a shape, you need visual feedback. Is it selected? Is your mouse hovering over it? Selection provides that feedback with outlines and highlights.
Without these systems, your shapes are just pictures on a screen. With them, users can create and modify designs interactively - exactly what a design tool needs!
Overview of the Three Systems
Paths are sequences of points that form lines and curves. They're the foundation for drawing complex shapes that can't be represented as simple primitives. The path renderer handles turtle graphics, bezier curves, smooth paths, and more.
Handles are interactive control points on shapes. When a user drags a handle, the shape updates. Handles appear at key points like corners, edges, or centers, depending on the shape type.
Selection provides visual feedback and interaction state. Selected shapes are highlighted, handles appear, and the system knows what the user is working with.
What Paths Are - Explained Simply
Think of a path like connect-the-dots, but for computers. You give the computer a list of points (coordinates), and it draws lines connecting them in order.
Simple Analogy: Imagine you're drawing a square by hand:
- Put your pen at the top-left corner (point 1)
- Draw a line to the top-right corner (point 2)
- Draw a line down to the bottom-right corner (point 3)
- Draw a line to the bottom-left corner (point 4)
- Draw a line back to the top-left (point 1 again - this "closes" the square)
A path is exactly like this, but you tell the computer where each point is, and it draws the lines automatically.
The Path Data Structure:
const path = {
points: [
[0, 0], // First point: x=0, y=0 (top-left corner)
[50, 0], // Second point: x=50, y=0 (top-right corner)
[50, 50], // Third point: x=50, y=50 (bottom-right corner)
[0, 50] // Fourth point: x=0, y=50 (bottom-left corner)
],
closed: true // Connect last point back to first (makes it a square instead of 3 lines)
};
Understanding Each Part:
1. points - The List of Points:
- This is an array (list) of coordinate pairs
- Each pair
[x, y]tells you where one point is [0, 0]means "at x=0, y=0" (top-left corner in our example)[50, 0]means "at x=50, y=0" (50 pixels to the right)- The order matters! Points are connected in the order they appear in the array
- If you put points in the wrong order, you get a weird shape instead of what you want
2. closed: true - Connect the Last Point to the First:
- When
closed: true, the computer automatically draws a line from the last point back to the first - This creates a closed shape (like a square, circle, or any polygon)
- When
closed: falseor missing, the path is "open" - it's just a line that doesn't loop back - Think of it like drawing a square (closed) vs drawing just three sides of a square (open)
This Example Creates a Square:
- Start at (0, 0) - top-left
- Draw line to (50, 0) - top-right
- Draw line to (50, 50) - bottom-right
- Draw line to (0, 50) - bottom-left
- Because
closed: true, automatically draw line back to (0, 0) - closes the square
Why Do We Need Paths?
Simple shapes (circles, rectangles) can be drawn with special commands. But what if you want:
- A star shape? (need points at all the tips)
- A custom polygon? (any shape with any number of corners)
- A curved line? (bezier curves need control points)
- A shape someone drew freehand? (turtle graphics or drawing tools)
All of these need paths - a way to say "draw lines connecting these specific points." Paths give you complete flexibility - you can draw literally any shape by specifying the points.
Real-World Example: If you've used drawing tools like Photoshop, Illustrator, or even MS Paint with the polygon tool, you've used paths. When you click to place points and the computer draws lines between them, that's exactly what paths do.
Building Basic Path Rendering From Scratch
This is the simplest path rendering - just connect points with straight lines. Think of it like a connect-the-dots puzzle where you draw straight lines between each dot.
What We're Building: A function that takes a list of points and draws lines connecting them. This is the foundation for everything else - once you understand this, you can build curves, complex shapes, and more.
Real-World Analogy: Imagine you have a map with 4 cities marked. You want to draw roads connecting them. You:
- Start at city 1
- Draw a road to city 2
- Draw a road to city 3
- Draw a road to city 4
- If it's a closed route, draw a road back to city 1
That's exactly what path rendering does, but with points on a canvas.
How to Build It Step by Step:
Step 1: Understand the Function Signature
Before we write code, let's understand what information the function needs:
renderRegularPath(params, styleContext, isSelected, isHovered)
What Each Parameter Does:
params- This contains the actual path data- Has a
pointsarray (the list of coordinates to connect) - Has a
closedproperty (true/false - should we close the shape?) - Think of this as "what to draw"
- Has a
styleContext- This contains styling information- Has colors (fill color, stroke color)
- Has flags (shouldFill, strokeWidth, etc.)
- Think of this as "how to draw it" (what color, filled or just outline)
isSelected- Is this path currently selected?true= user clicked on it, should highlight itfalse= not selected, draw normally- Used to show a special outline when selected
isHovered- Is the mouse hovering over this path?true= mouse is over it, show hover effectfalse= mouse not over it, draw normally- Used to show a subtle highlight when mouse is near
Why Four Parameters?
params= the data (what to draw)styleContext= the style (colors, fills)isSelectedandisHovered= the state (user interaction)
Separating these makes the code cleaner and easier to understand. Each parameter has one job.
Step 2: Build the Function Step by Step
Step 2.1: Extract and Validate Points
We need at least 2 points to draw a line - one point is just a position, two points form a line segment. If we have fewer than 2 points, there's nothing to draw, so we return false to indicate failure. The early return prevents errors and wasted computation.
Why This Validation is Critical:
Validation is the first line of defense against errors. Without it, the function could crash when it tries to access points[0] or points[1] on an empty array. Additionally, attempting to draw with invalid data wastes CPU cycles and could cause rendering glitches. By validating early and returning false, we:
- Prevent crashes: Avoid
TypeError: Cannot read property '0' of undefinederrors - Improve performance: Skip unnecessary computation when data is invalid
- Provide clear feedback: Returning
falseindicates to the caller that rendering failed - Follow defensive programming: Always validate inputs before using them
Understanding the Destructuring Assignment:
The line const { points } = params; uses JavaScript destructuring. This is equivalent to writing const points = params.points; but more concise. Destructuring extracts the points property from the params object and assigns it to a local variable. If params doesn't have a points property, points will be undefined. If params itself is null or undefined, this will throw an error - which is why we check for !points afterwards.
The Validation Logic Explained:
The condition if (!points || points.length < 2) checks two things:
!points- This is true ifpointsis:undefined(property doesn't exist)null(explicitly set to null)false,0,"", or any other falsy value
points.length < 2- This checks the array length:- Length 0 = empty array (no points)
- Length 1 = single point (can't draw a line)
- Length 2+ = valid (can draw lines)
The || (OR) operator means "if either condition is true, return false". We use early return pattern - if validation fails, immediately exit the function without doing any more work.
Common Validation Scenarios:
- Empty array:
points = []- nothing to draw, length is 0 - Single point:
points = [[0, 0]]- need at least two for a line, length is 1 - Undefined points:
points = undefined- data missing, property doesn't exist - Null points:
points = null- invalid data, explicitly null - Missing property:
params = { closed: true }- points property not included
renderRegularPath(params, styleContext, isSelected, isHovered) {
// Extract points from params using destructuring
// This creates a local variable 'points' containing params.points
// If params doesn't have 'points', points will be undefined
const { points } = params;
// Validate that points exists and has at least 2 elements
// !points checks for undefined/null/falsy values
// points.length < 2 checks for empty array or single point
// If either condition is true, return false to indicate failure
// Early return prevents errors and saves computation
if (!points || points.length < 2) return false;
}
If you see an error at this step:
Error: TypeError: Cannot destructure property 'points' of 'params' as it is undefined
- What this means:
paramsis undefined or null - Common causes:
- Method called without params:
renderRegularPath()instead ofrenderRegularPath(params, ...) - params passed as null/undefined:
renderRegularPath(null, ...) - Wrong parameter order in calling code
- Method called without params:
- Fix: Add check:
if (!params) return false;before destructuring, or ensure params is always passed
Error: TypeError: Cannot read property 'length' of undefined
- What this means:
pointsis undefined after destructuring - Common causes:
- params object doesn't have
pointsproperty:{ closed: true }missing points - Typo in property name:
params.pointinstead ofparams.points - Points is null:
params.points = null
- params object doesn't have
- Fix: Check params structure, verify
pointsproperty exists:console.log('Params:', params);
Error: TypeError: points.length is not a number
- What this means:
pointsexists but is not an array - Common causes:
- Points is an object:
params.points = {}instead of[] - Points is a string:
params.points = "0,0,50,50"instead of array - Points is a number:
params.points = 4instead of array of coordinates
- Points is an object:
- Fix: Ensure points is always an array:
if (!Array.isArray(points)) return false;
Error: Path renders with 1 point or 0 points (nothing appears)
- What this means: Validation didn't catch edge case
- Common causes:
- Points array has 1 element:
points = [[0, 0]](needs at least 2) - Points array empty:
points = [] - Validation check wrong:
points.length < 1instead ofpoints.length < 2
- Points array has 1 element:
- Fix: Check validation:
if (!points || points.length < 2) return false;ensures at least 2 points
Step 2.2: Start a New Path
beginPath() starts a new path, clearing any previous path state. Think of the canvas like a piece of paper - when you draw one shape, then try to draw another, the canvas "remembers" where your pen was. beginPath() is like picking up your pen and starting a fresh drawing.
Understanding Canvas Path State:
The HTML5 Canvas API maintains internal state about the current path. When you call moveTo(), lineTo(), or other path commands, the canvas adds them to the current path. The canvas doesn't automatically "reset" between shapes - it accumulates path commands until you explicitly clear them. This state includes:
- The current path's points: All the points that have been added via
moveTo(),lineTo(),arc(), etc. - The current position: Where the "pen" last was (where you left off drawing)
- Path state flags: Whether the path is open or closed
Without beginPath(), all new drawing commands are added to the existing path, creating unwanted connections between shapes.
Why This is Critical:
Without beginPath(), if you drew a circle, then tried to draw a square, the square would have an unwanted line connecting it to where the circle ended. The canvas would think you're still drawing the same shape. This happens because:
- You draw a circle using
arc()- the canvas remembers you're at some point on the circle - You start drawing a square with
moveTo()to the square's first corner - If
beginPath()wasn't called, the canvas still thinks you're continuing the circle's path - It draws a line from the circle's end position to the square's start position - unwanted connection!
Real-World Analogy:
- Without
beginPath(): You draw a circle, then try to draw a square - but the pen leaves a line from the circle to the square (messy!). It's like drawing without lifting your pen - everything connects. - With
beginPath(): You lift your pen, move to a new spot, put it down, and draw the square cleanly. It's like starting a new drawing on a fresh part of the paper.
What beginPath() Actually Does:
When you call beginPath(), the canvas:
- Clears the current path's point list (forgets all previous points)
- Resets the current position (forgets where the pen was)
- Sets the path to "open" state (not closed)
- Prepares for a fresh sequence of path commands
Always call beginPath() at the start of any drawing function to avoid unwanted connections between shapes. This is a fundamental rule of canvas programming - every path drawing function should start with beginPath().
Common Mistakes:
- Forgetting
beginPath()results in shapes connecting unexpectedly - Calling
beginPath()in the middle of a path (breaks the path into pieces) - Calling
beginPath()multiple times unnecessarily (harmless but inefficient)
// Start a new path (clears previous path state)
// This is critical - without it, new drawing commands would be added
// to the existing path, creating unwanted connections between shapes
// beginPath() resets the path state, clearing all previous points
// and preparing for a fresh sequence of path drawing commands
this.ctx.beginPath();
Step 2.3: Move to the First Point
moveTo() moves the "pen" to a position without drawing a line. Think of it like lifting your pen off the paper, moving it to a new spot, and putting it down - but not drawing yet.
Understanding the Coordinates:
The points array uses nested arrays to represent coordinate pairs. Understanding how to access these coordinates is crucial:
points[0]= the first point in the array (arrays start at 0, so index 0 is the first element)points[0][0]= the X coordinate of the first point (first point's first element)points[0][1]= the Y coordinate of the first point (first point's second element)
Why Nested Arrays?
We use nested arrays [[x, y], [x, y], ...] instead of flat arrays [x, y, x, y, ...] because:
- Clarity: Each point is clearly grouped together
- Easier iteration: You can loop through points, and each iteration gives you a complete coordinate pair
- Common pattern: This matches how many graphics APIs represent coordinates (SVG, DXF, etc.)
Example:
If points = [[10, 20], [50, 30]]:
points[0]=[10, 20](the first point - an array containing x and y)points[0][0]=10(the X coordinate - first element of first point)points[0][1]=20(the Y coordinate - second element of first point)- So
moveTo(points[0][0], points[0][1])becomesmoveTo(10, 20)after evaluation
Why Use moveTo() for the First Point?
We use moveTo() instead of lineTo() for the first point because:
- We don't want a line from wherever the pen was before (from a previous shape)
- We want to START at the first point (position the pen without drawing)
moveTo()positions without drawing - perfect for the starting point- After
beginPath(), the current position is undefined/reset, but usingmoveTo()explicitly sets it
The Difference Between moveTo() and lineTo():
moveTo(x, y): Moves the "pen" to position (x, y) without drawing. Like lifting your pen and placing it down somewhere new.lineTo(x, y): Draws a line from the current position to (x, y). Like dragging your pen from where it is to the new position, leaving a line behind.
Real-World Analogy: Imagine drawing a square:
- Lift your pen (don't draw yet) and move to the top-left corner - that's
moveTo()- no line drawn, just positioning - Now draw a line to the top-right corner - that's
lineTo()- draws a line as you move - Draw a line to the bottom-right -
lineTo()- another line drawn - And so on...
If you accidentally use lineTo() for the first point, you'll get an unwanted line from the previous drawing location to your first point, creating messy connections between shapes. This happens because the canvas remembers where you last were, even after beginPath() (though beginPath() clears the path, the current position might still exist from the last drawing operation).
The Coordinate Extraction Process:
When we write points[0][0] and points[0][1]:
- JavaScript first evaluates
points[0]- this gets the first element of the points array - Then
[0]accesses the first element of that coordinate pair (X) - And
[1]accesses the second element of that coordinate pair (Y) - These values are then passed as separate arguments to
moveTo(x, y)
// Move to first point without drawing (start position)
// moveTo() positions the "pen" without leaving a line
// We use points[0][0] to get X coordinate and points[0][1] to get Y coordinate
// This sets the starting point for the path without drawing from the previous position
// After beginPath(), this explicitly establishes where we begin drawing
this.ctx.moveTo(points[0][0], points[0][1]);
If you see an error at this step:
Error: TypeError: Cannot read property '0' of undefined
- What this means:
points[0]is undefined - Common causes:
- Points array is empty: validation didn't catch it,
points.length === 0 - Points array has null/undefined elements:
points = [null, [50, 50]] - Wrong array structure:
points = [0, 50](flat array) instead of[[0, 0], [50, 50]]
- Points array is empty: validation didn't catch it,
- Fix: Add check:
if (!points[0]) return false;or ensure all points are valid arrays
Error: TypeError: points[0][0] is undefined or points[0][1] is undefined
- What this means: First point is not a coordinate pair
- Common causes:
- Point is not an array:
points[0] = 0instead of[0, 0] - Point array has wrong length:
points[0] = [0](only x, missing y) - Point is object:
points[0] = { x: 0, y: 0 }instead of[0, 0]
- Point is not an array:
- Fix: Validate point structure:
if (!Array.isArray(points[0]) || points[0].length < 2) return false;
Error: TypeError: this.ctx.moveTo is not a function
- What this means: ctx is not a 2D canvas context
- Common causes:
- ctx not initialized:
this.ctx = nullorundefined - Wrong context type:
getContext('webgl')instead ofgetContext('2d') - ctx not passed to constructor
- ctx not initialized:
- Fix: Check ctx is 2D context:
if (!this.ctx || typeof this.ctx.moveTo !== 'function') throw new Error('Invalid canvas context');
Error: Path starts with unexpected line segment
- What this means: Line drawn from previous path to first point
- Common causes:
- Forgot
beginPath()beforemoveTo()- previous path state still active moveTo()called afterlineTo()accidentally- Canvas context not cleared between renders
- Forgot
- Fix: Always call
this.ctx.beginPath()at start of function beforemoveTo()
Step 2.4: Draw Lines to Each Subsequent Point
lineTo() draws a straight line from wherever the pen currently is to the specified point. Unlike moveTo(), this actually draws a visible line. This step connects all the points together into a continuous path by drawing lines between them sequentially.
Understanding the Loop:
Why Start at i = 1?
- We already positioned at
points[0](the first point) usingmoveTo() - So we start the loop at
i = 1(the second point) and draw lines from there - If we started at
i = 0, we'd try to draw a line to the first point (which we're already at - redundant!)
Understanding Loop Fundamentals:
A for loop repeats code for each element in a sequence. In this case, we're iterating through array indices. The loop structure for (let i = 1; i < points.length; i++) consists of:
Initialization (
let i = 1): Creates a variableiand sets it to 1. This executes once before the loop starts. We start at 1 because index 0 was already handled bymoveTo().Condition (
i < points.length): Before each iteration, checks ifiis less than the array length. If true, the loop continues; if false, the loop exits. For example, ifpoints.length = 4, the loop runs fori = 1, 2, 3and stops wheni = 4(because4 < 4is false).Increment (
i++): After each iteration, increasesiby 1. This moves to the next array index. The++operator is shorthand fori = i + 1.Body (the code inside
{}): The code that executes for each iteration. Here, it's thelineTo()call that draws a line to the current point.
Understanding lineTo() Behavior:
The lineTo(x, y) method does two things:
- Draws a line: Creates a visible line from the current pen position to the specified coordinates
- Updates position: After drawing, the pen position is updated to
(x, y). This means each subsequentlineTo()continues from where the previous one ended, creating a continuous path.
This is different from moveTo(), which only updates position without drawing. The cumulative effect of multiple lineTo() calls is a connected series of line segments - a polyline.
How the Loop Works - Detailed Step-by-Step:
Let's trace through an example with 4 points: [[0, 0], [50, 0], [50, 50], [0, 50]]
Before loop starts: After
moveTo(points[0][0], points[0][1]), the pen is positioned at point 0:[0, 0](the first point). No line has been drawn yet.Loop iteration 1 (i = 1):
- Condition check:
1 < 4is true, so enter the loop body - Get point:
points[1]=[50, 0](the second point in the array) - Extract coordinates:
points[1][0]=50(X),points[1][1]=0(Y) - Call lineTo:
this.ctx.lineTo(50, 0) - What happens: A line is drawn from current position
[0, 0]to[50, 0](horizontal line moving right) - Position update: The pen position is now updated to
[50, 0] - Increment:
i++changesifrom 1 to 2
- Condition check:
Loop iteration 2 (i = 2):
- Condition check:
2 < 4is true, so continue - Get point:
points[2]=[50, 50](the third point) - Extract coordinates:
points[2][0]=50(X),points[2][1]=50(Y) - Call lineTo:
this.ctx.lineTo(50, 50) - What happens: A line is drawn from
[50, 0](current position) to[50, 50](vertical line moving down) - Position update: The pen position is now
[50, 50] - Increment:
i++changesifrom 2 to 3
- Condition check:
Loop iteration 3 (i = 3):
- Condition check:
3 < 4is true, so continue - Get point:
points[3]=[0, 50](the fourth and final point) - Extract coordinates:
points[3][0]=0(X),points[3][1]=50(Y) - Call lineTo:
this.ctx.lineTo(0, 50) - What happens: A line is drawn from
[50, 50]to[0, 50](horizontal line moving left) - Position update: The pen position is now
[0, 50] - Increment:
i++changesifrom 3 to 4
- Condition check:
Loop exit (i = 4):
- Condition check:
4 < 4is false, so exit the loop
- Condition check:
Result: After the loop completes, we have three line segments connecting the four points:
- Line segment 1: From
[0, 0]to[50, 0](top edge of square) - Line segment 2: From
[50, 0]to[50, 50](right edge of square) - Line segment 3: From
[50, 50]to[0, 50](bottom edge of square)
The path now goes from [0, 0] → [50, 0] → [50, 50] → [0, 50]. The final edge from [0, 50] back to [0, 0] will be added by closePath() if the path is closed.
What is a Polyline?
A polyline is a series of connected line segments. After this loop, you have a polyline - multiple straight lines connected end-to-end. Think of it like a connect-the-dots drawing where you draw straight lines between each consecutive dot. If the path is closed (we'll do that next), it becomes a polygon (a closed shape).
The key difference:
- Polyline: Open path, line segments connected end-to-end but not forming a closed shape
- Polygon: Closed path, line segments form a complete loop (the last point connects back to the first)
Why This Works - The Cumulative Effect:
Each lineTo() call draws from the current pen position to the new point, then the pen stays at that new point. This creates a chain reaction:
- First
lineTo(): Draws from start to point 1, pen now at point 1 - Second
lineTo(): Continues from point 1 to point 2, pen now at point 2 - Third
lineTo(): Continues from point 2 to point 3, pen now at point 3 - And so on...
The result is a continuous path where each line segment connects seamlessly to the next, forming a single connected path. This is the foundation for drawing complex shapes - any shape can be represented as a series of connected line segments.
Performance Considerations:
For large point arrays (hundreds or thousands of points), this loop executes many times. Each lineTo() call is relatively fast, but the cumulative effect can impact performance:
- 100 points = 99 lineTo() calls
- 1000 points = 999 lineTo() calls
- 10000 points = 9999 lineTo() calls
Some optimization strategies:
- Use Path2D objects: Can be created once and reused (especially useful for static paths)
- Reduce point count: Simplify paths when possible (remove redundant points)
- Use requestAnimationFrame: Already handled by the renderer for smooth animation
- Batch rendering: Draw multiple paths in a single frame when possible
Array Access Pattern Explained:
The expression points[i][0] and points[i][1] uses nested array access:
points[i]gets the point at indexi(which is itself an array[x, y])[0]then accesses the first element of that point array (the X coordinate)[1]accesses the second element (the Y coordinate)
This pattern assumes points are stored as nested arrays [[x1, y1], [x2, y2], ...]. If points were stored differently (like objects {x, y} or a flat array), you'd need different access patterns.
// Draw lines to each subsequent point
// Loop starts at i=1 because we already moved to points[0] with moveTo()
// Each iteration draws a line from current position to the next point
// lineTo() automatically updates the current position after drawing,
// so each line segment continues seamlessly from where the previous ended
// This creates a continuous path connecting all points in sequence
// The loop continues until all points have been connected (i < points.length)
for (let i = 1; i < points.length; i++) {
// Extract X and Y coordinates from the point at index i
// points[i] is an array [x, y], so:
// points[i][0] extracts the X coordinate (first element)
// points[i][1] extracts the Y coordinate (second element)
// lineTo() draws a visible line from current position to these coordinates
this.ctx.lineTo(points[i][0], points[i][1]);
}
If you see an error at this step:
Error: TypeError: Cannot read property '0' of undefined inside loop
- What this means: Some point in array is undefined or not an array
- Common causes:
- Sparse array:
points = [[0, 0], , [50, 50]](missing middle element) - Points array has null elements:
points = [[0, 0], null, [50, 50]] - Points array has wrong structure: mixed types
- Sparse array:
- Fix: Validate all points:
for (let i = 1; i < points.length; i++) { if (!points[i] || !Array.isArray(points[i])) continue; this.ctx.lineTo(points[i][0], points[i][1]); }
Error: TypeError: points[i][0] is undefined
- What this means: Point exists but doesn't have x/y coordinates
- Common causes:
- Point is empty array:
points[i] = [] - Point has only one coordinate:
points[i] = [50](missing y) - Point is wrong type:
points[i] = "50,50"(string) instead of[50, 50]
- Point is empty array:
- Fix: Check point length:
if (!points[i] || points[i].length < 2) continue;before accessing coordinates
Error: Path has missing segments (some lines don't draw)
- What this means: Some points are invalid and skipped
- Common causes:
- Points array has invalid elements (null, wrong format)
- Validation inside loop returns early instead of continuing
- Points have NaN values:
points[i] = [NaN, 50]
- Fix: Filter invalid points before loop, or handle gracefully in loop with continue
Error: TypeError: this.ctx.lineTo is not a function
- What this means: Canvas context methods not available
- Common causes:
- Context lost: canvas was removed from DOM
- Wrong context type: using WebGL or other context
- Context not properly initialized
- Fix: Check context exists:
if (typeof this.ctx.lineTo !== 'function') throw new Error('Invalid context');
Step 2.5: Close the Path if Needed
closePath() automatically draws a line from the last point back to the first point. This "closes" the shape, turning an open path (just lines) into a closed polygon (a complete shape).
Understanding What closePath() Does:
When you call closePath(), the canvas automatically:
- Calculates the connection: Determines the first point in the current path (where we started with
moveTo()) - Draws a line: Creates a line segment from the current position (last point) to that first point
- Completes the shape: The path is now closed - it forms a complete loop
This is different from manually calling lineTo() with the first point's coordinates. closePath() is specifically designed for this purpose and handles edge cases properly.
Understanding the Check: params.closed !== false
This might look confusing at first, but it's a JavaScript pattern for defaulting to true:
- If
params.closedistrue:true !== falseistrue, so close the path ✓ - If
params.closedisundefined(not set):undefined !== falseistrue, so close the path ✓ (default behavior) - If
params.closedisfalse:false !== falseisfalse, so don't close the path ✗
So closed !== false means "close it unless explicitly told not to." This pattern is useful when you want a sensible default (closed paths are more common than open paths in graphics).
Alternative Patterns:
You could write this differently:
if (params.closed === true || params.closed === undefined)- more explicit but verboseif (params.closed !== false)- concise and handles undefined gracefullyif (params.closed ?? true)- uses nullish coalescing (modern JavaScript), defaults to true
The !== false pattern is common in JavaScript for "truthy with a default of true" scenarios.
Why Close Before Fill:
fill() fills the inside of a closed shape with color. It needs a closed shape to know what region to fill. Here's why the order matters:
If you call
fill()on an open path: The browser automatically tries to close it by drawing a line from the last point to the first point. However, this automatic closure might not match your intent. For example, if the path has gaps or the points are in an unexpected order, the fill might create an unexpected shape.If you call
closePath()first, thenfill(): You have explicit control over how the shape closes. You ensure the path is properly closed before filling, which gives predictable results.
The Fill Algorithm:
When fill() is called on a closed path, the browser uses the "even-odd" or "non-zero" winding rule to determine what's inside:
- It traces the path and determines which areas are enclosed
- For complex paths (with holes or self-intersections), the winding rule determines what counts as "inside"
- The filled area uses the current
fillStyle(color, gradient, pattern, etc.)
Real-World Analogy: Imagine painting a room:
- First, draw the outline of the room (the path) - that's the
lineTo()calls creating the walls - Close the outline (make sure all walls connect) - that's
closePath()ensuring the outline is complete - Then paint the inside - that's
fill()filling the enclosed area
You can't paint the inside until you have a complete outline! Similarly, you can't fill a path until it's closed and forms a complete region.
The shouldFill Check:
We only fill if styleContext.shouldFill is true. Sometimes you want just an outline (stroke only), sometimes you want it filled. This check lets you choose. The styleContext object contains styling information that was processed earlier, and shouldFill is a boolean flag indicating whether this shape should be filled or not.
Why Check Before Filling:
Filling a shape can be computationally expensive, especially for complex paths. By checking shouldFill first, we avoid unnecessary fill operations when only a stroke (outline) is desired. Additionally, some shapes (like turtle graphics paths) are typically stroke-only and shouldn't be filled.
Common Scenarios:
- Filled shape:
shouldFill = true,closed = true→ Draws filled shape with outline - Outline only:
shouldFill = false,closed = true→ Draws just the outline - Open path:
shouldFill = false,closed = false→ Draws an open line (doesn't close or fill)
// Close the path if needed (default to closed unless explicitly false)
// The condition params.closed !== false means:
// - If closed is true → close it ✓
// - If closed is undefined (not set) → close it ✓ (default behavior)
// - If closed is false → don't close it ✗
// This pattern provides a sensible default (closed paths are common)
if (params.closed !== false) {
// closePath() automatically draws a line from the last point back to the first point
// This completes the shape, turning an open polyline into a closed polygon
// After closing, the path forms a complete region that can be filled
this.ctx.closePath();
// Fill the closed path, but only if shouldFill is true
// shouldFill is a flag in styleContext indicating whether to fill or just stroke
// We check before filling to avoid unnecessary fill operations
// Filling is computationally expensive, so we skip it if not needed
if (styleContext.shouldFill) {
// fill() fills the interior of the closed path using the current fillStyle
// The filled area is determined by the winding rule (even-odd or non-zero)
this.ctx.fill();
}
}
If you see an error at this step:
Error: TypeError: Cannot read property 'closed' of undefined
- What this means: params is undefined
- Common causes:
- params not passed to function
- params is null
- Wrong parameter order
- Fix: Add check:
if (!params) return false;at start, or use optional chaining:params?.closed !== false
Error: Path doesn't close when it should (open shape when expecting closed)
- What this means: Closed check logic is wrong
- Common causes:
- Wrong comparison:
if (params.closed === true)instead ofif (params.closed !== false)(fails when undefined) - Closed property is string:
params.closed = "true"(string, not boolean) - Logic inverted: checking for false instead of true
- Wrong comparison:
- Fix: Use
params.closed !== falseto default to closed, or explicitly check:if (params.closed === true || params.closed === undefined)
Error: TypeError: styleContext.shouldFill is undefined
- What this means: styleContext is undefined or missing shouldFill property
- Common causes:
- styleContext not passed:
renderRegularPath(params)missing second parameter - styleContext created incorrectly: missing shouldFill property
- styleContext is null
- styleContext not passed:
- Fix: Check styleContext exists:
if (!styleContext) return false;, verify shouldFill property exists
Error: Fill doesn't appear even though shouldFill is true
- What this means: Fill called incorrectly or styles not set
- Common causes:
- fillStyle not set before fill():
ctx.fill()withoutctx.fillStyle = 'red' - closePath() not called before fill()
- Path has zero area (all points same or collinear)
- fillStyle not set before fill():
- Fix: Set fillStyle before fill:
this.ctx.fillStyle = styleContext.fillColor; this.ctx.fill();, ensure closePath() called first
Step 2.6: Draw the Outline
stroke() draws the outline/border of the path. Think of it like drawing with a marker around the edges of a shape. This step renders the path's boundary using the current stroke style settings.
Understanding stroke():
When you call stroke(), the canvas:
- Uses the current path: Renders the path that was built with
moveTo(),lineTo(), andclosePath()calls - Applies stroke style: Uses the current
strokeStyle(color, gradient, pattern) andlineWidthsettings - Draws the outline: Creates visible lines along all the path segments
- Doesn't fill: Only draws the border/outline, not the interior
The stroke is drawn centered on the path - half the line width extends inside the path, half extends outside. For a closed path, this creates a visible border around the shape.
The Order Matters - Fill First, Then Stroke:
The order is critical and follows standard graphics programming practice:
Fill first (
ctx.fill()) - Paints the inside of the shape with color (the background). This fills the interior region defined by the closed path.Stroke second (
ctx.stroke()) - Draws the outline on top. Since we do it after filling, the outline appears on top of the fill, making it clearly visible and preventing the fill from covering any part of the stroke.
What Happens If You Do It Backwards (Stroke Then Fill):
If you stroke first, then fill:
- The stroke (outline) gets drawn first, creating a visible border
- Then the fill covers part of the stroke (half of the stroke width gets covered because the fill draws over it)
- Result: You only see half the stroke thickness - the outer half is visible, but the inner half is covered by the fill. This makes the stroke look thinner and inconsistent, and it can appear visually broken or incomplete.
Why This Happens:
The stroke is drawn centered on the path line. So if your path defines the edge of a shape, the stroke extends both inward and outward from that edge:
- Inner half: Extends into the shape's interior (gets covered by fill if fill comes after)
- Outer half: Extends outside the shape (always visible)
By filling first, then stroking, we ensure:
- The fill covers the interior (including the inner half of where the stroke will be)
- The stroke then draws on top, covering both the fill's edge and extending outward
- Result: Full stroke thickness is visible
Real-World Analogy: Think of painting a picture:
- First, fill in the shapes with paint (the fill) - this is like painting the background/base color
- Then, go back and outline them with a pen (the stroke) - this is like drawing the border on top
If you outline first, then paint over it, the paint will cover part of your outline! The outline you drew would be partially obscured by the paint. This is why artists typically paint the base colors first, then add outlines/details on top.
This is Standard Practice:
This is the standard way to draw filled shapes with outlines in graphics programming. Fill provides the base color/background, stroke provides the border/outline on top. Almost all graphics libraries (Canvas API, SVG, OpenGL, etc.) follow this pattern:
- Fill first (base layer)
- Stroke second (top layer)
This ensures consistent, predictable rendering where the stroke is always fully visible.
Return Value:
Returning true indicates successful rendering. This is useful for error handling - if validation fails or rendering can't complete, we return false to indicate failure. The caller can check the return value to know if the path was rendered successfully.
What Happens After stroke():
After stroke() is called:
- The path outline is rendered on the canvas
- The path data remains in memory (available for further operations if needed)
- Canvas state (colors, line width, etc.) is preserved for subsequent drawing operations
- The function completes and returns control to the caller
// Draw the outline (stroke) on top of the fill
// stroke() renders the path's boundary using current strokeStyle and lineWidth
// It's called AFTER fill() so the outline appears on top and is fully visible
// The stroke is drawn centered on the path - half inside, half outside
// By stroking after filling, we ensure the full stroke thickness is visible
// (if we stroked first, the fill would cover the inner half of the stroke)
this.ctx.stroke();
// Return true to indicate successful rendering
// This allows callers to check if path rendering completed successfully
return true;
}
If you see an error at this step:
Error: TypeError: this.ctx.stroke is not a function
- What this means: Canvas context doesn't have stroke method
- Common causes:
- Context is null/undefined:
this.ctx = null - Wrong context type: not a 2D rendering context
- Context was lost: canvas removed from DOM
- Context is null/undefined:
- Fix: Check context exists and is 2D:
if (!this.ctx || typeof this.ctx.stroke !== 'function') throw new Error('Invalid context');
Error: Path outline doesn't appear (no stroke visible)
- What this means: Stroke not drawing or styles not set
- Common causes:
- strokeStyle not set:
ctx.stroke()withoutctx.strokeStyle = 'color' - lineWidth is 0:
ctx.lineWidth = 0 - strokeStyle is transparent:
ctx.strokeStyle = 'rgba(0,0,0,0)' - Path has no segments (all points same location)
- strokeStyle not set:
- Fix: Set stroke style before stroke:
this.ctx.strokeStyle = styleContext.strokeColor; this.ctx.lineWidth = styleContext.strokeWidth; this.ctx.stroke();
Error: Stroke appears on top but fill covers part of it
- What this means: Stroke called before fill (wrong order)
- Common causes:
- stroke() called before fill()
- closePath() and fill() not called, but stroke() is
- Fix: Always call fill() first (if needed), then stroke(): fill background, stroke on top
Error: Path renders but stroke is very thick or invisible
- What this means: lineWidth set incorrectly
- Common causes:
- lineWidth too large:
ctx.lineWidth = 100makes stroke huge - lineWidth is 0 or negative: stroke invisible
- lineWidth is NaN: stroke might not render
- lineWidth too large:
- Fix: Check lineWidth value:
if (styleContext.strokeWidth > 0) { this.ctx.lineWidth = styleContext.strokeWidth; this.ctx.stroke(); }
The Complete Function:
renderRegularPath(params, styleContext, isSelected, isHovered) {
// Step 1: Extract points from params and validate
const { points } = params;
if (!points || points.length < 2) return false;
// Step 2: Start a new path on the canvas
this.ctx.beginPath();
// Step 3: Move to the first point without drawing
this.ctx.moveTo(points[0][0], points[0][1]);
// Step 4: Draw lines to each subsequent point
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i][0], points[i][1]);
}
// Step 5: Close the path if needed
if (params.closed !== false) {
this.ctx.closePath();
if (styleContext.shouldFill) {
this.ctx.fill();
}
}
// Step 6: Draw the outline
this.ctx.stroke();
return true;
}
Building This Step by Step:
- Create
renderRegularPath()method with four parameters - Extract points from params and validate (need at least 2 points)
- Call
beginPath()to start a new path - Call
moveTo()to position at first point - Loop through remaining points, calling
lineTo()for each - Check if path should be closed, call
closePath()if needed - Fill the path if
shouldFillis true - Stroke the path outline
- Return true to indicate success
Canvas Path Drawing Sequence Explained: The canvas 2D API uses a state machine for path drawing. Here's what happens:
beginPath()- Starts a new path, clearing any previous path state. This is like getting a fresh sheet of paper. Without it, you'd be drawing on top of previous paths.moveTo()- Positions the "pen" without drawing. This is like lifting your pen and placing it at a new location. We use it for the first point because we don't want a line from wherever the pen was before.lineTo()- Draws a line from the current position to the specified point. Each call extends the path. After multiplelineTo()calls, you have a polyline.closePath()- Connects the last point back to the first point, creating a closed polygon. This is essential for filling. The browser doesn't automatically close paths - you must call this explicitly.fill()- Fills the interior of the closed path. This only works properly on closed paths. The fill uses the currentfillStyle(color, gradient, etc.).stroke()- Draws the outline of the path. This uses the currentstrokeStyleandlineWidth. We call it afterfill()so the stroke appears on top.
Why This Order Matters:
- Fill before stroke ensures the stroke is visible on top
- Close before fill ensures proper shape closure
- Begin path first to avoid drawing on previous paths
- Move to first point to avoid unwanted initial line
Point Array Access:
Points are stored as [x, y] arrays, so we access them with:
points[i][0]- X coordinate of point ipoints[i][1]- Y coordinate of point i
This is a common pattern in graphics programming - coordinate pairs as arrays.
Turtle Path Rendering
Turtle graphics create paths with multiple sub-paths. This is different from regular paths because the turtle can lift its pen (penup) and move without drawing, then put the pen down (pendown) and draw again. Each continuous drawing segment becomes a separate sub-path.
Understanding Sub-Paths: When a turtle executes commands like:
forward(50)- draws a linepenup()- lifts the penforward(30)- moves without drawingpendown()- puts pen downforward(50)- draws another line
This creates two separate sub-paths: the first line and the second line. They're not connected because the pen was lifted between them.
The Rendering Function:
Turtle graphics create paths with multiple sub-paths. This is different from regular paths because the turtle can lift its pen (penup) and move without drawing, then put the pen down (pendown) and draw again. Each continuous drawing segment becomes a separate sub-path.
Understanding Sub-Paths:
When a turtle executes commands like:
forward(50)- draws a linepenup()- lifts the penforward(30)- moves without drawingpendown()- puts pen downforward(50)- draws another line
This creates two separate sub-paths: the first line and the second line. They're not connected because the pen was lifted between them.
Key Differences from Regular Paths:
- Each sub-path needs its own
beginPath()call to ensure sub-paths don't connect to each other - Turtle paths are always open (never closed) - they represent the path the pen took
- No fill - turtle graphics are line drawings, not filled shapes
- Only stroke - we just draw the outline
renderTurtlePath(params, styleContext, isSelected, isHovered) {
// Validate we have sub-paths to render
// subPaths is an array of arrays - each inner array is a separate path segment
if (!params.subPaths || params.subPaths.length === 0) return false;
// Render each sub-path separately
for (const path of params.subPaths) {
// Validate sub-path has enough points (need at least 2 for a line)
if (path.length >= 2) {
// Each sub-path needs its own beginPath() to prevent connections between segments
this.ctx.beginPath();
// Move to first point of this sub-path (position without drawing)
this.ctx.moveTo(path[0][0], path[0][1]);
// Draw lines connecting all points in this sub-path
for (let i = 1; i < path.length; i++) {
this.ctx.lineTo(path[i][0], path[i][1]);
}
// Draw outline (no fill for turtle paths - they're line drawings)
this.ctx.stroke();
}
}
return true;
}
Why Sub-Paths for Turtle Graphics: The key difference between turtle paths and regular paths is the concept of sub-paths:
Pen State: When the turtle executes
penup, it stops drawing and can move without leaving a trail. When it executespendown, it starts drawing again from the new position. Each continuous drawing segment (between penup/pendown pairs) becomes a separate sub-path.Example: If the turtle draws a square, lifts the pen, moves, draws a circle - that's two sub-paths. They're visually separate because the pen was lifted between them.
Rendering Strategy: We loop through each sub-path and render it separately with its own
beginPath()andstroke()calls. This ensures sub-paths don't connect to each other visually.Validation: We check
path.length >= 2because you need at least 2 points to draw a line (start and end). Sub-paths with fewer points are skipped.No Fill, No Close: Turtle paths are always open (not closed) and always stroked (not filled) because they represent pen movements, not filled shapes. They're like a pen drawing on paper - you can see the line, but there's no fill.
Performance Considerations:
- Each sub-path requires its own
beginPath()call, which has a small performance cost - For paths with many sub-paths, this can add up
- However, the visual correctness (separate sub-paths) is more important than micro-optimizations
- If you have thousands of sub-paths, consider batching or optimization strategies
Building Bezier Curve Rendering From Scratch
Bezier curves are smooth curves defined by control points. Unlike straight lines, bezier curves create smooth, flowing shapes that are essential for modern graphics. Understanding how to render them is crucial for creating professional-looking designs.
How to Build It Step by Step:
Step 1: Understand Bezier Curves A cubic bezier curve (the most common type) requires exactly 4 points:
- Start point - Where the curve begins
- Control point 1 - "Pulls" the curve in one direction
- Control point 2 - "Pulls" the curve in another direction
- End point - Where the curve ends
The curve doesn't pass through the control points - they act like magnets that influence the curve's shape. The curve starts at the start point, is pulled toward control point 1, then toward control point 2, and finally reaches the end point.
Step 2: Build the Rendering Function
Step 2.1: Validate Points
renderBezierPath(params, styleContext, isSelected, isHovered) {
// Validate we have enough points for a bezier curve
if (!params.points || params.points.length < 4) return false;
// Extract points for easier access
const points = params.points;
}
Why Validate: A cubic bezier curve requires exactly 4 points. If we have fewer, we can't draw a proper bezier curve. Return false to indicate we can't render this. This validation prevents errors and ensures we only render valid bezier curves.
Step 2.2: Start Path and Move to Start Point
// Start a new path
this.ctx.beginPath();
// Move to the start point
this.ctx.moveTo(points[0][0], points[0][1]);
Why beginPath() and moveTo():
beginPath() clears any previous path state. moveTo() positions the pen at the start of the curve without drawing. points[0] is the start point of the bezier curve.
Step 2.3: Draw the Bezier Curve
// Draw the bezier curve or fallback to lines
if (points.length === 4) {
// Perfect case: exactly 4 points for a cubic bezier
this.ctx.bezierCurveTo(
points[1][0], points[1][1], // Control point 1: influences start of curve
points[2][0], points[2][1], // Control point 2: influences end of curve
points[3][0], points[3][1] // End point: where the curve ends
);
} else {
// Fallback: if we have more than 4 points, just draw straight lines
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i][0], points[i][1]);
}
}
How bezierCurveTo() Works:
bezierCurveTo() draws a cubic bezier curve from the current position (the start point we moved to) to the end point, with the curve being influenced by the two control points. The curve starts at the current position, is "pulled" toward control point 1 (strongest influence at the start), then "pulled" toward control point 2 (strongest influence at the end), and finally reaches the end point. The control points don't lie on the curve - they're like handles that shape the curve. Moving a control point changes the curve's shape.
Why Fallback: If we have more than 4 points, we fall back to drawing straight lines. This handles edge cases where the data might be malformed or we're transitioning between different path types. This is a graceful degradation - if we can't draw a bezier curve, at least show something.
Step 2.4: Draw the Outline
// Draw the curve outline
this.ctx.stroke();
Why Stroke Only: We don't fill bezier curves by default - they're typically used for outlines. If you want to fill, you'd need to close the path first (but bezier curves are usually open paths, not closed shapes).
Step 2.5: Show Control Points if Selected
// Show control points if selected (for editing)
if (isSelected && params.showControlPoints !== false) {
this.renderBezierControlPoints(points);
}
return true;
}
Why Show Control Points:
When a bezier curve is selected, show the control points visually. This helps users understand and edit the curve. They can see where the control points are and how they influence the curve. The check params.showControlPoints !== false means "show by default" - only hide if explicitly set to false.
The Complete Function:
Bezier curves require exactly 4 points: a start point, two control points, and an end point. The control points act like "handles" that pull the curve toward them. The closer a control point is to the curve, the stronger its influence.
How Bezier Curves Work Mathematically:
The bezier curve is calculated using a mathematical formula that interpolates between the points:
- At t=0 (start): The curve is at the start point
- At t=0.5 (middle): The curve is influenced by both control points
- At t=1 (end): The curve is at the end point
If both control points are on the same side of the line between start and end, you get a simple curve. If they're on opposite sides, you get an S-curve.
Control Point Visualization:
When a bezier curve is selected, we draw the control points and lines connecting them to help users understand and edit the curve:
- Control points are typically drawn as small circles or squares
- Lines connect the start point to control point 1, and the end point to control point 2
- This visual feedback shows users how the control points influence the curve
- Users can then drag these control points to modify the curve's shape
Why the Fallback?
If we have more than 4 points, we fall back to drawing straight lines. This handles edge cases where the data might be malformed, we're transitioning between path types, or the system is in an inconsistent state. The fallback ensures we always render something, even if it's not a perfect bezier curve.
renderBezierPath(params, styleContext, isSelected, isHovered) {
// Validate we have enough points for a bezier curve (need 4: start, control1, control2, end)
if (!params.points || params.points.length < 4) return false;
const points = params.points;
// Start a new path
this.ctx.beginPath();
// Move to the start point
this.ctx.moveTo(points[0][0], points[0][1]);
// Draw bezier curve if we have exactly 4 points, otherwise fallback to lines
if (points.length === 4) {
this.ctx.bezierCurveTo(
points[1][0], points[1][1], // Control point 1
points[2][0], points[2][1], // Control point 2
points[3][0], points[3][1] // End point
);
} else {
// Fallback: draw straight lines if we have more than 4 points
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i][0], points[i][1]);
}
}
// Draw the curve outline
this.ctx.stroke();
// Show control points if selected (for editing) - default to showing unless explicitly disabled
if (isSelected && params.showControlPoints !== false) {
this.renderBezierControlPoints(points);
}
return true;
}
Smooth Path Rendering
Smooth paths use quadratic curves to create smooth transitions:
renderSmoothPath(params, styleContext, isSelected, isHovered) {
const { points } = params;
if (!points || points.length < 3) {
return this.renderRegularPath(params, styleContext, isSelected, isHovered);
}
this.ctx.beginPath();
this.ctx.moveTo(points[0][0], points[0][1]);
// Use quadratic curves between points
for (let i = 1; i < points.length - 1; i++) {
const currentPoint = points[i];
const nextPoint = points[i + 1];
// Control point is halfway between current and next
const controlX = (currentPoint[0] + nextPoint[0]) / 2;
const controlY = (currentPoint[1] + nextPoint[1]) / 2;
// Quadratic curve: goes through current point, curves toward control, ends at midpoint
this.ctx.quadraticCurveTo(
currentPoint[0], currentPoint[1], // Control point (the actual point)
controlX, controlY // End point (midpoint to next)
);
}
// Draw final line to last point
this.ctx.lineTo(points[points.length - 1][0], points[points.length - 1][1]);
if (params.closed !== false && points.length > 2) {
this.ctx.closePath();
if (styleContext.shouldFill) {
this.ctx.fill();
}
}
this.ctx.stroke();
return true;
}
How smooth paths work: Instead of sharp corners, use quadratic curves. The curve goes through each point, but curves smoothly between them. The control point is the actual point, the end point is the midpoint to the next point.
Why quadratic? Simpler than cubic bezier (only one control point), but still smooth. Good for approximating smooth curves from point data.
Path with Markers
Paths can have markers at the start and end (arrows, circles, etc.):
renderPathMarkers(params, styleContext) {
const { points } = params;
if (!points || points.length < 2) return;
if (params.startMarker) {
// Draw marker at first point, pointing toward second point
this.renderMarker(
points[0],
points[1],
params.startMarker,
styleContext
);
}
if (params.endMarker) {
// Draw marker at last point, pointing from second-to-last point
const lastIndex = points.length - 1;
this.renderMarker(
points[lastIndex],
points[lastIndex - 1],
params.endMarker,
styleContext
);
}
}
renderMarker(point, direction, markerType, styleContext) {
if (!point || !direction) return;
// Calculate angle from direction
const dx = direction[0] - point[0];
const dy = direction[1] - point[1];
const angle = Math.atan2(dy, dx);
this.ctx.save();
this.ctx.translate(point[0], point[1]);
this.ctx.rotate(angle);
switch (markerType) {
case 'arrow':
this.renderArrowMarker(styleContext);
break;
case 'circle':
this.renderCircleMarker(styleContext);
break;
// ... etc
}
this.ctx.restore();
}
How markers work: Calculate the angle from the point to the direction point. Rotate the canvas, draw the marker, restore. The marker is drawn in local space (pointing right), then rotated to match the path direction.
Arrow marker example:
renderArrowMarker(styleContext) {
const size = 8;
this.ctx.beginPath();
this.ctx.moveTo(0, 0); // Tip of arrow
this.ctx.lineTo(-size, -size/2); // Top of arrowhead
this.ctx.lineTo(-size, size/2); // Bottom of arrowhead
this.ctx.closePath();
if (styleContext.shouldFill) {
this.ctx.fill();
}
this.ctx.stroke();
}
Animated Path Rendering
For drawing animations, you can render only part of a path:
renderAnimatedPath(params, styleContext, isSelected, isHovered, animationProgress = 0) {
const { points } = params;
if (!points || points.length < 2) return false;
// Calculate total path length
const totalLength = this.calculatePathLength(points);
const currentLength = totalLength * animationProgress;
// Find which points are visible
let accumulatedLength = 0;
let visiblePoints = [points[0]];
for (let i = 1; i < points.length; i++) {
const segmentLength = this.calculateDistance(points[i-1], points[i]);
if (accumulatedLength + segmentLength <= currentLength) {
// This entire segment is visible
visiblePoints.push(points[i]);
accumulatedLength += segmentLength;
} else {
// Partially visible - interpolate
const remainingLength = currentLength - accumulatedLength;
const ratio = remainingLength / segmentLength;
const interpolatedPoint = [
points[i-1][0] + (points[i][0] - points[i-1][0]) * ratio,
points[i-1][1] + (points[i][1] - points[i-1][1]) * ratio
];
visiblePoints.push(interpolatedPoint);
break;
}
}
// Render only the visible portion
const animatedParams = { ...params, points: visiblePoints, closed: false };
return this.renderRegularPath(animatedParams, styleContext, isSelected, isHovered);
}
How animation works: Calculate the total path length. Based on progress (0 to 1), calculate how much of the path should be visible. Find which points are in that range, interpolate the last point if needed, then render only that portion.
Path length calculation:
calculatePathLength(points) {
let totalLength = 0;
for (let i = 1; i < points.length; i++) {
totalLength += this.calculateDistance(points[i-1], points[i]);
}
return totalLength;
}
calculateDistance(point1, point2) {
const dx = point2[0] - point1[0];
const dy = point2[1] - point1[1];
return Math.sqrt(dx * dx + dy * dy);
}
Path Simplification
For performance, you can reduce the number of points:
simplifyPath(points, tolerance = 1) {
if (!points || points.length <= 2) return points;
const simplified = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const current = points[i];
const next = points[i + 1];
// Calculate distance from current point to line between prev and next
const distance = this.distanceToLine(current, prev, next);
// If distance is large, keep the point. Otherwise, skip it.
if (distance > tolerance) {
simplified.push(current);
}
}
simplified.push(points[points.length - 1]);
return simplified;
}
How simplification works: For each point, check if it's close to the line between the previous and next points. If it is, you can remove it without changing the shape much. This reduces point count while keeping the path looking similar.
Distance to line: Project the point onto the line, calculate distance from point to projection. If it's small, the point is redundant.
Common Gotchas
Points must be in order: Path points define the outline. They must be sequential. Random order = broken path.
Closed paths: Set closed: true to connect last point to first. Otherwise, the path is open (just a line).
Bezier control points: Control points don't lie on the curve. They "pull" the curve. Users often expect them to be on the curve - that's a common confusion.
Path length: Calculating path length is expensive (iterate through all points). Cache it if you use it often.
Smooth paths: Quadratic curves are simpler than bezier, but still smooth. Good for approximating smooth curves.
The path renderer handles all the complex path drawing. It doesn't know about shapes, selection, or interaction. It just draws paths. This makes it reusable - any system that needs to draw paths can use it.
Building the Handle System From Scratch
Handles are the little squares/circles you see when you select a shape. You drag them to resize or rotate. The handle system manages all of this.
What Handles Are - Explained Simply
What Are Handles? Handles are the little squares or circles you see when you select a shape in design tools. They appear at key points on the shape (corners, edges, etc.) and let you drag them to modify the shape.
Real-World Analogy: Think of handles like the corners of a picture frame. When you want to resize a picture:
- You grab the corner (the handle)
- You drag it to make the picture bigger or smaller
- The picture resizes as you drag
Handles work exactly the same way, but in code!
Types of Handles:
Corner Handles (tl, tr, br, bl):
tl= top-left cornertr= top-right cornerbr= bottom-right cornerbl= bottom-left corner- These let you resize the shape by dragging any corner
- Dragging a corner makes the shape bigger or smaller
Rotation Handle:
- Usually appears outside the shape (often above it)
- Drag it in a circle to rotate the shape
- Like spinning a wheel - the handle is where you grab it
Edge Handles (optional):
- Appear in the middle of each edge (top, right, bottom, left)
- Let you resize along just one axis (make it wider or taller, but not both)
- Useful for fine-tuning dimensions
Visual Example:
When a shape is selected, handles appear:
[rotation handle]
|
+----+----+
| | | ← corner handles at corners
+--+ | +--+
| | | ← edge handles on sides
+-------+-------+
Why Do We Need Handles?
Without handles, users can't interact with shapes. They can't:
- Resize shapes by dragging
- Rotate shapes easily
- See what they can modify
Handles provide:
- Visual feedback - "I can drag this to resize"
- Clear interaction points - Users know where to click/drag
- Intuitive editing - Like real-world tools (Photoshop, Figma, etc.)
Where Have You Seen Handles?
- Photoshop: When you select a layer, handles appear
- Figma/Sketch: Selection shows corner handles for resizing
- Word/PowerPoint: Images have corner handles for resizing
- Even this text editor: If you select text, you might see handles for resizing
Handles are everywhere in design tools - they're a standard way to make things editable!
Building Basic Handle Drawing From Scratch
Handles are the interactive control points that appear when a shape is selected. They allow users to resize, rotate, and manipulate shapes directly.
How to Build It Step by Step:
Step 1: Create the drawHandleAtPosition() Method
This method draws a single handle at a specified position:
drawHandleAtPosition(x, y, handleType = 'corner', isHovered = false, isActive = false, color = null) {
// Step 1.1: Determine handle size and color
// Hovered handles are slightly larger for better visibility
const radius = isHovered ? this.handleHoverRadius : this.handleRadius;
const strokeColor = color || this.selectionColor;
// Step 1.2: Save canvas state and translate to handle position
// This allows us to draw the handle centered at (0, 0) in local space
this.ctx.save();
this.ctx.translate(x, y);
}
Why Save and Translate: By saving the canvas state and translating to the handle position, we can draw the handle centered at (0, 0) in local space. This makes the drawing code simpler - we don't need to add x/y offsets to every coordinate.
Step 2: Draw the Shadow
// Step 2.1: Draw shadow (offset by 0.5px for depth)
this.ctx.beginPath();
this.ctx.arc(0.5, 0.5, radius, 0, Math.PI * 2);
this.ctx.fillStyle = this.handleShadowColor;
this.ctx.fill();
Why Shadow: The shadow makes handles pop off the canvas. The 0.5px offset creates a subtle 3D effect, making the handle appear to float above the canvas. This improves visibility and provides visual feedback.
Step 3: Draw the Handle
// Step 3.1: Draw handle (white fill)
this.ctx.beginPath();
this.ctx.arc(0, 0, radius, 0, Math.PI * 2);
this.ctx.fillStyle = this.handleFillColor; // White
this.ctx.fill();
Why White Fill: White fill provides good contrast against most backgrounds. The handle is clearly visible whether the shape is light or dark colored.
Step 4: Draw the Border
// Step 4.1: Draw border
// Active handles (being dragged) get thicker, more opaque border
this.ctx.strokeStyle = isActive ? strokeColor + 'FF' : strokeColor;
this.ctx.lineWidth = isActive ? 3 : 2;
this.ctx.stroke();
Why Different Styles for Active: Active handles (being dragged) get a thicker, more opaque border. This provides clear visual feedback that the handle is currently being manipulated. The user can see which handle they're dragging.
Step 5: Draw Hover Ring
// Step 5.1: Draw hover ring (if hovered)
if (isHovered) {
this.ctx.beginPath();
this.ctx.arc(0, 0, radius + 2, 0, Math.PI * 2);
this.ctx.strokeStyle = strokeColor + '40'; // Semi-transparent
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
// Step 5.2: Restore canvas state
this.ctx.restore();
}
Why Hover Ring: The hover ring provides visual feedback when the mouse is over a handle. It's slightly larger than the handle and semi-transparent, making it clear which handle the mouse is hovering over without being too distracting.
The Complete Function:
drawHandleAtPosition(x, y, handleType = 'corner', isHovered = false, isActive = false, color = null) {
const radius = isHovered ? this.handleHoverRadius : this.handleRadius;
const strokeColor = color || this.selectionColor;
this.ctx.save();
this.ctx.translate(x, y);
// Draw shadow (offset by 0.5px for depth)
this.ctx.beginPath();
this.ctx.arc(0.5, 0.5, radius, 0, Math.PI * 2);
this.ctx.fillStyle = this.handleShadowColor;
this.ctx.fill();
// Draw handle
this.ctx.beginPath();
this.ctx.arc(0, 0, radius, 0, Math.PI * 2);
this.ctx.fillStyle = this.handleFillColor; // White
this.ctx.fill();
// Draw border
this.ctx.strokeStyle = isActive ? strokeColor + 'FF' : strokeColor;
this.ctx.lineWidth = isActive ? 3 : 2;
this.ctx.stroke();
// Draw hover ring
if (isHovered) {
this.ctx.beginPath();
this.ctx.arc(0, 0, radius + 2, 0, Math.PI * 2);
this.ctx.strokeStyle = strokeColor + '40';
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
this.ctx.restore();
}
The Visual Design: White fill, colored border, shadow for depth. When hovered, show a ring. When active (dragging), thicker border. This creates a clear, professional-looking handle that provides excellent visual feedback.
Building This Step by Step:
- Create
drawHandleAtPosition()method with parameters for position, type, state, and color - Determine handle size based on hover state
- Get stroke color (use provided color or default selection color)
- Save canvas state and translate to handle position
- Draw shadow circle (offset by 0.5px)
- Draw handle circle (white fill)
- Draw border (thicker and more opaque if active)
- Draw hover ring if hovered (semi-transparent, slightly larger)
- Restore canvas state
Building Corner Handle Drawing From Scratch
For a selected shape, draw handles at the corners. These handles allow users to resize the shape by dragging the corners.
How to Build It Step by Step:
Step 1: Calculate Handle Positions Handle positions are calculated based on the shape's bounds in local space:
drawCornerHandles(shape, scaledWidth, scaledHeight) {
// Step 1.1: Define handle positions in local space
// Shapes are centered at (0, 0), so corners are at ±width/2, ±height/2
const handlePositions = [
{ x: -scaledWidth / 2, y: -scaledHeight / 2, handle: 'tl' }, // Top-left
{ x: scaledWidth / 2, y: -scaledHeight / 2, handle: 'tr' }, // Top-right
{ x: scaledWidth / 2, y: scaledHeight / 2, handle: 'br' }, // Bottom-right
{ x: -scaledWidth / 2, y: scaledHeight / 2, handle: 'bl' } // Bottom-left
];
Why Local Space: The canvas is already translated and rotated to the shape's position. So handles are drawn in local space, then the canvas transform applies. This means we calculate positions relative to the shape's center (0, 0), and the canvas transform handles positioning and rotation.
Why These Coordinates: Shapes are centered at (0, 0), so:
- Top-left corner is at
(-width/2, -height/2) - Top-right corner is at
(width/2, -height/2) - Bottom-right corner is at
(width/2, height/2) - Bottom-left corner is at
(-width/2, height/2)
Step 2: Draw Each Handle
// Step 2.1: Draw each handle
handlePositions.forEach(pos => {
// Step 2.2: Check if this handle is hovered or active
const isHovered = this.hoveredHandle === pos.handle;
const isActive = this.activeHandle === pos.handle;
// Step 2.3: Draw the handle at its position
this.drawHandleAtPosition(pos.x, pos.y, 'corner', isHovered, isActive);
});
}
Why Check State:
We check if each handle is hovered or active so we can pass the correct state to drawHandleAtPosition(). This ensures handles show the correct visual feedback (hover ring, thicker border, etc.).
The Complete Function:
drawCornerHandles(shape, scaledWidth, scaledHeight) {
const handlePositions = [
{ x: -scaledWidth / 2, y: -scaledHeight / 2, handle: 'tl' },
{ x: scaledWidth / 2, y: -scaledHeight / 2, handle: 'tr' },
{ x: scaledWidth / 2, y: scaledHeight / 2, handle: 'br' },
{ x: -scaledWidth / 2, y: scaledHeight / 2, handle: 'bl' }
];
handlePositions.forEach(pos => {
const isHovered = this.hoveredHandle === pos.handle;
const isActive = this.activeHandle === pos.handle;
this.drawHandleAtPosition(pos.x, pos.y, 'corner', isHovered, isActive);
});
}
Building This Step by Step:
- Create
drawCornerHandles()method with shape and dimensions - Calculate handle positions in local space (four corners)
- Loop through each handle position
- Check if handle is hovered or active
- Call
drawHandleAtPosition()for each handle - Handles will be drawn with correct visual feedback
Drawing Rotation Handle
The rotation handle is above the shape:
drawRotationHandle(shape, scaledHeight) {
const rotHandleY = -scaledHeight / 2 - this.rotationHandleDistance;
const isRotHovered = this.hoveredHandle === 'rotate';
const isRotActive = this.activeHandle === 'rotate';
// Draw connection line
this.ctx.beginPath();
this.ctx.moveTo(0, -scaledHeight / 2);
this.ctx.lineTo(0, rotHandleY);
this.ctx.strokeStyle = this.selectionColor + '60';
this.ctx.lineWidth = 1;
this.ctx.stroke();
// Draw handle
this.drawHandleAtPosition(0, rotHandleY, 'rotation', isRotHovered, isRotActive);
// Draw rotation icon (curved arrow)
this.ctx.beginPath();
this.ctx.arc(0, rotHandleY, this.handleRadius * 0.4, 0, Math.PI * 1.5);
this.ctx.strokeStyle = this.selectionColor;
this.ctx.lineWidth = 1.5;
this.ctx.stroke();
}
Why above? Rotation handle needs to be separate from resize handles. Putting it above makes it clear it's for rotation.
The icon: A curved arrow indicates rotation. Draw a partial arc, then a small arrow.
Getting Handle Positions
To test if the mouse is over a handle:
getHandlePositions(shape) {
if (!shape || !shape.transform) return [];
// Get bounds in local space
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
// Get shape position in screen space
const shapeX = this.coordinateSystem.transformX(shape.transform.position[0]);
const shapeY = this.coordinateSystem.transformY(shape.transform.position[1]);
const halfWidth = scaledWidth / 2;
const halfHeight = scaledHeight / 2;
// Account for rotation
const angle = -shape.transform.rotation * Math.PI / 180;
const rotate = (px, py) => {
const s = Math.sin(angle);
const c = Math.cos(angle);
const dx = px - shapeX;
const dy = py - shapeY;
return {
x: shapeX + (dx * c - dy * s),
y: shapeY + (dx * s + dy * c)
};
};
// Calculate handle positions in screen space
const positions = [
{ id: 'tl', ...rotate(shapeX - halfWidth, shapeY - halfHeight), type: 'corner' },
{ id: 'tr', ...rotate(shapeX + halfWidth, shapeY - halfHeight), type: 'corner' },
{ id: 'br', ...rotate(shapeX + halfWidth, shapeY + halfHeight), type: 'corner' },
{ id: 'bl', ...rotate(shapeX - halfWidth, shapeY + halfHeight), type: 'corner' },
{ id: 'rotate', ...rotate(shapeX, shapeY - halfHeight - this.rotationHandleDistance), type: 'rotation' }
];
return positions;
}
Why screen space? Mouse coordinates are in screen space. To test if mouse is over a handle, handles need to be in screen space too.
Rotation handling: If the shape is rotated, rotate the handle positions. The rotate function rotates a point around the shape's center.
Testing Handle Hits
When the mouse moves, check if it's over a handle:
getHandleAtPoint(x, y, shape) {
if (!shape) return null;
const positions = this.getHandlePositions(shape);
for (const pos of positions) {
const dx = x - pos.x;
const dy = y - pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Check if point is within handle radius
if (dist <= this.handleRadius + 3) { // +3 for easier clicking
return {
type: pos.type === 'rotation' ? 'rotate' : 'scale',
handle: pos.id,
position: pos
};
}
}
return null;
}
Hit testing: Calculate distance from mouse to each handle. If distance < radius, it's a hit. The +3 makes it easier to click (slightly larger hit area).
Return value: Returns handle info (type, id, position) so the interaction handler knows what to do.
Drawing Selection Handles
The main method that draws all handles for a selected shape:
drawSelectionHandles(shape, transformContext) {
if (!shape || !shape.transform) return;
const { transform } = shape;
const screenX = this.coordinateSystem.transformX(transform.position[0]);
const screenY = this.coordinateSystem.transformY(transform.position[1]);
this.ctx.save();
this.ctx.translate(screenX, screenY);
this.ctx.rotate(-transform.rotation * Math.PI / 180);
// Get bounds
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
// Draw handles
this.drawCornerHandles(shape, scaledWidth, scaledHeight);
this.drawRotationHandle(shape, scaledHeight);
this.ctx.restore();
}
The transform: Translate to shape position, rotate by shape rotation. Now handles are drawn in the shape's local space, but rotated correctly.
Why negative rotation? Canvas rotation is counter-clockwise, but we store rotation as clockwise (standard). So negate it.
Handle State Management
Track which handle is active/hovered:
setActiveHandle(handle) {
this.activeHandle = handle; // 'tl', 'tr', 'br', 'bl', 'rotate', or null
}
setHoveredHandle(handle) {
this.hoveredHandle = handle;
}
clearHandleState() {
this.activeHandle = null;
this.hoveredHandle = null;
}
State tracking: The interaction handler sets these based on mouse events. The renderer uses them to draw handles with the right visual state.
Common Gotchas
Handle positions must account for rotation: If the shape is rotated, handles need to be rotated too. Calculate in screen space, not local space.
Hit testing needs screen coordinates: Mouse events give screen coordinates. Handles need to be in screen space for hit testing.
Handle size should scale with zoom: At high zoom, handles might be too small. Scale handle radius with zoom level.
Rotation handle distance: Make it far enough that it doesn't overlap with corner handles, but close enough that it's easy to reach.
Visual feedback: Hover and active states need to be obvious. Use color, size, and rings to make it clear.
The handle system provides the UI for shape manipulation. It doesn't know about shape parameters or how resizing works - that's the transform manager's job. The handle system just draws handles and reports which one was clicked.
Building the Selection System From Scratch
Selection is fundamental to any graphics editor. Users need to see which shape is selected, get visual feedback, and see shape information. The selection system handles all of this.
What Selection Is
Selection is just tracking which shape the user has selected:
this.selectedShape = shape; // The shape object, or null
this.hoveredShape = shapeName; // The shape name (for hover), or null
Why track both? Selected = clicked and active. Hovered = mouse is over it. Different visual feedback for each.
Building Selection Outline Drawing From Scratch
When a shape is selected, draw an outline to provide visual feedback. This outline should clearly indicate which shape is selected.
How to Build It Step by Step:
Step 1: Validate and Get Transform
drawSelectionOutline(shape, transformContext) {
// Step 1.1: Validate shape has transform
if (!shape || !shape.transform) return;
// Step 1.2: Extract transform for easier access
const { transform } = shape;
Why Validate: If the shape doesn't have a transform, we can't draw the outline correctly. Return early to avoid errors.
Step 2: Convert Position to Screen Coordinates
// Step 2.1: Convert shape position to screen coordinates
const screenX = this.coordinateSystem.transformX(transform.position[0]);
const screenY = this.coordinateSystem.transformY(transform.position[1]);
Why Convert to Screen: The shape's position is in world coordinates. We need to convert it to screen coordinates (pixels) to draw on the canvas.
Step 3: Transform Canvas to Shape Position and Rotation
// Step 3.1: Save canvas state
this.ctx.save();
// Step 3.2: Translate to shape position
this.ctx.translate(screenX, screenY);
// Step 3.3: Rotate to match shape rotation
this.ctx.rotate(-transform.rotation * Math.PI / 180);
Why Transform Canvas: The shape might be rotated. By transforming the canvas (translate and rotate), we can draw the outline aligned with the shape. The negative rotation is because canvas rotation is clockwise, but our rotation values are typically counter-clockwise.
Step 4: Calculate Scaled Bounds
// Step 4.1: Get shape bounds
const bounds = this.renderer.transformManager.calculateBounds(shape);
// Step 4.2: Scale bounds to screen space
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
Why Scale Bounds: Bounds are in world coordinates. We need to scale them by the coordinate system's scale to get screen-space dimensions.
Step 5: Draw Dashed Outline
// Step 5.1: Set stroke style (semi-transparent selection color)
this.ctx.strokeStyle = this.selectionColor + '40';
this.ctx.lineWidth = 1;
// Step 5.2: Set dash pattern
this.ctx.setLineDash([4, 4]);
// Step 5.3: Draw rectangle outline (centered at 0, 0)
this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
// Step 5.4: Reset dash pattern
this.ctx.setLineDash([]);
// Step 5.5: Restore canvas state
this.ctx.restore();
}
Why Dashed Outline:
A dashed rectangle around the shape's bounds. Dashed makes it clear it's a selection indicator, not part of the shape. The semi-transparent color ('40' = 25% opacity) ensures it's visible but not too prominent.
Why Centered at (0, 0):
Since we translated the canvas to the shape's position, we draw the rectangle centered at (0, 0) in local space. The rectangle extends from -width/2 to +width/2 and -height/2 to +height/2.
The Complete Function:
drawSelectionOutline(shape, transformContext) {
if (!shape || !shape.transform) return;
const { transform } = shape;
const screenX = this.coordinateSystem.transformX(transform.position[0]);
const screenY = this.coordinateSystem.transformY(transform.position[1]);
this.ctx.save();
this.ctx.translate(screenX, screenY);
this.ctx.rotate(-transform.rotation * Math.PI / 180);
// Get bounds
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
// Draw dashed outline
this.ctx.strokeStyle = this.selectionColor + '40';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([4, 4]);
this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
this.ctx.setLineDash([]);
this.ctx.restore();
}
Building This Step by Step:
- Create
drawSelectionOutline()method with shape and transform context - Validate shape has transform, return early if not
- Extract transform from shape
- Convert shape position to screen coordinates
- Save canvas state
- Translate canvas to shape position
- Rotate canvas to match shape rotation
- Get shape bounds and scale to screen space
- Set stroke style (semi-transparent selection color)
- Set dash pattern for dashed line
- Draw rectangle outline centered at (0, 0)
- Reset dash pattern
- Restore canvas state
Building Hover Outline Drawing From Scratch
Hover outline is similar to selection outline, but with different styling to distinguish it. It shows which shape the mouse is hovering over.
How to Build It Step by Step:
Step 1: Build Similar to Selection Outline The hover outline uses the same structure as the selection outline, but with different visual styling:
drawHoverOutline(shape, transformContext) {
// Step 1.1: Same validation and setup as selection outline
if (!shape || !shape.transform) return;
const { transform } = shape;
const screenX = this.coordinateSystem.transformX(transform.position[0]);
const screenY = this.coordinateSystem.transformY(transform.position[1]);
this.ctx.save();
this.ctx.translate(screenX, screenY);
this.ctx.rotate(-transform.rotation * Math.PI / 180);
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
// Step 1.2: Different styling for hover
this.ctx.strokeStyle = this.hoverColor + '80'; // Different color, more opaque
this.ctx.lineWidth = 2; // Thicker
this.ctx.setLineDash([2, 2]); // Different dash pattern (shorter dashes)
this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
this.ctx.setLineDash([]);
this.ctx.restore();
}
Hover vs Selection Styling:
- Hover: Lighter/more transparent color (
'80'= 50% opacity), thicker line (2px), shorter dashes ([2, 2]) - Selection: More prominent color (
'40'= 25% opacity), thinner line (1px), longer dashes ([4, 4])
Why Different Styling: Hover shows "this will be selected if you click", selection shows "this is selected". The hover outline should be noticeable but not as prominent as the selection outline. The thicker line and more opaque color make it clear which shape is being hovered, while the selection outline is more subtle since the shape is already selected.
The Complete Function:
drawHoverOutline(shape, transformContext) {
if (!shape || !shape.transform) return;
const { transform } = shape;
const screenX = this.coordinateSystem.transformX(transform.position[0]);
const screenY = this.coordinateSystem.transformY(transform.position[1]);
this.ctx.save();
this.ctx.translate(screenX, screenY);
this.ctx.rotate(-transform.rotation * Math.PI / 180);
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
// Different styling for hover
this.ctx.strokeStyle = this.hoverColor + '80'; // Different color, more opaque
this.ctx.lineWidth = 2; // Thicker
this.ctx.setLineDash([2, 2]); // Different dash pattern
this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
this.ctx.setLineDash([]);
this.ctx.restore();
}
Building This Step by Step:
- Create
drawHoverOutline()method (same structure as selection outline) - Use same validation and setup as selection outline
- Use different stroke style (hover color, more opaque)
- Use thicker line width (2px instead of 1px)
- Use different dash pattern (shorter dashes)
- Draw rectangle outline same way
- Restore canvas state
Building Operation Label Drawing From Scratch
For boolean operations (union, difference, intersection), show what operation created the shape. This helps users understand the shape's origin.
How to Build It Step by Step:
Step 1: Validate and Get Position
drawOperationLabel(shape, operation) {
// Step 1.1: Check if labels should be shown
// showOperationLabels is a setting that can be toggled
// If operation is not provided, there's nothing to label
if (!this.showOperationLabels || !operation) return;
// Step 1.2: Get shape position in screen coordinates
const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
Why Validate: Labels are optional - users might not want them cluttering the canvas. If labels are disabled or there's no operation to label, return early.
Step 2: Calculate Label Position
// Step 2.1: Get shape bounds
const bounds = this.renderer.transformManager.calculateBounds(shape);
// Step 2.2: Calculate label Y position (above the shape)
// Start at shape center Y, move up by half the shape height, then add 25px spacing
const labelY = screenY - (bounds.height * this.coordinateSystem.scale) / 2 - 25;
Why Above Shape: Positioning the label above the shape keeps it visible and doesn't obscure the shape itself. The 25px spacing provides visual separation.
Step 3: Draw Label Background
// Step 3.1: Draw label background (semi-transparent black)
// This provides contrast so the text is readable over any background
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
this.ctx.fillRect(screenX - 40, labelY - 10, 80, 20);
Why Background: The semi-transparent black background provides contrast so the white text is readable over any shape color. The rectangle is 80px wide and 20px tall, centered horizontally on the shape.
Step 4: Draw Label Border
// Step 4.1: Draw label border (colored by operation type)
// Different operations get different colors for visual distinction
this.ctx.strokeStyle = this.getOperationColor(operation);
this.ctx.lineWidth = 2;
this.ctx.strokeRect(screenX - 40, labelY - 10, 80, 20);
Why Colored Border: Different operations get different colors (e.g., union = green, difference = red, intersection = blue). This provides quick visual identification of the operation type.
Step 5: Draw Label Text
// Step 5.1: Set text style
this.ctx.fillStyle = 'white';
this.ctx.font = '12px monospace';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
// Step 5.2: Draw the operation name (uppercase)
this.ctx.fillText(operation.toUpperCase(), screenX, labelY);
}
Why These Text Settings:
- White fill for contrast against black background
- Monospace font for consistent width
- Center alignment so text is centered in the label
- Middle baseline so text is vertically centered
- Uppercase for consistency and readability
The Complete Function:
drawOperationLabel(shape, operation) {
if (!this.showOperationLabels || !operation) return;
const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
const bounds = this.renderer.transformManager.calculateBounds(shape);
const labelY = screenY - (bounds.height * this.coordinateSystem.scale) / 2 - 25;
// Draw label background
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
this.ctx.fillRect(screenX - 40, labelY - 10, 80, 20);
// Draw label border
this.ctx.strokeStyle = this.getOperationColor(operation);
this.ctx.lineWidth = 2;
this.ctx.strokeRect(screenX - 40, labelY - 10, 80, 20);
// Draw label text
this.ctx.fillStyle = 'white';
this.ctx.font = '12px monospace';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(operation.toUpperCase(), screenX, labelY);
}
Building This Step by Step:
- Create
drawOperationLabel()method with shape and operation - Check if labels should be shown and operation exists
- Get shape position in screen coordinates
- Calculate label Y position (above shape with spacing)
- Draw semi-transparent black background rectangle
- Draw colored border rectangle (color based on operation)
- Set text style (white, monospace, centered)
- Draw operation name in uppercase
- This provides clear visual identification of boolean operations
Building Selection Info Panel From Scratch
Show shape information in a panel when a shape is selected. This provides detailed information about the shape's properties and state.
How to Build It Step by Step:
Step 1: Validate and Get Position
drawSelectionInfo(shape, shapeName) {
// Step 1.1: Validate shape exists
if (!shape) return;
// Step 1.2: Get shape position in screen coordinates
const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
// Step 1.3: Get shape bounds for positioning
const bounds = this.renderer.transformManager.calculateBounds(shape);
Why Validate: If there's no shape, there's nothing to show info for. Return early to prevent errors.
Step 2: Calculate Panel Position
// Step 2.1: Position info panel to the right of the shape
// Start at shape center X, move right by half the shape width, add 15px spacing
const infoX = screenX + (bounds.width * this.coordinateSystem.scale) / 2 + 15;
// Step 2.2: Position panel vertically centered on shape
const infoY = screenY;
Why Right of Shape: Positioning the panel to the right keeps it visible and doesn't obscure the shape. The 15px spacing provides visual separation.
Step 3: Get Info Text and Calculate Panel Size
// Step 3.1: Get info text lines
// This method generates an array of text lines with shape information
// (name, type, position, rotation, parameters, etc.)
const info = this.getShapeInfoText(shape, shapeName);
// Step 3.2: Calculate maximum line width
// Measure each line's width and find the maximum
// This ensures the panel is wide enough for all text
const maxWidth = Math.max(...info.map(line => this.ctx.measureText(line).width));
// Step 3.3: Calculate panel dimensions
// Width: max text width + 16px padding (8px on each side)
const panelWidth = maxWidth + 16;
// Height: number of lines * line height (16px) + 8px padding (4px top/bottom)
const panelHeight = info.length * 16 + 8;
Why Calculate Size Dynamically: Different shapes have different amounts of information. Calculating the panel size based on the actual text ensures it fits all information without being too large or too small.
Step 4: Draw Panel Background and Border
// Step 4.1: Draw panel background (semi-transparent white)
// White background provides good contrast for dark text
// 0.95 opacity makes it slightly transparent so it doesn't completely obscure
// shapes behind it, but opaque enough to be clearly readable
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
this.ctx.fillRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);
// Step 4.2: Draw panel border
// Border uses selection color to visually connect the panel to the selected shape
this.ctx.strokeStyle = this.selectionColor;
this.ctx.lineWidth = 1;
this.ctx.strokeRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);
Why Border: The border uses the selection color to visually connect the panel to the selected shape. This helps users understand that the panel is showing information about the selected shape.
Step 5: Draw Info Text
// Step 5.1: Set text style
// Dark text (#333) for good contrast against white background
// Monospace font for consistent width and readability
this.ctx.fillStyle = '#333';
this.ctx.font = '11px monospace';
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
// Step 5.2: Draw each info line
// Loop through info lines and draw each one with proper spacing
// 8px left padding, 8px top padding, 16px line height
for (let i = 0; i < info.length; i++) {
this.ctx.fillText(info[i], infoX + 8, infoY - panelHeight/2 + 8 + i * 16);
}
}
Why These Text Settings:
- Dark text (#333) for good contrast against white background
- Monospace font for consistent width and readability
- Left-aligned text with padding for comfortable reading
- 16px line height for clear separation between lines
- 8px padding on all sides for visual breathing room
The Complete Function:
drawSelectionInfo(shape, shapeName) {
if (!shape) return;
const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
const bounds = this.renderer.transformManager.calculateBounds(shape);
// Position info panel to the right of the shape
const infoX = screenX + (bounds.width * this.coordinateSystem.scale) / 2 + 15;
const infoY = screenY;
// Get info text
const info = this.getShapeInfoText(shape, shapeName);
const maxWidth = Math.max(...info.map(line => this.ctx.measureText(line).width));
const panelWidth = maxWidth + 16;
const panelHeight = info.length * 16 + 8;
// Draw panel background
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
this.ctx.fillRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);
// Draw border
this.ctx.strokeStyle = this.selectionColor;
this.ctx.lineWidth = 1;
this.ctx.strokeRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);
// Draw text
this.ctx.fillStyle = '#333';
this.ctx.font = '11px monospace';
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
for (let i = 0; i < info.length; i++) {
this.ctx.fillText(info[i], infoX + 8, infoY - panelHeight/2 + 8 + i * 16);
}
}
Building This Step by Step:
- Create
drawSelectionInfo()method with shape and shape name - Validate shape exists, return early if not
- Get shape position in screen coordinates
- Calculate panel position (right of shape with spacing)
- Get info text lines from helper method
- Calculate panel size based on text content
- Draw semi-transparent white background rectangle
- Draw border rectangle with selection color
- Set text style (dark, monospace, left-aligned)
- Draw each info line with proper spacing
- This provides detailed shape information when selected
getShapeInfoText(shape, shapeName) {
const info = [${shapeName} (${shape.type})];
if (shape.transform.position) {
info.push(`pos: ${shape.transform.position[0].toFixed(1)}, ${shape.transform.position[1].toFixed(1)}`);
}
if (shape.transform.rotation) {
info.push(`rot: ${shape.transform.rotation.toFixed(1)}°`);
}
const params = shape.params;
if (params.width && params.height) {
info.push(`size: ${params.width.toFixed(1)} × ${params.height.toFixed(1)}`);
} else if (params.radius) {
info.push(`radius: ${params.radius.toFixed(1)}`);
}
if (params.operation) {
info.push(`op: ${params.operation}`);
}
return info;
}
**Info panel:** Shows shape name, position, rotation, size, operation. Useful for debugging and precise editing.
**Text measurement:** Use `ctx.measureText()` to calculate text width. Size the panel to fit the text.
### Multi-Selection
Support selecting multiple shapes:
```javascript
drawMultiSelectionOutline(shapes) {
if (!shapes || shapes.length === 0) return;
// Calculate bounding box of all selected shapes
const bounds = this.calculateMultiSelectionBounds(shapes);
// Draw outline around all shapes
this.ctx.save();
this.ctx.strokeStyle = this.selectionColor + '60';
this.ctx.lineWidth = 2;
this.ctx.setLineDash([8, 4]);
this.ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
this.ctx.setLineDash([]);
// Draw semi-transparent fill
this.ctx.fillStyle = this.selectionColor + '10';
this.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
// Draw selection count
this.drawSelectionCount(shapes.length, bounds);
this.ctx.restore();
}
calculateMultiSelectionBounds(shapes) {
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const shape of shapes) {
const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
const bounds = this.renderer.transformManager.calculateBounds(shape);
const scaledWidth = bounds.width * this.coordinateSystem.scale;
const scaledHeight = bounds.height * this.coordinateSystem.scale;
minX = Math.min(minX, screenX - scaledWidth / 2);
maxX = Math.max(maxX, screenX + scaledWidth / 2);
minY = Math.min(minY, screenY - scaledHeight / 2);
maxY = Math.max(maxY, screenY + scaledHeight / 2);
}
return {
x: minX - 10,
y: minY - 10,
width: maxX - minX + 20,
height: maxY - minY + 20
};
}
Multi-selection bounds: Find the bounding box that contains all selected shapes. Draw an outline around that.
Selection count: Show how many shapes are selected. Useful when you have many shapes.
Selection State Management
Simple state tracking:
setSelectedShape(shape) {
this.selectedShape = shape;
}
setHoveredShape(shapeName) {
this.hoveredShape = shapeName;
}
clearSelection() {
this.selectedShape = null;
this.hoveredShape = null;
}
isShapeSelected(shape) {
return this.selectedShape === shape;
}
isShapeHovered(shapeName) {
return this.hoveredShape === shapeName;
}
State management: The interaction handler sets these based on mouse events. The renderer uses them to draw the right visual feedback.
Why shape object for selected, name for hovered? Selected needs the full object (for manipulation). Hovered just needs the name (for lookup). This is a design choice - you could use names for both.
Common Gotchas
Selection outline must account for rotation: If the shape is rotated, the outline must be rotated too. Transform the canvas before drawing.
Hover vs selection: Different visual styles. Hover = "will select", selection = "is selected". Make them distinct.
Info panel positioning: Position it so it doesn't overlap the shape. Usually to the right or above.
Multi-selection bounds: Calculate correctly. Include all shapes, account for rotation.
Performance: Drawing selection outlines on every frame can be expensive. Only draw when needed (shape is selected/hovered).
The selection system provides visual feedback for user interaction. It doesn't know about shape manipulation or parameters - that's other systems' jobs. The selection system just shows what's selected and provides visual cues.
How to Build Paths, Handles, and Selection Systems - Complete Step-by-Step Guide
This section provides a complete guide for building the path renderer, handle system, and selection system from scratch.
Part 1: Building the Path Renderer
Step 1.1: Create the Path Renderer Class
File: src/renderer/pathRenderer.mjs
What You're Building:
A PathRenderer class that draws paths (sequences of connected points) on the canvas. Paths can be open (lines) or closed (polygons), and can have both fill and stroke styles. This class handles coordinate conversion and canvas drawing operations.
Why This Class: Paths are a fundamental drawing primitive. They allow users to create custom shapes by specifying points. The path renderer handles the complexity of converting world coordinates to screen coordinates, applying styles, and using the canvas API correctly.
How to Build It Step by Step:
Step 1.1.1: Create the PathRenderer Class and Constructor Start with the class definition:
export class PathRenderer {
constructor(ctx) {
// Step 1.1.1.1: Store canvas context
// The context provides all drawing methods (beginPath, lineTo, stroke, etc.)
this.ctx = ctx;
}
Why Store Context: The canvas context is needed for all drawing operations. We store it as an instance property so all methods can access it.
Step 1.1.2: Implement drawPath() Method - Validation and Setup
Start the method with validation and style setup:
drawPath(shape, style, coordinateSystem) {
// Step 1.1.2.1: Extract parameters from shape
// Paths have a 'points' array and optional 'closed' flag
const params = shape.params || {};
const points = params.points || [];
// Step 1.1.2.2: Validate minimum points
// A path needs at least 2 points (start and end)
if (points.length < 2) return false;
// Step 1.1.2.3: Save canvas state
// This allows us to restore canvas state after drawing
// Important for nested drawing operations
this.ctx.save();
Why Save Canvas State: Canvas operations modify global state (fillStyle, strokeStyle, etc.). By saving before and restoring after, we ensure our drawing doesn't affect other drawing operations.
Step 1.1.3: Apply Styles Set up fill and stroke styles:
// Step 1.1.3.1: Apply stroke style
// Stroke is the outline of the path
if (style.stroke) {
this.ctx.strokeStyle = style.strokeColor || '#000000';
this.ctx.lineWidth = style.strokeWidth || 1;
}
// Step 1.1.3.2: Apply fill style
// Fill is the interior of closed paths
if (style.fill) {
this.ctx.fillStyle = style.fillColor || '#000000';
}
Why Check Style Flags: We only set styles if they're enabled. This avoids unnecessary style changes and allows paths to be stroke-only or fill-only.
Step 1.1.4: Draw the Path Convert points and draw the path:
// Step 1.1.4.1: Start a new path
// beginPath() starts a new path, clearing any previous path
this.ctx.beginPath();
// Step 1.1.4.2: Move to first point
// moveTo() sets the starting point without drawing
const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
this.ctx.moveTo(firstPoint.x, firstPoint.y);
// Step 1.1.4.3: Draw lines to remaining points
// lineTo() draws a line from current point to specified point
for (let i = 1; i < points.length; i++) {
const screen = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
this.ctx.lineTo(screen.x, screen.y);
}
Why Convert Coordinates: Points are in world coordinates (millimeters), but canvas uses screen coordinates (pixels). We must convert each point using the coordinate system.
Step 1.1.5: Close Path and Apply Fill/Stroke Finish the path:
// Step 1.1.5.1: Close path if needed
// closePath() draws a line from last point back to first point
// Default is closed (closed !== false means closed by default)
if (params.closed !== false) {
this.ctx.closePath();
// Step 1.1.5.2: Fill closed paths
// Fill only works on closed paths
if (style.fill) {
this.ctx.fill();
}
}
// Step 1.1.5.3: Stroke the path
// Stroke draws the outline (works for both open and closed paths)
if (style.stroke) {
this.ctx.stroke();
}
// Step 1.1.5.4: Restore canvas state
// Restore the canvas state we saved earlier
this.ctx.restore();
return true;
}
}
Why Close Path:
Closed paths form polygons that can be filled. Open paths are just lines. The closed parameter controls this. We check closed !== false so the default is closed (undefined = closed).
The Complete Class:
export class PathRenderer {
constructor(ctx) {
this.ctx = ctx;
}
drawPath(shape, style, coordinateSystem) {
const params = shape.params || {};
const points = params.points || [];
if (points.length < 2) return false;
this.ctx.save();
// Apply style
if (style.stroke) {
this.ctx.strokeStyle = style.strokeColor || '#000000';
this.ctx.lineWidth = style.strokeWidth || 1;
}
if (style.fill) {
this.ctx.fillStyle = style.fillColor || '#000000';
}
// Convert points to screen coordinates
this.ctx.beginPath();
const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
this.ctx.moveTo(firstPoint.x, firstPoint.y);
for (let i = 1; i < points.length; i++) {
const screen = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
this.ctx.lineTo(screen.x, screen.y);
}
// Close path if needed
if (params.closed !== false) {
this.ctx.closePath();
if (style.fill) {
this.ctx.fill();
}
}
if (style.stroke) {
this.ctx.stroke();
}
this.ctx.restore();
return true;
}
}
Building This Step by Step:
- Create new file
src/renderer/pathRenderer.mjs - Export
PathRendererclass - Create constructor that takes
ctxparameter - Store context as instance property
- Create
drawPath()method with shape, style, coordinateSystem parameters - Extract params and points from shape
- Validate minimum 2 points, return false if invalid
- Call
ctx.save()to save canvas state - Check if stroke is enabled, set strokeStyle and lineWidth
- Check if fill is enabled, set fillStyle
- Call
ctx.beginPath()to start new path - Convert first point to screen coordinates
- Call
ctx.moveTo()with first point - Loop through remaining points
- Convert each point to screen coordinates
- Call
ctx.lineTo()for each point - Check if path should be closed
- If closed, call
ctx.closePath() - If closed and fill enabled, call
ctx.fill() - If stroke enabled, call
ctx.stroke() - Call
ctx.restore()to restore canvas state - Return true on success
- This class provides path rendering with coordinate conversion
Step 1.2: Add Bezier Curve Support
What You're Building: A method that extends path rendering to support Bezier curves. Bezier curves allow smooth, curved paths instead of just straight lines. This method handles both cubic Bezier curves (with two control points) and quadratic Bezier curves (with one control point).
Why This Method: Straight lines are limiting. Bezier curves allow smooth, organic shapes. They're essential for professional graphics. This method adds curve support while maintaining compatibility with straight-line paths.
How to Build It Step by Step:
Step 1.2.1: Method Setup and Validation Start with parameter extraction and validation:
drawBezierPath(shape, style, coordinateSystem) {
// Step 1.2.1.1: Extract parameters
// Points are the anchor points (start/end of curves)
// Control points define the curve shape
const params = shape.params || {};
const points = params.points || [];
const controlPoints = params.controlPoints || [];
// Step 1.2.1.2: Validate minimum points
// Need at least 2 anchor points (start and end)
if (points.length < 2) return false;
// Step 1.2.1.3: Save canvas state
this.ctx.save();
// Step 1.2.1.4: Apply styles (assuming applyStyle method exists)
this.applyStyle(style);
Why Control Points: Bezier curves use control points to define the curve shape. Each curve segment has anchor points (start/end) and control points (define curvature). More control points = more complex curves.
Step 1.2.2: Start Path and Move to First Point Initialize the path:
// Step 1.2.2.1: Start new path
this.ctx.beginPath();
// Step 1.2.2.2: Move to first anchor point
// This is the starting point of the path
const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
this.ctx.moveTo(firstPoint.x, firstPoint.y);
Why Move to First Point:
The first point is special - we use moveTo() instead of lineTo() because there's no previous point to draw from.
Step 1.2.3: Draw Curves or Lines Loop through points, drawing curves or lines:
// Step 1.2.3.1: Loop through remaining anchor points
// Each iteration draws a segment (curve or line) to the next anchor point
for (let i = 1; i < points.length; i++) {
// Step 1.2.3.2: Check if this segment has control points
// Control points are indexed by previous anchor point (i - 1)
if (controlPoints[i - 1]) {
// Step 1.2.3.3: Bezier curve segment
// Convert first control point to screen coordinates
const cp1 = coordinateSystem.worldToScreen(
controlPoints[i - 1][0],
controlPoints[i - 1][1]
);
// Step 1.2.3.4: Check for second control point
// If present, it's a cubic Bezier (2 control points)
// If absent, use cp1 as second control point (quadratic Bezier)
const cp2 = controlPoints[i - 1][2] ?
coordinateSystem.worldToScreen(controlPoints[i - 1][2], controlPoints[i - 1][3]) :
cp1;
// Step 1.2.3.5: Convert end anchor point
const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
// Step 1.2.3.6: Draw cubic Bezier curve
// bezierCurveTo(cp1x, cp1y, cp2x, cp2y, endX, endY)
// Draws curve from current point to end, using cp1 and cp2 to define curvature
this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
} else {
// Step 1.2.3.7: Straight line segment
// No control points = straight line
const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
this.ctx.lineTo(end.x, end.y);
}
}
Why Check Control Points: Not all segments need curves. If a segment has no control points, we draw a straight line. This allows mixing curves and lines in the same path.
Why Cubic Bezier: Cubic Bezier curves (with 2 control points) provide the most flexibility. Quadratic Bezier (1 control point) is a special case where both control points are the same.
Step 1.2.4: Close Path and Apply Styles Finish the path:
// Step 1.2.4.1: Close path if needed
if (params.closed) {
this.ctx.closePath();
// Step 1.2.4.2: Fill if enabled
if (style.fill) this.ctx.fill();
}
// Step 1.2.4.3: Stroke the path
if (style.stroke) this.ctx.stroke();
// Step 1.2.4.4: Restore canvas state
this.ctx.restore();
return true;
}
Why This Order: We close the path first (if needed), then fill (only works on closed paths), then stroke (works on both open and closed paths).
The Complete Method:
drawBezierPath(shape, style, coordinateSystem) {
const params = shape.params || {};
const points = params.points || [];
const controlPoints = params.controlPoints || [];
if (points.length < 2) return false;
this.ctx.save();
this.applyStyle(style);
this.ctx.beginPath();
const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
this.ctx.moveTo(firstPoint.x, firstPoint.y);
for (let i = 1; i < points.length; i++) {
if (controlPoints[i - 1]) {
// Bezier curve
const cp1 = coordinateSystem.worldToScreen(
controlPoints[i - 1][0],
controlPoints[i - 1][1]
);
const cp2 = controlPoints[i - 1][2] ?
coordinateSystem.worldToScreen(controlPoints[i - 1][2], controlPoints[i - 1][3]) :
cp1;
const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
} else {
// Straight line
const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
this.ctx.lineTo(end.x, end.y);
}
}
if (params.closed) {
this.ctx.closePath();
if (style.fill) this.ctx.fill();
}
if (style.stroke) this.ctx.stroke();
this.ctx.restore();
return true;
}
Building This Step by Step:
- Add
drawBezierPath()method to PathRenderer class - Extract params, points, and controlPoints from shape
- Validate minimum 2 points
- Save canvas state
- Apply styles
- Begin new path
- Convert first point to screen coordinates
- Move to first point
- Loop through remaining points
- Check if segment has control points
- If yes, convert control points to screen coordinates
- Check for second control point (cubic vs quadratic)
- Convert end anchor point to screen coordinates
- Call
bezierCurveTo()with control points and end point - If no control points, convert end point and call
lineTo() - After loop, check if path should be closed
- If closed, call
closePath()and fill if enabled - Stroke the path if enabled
- Restore canvas state
- Return true on success
- This method provides Bezier curve support for smooth paths
Part 2: Building the Handle System
Step 2.1: Create the Handle System Class
File: src/renderer/handleSystem.mjs
What You're Building:
A HandleSystem class that manages interactive handles (control points) for shapes. Handles allow users to resize shapes (corner handles) and rotate shapes (rotation handle). This class generates handle positions, draws handles, and detects handle hits for interaction.
Why This Class: Handles provide visual feedback and interaction points for shape manipulation. Users need to see where they can interact (handles) and be able to click/drag them. This class centralizes all handle-related functionality.
How to Build It Step by Step:
Step 2.1.1: Create the HandleSystem Class and Constructor Start with the class definition:
export class HandleSystem {
constructor(renderer) {
// Step 2.1.1.1: Store renderer reference
// We need renderer for context, coordinate system, and shape bounds
this.renderer = renderer;
// Step 2.1.1.2: Set handle radius
// This is the visual size of handles in pixels
// 5 pixels is a good size - visible but not too large
this.handleRadius = 5;
// Step 2.1.1.3: Initialize handles array
// This stores all handles for the current selection
this.handles = [];
// Step 2.1.1.4: Track active handle
// This is the handle currently being dragged (if any)
this.activeHandle = null;
}
Why These Properties:
renderer: Provides access to canvas context, coordinate system, and shape informationhandleRadius: Controls handle size. Larger = easier to click, but more obtrusivehandles: Stores handle objects for the selected shapeactiveHandle: Tracks which handle is being dragged for interaction feedback
Step 2.1.2: Implement getHandlesForShape() Method
Generate handles for a shape:
getHandlesForShape(shape) {
// Step 2.1.2.1: Initialize handles array
const handles = [];
// Step 2.1.2.2: Get shape bounds
// Bounds define the rectangle that contains the shape
// We'll place handles at corners and edges
const bounds = this.getShapeBounds(shape);
// Step 2.1.2.3: Create corner handles
// Corner handles allow resizing the shape
// We create 4 handles, one at each corner
handles.push({
type: 'corner',
position: 'topLeft',
x: bounds.x,
y: bounds.y
});
handles.push({
type: 'corner',
position: 'topRight',
x: bounds.x + bounds.width,
y: bounds.y
});
handles.push({
type: 'corner',
position: 'bottomRight',
x: bounds.x + bounds.width,
y: bounds.y + bounds.height
});
handles.push({
type: 'corner',
position: 'bottomLeft',
x: bounds.x,
y: bounds.y + bounds.height
});
Why Corner Handles: Corner handles allow resizing shapes. By dragging corners, users can change width and height simultaneously. The position property ('topLeft', etc.) helps identify which corner is being dragged.
Step 2.1.3: Add Rotation Handle Add a rotation handle above the shape:
// Step 2.1.3.1: Rotation handle (above shape)
// Rotation handle is positioned above the shape's center
// Users drag this to rotate the shape
handles.push({
type: 'rotation',
x: bounds.x + bounds.width / 2, // Center horizontally
y: bounds.y - 30 // 30 units above top edge
});
// Step 2.1.3.2: Return all handles
return handles;
}
Why Rotation Handle: Rotation requires a separate handle because it's a different operation than resizing. Placing it above the shape makes it visually distinct and easy to access.
Step 2.1.4: Implement drawHandles() Method
Draw handles on the canvas:
drawHandles(handles) {
// Step 2.1.4.1: Get canvas context from renderer
this.ctx = this.renderer.ctx;
// Step 2.1.4.2: Save canvas state
this.ctx.save();
// Step 2.1.4.3: Draw each handle
handles.forEach(handle => {
// Step 2.1.4.4: Convert handle position to screen coordinates
// Handles are in world coordinates, canvas needs screen coordinates
const screen = this.renderer.coordinateSystem.worldToScreen(handle.x, handle.y);
// Step 2.1.4.5: Draw handle circle
// Handles are drawn as filled circles
// Active handle could have different color (currently same)
this.ctx.fillStyle = handle === this.activeHandle ? '#0066FF' : '#0066FF';
this.ctx.beginPath();
this.ctx.arc(screen.x, screen.y, this.handleRadius, 0, Math.PI * 2);
this.ctx.fill();
// Step 2.1.4.6: Draw outline
// White outline makes handles visible on any background
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.stroke();
});
// Step 2.1.4.7: Restore canvas state
this.ctx.restore();
}
Why This Drawing Style: Handles need to be visible on any background. A filled circle with a white outline ensures visibility. The blue fill indicates interactivity.
Step 2.1.5: Implement getHandleAtPoint() Method
Detect which handle (if any) is at a screen position:
getHandleAtPoint(screenX, screenY) {
// Step 2.1.5.1: Convert screen coordinates to world coordinates
// Handles are stored in world coordinates, so we need to convert
const worldPos = this.renderer.coordinateSystem.screenToWorld(screenX, screenY);
// Step 2.1.5.2: Check each handle
for (const handle of this.handles) {
// Step 2.1.5.3: Calculate distance from click to handle center
// Use Euclidean distance formula
const distance = Math.sqrt(
Math.pow(worldPos.x - handle.x, 2) +
Math.pow(worldPos.y - handle.y, 2)
);
// Step 2.1.5.4: Check if click is within handle radius
// We use radius * 2 for hit testing (larger than visual radius)
// This makes handles easier to click
if (distance < this.handleRadius * 2) {
return handle;
}
}
// Step 2.1.5.5: No handle found
return null;
}
}
Why Distance Check: We calculate the distance from the click point to each handle center. If the distance is less than the handle radius (multiplied by 2 for easier clicking), the click hit that handle.
The Complete Class:
export class HandleSystem {
constructor(renderer) {
this.renderer = renderer;
this.handleRadius = 5;
this.handles = [];
this.activeHandle = null;
}
getHandlesForShape(shape) {
const handles = [];
const bounds = this.getShapeBounds(shape);
// Corner handles
handles.push({
type: 'corner',
position: 'topLeft',
x: bounds.x,
y: bounds.y
});
handles.push({
type: 'corner',
position: 'topRight',
x: bounds.x + bounds.width,
y: bounds.y
});
handles.push({
type: 'corner',
position: 'bottomRight',
x: bounds.x + bounds.width,
y: bounds.y + bounds.height
});
handles.push({
type: 'corner',
position: 'bottomLeft',
x: bounds.x,
y: bounds.y + bounds.height
});
// Rotation handle (above shape)
handles.push({
type: 'rotation',
x: bounds.x + bounds.width / 2,
y: bounds.y - 30
});
return handles;
}
drawHandles(handles) {
this.ctx = this.renderer.ctx;
this.ctx.save();
handles.forEach(handle => {
const screen = this.renderer.coordinateSystem.worldToScreen(handle.x, handle.y);
// Draw handle circle
this.ctx.fillStyle = handle === this.activeHandle ? '#0066FF' : '#0066FF';
this.ctx.beginPath();
this.ctx.arc(screen.x, screen.y, this.handleRadius, 0, Math.PI * 2);
this.ctx.fill();
// Draw outline
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.stroke();
});
this.ctx.restore();
}
getHandleAtPoint(screenX, screenY) {
const worldPos = this.renderer.coordinateSystem.screenToWorld(screenX, screenY);
for (const handle of this.handles) {
const distance = Math.sqrt(
Math.pow(worldPos.x - handle.x, 2) +
Math.pow(worldPos.y - handle.y, 2)
);
if (distance < this.handleRadius * 2) {
return handle;
}
}
return null;
}
}
Building This Step by Step:
- Create new file
src/renderer/handleSystem.mjs - Export
HandleSystemclass - Create constructor that takes renderer parameter
- Store renderer as instance property
- Set handleRadius to 5 pixels
- Initialize handles array
- Initialize activeHandle to null
- Create
getHandlesForShape()method - Initialize handles array
- Get shape bounds
- Create top-left corner handle
- Create top-right corner handle
- Create bottom-right corner handle
- Create bottom-left corner handle
- Create rotation handle above shape center
- Return handles array
- Create
drawHandles()method - Get context from renderer
- Save canvas state
- Loop through handles
- Convert each handle to screen coordinates
- Set fill style
- Draw handle circle using arc()
- Set stroke style and line width
- Stroke handle outline
- Restore canvas state
- Create
getHandleAtPoint()method - Convert screen coordinates to world coordinates
- Loop through handles
- Calculate distance from point to handle
- Check if distance is within handle radius * 2
- Return handle if hit, null if no hit
- This class provides interactive handles for shape manipulation
Part 3: Building the Selection System
Step 3.1: Create the Selection System Class
File: src/renderer/selectionSystem.mjs
What You're Building:
A SelectionSystem class that manages shape selection and hover states. This class tracks which shape is selected (clicked) and which shape is hovered (mouse over), and provides methods to draw selection outlines and manage selection state.
Why This Class: Users need visual feedback to know which shape they're interacting with. Selection (click) and hover (mouse over) are fundamental interactions. This class centralizes selection management and provides visual feedback through outlines.
How to Build It Step by Step:
Step 3.1.1: Create the SelectionSystem Class and Constructor Start with the class definition:
export class SelectionSystem {
constructor(renderer) {
// Step 3.1.1.1: Store renderer reference
// We need renderer for context, coordinate system, and redraw
this.renderer = renderer;
// Step 3.1.1.2: Track selected shape
// This is the shape currently selected (clicked)
this.selectedShape = null;
// Step 3.1.1.3: Track hovered shape
// This is the shape currently under the mouse cursor
this.hoveredShape = null;
}
Why These Properties:
renderer: Provides access to canvas context, coordinate system, and redraw functionalityselectedShape: The shape the user has clicked/selected. Only one shape can be selected at a time.hoveredShape: The shape currently under the mouse cursor. Provides hover feedback.
Step 3.1.2: Implement setSelected() Method
Set the selected shape:
setSelected(shape) {
// Step 3.1.2.1: Update selected shape
// This replaces any previously selected shape
this.selectedShape = shape;
// Step 3.1.2.2: Trigger redraw
// Selection change needs visual update (draw selection outline)
this.renderer.redraw();
}
Why Redraw: When selection changes, we need to redraw the canvas to show the new selection outline. The outline is drawn during the redraw process.
Step 3.1.3: Implement setHovered() Method
Set the hovered shape:
setHovered(shape) {
// Step 3.1.3.1: Update hovered shape
// This changes as the mouse moves
this.hoveredShape = shape;
// Step 3.1.3.2: Trigger redraw
// Hover change needs visual update (draw hover outline)
this.renderer.redraw();
}
Why Separate Hover: Hover provides immediate feedback as the mouse moves, before clicking. It's separate from selection so users can see what they're about to select.
Step 3.1.4: Implement clearSelection() Method
Clear both selection and hover:
clearSelection() {
// Step 3.1.4.1: Clear selected shape
this.selectedShape = null;
// Step 3.1.4.2: Clear hovered shape
this.hoveredShape = null;
}
Why This Method: Provides a clean way to deselect everything. Useful when clicking on empty space or pressing Escape.
Step 3.1.5: Implement drawSelectionOutline() Method
Draw the selection outline around a shape:
drawSelectionOutline(shape) {
// Step 3.1.5.1: Validate shape exists
if (!shape) return;
// Step 3.1.5.2: Get shape bounds
// Bounds define the rectangle that contains the shape
const bounds = this.getShapeBounds(shape);
const ctx = this.renderer.ctx;
// Step 3.1.5.3: Save canvas state
ctx.save();
// Step 3.1.5.4: Set selection style
// Blue dashed outline indicates selection
ctx.strokeStyle = '#0066FF';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]); // Dashed line pattern
// Step 3.1.5.5: Convert bounds to screen coordinates
// Bounds are in world coordinates, canvas needs screen coordinates
const topLeft = this.renderer.coordinateSystem.worldToScreen(bounds.x, bounds.y);
const bottomRight = this.renderer.coordinateSystem.worldToScreen(
bounds.x + bounds.width,
bounds.y + bounds.height
);
// Step 3.1.5.6: Draw selection rectangle
// Draw a rectangle around the shape's bounds
ctx.strokeRect(
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x, // Width
bottomRight.y - topLeft.y // Height
);
// Step 3.1.5.7: Reset line dash
// Important: reset dash pattern so it doesn't affect other drawings
ctx.setLineDash([]);
// Step 3.1.5.8: Restore canvas state
ctx.restore();
}
Why Dashed Outline: A dashed outline is a standard visual convention for selection. It's clearly visible but doesn't obscure the shape. The blue color (#0066FF) is a common selection color.
Why Reset Line Dash:
The line dash pattern persists after strokeRect(). We reset it to [] so it doesn't affect subsequent drawings.
The Complete Class:
export class SelectionSystem {
constructor(renderer) {
this.renderer = renderer;
this.selectedShape = null;
this.hoveredShape = null;
}
setSelected(shape) {
this.selectedShape = shape;
this.renderer.redraw();
}
setHovered(shape) {
this.hoveredShape = shape;
this.renderer.redraw();
}
clearSelection() {
this.selectedShape = null;
this.hoveredShape = null;
}
drawSelectionOutline(shape) {
if (!shape) return;
const bounds = this.getShapeBounds(shape);
const ctx = this.renderer.ctx;
ctx.save();
// Draw selection rectangle
ctx.strokeStyle = '#0066FF';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
const topLeft = this.renderer.coordinateSystem.worldToScreen(bounds.x, bounds.y);
const bottomRight = this.renderer.coordinateSystem.worldToScreen(
bounds.x + bounds.width,
bounds.y + bounds.height
);
ctx.strokeRect(
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x,
bottomRight.y - topLeft.y
);
ctx.setLineDash([]);
ctx.restore();
}
drawHoverOutline(shape) {
if (!shape) return;
// Similar to selection outline but different color/style
const bounds = this.getShapeBounds(shape);
const ctx = this.renderer.ctx;
ctx.save();
ctx.strokeStyle = '#00AAFF';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
// Draw outline...
ctx.setLineDash([]);
ctx.restore();
}
getShapeBounds(shape) {
// Calculate bounding box for shape
// Account for transforms
const transform = shape.transform || {
position: [0, 0],
rotation: 0,
scale: [1, 1]
};
// Get shape points
const shapeClass = this.renderer.getShapeClass(shape);
const points = shapeClass.getPoints();
// Transform points and find bounds
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
points.forEach(point => {
const transformed = this.renderer.transformManager.applyTransform(point, transform);
minX = Math.min(minX, transformed.x);
maxX = Math.max(maxX, transformed.x);
minY = Math.min(minY, transformed.y);
maxY = Math.max(maxY, transformed.y);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}
}
Common Issues and Fixes
Issue: Paths don't render
- Check points array has at least 2 points
- Verify coordinate conversion (world → screen)
- Check path is closed correctly
Issue: Handles don't appear
- Check handles are generated for shape
- Verify coordinate conversion
- Check handle radius is visible
Issue: Selection outline wrong size
- Check bounds calculation includes all points
- Verify transforms are applied
- Check coordinate conversion
Issue: Hit testing doesn't work
- Check coordinate conversion (screen → world)
- Verify distance calculation
- Check handle radius is correct