{"spec_id":"polar-scatter","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// polar-scatter: Polar Scatter Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\n\n// Only the core `highcharts` bundle is loaded (no highcharts-more), so the\n// native polar chart type (chart.polar) isn't available — PolarComposition\n// lives in the highcharts-more module only. Each (direction, speed) polar\n// coordinate is projected to Cartesian ourselves and bound as real scatter\n// points on hidden, fixed-extent axes, so hover/tooltip still work in the\n// interactive HTML. The radial rings, compass spokes, and their labels have\n// no native polar-axis equivalent without highcharts-more, so those are\n// drawn once with the core SVG renderer.\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (wind observations: bearing in degrees, speed in m/s) ------------\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\nconst randNormal = (mean, std) => {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n};\n\nconst TIME_OF_DAY = [\"Morning\", \"Afternoon\", \"Evening\", \"Night\"];\nconst N_OBSERVATIONS = 130;\nconst observations = [];\nfor (let i = 0; i < N_OBSERVATIONS; i += 1) {\n  // Two prevailing regimes: a strong south-westerly and a lighter north-easterly.\n  const southwesterly = rand() < 0.65;\n  const meanBearing = southwesterly ? 232 : 48;\n  const bearingSpread = southwesterly ? 22 : 28;\n  const meanSpeed = southwesterly ? 13.5 : 7.5;\n  const speedSpread = southwesterly ? 3.5 : 2.5;\n\n  const bearing = ((randNormal(meanBearing, bearingSpread) % 360) + 360) % 360;\n  const speed = Math.max(0.6, randNormal(meanSpeed, speedSpread));\n  const timeOfDay = Math.floor(rand() * TIME_OF_DAY.length);\n  observations.push({ bearing, speed, timeOfDay });\n}\n\nconst maxSpeed = Math.max(...observations.map((o) => o.speed));\n// Round up to a multiple of 4 (not 5) so MAX_RADIUS/4 is always a whole\n// number — every ring label is a clean, evenly-spaced integer.\nconst MAX_RADIUS = Math.ceil(maxSpeed / 4) * 4;\nconst RING_STEP = MAX_RADIUS / 4;\nconst RING_LEVELS = [RING_STEP, RING_STEP * 2, RING_STEP * 3, MAX_RADIUS];\nconst COMPASS = [\n  { deg: 0, label: \"N\" },\n  { deg: 45, label: \"NE\" },\n  { deg: 90, label: \"E\" },\n  { deg: 135, label: \"SE\" },\n  { deg: 180, label: \"S\" },\n  { deg: 225, label: \"SW\" },\n  { deg: 270, label: \"W\" },\n  { deg: 315, label: \"NW\" },\n];\n\nconst TITLE = \"polar-scatter · javascript · highcharts · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / TITLE.length)) + \"px\";\n\n// --- Chart (empty core chart used as a canvas for the renderer overlay) ----\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    margin: [150, 110, 110, 110],\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE,\n    style: { color: t.ink, fontSize: titleFontSize, fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Wind bearing and speed from 130 station observations\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  yAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  legend: {\n    enabled: true,\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink },\n    formatter() {\n      const bearing = Math.round(this.point.custom.bearing);\n      const speed = this.point.custom.speed.toFixed(1);\n      return `<b>${this.series.name}</b><br/>${bearing}° · ${speed} m/s`;\n    },\n  },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\n// Fix the (visible: false) axes to a known pixel-space extent so real series\n// can be data-bound at the same polar-projected coordinates the renderer\n// overlay below uses for the grid.\nchart.xAxis[0].setExtremes(0, chart.plotWidth, false);\nchart.yAxis[0].setExtremes(0, chart.plotHeight, false);\n\n// --- Geometry ----------------------------------------------------------------\nconst cx = chart.plotLeft + chart.plotWidth / 2;\nconst cy = chart.plotTop + chart.plotHeight / 2;\nconst outerR = Math.min(chart.plotWidth, chart.plotHeight) / 2 - 60;\n\n// 0° points straight up (north); bearings increase clockwise, matching a compass.\nconst angleOf = (bearing) => ((bearing - 90) * Math.PI) / 180;\nconst pointAt = (bearing, radiusFrac) => {\n  const angle = angleOf(bearing);\n  return [cx + outerR * radiusFrac * Math.cos(angle), cy + outerR * radiusFrac * Math.sin(angle)];\n};\n// Project a renderer-space [x, y] pixel into the fixed-extent axis coordinates\n// the data-bound series below use (yAxis increases upward, so flip y).\nconst toAxisXY = ([sx, sy]) => [sx - chart.plotLeft, chart.plotTop + chart.plotHeight - sy];\n\n// Convert a palette hex color to rgba so overlapping markers can be given a\n// slight fill translucency without losing the category color.\nconst hexToRgba = (hex, alpha) => {\n  const clean = hex.replace(\"#\", \"\");\n  const value = parseInt(clean, 16);\n  const r = (value >> 16) & 255;\n  const g = (value >> 8) & 255;\n  const b = value & 255;\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n};\n\n// Subtle radial tint behind the grid — lifts the chrome beyond a flat plane\n// without competing with the data. Low alpha throughout so it reads as a\n// faint glow, not a filled disc.\nchart.renderer\n  .circle(cx, cy, outerR)\n  .attr({\n    fill: {\n      radialGradient: { cx: 0.5, cy: 0.5, r: 0.5 },\n      stops: [\n        [0, hexToRgba(t.elevatedBg, 0.4)],\n        [1, hexToRgba(t.elevatedBg, 0)],\n      ],\n    },\n    zIndex: 0,\n  })\n  .add();\n\n// --- Radial grid rings + value labels (no native polar axis without more.js) --\n// The two prevailing-wind clusters sit around 48° (20°-76°) and 232°\n// (210°-254°), so the SE sector stays clear of data at every radius — the\n// natural spot for the scale.\nconst RING_LABEL_ANGLE = 145;\nRING_LEVELS.forEach((level, index) => {\n  const isOutermost = index === RING_LEVELS.length - 1;\n  chart.renderer\n    .circle(cx, cy, outerR * (level / MAX_RADIUS))\n    .attr({ stroke: t.grid, \"stroke-width\": isOutermost ? 2 : 1, fill: \"none\", zIndex: 1 })\n    .add();\n\n  const [lx, ly] = pointAt(RING_LABEL_ANGLE, level / MAX_RADIUS);\n  chart.renderer\n    .text(`${level} m/s`, lx + 6, ly - 4)\n    .attr({ zIndex: 3 })\n    .css({ color: t.inkSoft, fontSize: \"13px\" })\n    .add();\n});\n\n// --- Angular spokes + compass labels -----------------------------------------\nCOMPASS.forEach(({ deg, label }) => {\n  const [ex, ey] = pointAt(deg, 1);\n  chart.renderer\n    .path([\"M\", cx, cy, \"L\", ex, ey])\n    .attr({ stroke: t.grid, \"stroke-width\": 1, zIndex: 1 })\n    .add();\n\n  const [lx, ly] = pointAt(deg, 1.08);\n  chart.renderer\n    .text(label, lx, ly)\n    .attr({ align: \"center\", zIndex: 3 })\n    .css({ color: t.inkSoft, fontSize: \"16px\", fontWeight: \"600\" })\n    .add();\n});\n\n// --- Scatter series, one per time-of-day category (real data-bound points) --\nconst series = TIME_OF_DAY.map((name, categoryIndex) => {\n  const data = observations\n    .filter((o) => o.timeOfDay === categoryIndex)\n    .map((o) => {\n      const [sx, sy] = pointAt(o.bearing, o.speed / MAX_RADIUS);\n      const [ax, ay] = toAxisXY([sx, sy]);\n      return { x: ax, y: ay, custom: { bearing: o.bearing, speed: o.speed } };\n    });\n  return {\n    type: \"scatter\",\n    name,\n    color: t.palette[categoryIndex],\n    marker: {\n      radius: 6,\n      lineColor: t.pageBg,\n      lineWidth: 1,\n      fillColor: hexToRgba(t.palette[categoryIndex], 0.85),\n    },\n    data,\n  };\n});\nseries.forEach((s) => chart.addSeries(s, false));\nchart.redraw();\n"}