{"spec_id":"smith-chart-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// smith-chart-basic: Smith Chart for RF/Impedance\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: S11 sweep of an antenna feed impedance across 1-6 GHz -----------\n// A short series inductor plus a parasitic capacitance models the feed\n// reactance; resistance is a Gaussian bump that peaks at Z0 exactly at the\n// self-resonant frequency, the way a real antenna's radiation resistance\n// approaches a matched condition near resonance.\nconst referenceImpedance = 50; // Z0, ohms\nconst seriesInductanceH = 3e-9; // 3 nH feed inductance\nconst parasiticCapacitanceF = 0.8e-12; // 0.8 pF parasitic capacitance\nconst selfResonantFreqHz = 1 / (2 * Math.PI * Math.sqrt(seriesInductanceH * parasiticCapacitanceF));\n\nconst sweepStartHz = 1e9;\nconst sweepEndHz = 6e9;\nconst sweepPointCount = 31;\n\n// Standard reflection-coefficient closed form: Gamma = (z - 1) / (z + 1),\n// z the impedance normalized to the reference impedance z0.\nfunction toReflectionCoefficient(resistance, reactance, z0) {\n  const zReal = resistance / z0;\n  const zImag = reactance / z0;\n  const denominator = (zReal + 1) ** 2 + zImag ** 2;\n  return {\n    x: (zReal ** 2 + zImag ** 2 - 1) / denominator,\n    y: (2 * zImag) / denominator,\n  };\n}\n\nconst sweepPoints = Array.from({ length: sweepPointCount }, (_, i) => {\n  const frequencyHz = sweepStartHz + ((sweepEndHz - sweepStartHz) * i) / (sweepPointCount - 1);\n  const omega = 2 * Math.PI * frequencyHz;\n  const reactance = omega * seriesInductanceH - 1 / (omega * parasiticCapacitanceF);\n  const detuning = (frequencyHz - selfResonantFreqHz) / 0.9e9;\n  const resistance = 35 + 15 * Math.exp(-(detuning * detuning));\n  const gamma = toReflectionCoefficient(resistance, reactance, referenceImpedance);\n  return { frequencyHz, resistance, reactance, x: gamma.x, y: gamma.y };\n});\n\nlet bestMatchIndex = 0;\nsweepPoints.forEach((point, i) => {\n  const magnitude = Math.hypot(point.x, point.y);\n  const bestMagnitude = Math.hypot(sweepPoints[bestMatchIndex].x, sweepPoints[bestMatchIndex].y);\n  if (magnitude < bestMagnitude) bestMatchIndex = i;\n});\nconst labeledIndices = [0, 6, 12, 18, 24, 30];\n\n// --- Smith chart grid geometry (normalized Gamma-plane, |Gamma| <= 1) ------\n// Constant-resistance circles: center (r/(1+r), 0), radius 1/(1+r) — r=0\n// degenerates to the |Gamma|=1 boundary itself.\n// Constant-reactance arcs: center (1, 1/x), radius 1/|x| — x=0 degenerates\n// to the real axis, drawn separately as a straight line.\nconst resistanceCircleValues = [0, 0.2, 0.5, 1, 2, 5];\nconst reactanceArcValues = [0.2, 0.5, 1, 2, 5];\nconst vswrReferenceValue = 2;\nconst vswrReferenceRadius = (vswrReferenceValue - 1) / (vswrReferenceValue + 1);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// Chart.js has no per-axis \"equal aspect\" option; without one the Smith\n// chart's circles render as ellipses whenever the title bar leaves the\n// chart area taller or shorter than it is wide. This plugin measures\n// pixels-per-unit on both axes after layout and expands the tighter axis's\n// range to match, so the circles stay true circles. Guarded by $aspectDone\n// so the single corrective chart.update() it triggers doesn't recurse.\nconst equalAspectPlugin = {\n  id: \"equalAspect\",\n  afterLayout(chart) {\n    if (chart.$aspectDone) return;\n    const { chartArea, scales } = chart;\n    const xs = scales.x;\n    const ys = scales.y;\n    const pxPerUnitX = chartArea.width / (xs.max - xs.min);\n    const pxPerUnitY = chartArea.height / (ys.max - ys.min);\n    if (pxPerUnitX < pxPerUnitY) {\n      const yCenter = (ys.min + ys.max) / 2;\n      const halfRange = chartArea.height / pxPerUnitX / 2;\n      ys.options.min = yCenter - halfRange;\n      ys.options.max = yCenter + halfRange;\n    } else {\n      const xCenter = (xs.min + xs.max) / 2;\n      const halfRange = chartArea.width / pxPerUnitY / 2;\n      xs.options.min = xCenter - halfRange;\n      xs.options.max = xCenter + halfRange;\n    }\n    chart.$aspectDone = true;\n    chart.update(\"none\");\n  },\n};\n\n// Chart.js core ships no Smith-chart geometry, so the grid (resistance\n// circles, reactance arcs, VSWR reference, value labels) is drawn directly\n// on the canvas via the public plugin hooks — the documented way to extend\n// core Chart.js rendering, same technique as the mohr-circle entry.\nconst smithChartPlugin = {\n  id: \"smithChart\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const px = (v) => scales.x.getPixelForValue(v);\n    const py = (v) => scales.y.getPixelForValue(v);\n    const originX = px(0);\n    const originY = py(0);\n    const unitPx = Math.abs(px(1) - originX);\n\n    ctx.save();\n    ctx.beginPath();\n    ctx.arc(originX, originY, unitPx, 0, 2 * Math.PI);\n    ctx.clip();\n\n    resistanceCircleValues.forEach((r) => {\n      const centerX = px(r / (1 + r));\n      const radiusPx = unitPx / (1 + r);\n      ctx.beginPath();\n      ctx.arc(centerX, originY, radiusPx, 0, 2 * Math.PI);\n      ctx.lineWidth = r === 0 ? 2 : 1.25;\n      ctx.strokeStyle = r === 0 ? t.inkSoft : t.grid;\n      ctx.stroke();\n    });\n\n    reactanceArcValues.forEach((xValue) => {\n      [xValue, -xValue].forEach((signedX) => {\n        const centerY = py(1 / signedX);\n        const radiusPx = unitPx / Math.abs(signedX);\n        ctx.beginPath();\n        ctx.arc(px(1), centerY, radiusPx, 0, 2 * Math.PI);\n        ctx.lineWidth = 1.25;\n        ctx.strokeStyle = t.grid;\n        ctx.stroke();\n      });\n    });\n\n    // Zero-reactance line: the x=0 arc degenerates to the real axis.\n    ctx.beginPath();\n    ctx.moveTo(px(-1), originY);\n    ctx.lineTo(px(1), originY);\n    ctx.lineWidth = 1.25;\n    ctx.strokeStyle = t.grid;\n    ctx.stroke();\n\n    // Optional VSWR reference circle (constant |Gamma| boundary, dashed).\n    ctx.beginPath();\n    ctx.setLineDash([6, 5]);\n    ctx.lineWidth = 1.75;\n    ctx.strokeStyle = t.amber;\n    ctx.arc(originX, originY, vswrReferenceRadius * unitPx, 0, 2 * Math.PI);\n    ctx.stroke();\n    ctx.setLineDash([]);\n\n    ctx.restore();\n\n    // Resistance-value labels, anchored where each circle crosses the real\n    // axis on its low-|Gamma| side (same closed-form used for the data curve).\n    // r=1 is skipped: its anchor is the origin itself, already marked by the\n    // matched-condition crosshair below.\n    ctx.save();\n    ctx.font = \"500 14px -apple-system, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"top\";\n    resistanceCircleValues\n      .filter((r) => r > 0 && r !== 1)\n      .forEach((r) => {\n        const anchor = toReflectionCoefficient(r, 0, 1);\n        ctx.fillText(String(r), px(anchor.x), originY + 8);\n      });\n    ctx.restore();\n\n    // Reactance-value labels, anchored on the |Gamma|=1 boundary via the same\n    // reflection formula with resistance=0 (a pure reactance always maps to\n    // the boundary), nudged further out for legibility.\n    ctx.save();\n    ctx.font = \"500 13px -apple-system, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    reactanceArcValues.forEach((xValue) => {\n      [xValue, -xValue].forEach((signedX) => {\n        const boundary = toReflectionCoefficient(0, signedX, 1);\n        const labelX = px(boundary.x * 1.09);\n        const labelY = py(boundary.y * 1.09);\n        ctx.textAlign = boundary.x >= 0 ? \"left\" : \"right\";\n        ctx.textBaseline = boundary.y >= 0 ? \"bottom\" : \"top\";\n        ctx.fillText(`${signedX > 0 ? \"+\" : \"−\"}j${Math.abs(signedX)}`, labelX, labelY);\n      });\n    });\n    ctx.restore();\n\n    // Matched-condition crosshair (Gamma = 0, Z = Z0) at the chart's center.\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1.5;\n    ctx.beginPath();\n    ctx.moveTo(originX - 8, originY);\n    ctx.lineTo(originX + 8, originY);\n    ctx.moveTo(originX, originY - 8);\n    ctx.lineTo(originX, originY + 8);\n    ctx.stroke();\n    ctx.font = \"500 13px -apple-system, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"right\";\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"Z0 matched\", originX - 12, originY - 10);\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const px = (v) => scales.x.getPixelForValue(v);\n    const py = (v) => scales.y.getPixelForValue(v);\n\n    ctx.save();\n    ctx.font = \"500 14px -apple-system, sans-serif\";\n    ctx.fillStyle = t.ink;\n    labeledIndices.forEach((index) => {\n      const point = sweepPoints[index];\n      const cx = px(point.x);\n      const cy = py(point.y);\n      const magnitude = Math.hypot(point.x, point.y) || 1;\n      const ux = point.x / magnitude;\n      const uy = point.y / magnitude;\n      const labelX = cx + ux * 20;\n      const labelY = cy - uy * 20;\n      ctx.textAlign = ux >= 0 ? \"left\" : \"right\";\n      ctx.textBaseline = uy >= 0 ? \"bottom\" : \"top\";\n      ctx.fillText(`${(point.frequencyHz / 1e9).toFixed(1)} GHz`, labelX, labelY);\n    });\n\n    const bestPoint = sweepPoints[bestMatchIndex];\n    ctx.font = \"600 14px -apple-system, sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(`best match — ${(bestPoint.frequencyHz / 1e9).toFixed(2)} GHz`, px(bestPoint.x) + 14, py(bestPoint.y) + 6);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Impedance locus (Gamma)\",\n        data: sweepPoints,\n        showLine: true,\n        fill: false,\n        tension: 0.3,\n        borderColor: t.palette[0],\n        borderWidth: 3.5,\n        pointBackgroundColor: (context) => (context.dataIndex === bestMatchIndex ? t.ink : t.palette[0]),\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 1.5,\n        pointRadius: (context) =>\n          context.dataIndex === bestMatchIndex ? 9 : labeledIndices.includes(context.dataIndex) ? 6 : 3.5,\n        pointHoverRadius: 9,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 20, right: 30, bottom: 20, left: 30 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"smith-chart-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label(context) {\n            const point = sweepPoints[context.dataIndex];\n            const freqGHz = (point.frequencyHz / 1e9).toFixed(2);\n            const reactanceSign = point.reactance >= 0 ? \"+\" : \"−\";\n            const gammaMagnitude = Math.hypot(point.x, point.y).toFixed(2);\n            return `${freqGHz} GHz — Z = ${point.resistance.toFixed(1)} ${reactanceSign} j${Math.abs(point.reactance).toFixed(1)} Ω, |Gamma| = ${gammaMagnitude}`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: { type: \"linear\", min: -1.3, max: 1.3, display: false },\n      y: { type: \"linear\", min: -1.3, max: 1.3, display: false },\n    },\n  },\n  plugins: [equalAspectPlugin, smithChartPlugin],\n});\n"}