{"spec_id":"root-locus-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// root-locus-basic: Root Locus Plot for Control Systems\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 86/100 | Created: 2026-06-18\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// Horner evaluation of monic cubic s³ + p·s² + q·s + r at complex z = (zr + j·zi)\nfunction evalCubic(p, q, r, zr, zi) {\n  let re = 1, im = 0;\n  for (const c of [p, q, r]) {\n    const nr = re * zr - im * zi + c;\n    const ni = re * zi + im * zr;\n    re = nr; im = ni;\n  }\n  return { re, im };\n}\n\n// Durand–Kerner method: three roots of s³ + p·s² + q·s + r = 0\nfunction cubicRoots(p, q, r) {\n  let z = [{ re: 0.5, im: 0.6 }, { re: -0.7, im: 0.3 }, { re: 0.2, im: -0.8 }];\n  for (let it = 0; it < 150; it++) {\n    for (let i = 0; i < 3; i++) {\n      const f = evalCubic(p, q, r, z[i].re, z[i].im);\n      let dr = 1, di = 0;\n      for (let j = 0; j < 3; j++) {\n        if (j === i) continue;\n        const nr = dr * (z[i].re - z[j].re) - di * (z[i].im - z[j].im);\n        const ni = dr * (z[i].im - z[j].im) + di * (z[i].re - z[j].re);\n        dr = nr; di = ni;\n      }\n      const d2 = dr * dr + di * di;\n      if (d2 < 1e-28) continue;\n      z[i].re -= (f.re * dr + f.im * di) / d2;\n      z[i].im -= (f.im * dr - f.re * di) / d2;\n    }\n  }\n  return z.sort((a, b) =>\n    Math.abs(a.re - b.re) > 0.01 ? b.re - a.re : b.im - a.im\n  );\n}\n\n// G(s) = K / [s(s+2)(s+4)]  →  s³ + 6s² + 8s + K = 0\n// Poles: 0, −2, −4 | Breakaway ≈ −0.845 (K≈3.08) | Im-axis ±j√8 ≈ ±j2.83 (K=48)\nconst N = 600;\nconst K_MAX = 70;\nconst branches = [[], [], []];\nfor (let i = 0; i <= N; i++) {\n  const K = (i / N) * K_MAX;\n  const roots = cubicRoots(6, 8, K);\n  for (let b = 0; b < 3; b++) {\n    branches[b].push({ x: roots[b].re, y: roots[b].im });\n  }\n}\n\nconst openLoopPoles = [{ x: 0, y: 0 }, { x: -2, y: 0 }, { x: -4, y: 0 }];\nconst stabMarkers = [{ x: 0, y: Math.sqrt(8) }, { x: 0, y: -Math.sqrt(8) }];\n\n// Equal pixel-per-unit scaling: canvas 3200×1800 → aspect 16:9\n// X span 10.67 units, Y span 6.0 units → 300 px/unit each\nconst X_MIN = -8.33, X_MAX = 2.34, Y_MIN = -3.0, Y_MAX = 3.0;\n\n// Constant-ζ reference lines (radial rays from origin into left half-plane)\n// Slope: ω / |σ| = sqrt(1−ζ²) / ζ\nconst zetaDatasets = [0.3, 0.5, 0.7].map((zeta) => {\n  const slope = Math.sqrt(1 - zeta * zeta) / zeta;\n  // Clamp ray to Y boundary (Y_MAX / slope gives |σ| where ω hits Y_MAX)\n  const sigmaEnd = Math.max(X_MIN, -Y_MAX / slope);\n  const omegaEnd = Math.abs(sigmaEnd) * slope;\n  return {\n    label: `ζ = ${zeta}`,\n    data: [{ x: sigmaEnd, y: -omegaEnd }, { x: 0, y: 0 }, { x: sigmaEnd, y: omegaEnd }],\n    borderColor: t.grid,\n    backgroundColor: \"transparent\",\n    showLine: true,\n    borderWidth: 1,\n    borderDash: [5, 5],\n    pointRadius: 0,\n    tension: 0,\n  };\n});\n\n// Constant-ωn reference arcs (left half-plane semicircles)\nconst omegaNDatasets = [1, 2, 3].map((wn) => {\n  const pts = [];\n  for (let i = 0; i <= 80; i++) {\n    const angle = Math.PI / 2 + (Math.PI * i) / 80;\n    pts.push({ x: wn * Math.cos(angle), y: wn * Math.sin(angle) });\n  }\n  return {\n    label: `ωₙ = ${wn}`,\n    data: pts,\n    borderColor: t.grid,\n    backgroundColor: \"transparent\",\n    showLine: true,\n    borderWidth: 1,\n    borderDash: [3, 5],\n    pointRadius: 0,\n    tension: 0,\n  };\n});\n\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nconst title = \"root-locus-basic · javascript · chartjs · anyplot.ai\";\nconst titleSize = title.length > 67 ? Math.round(22 * 67 / title.length) : 22;\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      // Reference overlays drawn first (behind branches)\n      ...zetaDatasets,\n      ...omegaNDatasets,\n      // Locus branches\n      {\n        label: \"Branch 1 (pole at 0)\",\n        data: branches[0],\n        borderColor: t.palette[0],\n        backgroundColor: \"transparent\",\n        showLine: true,\n        borderWidth: 2.5,\n        pointRadius: 0,\n        tension: 0,\n      },\n      {\n        label: \"Branch 2 (pole at −2)\",\n        data: branches[1],\n        borderColor: t.palette[1],\n        backgroundColor: \"transparent\",\n        showLine: true,\n        borderWidth: 2.5,\n        pointRadius: 0,\n        tension: 0,\n      },\n      {\n        label: \"Branch 3 (pole at −4)\",\n        data: branches[2],\n        borderColor: t.palette[2],\n        backgroundColor: \"transparent\",\n        showLine: true,\n        borderWidth: 2.5,\n        pointRadius: 0,\n        tension: 0,\n      },\n      // Landmarks\n      {\n        label: \"Open-loop poles\",\n        data: openLoopPoles,\n        borderColor: t.ink,\n        backgroundColor: \"transparent\",\n        pointStyle: \"crossRot\",\n        pointRadius: 12,\n        pointBorderWidth: 3,\n        showLine: false,\n      },\n      {\n        label: \"Stability boundary (K = 48)\",\n        data: stabMarkers,\n        borderColor: t.amber,\n        backgroundColor: t.amber,\n        pointStyle: \"triangle\",\n        pointRadius: 10,\n        pointBorderWidth: 2,\n        showLine: false,\n      },\n    ],\n  },\n  plugins: [\n    {\n      id: \"bg\",\n      beforeDraw: (chart) => {\n        const ctx = chart.ctx;\n        ctx.save();\n        ctx.fillStyle = t.pageBg;\n        ctx.fillRect(0, 0, chart.width, chart.height);\n        ctx.restore();\n      },\n    },\n    {\n      // Gain-direction arrows: filled triangles at branch midpoints\n      id: \"gainArrows\",\n      afterDatasetsDraw: (chart) => {\n        const ctx = chart.ctx;\n        const xs = chart.scales.x;\n        const ys = chart.scales.y;\n        const arrowSpec = [\n          { branch: branches[0], color: t.palette[0], midIdx: 180 },\n          { branch: branches[1], color: t.palette[1], midIdx: 180 },\n          { branch: branches[2], color: t.palette[2], midIdx: 350 },\n        ];\n        for (const { branch, color, midIdx } of arrowSpec) {\n          const p0 = branch[midIdx];\n          const p1 = branch[Math.min(midIdx + 10, branch.length - 1)];\n          const x0 = xs.getPixelForValue(p0.x);\n          const y0 = ys.getPixelForValue(p0.y);\n          const x1 = xs.getPixelForValue(p1.x);\n          const y1 = ys.getPixelForValue(p1.y);\n          const angle = Math.atan2(y1 - y0, x1 - x0);\n          const sz = 18;\n          ctx.save();\n          ctx.translate(x0, y0);\n          ctx.rotate(angle);\n          ctx.fillStyle = color;\n          ctx.beginPath();\n          ctx.moveTo(sz, 0);\n          ctx.lineTo(-sz * 0.6, sz * 0.45);\n          ctx.lineTo(-sz * 0.6, -sz * 0.45);\n          ctx.closePath();\n          ctx.fill();\n          ctx.restore();\n        }\n      },\n    },\n    {\n      // Label ζ and ωn reference lines directly on the chart\n      id: \"refLabels\",\n      afterDatasetsDraw: (chart) => {\n        const ctx = chart.ctx;\n        const xs = chart.scales.x;\n        const ys = chart.scales.y;\n        ctx.save();\n        ctx.font = \"600 22px sans-serif\";\n        ctx.fillStyle = t.inkSoft;\n        ctx.textAlign = \"right\";\n        ctx.textBaseline = \"bottom\";\n        // ζ labels at upper ray endpoints\n        [0.3, 0.5, 0.7].forEach((zeta) => {\n          const slope = Math.sqrt(1 - zeta * zeta) / zeta;\n          const sigmaEnd = Math.max(X_MIN, -Y_MAX / slope);\n          const omegaEnd = Math.abs(sigmaEnd) * slope;\n          const px = xs.getPixelForValue(sigmaEnd) - 4;\n          const py = ys.getPixelForValue(omegaEnd) + 2;\n          ctx.fillText(`ζ=${zeta}`, px, py);\n        });\n        // ωn labels at leftmost arc point (angle = π)\n        ctx.textAlign = \"right\";\n        ctx.textBaseline = \"middle\";\n        [1, 2, 3].forEach((wn) => {\n          const px = xs.getPixelForValue(-wn) - 6;\n          const py = ys.getPixelForValue(0);\n          ctx.fillText(`ωₙ=${wn}`, px, py);\n        });\n        ctx.restore();\n      },\n    },\n  ],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 4, right: 20, bottom: 10, left: 10 } },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleSize, weight: \"500\" },\n        padding: { top: 8, bottom: 16 },\n      },\n      legend: {\n        labels: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          usePointStyle: true,\n          padding: 18,\n          // Exclude reference overlay datasets from legend\n          filter: (item) => !item.text.startsWith(\"ζ\") && !item.text.startsWith(\"ω\"),\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: X_MIN,\n        max: X_MAX,\n        ticks: { color: t.inkSoft, font: { size: 13 } },\n        grid: { color: t.grid },\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Real Axis\",\n          color: t.ink,\n          font: { size: 15, weight: \"500\" },\n        },\n      },\n      y: {\n        type: \"linear\",\n        min: Y_MIN,\n        max: Y_MAX,\n        ticks: { color: t.inkSoft, font: { size: 13 } },\n        grid: { color: t.grid },\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Imaginary Axis\",\n          color: t.ink,\n          font: { size: 15, weight: \"500\" },\n        },\n      },\n    },\n  },\n});\n"}