{"spec_id":"polar-line","library":"muix","language":"javascript","code":"// anyplot.ai\n// polar-line: Polar Line Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// polar-line: Polar Line Plot\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-05\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\n\n// @mui/x-charts 7.x community has no polar/radial-line chart component (a\n// PolarProvider exists internally but isn't part of the public export\n// surface), so the plot is composed on MUI X's own charting surface:\n// ChartContainer sizes the <svg> + theme, and useDrawingArea() gives the plot\n// rect the hand-drawn ring/spoke/polyline geometry is mapped onto — real\n// trigonometry from the real gain values, not faked chrome. Each series'\n// angle/gain pairs are ALSO registered as a genuine `scatter` series (mapped\n// through a matching linear xAxis/yAxis so the pixels line up exactly with\n// the hand-drawn polylines), so ChartsLegend and ChartsTooltip read real\n// series data and MUI's own hover-highlight works — nothing here is faked.\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// Theme-adaptive chrome (ThemeProvider handles MUI text; these are for our SVG).\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\nconst PAGE_BG = t.pageBg;\nconst OMNI_COLOR = t.palette[0]; // Imprint palette position 1 — always first series\nconst YAGI_COLOR = t.palette[1];\n\n// --- Data (in-memory, deterministic): simulated far-field radiation patterns\n// for two antenna types, sampled every 15° of azimuth — the classic \"line\n// plot in polar coordinates\" use case from RF/antenna engineering ------------\nconst ANGLE_STEP = 15;\nconst ANGLES = Array.from({ length: 360 / ANGLE_STEP }, (_, i) => i * ANGLE_STEP);\nconst GAIN_MAX = 1; // normalized gain, 0-1\nconst RINGS = [0.2, 0.4, 0.6, 0.8, 1.0];\nconst DEGREE_TICKS = [0, 45, 90, 135, 180, 225, 270, 315];\n\n// Omnidirectional dipole: near-circular with the small real-world ripple that\n// distinguishes an actual antenna from an idealized isotropic radiator.\nconst omniGain = (deg) => {\n  const rad = (deg * Math.PI) / 180;\n  return 0.78 + 0.05 * Math.cos(3 * rad) + 0.03 * Math.cos(7 * rad);\n};\n\n// Directional Yagi: a narrow forward main lobe (boresight at 0°) plus a small\n// back lobe — modeled with clamped cosine powers rather than measured data.\nconst yagiGain = (deg) => {\n  const rad = (deg * Math.PI) / 180;\n  const c = Math.cos(rad);\n  const mainLobe = Math.pow(Math.max(0, c), 6);\n  const backLobe = 0.18 * Math.pow(Math.max(0, -c), 10);\n  return Math.min(1, 0.06 + 0.94 * mainLobe + backLobe);\n};\n\nconst omni = ANGLES.map((deg) => ({ deg, gain: omniGain(deg) }));\nconst yagi = ANGLES.map((deg) => ({ deg, gain: yagiGain(deg) }));\n\n// 0° points to the top (boresight), gain grows clockwise with azimuth — same\n// convention as a compass rose, matching the spoke labels drawn below.\nconst toRad = (deg) => ((deg - 90) * Math.PI) / 180;\n\n// --- Real MUI X scatter series for the sampled points ------------------------\n// A shared linear xAxis/yAxis pair whose domain is sized so that gain 1\n// (GAIN_MAX) lands exactly `DATA_R` px from centre — the same radius the\n// hand-drawn rings/polylines below use — so the real MUI scatter dots\n// register precisely on top of the custom SVG geometry. The yAxis grows\n// upward in data space while SVG grows downward in pixel space, so the y\n// component is negated here to compensate.\nconst MARGIN = 110;\nconst HALF = Math.min(size.width, size.height) / 2 - MARGIN;\nconst DATA_R = HALF - 76;\nconst DOMAIN = HALF / DATA_R;\nconst toPoint = (deg, gain) => {\n  const a = toRad(deg);\n  const frac = gain / GAIN_MAX;\n  return { x: frac * Math.cos(a), y: -frac * Math.sin(a) };\n};\nconst SERIES = [\n  {\n    type: \"scatter\",\n    data: omni.map(({ deg, gain }) => ({ ...toPoint(deg, gain), id: `omni-${deg}`, deg, gain })),\n    color: OMNI_COLOR,\n    markerSize: 8,\n    label: \"Omnidirectional dipole\",\n    valueFormatter: (v) => `${v.deg}° · gain ${v.gain.toFixed(2)}`,\n  },\n  {\n    type: \"scatter\",\n    data: yagi.map(({ deg, gain }) => ({ ...toPoint(deg, gain), id: `yagi-${deg}`, deg, gain })),\n    color: YAGI_COLOR,\n    markerSize: 8,\n    label: \"Directional Yagi\",\n    valueFormatter: (v) => `${v.deg}° · gain ${v.gain.toFixed(2)}`,\n  },\n];\n\n// --- Polar layer: rendered as children inside MUI X's ChartsSurface ----------\nfunction PolarLayer() {\n  const area = useDrawingArea();\n  const cx = area.left + area.width / 2;\n  const cy = area.top + area.height / 2;\n  const half = Math.min(area.width, area.height) / 2;\n  const R = half - 76; // data radius; the margin to `half` holds degree labels\n  const labelR = half - 18;\n\n  const point = (deg, gain) => {\n    const a = toRad(deg);\n    const r = (gain / GAIN_MAX) * R;\n    return [cx + r * Math.cos(a), cy + r * Math.sin(a)];\n  };\n  const polylinePoints = (series) =>\n    series.map(({ deg, gain }) => point(deg, gain).join(\",\")).join(\" \");\n\n  return (\n    <g>\n      {/* Concentric gain rings — outer solid, inner lighter so the grid stays subtle */}\n      {RINGS.map((level) => (\n        <circle\n          key={`ring-${level}`}\n          cx={cx}\n          cy={cy}\n          r={(level / GAIN_MAX) * R}\n          fill=\"none\"\n          stroke={GRID}\n          strokeWidth={level === GAIN_MAX ? 2 : 1}\n          strokeOpacity={level === GAIN_MAX ? 1 : 0.6}\n        />\n      ))}\n\n      {/* Radial spokes at every 45° of azimuth, with degree labels */}\n      {DEGREE_TICKS.map((deg) => {\n        const [ox, oy] = point(deg, GAIN_MAX);\n        const a = toRad(deg);\n        const lx = cx + labelR * Math.cos(a);\n        const ly = cy + labelR * Math.sin(a);\n        const cos = Math.cos(a);\n        const sin = Math.sin(a);\n        const anchor = cos > 0.3 ? \"start\" : cos < -0.3 ? \"end\" : \"middle\";\n        const baseline = sin > 0.5 ? \"hanging\" : sin < -0.5 ? \"auto\" : \"central\";\n        const isCardinal = deg % 90 === 0;\n        return (\n          <g key={`spoke-${deg}`}>\n            <line x1={cx} y1={cy} x2={ox} y2={oy} stroke={GRID} strokeWidth={1} />\n            <text\n              x={lx}\n              y={ly}\n              fill={isCardinal ? INK : INK_SOFT}\n              fontSize={isCardinal ? 18 : 14}\n              fontWeight={isCardinal ? 700 : 400}\n              textAnchor={anchor}\n              dominantBaseline={baseline}\n            >\n              {`${deg}°`}\n            </text>\n          </g>\n        );\n      })}\n\n      {/* The two radiation-pattern lines — closed loops since azimuth wraps at 360° */}\n      <polygon\n        points={polylinePoints(omni)}\n        fill=\"none\"\n        stroke={OMNI_COLOR}\n        strokeWidth={3}\n        strokeLinejoin=\"round\"\n      />\n      <polygon\n        points={polylinePoints(yagi)}\n        fill=\"none\"\n        stroke={YAGI_COLOR}\n        strokeWidth={3}\n        strokeLinejoin=\"round\"\n      />\n\n      {/* Gain (radius) tick labels, offset off the top spoke, drawn last so\n          they sit above the pattern lines that cross it. A page-background\n          stroke halo (instead of a flat rect) keeps them legible without the\n          utilitarian pill look. */}\n      {RINGS.map((level) => {\n        const ty = cy - (level / GAIN_MAX) * R;\n        const label = level.toFixed(1);\n        return (\n          <text\n            key={`tick-${level}`}\n            x={cx + 12}\n            y={ty}\n            fill={INK_SOFT}\n            fontSize={15}\n            textAnchor=\"start\"\n            dominantBaseline=\"central\"\n            stroke={PAGE_BG}\n            strokeWidth={4}\n            strokeLinejoin=\"round\"\n            paintOrder=\"stroke\"\n          >\n            {label}\n          </text>\n        );\n      })}\n\n      {/* Descriptive label for the radial (gain) axis, running alongside the\n          ring ticks so the numbers aren't bare digits */}\n      <text\n        x={cx + 58}\n        y={cy - (RINGS[2] / GAIN_MAX) * R}\n        fill={INK_SOFT}\n        fontSize={13}\n        fontWeight={600}\n        textAnchor=\"middle\"\n        dominantBaseline=\"central\"\n        stroke={PAGE_BG}\n        strokeWidth={4}\n        strokeLinejoin=\"round\"\n        paintOrder=\"stroke\"\n        transform={`rotate(-90, ${cx + 58}, ${cy - (RINGS[2] / GAIN_MAX) * R})`}\n      >\n        Normalized gain\n      </text>\n    </g>\n  );\n}\n\n// --- Title drawn on the surface -----------------------------------------------\nfunction Title() {\n  return (\n    <text x={size.width / 2} y={52} fill={INK} fontSize={30} fontWeight={700} textAnchor=\"middle\">\n      polar-line · javascript · muix · anyplot.ai\n    </text>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) --------------\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={size.width}\n      height={size.height}\n      series={SERIES}\n      xAxis={[{ scaleType: \"linear\", min: -DOMAIN, max: DOMAIN }]}\n      yAxis={[{ scaleType: \"linear\", min: -DOMAIN, max: DOMAIN }]}\n      margin={{ top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN }}\n      skipAnimation\n    >\n      <Title />\n      <PolarLayer />\n      <ScatterPlot />\n      <ChartsLegend\n        position={{ vertical: \"bottom\", horizontal: \"middle\" }}\n        labelStyle={{ fontSize: 16 }}\n        itemMarkWidth={22}\n        itemMarkHeight={22}\n        markGap={8}\n        itemGap={36}\n      />\n      <ChartsTooltip trigger=\"item\" />\n    </ChartContainer>\n  );\n}\n"}