Root Locus Plot for Control Systems — Chart.js

A root locus plot traces how the closed-loop poles of a transfer function migrate through the complex plane as a system parameter (typically gain K) varies from 0 to infinity. It is a fundamental tool in classical control theory for analyzing system stability and designing controllers. The plot reveals critical information about pole trajectories, stability boundaries, and gain margins.

Root Locus Plot for Control Systems rendered with Chart.js

JavaScript source (Chart.js)

// anyplot.ai
// root-locus-basic: Root Locus Plot for Control Systems
// Library: chartjs 4.4.7 | JavaScript 22.22.3
// Quality: 86/100 | Created: 2026-06-18

//# anyplot-orientation: landscape
const t = window.ANYPLOT_TOKENS;

// Horner evaluation of monic cubic s³ + p·s² + q·s + r at complex z = (zr + j·zi)
function evalCubic(p, q, r, zr, zi) {
  let re = 1, im = 0;
  for (const c of [p, q, r]) {
    const nr = re * zr - im * zi + c;
    const ni = re * zi + im * zr;
    re = nr; im = ni;
  }
  return { re, im };
}

// Durand–Kerner method: three roots of s³ + p·s² + q·s + r = 0
function cubicRoots(p, q, r) {
  let z = [{ re: 0.5, im: 0.6 }, { re: -0.7, im: 0.3 }, { re: 0.2, im: -0.8 }];
  for (let it = 0; it < 150; it++) {
    for (let i = 0; i < 3; i++) {
      const f = evalCubic(p, q, r, z[i].re, z[i].im);
      let dr = 1, di = 0;
      for (let j = 0; j < 3; j++) {
        if (j === i) continue;
        const nr = dr * (z[i].re - z[j].re) - di * (z[i].im - z[j].im);
        const ni = dr * (z[i].im - z[j].im) + di * (z[i].re - z[j].re);
        dr = nr; di = ni;
      }
      const d2 = dr * dr + di * di;
      if (d2 < 1e-28) continue;
      z[i].re -= (f.re * dr + f.im * di) / d2;
      z[i].im -= (f.im * dr - f.re * di) / d2;
    }
  }
  return z.sort((a, b) =>
    Math.abs(a.re - b.re) > 0.01 ? b.re - a.re : b.im - a.im
  );
}

// G(s) = K / [s(s+2)(s+4)]  →  s³ + 6s² + 8s + K = 0
// Poles: 0, −2, −4 | Breakaway ≈ −0.845 (K≈3.08) | Im-axis ±j√8 ≈ ±j2.83 (K=48)
const N = 600;
const K_MAX = 70;
const branches = [[], [], []];
for (let i = 0; i <= N; i++) {
  const K = (i / N) * K_MAX;
  const roots = cubicRoots(6, 8, K);
  for (let b = 0; b < 3; b++) {
    branches[b].push({ x: roots[b].re, y: roots[b].im });
  }
}

const openLoopPoles = [{ x: 0, y: 0 }, { x: -2, y: 0 }, { x: -4, y: 0 }];
const stabMarkers = [{ x: 0, y: Math.sqrt(8) }, { x: 0, y: -Math.sqrt(8) }];

// Equal pixel-per-unit scaling: canvas 3200×1800 → aspect 16:9
// X span 10.67 units, Y span 6.0 units → 300 px/unit each
const X_MIN = -8.33, X_MAX = 2.34, Y_MIN = -3.0, Y_MAX = 3.0;

// Constant-ζ reference lines (radial rays from origin into left half-plane)
// Slope: ω / |σ| = sqrt(1−ζ²) / ζ
const zetaDatasets = [0.3, 0.5, 0.7].map((zeta) => {
  const slope = Math.sqrt(1 - zeta * zeta) / zeta;
  // Clamp ray to Y boundary (Y_MAX / slope gives |σ| where ω hits Y_MAX)
  const sigmaEnd = Math.max(X_MIN, -Y_MAX / slope);
  const omegaEnd = Math.abs(sigmaEnd) * slope;
  return {
    label: `ζ = ${zeta}`,
    data: [{ x: sigmaEnd, y: -omegaEnd }, { x: 0, y: 0 }, { x: sigmaEnd, y: omegaEnd }],
    borderColor: t.grid,
    backgroundColor: "transparent",
    showLine: true,
    borderWidth: 1,
    borderDash: [5, 5],
    pointRadius: 0,
    tension: 0,
  };
});

// Constant-ωn reference arcs (left half-plane semicircles)
const omegaNDatasets = [1, 2, 3].map((wn) => {
  const pts = [];
  for (let i = 0; i <= 80; i++) {
    const angle = Math.PI / 2 + (Math.PI * i) / 80;
    pts.push({ x: wn * Math.cos(angle), y: wn * Math.sin(angle) });
  }
  return {
    label: `ωₙ = ${wn}`,
    data: pts,
    borderColor: t.grid,
    backgroundColor: "transparent",
    showLine: true,
    borderWidth: 1,
    borderDash: [3, 5],
    pointRadius: 0,
    tension: 0,
  };
});

const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

const title = "root-locus-basic · javascript · chartjs · anyplot.ai";
const titleSize = title.length > 67 ? Math.round(22 * 67 / title.length) : 22;

