{"spec_id":"radar-multi","library":"muix","language":"javascript","code":"// anyplot.ai\n// radar-multi: Multi-Series Radar Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-20\n//# anyplot-orientation: square\n// anyplot.ai\n// radar-multi: Multi-Series Radar Chart\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-08-20\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\n\n// @mui/x-charts 7.x community has no RadarChart component (added in v8), so the\n// radar is composed on MUI X's own charting surface: ChartContainer provides the\n// sized <svg> + drawing area, and useDrawingArea() gives the plot rect we map the\n// polar geometry onto. Everything is real data drawn to scale — no faked chrome.\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;\n\n// --- Data (in-memory, deterministic) — noise-cancelling headphone comparison --\nconst axes = [\n  \"Sound Quality\",\n  \"Noise Cancelling\",\n  \"Battery Life\",\n  \"Comfort\",\n  \"Build Quality\",\n  \"Value\",\n  \"Connectivity\",\n];\nconst series = [\n  { label: \"AudioMax Pro\", color: t.palette[0], values: [92, 88, 75, 80, 85, 60, 90] },\n  { label: \"SoundWave Elite\", color: t.palette[1], values: [78, 95, 90, 85, 70, 55, 82] },\n  { label: \"EchoBudget\", color: t.palette[2], values: [65, 60, 82, 70, 60, 92, 75] },\n];\nconst MAX = 100;\nconst RINGS = [20, 40, 60, 80, 100];\nconst N = axes.length;\n\n// Axis i points from the top (-90°) going clockwise; SVG y grows downward.\nconst angleOf = (i) => (-90 + (i * 360) / N) * (Math.PI / 180);\n\n// --- Radar layer: rendered as children inside MUI X's ChartsSurface -----------\nfunction RadarLayer() {\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 - 58; // data radius; the margin between R and half holds labels\n  const labelR = half - 6;\n\n  const point = (frac, i) => {\n    const a = angleOf(i);\n    return [cx + frac * R * Math.cos(a), cy + frac * R * Math.sin(a)];\n  };\n  const polygon = (frac) => axes.map((_, i) => point(frac, i).join(\",\")).join(\" \");\n\n  // Data-storytelling focal point: EchoBudget is the standout trade-off in this\n  // dataset — it trails on every axis except Value, where it clearly leads\n  // (92 vs. 60/55), the classic \"budget pick wins on the one thing that\n  // matters most to price-sensitive buyers\" story.\n  const echo = series.find((s) => s.label === \"EchoBudget\");\n  const valueIdx = axes.indexOf(\"Value\");\n  const [calloutX, calloutY] = point(echo.values[valueIdx] / MAX, valueIdx);\n  const calloutAngle = angleOf(valueIdx);\n  const anchorR = R * 0.32;\n  const anchorX = cx + anchorR * Math.cos(calloutAngle);\n  const anchorY = cy + anchorR * Math.sin(calloutAngle);\n\n  return (\n    <g>\n      {/* Alternating background bands for depth — page-bg polygons \"erase\" every\n          other ring so the grid reads as banded rings rather than a flat wash */}\n      {[...RINGS].reverse().map((level, idx) => (\n        <polygon\n          key={`band-${level}`}\n          points={polygon(level / MAX)}\n          fill={idx % 2 === 0 ? GRID : PAGE_BG}\n          fillOpacity={idx % 2 === 0 ? 0.07 : 1}\n        />\n      ))}\n\n      {/* Concentric grid rings at each value level — opacity eases in toward the\n          outer ring so the nested grid stays light near the center */}\n      {RINGS.map((level) => (\n        <polygon\n          key={`ring-${level}`}\n          points={polygon(level / MAX)}\n          fill=\"none\"\n          stroke={GRID}\n          strokeWidth={level === MAX ? 2 : 1}\n          strokeOpacity={0.35 + 0.65 * (level / MAX)}\n        />\n      ))}\n\n      {/* Radial spokes + outer axis labels */}\n      {axes.map((label, i) => {\n        const [ox, oy] = point(1, i);\n        const a = angleOf(i);\n        const lx = cx + labelR * Math.cos(a);\n        const ly = cy + labelR * Math.sin(a);\n        const cos = Math.cos(a);\n        const anchor = cos > 0.15 ? \"start\" : cos < -0.15 ? \"end\" : \"middle\";\n        const sin = Math.sin(a);\n        const baseline = sin > 0.5 ? \"hanging\" : sin < -0.5 ? \"auto\" : \"central\";\n        return (\n          <g key={`axis-${label}`}>\n            <line x1={cx} y1={cy} x2={ox} y2={oy} stroke={GRID} strokeWidth={1.25} />\n            <text\n              x={lx}\n              y={ly}\n              fill={INK}\n              fontSize={16}\n              fontWeight={600}\n              textAnchor={anchor}\n              dominantBaseline={baseline}\n            >\n              {label}\n            </text>\n          </g>\n        );\n      })}\n\n      {/* Value tick labels, offset off the top spoke so they read on their own */}\n      {RINGS.map((level) => (\n        <text\n          key={`tick-${level}`}\n          x={cx + 10}\n          y={cy - (level / MAX) * R}\n          fill={INK_SOFT}\n          fontSize={15}\n          textAnchor=\"start\"\n          dominantBaseline=\"central\"\n        >\n          {level}\n        </text>\n      ))}\n\n      {/* Series polygons: translucent fill + solid outline + vertex markers */}\n      {series.map((s) => (\n        <g key={`series-${s.label}`}>\n          <polygon\n            points={axes.map((_, i) => point(s.values[i] / MAX, i).join(\",\")).join(\" \")}\n            fill={s.color}\n            fillOpacity={0.22}\n            stroke={s.color}\n            strokeWidth={3}\n            strokeLinejoin=\"round\"\n          />\n          {axes.map((_, i) => {\n            const [px, py] = point(s.values[i] / MAX, i);\n            return (\n              <circle\n                key={`pt-${s.label}-${i}`}\n                cx={px}\n                cy={py}\n                r={5.5}\n                fill={s.color}\n                stroke={PAGE_BG}\n                strokeWidth={2}\n              />\n            );\n          })}\n        </g>\n      ))}\n\n      {/* Focal-point callout: highlights EchoBudget's Value lead */}\n      <g>\n        <line\n          x1={anchorX}\n          y1={anchorY}\n          x2={calloutX}\n          y2={calloutY}\n          stroke={echo.color}\n          strokeWidth={1.5}\n          strokeDasharray=\"4 3\"\n        />\n        <circle cx={calloutX} cy={calloutY} r={10} fill=\"none\" stroke={echo.color} strokeWidth={2} />\n        <rect\n          x={anchorX - 100}\n          y={anchorY - 32}\n          width={200}\n          height={46}\n          rx={8}\n          fill={PAGE_BG}\n          fillOpacity={0.88}\n          stroke={GRID}\n          strokeWidth={1}\n        />\n        <text x={anchorX} y={anchorY - 13} fill={INK} fontSize={15} fontWeight={700} textAnchor=\"middle\">\n          Best on Value\n        </text>\n        <text x={anchorX} y={anchorY + 8} fill={INK_SOFT} fontSize={13} textAnchor=\"middle\">\n          EchoBudget trails elsewhere\n        </text>\n      </g>\n    </g>\n  );\n}\n\n// --- Title + legend drawn on the surface, then the radar layer ----------------\nfunction Chrome() {\n  const legendGap = 320;\n  const legendStart = size.width / 2 - ((series.length - 1) * legendGap) / 2;\n  const legendY = size.height - 40;\n  return (\n    <g>\n      <text x={size.width / 2} y={50} fill={INK} fontSize={26} fontWeight={700} textAnchor=\"middle\">\n        radar-multi · javascript · muix · anyplot.ai\n      </text>\n      {series.map((s, i) => {\n        const x = legendStart + i * legendGap;\n        return (\n          <g key={`legend-${s.label}`}>\n            <rect x={x - 96} y={legendY - 13} width={24} height={24} rx={5} fill={s.color} />\n            <text x={x - 64} y={legendY - 1} fill={INK} fontSize={16} textAnchor=\"start\" dominantBaseline=\"central\">\n              {s.label}\n            </text>\n          </g>\n        );\n      })}\n    </g>\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={[]}\n      margin={{ top: 88, bottom: 88, left: 96, right: 96 }}\n      skipAnimation\n    >\n      <Chrome />\n      <RadarLayer />\n    </ChartContainer>\n  );\n}\n"}