Magyar zászlóMagyar

Practice 6 - Pixel Art Completion & Canvas API

2025-03-20 10 min read GitHub

Introduction

This session has two parts:

  1. Pixel Art Editor — finishing up (practice-05): generated grid, click-to-paint with event delegation, color picker wired to state
  2. Canvas API introduction (practice-06): pixel-level drawing with <canvas> — rectangles, text, images, paths

Part 1 – Finishing the Pixel Art Editor

What was already in place

Last session we put the foundations in place:

  • A state object (gridWidth, gridHeight, pixels 2D array, currentColor)
  • A renderGrid() function that builds a <table> from state.pixels and stamps every cell with data-row / data-col attributes
  • The full HTML layout: controls panel, editor grid, color section, saved pixel-arts list

The grid could not yet be generated or painted, and the color picker had no effect. That is what we finish now.


1. Creating an empty grid — createEmptyPixels()

The pixel data is a two-dimensional array: an array of rows, each row being an array of columns.

function createEmptyPixels(cols, rows) {
    return Array.from({ length: rows }, () => Array(cols).fill(null));
}

Array.from(arrayLike, mapFn) takes two arguments:

  • { length: rows } — an array-like that sets the desired length
  • The callback () => Array(cols).fill(null) — called once per row, returning a fresh null-filled array for that row
createEmptyPixels(3, 2);
// [
//   [null, null, null],   // row 0
//   [null, null, null],   // row 1
// ]

Each null means “not yet painted”. renderGrid() already handles null by leaving the cell unstyled.

ℹ️

A fresh array is created for each row by the callback. If you wrote Array(cols).fill(null) once and used the same reference for every row, painting one cell would affect the entire column.


2. Generate button — rebuilding the grid

const generateBtn = document.querySelector("#generate-btn");
generateBtn.addEventListener("click", function() {
    const gridWidth  = Number(document.querySelector("#grid-width").value);
    const gridHeight = Number(document.querySelector("#grid-height").value);

    state.gridWidth  = gridWidth;
    state.gridHeight = gridHeight;
    state.pixels     = createEmptyPixels(gridWidth, gridHeight);

    renderAll();
});

input.value is always a string. Number(...) converts it to an integer — parseInt would also work, but Number is more concise here.


3. Painting cells — attachGridEvents()

Every time renderGrid() runs, it wipes the DOM and inserts a brand-new table. Event listeners attached to old <td> elements are lost. The solution is event delegation: attach a single listener to the <table> (which survives across renders) and let click events bubble up to it.

function attachGridEvents() {
    const table = document.querySelector('table.edit');
    table.addEventListener('click', function(event) {
        if (!event.target.matches("td")) {
            return;
        }
        const rowIndex = Number(event.target.dataset.row);
        const colIndex = Number(event.target.dataset.col);
        state.pixels[rowIndex][colIndex] = state.currentColor;
        renderGrid();
    });
}

This function is called at the end of renderGrid(), immediately after the fresh table is in the DOM.

Why event.target.matches("td")?

A click on the table border or on <tbody> also bubbles up to the table listener. event.target is the element that was actually clicked. The early return filters out anything that is not a <td>.

Reading data-* attributes via dataset

renderGrid() stamped coordinates onto every cell:

<td data-row="2" data-col="4" ...></td>

These are readable through the dataset API:

event.target.dataset.row   // "2"  (always a string)
event.target.dataset.col   // "4"

The data- prefix is stripped, and the rest becomes a camelCase property name (data-row-indexdataset.rowIndex).


4. Color picker

const colorPicker        = document.querySelector("#color-picker");
const colorPickerPreview = document.querySelector("#current-color-display");

colorPicker.addEventListener("input", function() {
    state.currentColor = this.value;
    colorPickerPreview.style.backgroundColor = this.value;
});

The input event fires continuously as the user drags the picker — unlike change, which fires only when the picker is closed. The value of <input type="color"> is always a "#rrggbb" hex string.

💡

attachGridEvents() must be called after every renderGrid() call, not just once on load. Each renderGrid() replaces the entire table, so the previous listener vanishes with the old DOM node.


Complete main.js

let state = {
    gridWidth: 10,
    gridHeight: 10,
    pixels: [],
    currentColor: '#ff0000',
    pixelArts: []
};

function renderAll() {
    renderGrid();
}

function renderGrid() {
    const rows = state.pixels.map((row, rowIndex) => {
        return `<tr>` +
            row.map((color, colIndex) => `
                <td data-row="${rowIndex}" data-col="${colIndex}"
                    style="${color ? 'background-color:' + color : ''}">
                </td>
            `).join("") +
        `</tr>`;
    }).join("");

    document.querySelector("#grid-container").innerHTML = `
        <table class="edit">
            <tbody>${rows}</tbody>
        </table>
    `;
    attachGridEvents();
}

