{"spec_id":"bode-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// bode-basic: Bode Plot for Frequency Response\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-17\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// System: H(s) = ωn² / [(s/ω₁ + 1)(s² + 2ζωns + ωn²)]\n// Third-order low-pass — natural freq 100 Hz, extra pole at 800 Hz, ζ = 0.2\nconst fn = 100;\nconst wn = 2 * Math.PI * fn;\nconst zeta = 0.2;\nconst f1 = 800;\nconst w1 = 2 * Math.PI * f1;\n\nfunction complexDiv(ar, ai, br, bi) {\n  const d = br * br + bi * bi;\n  return [(ar * br + ai * bi) / d, (ai * br - ar * bi) / d];\n}\n\n// Log-spaced frequencies 0.1–10 000 Hz (600 points)\nconst N = 600;\nconst fMin = 0.1;\nconst fMax = 10000;\nconst rawData = Array.from({ length: N }, (_, i) => {\n  const f = fMin * Math.pow(fMax / fMin, i / (N - 1));\n  const w = 2 * Math.PI * f;\n  const d1r = 1;\n  const d1i = w / w1;\n  const d2r = wn * wn - w * w;\n  const d2i = 2 * zeta * wn * w;\n  const dr = d1r * d2r - d1i * d2i;\n  const di = d1r * d2i + d1i * d2r;\n  const [Hr, Hi] = complexDiv(wn * wn, 0, dr, di);\n  const magnitude = 20 * Math.log10(Math.sqrt(Hr * Hr + Hi * Hi));\n  const phase = Math.atan2(Hi, Hr) * 180 / Math.PI;\n  return { f, magnitude, phase };\n});\n\n// Unwrap phase so it tracks continuously past ±180°\nconst data = [];\nlet prevPhase = null;\nfor (const { f, magnitude, phase: wp } of rawData) {\n  let phase = wp;\n  if (prevPhase !== null) {\n    let dp = phase - prevPhase;\n    if (dp > 180) dp -= 360;\n    if (dp < -180) dp += 360;\n    phase = prevPhase + dp;\n  }\n  prevPhase = phase;\n  data.push({ f, magnitude, phase });\n}\n\n// Gain crossover: magnitude descends through 0 dB\nlet gcIdx = -1;\nfor (let i = 1; i < data.length; i++) {\n  if (data[i - 1].magnitude >= 0 && data[i].magnitude < 0) { gcIdx = i; break; }\n}\nconst fGC = gcIdx > 0 ? data[gcIdx].f : null;\nconst phaseAtGC = gcIdx > 0 ? data[gcIdx].phase : null;\nconst phaseMargin = phaseAtGC !== null ? phaseAtGC + 180 : null;\n\n// Phase crossover: phase descends through −180°\nlet pcIdx = -1;\nfor (let i = 1; i < data.length; i++) {\n  if (data[i - 1].phase > -180 && data[i].phase <= -180) { pcIdx = i; break; }\n}\nconst fPC = pcIdx > 0 ? data[pcIdx].f : null;\nconst magAtPC = pcIdx > 0 ? data[pcIdx].magnitude : null;\nconst gainMargin = magAtPC !== null ? -magAtPC : null;\n\n// Layout\nconst margin = { top: 88, right: 50, bottom: 60, left: 90 };\nconst panelGap = 44;\nconst iw = width - margin.left - margin.right;\nconst panelH = (height - margin.top - margin.bottom - panelGap) / 2;\n\nconst svg = d3.select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"bode-basic · javascript · d3 · anyplot.ai\");\n\n// Subtitle — system description\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"H(s) = ωn² / [(s/ω₁ + 1)(s² + 2ζωns + ωn²)]  ·  ωn = 100 Hz, ζ = 0.2, ω₁ = 800 Hz\");\n\n// Shared log-frequency x-scale\nconst xScale = d3.scaleLog().domain([fMin, fMax]).range([0, iw]);\nconst xTickVals = [0.1, 1, 10, 100, 1000, 10000];\n\n// ── Magnitude panel ──────────────────────────────────────────────────────────\nconst magExtent = d3.extent(data, d => d.magnitude);\nconst yMagLo = Math.floor(magExtent[0] / 10) * 10 - 10;\nconst yMagHi = Math.ceil(magExtent[1] / 10) * 10 + 6;\nconst yMag = d3.scaleLinear().domain([yMagLo, yMagHi]).range([panelH, 0]);\n\nconst gMag = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Horizontal gridlines\nyMag.ticks(6).forEach(v => {\n  gMag.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", yMag(v)).attr(\"y2\", yMag(v))\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// Vertical frequency gridlines\nxTickVals.forEach(fv => {\n  gMag.append(\"line\")\n    .attr(\"x1\", xScale(fv)).attr(\"x2\", xScale(fv))\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// 0 dB reference line\ngMag.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", yMag(0)).attr(\"y2\", yMag(0))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"6,4\");\n\ngMag.append(\"text\")\n  .attr(\"x\", 5).attr(\"y\", yMag(0) - 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\")\n  .text(\"0 dB\");\n\n// Magnitude curve\ngMag.append(\"path\")\n  .datum(data)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"d\", d3.line().x(d => xScale(d.f)).y(d => yMag(d.magnitude)));\n\n// Gain-crossover vertical (phase margin marker)\nif (fGC !== null) {\n  const xGC = xScale(fGC);\n  gMag.append(\"line\")\n    .attr(\"x1\", xGC).attr(\"x2\", xGC)\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.palette[2])\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"5,4\");\n  gMag.append(\"circle\")\n    .attr(\"cx\", xGC).attr(\"cy\", yMag(0))\n    .attr(\"r\", 5).attr(\"fill\", t.palette[2]);\n  gMag.append(\"text\")\n    .attr(\"x\", xGC + 9).attr(\"y\", yMag(0) - 10)\n    .attr(\"fill\", t.palette[2]).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n    .text(`PM = ${phaseMargin.toFixed(1)}°`);\n}\n\n// Phase-crossover vertical (gain margin marker)\nif (fPC !== null) {\n  const xPC = xScale(fPC);\n  gMag.append(\"line\")\n    .attr(\"x1\", xPC).attr(\"x2\", xPC)\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.palette[3])\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"5,4\");\n  gMag.append(\"circle\")\n    .attr(\"cx\", xPC).attr(\"cy\", yMag(magAtPC))\n    .attr(\"r\", 5).attr(\"fill\", t.palette[3]);\n  // Bracket from magAtPC up to 0 dB\n  const yTop = yMag(0);\n  const yBot = yMag(magAtPC);\n  const bx = xPC + 5;\n  gMag.append(\"line\")\n    .attr(\"x1\", bx).attr(\"x2\", bx)\n    .attr(\"y1\", yTop).attr(\"y2\", yBot)\n    .attr(\"stroke\", t.palette[3]).attr(\"stroke-width\", 2);\n  for (const yy of [yTop, yBot]) {\n    gMag.append(\"line\")\n      .attr(\"x1\", bx - 4).attr(\"x2\", bx + 4)\n      .attr(\"y1\", yy).attr(\"y2\", yy)\n      .attr(\"stroke\", t.palette[3]).attr(\"stroke-width\", 2);\n  }\n  gMag.append(\"text\")\n    .attr(\"x\", bx + 8).attr(\"y\", (yTop + yBot) / 2 + 5)\n    .attr(\"fill\", t.palette[3]).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n    .text(`GM = ${gainMargin.toFixed(1)} dB`);\n}\n\n// Magnitude x-axis — tick marks only (labels on phase panel below)\nconst xMagG = gMag.append(\"g\")\n  .attr(\"transform\", `translate(0,${panelH})`)\n  .call(d3.axisBottom(xScale).tickValues(xTickVals).tickFormat(() => \"\").tickSize(5));\nxMagG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nxMagG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Magnitude y-axis\nconst yMagG = gMag.append(\"g\").call(d3.axisLeft(yMag).ticks(6));\nyMagG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nyMagG.selectAll(\"line\").attr(\"stroke\", t.grid);\nyMagG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Remove top spine of magnitude panel\ngMag.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", 0).attr(\"y2\", 0)\n  .attr(\"stroke\", \"none\");\n\n// Magnitude y-axis label\ngMag.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -panelH / 2).attr(\"y\", -72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"15px\")\n  .text(\"Magnitude (dB)\");\n\n// ── Phase panel ──────────────────────────────────────────────────────────────\nconst phaseMin = d3.min(data, d => d.phase);\nconst yPhaseLo = Math.floor(phaseMin / 45) * 45 - 10;\nconst yPhase = d3.scaleLinear().domain([yPhaseLo, 25]).range([panelH, 0]);\n\nconst gPhase = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top + panelH + panelGap})`);\n\n// Horizontal gridlines at canonical phase angles\nconst phaseGridVals = [0, -45, -90, -135, -180, -225, -270].filter(p => p >= yPhaseLo - 5);\nphaseGridVals.forEach(v => {\n  gPhase.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", yPhase(v)).attr(\"y2\", yPhase(v))\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// Vertical frequency gridlines\nxTickVals.forEach(fv => {\n  gPhase.append(\"line\")\n    .attr(\"x1\", xScale(fv)).attr(\"x2\", xScale(fv))\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// −180° reference line\ngPhase.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", yPhase(-180)).attr(\"y2\", yPhase(-180))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"6,4\");\n\ngPhase.append(\"text\")\n  .attr(\"x\", 5).attr(\"y\", yPhase(-180) - 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\")\n  .text(\"−180°\");\n\n// Phase curve\ngPhase.append(\"path\")\n  .datum(data)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"d\", d3.line().x(d => xScale(d.f)).y(d => yPhase(d.phase)));\n\n// Gain-crossover on phase panel — shows phase margin graphically\nif (fGC !== null) {\n  const xGC = xScale(fGC);\n  gPhase.append(\"line\")\n    .attr(\"x1\", xGC).attr(\"x2\", xGC)\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.palette[2])\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"5,4\");\n  gPhase.append(\"circle\")\n    .attr(\"cx\", xGC).attr(\"cy\", yPhase(phaseAtGC))\n    .attr(\"r\", 5).attr(\"fill\", t.palette[2]);\n  // Bracket from phaseAtGC to −180°\n  const y1 = yPhase(phaseAtGC);\n  const y2 = yPhase(-180);\n  const bx = xGC + 5;\n  gPhase.append(\"line\")\n    .attr(\"x1\", bx).attr(\"x2\", bx)\n    .attr(\"y1\", y1).attr(\"y2\", y2)\n    .attr(\"stroke\", t.palette[2]).attr(\"stroke-width\", 2);\n  for (const yy of [y1, y2]) {\n    gPhase.append(\"line\")\n      .attr(\"x1\", bx - 4).attr(\"x2\", bx + 4)\n      .attr(\"y1\", yy).attr(\"y2\", yy)\n      .attr(\"stroke\", t.palette[2]).attr(\"stroke-width\", 2);\n  }\n  gPhase.append(\"text\")\n    .attr(\"x\", bx + 8).attr(\"y\", (y1 + y2) / 2 + 5)\n    .attr(\"fill\", t.palette[2]).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n    .text(\"PM\");\n}\n\n// Phase-crossover on phase panel\nif (fPC !== null) {\n  const xPC = xScale(fPC);\n  gPhase.append(\"line\")\n    .attr(\"x1\", xPC).attr(\"x2\", xPC)\n    .attr(\"y1\", 0).attr(\"y2\", panelH)\n    .attr(\"stroke\", t.palette[3])\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"5,4\");\n  gPhase.append(\"circle\")\n    .attr(\"cx\", xPC).attr(\"cy\", yPhase(-180))\n    .attr(\"r\", 5).attr(\"fill\", t.palette[3]);\n}\n\n// Phase x-axis with frequency labels\nconst xPhaseG = gPhase.append(\"g\")\n  .attr(\"transform\", `translate(0,${panelH})`)\n  .call(\n    d3.axisBottom(xScale)\n      .tickValues(xTickVals)\n      .tickFormat(d => d >= 1000 ? `${d / 1000}k` : `${d}`)\n  );\nxPhaseG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nxPhaseG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nxPhaseG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Phase y-axis\nconst yPhaseG = gPhase.append(\"g\")\n  .call(d3.axisLeft(yPhase).tickValues(phaseGridVals));\nyPhaseG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nyPhaseG.selectAll(\"line\").attr(\"stroke\", t.grid);\nyPhaseG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Phase y-axis label\ngPhase.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -panelH / 2).attr(\"y\", -72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"15px\")\n  .text(\"Phase (°)\");\n\n// X-axis label (bottom of phase panel)\ngPhase.append(\"text\")\n  .attr(\"x\", iw / 2).attr(\"y\", panelH + 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"15px\")\n  .text(\"Frequency (Hz)\");\n"}