<p class="hint">The three <b>×</b> marks are the open-loop poles of $G(s)H(s) = K / (s(s+2)(s+4))$. Drag the slider to raise the gain $K$ and watch the closed-loop poles (dots) slide along the dashed locus curves.</p>
<div class="wrap">
<svg id="plane" viewBox="0 0 460 360" class="plane"></svg>
<div class="side">
<label class="glabel" for="gain">Gain K</label>
<input id="gain" type="range" min="0" max="70" step="0.5" value="0">
<div class="gval" id="gval">K = 0.0</div>
<div class="status" id="status">Stable — every pole is in the left half-plane.</div>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
</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; }
.wrap { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.plane { width: 100%; max-width: 460px; height: auto; background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; }
.side { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: .5rem; }
.glabel { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type="range"] { width: 100%; }
.gval { font: 700 15px ui-monospace, monospace; color: #1d3557; }
.status { font-size: .95rem; font-weight: 700; padding: .4rem .6rem; border-radius: 6px; min-height: 1.4em; }
.status.ok { color: #0a7d33; background: #e5f6ea; }
.status.bad { color: #c92f3c; background: #fdeaec; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; align-self: flex-start; }
// Plant: G(s)H(s) = K / (s(s+2)(s+4)) — a classic three-pole open-loop system.
// s-plane pixel mapping: linear scale from (RE_MIN..RE_MAX, IM_MIN..IM_MAX) to the SVG viewBox.
const SVG_W = 460, SVG_H = 360;
const RE_MIN = -8, RE_MAX = 2, IM_MIN = -4, IM_MAX = 4;
const MARGIN = 30;
function toX(re){ return MARGIN + (re - RE_MIN) / (RE_MAX - RE_MIN) * (SVG_W - 2 * MARGIN); }
function toY(im){ return SVG_H - MARGIN - (im - IM_MIN) / (IM_MAX - IM_MIN) * (SVG_H - 2 * MARGIN); }
// Closed-form solver for a monic real cubic a s^3 + b s^2 + c s + d = 0 (Cardano's method).
function cubicRoots(a, b, c, d) {
b /= a; c /= a; d /= a;
const p = c - b * b / 3;
const q = 2 * b * b * b / 27 - b * c / 3 + d;
const disc = (q * q) / 4 + (p * p * p) / 27;
const roots = [];
if (disc > 1e-9) {
const sq = Math.sqrt(disc);
const u = Math.cbrt(-q / 2 + sq);
const v = Math.cbrt(-q / 2 - sq);
roots.push([u + v - b / 3, 0]);
const re = -(u + v) / 2 - b / 3;
const im = (u - v) * Math.sqrt(3) / 2;
roots.push([re, im]);
roots.push([re, -im]);
} else if (disc < -1e-9) {
const r = Math.sqrt(-p * p * p / 27);
const phi = Math.acos(Math.max(-1, Math.min(1, -q / (2 * r))));
const m = 2 * Math.sqrt(-p / 3);
for (let k = 0; k < 3; k++) roots.push([m * Math.cos(phi / 3 - 2 * Math.PI * k / 3) - b / 3, 0]);
} else {
const u = Math.cbrt(-q / 2);
roots.push([2 * u - b / 3, 0]);
roots.push([-u - b / 3, 0]);
roots.push([-u - b / 3, 0]);
}
return roots;
}
// Characteristic equation 1 + K*G(s)H(s) = 0 expands to s^3 + 6 s^2 + 8 s + K = 0.
function closedLoopPoles(K) { return cubicRoots(1, 6, 8, K); }
const OPEN_POLES = [0, -2, -4];
const K_MAX = 70;
const K_CRIT = 48; // Exact instability threshold from Routh-Hurwitz on s^3 + 6 s^2 + 8 s + K.
const svg = document.getElementById('plane');
const NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
// Sample closed-loop poles densely over K in [0, K_MAX] to draw the locus curves.
function buildLocusPaths() {
const N = 280;
const samples = [];
for (let i = 0; i <= N; i++) samples.push(closedLoopPoles(K_MAX * i / N));
// Match each new sample's 3 roots to the nearest previous point per branch, so the drawn path does not jump between branches.
const branches = [[], [], []];
samples[0].forEach((r, i) => branches[i].push(r));
for (let i = 1; i < samples.length; i++) {
const prev = branches.map(b => b[b.length - 1]);
const cur = samples[i].slice();
const used = [false, false, false];
for (let bi = 0; bi < 3; bi++) {
let best = -1, bestD = Infinity;
for (let ci = 0; ci < 3; ci++) {
if (used[ci]) continue;
const d = Math.hypot(cur[ci][0] - prev[bi][0], cur[ci][1] - prev[bi][1]);
if (d < bestD) { bestD = d; best = ci; }
}
used[best] = true;
branches[bi].push(cur[best]);
}
}
return branches;
}
const LOCUS_BRANCHES = buildLocusPaths();
function drawStatic() {
svg.innerHTML = '';
// Draw the real and imaginary axes.
const yAxisX = toX(0), xAxisY = toY(0);
svg.appendChild(el('line', { x1: MARGIN, y1: xAxisY, x2: SVG_W - MARGIN, y2: xAxisY, stroke: '#c3cdd6', 'stroke-width': 1 }));
svg.appendChild(el('line', { x1: yAxisX, y1: MARGIN, x2: yAxisX, y2: SVG_H - MARGIN, stroke: '#c3cdd6', 'stroke-width': 1 }));
const reLabel = el('text', { x: SVG_W - MARGIN, y: xAxisY + 14, 'font-size': 10, fill: '#7a8794', 'text-anchor': 'end' });
reLabel.textContent = 'Re';
svg.appendChild(reLabel);
const imLabel = el('text', { x: yAxisX + 5, y: MARGIN, 'font-size': 10, fill: '#7a8794' });
imLabel.textContent = 'Im';
svg.appendChild(imLabel);
// Draw the three precomputed locus branches as dashed guide curves.
LOCUS_BRANCHES.forEach(branch => {
const d = branch.map((p, i) => (i === 0 ? 'M' : 'L') + toX(p[0]).toFixed(2) + ',' + toY(p[1]).toFixed(2)).join(' ');
svg.appendChild(el('path', { d, fill: 'none', stroke: '#b7c4cf', 'stroke-width': 1.5, 'stroke-dasharray': '4 3' }));
});
// Draw the open-loop poles (K = 0 starting points) as X marks.
OPEN_POLES.forEach(p => {
const x = toX(p), y = toY(0);
const g = el('g', {});
g.appendChild(el('line', { x1: x - 5, y1: y - 5, x2: x + 5, y2: y + 5, stroke: '#1d3557', 'stroke-width': 2 }));
g.appendChild(el('line', { x1: x - 5, y1: y + 5, x2: x + 5, y2: y - 5, stroke: '#1d3557', 'stroke-width': 2 }));
svg.appendChild(g);
});
// Highlight the imaginary axis: the stability boundary between left and right half-planes.
svg.appendChild(el('line', { x1: yAxisX, y1: MARGIN, x2: yAxisX, y2: SVG_H - MARGIN, stroke: '#c92f3c', 'stroke-width': 1.4, 'stroke-dasharray': '2 3', opacity: 0.55 }));
}
let poleDots = [];
function drawPoles(K) {
poleDots.forEach(d => d.remove());
poleDots = [];
const poles = closedLoopPoles(K);
let unstable = false;
poles.forEach(([re, im]) => {
if (re > 1e-6) unstable = true;
const dot = el('circle', { cx: toX(re), cy: toY(im), r: 5.5, fill: re > 1e-6 ? '#c92f3c' : '#0a7d33', stroke: '#fff', 'stroke-width': 1.2 });
svg.appendChild(dot);
poleDots.push(dot);
});
return unstable;
}
const gainInput = document.getElementById('gain');
const gval = document.getElementById('gval');
const statusEl = document.getElementById('status');
function update() {
const K = parseFloat(gainInput.value);
gval.textContent = fmt('K = %k%', { k: K.toFixed(1) });
const unstable = drawPoles(K);
if (unstable) {
statusEl.textContent = fmt('Unstable — a pole pair crossed into the right half-plane (critical gain K ≈ %kcrit%).', { kcrit: K_CRIT });
statusEl.className = 'status bad';
} else {
statusEl.textContent = fmt('Stable — every pole is in the left half-plane.', {});
statusEl.className = 'status ok';
}
}
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
gainInput.addEventListener('input', update);
document.getElementById('reset').addEventListener('click', () => { gainInput.value = 0; update(); });
drawStatic();
update();
<p class="hint">The three <b>×</b> marks are the open-loop poles of $G(s)H(s) = K / (s(s+2)(s+4))$. Drag the slider to raise the gain $K$ and watch the closed-loop poles (dots) slide along the dashed locus curves.</p>
<div class="wrap">
<svg id="plane" viewBox="0 0 460 360" class="plane"></svg>
<div class="side">
<label class="glabel" for="gain">Gain K</label>
<input id="gain" type="range" min="0" max="70" step="0.5" value="0">
<div class="gval" id="gval">K = 0.0</div>
<div class="status" id="status">Stable — every pole is in the left half-plane.</div>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
</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; }
.wrap { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.plane { width: 100%; max-width: 460px; height: auto; background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; }
.side { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: .5rem; }
.glabel { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type="range"] { width: 100%; }
.gval { font: 700 15px ui-monospace, monospace; color: #1d3557; }
.status { font-size: .95rem; font-weight: 700; padding: .4rem .6rem; border-radius: 6px; min-height: 1.4em; }
.status.ok { color: #0a7d33; background: #e5f6ea; }
.status.bad { color: #c92f3c; background: #fdeaec; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; align-self: flex-start; }
// Plant: G(s)H(s) = K / (s(s+2)(s+4)) — a classic three-pole open-loop system.
// s-plane pixel mapping: linear scale from (RE_MIN..RE_MAX, IM_MIN..IM_MAX) to the SVG viewBox.
const SVG_W = 460, SVG_H = 360;
const RE_MIN = -8, RE_MAX = 2, IM_MIN = -4, IM_MAX = 4;
const MARGIN = 30;
function toX(re){ return MARGIN + (re - RE_MIN) / (RE_MAX - RE_MIN) * (SVG_W - 2 * MARGIN); }
function toY(im){ return SVG_H - MARGIN - (im - IM_MIN) / (IM_MAX - IM_MIN) * (SVG_H - 2 * MARGIN); }
// Closed-form solver for a monic real cubic a s^3 + b s^2 + c s + d = 0 (Cardano's method).
function cubicRoots(a, b, c, d) {
b /= a; c /= a; d /= a;
const p = c - b * b / 3;
const q = 2 * b * b * b / 27 - b * c / 3 + d;
const disc = (q * q) / 4 + (p * p * p) / 27;
const roots = [];
if (disc > 1e-9) {
const sq = Math.sqrt(disc);
const u = Math.cbrt(-q / 2 + sq);
const v = Math.cbrt(-q / 2 - sq);
roots.push([u + v - b / 3, 0]);
const re = -(u + v) / 2 - b / 3;
const im = (u - v) * Math.sqrt(3) / 2;
roots.push([re, im]);
roots.push([re, -im]);
} else if (disc < -1e-9) {
const r = Math.sqrt(-p * p * p / 27);
const phi = Math.acos(Math.max(-1, Math.min(1, -q / (2 * r))));
const m = 2 * Math.sqrt(-p / 3);
for (let k = 0; k < 3; k++) roots.push([m * Math.cos(phi / 3 - 2 * Math.PI * k / 3) - b / 3, 0]);
} else {
const u = Math.cbrt(-q / 2);
roots.push([2 * u - b / 3, 0]);
roots.push([-u - b / 3, 0]);
roots.push([-u - b / 3, 0]);
}
return roots;
}
// Characteristic equation 1 + K*G(s)H(s) = 0 expands to s^3 + 6 s^2 + 8 s + K = 0.
function closedLoopPoles(K) { return cubicRoots(1, 6, 8, K); }
const OPEN_POLES = [0, -2, -4];
const K_MAX = 70;
const K_CRIT = 48; // Exact instability threshold from Routh-Hurwitz on s^3 + 6 s^2 + 8 s + K.
const svg = document.getElementById('plane');
const NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
// Sample closed-loop poles densely over K in [0, K_MAX] to draw the locus curves.
function buildLocusPaths() {
const N = 280;
const samples = [];
for (let i = 0; i <= N; i++) samples.push(closedLoopPoles(K_MAX * i / N));
// Match each new sample's 3 roots to the nearest previous point per branch, so the drawn path does not jump between branches.
const branches = [[], [], []];
samples[0].forEach((r, i) => branches[i].push(r));
for (let i = 1; i < samples.length; i++) {
const prev = branches.map(b => b[b.length - 1]);
const cur = samples[i].slice();
const used = [false, false, false];
for (let bi = 0; bi < 3; bi++) {
let best = -1, bestD = Infinity;
for (let ci = 0; ci < 3; ci++) {
if (used[ci]) continue;
const d = Math.hypot(cur[ci][0] - prev[bi][0], cur[ci][1] - prev[bi][1]);
if (d < bestD) { bestD = d; best = ci; }
}
used[best] = true;
branches[bi].push(cur[best]);
}
}
return branches;
}
const LOCUS_BRANCHES = buildLocusPaths();
function drawStatic() {
svg.innerHTML = '';
// Draw the real and imaginary axes.
const yAxisX = toX(0), xAxisY = toY(0);
svg.appendChild(el('line', { x1: MARGIN, y1: xAxisY, x2: SVG_W - MARGIN, y2: xAxisY, stroke: '#c3cdd6', 'stroke-width': 1 }));
svg.appendChild(el('line', { x1: yAxisX, y1: MARGIN, x2: yAxisX, y2: SVG_H - MARGIN, stroke: '#c3cdd6', 'stroke-width': 1 }));
const reLabel = el('text', { x: SVG_W - MARGIN, y: xAxisY + 14, 'font-size': 10, fill: '#7a8794', 'text-anchor': 'end' });
reLabel.textContent = 'Re';
svg.appendChild(reLabel);
const imLabel = el('text', { x: yAxisX + 5, y: MARGIN, 'font-size': 10, fill: '#7a8794' });
imLabel.textContent = 'Im';
svg.appendChild(imLabel);
// Draw the three precomputed locus branches as dashed guide curves.
LOCUS_BRANCHES.forEach(branch => {
const d = branch.map((p, i) => (i === 0 ? 'M' : 'L') + toX(p[0]).toFixed(2) + ',' + toY(p[1]).toFixed(2)).join(' ');
svg.appendChild(el('path', { d, fill: 'none', stroke: '#b7c4cf', 'stroke-width': 1.5, 'stroke-dasharray': '4 3' }));
});
// Draw the open-loop poles (K = 0 starting points) as X marks.
OPEN_POLES.forEach(p => {
const x = toX(p), y = toY(0);
const g = el('g', {});
g.appendChild(el('line', { x1: x - 5, y1: y - 5, x2: x + 5, y2: y + 5, stroke: '#1d3557', 'stroke-width': 2 }));
g.appendChild(el('line', { x1: x - 5, y1: y + 5, x2: x + 5, y2: y - 5, stroke: '#1d3557', 'stroke-width': 2 }));
svg.appendChild(g);
});
// Highlight the imaginary axis: the stability boundary between left and right half-planes.
svg.appendChild(el('line', { x1: yAxisX, y1: MARGIN, x2: yAxisX, y2: SVG_H - MARGIN, stroke: '#c92f3c', 'stroke-width': 1.4, 'stroke-dasharray': '2 3', opacity: 0.55 }));
}
let poleDots = [];
function drawPoles(K) {
poleDots.forEach(d => d.remove());
poleDots = [];
const poles = closedLoopPoles(K);
let unstable = false;
poles.forEach(([re, im]) => {
if (re > 1e-6) unstable = true;
const dot = el('circle', { cx: toX(re), cy: toY(im), r: 5.5, fill: re > 1e-6 ? '#c92f3c' : '#0a7d33', stroke: '#fff', 'stroke-width': 1.2 });
svg.appendChild(dot);
poleDots.push(dot);
});
return unstable;
}
const gainInput = document.getElementById('gain');
const gval = document.getElementById('gval');
const statusEl = document.getElementById('status');
function update() {
const K = parseFloat(gainInput.value);
gval.textContent = fmt('K = %k%', { k: K.toFixed(1) });
const unstable = drawPoles(K);
if (unstable) {
statusEl.textContent = fmt('Unstable — a pole pair crossed into the right half-plane (critical gain K ≈ %kcrit%).', { kcrit: K_CRIT });
statusEl.className = 'status bad';
} else {
statusEl.textContent = fmt('Stable — every pole is in the left half-plane.', {});
statusEl.className = 'status ok';
}
}
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
gainInput.addEventListener('input', update);
document.getElementById('reset').addEventListener('click', () => { gainInput.value = 0; update(); });
drawStatic();
update();
Two branches leave the real axis, curve upward and downward, and race toward the imaginary axis. Watch the status readout: at a precise critical gain the pair crosses into the right half-plane and the status flips from stable to unstable. Nothing about the plant changed — only how hard the controller reacts to error. That crossing point is exactly what a control engineer must find before shipping a design.
Comments
Loading comments...