{"spec_id":"windrose-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// windrose-basic: Wind Rose Chart\n// Library: muix 7.29.1 | JavaScript 22.23.1\n// Quality: 92/100 | Created: 2026-08-05\n//# anyplot-orientation: square\n// anyplot.ai\n// windrose-basic: Wind Rose Chart\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-08-05\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\nimport { ChartsLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { useInteractionItemProps } from \"@mui/x-charts/internals\";\n\n// @mui/x-charts 7.x community has no polar / radial-bar chart component, so the\n// wind rose is composed on MUI X's own charting surface: ChartContainer supplies\n// the sized <svg> + drawing area, and useDrawingArea() gives the plot rect the\n// polar geometry is mapped onto — real stacked-sector geometry, not faked chrome.\n// The 5 speed bins are still registered as genuine (unrendered) MUI X `bar`\n// series so the native <ChartsLegend> and <ChartsTooltip> read real series data;\n// useInteractionItemProps wires our hand-drawn wedges into MUI X's own\n// interaction/highlight system instead of faking a legend or tooltip.\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// Theme-adaptive chrome (ThemeProvider handles MUI text; these are for our SVG).\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\nconst PAGE_BG = t.pageBg;\n\n// --- Fixed-seed PRNG (mulberry32) — Math.random() is not reproducible --------\nfunction mulberry32(a) {\n  return function () {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let x = Math.imul(a ^ (a >>> 15), 1 | a);\n    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;\n    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;\n  };\n}\nconst rand = mulberry32(42);\nconst randNormal = () => {\n  const u1 = Math.max(rand(), 1e-6); // guard log(0); mulberry32 can return exactly 0\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\n\n// --- Data: one year (hourly) of wind observations at a coastal weather station,\n// binned into 16 compass sectors × 5 speed classes ---------------------------\nconst DIRECTIONS = [\n  \"N\",\n  \"NNE\",\n  \"NE\",\n  \"ENE\",\n  \"E\",\n  \"ESE\",\n  \"SE\",\n  \"SSE\",\n  \"S\",\n  \"SSW\",\n  \"SW\",\n  \"WSW\",\n  \"W\",\n  \"WNW\",\n  \"NW\",\n  \"NNW\",\n];\nconst SPEED_BINS = [\n  [0, 2],\n  [2, 4],\n  [4, 6],\n  [6, 8],\n  [8, Infinity],\n];\nconst SPEED_LABELS = [\"0–2 m/s\", \"2–4 m/s\", \"4–6 m/s\", \"6–8 m/s\", \"8+ m/s\"];\nconst PREVAILING_DEG = 225; // prevailing wind from the southwest\nconst SECONDARY_DEG = 315; // secondary regime: brisk northwesterly gusts\nconst N_SAMPLES = 4380; // hourly readings across half a year\n\nconst counts = DIRECTIONS.map(() => SPEED_BINS.map(() => 0));\nfor (let i = 0; i < N_SAMPLES; i += 1) {\n  const regime = rand();\n  let direction;\n  let speedScale;\n  if (regime < 0.6) {\n    direction = PREVAILING_DEG + randNormal() * 36;\n    speedScale = 3.4;\n  } else if (regime < 0.78) {\n    direction = SECONDARY_DEG + randNormal() * 24;\n    speedScale = 3.0;\n  } else {\n    direction = rand() * 360;\n    speedScale = 1.7;\n  }\n  direction = ((direction % 360) + 360) % 360;\n  const speed = -Math.log(1 - rand()) * speedScale;\n  const dirIdx = Math.round(direction / 22.5) % DIRECTIONS.length;\n  const speedIdx = SPEED_BINS.findIndex(\n    ([lo, hi]) => speed >= lo && speed < hi,\n  );\n  counts[dirIdx][speedIdx] += 1;\n}\nconst freqPct = counts.map((row) => row.map((c) => (c / N_SAMPLES) * 100));\nconst cumPct = freqPct.map((row) => {\n  const out = [];\n  row.reduce((sum, v, i) => (out[i] = sum + v), 0);\n  return out;\n});\nconst maxTotal = Math.max(...cumPct.map((row) => row[row.length - 1]));\nconst RING_MAX = Math.ceil(maxTotal / 5) * 5;\nconst RINGS = [RING_MAX * 0.25, RING_MAX * 0.5, RING_MAX * 0.75, RING_MAX];\n\n// Minimum-radius floor: near-zero direction sectors (E/ENE/NE/ESE/SE) still get\n// a legible sliver per present speed bin, so calm directions don't collapse into\n// indistinguishable slivers. Only used for wedge geometry — the real freqPct\n// values (below) still drive the legend/tooltip, so displayed numbers stay honest.\nconst MIN_SLICE_PCT = RING_MAX * 0.015;\nconst renderPct = freqPct.map((row) =>\n  row.map((v) => (v > 0 ? Math.max(v, MIN_SLICE_PCT) : 0)),\n);\nconst renderCumPct = renderPct.map((row) => {\n  const out = [];\n  row.reduce((sum, v, i) => (out[i] = sum + v), 0);\n  return out;\n});\n\n// --- Sequential Imprint colours (calm → strong) — never a library gradient ---\nconst hexToRgb = (hex) => {\n  const v = parseInt(hex.slice(1), 16);\n  return [(v >> 16) & 255, (v >> 8) & 255, v & 255];\n};\nconst lerpHex = (from, to, f) => {\n  const [r1, g1, b1] = hexToRgb(from);\n  const [r2, g2, b2] = hexToRgb(to);\n  const mix = (a, b) => Math.round(a + (b - a) * f);\n  return `rgb(${mix(r1, r2)}, ${mix(g1, g2)}, ${mix(b1, b2)})`;\n};\nconst SPEED_COLORS = SPEED_BINS.map((_, i) =>\n  lerpHex(t.seq[0], t.seq[1], i / (SPEED_BINS.length - 1)),\n);\n\n// Real MUI X `bar` series (one per speed bin, values = true freqPct per\n// direction) — never rendered by a <BarPlot>, but registered on ChartContainer\n// so <ChartsLegend> and <ChartsTooltip> below are driven by genuine series\n// data/colors rather than hand-drawn chrome.\nconst BAR_SERIES = SPEED_BINS.map((_, speedIdx) => ({\n  id: `speed-${speedIdx}`,\n  type: \"bar\",\n  label: SPEED_LABELS[speedIdx],\n  color: SPEED_COLORS[speedIdx],\n  data: freqPct.map((row) => row[speedIdx]),\n  valueFormatter: (v) => (v == null ? \"\" : `${v.toFixed(1)}%`),\n}));\n\n// Direction i's centre angle is i·22.5° clockwise from north; SVG's 0° points\n// at 3 o'clock, so shift by -90° before converting to radians.\nconst toRad = (deg) => ((deg - 90) * Math.PI) / 180;\n\nconst annularSectorPath = (cx, cy, rInner, rOuter, startDeg, endDeg) => {\n  const a0 = toRad(startDeg);\n  const a1 = toRad(endDeg);\n  const pt = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];\n  const [x1, y1] = pt(rOuter, a0);\n  const [x2, y2] = pt(rOuter, a1);\n  if (rInner <= 0.01) {\n    return `M ${cx} ${cy} L ${x1} ${y1} A ${rOuter} ${rOuter} 0 0 1 ${x2} ${y2} Z`;\n  }\n  const [x3, y3] = pt(rInner, a1);\n  const [x4, y4] = pt(rInner, a0);\n  return `M ${x1} ${y1} A ${rOuter} ${rOuter} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${rInner} ${rInner} 0 0 0 ${x4} ${y4} Z`;\n};\n\nconst SECTOR_SPAN = 360 / DIRECTIONS.length;\nconst SECTOR_GAP = 1.4; // degrees of padding between adjacent wedges\nconst TICK_ANGLE = SECTOR_SPAN * 2.5; // reference axis sits in the NE/ENE gap\n\n// --- Wind rose layer: rendered as children inside MUI X's ChartsSurface -------\nfunction WindRoseLayer() {\n  const area = useDrawingArea();\n  const getInteractionProps = useInteractionItemProps();\n  const cx = area.left + area.width / 2;\n  const cy = area.top + area.height / 2;\n  const half = Math.min(area.width, area.height) / 2;\n  const R = half - 62; // data radius; the margin to `half` holds compass labels\n  const labelR = half - 14;\n  const scaleR = (value) => (value / RING_MAX) * R;\n\n  return (\n    <g>\n      {/* Frequency rings — outer solid, inner lighter so the grid stays subtle */}\n      {RINGS.map((level) => (\n        <circle\n          key={`ring-${level}`}\n          cx={cx}\n          cy={cy}\n          r={scaleR(level)}\n          fill=\"none\"\n          stroke={GRID}\n          strokeWidth={level === RING_MAX ? 2 : 1}\n          strokeOpacity={level === RING_MAX ? 1 : 0.6}\n        />\n      ))}\n\n      {/* Reference axis for the frequency-percent ticks, clear of any wedge */}\n      <line\n        x1={cx}\n        y1={cy}\n        x2={cx + R * Math.cos(toRad(TICK_ANGLE))}\n        y2={cy + R * Math.sin(toRad(TICK_ANGLE))}\n        stroke={GRID}\n        strokeWidth={1}\n        strokeDasharray=\"4 4\"\n      />\n      {RINGS.map((level) => {\n        const r = scaleR(level);\n        const x = cx + r * Math.cos(toRad(TICK_ANGLE));\n        const y = cy + r * Math.sin(toRad(TICK_ANGLE));\n        const label = `${Math.round(level)}%`;\n        return (\n          <g key={`tick-${level}`}>\n            <rect\n              x={x - 4}\n              y={y - 13}\n              width={label.length * 8 + 8}\n              height={18}\n              rx={4}\n              fill={PAGE_BG}\n              opacity={0.85}\n            />\n            <text\n              x={x}\n              y={y}\n              fill={INK_SOFT}\n              fontSize={14}\n              textAnchor=\"start\"\n              dominantBaseline=\"central\"\n            >\n              {label}\n            </text>\n          </g>\n        );\n      })}\n\n      {/* Stacked speed-bin wedges — one annular sector per direction/speed pair */}\n      {DIRECTIONS.map((_, dirIdx) => {\n        const centerDeg = dirIdx * SECTOR_SPAN;\n        const startDeg = centerDeg - SECTOR_SPAN / 2 + SECTOR_GAP / 2;\n        const endDeg = centerDeg + SECTOR_SPAN / 2 - SECTOR_GAP / 2;\n        let inner = 0;\n        return SPEED_BINS.map((_, speedIdx) => {\n          const outer = renderCumPct[dirIdx][speedIdx];\n          const isZero = outer <= inner;\n          const path = isZero\n            ? null\n            : annularSectorPath(\n                cx,\n                cy,\n                scaleR(inner),\n                scaleR(outer),\n                startDeg,\n                endDeg,\n              );\n          inner = outer;\n          if (isZero) return null;\n          return (\n            <path\n              key={`wedge-${dirIdx}-${speedIdx}`}\n              d={path}\n              fill={SPEED_COLORS[speedIdx]}\n              stroke={PAGE_BG}\n              strokeWidth={1}\n              {...getInteractionProps({\n                type: \"bar\",\n                seriesId: `speed-${speedIdx}`,\n                dataIndex: dirIdx,\n              })}\n            />\n          );\n        });\n      })}\n\n      {/* Compass labels — cardinal / intercardinal / secondary type hierarchy */}\n      {DIRECTIONS.map((label, i) => {\n        const a = toRad(i * SECTOR_SPAN);\n        const cos = Math.cos(a);\n        const sin = Math.sin(a);\n        const anchor = cos > 0.15 ? \"start\" : cos < -0.15 ? \"end\" : \"middle\";\n        const baseline =\n          sin > 0.5 ? \"hanging\" : sin < -0.5 ? \"auto\" : \"central\";\n        const isCardinal = i % 4 === 0;\n        const isIntercardinal = i % 4 === 2;\n        const fontSize = isCardinal ? 20 : isIntercardinal ? 16 : 13;\n        return (\n          <text\n            key={`label-${label}`}\n            x={cx + labelR * cos}\n            y={cy + labelR * sin}\n            fill={isCardinal || isIntercardinal ? INK : INK_SOFT}\n            fontSize={fontSize}\n            fontWeight={isCardinal ? 700 : isIntercardinal ? 600 : 400}\n            textAnchor={anchor}\n            dominantBaseline={baseline}\n          >\n            {label}\n          </text>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Title drawn on the surface (legend is the native <ChartsLegend> below) --\nfunction Title() {\n  return (\n    <text\n      x={size.width / 2}\n      y={54}\n      fill={INK}\n      fontSize={30}\n      fontWeight={700}\n      textAnchor=\"middle\"\n    >\n      windrose-basic · javascript · muix · anyplot.ai\n    </text>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) --------------\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={size.width}\n      height={size.height}\n      series={BAR_SERIES}\n      xAxis={[{ id: \"direction\", scaleType: \"band\", data: DIRECTIONS }]}\n      margin={{ top: 96, bottom: 96, left: 70, right: 70 }}\n      skipAnimation\n    >\n      <Title />\n      <WindRoseLayer />\n      <ChartsLegend\n        position={{ vertical: \"bottom\", horizontal: \"middle\" }}\n        labelStyle={{ fontSize: 16 }}\n        itemMarkWidth={22}\n        itemMarkHeight={22}\n        markGap={8}\n        itemGap={36}\n      />\n      <ChartsTooltip trigger=\"item\" />\n    </ChartContainer>\n  );\n}\n"}