The robot (blue dot) follows the gradient of the combined potential: the goal (green star) attracts it and the red obstacles repel it. Click anywhere on the grid to toggle an obstacle, then press Go and watch what happens.
<!-- Potential field navigation demo — robot follows gradient toward goal, avoids obstacles -->
<div class="toolbar">
<span class="legend"><span class="dot robot"></span> Robot</span>
<span class="legend"><span class="dot goal"></span> Goal</span>
<span class="legend"><span class="dot obs"></span> Obstacle</span>
<span class="hint-inline">Click grid to toggle obstacles</span>
</div>
<canvas id="canvas" width="340" height="280" title="Potential field navigation canvas — click to add or remove obstacles"></canvas>
<div class="status" id="status">Click Go to start, or click the grid to add/remove obstacles.</div>
<div class="btns">
<button id="btn-go" type="button">Go</button>
<button id="btn-escape" type="button">Escape local min</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Potential fields demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.toolbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .4rem; font-size: .85rem; }
.legend { display: flex; align-items: center; gap: .3rem; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
.dot.robot { background: #1d6fa4; }
.dot.goal { background: #0a7d33; }
.dot.obs { background: #c92f3c; }
.hint-inline { color: #555; font-style: italic; font-size: .8rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .45rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d6fa4; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Grid dimensions and cell size
const COLS = 17, ROWS = 14;
const CW = 340 / COLS, CH = 280 / ROWS;
// Simulation state — robot position, obstacles, running flag
const obstacles = new Set();
let robotPos = { x: 1, y: 1 };
const GOAL = { x: 15, y: 12 };
let running = false;
let escaping = false;
let stepTimer = null;
let stuckCount = 0;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Attractive potential: unit vector pointing from robot to goal
function attractive(rx, ry) {
const dx = GOAL.x - rx, dy = GOAL.y - ry;
const d = Math.sqrt(dx * dx + dy * dy) || 0.001;
return { fx: dx / d, fy: dy / d };
}
// Repulsive potential: sum of inverse-square pushes from nearby obstacles
const REPS_RADIUS = 3.0;
const REPS_K = 4.0;
function repulsive(rx, ry) {
let fx = 0, fy = 0;
for (const key of obstacles) {
const [ox, oy] = key.split(',').map(Number);
const dx = rx - ox, dy = ry - oy;
const d = Math.sqrt(dx * dx + dy * dy) || 0.001;
if (d < REPS_RADIUS) {
const mag = REPS_K * (1 / d - 1 / REPS_RADIUS) / (d * d);
fx += mag * (dx / d);
fy += mag * (dy / d);
}
}
return { fx, fy };
}
// Total force = attractive + repulsive (gradient of combined potential)
function totalForce(rx, ry) {
const a = attractive(rx, ry);
const r = repulsive(rx, ry);
return { fx: a.fx + r.fx, fy: a.fy + r.fy };
}
function cellKey(x, y) { return x + ',' + y; }
// Render the canvas — grid, force arrows, obstacles, goal star, robot
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background grid lines
ctx.strokeStyle = '#e0e6ea';
ctx.lineWidth = 0.5;
for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c * CW, 0); ctx.lineTo(c * CW, canvas.height); ctx.stroke(); }
for (let r = 0; r <= ROWS; r++) { ctx.beginPath(); ctx.moveTo(0, r * CH); ctx.lineTo(canvas.width, r * CH); ctx.stroke(); }
// Draw gradient arrows (sample every cell)
for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
const key = cellKey(c, r);
if (obstacles.has(key)) continue;
const { fx, fy } = totalForce(c + 0.5, r + 0.5);
const mag = Math.sqrt(fx * fx + fy * fy);
if (mag < 0.05) continue;
const nx = fx / mag, ny = fy / mag;
const cx2 = (c + 0.5) * CW, cy2 = (r + 0.5) * CH;
const len = Math.min(CW, CH) * 0.38;
ctx.strokeStyle = 'rgba(90,112,136,0.35)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(cx2 - nx * len * 0.4, cy2 - ny * len * 0.4);
ctx.lineTo(cx2 + nx * len * 0.6, cy2 + ny * len * 0.6);
ctx.stroke();
}
// Draw obstacles as red filled cells
ctx.fillStyle = '#c92f3c';
for (const key of obstacles) {
const [ox, oy] = key.split(',').map(Number);
ctx.fillRect(ox * CW + 1, oy * CH + 1, CW - 2, CH - 2);
}
// Draw goal as green star
const gx = (GOAL.x + 0.5) * CW, gy = (GOAL.y + 0.5) * CH;
ctx.fillStyle = '#0a7d33';
ctx.font = `bold ${Math.round(CW * 1.1)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', gx, gy);
// Draw robot as blue (or orange while escaping) filled circle
const rx2 = (robotPos.x + 0.5) * CW, ry2 = (robotPos.y + 0.5) * CH;
ctx.beginPath();
ctx.arc(rx2, ry2, Math.min(CW, CH) * 0.36, 0, Math.PI * 2);
ctx.fillStyle = escaping ? '#e09000' : '#1d6fa4';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Simulation step — move robot one cell in the direction of the net force
function step() {
if (!running) return;
const rx = robotPos.x, ry = robotPos.y;
// Check if the robot reached the goal
if (Math.abs(rx - GOAL.x) < 1 && Math.abs(ry - GOAL.y) < 1) {
setStatus('Goal reached! The gradient led the robot home.', 'ok');
running = false; escaping = false;
draw(); return;
}
let moved = false;
if (escaping) {
// Random-walk escape from local minimum (robot turns orange)
const dx = (Math.random() * 2 - 1) * 1.2;
const dy = (Math.random() * 2 - 1) * 1.2;
const nx2 = Math.max(0, Math.min(COLS - 1, Math.round(rx + dx)));
const ny2 = Math.max(0, Math.min(ROWS - 1, Math.round(ry + dy)));
if (!obstacles.has(cellKey(nx2, ny2))) { robotPos = { x: nx2, y: ny2 }; moved = true; }
stuckCount++;
if (stuckCount > 8) { escaping = false; stuckCount = 0; setStatus('Resuming gradient descent after escape.', 'info'); }
} else {
// Gradient-descent step — move one cell in the direction of the net force
const { fx, fy } = totalForce(rx + 0.5, ry + 0.5);
const mag = Math.sqrt(fx * fx + fy * fy);
if (mag < 0.08) {
stuckCount++;
if (stuckCount > 3) { setStatus('Stuck in a local minimum! Press Escape local min to get out.', 'bad'); running = false; draw(); return; }
} else {
stuckCount = 0;
const nx2 = Math.max(0, Math.min(COLS - 1, Math.round(rx + fx / mag)));
const ny2 = Math.max(0, Math.min(ROWS - 1, Math.round(ry + fy / mag)));
if (!obstacles.has(cellKey(nx2, ny2)) && (nx2 !== rx || ny2 !== ry)) {
robotPos = { x: nx2, y: ny2 }; moved = true; stuckCount = 0;
} else {
stuckCount++;
if (stuckCount > 3) { setStatus('Stuck in a local minimum! Press Escape local min to get out.', 'bad'); running = false; draw(); return; }
}
}
}
draw();
stepTimer = setTimeout(step, 120);
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
// Toggle obstacle on click (not while running)
canvas.addEventListener('click', e => {
if (running) return;
const rect = canvas.getBoundingClientRect();
const col = Math.floor((e.clientX - rect.left) / CW);
const row = Math.floor((e.clientY - rect.top) / CH);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return;
if (col === GOAL.x && row === GOAL.y) return;
if (col === robotPos.x && row === robotPos.y) return;
const key = cellKey(col, row);
obstacles.has(key) ? obstacles.delete(key) : obstacles.add(key);
draw();
});
document.getElementById('btn-go').addEventListener('click', () => {
if (running) return;
running = true; escaping = false; stuckCount = 0;
setStatus('Running — following the gradient…', 'info');
step();
});
document.getElementById('btn-escape').addEventListener('click', () => {
if (!running) { running = true; stuckCount = 0; }
escaping = true; stuckCount = 0;
setStatus('Escaping with random walk… robot turns orange.', 'info');
if (!stepTimer) step();
});
document.getElementById('btn-reset').addEventListener('click', () => {
running = false; escaping = false; stuckCount = 0;
if (stepTimer) { clearTimeout(stepTimer); stepTimer = null; }
robotPos = { x: 1, y: 1 };
obstacles.clear();
setStatus('Click Go to start, or click the grid to add/remove obstacles.', '');
draw();
});
// Preset obstacle ring that creates a local minimum to demonstrate the trap
['7,5','7,6','7,7','8,5','8,7','9,5','9,6','9,7'].forEach(k => obstacles.add(k));
draw();
setStatus('Click Go to start, or click the grid to add/remove obstacles.', '');
<!-- Potential field navigation demo — robot follows gradient toward goal, avoids obstacles -->
<div class="toolbar">
<span class="legend"><span class="dot robot"></span> Robot</span>
<span class="legend"><span class="dot goal"></span> Goal</span>
<span class="legend"><span class="dot obs"></span> Obstacle</span>
<span class="hint-inline">Click grid to toggle obstacles</span>
</div>
<canvas id="canvas" width="340" height="280" title="Potential field navigation canvas — click to add or remove obstacles"></canvas>
<div class="status" id="status">Click Go to start, or click the grid to add/remove obstacles.</div>
<div class="btns">
<button id="btn-go" type="button">Go</button>
<button id="btn-escape" type="button">Escape local min</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Potential fields demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.toolbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .4rem; font-size: .85rem; }
.legend { display: flex; align-items: center; gap: .3rem; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
.dot.robot { background: #1d6fa4; }
.dot.goal { background: #0a7d33; }
.dot.obs { background: #c92f3c; }
.hint-inline { color: #555; font-style: italic; font-size: .8rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .45rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d6fa4; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Grid dimensions and cell size
const COLS = 17, ROWS = 14;
const CW = 340 / COLS, CH = 280 / ROWS;
// Simulation state — robot position, obstacles, running flag
const obstacles = new Set();
let robotPos = { x: 1, y: 1 };
const GOAL = { x: 15, y: 12 };
let running = false;
let escaping = false;
let stepTimer = null;
let stuckCount = 0;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Attractive potential: unit vector pointing from robot to goal
function attractive(rx, ry) {
const dx = GOAL.x - rx, dy = GOAL.y - ry;
const d = Math.sqrt(dx * dx + dy * dy) || 0.001;
return { fx: dx / d, fy: dy / d };
}
// Repulsive potential: sum of inverse-square pushes from nearby obstacles
const REPS_RADIUS = 3.0;
const REPS_K = 4.0;
function repulsive(rx, ry) {
let fx = 0, fy = 0;
for (const key of obstacles) {
const [ox, oy] = key.split(',').map(Number);
const dx = rx - ox, dy = ry - oy;
const d = Math.sqrt(dx * dx + dy * dy) || 0.001;
if (d < REPS_RADIUS) {
const mag = REPS_K * (1 / d - 1 / REPS_RADIUS) / (d * d);
fx += mag * (dx / d);
fy += mag * (dy / d);
}
}
return { fx, fy };
}
// Total force = attractive + repulsive (gradient of combined potential)
function totalForce(rx, ry) {
const a = attractive(rx, ry);
const r = repulsive(rx, ry);
return { fx: a.fx + r.fx, fy: a.fy + r.fy };
}
function cellKey(x, y) { return x + ',' + y; }
// Render the canvas — grid, force arrows, obstacles, goal star, robot
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background grid lines
ctx.strokeStyle = '#e0e6ea';
ctx.lineWidth = 0.5;
for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c * CW, 0); ctx.lineTo(c * CW, canvas.height); ctx.stroke(); }
for (let r = 0; r <= ROWS; r++) { ctx.beginPath(); ctx.moveTo(0, r * CH); ctx.lineTo(canvas.width, r * CH); ctx.stroke(); }
// Draw gradient arrows (sample every cell)
for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
const key = cellKey(c, r);
if (obstacles.has(key)) continue;
const { fx, fy } = totalForce(c + 0.5, r + 0.5);
const mag = Math.sqrt(fx * fx + fy * fy);
if (mag < 0.05) continue;
const nx = fx / mag, ny = fy / mag;
const cx2 = (c + 0.5) * CW, cy2 = (r + 0.5) * CH;
const len = Math.min(CW, CH) * 0.38;
ctx.strokeStyle = 'rgba(90,112,136,0.35)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(cx2 - nx * len * 0.4, cy2 - ny * len * 0.4);
ctx.lineTo(cx2 + nx * len * 0.6, cy2 + ny * len * 0.6);
ctx.stroke();
}
// Draw obstacles as red filled cells
ctx.fillStyle = '#c92f3c';
for (const key of obstacles) {
const [ox, oy] = key.split(',').map(Number);
ctx.fillRect(ox * CW + 1, oy * CH + 1, CW - 2, CH - 2);
}
// Draw goal as green star
const gx = (GOAL.x + 0.5) * CW, gy = (GOAL.y + 0.5) * CH;
ctx.fillStyle = '#0a7d33';
ctx.font = `bold ${Math.round(CW * 1.1)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', gx, gy);
// Draw robot as blue (or orange while escaping) filled circle
const rx2 = (robotPos.x + 0.5) * CW, ry2 = (robotPos.y + 0.5) * CH;
ctx.beginPath();
ctx.arc(rx2, ry2, Math.min(CW, CH) * 0.36, 0, Math.PI * 2);
ctx.fillStyle = escaping ? '#e09000' : '#1d6fa4';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Simulation step — move robot one cell in the direction of the net force
function step() {
if (!running) return;
const rx = robotPos.x, ry = robotPos.y;
// Check if the robot reached the goal
if (Math.abs(rx - GOAL.x) < 1 && Math.abs(ry - GOAL.y) < 1) {
setStatus('Goal reached! The gradient led the robot home.', 'ok');
running = false; escaping = false;
draw(); return;
}
let moved = false;
if (escaping) {
// Random-walk escape from local minimum (robot turns orange)
const dx = (Math.random() * 2 - 1) * 1.2;
const dy = (Math.random() * 2 - 1) * 1.2;
const nx2 = Math.max(0, Math.min(COLS - 1, Math.round(rx + dx)));
const ny2 = Math.max(0, Math.min(ROWS - 1, Math.round(ry + dy)));
if (!obstacles.has(cellKey(nx2, ny2))) { robotPos = { x: nx2, y: ny2 }; moved = true; }
stuckCount++;
if (stuckCount > 8) { escaping = false; stuckCount = 0; setStatus('Resuming gradient descent after escape.', 'info'); }
} else {
// Gradient-descent step — move one cell in the direction of the net force
const { fx, fy } = totalForce(rx + 0.5, ry + 0.5);
const mag = Math.sqrt(fx * fx + fy * fy);
if (mag < 0.08) {
stuckCount++;
if (stuckCount > 3) { setStatus('Stuck in a local minimum! Press Escape local min to get out.', 'bad'); running = false; draw(); return; }
} else {
stuckCount = 0;
const nx2 = Math.max(0, Math.min(COLS - 1, Math.round(rx + fx / mag)));
const ny2 = Math.max(0, Math.min(ROWS - 1, Math.round(ry + fy / mag)));
if (!obstacles.has(cellKey(nx2, ny2)) && (nx2 !== rx || ny2 !== ry)) {
robotPos = { x: nx2, y: ny2 }; moved = true; stuckCount = 0;
} else {
stuckCount++;
if (stuckCount > 3) { setStatus('Stuck in a local minimum! Press Escape local min to get out.', 'bad'); running = false; draw(); return; }
}
}
}
draw();
stepTimer = setTimeout(step, 120);
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
// Toggle obstacle on click (not while running)
canvas.addEventListener('click', e => {
if (running) return;
const rect = canvas.getBoundingClientRect();
const col = Math.floor((e.clientX - rect.left) / CW);
const row = Math.floor((e.clientY - rect.top) / CH);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return;
if (col === GOAL.x && row === GOAL.y) return;
if (col === robotPos.x && row === robotPos.y) return;
const key = cellKey(col, row);
obstacles.has(key) ? obstacles.delete(key) : obstacles.add(key);
draw();
});
document.getElementById('btn-go').addEventListener('click', () => {
if (running) return;
running = true; escaping = false; stuckCount = 0;
setStatus('Running — following the gradient…', 'info');
step();
});
document.getElementById('btn-escape').addEventListener('click', () => {
if (!running) { running = true; stuckCount = 0; }
escaping = true; stuckCount = 0;
setStatus('Escaping with random walk… robot turns orange.', 'info');
if (!stepTimer) step();
});
document.getElementById('btn-reset').addEventListener('click', () => {
running = false; escaping = false; stuckCount = 0;
if (stepTimer) { clearTimeout(stepTimer); stepTimer = null; }
robotPos = { x: 1, y: 1 };
obstacles.clear();
setStatus('Click Go to start, or click the grid to add/remove obstacles.', '');
draw();
});
// Preset obstacle ring that creates a local minimum to demonstrate the trap
['7,5','7,6','7,7','8,5','8,7','9,5','9,6','9,7'].forEach(k => obstacles.add(k));
draw();
setStatus('Click Go to start, or click the grid to add/remove obstacles.', '');
Notice: with obstacles arranged to create a bowl around the robot, the attractive and repulsive forces cancel exactly and the robot freezes. Press Escape local min to inject a random walk — the robot stumbles out of the valley and tries the gradient again. This random-restart trick is the simplest of many real-world escapes, but it has no guarantee: with the wrong landscape the robot may wander forever.
Comments
Loading comments...