{"spec_id":"polar-scatter","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// polar-scatter: Polar Scatter Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG — wind observations) ---------------\n// Wind direction (theta, degrees, compass convention: 0=N, 90=E, clockwise)\n// and wind speed (radius, m/s), grouped by time of day. Each period has its\n// own prevailing direction and typical speed, like a small wind rose.\nfunction lcg(seed) {\n  let s = seed >>> 0;\n  return () => {\n    s = (1103515245 * s + 12345) >>> 0;\n    return s / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nfunction windObservations(prevailingDeg, spreadDeg, speedMean, speedSpread, n) {\n  const points = [];\n  for (let i = 0; i < n; i++) {\n    // Sum of 3 uniforms approximates a bell-shaped jitter around the prevailing direction.\n    const jitter = (rand() + rand() + rand() - 1.5) * spreadDeg;\n    const theta = ((prevailingDeg + jitter) % 360 + 360) % 360;\n    const radius = Math.max(0.5, speedMean + (rand() + rand() - 1) * speedSpread);\n    points.push({ theta, radius });\n  }\n  return points;\n}\n\n// Afternoon carries two modes (a squall shifting the prevailing direction\n// mid-period) so the dataset also demonstrates a bimodal angular spread,\n// not just three narrow jitter cones.\nconst periods = [\n  {\n    label: \"Morning\",\n    modes: [{ prevailing: 45, spread: 30, speedMean: 6, speedSpread: 4, n: 44 }],\n  },\n  {\n    label: \"Afternoon\",\n    modes: [\n      { prevailing: 200, spread: 22, speedMean: 10, speedSpread: 4, n: 22 },\n      { prevailing: 258, spread: 22, speedMean: 13, speedSpread: 5, n: 21 },\n    ],\n  },\n  {\n    label: \"Evening\",\n    modes: [{ prevailing: 285, spread: 45, speedMean: 7, speedSpread: 4, n: 43 }],\n  },\n];\n\nconst datasetsRaw = periods.map((period, i) => ({\n  label: period.label,\n  color: t.palette[i],\n  observations: period.modes.flatMap((m) =>\n    windObservations(m.prevailing, m.spread, m.speedMean, m.speedSpread, m.n)\n  ),\n}));\n\nconst maxSpeed = Math.max(...datasetsRaw.flatMap((d) => d.observations.map((o) => o.radius)));\nconst ringMax = Math.ceil(maxSpeed / 5) * 5;\nconst axisMax = ringMax * 1.22;\nconst ringFractions = [0.25, 0.5, 0.75, 1];\n\n// Compass polar -> cartesian: 0 deg (N) is up, angle grows clockwise.\nfunction toXY(thetaDeg, radius) {\n  const rad = (thetaDeg * Math.PI) / 180;\n  return { x: radius * Math.sin(rad), y: radius * Math.cos(rad) };\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chrome layout constants (CSS px, symmetric so the plot area stays square) ---\nconst PAD = 130;\nconst title = \"polar-scatter · javascript · chartjs · anyplot.ai\";\nconst subtitle = \"Prevailing wind direction rotates clockwise through the day\";\n\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// Custom plugin: draws the polar grid behind the points, then cardinal ticks,\n// the title, and the category legend on top — Chart.js's own plugin hooks\n// (beforeDraw/afterDraw), no external plugin package involved.\nconst polarChrome = {\n  id: \"polarChrome\",\n  beforeDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPixel = (thetaDeg, radius) => {\n      const p = toXY(thetaDeg, radius);\n      return { x: scales.x.getPixelForValue(p.x), y: scales.y.getPixelForValue(p.y) };\n    };\n\n    ctx.save();\n\n    // Radial rings — line weight/opacity increases with distance so the outer\n    // (axis-defining) ring reads slightly stronger than the inner guides.\n    ctx.strokeStyle = t.grid;\n    ringFractions.forEach((frac) => {\n      const r = ringMax * frac;\n      ctx.globalAlpha = 0.5 + 0.5 * frac;\n      ctx.lineWidth = 0.75 + 0.75 * frac;\n      ctx.beginPath();\n      for (let deg = 0; deg <= 360; deg += 5) {\n        const p = toPixel(deg, r);\n        if (deg === 0) ctx.moveTo(p.x, p.y);\n        else ctx.lineTo(p.x, p.y);\n      }\n      ctx.stroke();\n    });\n    ctx.globalAlpha = 1;\n    ctx.lineWidth = 1;\n\n    // Angular spokes every 45 degrees\n    for (let deg = 0; deg < 360; deg += 45) {\n      const center = toPixel(0, 0);\n      const outer = toPixel(deg, ringMax);\n      ctx.beginPath();\n      ctx.moveTo(center.x, center.y);\n      ctx.lineTo(outer.x, outer.y);\n      ctx.stroke();\n    }\n\n    // Radius tick labels along the SE spoke (clear of every prevailing-wind\n    // cluster) — a single axis caption carries the unit so the ticks\n    // themselves stay terse.\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"13px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    ringFractions.forEach((frac) => {\n      const p = toPixel(135, ringMax * frac);\n      ctx.fillText(`${Math.round(ringMax * frac)}`, p.x + 6, p.y);\n    });\n    ctx.font = \"italic 12px -apple-system, BlinkMacSystemFont, sans-serif\";\n    const captionP = toPixel(135, ringMax * 1.16);\n    ctx.fillText(\"Wind speed (m/s)\", captionP.x + 6, captionP.y);\n\n    ctx.restore();\n  },\n  afterDraw(chart) {\n    const { ctx, scales, width } = chart;\n    const toPixel = (thetaDeg, radius) => {\n      const p = toXY(thetaDeg, radius);\n      return { x: scales.x.getPixelForValue(p.x), y: scales.y.getPixelForValue(p.y) };\n    };\n\n    ctx.save();\n\n    // Cardinal direction labels\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 15px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    const cardinals = [\n      { deg: 0, text: \"N\" },\n      { deg: 90, text: \"E\" },\n      { deg: 180, text: \"S\" },\n      { deg: 270, text: \"W\" },\n    ];\n    cardinals.forEach(({ deg, text }) => {\n      const p = toPixel(deg, axisMax * 0.94);\n      ctx.fillText(text, p.x, p.y);\n    });\n\n    // Title (top padding band)\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 22px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillText(title, width / 2, PAD * 0.35);\n\n    // Subtitle: calls out the insight directly instead of leaving the reader\n    // to infer it from the color-coded clusters alone.\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"14px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.fillText(subtitle, width / 2, PAD * 0.65);\n\n    // Legend (bottom padding band): swatch + label per category, centered row\n    ctx.font = \"14px -apple-system, BlinkMacSystemFont, sans-serif\";\n    const swatchR = 7;\n    const gapAfterSwatch = 8;\n    const gapBetweenItems = 28;\n    const widths = datasetsRaw.map((d) => ctx.measureText(d.label).width);\n    const itemWidths = widths.map((w) => swatchR * 2 + gapAfterSwatch + w);\n    const totalWidth = itemWidths.reduce((a, b) => a + b, 0) + gapBetweenItems * (datasetsRaw.length - 1);\n    let cursorX = width / 2 - totalWidth / 2;\n    const legendY = chart.height - PAD / 2;\n    datasetsRaw.forEach((d, i) => {\n      ctx.fillStyle = d.color;\n      ctx.beginPath();\n      ctx.arc(cursorX + swatchR, legendY, swatchR, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.fillStyle = t.inkSoft;\n      ctx.textAlign = \"left\";\n      ctx.fillText(d.label, cursorX + swatchR * 2 + gapAfterSwatch, legendY);\n      cursorX += itemWidths[i] + gapBetweenItems;\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: datasetsRaw.map((d) => ({\n      label: d.label,\n      data: d.observations.map((o) => toXY(o.theta, o.radius)),\n      backgroundColor: hexToRgba(d.color, 0.72),\n      borderColor: t.pageBg,\n      borderWidth: 1.5,\n      pointRadius: 6,\n      pointHoverRadius: 6,\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: PAD },\n    plugins: {\n      title: { display: false },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { type: \"linear\", min: -axisMax, max: axisMax, display: false },\n      y: { type: \"linear\", min: -axisMax, max: axisMax, display: false },\n    },\n  },\n  plugins: [polarChrome],\n});\n"}