function createEmptyPixels(cols, rows) {
    return Array.from({ length: rows }, () => Array(cols).fill(null));
}

// Generate button
document.querySelector("#generate-btn").addEventListener("click", function() {
    state.gridWidth  = Number(document.querySelector("#grid-width").value);
    state.gridHeight = Number(document.querySelector("#grid-height").value);
    state.pixels     = createEmptyPixels(state.gridWidth, state.gridHeight);
    renderAll();
});

// Click-to-paint
function attachGridEvents() {
    const table = document.querySelector('table.edit');
    table.addEventListener('click', function(event) {
        if (!event.target.matches("td")) return;
        const r = Number(event.target.dataset.row);
        const c = Number(event.target.dataset.col);
        state.pixels[r][c] = state.currentColor;
        renderGrid();
    });
}

// Color picker
const colorPicker        = document.querySelector("#color-picker");
const colorPickerPreview = document.querySelector("#current-color-display");
colorPicker.addEventListener("input", function() {
    state.currentColor = this.value;
    colorPickerPreview.style.backgroundColor = this.value;
});

renderAll();

Part 2 – Canvas API

What is the Canvas?

Everything you have done so far manipulates the DOM — you select elements, set their text, toggle classes. The DOM represents your page as a tree of objects that the browser renders and re-renders automatically.

The <canvas> element is completely different. It is a bitmap: a rectangular grid of pixels that you draw on directly using JavaScript commands. The browser does not know what “objects” are on it — just pixels. You are responsible for everything: what gets drawn, where, and when.

This makes canvas the right tool for things that are not naturally structured content: games, charts, image editors, generative art.


Project structure

practice-06/
├── 1.html    # canvas element + script tag
└── 1.js      # all drawing code

The <canvas> element

<canvas width="500" height="500"></canvas>

The width and height HTML attributes set the size of the drawing surface in pixels. We add a border via CSS purely so the canvas boundary is visible on the page:

canvas {
    border: 1px solid black;
}
⚠️

Do not set canvas dimensions with CSS (width: 500px). CSS stretches the element visually, but the drawing surface stays at its original size. The result is blurry, scaled-up pixels — the same as enlarging a low-resolution image. Always use the HTML width and height attributes.


Getting the rendering context

The <canvas> element itself has almost no drawing API. Drawing commands live on a separate rendering context object that you request from the canvas:

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

ctx is your paintbrush for the entire session. Every single drawing command goes through it.

The argument "2d" selects the two-dimensional rendering context — the one used throughout this course. There is also "webgl" for hardware-accelerated 3D, but that is a completely different topic.


The context is a state machine

This is the most important mental model for working with canvas: ctx holds a persistent set of style properties. When you set ctx.fillStyle = "green", it stays green for every subsequent fill operation until you change it again. Nothing resets automatically.

ctx.fillStyle = "green";
ctx.fillRect(0, 0, 50, 50);    // green

ctx.fillRect(100, 0, 50, 50);  // also green — fillStyle didn't change

ctx.fillStyle = "red";
ctx.fillRect(200, 0, 50, 50);  // now red

Every method call that draws something uses whichever style properties are currently set. Keep this in mind as you read the sections below.


1. Rectangles

Rectangles are the only shape with dedicated shorthand methods. Everything else requires paths (covered later).

// Filled rectangle
ctx.fillStyle = "green";
ctx.fillRect(0, 0, 100, 200);
//           x  y   w    h

fillRect(x, y, width, height) draws a solid rectangle. The colour is whatever fillStyle is currently set to.

// Stroked (outline-only) rectangle
ctx.strokeStyle = "blue";
ctx.lineWidth = 2;
ctx.strokeRect(110, 0, 100, 200);
//             x    y   w    h

strokeRect draws only the border — no fill. lineWidth controls the border thickness in pixels. strokeStyle sets the border colour (it is separate from fillStyle).

The coordinate system

The canvas origin (0, 0) is the top-left corner. X increases to the right, Y increases downward. This is the standard screen coordinate system.

(0,0) ──────────→  x



  y

So fillRect(110, 0, 100, 200) starts 110 px from the left edge, 0 px from the top, and is 100 px wide, 200 px tall.

There is also clearRect

ctx.clearRect(x, y, width, height);

clearRect erases a rectangle back to transparent. It is especially useful when you need to redraw the canvas — for an animation or an updated state. To wipe the entire canvas:

