{"spec_id":"bode-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bode-basic: Bode Plot for Frequency Response\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-17\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Open-loop transfer function G(jω) = 1 / (jω · (1 + jω/p1) · ((jω/ωn)² + 2ζ(jω/ωn) + 1))\n// Real pole at ω=1 rad/s, underdamped complex pair at ωn=5 rad/s (ζ=0.1) → resonance peak\nconst p1 = 1, wn = 5, zeta = 0.1;\nconst N = 500;\nconst W_MIN = 0.01, W_MAX = 1000;\n\nconst magData = [], phaseData = [];\nlet gainCrossFreq = null, phaseCrossFreq = null;\nlet phaseMargin = null, gainMargin = null;\n\nfor (let i = 0; i < N; i++) {\n  const w = Math.pow(10, Math.log10(W_MIN) + (i / (N - 1)) * (Math.log10(W_MAX) - Math.log10(W_MIN)));\n\n  // Underdamped second-order factor denominator: (1 - (ω/ωn)²) + j(2ζω/ωn)\n  const re = 1 - (w / wn) ** 2;\n  const im = 2 * zeta * w / wn;\n\n  // |G| = (1/ω) · 1/sqrt(1+(ω/p1)²) · 1/sqrt(re²+im²)\n  const mag = (1 / w) * (1 / Math.sqrt(1 + (w / p1) ** 2)) * (1 / Math.sqrt(re * re + im * im));\n  const magDb = 20 * Math.log10(mag);\n\n  // phase(G) = -90° - atan(ω/p1) - atan2(im, re) [degrees]\n  const phase = -90 - (Math.atan(w / p1) * 180 / Math.PI) - (Math.atan2(im, re) * 180 / Math.PI);\n\n  magData.push({ x: w, y: magDb });\n  phaseData.push({ x: w, y: phase });\n\n  if (i > 0) {\n    if (gainCrossFreq === null && magData[i - 1].y > 0 && magDb <= 0) {\n      gainCrossFreq = w;\n      phaseMargin = 180 + phase;\n    }\n    if (phaseCrossFreq === null && phaseData[i - 1].y > -180 && phase <= -180) {\n      phaseCrossFreq = w;\n      gainMargin = -magDb;\n    }\n  }\n}\n\n// --- Layout: flex column, top = magnitude, bottom = phase ---\nconst container = document.getElementById('container');\ncontainer.style.display = 'flex';\ncontainer.style.flexDirection = 'column';\ncontainer.style.background = t.pageBg;\ncontainer.style.boxSizing = 'border-box';\n\nfunction addPanel() {\n  const wrap = document.createElement('div');\n  wrap.style.cssText = 'position:relative;flex:1;width:100%;min-height:0;overflow:hidden';\n  container.appendChild(wrap);\n  const canvas = document.createElement('canvas');\n  wrap.appendChild(canvas);\n  return canvas;\n}\n\nconst magCanvas = addPanel();\nconst phaseCanvas = addPanel();\n\n// Log-axis tick formatter: show powers of 10 only\nconst logTickCallback = (val) => {\n  const exp = Math.log10(val);\n  const rounded = Math.round(exp);\n  if (Math.abs(exp - rounded) < 0.02) {\n    if (rounded >= 0) return String(Math.round(Math.pow(10, rounded)));\n    return (Math.pow(10, rounded)).toFixed(-rounded);\n  }\n  return '';\n};\n\nconst logXBase = {\n  type: 'logarithmic',\n  min: W_MIN,\n  max: W_MAX,\n  border: { display: false },\n  grid: { color: t.grid },\n  ticks: { color: t.inkSoft, font: { size: 14 }, callback: logTickCallback }\n};\n\n// L-frame plugin: draw only left + bottom axis lines (remove default box)\nfunction makeLFramePlugin() {\n  return {\n    id: 'lFrame',\n    afterDraw(chart) {\n      const { ctx, chartArea } = chart;\n      ctx.save();\n      ctx.strokeStyle = t.inkSoft;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(chartArea.left, chartArea.top);\n      ctx.lineTo(chartArea.left, chartArea.bottom);\n      ctx.lineTo(chartArea.right, chartArea.bottom);\n      ctx.stroke();\n      ctx.restore();\n    }\n  };\n}\n\n// Custom afterDraw plugin for margin annotations\nfunction makeMarginPlugin(annotations) {\n  return {\n    id: 'marginLabels',\n    afterDraw(chart) {\n      const ctx = chart.ctx;\n      const xScale = chart.scales.x;\n      const yScale = chart.scales.y;\n      ctx.save();\n      ctx.font = 'bold 14px sans-serif';\n      ctx.textBaseline = 'bottom';\n      for (const ann of annotations) {\n        if (ann.xValue == null) continue;\n        const xPx = xScale.getPixelForValue(ann.xValue);\n        const yPx = yScale.getPixelForValue(ann.yValue);\n        ctx.fillStyle = ann.color;\n        ctx.textAlign = ann.align || 'left';\n        ctx.fillText(ann.text, xPx + (ann.dx || 6), yPx + (ann.dy || -4));\n      }\n      ctx.restore();\n    }\n  };\n}\n\nconst MAG_MIN = -80, MAG_MAX = 40;\nconst PHASE_MIN = -270, PHASE_MAX = 0;\n\n// ---- Magnitude chart datasets ----\nconst magDatasets = [\n  {\n    label: 'Magnitude',\n    data: magData,\n    borderColor: t.palette[0],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 3\n  },\n  {\n    label: '0 dB',\n    data: [{ x: W_MIN, y: 0 }, { x: W_MAX, y: 0 }],\n    borderColor: t.inkSoft,\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1,\n    borderDash: [8, 5]\n  }\n];\n\nif (gainCrossFreq !== null) {\n  magDatasets.push({\n    label: 'Phase margin',\n    data: [{ x: gainCrossFreq, y: MAG_MIN }, { x: gainCrossFreq, y: MAG_MAX }],\n    borderColor: t.palette[1],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1.5,\n    borderDash: [5, 5]\n  });\n}\n\nif (phaseCrossFreq !== null) {\n  magDatasets.push({\n    label: 'Gain margin',\n    data: [{ x: phaseCrossFreq, y: MAG_MIN }, { x: phaseCrossFreq, y: MAG_MAX }],\n    borderColor: t.palette[2],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1.5,\n    borderDash: [5, 5]\n  });\n}\n\nconst magAnnotations = [];\nif (gainCrossFreq !== null && phaseMargin !== null) {\n  magAnnotations.push({\n    xValue: gainCrossFreq,\n    yValue: 8,\n    text: 'PM = ' + phaseMargin.toFixed(1) + '°',\n    color: t.palette[1],\n    align: 'left',\n    dx: 8,\n    dy: 0\n  });\n}\nif (phaseCrossFreq !== null && gainMargin !== null) {\n  magAnnotations.push({\n    xValue: phaseCrossFreq,\n    yValue: -gainMargin / 2,\n    text: 'GM = ' + gainMargin.toFixed(1) + ' dB',\n    color: t.palette[2],\n    align: 'left',\n    dx: 8,\n    dy: 0\n  });\n}\n\nnew Chart(magCanvas, {\n  type: 'scatter',\n  data: { datasets: magDatasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: 'bode-basic · javascript · chartjs · anyplot.ai',\n        color: t.ink,\n        font: { size: 22, weight: '500' },\n        padding: { top: 12, bottom: 4 }\n      },\n      legend: { display: false },\n      tooltip: { enabled: false }\n    },\n    scales: {\n      x: Object.assign({}, logXBase, {\n        ticks: Object.assign({}, logXBase.ticks, { display: false })\n      }),\n      y: {\n        min: MAG_MIN,\n        max: MAG_MAX,\n        border: { display: false },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: { display: true, text: 'Magnitude (dB)', color: t.ink, font: { size: 16 } }\n      }\n    }\n  },\n  plugins: [makeLFramePlugin(), makeMarginPlugin(magAnnotations)]\n});\n\n// ---- Phase chart datasets ----\nconst phaseDatasets = [\n  {\n    label: 'Phase',\n    data: phaseData,\n    borderColor: t.palette[0],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 3\n  },\n  {\n    label: '-180°',\n    data: [{ x: W_MIN, y: -180 }, { x: W_MAX, y: -180 }],\n    borderColor: t.inkSoft,\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1,\n    borderDash: [8, 5]\n  }\n];\n\nif (gainCrossFreq !== null) {\n  phaseDatasets.push({\n    label: 'Phase margin',\n    data: [{ x: gainCrossFreq, y: PHASE_MIN }, { x: gainCrossFreq, y: PHASE_MAX }],\n    borderColor: t.palette[1],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1.5,\n    borderDash: [5, 5]\n  });\n}\n\nif (phaseCrossFreq !== null) {\n  phaseDatasets.push({\n    label: 'Gain margin',\n    data: [{ x: phaseCrossFreq, y: PHASE_MIN }, { x: phaseCrossFreq, y: PHASE_MAX }],\n    borderColor: t.palette[2],\n    backgroundColor: 'transparent',\n    showLine: true,\n    pointRadius: 0,\n    borderWidth: 1.5,\n    borderDash: [5, 5]\n  });\n}\n\nconst phaseAnnotations = [];\nif (gainCrossFreq !== null && phaseMargin !== null) {\n  phaseAnnotations.push({\n    xValue: gainCrossFreq,\n    yValue: -180 + phaseMargin / 2,\n    text: 'PM = ' + phaseMargin.toFixed(1) + '°',\n    color: t.palette[1],\n    align: 'left',\n    dx: 8,\n    dy: 0\n  });\n}\nif (phaseCrossFreq !== null) {\n  phaseAnnotations.push({\n    xValue: phaseCrossFreq,\n    yValue: -200,\n    text: 'ω_pc = ' + phaseCrossFreq.toFixed(1) + ' rad/s',\n    color: t.palette[2],\n    align: 'left',\n    dx: 8,\n    dy: 0\n  });\n}\n\nnew Chart(phaseCanvas, {\n  type: 'scatter',\n  data: { datasets: phaseDatasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: { display: false },\n      legend: { display: false },\n      tooltip: { enabled: false }\n    },\n    scales: {\n      x: Object.assign({}, logXBase, {\n        title: { display: true, text: 'Frequency (rad/s)', color: t.ink, font: { size: 16 } }\n      }),\n      y: {\n        min: PHASE_MIN,\n        max: PHASE_MAX,\n        border: { display: false },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: { display: true, text: 'Phase (°)', color: t.ink, font: { size: 16 } }\n      }\n    }\n  },\n  plugins: [makeLFramePlugin(), makeMarginPlugin(phaseAnnotations)]\n});\n"}