Polar Scatter Plot — Highcharts

A scatter plot displayed in polar coordinates where data points are positioned using angle (theta) and radius (r) rather than Cartesian x and y coordinates. This visualization is particularly effective for cyclical or directional data where the angular component carries meaningful information.

Polar Scatter Plot rendered with Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// polar-scatter: Polar Scatter Plot
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 89/100 | Created: 2026-09-05

//# anyplot-orientation: square

// Only the core `highcharts` bundle is loaded (no highcharts-more), so the
// native polar chart type (chart.polar) isn't available — PolarComposition
// lives in the highcharts-more module only. Each (direction, speed) polar
// coordinate is projected to Cartesian ourselves and bound as real scatter
// points on hidden, fixed-extent axes, so hover/tooltip still work in the
// interactive HTML. The radial rings, compass spokes, and their labels have
// no native polar-axis equivalent without highcharts-more, so those are
// drawn once with the core SVG renderer.
const t = window.ANYPLOT_TOKENS;

// --- Data (wind observations: bearing in degrees, speed in m/s) ------------
function lcg(seed) {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rand = lcg(42);
const randNormal = (mean, std) => {
  const u1 = Math.max(rand(), 1e-9);
  const u2 = rand();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + z * std;
};

const TIME_OF_DAY = ["Morning", "Afternoon", "Evening", "Night"];
const N_OBSERVATIONS = 130;
const observations = [];
for (let i = 0; i < N_OBSERVATIONS; i += 1) {
  // Two prevailing regimes: a strong south-westerly and a lighter north-easterly.
  const southwesterly = rand() < 0.65;
  const meanBearing = southwesterly ? 232 : 48;
  const bearingSpread = southwesterly ? 22 : 28;
  const meanSpeed = southwesterly ? 13.5 : 7.5;
  const speedSpread = southwesterly ? 3.5 : 2.5;

  const bearing = ((randNormal(meanBearing, bearingSpread) % 360) + 360) % 360;
  const speed = Math.max(0.6, randNormal(meanSpeed, speedSpread));
  const timeOfDay = Math.floor(rand() * TIME_OF_DAY.length);
  observations.push({ bearing, speed, timeOfDay });
}

const maxSpeed = Math.max(...observations.map((o) => o.speed));
// Round up to a multiple of 4 (not 5) so MAX_RADIUS/4 is always a whole
// number — every ring label is a clean, evenly-spaced integer.
const MAX_RADIUS = Math.ceil(maxSpeed / 4) * 4;
const RING_STEP = MAX_RADIUS / 4;
const RING_LEVELS = [RING_STEP, RING_STEP * 2, RING_STEP * 3, MAX_RADIUS];
const COMPASS = [
  { deg: 0, label: "N" },
  { deg: 45, label: "NE" },
  { deg: 90, label: "E" },
  { deg: 135, label: "SE" },
  { deg: 180, label: "S" },
  { deg: 225, label: "SW" },
  { deg: 270, label: "W" },
  { deg: 315, label: "NW" },
];

const TITLE = "polar-scatter · javascript · highcharts · anyplot.ai";
const titleFontSize = Math.round(22 * Math.min(1, 67 / TITLE.length)) + "px";

// --- Chart (empty core chart used as a canvas for the renderer overlay) ----
const chart = Highcharts.chart("container", {
  chart: {
    backgroundColor: "transparent",
    animation: false,
    style: { fontFamily: "inherit" },
    margin: [150, 110, 110, 110],
  },
  credits: { enabled: false },
  title: {
    text: TITLE,
    style: { color: t.ink, fontSize: titleFontSize, fontWeight: "600" },
  },
  subtitle: {
    text: "Wind bearing and speed from 130 station observations",
    style: { color: t.inkSoft, fontSize: "14px" },
  },
  xAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },
  yAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },
  legend: {
    enabled: true,
    itemStyle: { color: t.inkSoft, fontSize: "14px" },
    itemHoverStyle: { color: t.ink },
  },
  tooltip: {
    enabled: true,
    backgroundColor: t.elevatedBg,
    borderColor: t.grid,
    style: { color: t.ink },
    formatter() {
      const bearing = Math.round(this.point.custom.bearing);
      const speed = this.point.custom.speed.toFixed(1);
      return `<b>${this.series.name}</b><br/>${bearing}° · ${speed} m/s`;
    },
  },
  plotOptions: { series: { animation: false } },
  series: [],
});

// Fix the (visible: false) axes to a known pixel-space extent so real series
// can be data-bound at the same polar-projected coordinates the renderer
// overlay below uses for the grid.
chart.xAxis[0].setExtremes(0, chart.plotWidth, false);
chart.yAxis[0].setExtremes(0, chart.plotHeight, false);

