I recently discovered the small web and was really in love this this internet sub-culture. I realized that with this page, I am already partly part of this movement, without fully realizing it. One thing I loved about these pages are the guest books. The kind little messages that are left there by strangers are just pure cuteness and make my heart feel warm.
I would also like to have such an interactive element on my page, where strangers can leave a little message for me or say hello. However, at this time, I have other plans than the usual written guest book: A visual one!
Many years ago, I stumbled upon a website where there was an endless canvas of peoples drawings side by side. It was basically like a digital map of things people drew, and you could scroll around, zoom in and explore peoples thoughts and art. The nice thing about this canvas was, that when you draw your own tile, you get to see the edges of your neighboring tiles. That way allowed you to interact with their drawings, continue their lines, motives or ideas. Some places, some motives spanned multiple tiles and it was almost like exploring reddit’s r/place.
Since a long time, I wondered if I am able to recreate this with p5. I did a couple things with p5, but never tried something that felt so big. I could not imagine even how to create a layer that could be moved around by the mouse to pan through all the drawings.
Well, now with my discovery of the small web, I had the idea to finally tackle this project and turn it into a visual guest book. This way my visitors can come by, leave a little drawing and add to the endless canvas. In my mind, this is a cute idea.
Let’s define what I would want to have at minimum:
Build a visual mosaic that should fulfill the following criteria:
- Have basic drawing editor with a circular brush and different stroke sizes, and perhaps different colors
- The editor should have an undo feature and perhapt an eraser feature
- Be able to paint one painting in different locations and at different scales
- Have an endless cavas of tiles that can be inspected with panning and zooming around
- When drawing, you see the edges of neighboring tiles, so you can continue their motives
- Have the clients send the tile drawing data to the server
- Have an infrastructure that is scalable and uses minimal storage per painting
Version 1: Basic drawing
In the first version, I implemented the most basic form of drawing on the sketch, that I could come up with. The main logic is the following:
// Whenever the mouse is continously pressed and moved
function mouseDragged() {
// If this is not the first click, then ..
if (mouseLast.y != -1 && mouseLast.x != -1) {
push();
strokeWeight(10);
// Draw a line between the current mouse position
// and the last
line(mouseX, mouseY, mouseLast.x, mouseLast.y);
pop();
}
// Then save the current location as the new last
mouseLast = {
x: mouseX,
y: mouseY
}
}
It basically draws just straight lines between the current and last cursor’s locations.
Then, I also tested to save an image with the saveCanvas() function. This can be done, when you press the s key. However, this is not really useful, as it offers to save the canvas locally, on the client. And I would need it on the server.
Finally, I also tested loading an image and showing it on the canvas. The scribbles you can see are coming from an .png file that I am displaying on load.
I quickly realizes that loading png’s will probably not be the best choice. On the one side it takes more storage than other options, on the other, it would be very difficult to change something later. Let’s imagine someone draws something on the canvas while the background is dark (because of a theme), and later I want to load it in a bright theme. The dark colors of the dark theme would be baked into the png file, and not be easy to change.
A better approach would be to store the data of each stroke, and then replicate (essentially re-draw) the drawing based on this data. This will most likely be smaller in storage size and also allow to change colors (or anything else really) later on.
Let’s try this out in the next version!
Version 2: Storing and redrawing
To store the drawing data, I need to think of a format to save it in. My first thought is to have an strokes array, that keeps stroke objects. Each of those will store data like the stroke weight, the color and of course the coordinates.
If I want to create one object per stroke, I need to fully understand when a stroke starts, is ongoing and ends. After a quick test, I found out that when clicking, dragging and then releasing the mouse, the following events are fired in that order: mousePressed, mouseDragged (for each frame) and mouseReleased. Therefore, I need to create a stroke object on mouse press, fill it while dragging and then saving it when releasing.
The above sketch shows this working with multiple strokes. Whenever a mouse is released, a stroke object is saved in a stroke array. Then I can clear the canvas and afterwards loop through the stroke array and redraw all the strokes again. With a simple strokes.pop(), I can even undo the last stroke!
The data model currently looks like this:
strokes = [
{
strokeWeight: 10,
color: "#abcdef",
dots: [
[x1, y1],
[x2, y2],
...
]
},
...
]
As the next step, lets look how to draw inside a canvas that is fixed to a specific tile length.
Version 3: A simple drawing canvas
Each tile should have a fixed size (e.g. 500x500px). So lets make a drawing mode, where you can see the tile and draw into it.
This is an interesting problem actually, as you need to constrain the drawing to inside a rectangle. I tried to cut off strokes, when the mouse moves outside of the rectangle. So with every mouseDragged() I check if the mouse is still inside the rectangle, if not I end the stroke. However, this is also not perfect, as it cuts off the strokes early when you move the mouse fast. Test it out here:
When thinking about how to solve this problem, I already started to think about calculating the intersection between the line and the rectangle borders. But then I remembered that p5.js comes with clip()! This basically works like a mask in GIMP or Photoshop: When you draw something, it just draws it inside the boundaries of a shape that you define.
// Start the clip
clip(() => {
rect(
drawingCanvas.x,
drawingCanvas.y,
drawingCanvas.width,
drawingCanvas.height
);
});
So with this simple clip command, I ended up at this beautiful drawing canvas:
That is very nice!
Version 4: Redrawing and scaling
As we now have our little drawing canvas, lets try to redraw something at a different location and smaller. For this, I make the drawing canvas smaller and created a seperate displaying canvas on the right.
It was a bit tricky to figure out how to properly copy and scale the strokes over. This involes moving and scaling the coordinate system with translate() and scale(), as well as also masking the smaller displaying window with clip() again.
In the end, it was not soo much. Find the code here, if you are curious:
The code used
function copyStrokes(displayCanvas) {
// Pop to end drawing clip
pop();
// Begin push for copying
push();
// Clip small display window
clip(() => {
rect(
displayCanvas.x,
displayCanvas.y,
displayCanvas.width,
displayCanvas.height
);
});
// translate to display canvas
translate(displayCanvas.x, displayCanvas.y);
// Scale to make it smaller
// console.log((displayCanvas.width) / TILE_SIDE_LENGHT);
scale(displayCanvas.width / TILE_SIDE_LENGHT);
// Draw strokes
drawStrokes();
pop();
// Re-initiate the drawing canvas
initiateDrawingCanvas();
}
function drawStrokes() {
// console.log(strokes);
strokes.forEach(stroke => {
drawStroke(stroke);
});
}
function drawStroke(strokeCurrent) {
push();
strokeWeight(strokeCurrent.strokeWeight);
stroke(strokeCurrent.color);
for (let i = 1; i < strokeCurrent.dots.length; i++) {
line(
strokeCurrent.dots[i - 1][0],
strokeCurrent.dots[i - 1][1],
strokeCurrent.dots[i][0],
strokeCurrent.dots[i][1]
);
}
pop();
}
You can test it out here:
I am quite happy with this. It looks quite nice, renders fast and works like I imagined. The code is still a mess, but it is a proof of concept. As the next step, I would like to create a simple tile-layer that can be moved around and zoomed in and out.
Version 5: Improve Drawing
Before we go to the tiling, lets improve the drawing situation. As the first, I want to add some colors to the drawing possibilities. I started with developing my own color radio buttons, but was immediately confronted with a bigger topic: If I want to update the buttons (e.g. mark them as selected), I need to redraw those buttons. And if I need to redraw UI, I probably need to be able to redraw the whole canvas.
That led to restructuring a lot of code and now, each tile (the thing you draw on or to which drawings are copied to) are seperate objects that I can interact with in the code. Long story short: I did a lot of clean up, made the code nicer and now there are not only colors, but also a brush size. I used the beautiful colors of catppuccin for the palette and I am really happy with the painings you can get out of this already!

