// ── app (D3 implementation — never mentions heuristic or weight) ──────────────
app = {
const C = {
bg: "#f8fafc", // very light gray
grid: "#cbd5e1", // slate-300
empty: "#f1f5f9", // slate-100
wall: "#334155", // slate-700
start: "#3b82f6", // blue-500
end: "#ef4444", // red-500
open: "#93c5fd", // blue-300
closed: "#86efac", // green-300
path: "#fbbf24", // amber-400
current: "#f97316", // orange-500
text: "#ffffff", // white — for S/E labels on coloured cells
scoreText: "#1e293b", // slate-800 — dark, readable on all light cell colours
};
// ── SVG setup ───────────────────────────────────────────────────────────────
const svg = d3.create("svg")
.attr("width", "100%")
.attr("viewBox", `0 0 ${W} ${H}`)
.style("cursor", "crosshair")
.style("max-width", `${W}px`)
.style("display", "block");
// background
svg.append("rect").attr("width", W).attr("height", H).attr("fill", C.bg);
// ── build flat cell array ────────────────────────────────────────────────
// Each object is a stable reference — D3 joins on identity, not index.
const cells = Array.from({ length: ROWS }, (_, r) =>
Array.from({ length: COLS }, (_, c) => ({ r, c }))
).flat();
// ── cell rects ───────────────────────────────────────────────────────────
const rects = svg.append("g").attr("class", "cells")
.selectAll("rect")
.data(cells, d => key(d.r, d.c)) // key by position — stable across updates
.join("rect")
.attr("x", d => d.c * CELL + 1)
.attr("y", d => d.r * CELL + 1)
.attr("width", CELL - 2)
.attr("height", CELL - 2)
.attr("rx", 1);
// ── tooltip ───────────────────────────────────────────────────────────────
const tooltip = Object.assign(document.createElement("div"), {});
tooltip.style.cssText = `
position: fixed;
background: #1e293b;
color: #f8fafc;
border: 1px solid #475569;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.8em;
font-family: monospace;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
`;
document.body.appendChild(tooltip);
invalidation.then(() => tooltip.remove());
rects.on("mousemove", function(e, d) {
const k = key(d.r, d.c);
const g = s.gScore[k] ?? "—";
const h = heuristicFn(d.r, d.c, s.end.r, s.end.c, s.heuristic);
const f = s.fScore[k] ?? "—";
tooltip.innerHTML = `g=${g} h=${h} f=${f}`;
tooltip.style.opacity = 1;
tooltip.style.left = (e.clientX + 12) + "px";
tooltip.style.top = (e.clientY - 28) + "px";
});
rects.on("mouseleave", () => { tooltip.style.opacity = 0; });
// ── labels (S / E) ────────────────────────────────────────────────────────
const labels = svg.append("g").attr("class", "labels");
function updateLabels() {
const data = [
{ r: s.start.r, c: s.start.c, text: "S" },
{ r: s.end.r, c: s.end.c, text: "E" },
];
labels.selectAll("text")
.data(data)
.join("text")
.attr("x", d => d.c * CELL + CELL / 2)
.attr("y", d => d.r * CELL + CELL / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("font-size", CELL * 0.38)
.attr("font-family", "monospace")
.attr("font-weight", "bold")
.attr("fill", C.text)
.attr("pointer-events", "none")
.text(d => d.text);
}
// ── score text overlay ───────────────────────────────────────────────────
const scoreG = svg.append("g").attr("class", "scores");
function updateScores() {
scoreG.selectAll("*").remove();
if (!s.showScores) return;
const scored = cells.filter(d => {
const k = key(d.r, d.c);
return s.gScore[k] !== undefined && !s.walls[d.r][d.c]
&& !(d.r === s.start.r && d.c === s.start.c)
&& !(d.r === s.end.r && d.c === s.end.c);
});
// g — top-left
scoreG.selectAll("text.gs")
.data(scored, d => key(d.r, d.c) + "g")
.join("text").attr("class", "gs")
.attr("x", d => d.c * CELL + 3).attr("y", d => d.r * CELL + 10)
.attr("text-anchor", "start").attr("font-size", 9)
.attr("font-family", "monospace").attr("fill", C.scoreText)
.attr("pointer-events", "none")
.text(d => `g:${s.gScore[key(d.r, d.c)]}`);
// h — top-right
scoreG.selectAll("text.hs")
.data(scored, d => key(d.r, d.c) + "h")
.join("text").attr("class", "hs")
.attr("x", d => d.c * CELL + CELL - 3).attr("y", d => d.r * CELL + 10)
.attr("text-anchor", "end").attr("font-size", 9)
.attr("font-family", "monospace").attr("fill", C.scoreText)
.attr("pointer-events", "none")
.text(d => `h:${heuristicFn(d.r, d.c, s.end.r, s.end.c, s.heuristic)}`);
// f — bottom-centre, bold
scoreG.selectAll("text.fs")
.data(scored, d => key(d.r, d.c) + "f")
.join("text").attr("class", "fs")
.attr("x", d => d.c * CELL + CELL / 2).attr("y", d => d.r * CELL + CELL - 5)
.attr("text-anchor", "middle").attr("font-size", 10).attr("font-weight", "bold")
.attr("font-family", "monospace").attr("fill", C.scoreText)
.attr("pointer-events", "none")
.text(d => { const f = s.fScore[key(d.r, d.c)]; return f !== undefined ? `f:${Number.isInteger(f) ? f : f.toFixed(1)}` : ""; });
}
// ── grid lines ────────────────────────────────────────────────────────────
const gridG = svg.append("g").attr("class", "grid")
.attr("stroke", C.grid)
.attr("stroke-width", 0.4);
for (let r = 0; r <= ROWS; r++)
gridG.append("line").attr("x1", 0).attr("y1", r*CELL).attr("x2", W).attr("y2", r*CELL);
for (let c = 0; c <= COLS; c++)
gridG.append("line").attr("x1", c*CELL).attr("y1", 0).attr("x2", c*CELL).attr("y2", H);
// ── draw — only updates fill, no DOM rebuild ──────────────────────────────
const iterDisplay = document.createElement("div");
iterDisplay.style.cssText = "margin-top:6px;font-size:0.85em";
iterDisplay.textContent = "iterations: —";
function cellColor(d) {
const k = key(d.r, d.c);
const isStart = d.r === s.start.r && d.c === s.start.c;
const isEnd = d.r === s.end.r && d.c === s.end.c;
const isWall = s.walls[d.r][d.c];
const isPath = pathSet.has(k) && !isStart && !isEnd;
const isCurrent = k === s.current && !isStart && !isEnd;
const isOpen = openKeys.has(k) && !isStart && !isEnd;
const isClosed = s.closedSet.has(k) && !isStart && !isEnd;
return isWall ? C.wall : isStart ? C.start : isEnd ? C.end :
isPath ? C.path : isCurrent ? C.current : isOpen ? C.open : isClosed ? C.closed : C.empty;
}
// these are updated before every draw call
let openKeys = new Set(), pathSet = new Set();
function draw() {
openKeys = new Set(s.openSet.map(n => key(n.r, n.c)));
pathSet = new Set(s.path);
// D3 only touches the fill attribute — no DOM nodes created or destroyed
rects.attr("fill", d => cellColor(d));
updateLabels();
updateScores();
iterDisplay.textContent = s.done
? `iterations: ${s.iter} · ${s.status}`
: s.iter > 0 ? `iterations: ${s.iter}…` : "iterations: —";
}
// ── pointer / paint / drag ────────────────────────────────────────────────
let painting = false, paintValue = true, dragging = null;
function cellAt(e) {
const svgEl = svg.node();
const rect = svgEl.getBoundingClientRect();
const scaleX = W / rect.width;
const scaleY = H / rect.height;
return {
r: Math.floor((e.clientY - rect.top) * scaleY / CELL),
c: Math.floor((e.clientX - rect.left) * scaleX / CELL),
};
}
function applyEdit(r, c) {
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
if ((r === s.start.r && c === s.start.c) || (r === s.end.r && c === s.end.c)) return;
if (s.walls[r][c] === paintValue) return;
s.walls[r][c] = paintValue;
resetSearch(s); draw();
}
svg.on("pointerdown", e => {
stopAnim(); svg.node().setPointerCapture(e.pointerId);
const { r, c } = cellAt(e);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
if (r === s.start.r && c === s.start.c) { dragging = "start"; return; }
if (r === s.end.r && c === s.end.c) { dragging = "end"; return; }
painting = true;
paintValue = !s.walls[r][c]; applyEdit(r, c);
});
svg.on("pointermove", e => {
const { r, c } = cellAt(e);
if (dragging) {
if (r < 0 || r >= ROWS || c < 0 || c >= COLS || s.walls[r][c]) return;
if (dragging === "start") s.start = { r, c };
else s.end = { r, c };
resetSearch(s); draw();
} else if (painting) {
applyEdit(r, c);
}
});
svg.on("pointerup", () => { painting = false; dragging = null; });
// ── animation ─────────────────────────────────────────────────────────────
function stopAnim() {
s.animating = false;
if (s.animTimer) { clearTimeout(s.animTimer); s.animTimer = null; }
btnAnimate.textContent = "▶ Animate";
}
function animTick() {
if (!s.animating) return;
if (!s.running && !s.done) initSearch(s);
stepOnce(s); draw();
if (!s.done) s.animTimer = setTimeout(animTick, s.speed);
else stopAnim();
}
function startAnim() {
if (s.done) resetSearch(s);
s.animating = true;
btnAnimate.textContent = "⏸ Pause";
animTick();
}
// ── buttons ───────────────────────────────────────────────────────────────
const btnAnimate = Object.assign(document.createElement("button"), { textContent: "▶ Animate" });
const btnStep = Object.assign(document.createElement("button"), { textContent: "Step" });
const btnRun = Object.assign(document.createElement("button"), { textContent: "Run" });
const btnReset = Object.assign(document.createElement("button"), { textContent: "Reset" });
const btnClear = Object.assign(document.createElement("button"), { textContent: "Clear walls" });
const btnMaze = Object.assign(document.createElement("button"), { textContent: "New maze" });
const chkScores = Object.assign(document.createElement("input"), { type: "checkbox", id: "chk-scores" });
const lblScores = Object.assign(document.createElement("label"), { textContent: "Show g/h/f", htmlFor: "chk-scores" });
lblScores.style.cssText = "font-size:0.85em;cursor:pointer;user-select:none";
btnAnimate.onclick = () => s.animating ? stopAnim() : startAnim();
btnStep.onclick = () => {
stopAnim();
if (!s.running && !s.done) initSearch(s);
stepOnce(s); draw();
};
btnRun.onclick = () => {
stopAnim();
if (!s.running && !s.done) initSearch(s);
while (!s.done) stepOnce(s);
draw();
};
btnReset.onclick = () => { stopAnim(); resetSearch(s); draw(); };
btnClear.onclick = () => {
stopAnim();
s.walls = Array.from({ length: ROWS }, () => new Array(COLS).fill(false));
resetSearch(s); draw();
};
btnMaze.onclick = () => { stopAnim(); generateMaze(s); draw(); };
chkScores.onchange = () => { s.showScores = chkScores.checked; draw(); };
const btnRow = document.createElement("div");
btnRow.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center";
const scoreWrap = document.createElement("span");
scoreWrap.style.cssText = "display:flex;align-items:center;gap:4px;margin-left:8px";
scoreWrap.append(chkScores, lblScores);
btnRow.append(btnAnimate, btnStep, btnRun, btnReset, btnClear, btnMaze, scoreWrap);
const root = document.createElement("div");
root.append(svg.node(), btnRow, iterDisplay);
draw();
return { root, draw };
}