The car below follows a closed oval track using Pure Pursuit. The orange dot is the look-ahead point — the spot on the path one look-ahead distance ahead of the car. The car always steers toward it.
<!-- Pure Pursuit demo — car follows an oval track by steering toward a look-ahead point -->
<div class="controls">
<label for="ld-slider">Look-ahead: <span id="ld-val">60</span>px</label>
<input id="ld-slider" type="range" min="20" max="140" value="60" step="5">
<button id="btn-reset" type="button">Reset</button>
</div>
<canvas id="track" width="480" height="320"></canvas>
<div class="status" id="status">Running — drag the slider to change the look-ahead distance.</div>
/* Pure Pursuit demo styles — track canvas + controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls {
display: flex; align-items: center; gap: .7rem; flex-wrap: wrap;
margin-bottom: .6rem; font-size: .9rem;
}
label { font-weight: 600; white-space: nowrap; }
input[type=range] { width: 130px; accent-color: #1d3557; cursor: pointer; }
button {
font: 600 13px system-ui; padding: .35rem .8rem;
border: 1px solid #1d3557; background: #1d3557;
color: #fff; border-radius: 6px; cursor: pointer;
}
canvas {
display: block; border-radius: 10px;
border: 1px solid #cdd9e3; background: #f5f8fb;
max-width: 100%;
}
.status {
margin-top: .5rem; font-size: .9rem; font-weight: 600; color: #1d3557;
min-height: 1.3em;
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Canvas dimensions match the HTML attributes above */
const W = 480, H = 320;
/* Oval path centred on the canvas; RX/RY are the semi-axes in pixels */
const CX = W / 2, CY = H / 2, RX = 180, RY = 110;
const NPTS = 200;
/* Pre-compute NPTS evenly-spaced points around the ellipse */
function buildPath() {
const pts = [];
for (let i = 0; i < NPTS; i++) {
const a = (2 * Math.PI * i) / NPTS;
pts.push({ x: CX + RX * Math.cos(a), y: CY + RY * Math.sin(a) });
}
return pts;
}
const PATH = buildPath();
/* Car state — position, heading (radians), look-ahead distance, speed */
let car = { x: CX + RX, y: CY, heading: -Math.PI / 2 };
let Ld = 60;
const SPEED = 1.8;
const WHEELBASE = 28;
let animId = null;
let closestIdx = 0;
/* Return the path point closest to Ld ahead of the car position */
function findLookahead(cx, cy, heading) {
let best = null, bestDist = Infinity;
/* Walk forward from the nearest point until we bracket the look-ahead distance */
for (let i = 0; i < PATH.length; i++) {
const idx = (closestIdx + i) % PATH.length;
const p = PATH[idx];
const dx = p.x - cx, dy = p.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (Math.abs(dist - Ld) < bestDist) {
bestDist = Math.abs(dist - Ld);
best = { p, idx };
}
if (dist > Ld + 40) break;
}
return best ? best.p : PATH[(closestIdx + 1) % PATH.length];
}
/* Keep track of the nearest path index so look-ahead search stays local */
function updateClosest(cx, cy) {
let minD = Infinity, idx = closestIdx;
for (let i = -5; i < 15; i++) {
const j = (closestIdx + NPTS + i) % NPTS;
const p = PATH[j];
const d = (p.x - cx) ** 2 + (p.y - cy) ** 2;
if (d < minD) { minD = d; idx = j; }
}
closestIdx = idx;
}
/* One Pure Pursuit control step — compute steering, move car */
function step() {
updateClosest(car.x, car.y);
const lp = findLookahead(car.x, car.y, car.heading);
/* Rotate the look-ahead offset into the car's local frame (x=forward, y=left) */
const dx = lp.x - car.x, dy = lp.y - car.y;
const localX = dx * Math.cos(-car.heading) - dy * Math.sin(-car.heading);
const localY = dx * Math.sin(-car.heading) + dy * Math.cos(-car.heading);
/* alpha = angle to look-ahead in local frame; kappa = 2*sin(alpha)/Ld */
const alpha = Math.atan2(localY, localX);
const kappa = 2 * Math.sin(alpha) / Ld;
const delta = Math.atan(WHEELBASE * kappa);
/* Bicycle-model integration — heading and position update */
car.heading += (SPEED / WHEELBASE) * Math.tan(delta);
car.x += SPEED * Math.cos(car.heading);
car.y += SPEED * Math.sin(car.heading);
}
/* Render one animation frame — path, look-ahead indicator, car body */
function draw() {
const canvas = document.getElementById('track');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Draw thick grey oval as the road surface */
ctx.beginPath();
PATH.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.strokeStyle = '#b0bec5';
ctx.lineWidth = 10;
ctx.stroke();
/* White dashed centre line over the road */
ctx.beginPath();
PATH.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.strokeStyle = '#fff';
ctx.setLineDash([6, 8]);
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
/* Dashed line from car to current look-ahead point */
const lp = findLookahead(car.x, car.y, car.heading);
ctx.beginPath();
ctx.moveTo(car.x, car.y);
ctx.lineTo(lp.x, lp.y);
ctx.strokeStyle = 'rgba(230,57,70,.45)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 5]);
ctx.stroke();
ctx.setLineDash([]);
/* Orange dot marking the look-ahead point on the path */
ctx.beginPath();
ctx.arc(lp.x, lp.y, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
/* Car body — dark blue rounded rectangle, rotated to current heading */
ctx.save();
ctx.translate(car.x, car.y);
ctx.rotate(car.heading);
ctx.fillStyle = '#1d3557';
ctx.beginPath();
ctx.roundRect(-10, -5, 20, 10, 3);
ctx.fill();
/* Small arrow on the front of the car to show heading direction */
ctx.fillStyle = '#a8dadc';
ctx.beginPath();
ctx.moveTo(8, 0); ctx.lineTo(3, -4); ctx.lineTo(3, 4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function loop() {
step();
draw();
animId = requestAnimationFrame(loop);
}
/* Stop animation, reset car to start position, restart loop */
function reset() {
if (animId) cancelAnimationFrame(animId);
car = { x: CX + RX, y: CY, heading: -Math.PI / 2 };
closestIdx = 0;
draw();
document.getElementById('status').textContent = 'Running — drag the slider to change the look-ahead distance.';
animId = requestAnimationFrame(loop);
}
/* Update Ld from the range slider and display the new value */
document.getElementById('ld-slider').addEventListener('input', function () {
Ld = +this.value;
document.getElementById('ld-val').textContent = this.value;
document.getElementById('status').textContent = i18nFmt('Look-ahead distance set to %ld% px. Watch the corner behaviour change.', { ld: this.value });
});
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
<!-- Pure Pursuit demo — car follows an oval track by steering toward a look-ahead point -->
<div class="controls">
<label for="ld-slider">Look-ahead: <span id="ld-val">60</span>px</label>
<input id="ld-slider" type="range" min="20" max="140" value="60" step="5">
<button id="btn-reset" type="button">Reset</button>
</div>
<canvas id="track" width="480" height="320"></canvas>
<div class="status" id="status">Running — drag the slider to change the look-ahead distance.</div>
/* Pure Pursuit demo styles — track canvas + controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls {
display: flex; align-items: center; gap: .7rem; flex-wrap: wrap;
margin-bottom: .6rem; font-size: .9rem;
}
label { font-weight: 600; white-space: nowrap; }
input[type=range] { width: 130px; accent-color: #1d3557; cursor: pointer; }
button {
font: 600 13px system-ui; padding: .35rem .8rem;
border: 1px solid #1d3557; background: #1d3557;
color: #fff; border-radius: 6px; cursor: pointer;
}
canvas {
display: block; border-radius: 10px;
border: 1px solid #cdd9e3; background: #f5f8fb;
max-width: 100%;
}
.status {
margin-top: .5rem; font-size: .9rem; font-weight: 600; color: #1d3557;
min-height: 1.3em;
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Canvas dimensions match the HTML attributes above */
const W = 480, H = 320;
/* Oval path centred on the canvas; RX/RY are the semi-axes in pixels */
const CX = W / 2, CY = H / 2, RX = 180, RY = 110;
const NPTS = 200;
/* Pre-compute NPTS evenly-spaced points around the ellipse */
function buildPath() {
const pts = [];
for (let i = 0; i < NPTS; i++) {
const a = (2 * Math.PI * i) / NPTS;
pts.push({ x: CX + RX * Math.cos(a), y: CY + RY * Math.sin(a) });
}
return pts;
}
const PATH = buildPath();
/* Car state — position, heading (radians), look-ahead distance, speed */
let car = { x: CX + RX, y: CY, heading: -Math.PI / 2 };
let Ld = 60;
const SPEED = 1.8;
const WHEELBASE = 28;
let animId = null;
let closestIdx = 0;
/* Return the path point closest to Ld ahead of the car position */
function findLookahead(cx, cy, heading) {
let best = null, bestDist = Infinity;
/* Walk forward from the nearest point until we bracket the look-ahead distance */
for (let i = 0; i < PATH.length; i++) {
const idx = (closestIdx + i) % PATH.length;
const p = PATH[idx];
const dx = p.x - cx, dy = p.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (Math.abs(dist - Ld) < bestDist) {
bestDist = Math.abs(dist - Ld);
best = { p, idx };
}
if (dist > Ld + 40) break;
}
return best ? best.p : PATH[(closestIdx + 1) % PATH.length];
}
/* Keep track of the nearest path index so look-ahead search stays local */
function updateClosest(cx, cy) {
let minD = Infinity, idx = closestIdx;
for (let i = -5; i < 15; i++) {
const j = (closestIdx + NPTS + i) % NPTS;
const p = PATH[j];
const d = (p.x - cx) ** 2 + (p.y - cy) ** 2;
if (d < minD) { minD = d; idx = j; }
}
closestIdx = idx;
}
/* One Pure Pursuit control step — compute steering, move car */
function step() {
updateClosest(car.x, car.y);
const lp = findLookahead(car.x, car.y, car.heading);
/* Rotate the look-ahead offset into the car's local frame (x=forward, y=left) */
const dx = lp.x - car.x, dy = lp.y - car.y;
const localX = dx * Math.cos(-car.heading) - dy * Math.sin(-car.heading);
const localY = dx * Math.sin(-car.heading) + dy * Math.cos(-car.heading);
/* alpha = angle to look-ahead in local frame; kappa = 2*sin(alpha)/Ld */
const alpha = Math.atan2(localY, localX);
const kappa = 2 * Math.sin(alpha) / Ld;
const delta = Math.atan(WHEELBASE * kappa);
/* Bicycle-model integration — heading and position update */
car.heading += (SPEED / WHEELBASE) * Math.tan(delta);
car.x += SPEED * Math.cos(car.heading);
car.y += SPEED * Math.sin(car.heading);
}
/* Render one animation frame — path, look-ahead indicator, car body */
function draw() {
const canvas = document.getElementById('track');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Draw thick grey oval as the road surface */
ctx.beginPath();
PATH.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.strokeStyle = '#b0bec5';
ctx.lineWidth = 10;
ctx.stroke();
/* White dashed centre line over the road */
ctx.beginPath();
PATH.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.strokeStyle = '#fff';
ctx.setLineDash([6, 8]);
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
/* Dashed line from car to current look-ahead point */
const lp = findLookahead(car.x, car.y, car.heading);
ctx.beginPath();
ctx.moveTo(car.x, car.y);
ctx.lineTo(lp.x, lp.y);
ctx.strokeStyle = 'rgba(230,57,70,.45)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 5]);
ctx.stroke();
ctx.setLineDash([]);
/* Orange dot marking the look-ahead point on the path */
ctx.beginPath();
ctx.arc(lp.x, lp.y, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
/* Car body — dark blue rounded rectangle, rotated to current heading */
ctx.save();
ctx.translate(car.x, car.y);
ctx.rotate(car.heading);
ctx.fillStyle = '#1d3557';
ctx.beginPath();
ctx.roundRect(-10, -5, 20, 10, 3);
ctx.fill();
/* Small arrow on the front of the car to show heading direction */
ctx.fillStyle = '#a8dadc';
ctx.beginPath();
ctx.moveTo(8, 0); ctx.lineTo(3, -4); ctx.lineTo(3, 4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function loop() {
step();
draw();
animId = requestAnimationFrame(loop);
}
/* Stop animation, reset car to start position, restart loop */
function reset() {
if (animId) cancelAnimationFrame(animId);
car = { x: CX + RX, y: CY, heading: -Math.PI / 2 };
closestIdx = 0;
draw();
document.getElementById('status').textContent = 'Running — drag the slider to change the look-ahead distance.';
animId = requestAnimationFrame(loop);
}
/* Update Ld from the range slider and display the new value */
document.getElementById('ld-slider').addEventListener('input', function () {
Ld = +this.value;
document.getElementById('ld-val').textContent = this.value;
document.getElementById('status').textContent = i18nFmt('Look-ahead distance set to %ld% px. Watch the corner behaviour change.', { ld: this.value });
});
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
Comments
Loading comments...