ctx.clearRect(0, 0, canvas.width, canvas.height);
Method / propertyEffect
ctx.fillStyleColour for all fill operations (persists)
ctx.strokeStyleColour for all stroke/outline operations (persists)
ctx.lineWidthStroke line thickness in pixels (persists)
ctx.fillRect(x, y, w, h)Draw a solid rectangle
ctx.strokeRect(x, y, w, h)Draw a rectangle outline
ctx.clearRect(x, y, w, h)Erase a rectangle to transparency
ℹ️

fillStyle and strokeStyle accept any CSS colour value: named colours ("green"), hex ("#ff0000"), rgb(0, 128, 255), rgba(0, 0, 0, 0.5), hsl(120, 100%, 50%).


2. Text

ctx.font = "40px Arial";
ctx.fillText("Hello world!", 100, 300);
//                            x    y

ctx.lineWidth = 1;
ctx.strokeText("Hello", 100, 350);

Both methods take (text, x, y). The x and y position the baseline of the text — not the top-left corner of the bounding box. If you place text at y = 300, the bottom of most letters sits at y=300, and ascenders extend upward from there.

ctx.font uses the same shorthand syntax as the CSS font property: "style weight size family". Size and family are required:

ctx.font = "40px Arial";             // size + family
ctx.font = "bold 24px Georgia";      // weight + size + family
ctx.font = "italic 16px monospace";  // style + size + family

Like all context properties, ctx.font persists until you change it.

MethodWhat it draws
ctx.fillText(text, x, y)Solid filled text
ctx.strokeText(text, x, y)Outlined (hollow) text, no fill

3. Drawing images

Images must be loaded before they can be drawn. This is because loading a file from disk or the network takes some time — and the browser does this asynchronously: it starts the download and immediately continues running the rest of the script, without waiting.

This is the core problem:

// ❌ Does not work reliably
const mario = new Image();
mario.src = "mario.png";
ctx.drawImage(mario, 300, 100, 64, 64);  // runs immediately — file not ready yet

At the moment drawImage is called, the browser has only just started downloading the file. The image data is not available. The canvas draws nothing (or an empty image).

The fix is to draw inside a "load" event callback, which fires once the file has fully arrived:

// ✅ Correct
const mario = new Image();
mario.src = "mario.png";

mario.addEventListener("load", () => {
    ctx.drawImage(mario, 300, 100, 64, 64);
    //             img    x   y   w   h
});

The sequence of events:

1. new Image()                — create an empty image object
2. mario.src = "mario.png"   — tell the browser to start downloading
3. addEventListener("load")  — register a callback for when it finishes
   ↓ (script continues, browser downloads in background)
4. "load" fires              — file is fully downloaded
5. callback runs             — ctx.drawImage() executes, pixels are ready

Steps 3 and 4 are separated by however long the download takes. The browser does not pause the script — it calls the callback later, when the data is ready.

💡

This is your first real encounter with asynchronous code — code that does not execute top-to-bottom in one go. Instead, a piece of it (the callback) is scheduled to run in the future. We will use this pattern a great deal when fetching data from servers with fetch().

drawImage(image, x, y, width, height) places the image at (x, y) and scales it to the given width and height. If you omit width and height, the image is drawn at its natural size.


4. Paths — drawing arbitrary shapes

Rectangles have their own shorthand methods. For any other shape — a triangle, a polygon, a circle, a curve — you use a path.

A path is an invisible sequence of geometric instructions (lines, curves) that you build up step by step. Nothing appears on the canvas while you build it. Only when you call stroke() or fill() does the path actually get painted.

Think of it like tracing a shape with a pen held above the paper: you move the pen around describing the shape, then press it down all at once.

ctx.strokeStyle = "red";

ctx.beginPath();       // clear the path buffer, start fresh
ctx.moveTo(80, 80);    // lift the pen, place it at (80, 80)
ctx.lineTo(80, 150);   // draw a line from (80,80) to (80,150)
ctx.lineTo(150, 150);  // draw a line from (80,150) to (150,150)
ctx.lineTo(80, 80);    // draw a line back to the start

ctx.stroke();          // now paint everything as an outline
// ctx.fill();         // alternatively, fill the enclosed area

This draws a right-angle triangle.

closePath() — a shortcut for closing shapes

Instead of manually lineTo-ing back to the start point, you can call closePath():

ctx.beginPath();
ctx.moveTo(80, 80);
ctx.lineTo(80, 150);
ctx.lineTo(150, 150);
ctx.closePath();  // draws straight line back to (80, 80) automatically

ctx.stroke();

closePath() and manually writing lineTo(startX, startY) are equivalent for a stroked path. For filled shapes, closePath() can matter for how the junction is rendered.

The path lifecycle

