Below, a point moves in one dimension toward a target. The sliding surface here is simply s=x˙+λ(x−xtarget)=0 — a line in position/velocity space where "closing in at the right pace" holds exactly. The controller only ever pushes with maximum force in whichever direction shrinks s.
<p class="hint">The red dot lives in (position, velocity) space and is steered by a switching controller — full power one way or the other, never in between. The teal line is the <b>sliding surface</b>: once the dot reaches it, following it exactly drives position and velocity to zero together.</p>
<canvas id="scene" class="scene" width="640" height="220"></canvas>
<div class="status" id="status">On the surface — sliding straight to the target.</div>
<div class="btns">
<button id="shove" type="button">Shove it</button>
<button id="chatter" type="button" class="ghost">Show chattering</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.scene { width: 100%; max-width: 640px; height: 220px; background: #eef2f6; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.chatter-on { color: #c92f3c; }
.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; }
button.active { background: #c92f3c; border-color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Double-integrator plant: xdot = v, vdot = u (+ disturbance from a shove).
// The controller only ever knows the sign of the surface value, never the disturbance.
const LAMBDA = 1.5; // slope of the sliding surface in (x, v) space
const GAIN = 6; // switching gain; must exceed the largest disturbance to guarantee reaching the surface
const TARGET = 0; // target position
const DT = 0.02;
let x = -3.4, v = 0; // current position x and velocity v
let trail = []; // recent (x, v) history for drawing the path
let showChatter = false;
let running = true;
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
function sign(n) { return n > 0 ? 1 : (n < 0 ? -1 : 0); }
// s(x, v) = v + LAMBDA * (x - TARGET); s = 0 defines the sliding surface.
function surface(px, pv) { return pv + LAMBDA * (px - TARGET); }
function step() {
const s = surface(x, v);
const u = -GAIN * sign(s); // bang-bang control: push with maximum gain toward decreasing |s|
x += v * DT;
v += u * DT;
trail.push({ x, v, u });
if (trail.length > 260) trail.shift();
}
function shove() {
// Inject a random velocity kick to mimic wind or an external push.
v += (Math.random() > 0.5 ? 1 : -1) * (3 + Math.random() * 2);
setStatus(i18nFmt('Shoved! Watch it fight back to the surface.', {}), false);
}
function toXCanvas(px) {
// map position (roughly -6..6) onto the canvas width
return W / 2 + px * (W / 2 - 30) / 6;
}
function toYCanvas(pv) {
// map velocity (roughly -8..8) onto the canvas height, inverted
return H / 2 - pv * (H / 2 - 30) / 8;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// draw the position and velocity axes through the canvas center
ctx.strokeStyle = '#c3ccd6';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H);
ctx.stroke();
// draw the sliding surface s = 0 as a straight line
ctx.strokeStyle = '#2a9d8f';
ctx.lineWidth = 2;
ctx.beginPath();
const xa = -6, xb = 6;
const va = -LAMBDA * (xa - TARGET), vb = -LAMBDA * (xb - TARGET);
ctx.moveTo(toXCanvas(xa), toYCanvas(va));
ctx.lineTo(toXCanvas(xb), toYCanvas(vb));
ctx.stroke();
// mark the target point (0, 0)
ctx.fillStyle = '#457b9d';
ctx.beginPath();
ctx.arc(toXCanvas(TARGET), toYCanvas(0), 4, 0, Math.PI * 2);
ctx.fill();
// draw the recent trajectory of the state
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
trail.forEach((p, i) => {
const cx = toXCanvas(p.x), cy = toYCanvas(p.v);
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
ctx.stroke();
// draw the current state as a dot
ctx.fillStyle = '#e63946';
ctx.beginPath();
ctx.arc(toXCanvas(x), toYCanvas(v), 6, 0, Math.PI * 2);
ctx.fill();
if (showChatter && trail.length > 1) {
// draw a strip showing the control sign flipping at high frequency
const barH = 22, barY = H - barH - 6;
const n = Math.min(trail.length, 120);
const bw = W / n;
for (let i = 0; i < n; i++) {
const p = trail[trail.length - n + i];
ctx.fillStyle = p.u >= 0 ? '#2a9d8f' : '#e63946';
ctx.fillRect(i * bw, barY, bw + 1, barH);
}
}
}
function setStatus(text, isChatterHint) {
const s = document.getElementById('status');
s.textContent = text;
}
function tick() {
if (running) {
step();
const s = surface(x, v);
if (Math.abs(s) < 0.08 && Math.abs(x - TARGET) < 0.15) {
setStatus('On the surface — sliding straight to the target.', false);
}
draw();
}
requestAnimationFrame(tick);
}
function resetAll() {
x = -3.4; v = 0; trail = [];
setStatus('Reaching the sliding surface...', false);
draw();
}
document.getElementById('shove').addEventListener('click', shove);
document.getElementById('reset').addEventListener('click', resetAll);
document.getElementById('chatter').addEventListener('click', (e) => {
showChatter = !showChatter;
e.target.classList.toggle('active', showChatter);
e.target.textContent = showChatter ? 'Hide chattering' : 'Show chattering';
});
resetAll();
requestAnimationFrame(tick);
<p class="hint">The red dot lives in (position, velocity) space and is steered by a switching controller — full power one way or the other, never in between. The teal line is the <b>sliding surface</b>: once the dot reaches it, following it exactly drives position and velocity to zero together.</p>
<canvas id="scene" class="scene" width="640" height="220"></canvas>
<div class="status" id="status">On the surface — sliding straight to the target.</div>
<div class="btns">
<button id="shove" type="button">Shove it</button>
<button id="chatter" type="button" class="ghost">Show chattering</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.scene { width: 100%; max-width: 640px; height: 220px; background: #eef2f6; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.chatter-on { color: #c92f3c; }
.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; }
button.active { background: #c92f3c; border-color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Double-integrator plant: xdot = v, vdot = u (+ disturbance from a shove).
// The controller only ever knows the sign of the surface value, never the disturbance.
const LAMBDA = 1.5; // slope of the sliding surface in (x, v) space
const GAIN = 6; // switching gain; must exceed the largest disturbance to guarantee reaching the surface
const TARGET = 0; // target position
const DT = 0.02;
let x = -3.4, v = 0; // current position x and velocity v
let trail = []; // recent (x, v) history for drawing the path
let showChatter = false;
let running = true;
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
function sign(n) { return n > 0 ? 1 : (n < 0 ? -1 : 0); }
// s(x, v) = v + LAMBDA * (x - TARGET); s = 0 defines the sliding surface.
function surface(px, pv) { return pv + LAMBDA * (px - TARGET); }
function step() {
const s = surface(x, v);
const u = -GAIN * sign(s); // bang-bang control: push with maximum gain toward decreasing |s|
x += v * DT;
v += u * DT;
trail.push({ x, v, u });
if (trail.length > 260) trail.shift();
}
function shove() {
// Inject a random velocity kick to mimic wind or an external push.
v += (Math.random() > 0.5 ? 1 : -1) * (3 + Math.random() * 2);
setStatus(i18nFmt('Shoved! Watch it fight back to the surface.', {}), false);
}
function toXCanvas(px) {
// map position (roughly -6..6) onto the canvas width
return W / 2 + px * (W / 2 - 30) / 6;
}
function toYCanvas(pv) {
// map velocity (roughly -8..8) onto the canvas height, inverted
return H / 2 - pv * (H / 2 - 30) / 8;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// draw the position and velocity axes through the canvas center
ctx.strokeStyle = '#c3ccd6';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H);
ctx.stroke();
// draw the sliding surface s = 0 as a straight line
ctx.strokeStyle = '#2a9d8f';
ctx.lineWidth = 2;
ctx.beginPath();
const xa = -6, xb = 6;
const va = -LAMBDA * (xa - TARGET), vb = -LAMBDA * (xb - TARGET);
ctx.moveTo(toXCanvas(xa), toYCanvas(va));
ctx.lineTo(toXCanvas(xb), toYCanvas(vb));
ctx.stroke();
// mark the target point (0, 0)
ctx.fillStyle = '#457b9d';
ctx.beginPath();
ctx.arc(toXCanvas(TARGET), toYCanvas(0), 4, 0, Math.PI * 2);
ctx.fill();
// draw the recent trajectory of the state
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
trail.forEach((p, i) => {
const cx = toXCanvas(p.x), cy = toYCanvas(p.v);
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
ctx.stroke();
// draw the current state as a dot
ctx.fillStyle = '#e63946';
ctx.beginPath();
ctx.arc(toXCanvas(x), toYCanvas(v), 6, 0, Math.PI * 2);
ctx.fill();
if (showChatter && trail.length > 1) {
// draw a strip showing the control sign flipping at high frequency
const barH = 22, barY = H - barH - 6;
const n = Math.min(trail.length, 120);
const bw = W / n;
for (let i = 0; i < n; i++) {
const p = trail[trail.length - n + i];
ctx.fillStyle = p.u >= 0 ? '#2a9d8f' : '#e63946';
ctx.fillRect(i * bw, barY, bw + 1, barH);
}
}
}
function setStatus(text, isChatterHint) {
const s = document.getElementById('status');
s.textContent = text;
}
function tick() {
if (running) {
step();
const s = surface(x, v);
if (Math.abs(s) < 0.08 && Math.abs(x - TARGET) < 0.15) {
setStatus('On the surface — sliding straight to the target.', false);
}
draw();
}
requestAnimationFrame(tick);
}
function resetAll() {
x = -3.4; v = 0; trail = [];
setStatus('Reaching the sliding surface...', false);
draw();
}
document.getElementById('shove').addEventListener('click', shove);
document.getElementById('reset').addEventListener('click', resetAll);
document.getElementById('chatter').addEventListener('click', (e) => {
showChatter = !showChatter;
e.target.classList.toggle('active', showChatter);
e.target.textContent = showChatter ? 'Hide chattering' : 'Show chattering';
});
resetAll();
requestAnimationFrame(tick);
Press Shove it at any time — a random disturbance is added straight to the dynamics, like a gust of wind. Watch the trajectory: it does not need to re-plan or re-learn anything. The switching law was already built to survive exactly this, so the point snaps back onto the surface and keeps sliding toward the target. Turn on Chattering view to see the control signal itself flip sign at high frequency — the price paid for that robustness.
Comments
Loading comments...