Pixel Art Editor
ContentsHideShow
- Introduction
- Project setup and the static example
- 1. Store the drawing as rows of pixels
- 2. Render cells, rows, and the table
- 3. Generate a new grid safely
- 4. Choose a color and paint through one listener
- 5. Show and control elapsed time
- 6. Initialize the page
- 7. Save and load one drawing — guided continuation
- 8. Complete reference script.js
- Classroom notes and common mistakes
- Self-check
- Task
Introduction
In Practice 4, the Hangman game kept its data in JavaScript and rebuilt the page after each guess. The Pixel Art Editor follows the same pattern: an array stores the colors, and rendering turns that array into a grid of table cells.
Both groups built the grid, chose colors, painted cells through one click listener, and worked on the elapsed timer. The classroom files are useful records of that process, but neither is a finished editor. This article explains the shared work, points out the differences between Monday and Tuesday, and ends with the complete reference script.js. Saving and loading are the guided continuation after class.
Generate → replace the pixel array → render the grid
Cell click → change one pixel in the array → render the grid
Timer tick → increase elapsed seconds → render the timer Project setup and the static example
Open starter/example.html first. It is a static 5×5 drawing: its cells already have data-row, data-col, and background colors. It shows the HTML shape we will generate, but its buttons have no JavaScript behavior.
Open starter/index.html with Live Server to run the editor. It contains the width and height inputs, a permanent #grid-container, a color picker, timer controls, and Save/Load buttons. The starter script.js already selects those elements and supplies state, STORAGE_KEY, validColor(), and validDrawing(). We add functions and listeners below that starting code.
The grid is initially empty because state.pixels starts as []. Both classroom files relied on Generate grid to create the first grid. The final version also creates the default 10×10 grid on page load.
1. Store the drawing as rows of pixels
state.pixels[row][col] is either a color such as "#ff0000" or null for an unpainted cell. For example:
[
["#ff0000", null, null],
[null, "#0000ff", null]
] The outer array contains rows. Each row must be a different inner array. Both groups used Array.from with a callback to create that structure:
function createEmptyPixels(width, height) {
return Array.from({ length: height }, () => Array(width).fill(null));
} The callback runs once per row, so painting pixels[0][0] does not also paint pixels[1][0]. Width controls the number of cells in a row; height controls the number of rows.
2. Render cells, rows, and the table
The renderer works in three small steps:
function cellHtml(color, row, col) {
const background = color ?? "#ffffff";
return `<td data-row="${row}" data-col="${col}" style="background-color: ${background}"></td>`;
}
function rowHtml(row, rowIndex) {
const cells = row.map((color, colIndex) =>
cellHtml(color, rowIndex, colIndex)
).join("");
return `<tr>${cells}</tr>`;
}
function renderGrid() {
const rows = state.pixels.map(rowHtml).join("");
gridContainer.innerHTML = `<table class="edit"><tbody>${rows}</tbody></table>`;
} The inner map turns one row’s pixels into <td> strings. Its callback’s second argument is the column index. The outer map turns rows into <tr> strings and supplies the row index. Each join("") combines the strings without commas. ?? chooses white only when the stored color is null.
The coordinates in data-row and data-col connect a visible cell back to its place in state.pixels. The third row and fifth column use data-row="2" and data-col="4" because array indices start at zero.
3. Generate a new grid safely
An input’s .value is text, even for type="number". Convert both values with Number(...) and accept only integers from 1 to 30. Validate before changing the drawing:
function validSize(size) {
return Number.isInteger(size) && size >= 1 && size <= 30;
}
function generateGrid() {
const width = Number(widthInput.value);
const height = Number(heightInput.value);
if (!validSize(width) || !validSize(height)) {
status.textContent = "Choose a width and height from 1 to 30.";
return;
}
state.gridWidth = width;
state.gridHeight = height;
state.pixels = createEmptyPixels(width, height);
resetTimer();
renderGrid();
status.textContent = "New grid created.";
} Monday’s group added validSize() and checked both inputs before making a grid. Tuesday’s classroom handler converted the inputs but did not validate them. The final version keeps Monday’s check and adds a visible error message. A valid new grid replaces the current drawing and resets the drawing timer.
4. Choose a color and paint through one listener
The color input changes state.currentColor; renderColor() updates the preview. Painting changes one entry in state.pixels and rerenders:
function paintCell(event) {
if (!event.target.matches("td")) return;
const row = Number(event.target.dataset.row);
const col = Number(event.target.dataset.col);
state.pixels[row][col] = state.currentColor;
renderGrid();
}
gridContainer.addEventListener("click", paintCell); Both groups attached the click listener to the permanent gridContainer, not to each generated cell. The click bubbles from a td to that container. The table can be replaced on every render and the listener still works. dataset.row and dataset.col are strings, so the reference code converts them to numbers before using them as indices.
5. Show and control elapsed time
state.elapsedSeconds is the displayed value. intervalId holds the browser’s interval ID, or null when stopped. Render seconds as minutes and remaining seconds:
function renderTimer() {
const minutes = String(Math.floor(state.elapsedSeconds / 60)).padStart(2, "0");
const seconds = String(state.elapsedSeconds % 60).padStart(2, "0");
timerDisplay.textContent = `${minutes}:${seconds}`;
} Math.floor(... / 60) gives whole minutes, % 60 gives seconds within the minute, and padStart(2, "0") makes 01:05 instead of 1:5.
function startTimer() {
if (intervalId !== null) return;
intervalId = setInterval(() => {
state.elapsedSeconds += 1;
renderTimer();
}, 1000);
}
function pauseTimer() {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
}
function resetTimer() {
pauseTimer();
state.elapsedSeconds = 0;
renderTimer();
} The guard in startTimer() prevents two intervals from running after Start is clicked twice. Pause stops ticks but keeps the elapsed value, so Start can resume. Reset pauses first, then displays 00:00. Painting stays available whether the timer is running or paused.
6. Initialize the page
After registering the event listeners, create the first grid and render all three displays:
state.pixels = createEmptyPixels(state.gridWidth, state.gridHeight);
renderAll(); renderAll() calls renderGrid(), renderColor(), and renderTimer(). This shows a 10×10 white grid, the initial red color, and 00:00 before any button click. The classroom files had the rendering functions but did not make this initial call.
7. Save and load one drawing — guided continuation
Neither group completed Save/Load in the classroom files. The completed editor saves one snapshot under STORAGE_KEY. localStorage stores strings, so saveDrawing() puts gridWidth, gridHeight, pixels, and currentColor into an object and passes JSON.stringify(drawing) to localStorage.setItem(...). Saving again overwrites that one slot. The timer is separate session state and is not stored.
loadDrawing() reads the string with getItem(...), parses it with JSON.parse(...), and calls the supplied validDrawing() helper. That helper checks sizes with validSize() and checks colors with validColor(). Only after validation succeeds does Load replace state, update the width, height, and color inputs, reset the timer, and rerender. A missing or unreadable snapshot shows a message while the current drawing stays in place. try/catch covers invalid JSON and storage failures.
8. Complete reference script.js
This is the complete material solution. It includes the supplied selectors and validation helpers, so the file can be read from top to bottom. The a–g comments match the task checkpoints.
const STORAGE_KEY = "webprog-practice-05-pixel-art";
const state = {
gridWidth: 10,
gridHeight: 10,
pixels: [], // pixels[row][column] is a color string or null
currentColor: "#ff0000",
elapsedSeconds: 0,
};
let intervalId = null;
const widthInput = document.querySelector("#grid-width");
const heightInput = document.querySelector("#grid-height");
const generateButton = document.querySelector("#generate-btn");
const gridContainer = document.querySelector("#grid-container");
const colorPicker = document.querySelector("#color-picker");
const colorPreview = document.querySelector("#current-color-display");
const saveButton = document.querySelector("#save-btn");
const loadButton = document.querySelector("#load-btn");
const status = document.querySelector("#status");
const timerDisplay = document.querySelector("#timer-display");
const timerStartButton = document.querySelector("#timer-start");
const timerPauseButton = document.querySelector("#timer-pause");
const timerResetButton = document.querySelector("#timer-reset");
// a. Each row must be a new array, or painting one cell would affect other rows.
function createEmptyPixels(width, height) {
return Array.from({ length: height }, () => Array(width).fill(null));
}
// b. Turn pixels into cells, cells into rows, and rows into one table.
function cellHtml(color, row, col) {
const background = color ?? "#ffffff";
return `<td data-row="${row}" data-col="${col}" style="background-color: ${background}"></td>`;
}
function rowHtml(row, rowIndex) {
const cells = row.map((color, colIndex) =>
cellHtml(color, rowIndex, colIndex)
).join("");
return `<tr>${cells}</tr>`;
}
function renderGrid() {
const rows = state.pixels.map(rowHtml).join("");
gridContainer.innerHTML = `<table class="edit"><tbody>${rows}</tbody></table>`;
}
// c. Check both input sizes before replacing the current drawing.
function validSize(size) {
return Number.isInteger(size) && size >= 1 && size <= 30;
}
function generateGrid() {
const width = Number(widthInput.value);
const height = Number(heightInput.value);
if (!validSize(width) || !validSize(height)) {
status.textContent = "Choose a width and height from 1 to 30.";
return;
}
state.gridWidth = width;
state.gridHeight = height;
state.pixels = createEmptyPixels(width, height);
// Add this when you reach f: a new grid starts a new timed drawing.
resetTimer();
renderGrid();
status.textContent = "New grid created.";
}
// d. Store the chosen color in state; the grid is only its visual display.
function renderColor() {
colorPreview.style.backgroundColor = state.currentColor;
}
function paintCell(event) {
if (!event.target.matches("td")) return;
const row = Number(event.target.dataset.row);
const col = Number(event.target.dataset.col);
state.pixels[row][col] = state.currentColor;
renderGrid();
}
function changeColor() {
state.currentColor = colorPicker.value;
renderColor();
}
// e. The interval ID tells us whether the clock is already running.
function renderTimer() {
const minutes = String(Math.floor(state.elapsedSeconds / 60)).padStart(2, "0");
const seconds = String(state.elapsedSeconds % 60).padStart(2, "0");
timerDisplay.textContent = `${minutes}:${seconds}`;
}
function startTimer() {
if (intervalId !== null) return;
intervalId = setInterval(() => {
state.elapsedSeconds += 1;
renderTimer();
}, 1000);
}
// f. Stopping the interval does not clear its ID or elapsed time for us.
function pauseTimer() {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
}
function resetTimer() {
pauseTimer();
state.elapsedSeconds = 0;
renderTimer();
}
// Use this for the first display and after loading a saved drawing.
function renderAll() {
renderGrid();
renderColor();
renderTimer();
}
// g. Save drawing data only; the timer belongs to this session.
function saveDrawing() {
const drawing = {
gridWidth: state.gridWidth,
gridHeight: state.gridHeight,
pixels: state.pixels,
currentColor: state.currentColor,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(drawing));
status.textContent = "Drawing saved.";
} catch {
status.textContent = "Could not save the drawing.";
}
}
// These supplied helpers check saved data before it replaces the current drawing.
function validColor(color) {
return typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color);
}
function validDrawing(drawing) {
if (!drawing || !validSize(drawing.gridWidth) ||
!validSize(drawing.gridHeight) || !validColor(drawing.currentColor) ||
!Array.isArray(drawing.pixels) ||
drawing.pixels.length !== drawing.gridHeight) {
return false;
}
return drawing.pixels.every((row) =>
Array.isArray(row) && row.length === drawing.gridWidth &&
row.every((pixel) => pixel === null || validColor(pixel))
);
}
function loadDrawing() {
let drawing;
try {
const json = localStorage.getItem(STORAGE_KEY);
if (json === null) {
status.textContent = "No saved drawing yet.";
return;
}
drawing = JSON.parse(json);
if (!validDrawing(drawing)) throw new Error("Invalid drawing");
} catch {
status.textContent = "Saved drawing could not be loaded.";
return;
}
// Only a valid snapshot may change the current drawing and its controls.
state.gridWidth = drawing.gridWidth;
state.gridHeight = drawing.gridHeight;
state.pixels = drawing.pixels;
state.currentColor = drawing.currentColor;
widthInput.value = state.gridWidth;
heightInput.value = state.gridHeight;
colorPicker.value = state.currentColor;
resetTimer();
renderAll();
status.textContent = "Drawing loaded.";
}
// Listen on permanent elements. The table inside gridContainer is replaced.
generateButton.addEventListener("click", generateGrid);
gridContainer.addEventListener("click", paintCell);
colorPicker.addEventListener("input", changeColor);
timerStartButton.addEventListener("click", startTimer);
timerPauseButton.addEventListener("click", pauseTimer);
timerResetButton.addEventListener("click", resetTimer);
saveButton.addEventListener("click", saveDrawing);
loadButton.addEventListener("click", loadDrawing);
// Show the initial 10×10 drawing and the starting color and time.
state.pixels = createEmptyPixels(state.gridWidth, state.gridHeight);
renderAll(); Classroom notes and common mistakes
Both classroom files include temporary console.log calls used to inspect generated pixels or clicked cells. They help while learning but are not part of the final reference. The classroom click handlers also used dataset strings directly as array indices; JavaScript accepts numeric string property names here, though Number(...) makes the intended indices clear.
| Symptom | What to check |
|---|---|
| The page shows no grid until Generate is clicked | Initialize state.pixels and call renderAll() on page load. |
| Painting one cell changes cells in other rows | Create a fresh inner array for each row with the Array.from callback. |
| Commas appear between cells or rows | Add join("") after both map calls. |
| Painting stops after the first click | Attach the click listener to gridContainer, which is never replaced. |
| The timer advances too quickly | Pass 1000, not 100, to setInterval. |
| Start makes the timer run faster each time | Return when intervalId is already set. |
| Reset stops the clock but Start cannot resume | Set intervalId back to null after clearInterval. |
| Invalid sizes erase the drawing | Validate both dimensions before changing state. |
| Load erases the current drawing after bad data | Parse and validate into a temporary value before assigning to state. |
Self-check
- Why does
Array.fromneed a callback that creates a new row? - Which
mapbuilds cells, and which builds rows? - Why do cells need both
data-rowanddata-col? - Why does the click listener stay on
gridContainer? - What does
setIntervalreturn, and why do we keep that value? - What is the difference between Pause and Reset?
- Why must Load validate the parsed drawing before changing
state? - Why is
elapsedSecondsabsent from the saved object?
Task
Practice 5 — Pixel Art Editor lets you build the editor from the starter. Check grid generation, painting with two colors, timer start/pause/reset, and then finish the Save/Load continuation.