StepMethodEffect
1ctx.beginPath()Discard any existing path, start a new empty one
2ctx.moveTo(x, y)Place the pen at (x, y) without drawing a line
3ctx.lineTo(x, y)Add a straight line segment from current position to (x, y)
ctx.closePath()Add a straight line from current position back to the first moveTo point
4ctx.stroke()Paint the path as an outline using strokeStyle and lineWidth
4ctx.fill()Fill the enclosed area using fillStyle

You can call both stroke() and fill() on the same path to get a filled shape with a visible border.

⚠️

Always call ctx.beginPath() before starting a new shape. Without it, new moveTo/lineTo calls are appended to whatever path was built before. When you call stroke(), every previous shape gets repainted too — often with different style settings — producing unexpected results that are hard to debug.


5. Saving and restoring style state

Because every property (fillStyle, strokeStyle, lineWidth, font, etc.) persists until changed, drawing multiple independent things can get messy. If one drawing function sets lineWidth = 10, the next function will inherit that value.

The context has a built-in stack for this:

ctx.lineWidth = 1;
ctx.fillStyle = "black";

ctx.save();                   // push current state onto the stack

ctx.fillStyle = "red";
ctx.lineWidth = 5;
ctx.fillRect(0, 0, 100, 100); // drawn with red fill, thick line

ctx.restore();                // pop — fillStyle and lineWidth are back to "black" and 1

ctx.fillRect(110, 0, 100, 100); // back to black, thin line

save() and restore() can be nested — each restore() pops one level.

This is particularly useful once you start writing reusable drawing functions:

function drawHighlightedRect(ctx, x, y, w, h) {
    ctx.save();               // protect caller's state
    ctx.fillStyle = "yellow";
    ctx.lineWidth = 3;
    ctx.strokeStyle = "orange";
    ctx.fillRect(x, y, w, h);
    ctx.strokeRect(x, y, w, h);
    ctx.restore();            // leave things exactly as we found them
}

Complete 1.js

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

// --- Rectangles ---
ctx.fillStyle = "green";
ctx.fillRect(0, 0, 100, 200);

ctx.strokeStyle = "blue";
ctx.lineWidth = 2;
ctx.strokeRect(110, 0, 100, 200);

// --- Text ---
ctx.font = "40px Arial";
ctx.fillStyle = "black";
ctx.fillText("Hello world!", 100, 300);

ctx.lineWidth = 1;
ctx.strokeText("Hello", 100, 350);

// --- Image (asynchronous) ---
const mario = new Image();
mario.src = "mario.png";
mario.addEventListener("load", () => {
    ctx.drawImage(mario, 300, 100, 64, 64);
});

// --- Path: triangle ---
ctx.strokeStyle = "red";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(80, 80);
ctx.lineTo(80, 150);
ctx.lineTo(150, 150);
ctx.lineTo(80, 80);
ctx.stroke();

Canvas vs DOM — when to use which

DOMCanvas
Content typeStructured: text, forms, listsPixels: drawings, games, charts
Element selectionquerySelector, getElementByIdNo elements — just pixels
InteractivityEach element handles its own eventsYou calculate which pixel was clicked yourself
PersistenceElements remain as JS objectsOnce drawn, just pixels — no memory of objects
AccessibilityScreen-reader friendlyNot inherently accessible
AnimationCSS transitions, Web Animations APIErase and redraw every frame with requestAnimationFrame

Canvas is the right choice when you need pixel-level control. The DOM is the right choice when you need structure, interactivity with discrete elements, or accessibility.


Summary

ConceptAPI
2D arrayArray.from({ length: rows }, () => Array(cols).fill(null))
Event delegationparent.addEventListener('click', ...), event.target.matches('td')
Custom data attributesdata-row, data-coldataset.row, dataset.col
Color picker<input type="color">, input event, this.value
Get canvas contextcanvas.getContext("2d")
Context state machineAll style properties persist until changed
Fill rectanglectx.fillRect(x, y, w, h)
Stroke rectanglectx.strokeRect(x, y, w, h)
Erase rectanglectx.clearRect(x, y, w, h)
Fill / stroke colourctx.fillStyle, ctx.strokeStyle
Line thicknessctx.lineWidth
Fontctx.font = "size family"
Filled textctx.fillText(text, x, y)y is the baseline
Outlined textctx.strokeText(text, x, y)
Draw imagectx.drawImage(img, x, y, w, h)
Wait for imageimg.addEventListener("load", () => { ... }) — required because loading is async
Start pathctx.beginPath() — always call before a new shape
Move penctx.moveTo(x, y)
Line segmentctx.lineTo(x, y)
Close shapectx.closePath()
Paint outlinectx.stroke()
Fill shapectx.fill()
Save/restore stylesctx.save() / ctx.restore()