{"spec_id":"windrose-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// windrose-basic: Wind Rose Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.1\n// Quality: 88/100 | Created: 2026-08-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 isn't available. We build the wind rose's stacked\n// polar wedges with the core SVG renderer's built-in `arc` primitive (the\n// same donut-slice geometry pie charts use internally) instead — the same\n// renderer-overlay technique the radar-basic implementation uses.\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (hourly wind observations at a coastal station, one year) --------\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 m/s\", \"5-10 m/s\", \"10-15 m/s\", \"15+ m/s\"];\nconst n = DIRECTIONS.length;\n\n// Prevailing wind out of the WSW, tapering off with angular distance — the\n// 16 direction totals sum to 100% of observations.\nconst PREVAILING_INDEX = 11; // WSW\nconst SIGMA = 3.1;\nconst rawWeights = DIRECTIONS.map((_, i) => {\n  const dist = Math.min(Math.abs(i - PREVAILING_INDEX), n - Math.abs(i - PREVAILING_INDEX));\n  return 0.35 + Math.exp(-(dist * dist) / (2 * SIGMA * SIGMA));\n});\nconst weightSum = rawWeights.reduce((sum, w) => sum + w, 0);\nconst directionTotals = rawWeights.map((w) => (w / weightSum) * 100);\nconst maxTotal = Math.max(...directionTotals);\n\n// Speed-bin mix shifts toward the higher bins where the wind blows hardest.\nconst CALM_MIX = [0.55, 0.32, 0.11, 0.02];\nconst STRONG_MIX = [0.1, 0.28, 0.38, 0.24];\nconst freq = directionTotals.map((total) => {\n  const strength = total / maxTotal;\n  return CALM_MIX.map((c, k) => (c + (STRONG_MIX[k] - c) * strength) * total);\n});\n\n// Imprint sequential ramp (calm -> strong) for the 4 stacked speed bins —\n// position 1 (`t.seq[0]`) is `#009E73`, so the calm bin stays brand green.\nconst lerpHex = (hexA, hexB, frac) => {\n  const a = [1, 3, 5].map((p) => parseInt(hexA.slice(p, p + 2), 16));\n  const b = [1, 3, 5].map((p) => parseInt(hexB.slice(p, p + 2), 16));\n  return `#${a.map((v, i) => Math.round(v + (b[i] - v) * frac).toString(16).padStart(2, \"0\")).join(\"\")}`;\n};\nconst speedColors = SPEED_BINS.map((_, k) => lerpHex(t.seq[0], t.seq[1], k / (SPEED_BINS.length - 1)));\n\nconst TITLE = \"windrose-basic · javascript · highcharts · anyplot.ai\";\nconst titleFs = Math.max(15, 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: [110, 70, 90, 70],\n  },\n  credits: { enabled: false },\n  title: { text: TITLE, style: { color: t.ink, fontSize: titleFs, fontWeight: \"600\" } },\n  xAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  yAxis: { visible: false, gridLineWidth: 0, lineWidth: 0, tickLength: 0 },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink },\n    formatter() {\n      const p = this.point.custom;\n      return `<b>${p.direction}</b><br/>${p.speedBin}: ${p.value.toFixed(1)}% of observations`;\n    },\n  },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\n// Fix the (visible: false) axes to a known pixel-space extent so a real\n// scatter series can be data-bound at the same polar-projected coordinates\n// the renderer overlay uses below — the PNG stays untouched while the\n// interactive HTML gets genuine hoverable Highcharts points.\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\nconst RING_STEP = 3; // percentage points per grid ring\nconst maxRing = Math.ceil((maxTotal * 1.15) / RING_STEP) * RING_STEP;\nconst radiusFor = (value) => (value / maxRing) * outerR;\n\n// Angle 0 (North) points straight up; sectors proceed clockwise like a\n// compass. `renderer.arc` uses 0 = right, -PI/2 = up, increasing clockwise.\nconst sectorWidth = (2 * Math.PI) / n;\nconst wedgeGap = sectorWidth * 0.08;\nconst angleOf = (i) => -Math.PI / 2 + i * sectorWidth;\n\n// --- Radial grid rings + scale labels -----------------------------------------\nconst ringLabelAngle = angleOf(0) + sectorWidth / 2; // between N and NNE, clear of cardinal labels\nfor (let level = RING_STEP; level <= maxRing; level += RING_STEP) {\n  const r = radiusFor(level);\n  chart.renderer.circle(cx, cy, r).attr({ stroke: t.grid, \"stroke-width\": 1, fill: \"none\", zIndex: 1 }).add();\n  chart.renderer\n    .text(`${level}%`, cx + r * Math.cos(ringLabelAngle) + 4, cy + r * Math.sin(ringLabelAngle) - 4)\n    .attr({ zIndex: 1 })\n    .css({ fontSize: \"12px\", color: t.inkSoft, fontFamily: \"inherit\" })\n    .add();\n}\n\n// --- Direction spokes + compass labels ----------------------------------------\nconst CARDINALS = new Set([0, 4, 8, 12]); // N, E, S, W\nDIRECTIONS.forEach((label, i) => {\n  const angle = angleOf(i);\n  const cos = Math.cos(angle);\n  const sin = Math.sin(angle);\n  chart.renderer\n    .path([\"M\", cx, cy, \"L\", cx + outerR * cos, cy + outerR * sin])\n    .attr({ stroke: t.grid, \"stroke-width\": 1, zIndex: 0 })\n    .add();\n\n  const isCardinal = CARDINALS.has(i);\n  const [lx, ly] = [cx + (outerR + 24) * cos, cy + (outerR + 24) * sin];\n  const align = cos > 0.3 ? \"left\" : cos < -0.3 ? \"right\" : \"center\";\n  chart.renderer\n    .text(label, lx, ly + sin * 6 + 5)\n    .attr({ align, zIndex: 5 })\n    .css({\n      fontSize: isCardinal ? \"16px\" : \"13px\",\n      fontWeight: isCardinal ? \"700\" : \"400\",\n      color: isCardinal ? t.ink : t.inkSoft,\n      fontFamily: \"inherit\",\n    })\n    .add();\n});\n\n// --- Stacked wedge segments + invisible tooltip anchors -----------------------\nconst points = [];\nDIRECTIONS.forEach((direction, i) => {\n  const start = angleOf(i) - sectorWidth / 2 + wedgeGap / 2;\n  const end = angleOf(i) + sectorWidth / 2 - wedgeGap / 2;\n  const midAngle = (start + end) / 2;\n  let cumulative = 0;\n\n  freq[i].forEach((value, k) => {\n    const rInner = radiusFor(cumulative);\n    const rOuter = radiusFor(cumulative + value);\n    chart.renderer\n      .arc(cx, cy, rOuter, rInner, start, end)\n      .attr({ fill: speedColors[k], stroke: t.pageBg, \"stroke-width\": 1, zIndex: 2 })\n      .add();\n\n    const midR = (rInner + rOuter) / 2;\n    const px = cx + midR * Math.cos(midAngle);\n    const py = cy + midR * Math.sin(midAngle);\n    points.push({\n      x: px - chart.plotLeft,\n      y: chart.plotTop + chart.plotHeight - py,\n      color: speedColors[k],\n      custom: { direction, speedBin: SPEED_BINS[k], value },\n    });\n    cumulative += value;\n  });\n});\n\nchart.addSeries(\n  {\n    type: \"scatter\",\n    name: \"Frequency\",\n    enableMouseTracking: true,\n    stickyTracking: false,\n    animation: false,\n    marker: {\n      enabled: false,\n      radius: 10,\n      states: { hover: { enabled: true, radius: 10, lineWidth: 0 } },\n    },\n    data: points,\n  },\n  false\n);\nchart.redraw();\n\n// --- Legend (speed-bin ramp) --------------------------------------------------\nconst chipSize = 14;\nconst chipGap = 26;\nconst legendFs = 14;\nconst chipTextWidths = SPEED_BINS.map((label) => label.length * 7.6);\nconst legendWidth = SPEED_BINS.reduce(\n  (sum, _label, i) => sum + chipSize + 10 + chipTextWidths[i] + (i < SPEED_BINS.length - 1 ? chipGap : 0),\n  0\n);\nlet legendX = cx - legendWidth / 2;\nconst legendY = chart.plotTop + chart.plotHeight + 46;\n\nSPEED_BINS.forEach((label, i) => {\n  chart.renderer.rect(legendX, legendY - chipSize / 2, chipSize, chipSize, 3).attr({ fill: speedColors[i], zIndex: 5 }).add();\n  chart.renderer\n    .text(label, legendX + chipSize + 10, legendY + chipSize / 2 - 2)\n    .attr({ align: \"left\", zIndex: 5 })\n    .css({ fontSize: `${legendFs}px`, color: t.ink, fontFamily: \"inherit\" })\n    .add();\n  legendX += chipSize + 10 + chipTextWidths[i] + chipGap;\n});\n"}