Below is a simplified SLAM simulation. A robot (triangle) moves through a room with four landmark beacons (circles). Its sensors measure the distance to each beacon, but with random noise — the readings are never exact.
<!-- SLAM demo — robot navigates a room with 4 landmark beacons, sensor readings are noisy -->
<div class="slam-wrap">
<canvas id="slam-canvas" width="360" height="300"></canvas>
<div class="info-panel">
<div class="info-row"><span class="dot true-dot"></span> True path</div>
<div class="info-row"><span class="dot est-dot"></span> Estimated path</div>
<div class="info-row"><span class="dot lm-dot"></span> Landmark (true ○ / estimated ●)</div>
</div>
<div class="status" id="status">Press Step or Run to start the simulation.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
/* Layout and colors for the SLAM demo canvas and controls */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; }
.slam-wrap { display: flex; flex-direction: column; align-items: flex-start; gap: .5rem; padding: .4rem 0; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #f4f7fa; display: block; }
.info-panel { display: flex; gap: 1rem; font-size: .82rem; color: #555; flex-wrap: wrap; }
.info-row { display: flex; align-items: center; gap: .3rem; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.true-dot { background: #2a9d5c; }
.est-dot { background: #1d6fa0; }
.lm-dot { background: #e07b00; border: 2px solid #e07b00; border-radius: 50%; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; color: #333; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Simplified EKF-style SLAM — motion model + range-only observation update */
/* Simulation constants */
const W = 360, H = 300;
const SCALE = 60; /* pixels per world unit */
const NOISE_MOVE = 0.08; /* motion noise sigma (world units) */
const NOISE_OBS = 0.18; /* observation noise sigma (world units) */
const MAX_STEPS = 20;
/* True landmark positions (never given to the robot directly) */
const LANDMARKS_TRUE = [
{ x: 1.0, y: 1.0 },
{ x: 5.0, y: 1.0 },
{ x: 5.0, y: 4.0 },
{ x: 1.0, y: 4.0 }
];
/* Planned waypoints the robot follows (its true trajectory) */
const WAYPOINTS = [
{ x: 1.5, y: 1.5 }, { x: 3.0, y: 1.5 }, { x: 4.5, y: 1.5 },
{ x: 4.5, y: 2.5 }, { x: 4.5, y: 3.5 }, { x: 3.0, y: 3.5 },
{ x: 1.5, y: 3.5 }, { x: 1.5, y: 2.5 }, { x: 1.5, y: 1.5 },
{ x: 3.0, y: 2.5 }, { x: 4.0, y: 2.0 }, { x: 2.0, y: 2.0 },
{ x: 3.0, y: 3.0 }, { x: 3.0, y: 2.5 }, { x: 2.0, y: 3.0 },
{ x: 4.0, y: 3.0 }, { x: 3.0, y: 2.0 }, { x: 3.0, y: 2.5 },
{ x: 1.8, y: 2.0 }, { x: 3.0, y: 1.8 }
];
/* Box-Muller transform for Gaussian noise */
function randGauss(sigma) {
let u = 0, v = 0;
while (!u) u = Math.random();
while (!v) v = Math.random();
return sigma * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
/* Euclidean distance helper */
function dist(a, b) { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); }
/* Simulation state variables */
let step, truePos, estPos, truePath, estPath, lmEst, running, rafId;
function initState() {
step = 0;
truePos = { x: WAYPOINTS[0].x, y: WAYPOINTS[0].y };
estPos = { x: WAYPOINTS[0].x + randGauss(0.05), y: WAYPOINTS[0].y + randGauss(0.05) };
truePath = [{ ...truePos }];
estPath = [{ ...estPos }];
/* Landmark estimates start with large uncertainty */
lmEst = LANDMARKS_TRUE.map(lm => ({
x: lm.x + randGauss(0.4),
y: lm.y + randGauss(0.4)
}));
running = false;
rafId = null;
}
/* One SLAM step — move then correct from observations */
function slamStep() {
if (step >= MAX_STEPS - 1) { stopRun(); return; }
step++;
const wp = WAYPOINTS[step];
/* Apply motion (true) + add noise to estimate */
const dx = wp.x - truePos.x;
const dy = wp.y - truePos.y;
truePos = { x: wp.x, y: wp.y };
estPos = {
x: estPos.x + dx + randGauss(NOISE_MOVE),
y: estPos.y + dy + randGauss(NOISE_MOVE)
};
truePath.push({ ...truePos });
estPath.push({ ...estPos });
/* Observation update — each landmark reading corrects both pose and map */
/* Range-only update: measured distance corrects landmark position and robot pose */
LANDMARKS_TRUE.forEach((lm, i) => {
const dTrue = dist(truePos, lm);
const measuredDist = dTrue + randGauss(NOISE_OBS);
const dEst = dist(estPos, lmEst[i]);
if (dEst < 0.01) return;
/* simple scalar gain (full EKF would use Kalman gain matrix) */
const gain = 0.3;
const err = measuredDist - dEst;
const nx = (lmEst[i].x - estPos.x) / dEst;
const ny = (lmEst[i].y - estPos.y) / dEst;
lmEst[i].x += gain * err * nx;
lmEst[i].y += gain * err * ny;
estPos.x -= gain * err * nx * 0.4;
estPos.y -= gain * err * ny * 0.4;
});
draw();
const posErr = dist(truePos, estPos).toFixed(2);
document.getElementById('status').textContent =
i18nFmt('Step %n% / %total% — position error: %err% units', { n: step, total: MAX_STEPS - 1, err: posErr });
}
/* Canvas rendering helpers */
function toCanvas(p) { return { x: p.x * SCALE + 20, y: H - (p.y * SCALE + 20) }; }
function draw() {
const canvas = document.getElementById('slam-canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Background grid */
ctx.strokeStyle = '#dde4ec';
ctx.lineWidth = 0.8;
for (let gx = 0; gx <= 6; gx++) {
const cx = gx * SCALE + 20;
ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, H); ctx.stroke();
}
for (let gy = 0; gy <= 5; gy++) {
const cy = H - (gy * SCALE + 20);
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(W, cy); ctx.stroke();
}
/* Draw ground-truth robot path (green solid) */
if (truePath.length > 1) {
ctx.strokeStyle = '#2a9d5c'; ctx.lineWidth = 1.8; ctx.setLineDash([]);
ctx.beginPath();
truePath.forEach((p, i) => {
const c = toCanvas(p);
i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y);
});
ctx.stroke();
}
/* Draw estimated robot path (blue dashed) */
if (estPath.length > 1) {
ctx.strokeStyle = '#1d6fa0'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath();
estPath.forEach((p, i) => {
const c = toCanvas(p);
i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y);
});
ctx.stroke();
ctx.setLineDash([]);
}
/* Draw true landmark positions (orange rings) */
LANDMARKS_TRUE.forEach(lm => {
const c = toCanvas(lm);
ctx.beginPath(); ctx.arc(c.x, c.y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#e07b00'; ctx.lineWidth = 2; ctx.stroke();
});
/* Draw estimated landmark positions (faint orange fill) */
lmEst.forEach(lm => {
const c = toCanvas(lm);
ctx.beginPath(); ctx.arc(c.x, c.y, 4, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(224,123,0,0.35)'; ctx.fill();
});
/* Draw sensor lines from robot to landmarks */
LANDMARKS_TRUE.forEach((lm, i) => {
const ct = toCanvas(truePos);
const cl = toCanvas(lm);
ctx.strokeStyle = 'rgba(42,157,92,0.18)'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(ct.x, ct.y); ctx.lineTo(cl.x, cl.y); ctx.stroke();
const ce = toCanvas(estPos);
const ce2 = toCanvas(lmEst[i]);
ctx.strokeStyle = 'rgba(29,111,160,0.18)';
ctx.beginPath(); ctx.moveTo(ce.x, ce.y); ctx.lineTo(ce2.x, ce2.y); ctx.stroke();
ctx.setLineDash([]);
});
/* Draw true robot as green triangle */
(function drawRobot(p, color) {
const c = toCanvas(p);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(c.x, c.y - 9);
ctx.lineTo(c.x - 6, c.y + 6);
ctx.lineTo(c.x + 6, c.y + 6);
ctx.closePath(); ctx.fill();
})(truePos, '#2a9d5c');
/* Draw estimated robot as blue triangle */
(function drawRobot(p, color) {
const c = toCanvas(p);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(c.x, c.y - 9);
ctx.lineTo(c.x - 6, c.y + 6);
ctx.lineTo(c.x + 6, c.y + 6);
ctx.closePath(); ctx.fill();
})(estPos, '#1d6fa0');
}
/* Run/pause/reset logic */
function stopRun() {
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
document.getElementById('btn-run').textContent = 'Run';
document.getElementById('btn-step').disabled = (step >= MAX_STEPS - 1);
}
function runLoop() {
if (!running) return;
slamStep();
if (step < MAX_STEPS - 1) {
rafId = setTimeout(() => requestAnimationFrame(runLoop), 350);
} else {
stopRun();
document.getElementById('status').textContent =
i18nFmt('Done! Final position error: %err% units', { err: dist(truePos, estPos).toFixed(2) });
}
}
document.getElementById('btn-step').addEventListener('click', () => {
if (running) return;
slamStep();
if (step >= MAX_STEPS - 1)
document.getElementById('btn-step').disabled = true;
});
document.getElementById('btn-run').addEventListener('click', () => {
if (running) { stopRun(); return; }
if (step >= MAX_STEPS - 1) return;
running = true;
document.getElementById('btn-run').textContent = 'Pause';
runLoop();
});
document.getElementById('btn-reset').addEventListener('click', () => {
stopRun();
initState();
draw();
document.getElementById('status').textContent = 'Press Step or Run to start the simulation.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').textContent = 'Run';
});
/* Initialize and draw initial scene */
initState();
draw();
<!-- SLAM demo — robot navigates a room with 4 landmark beacons, sensor readings are noisy -->
<div class="slam-wrap">
<canvas id="slam-canvas" width="360" height="300"></canvas>
<div class="info-panel">
<div class="info-row"><span class="dot true-dot"></span> True path</div>
<div class="info-row"><span class="dot est-dot"></span> Estimated path</div>
<div class="info-row"><span class="dot lm-dot"></span> Landmark (true ○ / estimated ●)</div>
</div>
<div class="status" id="status">Press Step or Run to start the simulation.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
/* Layout and colors for the SLAM demo canvas and controls */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; }
.slam-wrap { display: flex; flex-direction: column; align-items: flex-start; gap: .5rem; padding: .4rem 0; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #f4f7fa; display: block; }
.info-panel { display: flex; gap: 1rem; font-size: .82rem; color: #555; flex-wrap: wrap; }
.info-row { display: flex; align-items: center; gap: .3rem; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.true-dot { background: #2a9d5c; }
.est-dot { background: #1d6fa0; }
.lm-dot { background: #e07b00; border: 2px solid #e07b00; border-radius: 50%; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; color: #333; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Simplified EKF-style SLAM — motion model + range-only observation update */
/* Simulation constants */
const W = 360, H = 300;
const SCALE = 60; /* pixels per world unit */
const NOISE_MOVE = 0.08; /* motion noise sigma (world units) */
const NOISE_OBS = 0.18; /* observation noise sigma (world units) */
const MAX_STEPS = 20;
/* True landmark positions (never given to the robot directly) */
const LANDMARKS_TRUE = [
{ x: 1.0, y: 1.0 },
{ x: 5.0, y: 1.0 },
{ x: 5.0, y: 4.0 },
{ x: 1.0, y: 4.0 }
];
/* Planned waypoints the robot follows (its true trajectory) */
const WAYPOINTS = [
{ x: 1.5, y: 1.5 }, { x: 3.0, y: 1.5 }, { x: 4.5, y: 1.5 },
{ x: 4.5, y: 2.5 }, { x: 4.5, y: 3.5 }, { x: 3.0, y: 3.5 },
{ x: 1.5, y: 3.5 }, { x: 1.5, y: 2.5 }, { x: 1.5, y: 1.5 },
{ x: 3.0, y: 2.5 }, { x: 4.0, y: 2.0 }, { x: 2.0, y: 2.0 },
{ x: 3.0, y: 3.0 }, { x: 3.0, y: 2.5 }, { x: 2.0, y: 3.0 },
{ x: 4.0, y: 3.0 }, { x: 3.0, y: 2.0 }, { x: 3.0, y: 2.5 },
{ x: 1.8, y: 2.0 }, { x: 3.0, y: 1.8 }
];
/* Box-Muller transform for Gaussian noise */
function randGauss(sigma) {
let u = 0, v = 0;
while (!u) u = Math.random();
while (!v) v = Math.random();
return sigma * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
/* Euclidean distance helper */
function dist(a, b) { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); }
/* Simulation state variables */
let step, truePos, estPos, truePath, estPath, lmEst, running, rafId;
function initState() {
step = 0;
truePos = { x: WAYPOINTS[0].x, y: WAYPOINTS[0].y };
estPos = { x: WAYPOINTS[0].x + randGauss(0.05), y: WAYPOINTS[0].y + randGauss(0.05) };
truePath = [{ ...truePos }];
estPath = [{ ...estPos }];
/* Landmark estimates start with large uncertainty */
lmEst = LANDMARKS_TRUE.map(lm => ({
x: lm.x + randGauss(0.4),
y: lm.y + randGauss(0.4)
}));
running = false;
rafId = null;
}
/* One SLAM step — move then correct from observations */
function slamStep() {
if (step >= MAX_STEPS - 1) { stopRun(); return; }
step++;
const wp = WAYPOINTS[step];
/* Apply motion (true) + add noise to estimate */
const dx = wp.x - truePos.x;
const dy = wp.y - truePos.y;
truePos = { x: wp.x, y: wp.y };
estPos = {
x: estPos.x + dx + randGauss(NOISE_MOVE),
y: estPos.y + dy + randGauss(NOISE_MOVE)
};
truePath.push({ ...truePos });
estPath.push({ ...estPos });
/* Observation update — each landmark reading corrects both pose and map */
/* Range-only update: measured distance corrects landmark position and robot pose */
LANDMARKS_TRUE.forEach((lm, i) => {
const dTrue = dist(truePos, lm);
const measuredDist = dTrue + randGauss(NOISE_OBS);
const dEst = dist(estPos, lmEst[i]);
if (dEst < 0.01) return;
/* simple scalar gain (full EKF would use Kalman gain matrix) */
const gain = 0.3;
const err = measuredDist - dEst;
const nx = (lmEst[i].x - estPos.x) / dEst;
const ny = (lmEst[i].y - estPos.y) / dEst;
lmEst[i].x += gain * err * nx;
lmEst[i].y += gain * err * ny;
estPos.x -= gain * err * nx * 0.4;
estPos.y -= gain * err * ny * 0.4;
});
draw();
const posErr = dist(truePos, estPos).toFixed(2);
document.getElementById('status').textContent =
i18nFmt('Step %n% / %total% — position error: %err% units', { n: step, total: MAX_STEPS - 1, err: posErr });
}
/* Canvas rendering helpers */
function toCanvas(p) { return { x: p.x * SCALE + 20, y: H - (p.y * SCALE + 20) }; }
function draw() {
const canvas = document.getElementById('slam-canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Background grid */
ctx.strokeStyle = '#dde4ec';
ctx.lineWidth = 0.8;
for (let gx = 0; gx <= 6; gx++) {
const cx = gx * SCALE + 20;
ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, H); ctx.stroke();
}
for (let gy = 0; gy <= 5; gy++) {
const cy = H - (gy * SCALE + 20);
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(W, cy); ctx.stroke();
}
/* Draw ground-truth robot path (green solid) */
if (truePath.length > 1) {
ctx.strokeStyle = '#2a9d5c'; ctx.lineWidth = 1.8; ctx.setLineDash([]);
ctx.beginPath();
truePath.forEach((p, i) => {
const c = toCanvas(p);
i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y);
});
ctx.stroke();
}
/* Draw estimated robot path (blue dashed) */
if (estPath.length > 1) {
ctx.strokeStyle = '#1d6fa0'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath();
estPath.forEach((p, i) => {
const c = toCanvas(p);
i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y);
});
ctx.stroke();
ctx.setLineDash([]);
}
/* Draw true landmark positions (orange rings) */
LANDMARKS_TRUE.forEach(lm => {
const c = toCanvas(lm);
ctx.beginPath(); ctx.arc(c.x, c.y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#e07b00'; ctx.lineWidth = 2; ctx.stroke();
});
/* Draw estimated landmark positions (faint orange fill) */
lmEst.forEach(lm => {
const c = toCanvas(lm);
ctx.beginPath(); ctx.arc(c.x, c.y, 4, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(224,123,0,0.35)'; ctx.fill();
});
/* Draw sensor lines from robot to landmarks */
LANDMARKS_TRUE.forEach((lm, i) => {
const ct = toCanvas(truePos);
const cl = toCanvas(lm);
ctx.strokeStyle = 'rgba(42,157,92,0.18)'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(ct.x, ct.y); ctx.lineTo(cl.x, cl.y); ctx.stroke();
const ce = toCanvas(estPos);
const ce2 = toCanvas(lmEst[i]);
ctx.strokeStyle = 'rgba(29,111,160,0.18)';
ctx.beginPath(); ctx.moveTo(ce.x, ce.y); ctx.lineTo(ce2.x, ce2.y); ctx.stroke();
ctx.setLineDash([]);
});
/* Draw true robot as green triangle */
(function drawRobot(p, color) {
const c = toCanvas(p);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(c.x, c.y - 9);
ctx.lineTo(c.x - 6, c.y + 6);
ctx.lineTo(c.x + 6, c.y + 6);
ctx.closePath(); ctx.fill();
})(truePos, '#2a9d5c');
/* Draw estimated robot as blue triangle */
(function drawRobot(p, color) {
const c = toCanvas(p);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(c.x, c.y - 9);
ctx.lineTo(c.x - 6, c.y + 6);
ctx.lineTo(c.x + 6, c.y + 6);
ctx.closePath(); ctx.fill();
})(estPos, '#1d6fa0');
}
/* Run/pause/reset logic */
function stopRun() {
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
document.getElementById('btn-run').textContent = 'Run';
document.getElementById('btn-step').disabled = (step >= MAX_STEPS - 1);
}
function runLoop() {
if (!running) return;
slamStep();
if (step < MAX_STEPS - 1) {
rafId = setTimeout(() => requestAnimationFrame(runLoop), 350);
} else {
stopRun();
document.getElementById('status').textContent =
i18nFmt('Done! Final position error: %err% units', { err: dist(truePos, estPos).toFixed(2) });
}
}
document.getElementById('btn-step').addEventListener('click', () => {
if (running) return;
slamStep();
if (step >= MAX_STEPS - 1)
document.getElementById('btn-step').disabled = true;
});
document.getElementById('btn-run').addEventListener('click', () => {
if (running) { stopRun(); return; }
if (step >= MAX_STEPS - 1) return;
running = true;
document.getElementById('btn-run').textContent = 'Pause';
runLoop();
});
document.getElementById('btn-reset').addEventListener('click', () => {
stopRun();
initState();
draw();
document.getElementById('status').textContent = 'Press Step or Run to start the simulation.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').textContent = 'Run';
});
/* Initialize and draw initial scene */
initState();
draw();
Comments
Loading comments...