Perfboard Designer in Html Canvas And The Underlying Solutions In My Project
Krisztián Maurer
Posted on July 11, 2023
If you're an electronics hobbyist like me, you might have faced a common problem. After designing a circuit and testing it on a breadboard, you want a more reliable way to store it. That's where perfboards come in. They provide a budget-friendly option for creating robust circuits.
Perfboards have holes where you can place your components and then solder the connections between them. Simple circuits are easy to handle, but things get tricky with complex circuits and limited space.
Ensuring the right connections becomes crucial, and doing it on the go can lead to a messy outcome or running out of space. While there are programs available for designing PCB / perfboards, they aren't beginner-friendly. I tried a few, but they didn't cater to hobbyists who simply wanted to design prefboards.
Then I came across Tinkercad, a more beginner-friendly designer. However, being a programmer with basic HTML canvas knowledge, I thought of creating my own solution. How hard could it be, right?
For my initial prototype, I started with plain HTML and JavaScript. As the project grew more complex, I switched to using Vite and TypeScript. If the project continues to expand, I may consider migrating to a more advanced framework like React or Vue.js. But for now, my focus is on creating a usable canvas.
Hey ChatGPT, can you help me?
I reached out to ChatGPT to help me with the starting point. With a few iterations, it provided a decent starting code that saved me about 4 hours. However, as things got more complicated, the prompt became challenging, and I decided to stick with the code I had. I refactored and continued the development myself.
I introduced fixes and new features, such as hover effects and expanding the dot grid based on user input. But as the plain HTML and JavaScript code grew messy and hard to read, I decided to rewrite it in TypeScript and separate the functionalities. Using Vite, I built my TypeScript code and refactored it, resulting in simpler and more flexible code.
Current Features:
In just one weekend, I aimed to create a usable minimum viable product (MVP) and implemented the following features:
- Resize the dot grid
- Dots
- Add color
- Add descriptions / delete
- Move dot
- Select dot
- Hover dot - add effect, shows the whole description
- Lines
- Add color
- Connect two dots
- Delete lines
- Select lines
- Hover effect
- Save the project as an image
- Save the project to a file for sharing or future use
- Save and load projects from local storage
- ICs (show pin number and description) *incomplete
- Reset the project
- Undo / redo changes
- Keyboard shortcuts
Check out the GitHub codebase or the live demo
Solving a Problem:
Choosing the right project to start can be challenging. The best learning project is one that solves your own problems. It's more enjoyable and satisfying to work on something that interests you and addresses your needs.
I knew a little about canvas before, but now I understand it better. I learned that if I want to make a complicated canvas project, I need to create a virtual version of everything, keep things separate, and make use of the coordinate system and event handlers. And I realized that it's really just building upon the previous building blocks that I created.
Check out the GitHub codebase or the live demo
Create contexts
To begin, I wanted the canvas HTML element and its 2D context to be globally accessible. So, I created two static variables. However, you can accomplish this without using a class; it depends on your personal preference.
export class Canvas {
static c = Utils.getSafeHtmlElement<HTMLCanvasElement>("myCanvas");
static ctx = Canvas.c.getContext("2d") as CanvasRenderingContext2D;
}
I also created some globally accessible variables, such as dot spacing, selected dot, and lines etc. The State
class represents the current project's state.
(the Utils.getSafeHtmlElement
basicly just a getElementById)
Dot grid
We need to generate a dot grid that represents the holes on the board.
export function createDotGrid(horizontalDotNumbers: number, verticalDotNumbers: number) {
Canvas.c.width = horizontalDotNumbers * State.dotSpace;
Canvas.c.height = verticalDotNumbers * State.dotSpace;
State.dots = [];
for(let x = State.dotSpace / 2; x < Canvas.c.width; x += State.dotSpace){
for(let y = State.dotSpace / 2; y < Canvas.c.height; y += State.dotSpace){
State.dots.push({x: x, y: y, description: null, color: "#a4a0a0"});
}
}
}
We adjust the canvas size to accommodate all the dots by multiplying the spacing with the number of dots.
If you want to draw the dots with a constant canvas size and dynamically change the spacing based on the number of dots, you can do something like this:
const xSpacing = Canvas.c.width / horizontalDotNumbers;
const ySpacing = Canvas.c.height / verticalDotNumbers;
...
However, for my project, I believe it's better to stick with a constant spacing.
Next, we generate the dot grid using a double for loop.
To understand this calculation, we need to know how the canvas coordinate system works:
The top left is (0,0), and the bottom right is (canvas.width, canvas.height).
I'm not drawing the dots at this point because I want them to be changeable later. So, I store them in an array as a virtual representation (State.dots
).
The interface for the dots looks like this:
export interface IDot {
color?: string;
description?: string | null;
x: number,
y: number,
}
The required properties are the x and y coordinates on the canvas. Later on, I will add the ability to include a description and change the color.
Okay, we have nothing on the canvas. Let's draw these dots on the canvas.
function drawDot(dot){
Canvas.ctx.beginPath();
Canvas.ctx.arc(dot.x, dot.y, State.dotRadius, 0, Math.PI*2);
Canvas.ctx.fillStyle = dot.color;
Canvas.ctx.fill();
}
In my case, a dot represents a circuit *(the board holes). To draw a circuit, we add an arc with a start angle of 0
and an end angle of Math.PI*2
. The coordinates are calculated in the previous section, and we can color them using the fillStyle, which fills them with the specified color.
Next, we need to call this function for each stored dot. To handle the drawing when something changes, we can create a function called redrawCanvas(). If your project requires constant updates, such as for animation, this function can serve as the equivalent (check out the requestAnimationFrame
if you need a constant fps).
export function redrawCanvas() {
resetCanvas();
// Draw dots
for(let i = 0; i < State.dots.length; i++) {
drawDot(State.dots[i]);
}
}
In this code, we first call the resetCanvas()
function to clear the canvas. Then, we iterate over each dot in the State.dots
array and call the drawDot()
function to draw each dot on the canvas.
The resetCanvas()
function clears the canvas by setting the fillStyle to the canvas background color, using clearRect() to clear the canvas's width and height, and then filling the canvas with the specified background color using fill().
export function resetCanvas(){
Canvas.ctx.fillStyle = State.canvasBackgroundColor
Canvas.ctx.clearRect(0, 0, Canvas.c.width, Canvas.c.height);
Canvas.ctx.fillRect(0, 0, Canvas.c.width, Canvas.c.height);
Canvas.ctx.fill()
}
Looks fine, but it lacks interactivity. The real magic of HTML canvas comes alive when you can interact with it. Let's try to add some interaction to make it more engaging.
Dot hover effect
It would be helpful to show which dot is being hovered over, as we will later select and connect it to another dot.
To determine the hovered dot, we need to consider three things: the mouse coordinates, the dot coordinates, and the canvas element coordinates.
By adding a mouse move event listener to the canvas, we can obtain the precise mouse coordinates when it is positioned over the canvas.
Canvas.c.addEventListener('mousemove', function(e) {
const rect = Canvas.c.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if mouse is within a dot
State.hoverDot = undefined;
for(let i = 0; i < State.dots.length; i++) {
const dot = State.dots[i];
const dx = x - dot.x;
const dy = y - dot.y;
if(dx * dx + dy * dy < State.dotSelectionRadius * State.dotSelectionRadius){
State.hoverDot = dot;
break;
}
}
redrawCanvas();
// ... Continue with other interactions
});
In this code, we add a 'mousemove' event listener to the Canvas.c element (the canvas). Whenever the mouse moves within the canvas, the provided function will be executed.
We start by getting the position and size of the canvas relative to the viewport using getBoundingClientRect(). This information is stored in the rect variable.
Next, we calculate the X and Y coordinates of the mouse relative to the canvas by subtracting the left and top coordinates of the canvas (rect.left and rect.top) from the clientX and clientY values provided by the event.
Then, we check if the mouse is within the proximity of any dot. We iterate over the State.dots array and calculate the distance between the mouse coordinates (x, y) and each dot's coordinates (dot.x, dot.y). If the squared distance is less than the squared dotSelectionRadius, we consider the dot as hovered and store it in State.hoverDot.
if(dx * dx + dy * dy < State.dotSelectionRadius * State.dotSelectionRadius) { ... }
: This line checks if the squared distance between the mouse cursor and the current dot is less than the squared radius of the dot's selection area. The distances are cant be negative, this is the simplest solution to achieve the distance calculation
- Squaring the distances (
dx * dx
anddy * dy
) and the radius (State.dotSelectionRadius * State.dotSelectionRadius
) allows for a distance comparison without the need to calculate square roots, which can improve performance.
After updating the hover state, we call redrawCanvas() to redraw the canvas and reflect the hover effect.
Now, whenever the mouse moves on the canvas, we will check if there is a dot being hovered over and store it in the State.hoverDot variable.
lets add a it to the drawDot(dot: IDot) function.
...
if(dot == State.selectedDot) {
Canvas.ctx.strokeStyle = "#00f";
Canvas.ctx.lineWidth = 5;
Canvas.ctx.stroke();
}
...
When drawing the dots, if a dot is selected, it will be displayed as a larger dot.
(my mouse capture is not working, but you can see the main thing)
Great! Now that we can hover over a dot and store the hovered dot, we can use that reference to select a dot and assign it a description and color.
Dot select
The click event handling and indicating the selected dot is relatively straightforward, considering the previous effort put into calculating the hover position.
We simply need to listen for a click event and, if there is a hovered dot, set it as the selected dot. We can indicate the selection differently.
function setSelection(){
if(State.hoverDot){
State.selectedDot = dot;
}
}
Canvas.c.addEventListener('mousedown', function(e) {
setSelection(e);
})
When we draw a dot in the drawDot(dot: IDot) function, let's add a visual indication to the selected dot by coloring it differently.
...
if(dot == State.selectedDot){
Canvas.ctx.strokeStyle = "#00f"; // blue
Canvas.ctx.lineWidth = 5;
Canvas.ctx.stroke();
}
...
Adding Description to Dot
To enable the functionality of adding descriptions to dots, we can implement a dialog box. The entered description can then be saved to the selected dot and displayed beneath it.
Let's begin by adding an "Add Description" button to the HTML. Afterwards, we can obtain the corresponding button element and listen for a click event on it.
Utils.getSafeHtmlElement<HTMLButtonElement>('addDescriptionBtn').addEventListener('click', function() {
addDescriptionToDot();
});
function addDescriptionToDot(){
if(State.selectedDot){
const description = prompt("Enter a description for the dot");
State.selectedDot.description = description;
State.selectedDot = undefined;
redrawCanvas();
} else {
alert("Please select a dot first by clicking on it");
}
}
If there is no selected dot, we cannot determine which dot's description to set, so we will display an alert message.
If there is a selected dot, we will set its description to the entered string and then unselect the dot.
We can utilize the dot reference to modify State.dots.
The removal function follows a similar pattern:
if(dot.description){
Canvas.ctx.font = "10px Arial";
Canvas.ctx.textAlign = "center";
Canvas.ctx.fillStyle = dot.color;
if (dot == State.hoverDot){
Canvas.ctx.fillText(dot.description, dot.x, dot.y + State.dotRadius + 10);
}else {
Canvas.ctx.fillText(dot.description.substring(0, 5), dot.x, dot.y + State.dotRadius + 10);
}
}
Since we know the dot's position, we can position the description around it.
To avoid messy overlapping descriptions, especially when they are too long, we can display a substring of the description. However, if the dot is being hovered over, we will show the entire description.
Line
It looks good. Now, let's add another important element: the line that represents the path between two holes where solder / cable connection is placed.
export interface ILine {
start: IDot,
end: IDot,
color?: string;
}
A line is made up of two dots: a starting dot and an ending dot. It can also have a color, but we'll keep it optional for now.
Now, let's talk about when and how we should connect two dots:
We connect two dots when one dot is selected and the newly selected dot is different from the currently selected dot. In simpler terms, we connect two dots when we click on them.
The starting point of the line will be the selected dot, and the ending point will be the dot we hover over.
function addNewLineIfNeeded(){
if (!State.hoverDot){
return;
}
if(State.selectedDot && State.selectedDot != State.hoverDot){
const newLine: ILine = {start: State.selectedDot, end: State.hoverDot, color: "#777676"};
State.lines.push(newLine);
// Reset selection
State.selectedDot = undefined;
State.selectedLine = newLine;
redrawCanvas();
} else {
// Select this dot
State.selectedDot = State.hoverDot;
State.selectedLine = undefined;
redrawCanvas();
}
}
we can put this code to the setSelection(event)
function what runs when we click before we selecting a dot.
function setSelection(event) {
addNewLineIfNeeded()
selectDot()
}
then we have an array of lines, now time to draw them on the canvas.
function drawLine(line: ILine){
Canvas.ctx.beginPath();
Canvas.ctx.moveTo(line.start.x, line.start.y);
Canvas.ctx.lineTo(line.end.x, line.end.y);
Canvas.ctx.strokeStyle = line?.color || "#777676";
Canvas.ctx.lineWidth = (line == State.selectedLine) ? 5 : (line == State.hoverLine) ? 4 : 3;
Canvas.ctx.stroke();
}
we have the start and end coordinate, so we move to the start position then draw a line to the end position, and color it.
When the line is hovered over, it appears thicker, and when selected, it becomes even thicker.
Select line
It would be useful to have the ability to delete or change the color of a line, so we need a way to select the line.
The line selection is somewhat similar to dot selection but slightly more complex.
// This function is used to select a line on a canvas based on the user's mouse click event
export function selectLine(event) {
// Check if there is a hoverDot, if so, return early and do not select any lines
if (State.hoverDot) {
return;
}
// Get the bounding rectangle of the canvas
const rect = Canvas.c.getBoundingClientRect();
// Calculate the relative coordinates of the mouse click event within the canvas
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Iterate over each line in the State.lines array
for (let i = 0; i < State.lines.length; i++) {
const line = State.lines[i];
// Calculate the differences between the line's start and end points and the mouse click coordinates
const dx1 = line.start.x - x;
const dy1 = line.start.y - y;
const dx2 = line.end.x - x;
const dy2 = line.end.y - y;
// Calculate the distances from the start and end points of the line to the mouse click coordinates
const d1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); // distance from start dot to point
const d2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); // distance from end dot to point
// Calculate the distance between the start and end points of the line
const d = Math.sqrt(
Math.pow(line.end.x - line.start.x, 2) + Math.pow(line.end.y - line.start.y, 2)
);
// Check if the mouse click is within a certain tolerance distance from the line
if (Math.abs(d - (d1 + d2)) < State.lineSelectTolerance) {
// Line is selected
State.selectedLine = line;
State.selectedDot = undefined;
// Redraw the canvas
redrawCanvas();
// Exit the function after selecting the line
return;
}
}
// Click is in empty space, reset selection
State.selectedDot = undefined;
State.selectedLine = undefined;
// Redraw the canvas
redrawCanvas();
}
We can also include a hover effect for the lines. Remove the selected line e.g. by clicking a button etc.
Check out the GitHub codebase or the live demo
Future Plans:
- Create a more user-friendly tools menu
- Finish the IC feature
- Add a component creator
- Migrate to React or Vue
Contributing:
I'm not particularly skilled with CSS, so if you'd like to contribute (e.g. to the tools menu design), your help would be greatly appreciated.
That's all for now. We covered a lot of things, and I hope it was useful. If you have any questions, feel free to leave a comment. Thank you for reading!
Posted on July 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 14, 2022