// --- Geometry ----------------------------------------------------------------
const cx = chart.plotLeft + chart.plotWidth / 2;
const cy = chart.plotTop + chart.plotHeight / 2;
const outerR = Math.min(chart.plotWidth, chart.plotHeight) / 2 - 60;

// 0° points straight up (north); bearings increase clockwise, matching a compass.
const angleOf = (bearing) => ((bearing - 90) * Math.PI) / 180;
const pointAt = (bearing, radiusFrac) => {
  const angle = angleOf(bearing);
  return [cx + outerR * radiusFrac * Math.cos(angle), cy + outerR * radiusFrac * Math.sin(angle)];
};
// Project a renderer-space [x, y] pixel into the fixed-extent axis coordinates
// the data-bound series below use (yAxis increases upward, so flip y).
const toAxisXY = ([sx, sy]) => [sx - chart.plotLeft, chart.plotTop + chart.plotHeight - sy];

// Convert a palette hex color to rgba so overlapping markers can be given a
// slight fill translucency without losing the category color.
const hexToRgba = (hex, alpha) => {
  const clean = hex.replace("#", "");
  const value = parseInt(clean, 16);
  const r = (value >> 16) & 255;
  const g = (value >> 8) & 255;
  const b = value & 255;
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};

// Subtle radial tint behind the grid — lifts the chrome beyond a flat plane
// without competing with the data. Low alpha throughout so it reads as a
// faint glow, not a filled disc.
chart.renderer
  .circle(cx, cy, outerR)
  .attr({
    fill: {
      radialGradient: { cx: 0.5, cy: 0.5, r: 0.5 },
      stops: [
        [0, hexToRgba(t.elevatedBg, 0.4)],
        [1, hexToRgba(t.elevatedBg, 0)],
      ],
    },
    zIndex: 0,
  })
  .add();

// --- Radial grid rings + value labels (no native polar axis without more.js) --
// The two prevailing-wind clusters sit around 48° (20°-76°) and 232°
// (210°-254°), so the SE sector stays clear of data at every radius — the
// natural spot for the scale.
const RING_LABEL_ANGLE = 145;
RING_LEVELS.forEach((level, index) => {
  const isOutermost = index === RING_LEVELS.length - 1;
  chart.renderer
    .circle(cx, cy, outerR * (level / MAX_RADIUS))
    .attr({ stroke: t.grid, "stroke-width": isOutermost ? 2 : 1, fill: "none", zIndex: 1 })
    .add();

  const [lx, ly] = pointAt(RING_LABEL_ANGLE, level / MAX_RADIUS);
  chart.renderer
    .text(`${level} m/s`, lx + 6, ly - 4)
    .attr({ zIndex: 3 })
    .css({ color: t.inkSoft, fontSize: "13px" })
    .add();
});

// --- Angular spokes + compass labels -----------------------------------------
COMPASS.forEach(({ deg, label }) => {
  const [ex, ey] = pointAt(deg, 1);
  chart.renderer
    .path(["M", cx, cy, "L", ex, ey])
    .attr({ stroke: t.grid, "stroke-width": 1, zIndex: 1 })
    .add();

  const [lx, ly] = pointAt(deg, 1.08);
  chart.renderer
    .text(label, lx, ly)
    .attr({ align: "center", zIndex: 3 })
    .css({ color: t.inkSoft, fontSize: "16px", fontWeight: "600" })
    .add();
});

// --- Scatter series, one per time-of-day category (real data-bound points) --
const series = TIME_OF_DAY.map((name, categoryIndex) => {
  const data = observations
    .filter((o) => o.timeOfDay === categoryIndex)
    .map((o) => {
      const [sx, sy] = pointAt(o.bearing, o.speed / MAX_RADIUS);
      const [ax, ay] = toAxisXY([sx, sy]);
      return { x: ax, y: ay, custom: { bearing: o.bearing, speed: o.speed } };
    });
  return {
    type: "scatter",
    name,
    color: t.palette[categoryIndex],
    marker: {
      radius: 6,
      lineColor: t.pageBg,
      lineWidth: 1,
      fillColor: hexToRgba(t.palette[categoryIndex], 0.85),
    },
    data,
  };
});
series.forEach((s) => chart.addSeries(s, false));
chart.redraw();

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/polar-scatter/highcharts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "polar-scatter",
  "language": "javascript",
  "library": "highcharts",
  "page": "https://anyplot.ai/polar-scatter/javascript/highcharts",
  "hub": "https://anyplot.ai/polar-scatter",
  "code_json": "https://api.anyplot.ai/specs/polar-scatter/highcharts/code",
  "spec_json": "https://api.anyplot.ai/specs/polar-scatter",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/polar-scatter/javascript/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/polar-scatter/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/polar-scatter/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/polar-scatter/javascript/highcharts/plot-dark.html",
  "quality_score": 89.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Polar Scatter Plot on anyplot.ai.

Other implementations