Build a Pathfinding Visualizer
From scratch in vanilla JavaScript: a canvas grid where you can draw walls, pick start/end points, and watch Dijkstra or A* explore the maze cell by cell. Along the way you'll implement a binary min-heap priority queue, admissible heuristics, and smooth step-by-step animation.
- JavaScript arrays, objects, classes
-
Canvas 2D API basics (
fillRect,clearRect) - Basic understanding of graphs (nodes + edges)
Grid Data Structure and Rendering
The grid is a flat Uint8Array for cache efficiency. Each
cell is: 0 = open, 1 = wall, 2 = start, 3 = end, 4 = visited, 5 =
path:
const COLS = 40, ROWS = 30;
const CELL = 20; // pixels per cell
const canvas = document.getElementById('grid');
const ctx = canvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const grid = new Uint8Array(COLS * ROWS); // flat array
const idx = (c, r) => r * COLS + c;
const COLORS = {
0: '#1a1a2e', // open
1: '#334155', // wall
2: '#22c55e', // start
3: '#ef4444', // end
4: '#1e40af', // visited
5: '#facc15', // path
};
function render() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = COLORS[grid[idx(c, r)]];
ctx.fillRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
Binary Min-Heap Priority Queue
Both Dijkstra and A* need to efficiently pop the minimum-cost node. A binary min-heap gives O(log N) push/pop:
class MinHeap {
constructor() { this.data = []; }
push(item) {
this.data.push(item);
this._bubbleUp(this.data.length - 1);
}
pop() {
const top = this.data[0];
const last = this.data.pop();
if (this.data.length) { this.data[0] = last; this._sinkDown(0); }
return top;
}
get size() { return this.data.length; }
_bubbleUp(i) {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.data[parent].f <= this.data[i].f) break;
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
i = parent;
}
}
_sinkDown(i) {
const n = this.data.length;
while (true) {
let smallest = i;
const l = 2*i+1, r = 2*i+2;
if (l < n && this.data[l].f < this.data[smallest].f) smallest = l;
if (r < n && this.data[r].f < this.data[smallest].f) smallest = r;
if (smallest === i) break;
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
i = smallest;
}
}
}
Dijkstra's Algorithm
Dijkstra guarantees the shortest path on uniform-cost grids. It explores in all directions simultaneously, like water flooding outward:
function dijkstra(startC, startR, endC, endR) {
const dist = new Float32Array(COLS * ROWS).fill(Infinity);
const prev = new Int32Array(COLS * ROWS).fill(-1);
const heap = new MinHeap();
const start = idx(startC, startR);
dist[start] = 0;
heap.push({ f: 0, i: start });
const steps = []; // for animation
while (heap.size) {
const { i } = heap.pop();
if (i === idx(endC, endR)) break;
const c = i % COLS, r = Math.floor(i / COLS);
steps.push({ type: 'visit', i });
// 4-directional neighbours
for (const [dc, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nc = c + dc, nr = r + dr;
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
const ni = idx(nc, nr);
if (grid[ni] === 1) continue; // wall
const newDist = dist[i] + 1;
if (newDist < dist[ni]) {
dist[ni] = newDist;
prev[ni] = i;
heap.push({ f: newDist, i: ni });
}
}
}
// Reconstruct path
const path = [];
let cur = idx(endC, endR);
while (cur !== -1) { path.push(cur); cur = prev[cur]; }
return { steps, path: path.reverse() };
}
A* with Manhattan Heuristic
A* adds a heuristic to guide expansion towards the goal. On a grid without diagonals, Manhattan distance is admissible (never overestimates) and efficient:
Dijkstra
- f = g (cost from start)
- Explores all directions equally
- Guaranteed shortest path
- Slower on large open grids
A*
- f = g + h (cost + heuristic)
- Biased towards the goal
- Optimal if h is admissible
- Much faster in practice
// Manhattan distance heuristic
function heuristic(c, r, endC, endR) {
return Math.abs(c - endC) + Math.abs(r - endR);
}
function aStar(startC, startR, endC, endR) {
const g = new Float32Array(COLS * ROWS).fill(Infinity);
const prev = new Int32Array(COLS * ROWS).fill(-1);
const heap = new MinHeap();
const steps = [];
const start = idx(startC, startR);
g[start] = 0;
heap.push({ f: heuristic(startC, startR, endC, endR), i: start });
while (heap.size) {
const { i } = heap.pop();
if (i === idx(endC, endR)) break;
const c = i % COLS, r = Math.floor(i / COLS);
steps.push({ type: 'visit', i });
for (const [dc, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nc = c + dc, nr = r + dr;
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
const ni = idx(nc, nr);
if (grid[ni] === 1) continue;
const ng = g[i] + 1;
if (ng < g[ni]) {
g[ni] = ng;
prev[ni] = i;
const h = heuristic(nc, nr, endC, endR);
heap.push({ f: ng + h, i: ni });
}
}
}
const path = [];
let cur = idx(endC, endR);
while (cur !== -1) { path.push(cur); cur = prev[cur]; }
return { steps, path: path.reverse() };
}
Step-by-Step Animation
function animate(steps, path, speed = 15) {
let stepIndex = 0;
function frame() {
// Process `speed` steps per frame
for (let s = 0; s < speed && stepIndex < steps.length; s++, stepIndex++) {
const { i } = steps[stepIndex];
if (grid[i] !== 2 && grid[i] !== 3) grid[i] = 4; // mark visited
}
render();
if (stepIndex < steps.length) {
requestAnimationFrame(frame);
} else {
// Draw path when done
for (const i of path) {
if (grid[i] !== 2 && grid[i] !== 3) grid[i] = 5;
}
render();
}
}
requestAnimationFrame(frame);
}
Mouse Interaction — Draw Walls
let painting = false;
let paintType = 1; // 1 = wall, 0 = erase
canvas.addEventListener('mousedown', e => {
painting = true;
paintType = e.button === 2 ? 0 : 1; // right-click erases
paint(e);
});
canvas.addEventListener('mousemove', e => { if (painting) paint(e); });
canvas.addEventListener('mouseup', () => painting = false);
canvas.addEventListener('contextmenu', e => e.preventDefault());
function paint(e) {
const rect = canvas.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return;
const i = idx(c, r);
if (grid[i] === 2 || grid[i] === 3) return; // don't overwrite start/end
grid[i] = paintType;
render();
}
Comparing Algorithms Visually
To run both and compare visited counts, run each on a copy of the grid:
function runAndCompare() {
const startC = 1, startR = 1, endC = COLS-2, endR = ROWS-2;
const resD = dijkstra(startC, startR, endC, endR);
const resA = aStar(startC, startR, endC, endR);
console.log(`Dijkstra visited: ${resD.steps.length}, path: ${resD.path.length}`);
console.log(`A* visited: ${resA.steps.length}, path: ${resA.path.length}`);
// On an open grid A* typically visits 60-80% fewer nodes than Dijkstra
}
A* with Manhattan heuristic typically explores 40-80% fewer nodes than Dijkstra on open grids. They find identically-optimal paths. In mazes the difference shrinks because forced exploration is needed in both cases.