Pathfinding algorithms are one of those computer science topics that make far more sense once you can watch them work than they ever do from a page of pseudocode. Stellix Pathfinder is built around that idea: a grid, a start point, an end point, some walls, and four different algorithms racing to find a route, one visibly expanding cell at a time.
Here is how it came together, and why each algorithm is built the way it is.
The grid is just a 2D array
Underneath the canvas, the whole board is a plain grid of numbers β zero for open, one for a wall. No cell objects, no classes, nothing fancy. That plainness matters: every algorithm in this project works on exactly that array and nothing else, which means you can test the algorithms completely separately from anything that draws a pixel. I did exactly that, and it caught real bugs before a single line ever touched a canvas.
A cell’s neighbours are just its up, down, left and right positions, filtered to whatever stays inside the grid’s edges. That one small function is shared by all four algorithms, so a mistake in it would be a mistake everywhere at once β which also means getting it right once and testing it well pays off four times over.
BFS: the baseline everything else answers to
Breadth-first search is the simplest of the four, and it happens to guarantee a shortest path on an unweighted grid, which makes it the ruler every other algorithm gets measured against.
The idea: keep a queue of cells to look at, starting with just the start cell. Take a cell out, look at its neighbours, and add any you haven’t seen before to the back of the queue. Because you always explore cells in the order you discovered them, and every step costs exactly one move, the very first time you reach any cell is guaranteed to be by the shortest possible route. There is no need to ever revisit a cell once it’s been seen.
Recording where each cell was reached from β a small map from “this cell” to “the cell that led to it” β lets you walk backward from the end once you find it, rebuilding the actual path.
DFS: depth over breadth, on purpose
Depth-first search uses the exact same shape of code, with one difference: a stack instead of a queue. Where BFS explores in soft, complete rings expanding outward, DFS commits to one direction and follows it as far as it can before backtracking.
That difference matters a lot in the result: DFS finds a path, not necessarily a short one, and visualizing it side by side with BFS makes that immediately obvious β DFS’s search trail winds enthusiastically down corridors that BFS would never have wasted time on. It’s a genuinely good way to feel, rather than just read, the difference between the two strategies.
Dijkstra: the queue that grows up
Dijkstra’s algorithm behaves like BFS on this particular grid, because every move costs the same. What makes it worth including anyway is that its structure generalises to weighted grids β sand that’s slow to cross, water that’s slower still β in a way BFS’s plain queue cannot.
Instead of a plain first-in-first-out queue, Dijkstra needs a priority queue: always pull out whichever known cell currently has the shortest confirmed distance from the start. That means building a small binary heap β a structure where the smallest item is always at the front, and adding or removing an item takes only a handful of comparisons rather than a full sort. It’s around thirty lines of array juggling, and it is worth writing from scratch once, because Dijkstra and A* both lean on it.
A*: Dijkstra with a sense of direction
A* reuses Dijkstra’s exact machinery, with one addition: instead of ordering the priority queue purely by distance travelled so far, it orders by distance travelled plus an estimate of the distance still remaining to the goal. On a grid, that estimate is the Manhattan distance β the number of horizontal and vertical steps between two points, ignoring any walls in between.
That estimate biases the search toward the goal instead of expanding evenly outward in every direction. The path A* finds is exactly as short as Dijkstra’s, because the estimate never overstates the true remaining distance β but it typically checks noticeably fewer cells to get there. Running both side by side on the same maze and comparing how many cells each one lit up before finishing is the single most convincing demonstration in the whole project.
Reconstructing the path without an infinite loop
Every algorithm above records, for each cell it reaches, which cell led to it. Turning that into an actual path means starting at the end and walking backward through those links until you land on the start.
There is a quiet trap here worth guarding against: if that map of links is ever malformed β through a bug, or a deliberately hostile test case β walking backward could loop forever between two cells that point at each other. The fix is cheap: keep a set of cells you’ve already stepped through while walking backward, and if you’re about to revisit one, stop and report that no path was found rather than hang. It costs one line and it turns a possible crash into a graceful “no route.”
Making the search watchable
An algorithm that finishes instantly is correct but uninteresting. The visual version needs to reveal its work over time.
Run the algorithm to completion first, quietly, and keep the full order in which it visited cells. Then animate that recorded order afterward: paint a handful of cells per frame using requestAnimationFrame, colouring each one as “visited” the moment its turn comes up. A slider controlling how many cells get revealed per frame gives a speed control for free, without touching the algorithm itself. Once the whole visited order has played out, colour the final path over the top in a different colour, so the shortest route stands out clearly against everything the algorithm had to check to find it.
Separating “compute the search” from “play back the search” this way also makes the whole thing trivially easy to test β you can check that the recorded order and final path are correct without a canvas anywhere in sight.
Interaction that doesn’t get in its own way
Dragging matters more than it seems. Track whether a press started on the start dot, the end dot, or empty space, and remember that choice for the whole drag rather than re-checking every pixel β otherwise a fast drag near the start point can accidentally start painting walls instead of moving it. Prevent walls from landing directly on top of the start or end cells, so a maze can never accidentally seal off the very points you’re trying to connect.
A “generate maze” button that scatters walls at a fixed probability, then forces the start and end cells back open afterward, gives people an interesting board to experiment on without needing to draw one by hand every time.
What ties it together
None of BFS, DFS, Dijkstra or A* is difficult code in isolation β each is a short loop around a neighbour function and a bit of bookkeeping. What makes the project worth building is putting all four side by side on the same board, so the differences between “shortest” and “some path,” or between “expand blindly” and “expand toward the goal,” stop being definitions in a textbook and start being something you can actually watch happen.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stellix Pathfinder β Coding Stellix</title>
<meta name="description" content="Stellix Pathfinder by Coding Stellix β watch BFS, DFS, Dijkstra and A* search a grid step by step and compare how each one finds a path. Draw walls, generate a maze, and see the shortest route. Works offline in one HTML file." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0e14;
--panel:#111722;
--stroke:rgba(255,255,255,.10);
--soft:rgba(255,255,255,.045);
--ink:#e9edf7;
--muted:#8792ab;
--faint:#525c78;
--cyan:#2dd4ff;
--amber:#ffb020;
--violet:#a78bfa;
--lime:#8ef05a;
--rose:#ff5d7a;
--wall:#2a3142;
--shadow:0 22px 56px rgba(0,0,0,.5);
}
html[data-theme="light"]{
--bg:#f1f3f8;
--panel:#ffffff;
--stroke:rgba(15,20,45,.10);
--soft:rgba(15,20,45,.04);
--ink:#0f1320;
--muted:#5b6178;
--faint:#9aa0b8;
--wall:#c7ccdd;
--shadow:0 18px 44px rgba(30,35,70,.12);
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Jost',sans-serif;background:var(--bg);color:var(--ink);min-height:100vh;
padding:16px 14px 34px;transition:background .3s,color .3s}
.wrap{max-width:1220px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:11px}
.mark{width:40px;height:40px;border-radius:12px;flex:none;display:grid;place-items:center;
background:linear-gradient(140deg,var(--cyan),var(--violet));box-shadow:0 8px 24px rgba(45,212,255,.28)}
.mark svg{width:21px;height:21px}
.brand b{display:block;font-size:.97rem;font-weight:600;line-height:1.15}
.brand small{display:block;font-size:.66rem;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.icon-btn{width:42px;height:42px;border-radius:12px;border:1px solid var(--stroke);background:var(--panel);
color:var(--ink);cursor:pointer;display:grid;place-items:center;transition:transform .16s,border-color .16s}
.icon-btn:hover{transform:translateY(-2px);border-color:var(--cyan)}
.icon-btn:focus-visible{outline:2px solid var(--cyan);outline-offset:3px}
.icon-btn svg{width:19px;height:19px}
h1.page{font-size:clamp(1.55rem,5vw,2.05rem);font-weight:700;letter-spacing:-.03em}
.sub{color:var(--muted);font-size:.88rem;font-weight:300;margin-top:2px}
.card{background:var(--panel);border:1px solid var(--stroke);border-radius:16px;padding:14px;box-shadow:var(--shadow)}
.card + .card{margin-top:12px}
.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.algos{display:flex;gap:6px;flex-wrap:wrap}
.algo{font-size:.82rem;border:1px solid var(--stroke);background:var(--soft);border-radius:9px;padding:8px 13px;
cursor:pointer;color:var(--muted);transition:.15s;user-select:none;font-weight:500}
.algo:hover{border-color:var(--cyan);color:var(--ink)}
.algo.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.spacer{flex:1}
.btn{font-family:'Jost',sans-serif;font-size:.83rem;font-weight:500;min-height:40px;padding:9px 14px;border-radius:11px;
border:1px solid var(--stroke);background:var(--soft);color:var(--ink);cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;gap:7px;transition:transform .16s,border-color .16s}
.btn:hover{transform:translateY(-2px);border-color:var(--cyan)}
.btn:focus-visible{outline:2px solid var(--cyan);outline-offset:3px}
.btn.primary{background:linear-gradient(120deg,var(--cyan),var(--violet));border-color:transparent;color:#0a0e14;font-weight:600}
.btn svg{width:15px;height:15px}
.speedbox{display:flex;align-items:center;gap:7px;font-family:'Space Mono',monospace;font-size:.76rem;color:var(--muted)}
.speedbox input[type=range]{width:90px;accent-color:var(--cyan)}
.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:11px;font-size:.76rem;color:var(--muted)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend i{width:13px;height:13px;border-radius:4px;display:inline-block}
.gridwrap{margin-top:12px;overflow:auto;border-radius:12px;border:1px solid var(--stroke)}
#grid{display:block;touch-action:none;cursor:crosshair}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-top:12px}
.st{background:var(--soft);border:1px solid var(--stroke);border-radius:12px;padding:9px 11px}
.st span{display:block;font-size:.58rem;letter-spacing:.15em;text-transform:uppercase;color:var(--muted)}
.st strong{font-family:'Space Mono',monospace;font-size:1.1rem}
.msg{font-size:.78rem;color:var(--muted);font-weight:300;margin-top:9px}
footer{text-align:center;font-size:.75rem;color:var(--muted);font-weight:300;border-top:1px solid var(--stroke);padding-top:13px;margin-top:2px}
footer b{color:var(--ink);font-weight:600}
@media (max-width:640px){ .algos{width:100%} }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="mark">
<svg viewBox="0 0 24 24" fill="none" stroke="#0a0e14" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="5" r="2.2"/><circle cx="19" cy="19" r="2.2"/>
<path d="M6.6 6.6 12 8l3 6 3.4 3.4"/>
</svg>
</div>
<div><b>Coding Stellix</b><small>Pathfinder</small></div>
</div>
<button class="icon-btn" id="themeBtn" aria-label="Switch to light mode" title="Switch theme">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
</svg>
</button>
</header>
<div>
<h1 class="page">Pathfinding Visualizer</h1>
<p class="sub">Drag the start and end points, draw walls, and watch each algorithm search the grid differently.</p>
</div>
<div class="card">
<div class="bar">
<div class="algos" id="algoBox"></div>
<div class="spacer"></div>
<div class="speedbox"><span>Speed</span><input type="range" id="speedRange" min="1" max="100" value="60"></div>
<button class="btn primary" id="runBtn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
Visualize
</button>
<button class="btn" id="mazeBtn">Generate maze</button>
<button class="btn" id="wallsBtn">Clear walls</button>
<button class="btn" id="resetBtn">Reset all</button>
</div>
<div class="legend">
<span><i style="background:var(--cyan)"></i>Start</span>
<span><i style="background:var(--rose)"></i>End</span>
<span><i style="background:var(--wall)"></i>Wall</span>
<span><i style="background:var(--violet);opacity:.55"></i>Visited</span>
<span><i style="background:var(--amber)"></i>Frontier</span>
<span><i style="background:var(--lime)"></i>Path</span>
</div>
<div class="gridwrap"><canvas id="grid"></canvas></div>
<div class="stats" id="statBox"></div>
<p class="msg" id="statusMsg">Click and drag on the grid to draw walls. Drag the start or end dot to move them.</p>
</div>
<footer>Built by <b>Coding Stellix</b></footer>
</div>
<script>
(function(){
"use strict";
/* =========================================================
PURE HELPERS START β the search algorithms themselves,
working on a plain grid of numbers so they can be unit-tested
without any canvas or DOM in the picture
========================================================= */
const WALL=1, OPEN=0;
function makeGrid(rows,cols,fill){ return Array.from({length:rows},()=>new Array(cols).fill(fill||OPEN)); }
function neighbors(r,c,rows,cols){
const out=[];
if(r>0) out.push([r-1,c]);
if(r<rows-1) out.push([r+1,c]);
if(c>0) out.push([r,c-1]);
if(c<cols-1) out.push([r,c+1]);
return out;
}
function key(r,c){ return r+','+c; }
/* reconstruct the path from start to end by walking the "came from" map
backwards; returns [] if end was never reached */
function rebuildPath(cameFrom,start,end){
if(!cameFrom.has(key(end[0],end[1])) && key(start[0],start[1])!==key(end[0],end[1])) return [];
const path=[end];
let cur=key(end[0],end[1]);
const seen=new Set([cur]);
while(cur!==key(start[0],start[1])){
const prev=cameFrom.get(cur);
if(!prev) return []; // no route recorded β unreachable
if(seen.has(key(prev[0],prev[1]))) return []; // corrupt map, avoid an infinite loop
path.push(prev);
cur=key(prev[0],prev[1]);
seen.add(cur);
}
path.reverse();
return path;
}
/* Breadth-first search. Unweighted, so the first time we reach a cell is
guaranteed to be via a shortest path β no cell needs to be revisited. */
function bfs(grid,start,end){
const rows=grid.length, cols=grid[0].length;
const cameFrom=new Map();
const visitedOrder=[];
const seen=new Set([key(start[0],start[1])]);
const q=[start];
let qi=0;
while(qi<q.length){
const [r,c]=q[qi++];
visitedOrder.push([r,c]);
if(r===end[0]&&c===end[1]) break;
for(const [nr,nc] of neighbors(r,c,rows,cols)){
const k=key(nr,nc);
if(seen.has(k)||grid[nr][nc]===WALL) continue;
seen.add(k);
cameFrom.set(k,[r,c]);
q.push([nr,nc]);
}
}
return { order:visitedOrder, path:rebuildPath(cameFrom,start,end) };
}
/* Depth-first search. Explores as far as possible down one branch before
backtracking β finds *a* path, not necessarily the shortest one. */
function dfs(grid,start,end){
const rows=grid.length, cols=grid[0].length;
const cameFrom=new Map();
const visitedOrder=[];
const seen=new Set([key(start[0],start[1])]);
const stack=[start];
while(stack.length){
const [r,c]=stack.pop();
visitedOrder.push([r,c]);
if(r===end[0]&&c===end[1]) break;
for(const [nr,nc] of neighbors(r,c,rows,cols)){
const k=key(nr,nc);
if(seen.has(k)||grid[nr][nc]===WALL) continue;
seen.add(k);
cameFrom.set(k,[r,c]);
stack.push([nr,nc]);
}
}
return { order:visitedOrder, path:rebuildPath(cameFrom,start,end) };
}
/* a plain binary min-heap keyed by a numeric priority β small and
dependency-free, used by both Dijkstra and A* below */
function makeHeap(){
const items=[];
function swap(i,j){ const t=items[i]; items[i]=items[j]; items[j]=t; }
function up(i){
while(i>0){
const p=(i-1)>>1;
if(items[p].p<=items[i].p) break;
swap(p,i); i=p;
}
}
function down(i){
for(;;){
const l=i*2+1, r=i*2+2; let s=i;
if(l<items.length && items[l].p<items[s].p) s=l;
if(r<items.length && items[r].p<items[s].p) s=r;
if(s===i) break;
swap(s,i); i=s;
}
}
return {
push(v,p){ items.push({v:v,p:p}); up(items.length-1); },
pop(){ if(!items.length) return undefined; const top=items[0]; const last=items.pop();
if(items.length){ items[0]=last; down(0); } return top.v; },
get size(){ return items.length; }
};
}
/* Dijkstra. All moves cost 1 here, so it behaves like BFS with a priority
queue instead of a plain queue β included because the code generalises
to weighted grids, which BFS cannot. */
function dijkstra(grid,start,end){
const rows=grid.length, cols=grid[0].length;
const dist=new Map([[key(start[0],start[1]),0]]);
const cameFrom=new Map();
const visitedOrder=[];
const closed=new Set();
const heap=makeHeap();
heap.push(start,0);
while(heap.size){
const [r,c]=heap.pop();
const k=key(r,c);
if(closed.has(k)) continue;
closed.add(k);
visitedOrder.push([r,c]);
if(r===end[0]&&c===end[1]) break;
for(const [nr,nc] of neighbors(r,c,rows,cols)){
if(grid[nr][nc]===WALL) continue;
const nk=key(nr,nc);
const nd=(dist.get(k)||0)+1;
if(!dist.has(nk) || nd<dist.get(nk)){
dist.set(nk,nd);
cameFrom.set(nk,[r,c]);
heap.push([nr,nc],nd);
}
}
}
return { order:visitedOrder, path:rebuildPath(cameFrom,start,end) };
}
function manhattan(a,b){ return Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]); }
/* A*. Same shape as Dijkstra, but the priority queue is ordered by
(distance so far + estimated distance remaining), so it explores toward
the goal instead of expanding outward evenly in every direction. */
function astar(grid,start,end){
const rows=grid.length, cols=grid[0].length;
const gScore=new Map([[key(start[0],start[1]),0]]);
const cameFrom=new Map();
const visitedOrder=[];
const closed=new Set();
const heap=makeHeap();
heap.push(start,manhattan(start,end));
while(heap.size){
const [r,c]=heap.pop();
const k=key(r,c);
if(closed.has(k)) continue;
closed.add(k);
visitedOrder.push([r,c]);
if(r===end[0]&&c===end[1]) break;
for(const [nr,nc] of neighbors(r,c,rows,cols)){
if(grid[nr][nc]===WALL) continue;
const nk=key(nr,nc);
const ng=(gScore.get(k)||0)+1;
if(!gScore.has(nk) || ng<gScore.get(nk)){
gScore.set(nk,ng);
cameFrom.set(nk,[r,c]);
heap.push([nr,nc], ng+manhattan([nr,nc],end));
}
}
}
return { order:visitedOrder, path:rebuildPath(cameFrom,start,end) };
}
/* recursive-division style maze: start fully open, carve walls in a random
binary pattern that always leaves a spanning structure, then guarantee
start/end are open β used for the "generate maze" button */
function randomWalls(rows,cols,density,rand){
const rnd=rand||Math.random;
const grid=makeGrid(rows,cols,OPEN);
for(let r=0;r<rows;r++){
for(let c=0;c<cols;c++){
if(rnd()<density) grid[r][c]=WALL;
}
}
return grid;
}
/* ========================= PURE HELPERS END ========================= */
const $=id=>document.getElementById(id);
const ALGOS=[
{k:'bfs', n:'BFS', fn:bfs, desc:'Breadth-first β guarantees the shortest path on an unweighted grid.'},
{k:'dfs', n:'DFS', fn:dfs, desc:'Depth-first β finds a path, but rarely the shortest one.'},
{k:'dijkstra', n:'Dijkstra', fn:dijkstra, desc:'Expands the closest unvisited cell first β shortest path, generalises to weights.'},
{k:'astar', n:'A*', fn:astar, desc:'Like Dijkstra, but guided toward the goal β usually visits far fewer cells.'}
];
let ROWS=18, COLS=32;
let grid=makeGrid(ROWS,COLS,OPEN);
let start=[Math.floor(ROWS/2),4];
let end=[Math.floor(ROWS/2),COLS-5];
let algo='astar';
let running=false;
let dragging=null; // 'start' | 'end' | 'wall' | 'erase' | null
let animId=null;
const cv=$('grid'), ctx=cv.getContext('2d');
let CELL=24, DPR=1;
function sizeCanvas(){
const wrapW=cv.parentElement.clientWidth;
CELL=Math.max(14,Math.min(26,Math.floor(wrapW/COLS)));
DPR=Math.min(window.devicePixelRatio||1,2);
cv.style.width=(CELL*COLS)+'px';
cv.style.height=(CELL*ROWS)+'px';
cv.width=CELL*COLS*DPR; cv.height=CELL*ROWS*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
function cssVar(name){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
let lastResult=null;
function draw(visitedSet,frontierSet,pathSet){
const wallC=cssVar('--wall'), soft=cssVar('--soft'), stroke=cssVar('--stroke');
const cyan=cssVar('--cyan'), rose=cssVar('--rose'), violet=cssVar('--violet'),
amber=cssVar('--amber'), lime=cssVar('--lime');
ctx.clearRect(0,0,COLS*CELL,ROWS*CELL);
for(let r=0;r<ROWS;r++){
for(let c=0;c<COLS;c++){
const x=c*CELL, y=r*CELL;
let fill=soft;
if(grid[r][c]===WALL) fill=wallC;
if(visitedSet && visitedSet.has(key(r,c))) fill=hexA(violet,0.45);
if(pathSet && pathSet.has(key(r,c))) fill=lime;
if(frontierSet && frontierSet.has(key(r,c))) fill=amber;
ctx.fillStyle=fill;
ctx.fillRect(x+1,y+1,CELL-2,CELL-2);
}
}
ctx.strokeStyle=stroke; ctx.lineWidth=1;
for(let r=0;r<=ROWS;r++){ ctx.beginPath(); ctx.moveTo(0,r*CELL); ctx.lineTo(COLS*CELL,r*CELL); ctx.stroke(); }
for(let c=0;c<=COLS;c++){ ctx.beginPath(); ctx.moveTo(c*CELL,0); ctx.lineTo(c*CELL,ROWS*CELL); ctx.stroke(); }
dot(start,cyan); dot(end,rose);
}
function dot([r,c],color){
const x=c*CELL+CELL/2, y=r*CELL+CELL/2;
ctx.fillStyle=color;
ctx.beginPath(); ctx.arc(x,y,CELL*0.32,0,Math.PI*2); ctx.fill();
}
function hexA(hex,a){
hex=hex.trim();
if(hex.startsWith('#')){
const h=hex.replace('#','');
const n=parseInt(h.length===3?h.split('').map(c=>c+c).join(''):h,16);
return 'rgba('+((n>>16)&255)+','+((n>>8)&255)+','+(n&255)+','+a+')';
}
return hex;
}
function cellFromEvent(e){
const rect=cv.getBoundingClientRect();
const x=(e.clientX-rect.left)/rect.width*COLS*CELL;
const y=(e.clientY-rect.top)/rect.height*ROWS*CELL;
const c=Math.max(0,Math.min(COLS-1,Math.floor(x/CELL)));
const r=Math.max(0,Math.min(ROWS-1,Math.floor(y/CELL)));
return [r,c];
}
function sameCell(a,b){ return a[0]===b[0]&&a[1]===b[1]; }
cv.addEventListener('pointerdown',e=>{
if(running) return;
const cell=cellFromEvent(e);
if(sameCell(cell,start)){ dragging='start'; }
else if(sameCell(cell,end)){ dragging='end'; }
else { dragging = grid[cell[0]][cell[1]]===WALL ? 'erase' : 'wall'; applyDrag(cell); }
draw();
cv.setPointerCapture && cv.setPointerCapture(e.pointerId);
});
cv.addEventListener('pointermove',e=>{
if(!dragging||running) return;
applyDrag(cellFromEvent(e));
draw();
});
window.addEventListener('pointerup',()=>{ dragging=null; });
function applyDrag(cell){
if(dragging==='start'){ if(!sameCell(cell,end)) start=cell; }
else if(dragging==='end'){ if(!sameCell(cell,start)) end=cell; }
else if(dragging==='wall'){ if(!sameCell(cell,start)&&!sameCell(cell,end)) grid[cell[0]][cell[1]]=WALL; }
else if(dragging==='erase'){ grid[cell[0]][cell[1]]=OPEN; }
}
/* ---------------- controls ---------------- */
function renderAlgos(){
$('algoBox').innerHTML=ALGOS.map(a=>
'<span class="algo'+(algo===a.k?' on':'')+'" data-k="'+a.k+'" tabindex="0">'+a.n+'</span>').join('');
$('algoBox').querySelectorAll('.algo').forEach(el=>{
const go=()=>{ if(running) return; algo=el.dataset.k; renderAlgos();
$('statusMsg').textContent=(ALGOS.find(a=>a.k===algo)||{}).desc||''; };
el.onclick=go;
el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); go(); } };
});
}
$('wallsBtn').onclick=()=>{ if(running) return; grid=makeGrid(ROWS,COLS,OPEN); draw(); };
$('resetBtn').onclick=()=>{
if(running) return;
grid=makeGrid(ROWS,COLS,OPEN);
start=[Math.floor(ROWS/2),4]; end=[Math.floor(ROWS/2),COLS-5];
clearStats(); draw();
};
$('mazeBtn').onclick=()=>{
if(running) return;
grid=randomWalls(ROWS,COLS,0.28,Math.random);
grid[start[0]][start[1]]=OPEN; grid[end[0]][end[1]]=OPEN;
clearStats(); draw();
};
function clearStats(){ $('statBox').innerHTML=''; $('statusMsg').textContent='Click and drag on the grid to draw walls. Drag the start or end dot to move them.'; }
/* ---------------- visualize (animated) ---------------- */
$('runBtn').onclick=()=>{
if(running) return;
const def=ALGOS.find(a=>a.k===algo);
const result=def.fn(grid,start,end);
lastResult=result;
animate(result);
};
function animate(result){
running=true;
const speed=+$('speedRange').value; // 1..100
const perFrame=Math.max(1,Math.round(speed/6));
const order=result.order;
let i=0;
const visited=new Set(), frontier=new Set();
cancelAnimationFrame(animId);
function step(){
const batchEnd=Math.min(order.length,i+perFrame);
frontier.clear();
for(;i<batchEnd;i++){
const [r,c]=order[i];
visited.add(key(r,c));
frontier.add(key(r,c));
}
draw(visited,frontier,null);
if(i<order.length){
animId=requestAnimationFrame(step);
} else {
finishAnimation(result,visited);
}
}
step();
}
function finishAnimation(result,visited){
const pathSet=new Set(result.path.map(([r,c])=>key(r,c)));
draw(visited,null,pathSet);
running=false;
const found=result.path.length>0;
$('statBox').innerHTML=
stat('Cells visited',result.order.length)+
stat('Path length',found?result.path.length:'β')+
stat('Result',found?'Path found':'No path')+
stat('Algorithm',(ALGOS.find(a=>a.k===algo)||{}).n||'');
$('statusMsg').textContent = found
? 'Reached the end after checking '+result.order.length+' cell'+(result.order.length===1?'':'s')+'.'
: 'No route exists between start and end with the current walls.';
}
function stat(label,val){ return '<div class="st"><span>'+label+'</span><strong>'+val+'</strong></div>'; }
/* ---------------- theme ---------------- */
const themeBtn=$('themeBtn'), themeIcon=$('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUN='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.onclick=()=>{
const light=document.documentElement.getAttribute('data-theme')==='light';
document.documentElement.setAttribute('data-theme',light?'dark':'light');
themeIcon.innerHTML=light?SUN:MOON;
themeBtn.setAttribute('aria-label',light?'Switch to light mode':'Switch to dark mode');
draw(lastResult?new Set(lastResult.order.map(([r,c])=>key(r,c))):null,null,
lastResult?new Set(lastResult.path.map(([r,c])=>key(r,c))):null);
};
window.addEventListener('resize',()=>{ sizeCanvas(); draw(); });
/* ---------------- boot ---------------- */
renderAlgos();
$('statusMsg').textContent=(ALGOS.find(a=>a.k===algo)||{}).desc||'';
sizeCanvas(); draw();
window.__stellixPathfinder={makeGrid:makeGrid,neighbors:neighbors,bfs:bfs,dfs:dfs,dijkstra:dijkstra,
astar:astar,rebuildPath:rebuildPath,makeHeap:makeHeap,manhattan:manhattan,randomWalls:randomWalls,
WALL:WALL,OPEN:OPEN,key:key};
})();
</script>
</body>
</html>


