{"spec_id":"polar-bar","library":"d3","language":"javascript","code":"// anyplot.ai\n// polar-bar: Polar Bar Chart (Wind Rose)\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// A small LCG stands in for a seeded RNG (the browser has no Math.random seed).\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst random = lcg(42);\n\nconst DIRECTIONS = [\n  \"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\",\n  \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\",\n];\nconst SPEED_BINS = [\"0-5 kt\", \"5-10 kt\", \"10-15 kt\", \"15-20 kt\", \"20+ kt\"];\nconst PREVAILING_INDEX = DIRECTIONS.indexOf(\"SW\"); // prevailing wind direction\nconst SPREAD = 3; // concentration of frequency around the prevailing direction\n\nconst windData = DIRECTIONS.map((direction, i) => {\n  const rawDist = Math.abs(i - PREVAILING_INDEX);\n  const circularDist = Math.min(rawDist, DIRECTIONS.length - rawDist);\n  const intensity = Math.exp(-(circularDist ** 2) / (2 * SPREAD * SPREAD));\n  const observations = Math.round(16 + 68 * intensity + random() * 8);\n\n  // Calm directions skew toward the lowest speed bins; the prevailing\n  // direction skews toward the highest — a realistic wind-rose shape.\n  const lowWeights = [0.38, 0.3, 0.18, 0.09, 0.05];\n  const highWeights = [0.08, 0.14, 0.24, 0.3, 0.24];\n  const weights = lowWeights.map((w, k) => w + (highWeights[k] - w) * intensity);\n  const weightSum = weights.reduce((a, b) => a + b, 0);\n\n  const row = { direction };\n  SPEED_BINS.forEach((bin, k) => {\n    row[bin] = Math.round((observations * weights[k]) / weightSum);\n  });\n  return row;\n});\n\n// --- Layout ------------------------------------------------------------------\nconst margin = { top: 150, bottom: 130 };\nconst plotHeight = height - margin.top - margin.bottom;\nconst cx = width / 2;\nconst cy = margin.top + plotHeight / 2;\nconst outerRadius = Math.min(width / 2, plotHeight / 2) - 70;\nconst innerRadius = 14;\n\nconst rowTotal = (row) => SPEED_BINS.reduce((sum, bin) => sum + row[bin], 0);\nconst domainMax = Math.ceil(d3.max(windData, rowTotal) / 20) * 20;\n\nconst angle = d3.scaleBand().domain(DIRECTIONS).range([0, 2 * Math.PI]).paddingInner(0.18);\nconst radius = d3.scaleRadial().domain([0, domainMax]).range([innerRadius, outerRadius]);\nconst seqColor = d3.interpolateRgbBasis(t.seq);\nconst binColor = (k) => seqColor(k / (SPEED_BINS.length - 1));\n\nfunction toPoint(theta, r) {\n  return [cx + r * Math.sin(theta), cy - r * Math.cos(theta)];\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Radial grid --------------------------------------------------------------\nconst tickValues = d3.range(1, 5).map((k) => Math.round((domainMax * k) / 4));\nconst gapAngle = angle.step() - angle.bandwidth();\nconst tickAngle = angle(\"N\") - gapAngle / 2;\n\nconst gridCircles = svg.append(\"g\");\ngridCircles\n  .selectAll(\"circle\")\n  .data(tickValues)\n  .join(\"circle\")\n  .attr(\"cx\", cx)\n  .attr(\"cy\", cy)\n  .attr(\"r\", (d) => radius(d))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Stacked radial bars --------------------------------------------------------\nconst stacked = d3.stack().keys(SPEED_BINS)(windData);\nconst arcGen = d3\n  .arc()\n  .innerRadius((d) => radius(d[0]))\n  .outerRadius((d) => radius(d[1]))\n  .startAngle((d) => angle(d.data.direction))\n  .endAngle((d) => angle(d.data.direction) + angle.bandwidth())\n  .cornerRadius(2);\n\nsvg\n  .append(\"g\")\n  .selectAll(\"g\")\n  .data(stacked)\n  .join(\"g\")\n  .attr(\"fill\", (d, i) => binColor(i))\n  .selectAll(\"path\")\n  .data((d) => d)\n  .join(\"path\")\n  .attr(\"transform\", `translate(${cx},${cy})`)\n  .attr(\"d\", arcGen)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// Grid tick labels are drawn after the bars (and with a page-background halo)\n// so they stay legible even where the angular gap at small radii is narrower\n// than the label text.\nconst gridLabels = svg.append(\"g\");\ngridLabels\n  .selectAll(\"text\")\n  .data(tickValues)\n  .join(\"text\")\n  .attr(\"x\", (d) => toPoint(tickAngle, radius(d))[0])\n  .attr(\"y\", (d) => toPoint(tickAngle, radius(d))[1])\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dy\", \"0.35em\")\n  .style(\"font-size\", \"13px\")\n  .style(\"paint-order\", \"stroke\")\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"fill\", t.inkSoft)\n  .text((d) => `${d} obs`);\n\n// --- Compass direction labels -----------------------------------------------\nconst compass = svg.append(\"g\");\nDIRECTIONS.forEach((direction, i) => {\n  const theta = angle(direction) + angle.bandwidth() / 2;\n  const [x, y] = toPoint(theta, outerRadius + 26);\n  const sinT = Math.sin(theta);\n  const cosT = Math.cos(theta);\n\n  let textAnchor = \"middle\";\n  if (sinT > 0.35) textAnchor = \"start\";\n  else if (sinT < -0.35) textAnchor = \"end\";\n\n  let dy = \"0.35em\";\n  if (cosT > 0.85) dy = \"-0.2em\";\n  else if (cosT < -0.85) dy = \"1em\";\n\n  const isCardinal = i % 4 === 0; // N, E, S, W\n\n  compass\n    .append(\"text\")\n    .attr(\"x\", x)\n    .attr(\"y\", y)\n    .attr(\"text-anchor\", textAnchor)\n    .attr(\"dy\", dy)\n    .style(\"font-size\", \"15px\")\n    .style(\"font-weight\", isCardinal ? \"600\" : \"400\")\n    .attr(\"fill\", isCardinal ? t.ink : t.inkSoft)\n    .text(direction);\n});\n\n// --- Legend --------------------------------------------------------------------\nconst legendY = height - 60;\nconst itemWidth = 190;\nconst legendStart = cx - (SPEED_BINS.length * itemWidth) / 2;\n\nconst legend = svg.append(\"g\");\nSPEED_BINS.forEach((bin, i) => {\n  const itemX = legendStart + i * itemWidth;\n  legend\n    .append(\"rect\")\n    .attr(\"x\", itemX)\n    .attr(\"y\", legendY - 13)\n    .attr(\"width\", 26)\n    .attr(\"height\", 26)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", binColor(i));\n  legend\n    .append(\"text\")\n    .attr(\"x\", itemX + 36)\n    .attr(\"y\", legendY)\n    .attr(\"dy\", \"0.35em\")\n    .style(\"font-size\", \"15px\")\n    .attr(\"fill\", t.inkSoft)\n    .text(bin);\n});\n\n// --- Title -----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", cx)\n  .attr(\"y\", 70)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", t.ink)\n  .text(\"Wind Rose · polar-bar · javascript · d3 · anyplot.ai\");\n"}