It is also super cool, how simple the data is in the background. It is just an array of little stroke objects, with coordinates, a color value and a stroke weight. Nothing more.
It is still rough around the edges and some things are not proper yet, but I am very happy with the core drawing experience. Try it out yourself:
Version 6: Tiling
As the next chapter, I want to get into the mosaic or the tile layer. This should feel a bit like a digital map, where you can scroll around and zoom.
I started by creating a new and empty sketch and drawing a grid of tiles:
As the next test, I began animating this grid with the following snippet:
function draw() {
background(theme.primary || 220);
// Move the coordinate system based on the frames
translate(frameCount % 200, frameCount % 200);
// draw the grid
drawGridLines();
}
Then, lets do this with mouse movement. The first attempt did not work out very well. But you can see it is going in the right direction! Please try to move it around with your mouse!
The next one is more promising, but it jumps weirdly when panning from another location.
I finally got it working. It was actually quite easy in the end. No rocket science! And I also was quickly able to implement the possibility of scrolling. However, as you can see, it always scrolls from the top left corner. This is not random, this is where the origin of the coordinate system is located. This needs to be improved in the next verion!
The code used
let layer = {
originX: 0,
originY: 0,
dragStartX: 0,
dragStartY: 0,
scale: 1
}
function draw() {
// Begin with background
background(theme.primary || 220);
// translate to latest new origin of the layer
translate(layer.originX, layer.originY);
// Also scale
scale(layer.scale);
// Draw the grid
drawGridLines();
}
// This event is fired when the mouse is pressed
function mousePressed() {
// Save the location where the drag started, relative to the current origin
layer.dragStartX = mouseX - layer.originX;
layer.dragStartY = mouseY - layer.originY;
}
// This event is fired when the mouse is moving while pressed
function mouseDragged() {
// Update origin based on how much was moved to drag starting point
layer.originX = mouseX - layer.dragStartX;
layer.originY = mouseY - layer.dragStartY;
// console.log("Mouse dragged with newX: " + newX + " and newY: " + newY);
}
function mouseWheel(event) {
if (event.delta > 0) {
layer.scale *= 0.9;
} else {
layer.scale *= 1.1;
}
// Uncomment to prevent any default behavior.
return false;
}
With a couple of tweaks, I moved the origin to the center and the scrolling feels much better now.
However, I am realizing, this is still not a solution. The moment you move the grid to the side and then scroll, it still scrolls to the center of the grid! And that does not feel intuitive. When you apply scale() to the canvas, everything will be scaled (and thus moves) except the origin of the coordinate system.
At this point, I am realizing that the most intuitive behaviour is that it scrolls to the mouse. But that is a bit tricky: To scroll to the mouse, I need to have the origin of the coordinate system wherever the mouse is. This means, that point will jump around a lot, while the grid needs to stay in the same location (relatively speeking). That means we need to have new variables, that track an offset of the tile grid relative to the current (mouse) origin. That will be a bit more complex!
At this point I added a white circle in all sketches to show the origin of the coordinate system ( or where the coordinates are x = 0 and y = 0). In the following sketch, I also added some more information about the screen coordinates of the mouse (next to the white circle marking the origin) as well as the offset to the tile grid and the current scale.
I got it working very fast, as long as you do not zoom. The moment you zoom, it broke apart fast, as I was calculating the offsets wrongly. It took me around two hours (and finally some AI help) to figure out what my problems where. The short version was: I mixed screen coordinates (e.g. mouseX and mouseY) and the world coordinates in my calculations. After I brought the mouse coordinates into the world coordinate system, everything works perfectly: