Ryan Malloy 646c92324d 3D antenna radiation pattern visualization: analytical models + Three.js web UI
Add analytical radiation pattern models for 5 antenna types (dipole, monopole,
EFHW, loop, patch) driven by S11 impedance measurements. Pure Python math with
closed-form far-field equations — no numpy or simulation dependencies.

New MCP tools:
- radiation_pattern: scan S11 → find resonance → compute 3D pattern
- radiation_pattern_from_data: compute from known impedance (no hardware)
- radiation_pattern_multi: patterns across a frequency band for animation

Web UI (opt-in via MCNANOVNA_WEB_PORT env var):
- Three.js gain-mapped sphere with OrbitControls
- Surface/wireframe/plane cut display modes with teal→amber color ramp
- Smith chart overlay, dBi reference rings, E/H plane cross-sections
- Real-time WebSocket push on new pattern computation
- FastAPI backend shares process with MCP server, zero new core deps

Frontend: Vite + TypeScript + Three.js, built assets committed to webui/static/.
Optional dependencies: fastapi + uvicorn via pip install mcnanovna[webui].
2026-01-31 15:27:19 -07:00

155 lines
4.1 KiB
TypeScript

import type { ResonanceData } from './types';
const GRID_COLOR = '#475569'; // slate-600
const POINT_COLOR = '#2dd4bf'; // teal-400
const BG_COLOR = '#1e293b'; // slate-800
const BORDER_COLOR = '#334155'; // slate-700
const TEXT_COLOR = '#94a3b8'; // slate-400
/**
* Draw the Smith chart grid on a 2D canvas.
* Uses normalized impedance: z = Z/Z0 where Z0=50.
* Smith chart maps z to reflection coefficient Gamma = (z-1)/(z+1).
*/
export function drawSmithChart(canvas: HTMLCanvasElement, resonance?: ResonanceData): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const R = (Math.min(w, h) / 2) * 0.85; // chart radius in pixels
// Clear
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
// Border
ctx.strokeStyle = BORDER_COLOR;
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, w, h);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
ctx.clip();
// Constant R circles
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 0.5;
const rValues = [0, 0.2, 0.5, 1, 2, 5];
for (const r of rValues) {
// Circle center at ((r/(r+1))*R + cx, cy), radius R/(r+1)
const circR = R / (r + 1);
const circX = cx + (r / (r + 1)) * R;
ctx.beginPath();
ctx.arc(circX, cy, circR, 0, 2 * Math.PI);
ctx.stroke();
}
// Constant X arcs
const xValues = [0.2, 0.5, 1, 2, 5];
for (const x of xValues) {
// Positive X arc (inductive, above center)
drawXArc(ctx, cx, cy, R, x);
// Negative X arc (capacitive, below center)
drawXArc(ctx, cx, cy, R, -x);
}
// Horizontal center line
ctx.beginPath();
ctx.moveTo(cx - R, cy);
ctx.lineTo(cx + R, cy);
ctx.stroke();
ctx.restore();
// Outer circle
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
ctx.stroke();
// Title
ctx.fillStyle = TEXT_COLOR;
ctx.font = "10px 'Inter', sans-serif";
ctx.textAlign = 'center';
ctx.fillText('Smith Chart', cx, h - 4);
// Plot impedance point if we have resonance data
if (resonance) {
const z_real = resonance.impedance_real / 50;
const z_imag = resonance.impedance_imag / 50;
// Reflection coefficient: Gamma = (z-1)/(z+1) where z = z_real + j*z_imag
const denom_r = (z_real + 1) * (z_real + 1) + z_imag * z_imag;
const gamma_real = ((z_real * z_real + z_imag * z_imag - 1)) / denom_r;
const gamma_imag = (2 * z_imag) / denom_r;
const px = cx + gamma_real * R;
const py = cy - gamma_imag * R; // y-axis inverted in canvas
// Draw point
ctx.fillStyle = POINT_COLOR;
ctx.beginPath();
ctx.arc(px, py, 4, 0, 2 * Math.PI);
ctx.fill();
// Outline
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px, py, 4, 0, 2 * Math.PI);
ctx.stroke();
// Label
ctx.fillStyle = POINT_COLOR;
ctx.font = "bold 9px 'Inter', sans-serif";
ctx.textAlign = 'left';
const label = `${resonance.impedance_real.toFixed(0)}${resonance.impedance_imag >= 0 ? '+' : ''}${resonance.impedance_imag.toFixed(0)}j`;
ctx.fillText(label, px + 7, py + 3);
}
}
function drawXArc(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
R: number,
x: number
): void {
const arcR = R / Math.abs(x);
const centerX = cx + R;
const centerY = x > 0 ? cy - arcR : cy + arcR;
// We need to clip the arc to within the unit circle.
// Use many small segments and only draw those inside.
const steps = 100;
ctx.beginPath();
let drawing = false;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI;
const angle = x > 0 ? -Math.PI / 2 + t : Math.PI / 2 - t;
const px = centerX + arcR * Math.cos(angle);
const py = centerY + arcR * Math.sin(angle);
// Check if inside unit circle
const dx = px - cx;
const dy = py - cy;
if (dx * dx + dy * dy <= R * R * 1.01) {
if (!drawing) {
ctx.moveTo(px, py);
drawing = true;
} else {
ctx.lineTo(px, py);
}
} else {
drawing = false;
}
}
ctx.stroke();
}