{"spec_id":"root-locus-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// root-locus-basic: Root Locus Plot for Control Systems\n// Library: highcharts 12.6.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-18\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Analytic roots for s³ + 6s² + 8s + K = 0 ---------------------------\n// Substitution s = t−2 gives depressed cubic t³ − 4t + K = 0.\n// disc < 0: three real roots (trigonometric); disc > 0: one real + complex pair (Cardano).\n// Returns [[re,im],[re,im],[re,im]] in FIXED branch order across all K:\n//   index 0 → pole at  0  (rightmost; upper complex after breakaway)\n//   index 1 → pole at −2  (middle;    lower complex after breakaway)\n//   index 2 → pole at −4  (leftmost;  always real, goes to −∞)\nfunction cubicRoots(K) {\n  const disc = K * K / 4 - 64 / 27;\n\n  if (disc <= 0) {\n    // Three real roots via trigonometric method\n    const r   = 4 / Math.sqrt(3);\n    const arg = Math.max(-1, Math.min(1, -3 * K * Math.sqrt(3) / 16));\n    const phi = Math.acos(arg);\n    return [\n      [r * Math.cos(phi / 3)                - 2, 0],\n      [r * Math.cos(phi / 3 - 2 * Math.PI / 3) - 2, 0],\n      [r * Math.cos(phi / 3 - 4 * Math.PI / 3) - 2, 0],\n    ];\n  }\n\n  // One real root + complex conjugate pair (Cardano)\n  const D  = Math.sqrt(disc);\n  const u1 = -Math.pow(K / 2 - D, 1 / 3); // < 0\n  const u2 = -Math.pow(K / 2 + D, 1 / 3); // < 0\n  const re = -(u1 + u2) / 2 - 2;           // real part of complex roots\n  const im = (Math.sqrt(3) / 2) * (u1 - u2); // imaginary part > 0\n  return [\n    [re,  im],         // upper complex → branch from pole at  0\n    [re, -im],         // lower complex → branch from pole at −2\n    [u1 + u2 - 2, 0], // real root     → branch from pole at −4\n  ];\n}\n\n// --- Root locus data: G(s) = K / (s(s+2)(s+4)) --------------------------\n// OL poles: 0, −2, −4  |  No zeros\n// Breakaway: s ≈ −0.845, K ≈ 3.08\n// Imaginary-axis crossing: K = 48, s = ±j2√2 ≈ ±j2.828\nconst N_STEPS  = 400;\nconst K_MAX    = 90;\nconst branches = [[], [], []];\n\nfor (let i = 0; i <= N_STEPS; i++) {\n  const K   = (i / N_STEPS) * K_MAX;\n  const pts = cubicRoots(K);\n  for (let b = 0; b < 3; b++) {\n    branches[b].push([+pts[b][0].toFixed(3), +pts[b][1].toFixed(3)]);\n  }\n}\n\n// --- Custom × marker symbol for open-loop poles --------------------------\nHighcharts.SVGRenderer.prototype.symbols.x_pole = function(x, y, w, h) {\n  const p = 0.22 * w;\n  return [\n    'M', x + p,     y + p,     'L', x + w - p, y + h - p,\n    'M', x + w - p, y + p,     'L', x + p,     y + h - p,\n  ];\n};\n\n// --- Series definitions --------------------------------------------------\nconst branchColors = [t.palette[0], t.palette[1], t.palette[2]]; // canonical palette order\nconst branchNames  = [\n  \"Branch 1  (OL pole s = 0)\",\n  \"Branch 2  (OL pole s = −2)\",\n  \"Branch 3  (OL pole s = −4)\",\n];\n\nconst branchSeries = branches.map((pts, b) => ({\n  name:      branchNames[b],\n  type:      \"line\",\n  data:      pts,\n  color:     branchColors[b],\n  lineWidth: 2.5,\n  marker:    { enabled: false },\n  enableMouseTracking: false,\n}));\n\nconst poleSeries = {\n  name:  \"Open-loop Poles\",\n  type:  \"scatter\",\n  data:  [[0, 0], [-2, 0], [-4, 0]],\n  color: t.ink,\n  marker: {\n    symbol:    \"x_pole\",\n    lineWidth: 3.5,\n    lineColor: t.ink,\n    fillColor: \"none\",\n    radius:    9,\n  },\n  enableMouseTracking: false,\n  zIndex: 5,\n};\n\n// Imaginary-axis crossings at K = 48, s = ±j2√2 (stability boundary)\nconst jOmegaCross = +Math.sqrt(8).toFixed(4);\nconst crossSeries = {\n  name:  \"jω Crossings  (K = 48, stability boundary)\",\n  type:  \"scatter\",\n  data:  [[0, jOmegaCross], [0, -jOmegaCross]],\n  color: t.palette[4], // matte red — stability / loss\n  marker: { symbol: \"diamond\", radius: 8, lineWidth: 2, lineColor: t.palette[4] },\n  enableMouseTracking: false,\n  zIndex: 5,\n};\n\n// Constant damping-ratio guide lines (ζ = 0.5, ζ = 0.7), clipped at y = ±6\nfunction dampingLine(zeta, upper) {\n  const slope = Math.sqrt(1 - zeta * zeta) / zeta;\n  const yLim  = 6;\n  const xEnd  = -(yLim / slope);\n  return {\n    type:      \"line\",\n    data:      [[0, 0], [xEnd, upper ? yLim : -yLim]],\n    color:     t.grid,\n    lineWidth: 1,\n    dashStyle: \"ShortDash\",\n    marker:    { enabled: false },\n    enableMouseTracking: false,\n    showInLegend: false,\n  };\n}\n\nconst guideSeries = [\n  dampingLine(0.5, true),  dampingLine(0.5, false),\n  dampingLine(0.7, true),  dampingLine(0.7, false),\n];\n\n// --- Chart ---------------------------------------------------------------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation:       false,\n    style:           { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  title: {\n    text:  \"root-locus-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  xAxis: {\n    title:         { text: \"Real Axis (σ)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    lineColor:     t.inkSoft,\n    tickColor:     t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    labels:        { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    min: -7, max: 5,\n    plotLines: [{ value: 0, color: t.inkSoft, width: 1.5, zIndex: 3 }],\n  },\n  yAxis: {\n    title:         { text: \"Imaginary Axis (jω)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    lineColor:     t.inkSoft,\n    tickColor:     t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    labels:        { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    min: -6, max: 6,\n    plotLines: [{ value: 0, color: t.inkSoft, width: 1.5, zIndex: 3 }],\n  },\n  legend: {\n    enabled:        true,\n    itemStyle:      { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  plotOptions: { series: { animation: false } },\n  series: [...guideSeries, ...branchSeries, poleSeries, crossSeries],\n});\n\n// --- Gain-direction arrows (post-render) ---------------------------------\n// Filled triangle tips at K=20 and K=60 oriented tangent to each branch,\n// indicating the direction of increasing gain as required by the spec.\n[20, 60].forEach(arrowK => {\n  const idx = Math.round(arrowK / K_MAX * N_STEPS);\n  if (idx < 2 || idx >= N_STEPS - 1) return;\n\n  for (let b = 0; b < 3; b++) {\n    const [x,  y]  = branches[b][idx];\n    const [nx, ny] = branches[b][idx + 1];\n    const [px, py] = branches[b][idx - 1];\n\n    const dx = nx - px, dy = ny - py;\n    if (dx * dx + dy * dy < 1e-10) return;\n\n    const cx = chart.xAxis[0].toPixels(x, false);\n    const cy = chart.yAxis[0].toPixels(y, false);\n\n    // SVG y increases downward, so negate dy for the pixel-space angle\n    const θ = Math.atan2(-dy, dx);\n    const [cos_θ, sin_θ] = [Math.cos(θ), Math.sin(θ)];\n    const [len, w] = [10, 5];\n\n    chart.renderer.path([\n      'M', cx + len * cos_θ,                       cy + len * sin_θ,\n      'L', cx - (len / 3) * cos_θ - w * sin_θ,     cy - (len / 3) * sin_θ + w * cos_θ,\n      'L', cx - (len / 3) * cos_θ + w * sin_θ,     cy - (len / 3) * sin_θ - w * cos_θ,\n      'Z',\n    ])\n      .attr({ fill: branchColors[b], 'stroke-width': 0, zIndex: 6 })\n      .add();\n  }\n});\n"}