new Chart(canvas, {
  type: "scatter",
  data: {
    datasets: [
      // Reference overlays drawn first (behind branches)
      ...zetaDatasets,
      ...omegaNDatasets,
      // Locus branches
      {
        label: "Branch 1 (pole at 0)",
        data: branches[0],
        borderColor: t.palette[0],
        backgroundColor: "transparent",
        showLine: true,
        borderWidth: 2.5,
        pointRadius: 0,
        tension: 0,
      },
      {
        label: "Branch 2 (pole at −2)",
        data: branches[1],
        borderColor: t.palette[1],
        backgroundColor: "transparent",
        showLine: true,
        borderWidth: 2.5,
        pointRadius: 0,
        tension: 0,
      },
      {
        label: "Branch 3 (pole at −4)",
        data: branches[2],
        borderColor: t.palette[2],
        backgroundColor: "transparent",
        showLine: true,
        borderWidth: 2.5,
        pointRadius: 0,
        tension: 0,
      },
      // Landmarks
      {
        label: "Open-loop poles",
        data: openLoopPoles,
        borderColor: t.ink,
        backgroundColor: "transparent",
        pointStyle: "crossRot",
        pointRadius: 12,
        pointBorderWidth: 3,
        showLine: false,
      },
      {
        label: "Stability boundary (K = 48)",
        data: stabMarkers,
        borderColor: t.amber,
        backgroundColor: t.amber,
        pointStyle: "triangle",
        pointRadius: 10,
        pointBorderWidth: 2,
        showLine: false,
      },
    ],
  },
  plugins: [
    {
      id: "bg",
      beforeDraw: (chart) => {
        const ctx = chart.ctx;
        ctx.save();
        ctx.fillStyle = t.pageBg;
        ctx.fillRect(0, 0, chart.width, chart.height);
        ctx.restore();
      },
    },
    {
      // Gain-direction arrows: filled triangles at branch midpoints
      id: "gainArrows",
      afterDatasetsDraw: (chart) => {
        const ctx = chart.ctx;
        const xs = chart.scales.x;
        const ys = chart.scales.y;
        const arrowSpec = [
          { branch: branches[0], color: t.palette[0], midIdx: 180 },
          { branch: branches[1], color: t.palette[1], midIdx: 180 },
          { branch: branches[2], color: t.palette[2], midIdx: 350 },
        ];
        for (const { branch, color, midIdx } of arrowSpec) {
          const p0 = branch[midIdx];
          const p1 = branch[Math.min(midIdx + 10, branch.length - 1)];
          const x0 = xs.getPixelForValue(p0.x);
          const y0 = ys.getPixelForValue(p0.y);
          const x1 = xs.getPixelForValue(p1.x);
          const y1 = ys.getPixelForValue(p1.y);
          const angle = Math.atan2(y1 - y0, x1 - x0);
          const sz = 18;
          ctx.save();
          ctx.translate(x0, y0);
          ctx.rotate(angle);
          ctx.fillStyle = color;
          ctx.beginPath();
          ctx.moveTo(sz, 0);
          ctx.lineTo(-sz * 0.6, sz * 0.45);
          ctx.lineTo(-sz * 0.6, -sz * 0.45);
          ctx.closePath();
          ctx.fill();
          ctx.restore();
        }
      },
    },
    {
      // Label ζ and ωn reference lines directly on the chart
      id: "refLabels",
      afterDatasetsDraw: (chart) => {
        const ctx = chart.ctx;
        const xs = chart.scales.x;
        const ys = chart.scales.y;
        ctx.save();
        ctx.font = "600 22px sans-serif";
        ctx.fillStyle = t.inkSoft;
        ctx.textAlign = "right";
        ctx.textBaseline = "bottom";
        // ζ labels at upper ray endpoints
        [0.3, 0.5, 0.7].forEach((zeta) => {
          const slope = Math.sqrt(1 - zeta * zeta) / zeta;
          const sigmaEnd = Math.max(X_MIN, -Y_MAX / slope);
          const omegaEnd = Math.abs(sigmaEnd) * slope;
          const px = xs.getPixelForValue(sigmaEnd) - 4;
          const py = ys.getPixelForValue(omegaEnd) + 2;
          ctx.fillText(`ζ=${zeta}`, px, py);
        });
        // ωn labels at leftmost arc point (angle = π)
        ctx.textAlign = "right";
        ctx.textBaseline = "middle";
        [1, 2, 3].forEach((wn) => {
          const px = xs.getPixelForValue(-wn) - 6;
          const py = ys.getPixelForValue(0);
          ctx.fillText(`ωₙ=${wn}`, px, py);
        });
        ctx.restore();
      },
    },
  ],
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: { top: 4, right: 20, bottom: 10, left: 10 } },
    plugins: {
      title: {
        display: true,
        text: title,
        color: t.ink,
        font: { size: titleSize, weight: "500" },
        padding: { top: 8, bottom: 16 },
      },
      legend: {
        labels: {
          color: t.inkSoft,
          font: { size: 14 },
          usePointStyle: true,
          padding: 18,
          // Exclude reference overlay datasets from legend
          filter: (item) => !item.text.startsWith("ζ") && !item.text.startsWith("ω"),
        },
      },
    },
    scales: {
      x: {
        type: "linear",
        min: X_MIN,
        max: X_MAX,
        ticks: { color: t.inkSoft, font: { size: 13 } },
        grid: { color: t.grid },
        border: { display: false },
        title: {
          display: true,
          text: "Real Axis",
          color: t.ink,
          font: { size: 15, weight: "500" },
        },
      },
      y: {
        type: "linear",
        min: Y_MIN,
        max: Y_MAX,
        ticks: { color: t.inkSoft, font: { size: 13 } },
        grid: { color: t.grid },
        border: { display: false },
        title: {
          display: true,
          text: "Imaginary Axis",
          color: t.ink,
          font: { size: 15, weight: "500" },
        },
      },
    },
  },
});

Part of Root Locus Plot for Control Systems on anyplot.ai.

Other implementations