{"spec_id":"radar-multi","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// radar-multi: Multi-Series Radar Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 91/100 | Updated: 2026-08-18\n//# anyplot-orientation: square\n\n// Only the core `highcharts` bundle is loaded (no highcharts-more), so the\n// native polar chart mode isn't available. As with anyplot's other radar\n// specs, each axis's polar coordinate is projected to Cartesian by hand and\n// the grid, spokes, labels, and data polygons are drawn with the core SVG\n// renderer on top of an otherwise-empty chart.\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (three smartphone models scored across launch-review criteria) --\nconst axes = [\"Battery Life\", \"Camera Quality\", \"Performance\", \"Display\", \"Value\", \"Durability\"];\nconst models = [\n  { name: \"Aurora X1\", color: t.palette[0], scores: [88, 72, 90, 85, 60, 78] },\n  { name: \"Nova S\", color: t.palette[1], scores: [65, 90, 75, 70, 85, 60] },\n  { name: \"Zenith Pro\", color: t.palette[2], scores: [75, 65, 82, 95, 55, 90] },\n];\nconst SCALE_MAX = 100;\nconst RINGS = [20, 40, 60, 80, 100];\nconst axisCount = axes.length;\n\nconst TITLE = \"radar-multi · javascript · highcharts · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / TITLE.length))) + \"px\";\n\n// --- Chart shell (core-only; the polygon overlay is drawn below) ----------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    margin: [110, 90, 150, 90],\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE,\n    style: { color: t.ink, fontSize: titleFontSize, fontWeight: \"600\" },\n  },\n  xAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  yAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink },\n    formatter() {\n      return `<b>${this.series.name}</b><br/>${this.point.name}: ${this.point.custom.score}`;\n    },\n  },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\n// Pin the hidden axes to a known pixel extent so a real scatter series can be\n// data-bound at the same projected vertices the renderer overlay draws below\n// — the PNG stays untouched, but the interactive HTML gets genuine tooltips.\nchart.xAxis[0].setExtremes(0, chart.plotWidth, false);\nchart.yAxis[0].setExtremes(0, chart.plotHeight, false);\n\n// --- Polar → Cartesian projection ------------------------------------------\nconst centerX = chart.plotLeft + chart.plotWidth / 2;\nconst centerY = chart.plotTop + chart.plotHeight / 2;\nconst radius = Math.min(chart.plotWidth, chart.plotHeight) / 2 - 80;\n\n// axis 0 points straight up, remaining axes proceed clockwise\nfunction angleFor(axisIndex) {\n  return -Math.PI / 2 + axisIndex * ((2 * Math.PI) / axisCount);\n}\nfunction project(axisIndex, fraction) {\n  const angle = angleFor(axisIndex);\n  return [centerX + radius * fraction * Math.cos(angle), centerY + radius * fraction * Math.sin(angle)];\n}\nfunction polygonPath(fractionAt) {\n  const path = [\"M\"];\n  for (let i = 0; i < axisCount; i += 1) {\n    const [x, y] = project(i, fractionAt(i));\n    path.push(...(i === 0 ? [x, y] : [\"L\", x, y]));\n  }\n  path.push(\"Z\");\n  return path;\n}\n\n// --- Concentric grid rings --------------------------------------------------\nRINGS.forEach((level, i) => {\n  chart.renderer\n    .path(polygonPath(() => level / SCALE_MAX))\n    .attr({\n      stroke: t.grid,\n      \"stroke-width\": i === RINGS.length - 1 ? 1.5 : 1,\n      \"stroke-linejoin\": \"round\",\n      fill: \"none\",\n      zIndex: 1,\n    })\n    .add();\n});\n\n// --- Spokes + axis labels ---------------------------------------------------\naxes.forEach((label, i) => {\n  const [tipX, tipY] = project(i, 1);\n  chart.renderer\n    .path([\"M\", centerX, centerY, \"L\", tipX, tipY])\n    .attr({ stroke: t.inkSoft, \"stroke-width\": 1, \"stroke-linecap\": \"round\", zIndex: 1 })\n    .add();\n\n  const angle = angleFor(i);\n  const cos = Math.cos(angle);\n  const sin = Math.sin(angle);\n  const [labelX, labelY] = project(i, 1 + 30 / radius);\n  const align = cos > 0.3 ? \"left\" : cos < -0.3 ? \"right\" : \"center\";\n  chart.renderer\n    .text(label, labelX, labelY + sin * 6 + 5)\n    .attr({ align, zIndex: 5 })\n    .css({ fontSize: \"15px\", fontWeight: \"600\", color: t.ink, fontFamily: \"inherit\" })\n    .add();\n});\n\n// --- Ring scale labels (along the top spoke) --------------------------------\nRINGS.forEach((level) => {\n  const [, ringY] = project(0, level / SCALE_MAX);\n  chart.renderer\n    .text(String(level), centerX + 8, ringY + 5)\n    .attr({ align: \"left\", zIndex: 4 })\n    .css({ fontSize: \"14px\", fontWeight: \"500\", color: t.inkSoft, fontFamily: \"inherit\" })\n    .add();\n});\n\n// --- Data polygons + real (hidden-marker) scatter series for tooltips ------\nmodels.forEach((model) => {\n  const vertices = model.scores.map((score, i) => project(i, score / SCALE_MAX));\n\n  chart.renderer\n    .path(polygonPath((i) => model.scores[i] / SCALE_MAX))\n    .attr({\n      fill: model.color,\n      \"fill-opacity\": 0.22,\n      stroke: model.color,\n      \"stroke-width\": 2.75,\n      \"stroke-linejoin\": \"round\",\n      zIndex: 3,\n    })\n    .add();\n\n  vertices.forEach(([x, y]) => {\n    chart.renderer\n      .circle(x, y, 9)\n      .attr({ fill: model.color, stroke: t.pageBg, \"stroke-width\": 2, zIndex: 4 })\n      .add();\n  });\n\n  chart.addSeries(\n    {\n      type: \"scatter\",\n      name: model.name,\n      color: model.color,\n      enableMouseTracking: true,\n      stickyTracking: false,\n      animation: false,\n      marker: {\n        enabled: false,\n        radius: 9,\n        states: { hover: { enabled: true, radius: 9, lineWidth: 2, lineColor: t.pageBg } },\n      },\n      data: vertices.map(([x, y], i) => ({\n        x: x - chart.plotLeft,\n        y: chart.plotTop + chart.plotHeight - y,\n        name: axes[i],\n        custom: { score: model.scores[i] },\n      })),\n    },\n    false\n  );\n});\nchart.redraw();\n\n// --- Legend ------------------------------------------------------------------\nconst swatchSize = 14;\nconst itemGap = 30;\nconst legendFontSize = 15;\nconst labelWidths = models.map((model) => model.name.length * 8.4);\nconst legendTotalWidth = models.reduce(\n  (sum, _model, i) => sum + swatchSize + 10 + labelWidths[i] + (i < models.length - 1 ? itemGap : 0),\n  0\n);\nlet cursorX = centerX - legendTotalWidth / 2;\nconst legendY = chart.plotTop + chart.plotHeight + 70;\n\nmodels.forEach((model, i) => {\n  chart.renderer\n    .rect(cursorX, legendY - swatchSize / 2, swatchSize, swatchSize, swatchSize / 3)\n    .attr({ fill: model.color, stroke: t.pageBg, \"stroke-width\": 1.5, zIndex: 5 })\n    .shadow({ color: t.ink, opacity: 0.12, width: 2, offsetX: 0, offsetY: 1 })\n    .add();\n  chart.renderer\n    .text(model.name, cursorX + swatchSize + 10, legendY + swatchSize / 2 - 2)\n    .attr({ align: \"left\", zIndex: 5 })\n    .css({ fontSize: `${legendFontSize}px`, fontWeight: \"500\", color: t.ink, fontFamily: \"inherit\" })\n    .add();\n  cursorX += swatchSize + 10 + labelWidths[i] + itemGap;\n});\n"}