GarbageBot / frontend /script.js
Mihir Mithani
deploy: final submission with pure logging and port fix
ac2a7d9
raw
history blame
22.3 kB
/* ═══════════════════════════════════════════════════════
GarbageBot — Continuous-World Dashboard Logic
Policy chain: Fine-tuned LLM → Q-table → BFS fallback
Fix applied:
- API_BASE was hardcoded to "http://localhost:7861" which breaks on any
hosted environment (HuggingFace Spaces, cloud VMs, etc.).
Now uses a relative empty string "" so every fetch goes to the same
origin that served the page — works locally and in production without
any code change.
═══════════════════════════════════════════════════════ */
// FIX: use relative paths ("") instead of hardcoded "http://localhost:7861"
// so the dashboard works on HuggingFace Spaces and any other host automatically.
const API_BASE = "";
// ── DOM ───────────────────────────────────────────────────
const statusDot = document.getElementById("status-dot");
const statusLabel = document.getElementById("status-label");
const policyBadge = document.getElementById("policy-badge");
const policyLabel = document.getElementById("policy-label");
const taskSelect = document.getElementById("task-select");
const speedSlider = document.getElementById("speed-slider");
const speedVal = document.getElementById("speed-val");
const resetBtn = document.getElementById("reset-btn");
const autoBtn = document.getElementById("auto-btn");
const manualBtn = document.getElementById("manual-btn");
const clearLogBtn = document.getElementById("clear-log");
const envGrid = document.getElementById("env-grid");
const particleLayer = document.getElementById("particle-layer");
const batteryProgress = document.getElementById("battery-progress");
const batteryText = document.getElementById("battery-text");
const scoreText = document.getElementById("score-text");
const inventoryText = document.getElementById("inventory-text");
const stepCounter = document.getElementById("step-counter");
const episodeScoreChip = document.getElementById("episode-score-chip");
const logFeed = document.getElementById("log-feed");
const rewardCanvas = document.getElementById("reward-chart");
const modePill = document.getElementById("mode-pill");
const modeLabel = document.getElementById("mode-label");
const storageProgress = document.getElementById("storage-progress");
const storageText = document.getElementById("storage-text");
// ── State ─────────────────────────────────────────────────
let autoMode = false;
let autoTimer = null;
let currentState = null;
let robotEntity = null;
let stepCount = 0;
let totalReward = 0;
let rewardHistory = [];
let maxBattery = 30;
let stepDelay = 500;
let lastMode = "normal";
// World dimensions (set on reset)
let WORLD_W = 5, WORLD_H = 5;
const CELL = 52; // must match CSS --cell
// ── Speed slider ──────────────────────────────────────────
speedSlider.addEventListener("input", () => {
stepDelay = parseInt(speedSlider.value);
speedVal.textContent = `${stepDelay}ms`;
const pct = ((stepDelay - 100) / 1400) * 100;
speedSlider.style.background = `linear-gradient(90deg, var(--blue) ${pct}%, rgba(255,255,255,.15) ${pct}%)`;
syncRobotTransition();
if (autoMode) { clearInterval(autoTimer); autoTimer = setInterval(stepEnv, stepDelay); }
});
function syncRobotTransition() {
if (!robotEntity) return;
envGrid.style.setProperty("--move-dur", `${stepDelay}ms`);
}
// ── Log helpers ───────────────────────────────────────────
function addLog(msg, source = "sys") {
const ph = logFeed.querySelector(".placeholder");
if (ph) ph.remove();
const entry = document.createElement("div");
entry.className = "log-entry";
const badge = document.createElement("span");
badge.className = `log-badge ${source === "q_table" ? "q-table" : source}`;
badge.textContent = source.replace("_","-").toUpperCase();
const text = document.createElement("span");
text.textContent = msg;
entry.append(badge, text);
logFeed.prepend(entry);
while (logFeed.children.length > 65) logFeed.removeChild(logFeed.lastChild);
}
clearLogBtn.addEventListener("click", () => {
logFeed.innerHTML = `<p class="placeholder">Log cleared…</p>`;
});
// ── Mini reward chart ─────────────────────────────────────
function drawChart() {
const ctx = rewardCanvas.getContext("2d");
const W = rewardCanvas.width, H = rewardCanvas.height;
ctx.clearRect(0, 0, W, H);
if (rewardHistory.length < 2) return;
const maxR = Math.max(...rewardHistory.map(Math.abs), .1);
const step = W / (rewardHistory.length - 1);
const pts = rewardHistory.map((v, i) => [i * step, H - ((v + maxR) / (2 * maxR)) * H]);
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, "rgba(59,158,255,.5)");
grad.addColorStop(1, "rgba(59,158,255,0)");
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.lineTo(pts[pts.length-1][0], H);
ctx.lineTo(0, H); ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.strokeStyle = "#3b9eff"; ctx.lineWidth = 2;
ctx.lineJoin = "round"; ctx.stroke();
const [lx, ly] = pts[pts.length-1];
ctx.beginPath(); ctx.arc(lx, ly, 3.5, 0, Math.PI*2);
ctx.fillStyle = "#a5c8ff"; ctx.fill();
}
// ── Particles ─────────────────────────────────────────────
function spawnParticles(px, py) {
const colors = ["#c084fc","#818cf8","#3b9eff","#2dd4bf","#fbbf24"];
for (let i = 0; i < 14; i++) {
const p = document.createElement("div");
p.className = "particle";
const angle = (i / 14) * Math.PI * 2;
const dist = 28 + Math.random() * 42;
const size = 4 + Math.random() * 6;
p.style.cssText = `
left:${px}px; top:${py}px;
width:${size}px; height:${size}px;
background:${colors[i % colors.length]};
box-shadow:0 0 6px ${colors[i%colors.length]};
--tx:translate(${Math.cos(angle)*dist}px,${Math.sin(angle)*dist}px);
`;
particleLayer.appendChild(p);
setTimeout(() => p.remove(), 780);
}
}
// ── Trail ghost ───────────────────────────────────────────
function addTrail(left, top) {
const g = document.createElement("div");
g.className = "trail-ghost";
g.style.left = `${left}px`;
g.style.top = `${top}px`;
envGrid.appendChild(g);
setTimeout(() => g.remove(), 1100);
}
// ── World coordinates ─────────────────────────────────────
function wx(x) { return x * CELL; }
function wy(y, H) { return (H - 1 - y) * CELL; }
// ── Direction → emoji ─────────────────────────────────────
const DIR_EMOJI = { UP:"🤖", DOWN:"🤖", LEFT:"🤖", RIGHT:"🤖", COLLECT:"🤖" };
// ── Grid render ───────────────────────────────────────────
function renderGrid(obs, isReset = false) {
const [W, H] = obs.grid_size;
WORLD_W = W; WORLD_H = H;
const worldPx = W * CELL;
const worldPy = H * CELL;
if (isReset) {
envGrid.innerHTML = "";
envGrid.style.width = `${worldPx}px`;
envGrid.style.height = `${worldPy}px`;
envGrid.style.gridTemplateColumns = `repeat(${W}, ${CELL}px)`;
envGrid.style.gridTemplateRows = `repeat(${H}, ${CELL}px)`;
envGrid.style.backgroundSize = `${CELL}px ${CELL}px, ${CELL}px ${CELL}px, 100% 100%`;
// Transparent click-target cells
for (let y = H - 1; y >= 0; y--) {
for (let x = 0; x < W; x++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.dataset.x = x; cell.dataset.y = y;
cell.addEventListener("click", () => toggleGarbage(x, y));
envGrid.appendChild(cell);
}
}
// 3D obstacle walls
obs.obstacle_positions.forEach(([x, y]) => {
const el = document.createElement("div");
el.className = "world-obstacle";
el.style.left = `${wx(x)}px`;
el.style.top = `${wy(y, H)}px`;
el.style.width = `${CELL}px`;
el.style.height = `${CELL}px`;
envGrid.appendChild(el);
});
// Robot entity
robotEntity = document.createElement("div");
robotEntity.className = "robot-entity";
robotEntity.textContent = "🤖";
robotEntity.style.width = `${CELL}px`;
robotEntity.style.height = `${CELL}px`;
robotEntity.style.left = `${wx(obs.robot_position[0])}px`;
robotEntity.style.top = `${wy(obs.robot_position[1], H)}px`;
envGrid.appendChild(robotEntity);
// ⚡ Home Station
if (obs.home_position) {
const home = document.createElement("div");
home.className = "world-home";
home.style.left = `${wx(obs.home_position[0])}px`;
home.style.top = `${wy(obs.home_position[1], H)}px`;
envGrid.appendChild(home);
}
// 📦 Unload Station
if (obs.unload_station) {
const unload = document.createElement("div");
unload.className = "world-unload";
unload.style.left = `${wx(obs.unload_station[0])}px`;
unload.style.top = `${wy(obs.unload_station[1], H)}px`;
envGrid.appendChild(unload);
}
// Particle layer on top
const pl = document.createElement("div");
pl.id = "particle-layer";
pl.className = "particle-layer";
envGrid.appendChild(pl);
syncRobotTransition();
}
// Continuous robot move
if (robotEntity) {
const nl = wx(obs.robot_position[0]);
const nt = wy(obs.robot_position[1], H);
robotEntity.style.left = `${nl}px`;
robotEntity.style.top = `${nt}px`;
}
// Re-render garbage
document.querySelectorAll(".world-garbage").forEach(g => g.remove());
obs.garbage_positions.forEach(([x, y]) => {
const el = document.createElement("div");
el.className = "world-garbage";
el.style.left = `${wx(x)}px`;
el.style.top = `${wy(y, H)}px`;
el.style.width = `${CELL}px`;
el.style.height = `${CELL}px`;
el.innerHTML = `<span>🗑️</span>`;
el.addEventListener("click", () => toggleGarbage(x, y));
envGrid.appendChild(el);
});
addLog(obs.message, "sys");
}
// ── Telemetry ─────────────────────────────────────────────
function updateTelemetry(obs, reward, done) {
if (obs.battery_level > maxBattery) maxBattery = obs.battery_level;
const pct = Math.max(0, (obs.battery_level / maxBattery) * 100);
batteryProgress.style.width = `${pct}%`;
batteryText.textContent = `${obs.battery_level} / ${maxBattery}`;
if (pct > 55) batteryProgress.style.background = "#34d399";
else if (pct > 25) batteryProgress.style.background = "#fbbf24";
else batteryProgress.style.background = "#fb7185";
// Storage update
if (obs.storage_capacity) {
const sPct = (obs.current_storage_load / obs.storage_capacity) * 100;
storageProgress.style.width = `${sPct}%`;
storageProgress.style.background = sPct >= 100 ? "#f59e0b" : "#60a5fa";
storageText.textContent = `${obs.current_storage_load} / ${obs.storage_capacity}`;
}
// Inventory (total collected)
if (inventoryText) {
inventoryText.textContent = obs.inventory_count ?? 0;
}
// Mode updates
const mode = obs.robot_mode || "normal";
if (mode !== lastMode) {
addLog(`Robot mode changed to: ${mode.toUpperCase()}`, "sys");
lastMode = mode;
}
modeLabel.textContent = mode.toUpperCase();
modePill.classList.remove("normal", "recharging", "unloading");
modePill.classList.add(mode);
if (robotEntity) {
robotEntity.classList.remove("recharging", "unloading");
if (mode !== "normal") robotEntity.classList.add(mode);
}
if (reward !== undefined) {
totalReward += reward;
rewardHistory.push(totalReward);
if (rewardHistory.length > 80) rewardHistory.shift();
scoreText.textContent = totalReward.toFixed(2);
episodeScoreChip.textContent = `Score ${totalReward.toFixed(2)}`;
drawChart();
}
stepCounter.textContent = `Step ${stepCount}`;
}
// ── Policy badge ──────────────────────────────────────────
const POLICY_STYLES = {
llm: { color:"#3b9eff", border:"rgba(59,158,255,.6)" },
bfs: { color:"#2dd4bf", border:"rgba(45,212,191,.6)" },
q_table: { color:"#fbbf24", border:"rgba(251,191,36,.6)" },
sys: { color:"#7ea8d8", border:"rgba(126,168,216,.3)" },
};
function showPolicy(source, action) {
const s = POLICY_STYLES[source] || POLICY_STYLES.sys;
policyLabel.textContent = `${source.replace("_","-").toUpperCase()}${action}`;
policyBadge.style.borderColor = s.border;
policyBadge.style.color = s.color;
policyBadge.classList.add("active");
}
// ── BFS fallback ──────────────────────────────────────────
function bfsMove(rPos, target, obstacles, W, H) {
if (rPos[0]===target[0] && rPos[1]===target[1]) return "COLLECT";
const obs = new Set(obstacles.map(([x,y]) => `${x},${y}`));
const dirs = [["RIGHT",1,0],["LEFT",-1,0],["UP",0,1],["DOWN",0,-1]];
const q = [{pos:[...rPos], first:null}];
const vis = new Set([`${rPos[0]},${rPos[1]}`]);
while (q.length) {
const {pos, first} = q.shift();
for (const [name, dx, dy] of dirs) {
const nx = pos[0]+dx, ny = pos[1]+dy;
if (nx<0||nx>=W||ny<0||ny>=H) continue;
const key = `${nx},${ny}`;
if (obs.has(key)||vis.has(key)) continue;
const move = first||name;
if (nx===target[0]&&ny===target[1]) return move;
vis.add(key); q.push({pos:[nx,ny], first:move});
}
}
return null;
}
function nnOrder(start, targets, obstacles, W, H) {
function dist(a, b) {
if (a[0]===b[0]&&a[1]===b[1]) return 0;
const obs=new Set(obstacles.map(([x,y])=>`${x},${y}`));
const dirs=[[1,0],[-1,0],[0,1],[0,-1]];
const q=[{pos:[...a],d:0}];const vis=new Set([`${a[0]},${a[1]}`]);
while(q.length){const{pos,d}=q.shift();for(const[dx,dy]of dirs){const nx=pos[0]+dx,ny=pos[1]+dy;if(nx<0||nx>=W||ny<0||ny>=H)continue;const k=`${nx},${ny}`;if(obs.has(k)||vis.has(k))continue;if(nx===b[0]&&ny===b[1])return d+1;vis.add(k);q.push({pos:[nx,ny],d:d+1});}}
return Infinity;
}
let rem=[...targets],cur=[...start],ord=[];
while(rem.length){
let best=rem[0],bD=dist(cur,best);
for(const t of rem){const d=dist(cur,t);if(d<bD){bD=d;best=t;}}
ord.push(best);
rem=rem.filter(t=>!(t[0]===best[0]&&t[1]===best[1]));
cur=[...best];
}
return ord;
}
function localFallback(obs) {
if (!obs.garbage_positions.length) return "UP";
const r = obs.robot_position;
if (obs.garbage_positions.some(([x,y]) => x===r[0]&&y===r[1])) return "COLLECT";
const ordered = nnOrder(r, obs.garbage_positions, obs.obstacle_positions, obs.grid_size[0], obs.grid_size[1]);
return bfsMove(r, ordered[0], obs.obstacle_positions, obs.grid_size[0], obs.grid_size[1]) || "RIGHT";
}
// ── Custom garbage toggle ─────────────────────────────────
async function toggleGarbage(x, y) {
if (!currentState || autoMode) return;
if (currentState.obstacle_positions.some(([ox,oy]) => ox===x&&oy===y)) return;
if (currentState.robot_position[0]===x && currentState.robot_position[1]===y) return;
const has = currentState.garbage_positions.some(([gx,gy]) => gx===x&&gy===y);
const next = has
? currentState.garbage_positions.filter(([gx,gy]) => !(gx===x&&gy===y))
: [...currentState.garbage_positions, [x, y]];
try {
const res = await fetch(`${API_BASE}/configure`, {
method: "POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({task_id: taskSelect.value, garbage_positions: next})
});
const data = await res.json();
currentState = data.observation;
renderGrid(currentState);
addLog(`Garbage ${has?"removed":"placed"} at (${x},${y}) · ${next.length} remaining`, "sys");
} catch (e) { addLog(`Config error: ${e.message}`, "sys"); }
}
// ── Reset ─────────────────────────────────────────────────
async function resetEnv() {
if (autoMode) toggleAutoMode();
stepCount=0; totalReward=0; rewardHistory=[];
scoreText.textContent = "0.00";
episodeScoreChip.textContent = "Score 0.00";
stepCounter.textContent = "Step 0";
policyLabel.textContent = "–";
drawChart();
try {
const res = await fetch(`${API_BASE}/reset`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({task_id: taskSelect.value})
});
const data = await res.json();
currentState = data.observation;
maxBattery = currentState.battery_level;
logFeed.innerHTML = "";
renderGrid(currentState, true);
updateTelemetry(currentState);
statusDot.className = "pulse-dot online";
statusLabel.textContent = "Connected";
} catch (e) {
statusDot.className = "pulse-dot";
statusLabel.textContent = "Offline";
addLog(`Cannot reach server — is app.py running?`, "sys");
}
}
// ── Single step ───────────────────────────────────────────
async function stepEnv() {
if (!currentState) return;
stepCount++;
// 1. Policy endpoint (LLM / Q-table on server)
let action = null, source = "bfs";
try {
const pr = await fetch(`${API_BASE}/policy`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({message: currentState.message})
});
if (pr.ok) { const pd = await pr.json(); action=pd.action; source=pd.source||"llm"; }
} catch (_) {}
// 2. Local BFS fallback
if (!action) { action = localFallback(currentState); source = "bfs"; }
showPolicy(source, action);
// 3. Execute
try {
const res = await fetch(`${API_BASE}/step`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({command: action})
});
const data = await res.json();
const wasCollect = action === "COLLECT";
currentState = data.observation;
renderGrid(currentState);
updateTelemetry(currentState, data.reward, data.done);
// Collect animation + particles
if (wasCollect && robotEntity) {
robotEntity.classList.add("collecting");
setTimeout(() => robotEntity.classList.remove("collecting"), 440);
const cx = parseInt(robotEntity.style.left) + CELL/2;
const cy = parseInt(robotEntity.style.top) + CELL/2;
spawnParticles(cx, cy);
}
const sign = data.reward >= 0 ? "+" : "";
addLog(`${action} · ${sign}${data.reward.toFixed(2)}`, source);
if (data.done) {
addLog(`🏁 Episode complete · total ${totalReward.toFixed(2)}`, "sys");
if (autoMode) toggleAutoMode();
}
} catch (e) {
addLog(`Step error: ${e.message}`, "sys");
if (autoMode) toggleAutoMode();
}
}
// ── Auto mode ─────────────────────────────────────────────
function toggleAutoMode() {
autoMode = !autoMode;
if (autoMode) {
autoBtn.textContent = "⏹ Stop";
autoBtn.className = "btn stop";
autoTimer = setInterval(stepEnv, stepDelay);
} else {
autoBtn.textContent = "▶ Run Policy";
autoBtn.className = "btn primary";
clearInterval(autoTimer);
}
}
// ── Event listeners ───────────────────────────────────────
resetBtn .addEventListener("click", resetEnv);
autoBtn .addEventListener("click", toggleAutoMode);
manualBtn.addEventListener("click", stepEnv);
taskSelect.addEventListener("change", resetEnv);
// ── Boot ──────────────────────────────────────────────────
resetEnv();