{"spec_id":"polar-scatter","library":"d3","language":"javascript","code":"// anyplot.ai\n// polar-scatter: Polar Scatter Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 94/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 LCG — wind observations) ---------------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction noise() {\n  // roughly bell-shaped in [-1, 1]\n  return (rand() + rand() + rand() - 1.5) / 1.5;\n}\n\nconst CATEGORY_CONFIG = [\n  { category: \"morning\", angle: 65, angleSpread: 38, speedBase: 8, speedSpread: 4 },\n  { category: \"afternoon\", angle: 205, angleSpread: 34, speedBase: 15, speedSpread: 5 },\n  { category: \"evening\", angle: 245, angleSpread: 30, speedBase: 10, speedSpread: 4 },\n  { category: \"night\", angle: 350, angleSpread: 42, speedBase: 5, speedSpread: 3 },\n];\nconst POINTS_PER_CATEGORY = 30;\nconst OUTLIER_EVERY = 10; // every 10th observation is a genuine outlier gust\nconst OUTLIER_SPREAD_MULTIPLIER = 1.7;\n\nconst data = [];\nfor (const cfg of CATEGORY_CONFIG) {\n  for (let i = 0; i < POINTS_PER_CATEGORY; i++) {\n    const isOutlier = i % OUTLIER_EVERY === OUTLIER_EVERY - 1;\n    const spreadMul = isOutlier ? OUTLIER_SPREAD_MULTIPLIER : 1;\n    const theta = (cfg.angle + noise() * cfg.angleSpread * spreadMul + 360) % 360;\n    const speed = Math.max(0.5, cfg.speedBase + noise() * cfg.speedSpread * spreadMul);\n    data.push({ theta, speed, category: cfg.category, isOutlier });\n  }\n}\n\nconst categories = CATEGORY_CONFIG.map((c) => c.category);\nconst color = d3.scaleOrdinal().domain(categories).range(t.palette);\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 130, bottom: 110, left: 100, right: 100 };\nconst availableWidth = width - margin.left - margin.right;\nconst availableHeight = height - margin.top - margin.bottom;\nconst plotRadius = Math.min(availableWidth, availableHeight) / 2 - 40;\nconst cx = margin.left + availableWidth / 2;\nconst cy = margin.top + availableHeight / 2;\n\nconst maxSpeed = d3.max(data, (d) => d.speed);\nconst domainMax = Math.ceil(maxSpeed / 2) * 2;\nconst radialScale = d3.scaleLinear().domain([0, domainMax]).range([0, plotRadius]);\nconst radialTicks = d3.range(1, 5).map((i) => (domainMax * i) / 4);\nconst markerRadius = d3.scaleLinear().domain([0, domainMax]).range([5, 10]).clamp(true);\n\nfunction toXY(thetaDeg, r) {\n  const rad = (thetaDeg * Math.PI) / 180;\n  return [cx + r * Math.sin(rad), cy - r * Math.cos(rad)];\n}\n\n// --- SVG mount ------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Radial bands (subtle depth cue, outermost ring first) -----------------\nconst bandGroup = svg.append(\"g\");\nbandGroup\n  .selectAll(\"circle.radial-band\")\n  .data([...radialTicks].reverse())\n  .join(\"circle\")\n  .attr(\"class\", \"radial-band\")\n  .attr(\"cx\", cx)\n  .attr(\"cy\", cy)\n  .attr(\"r\", (d) => radialScale(d))\n  .attr(\"fill\", (d, i) => (i % 2 === 0 ? t.grid : t.pageBg))\n  .attr(\"fill-opacity\", (d, i) => (i % 2 === 0 ? 0.08 : 1));\n\n// --- Radial gridlines + tick labels ----------------------------------------\nconst gridGroup = svg.append(\"g\");\ngridGroup\n  .selectAll(\"circle.grid-ring\")\n  .data(radialTicks)\n  .join(\"circle\")\n  .attr(\"class\", \"grid-ring\")\n  .attr(\"cx\", cx)\n  .attr(\"cy\", cy)\n  .attr(\"r\", (d) => radialScale(d))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// Labels sit along a clear sector (between E and SE) so they never collide\n// with a data cluster, unlike a fixed N-spoke placement.\nconst TICK_LABEL_ANGLE = 115;\ngridGroup\n  .selectAll(\"text.grid-label\")\n  .data(radialTicks)\n  .join(\"text\")\n  .attr(\"class\", \"grid-label\")\n  .attr(\"x\", (d) => toXY(TICK_LABEL_ANGLE, radialScale(d))[0] + 6)\n  .attr(\"y\", (d) => toXY(TICK_LABEL_ANGLE, radialScale(d))[1])\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => `${d3.format(\".0f\")(d)} m/s`);\n\n// Outer domain circle\nsvg\n  .append(\"circle\")\n  .attr(\"cx\", cx)\n  .attr(\"cy\", cy)\n  .attr(\"r\", plotRadius)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Angular spokes + compass labels -----------------------------------\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 spokeGroup = svg.append(\"g\");\nspokeGroup\n  .selectAll(\"line.spoke\")\n  .data(COMPASS)\n  .join(\"line\")\n  .attr(\"class\", \"spoke\")\n  .attr(\"x1\", cx)\n  .attr(\"y1\", cy)\n  .attr(\"x2\", (d) => toXY(d.deg, plotRadius)[0])\n  .attr(\"y2\", (d) => toXY(d.deg, plotRadius)[1])\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\nspokeGroup\n  .selectAll(\"text.compass-label\")\n  .data(COMPASS)\n  .join(\"text\")\n  .attr(\"class\", \"compass-label\")\n  .attr(\"x\", (d) => toXY(d.deg, plotRadius + 34)[0])\n  .attr(\"y\", (d) => toXY(d.deg, plotRadius + 34)[1])\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d.label);\n\n// --- Data points ---------------------------------------------------------\n// Marker radius grows with speed and outlier gusts render slightly larger\n// and more opaque, giving the cluster a subtle visual hierarchy instead of\n// a flat wall of uniform dots.\nsvg\n  .selectAll(\"circle.observation\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"class\", (d) => `observation${d.isOutlier ? \" outlier\" : \"\"}`)\n  .attr(\"cx\", (d) => toXY(d.theta, radialScale(d.speed))[0])\n  .attr(\"cy\", (d) => toXY(d.theta, radialScale(d.speed))[1])\n  .attr(\"r\", (d) => markerRadius(d.speed) + (d.isOutlier ? 2 : 0))\n  .attr(\"fill\", (d) => color(d.category))\n  .attr(\"fill-opacity\", (d) => (d.isOutlier ? 0.95 : 0.7))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", (d) => (d.isOutlier ? 1.5 : 1));\n\n// --- Title + subtitle ----------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"polar-scatter · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 84)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Wind observations — radius = speed (m/s), angle = compass bearing\");\n\n// --- Legend ----------------------------------------------------------------\nconst legendGroup = svg.append(\"g\");\nconst swatch = 16;\nconst legendGap = 26;\nconst legendWidths = categories.map((c) => c.length * 9 + swatch + legendGap);\nconst legendTotalWidth = legendWidths.reduce((a, b) => a + b, 0);\nconst legendY = height - 48;\n\nconst legendEntries = (() => {\n  let x = width / 2 - legendTotalWidth / 2;\n  return categories.map((cat, i) => {\n    const entry = { cat, x };\n    x += legendWidths[i];\n    return entry;\n  });\n})();\n\nlegendGroup\n  .selectAll(\"rect.legend-swatch\")\n  .data(legendEntries)\n  .join(\"rect\")\n  .attr(\"class\", \"legend-swatch\")\n  .attr(\"x\", (d) => d.x)\n  .attr(\"y\", legendY - swatch / 2)\n  .attr(\"width\", swatch)\n  .attr(\"height\", swatch)\n  .attr(\"fill\", (d) => color(d.cat));\n\nlegendGroup\n  .selectAll(\"text.legend-label\")\n  .data(legendEntries)\n  .join(\"text\")\n  .attr(\"class\", \"legend-label\")\n  .attr(\"x\", (d) => d.x + swatch + 8)\n  .attr(\"y\", legendY + swatch / 2 - 2)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text((d) => d.cat.charAt(0).toUpperCase() + d.cat.slice(1));\n"}