{"spec_id":"root-locus-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// root-locus-basic: Root Locus Plot for Control Systems\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-18\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Root locus: G(s) = K / [s(s+2)(s+4)] ---------------------------------\n// Characteristic equation: s³ + 6s² + 8s + K = 0\n// Open-loop poles: 0, −2, −4 | No zeros | 3 branches\n// Breakaway: s ≈ −0.845 at K ≈ 3.08 (two real roots merge → complex pair)\n// Asymptote centroid: −2 | Angles: 60°, 180°, 300°\n// jω-axis crossing: ±j·2√2 ≈ ±j·2.828 at K = 48\n\n// Newton's method: track the monotonically-moving real root (branch 2)\n// starting from the root near 'start' as K varies.\nfunction findRealRoot(K, start) {\n  let s = start;\n  for (let i = 0; i < 200; i++) {\n    const ps = s * s * s + 6 * s * s + 8 * s + K;\n    const dps = 3 * s * s + 12 * s + 8;\n    if (Math.abs(dps) < 1e-15) break;\n    const step = ps / dps;\n    s -= step;\n    if (Math.abs(step) < 1e-12) break;\n  }\n  return s;\n}\n\n// Analytical deflation: divide s³+6s²+8s+K by (s − sReal),\n// then solve the resulting quadratic for the other two roots.\n// Returns [{upper}, {lower}, {real}] where upper/lower are conjugates past breakaway.\nfunction solveDeflated(sReal) {\n  const bq = 6 + sReal;               // quadratic s-coefficient\n  const cq = 8 + sReal * (6 + sReal); // quadratic constant\n  const disc = bq * bq - 4 * cq;\n  if (disc >= 0) {\n    const sq = Math.sqrt(disc);\n    // Two real roots: larger (branch 0) and smaller (branch 1)\n    return [\n      { r: (-bq + sq) / 2, i: 0 },\n      { r: (-bq - sq) / 2, i: 0 },\n      { r: sReal,          i: 0 },\n    ];\n  }\n  // Complex conjugate pair — upper (branch 0) and lower (branch 1)\n  const re = -bq / 2;\n  const im = Math.sqrt(-disc) / 2;\n  return [\n    { r: re, i:  im },\n    { r: re, i: -im },\n    { r: sReal, i: 0 },\n  ];\n}\n\nconst poles = [{ r: 0, i: 0 }, { r: -2, i: 0 }, { r: -4, i: 0 }];\nconst kSteps = Array.from({ length: 401 }, (_, i) => i * 0.2); // K: 0 → 80\nconst branches = [[], [], []];\nlet prevB2 = -4.0; // branch-2 real root starts at pole −4\n\nfor (const K of kSteps) {\n  const sReal = findRealRoot(K, prevB2);\n  prevB2 = sReal;\n  const roots = solveDeflated(sReal);\n  for (let b = 0; b < 3; b++) branches[b].push({ re: roots[b].r, im: roots[b].i, K });\n}\n\n// --- Layout (square canvas: 1200 × 1200 CSS px) ----------------------------\n// Equal aspect: iw = ih = 1010, domain = 9 units each → 112 px/unit\nconst margin = { top: 95, right: 80, bottom: 95, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nsvg.append(\"defs\").append(\"clipPath\").attr(\"id\", \"inner\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales (equal aspect) --------------------------------------------------\nconst xDom = [-7, 2], yDom = [-4.5, 4.5]; // Both range 9 units\nconst xs = d3.scaleLinear().domain(xDom).range([0, iw]);\nconst ys = d3.scaleLinear().domain(yDom).range([ih, 0]);\nconst unitPx = iw / (xDom[1] - xDom[0]); // ≈ 112 px per unit\n\n// --- Reference: constant ζ lines and ωn circles ----------------------------\nconst refG = g.append(\"g\").attr(\"clip-path\", \"url(#inner)\");\n\nconst zetaVals = [0.3, 0.5, 0.707];\nfor (const z of zetaVals) {\n  const sinA = Math.sqrt(1 - z * z);\n  const tScale = yDom[1] / sinA; // reach top of y-domain\n  const ex = Math.max(-z * tScale, xDom[0]);\n  const ey = sinA * tScale;\n  for (const sign of [1, -1]) {\n    refG.append(\"line\")\n      .attr(\"x1\", xs(0)).attr(\"y1\", ys(0))\n      .attr(\"x2\", xs(ex)).attr(\"y2\", ys(sign * ey))\n      .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1.5)\n      .attr(\"stroke-dasharray\", \"6,4\");\n  }\n  refG.append(\"text\")\n    .attr(\"x\", xs(ex) + 4).attr(\"y\", ys(ey) - 4)\n    .attr(\"fill\", t.grid).style(\"font-size\", \"13px\")\n    .text(`ζ=${z}`);\n}\n\n// Constant ωn circles\nfor (const wn of [1, 2, 3, 4]) {\n  refG.append(\"circle\")\n    .attr(\"cx\", xs(0)).attr(\"cy\", ys(0)).attr(\"r\", wn * unitPx)\n    .attr(\"fill\", \"none\").attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1.2).attr(\"stroke-dasharray\", \"4,4\");\n}\nrefG.append(\"text\")\n  .attr(\"x\", xs(0) + 2 * unitPx + 4).attr(\"y\", ys(0) + 14)\n  .attr(\"fill\", t.grid).style(\"font-size\", \"13px\").text(\"ωn=2\");\nrefG.append(\"text\")\n  .attr(\"x\", xs(0) + 4 * unitPx + 4).attr(\"y\", ys(0) + 14)\n  .attr(\"fill\", t.grid).style(\"font-size\", \"13px\").text(\"ωn=4\");\n\n// --- Imaginary axis (stability boundary) ------------------------------------\ng.append(\"line\")\n  .attr(\"x1\", xs(0)).attr(\"x2\", xs(0)).attr(\"y1\", 0).attr(\"y2\", ih)\n  .attr(\"stroke\", t.amber).attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"8,5\").attr(\"opacity\", 0.75);\n\n// --- Real axis --------------------------------------------------------------\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw).attr(\"y1\", ys(0)).attr(\"y2\", ys(0))\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1).attr(\"opacity\", 0.3);\n\n// --- Integer grid -----------------------------------------------------------\nconst gridG = g.append(\"g\").attr(\"clip-path\", \"url(#inner)\").attr(\"opacity\", 0.12);\nfor (let v = Math.ceil(xDom[0]); v <= Math.floor(xDom[1]); v++) {\n  gridG.append(\"line\").attr(\"x1\", xs(v)).attr(\"x2\", xs(v)).attr(\"y1\", 0).attr(\"y2\", ih)\n    .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1);\n}\nfor (let v = Math.ceil(yDom[0]); v <= Math.floor(yDom[1]); v++) {\n  gridG.append(\"line\").attr(\"x1\", 0).attr(\"x2\", iw).attr(\"y1\", ys(v)).attr(\"y2\", ys(v))\n    .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1);\n}\n\n// --- Axes -------------------------------------------------------------------\nconst xAxisG = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(\n  d3.axisBottom(xs).ticks(9).tickFormat(d3.format(\"d\"))\n);\nconst yAxisG = g.append(\"g\").call(\n  d3.axisLeft(ys).ticks(9).tickFormat(d3.format(\"d\"))\n);\nfor (const ax of [xAxisG, yAxisG]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.inkSoft).attr(\"opacity\", 0.35);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft).attr(\"opacity\", 0.35);\n}\n\n// --- Root locus branches (clipped) ------------------------------------------\nconst branchColors = [t.palette[0], t.palette[1], t.palette[2]];\nconst lineGen = d3.line()\n  .x(d => xs(d.re))\n  .y(d => ys(d.im))\n  .defined(d =>\n    isFinite(d.re) && isFinite(d.im)\n    && d.re >= xDom[0] - 0.05 && d.re <= xDom[1] + 0.05\n    && d.im >= yDom[0] - 0.05 && d.im <= yDom[1] + 0.05\n  );\n\nconst lociG = g.append(\"g\").attr(\"clip-path\", \"url(#inner)\");\nfor (let b = 0; b < 3; b++) {\n  lociG.append(\"path\")\n    .datum(branches[b])\n    .attr(\"d\", lineGen)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", branchColors[b])\n    .attr(\"stroke-width\", 3)\n    .attr(\"opacity\", 0.9);\n}\n\n// --- Directional arrows (increasing K) --------------------------------------\nfunction addArrow(grp, cx, cy, dx, dy, color) {\n  const len = Math.sqrt(dx * dx + dy * dy);\n  if (len < 1) return;\n  const ux = dx / len, uy = dy / len, sz = 14;\n  const bx = cx - ux * sz, by = cy - uy * sz;\n  const nx = -uy * sz * 0.45, ny = ux * sz * 0.45;\n  grp.append(\"polygon\")\n    .attr(\"points\", `${cx},${cy} ${bx + nx},${by + ny} ${bx - nx},${by - ny}`)\n    .attr(\"fill\", color).attr(\"opacity\", 0.9);\n}\n\nconst arrowG = g.append(\"g\").attr(\"clip-path\", \"url(#inner)\");\n// Arrows at K≈20 for branches 0 & 1 (complex region), K≈58 for branch 2 (real axis)\nconst arrowK = [20, 20, 58];\nfor (let b = 0; b < 3; b++) {\n  const idx = Math.round(arrowK[b] / 0.2);\n  const lo = Math.max(0, idx - 10), hi = Math.min(branches[b].length - 1, idx + 10);\n  const pt = branches[b][idx];\n  if (!pt) continue;\n  addArrow(\n    arrowG,\n    xs(pt.re), ys(pt.im),\n    xs(branches[b][hi].re) - xs(branches[b][lo].re),\n    ys(branches[b][hi].im) - ys(branches[b][lo].im),\n    branchColors[b]\n  );\n}\n\n// --- Open-loop pole markers (×) --------------------------------------------\nconst poleColor = t.palette[4];\nconst poleG = g.append(\"g\");\nfor (const p of poles) {\n  const px = xs(p.r), py = ys(p.i), s = 12;\n  poleG.append(\"line\").attr(\"x1\", px - s).attr(\"y1\", py - s).attr(\"x2\", px + s).attr(\"y2\", py + s)\n    .attr(\"stroke\", poleColor).attr(\"stroke-width\", 3.5).attr(\"stroke-linecap\", \"round\");\n  poleG.append(\"line\").attr(\"x1\", px - s).attr(\"y1\", py + s).attr(\"x2\", px + s).attr(\"y2\", py - s)\n    .attr(\"stroke\", poleColor).attr(\"stroke-width\", 3.5).attr(\"stroke-linecap\", \"round\");\n}\n\n// --- jω-axis crossing markers (K = 48, s = ±j·2√2) ------------------------\nconst jwCross = 2 * Math.sqrt(2); // ≈ 2.828\nfor (const sign of [1, -1]) {\n  g.append(\"circle\")\n    .attr(\"cx\", xs(0)).attr(\"cy\", ys(sign * jwCross)).attr(\"r\", 8)\n    .attr(\"fill\", t.amber).attr(\"stroke\", t.ink).attr(\"stroke-width\", 1.5);\n  g.append(\"text\")\n    .attr(\"x\", xs(0) + 13).attr(\"y\", ys(sign * jwCross) + (sign > 0 ? -4 : 12))\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n    .text(\"K=48\");\n}\n\n// --- Breakaway point marker -------------------------------------------------\ng.append(\"circle\")\n  .attr(\"cx\", xs(-0.845)).attr(\"cy\", ys(0)).attr(\"r\", 5.5)\n  .attr(\"fill\", t.palette[0]).attr(\"stroke\", t.ink).attr(\"stroke-width\", 1.5);\ng.append(\"text\")\n  .attr(\"x\", xs(-0.845)).attr(\"y\", ys(0) - 10)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"11px\")\n  .text(\"K≈3.1\");\n\n// --- Legend -----------------------------------------------------------------\nconst legendItems = [\n  { color: poleColor, label: \"Open-loop poles (K=0)\", type: \"x\" },\n  { color: t.palette[0], label: \"Branch 1: from s=0\", type: \"line\" },\n  { color: t.palette[1], label: \"Branch 2: from s=−2\", type: \"line\" },\n  { color: t.palette[2], label: \"Branch 3: from s=−4\", type: \"line\" },\n  { color: t.amber, label: \"Stability boundary (jω)\", type: \"dash\" },\n];\nconst lx = iw - 252, ly0 = 22;\ng.append(\"rect\")\n  .attr(\"x\", lx - 10).attr(\"y\", ly0 - 12)\n  .attr(\"width\", 265).attr(\"height\", legendItems.length * 24 + 22)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 5).attr(\"opacity\", 0.92)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\nlegendItems.forEach((item, i) => {\n  const liy = ly0 + i * 24;\n  if (item.type === \"x\") {\n    const mx = lx + 12, s = 7;\n    g.append(\"line\").attr(\"x1\", mx - s).attr(\"y1\", liy - s).attr(\"x2\", mx + s).attr(\"y2\", liy + s)\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 2.5);\n    g.append(\"line\").attr(\"x1\", mx - s).attr(\"y1\", liy + s).attr(\"x2\", mx + s).attr(\"y2\", liy - s)\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 2.5);\n  } else if (item.type === \"dash\") {\n    g.append(\"line\").attr(\"x1\", lx).attr(\"y1\", liy).attr(\"x2\", lx + 26).attr(\"y2\", liy)\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 2).attr(\"stroke-dasharray\", \"6,3\").attr(\"opacity\", 0.75);\n  } else {\n    g.append(\"line\").attr(\"x1\", lx).attr(\"y1\", liy).attr(\"x2\", lx + 26).attr(\"y2\", liy)\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 3);\n  }\n  g.append(\"text\").attr(\"x\", lx + 34).attr(\"y\", liy + 5)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\").text(item.label);\n});\n\n// --- Axis labels ------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw / 2).attr(\"y\", height - 22)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\")\n  .text(\"Real Axis  σ\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2)).attr(\"y\", 28)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\")\n  .text(\"Imaginary Axis  jω\");\n\n// --- Title ------------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"root-locus-basic · javascript · d3 · anyplot.ai\");\n"}