{"spec_id":"windbarb-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// windbarb-basic: Wind Barb Plot for Meteorological Data\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n//# anyplot-orientation: landscape\n// anyplot.ai\n// windbarb-basic: Wind Barb Plot for Meteorological Data\n// Library: MUI X Charts 7.29 | React 18 | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-02\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"Roboto, Helvetica, Arial, sans-serif\";\n\n// --- Data: synthetic surface wind observations around a low-pressure system --\n// (weather-station grid, distances in km, wind components u/v in knots).\n// A Rankine-vortex-like tangential profile with a small cross-isobar inflow\n// component — the same idealized shape used to teach cyclone wind fields.\nconst CENTER = { x: 400, y: 300 };\nconst PEAK_RADIUS_KM = 180;\nconst PEAK_SPEED_KT = 58;\nconst INFLOW_DEG = 22;\n\n// Rankine-vortex-like tangential speed profile: 0 at the core, ramping up to\n// PEAK_SPEED_KT at PEAK_RADIUS_KM, then decaying outward. Shared by the outer\n// station grid and the inner core transect below, so both draw from the same\n// physically consistent wind field. Never called with r=0 (a true center\n// point has an undefined direction), so no divide-by-zero guard is needed.\nfunction windAt(x, y) {\n  const dx = x - CENTER.x;\n  const dy = y - CENTER.y;\n  const r = Math.hypot(dx, dy);\n  const tangentE = -dy / r;\n  const tangentN = dx / r;\n  const inflowE = -dx / r;\n  const inflowN = -dy / r;\n  const a = (INFLOW_DEG * Math.PI) / 180;\n  const dirE = Math.cos(a) * tangentE + Math.sin(a) * inflowE;\n  const dirN = Math.cos(a) * tangentN + Math.sin(a) * inflowN;\n  const ratio = r / PEAK_RADIUS_KM;\n  const speed = PEAK_SPEED_KT * ratio * Math.exp(1 - ratio);\n  return { u: speed * dirE, v: speed * dirN };\n}\n\nconst stations = [];\nfor (let x = 0; x <= 800; x += 160) {\n  for (let y = 0; y <= 600; y += 150) {\n    stations.push({ x, y, ...windAt(x, y) });\n  }\n}\n\n// Inner core transect: the coarse outer grid never comes closer than ~80 km\n// to the vortex center, so every one of its stations lands in the high-wind\n// (27-58 kt) band. These four points sample small radii near the core, where\n// the same tangential profile ramps up from near-zero, so the real dataset\n// (not just the legend) demonstrates the calm circle and the simpler\n// half-barb / single-full-barb notations.\nconst CORE_TRANSECT = [\n  { r: 2, angleDeg: 35 }, // ~1.7 kt -> calm circle\n  { r: 7, angleDeg: 140 }, // ~5.9 kt -> half barb\n  { r: 14, angleDeg: 235 }, // ~11.3 kt -> single full barb\n  { r: 24, angleDeg: 330 }, // ~18.4 kt -> two full barbs\n];\nfor (const { r, angleDeg } of CORE_TRANSECT) {\n  const angle = (angleDeg * Math.PI) / 180;\n  const x = CENTER.x + r * Math.cos(angle);\n  const y = CENTER.y + r * Math.sin(angle);\n  stations.push({ x, y, ...windAt(x, y) });\n}\n\n// --- Wind barb geometry (standard meteorological notation) ------------------\n// Staff points toward the direction the wind blows FROM; pennant = 50 kt,\n// full barb = 10 kt, half barb = 5 kt, stacked from the staff tip inward.\n// Barbs sit on the left of the staff (Northern Hemisphere convention).\nconst CALM_KT = 2.5;\nconst CALM_RADIUS = 5.5;\nconst BARB_ANGLE_DEG = -112;\nconst STATION_GLYPH = { staffLen: 42, barbLen: 14, barbGap: 7 };\nconst LEGEND_GLYPH = { staffLen: 34, barbLen: 12, barbGap: 6 };\n\nfunction rotate(dx, dy, deg) {\n  const rad = (deg * Math.PI) / 180;\n  const cos = Math.cos(rad);\n  const sin = Math.sin(rad);\n  return [dx * cos - dy * sin, dx * sin + dy * cos];\n}\n\nfunction barbGlyph(key, px, py, u, v, brand, geom) {\n  const speed = Math.hypot(u, v);\n  if (speed < CALM_KT) {\n    return (\n      <circle key={key} cx={px} cy={py} r={CALM_RADIUS} fill=\"none\" stroke={brand} strokeWidth={2.5} />\n    );\n  }\n\n  const inv = 1 / speed;\n  // East/north wind vector -> screen pixels, reversed (staff points upwind).\n  const outDx = -u * inv;\n  const outDy = v * inv;\n  const tipX = px + outDx * geom.staffLen;\n  const tipY = py + outDy * geom.staffLen;\n  const along = (d) => [tipX - outDx * d, tipY - outDy * d];\n  const [rx, ry] = rotate(outDx, outDy, BARB_ANGLE_DEG);\n\n  const rounded = Math.round(speed / 5) * 5;\n  let remaining = rounded;\n  const pennants = Math.floor(remaining / 50);\n  remaining -= pennants * 50;\n  const fullBarbs = Math.floor(remaining / 10);\n  remaining -= fullBarbs * 10;\n  const halfBarb = remaining >= 5;\n\n  const marks = [];\n  let pos = 0;\n  for (let p = 0; p < pennants; p += 1) {\n    const [nx, ny] = along(pos);\n    const [fx, fy] = along(pos + geom.barbGap);\n    const apexX = (nx + fx) / 2 + rx * geom.barbLen;\n    const apexY = (ny + fy) / 2 + ry * geom.barbLen;\n    marks.push(\n      <polygon key={`${key}-p${p}`} points={`${nx},${ny} ${fx},${fy} ${apexX},${apexY}`} fill={brand} />\n    );\n    pos += geom.barbGap;\n  }\n  for (let b = 0; b < fullBarbs; b += 1) {\n    const [bx, by] = along(pos);\n    marks.push(\n      <line\n        key={`${key}-b${b}`}\n        x1={bx}\n        y1={by}\n        x2={bx + rx * geom.barbLen}\n        y2={by + ry * geom.barbLen}\n        stroke={brand}\n        strokeWidth={3.2}\n        strokeLinecap=\"round\"\n      />\n    );\n    pos += geom.barbGap;\n  }\n  if (halfBarb) {\n    const [bx, by] = along(pos);\n    marks.push(\n      <line\n        key={`${key}-half`}\n        x1={bx}\n        y1={by}\n        x2={bx + rx * geom.barbLen * 0.5}\n        y2={by + ry * geom.barbLen * 0.5}\n        stroke={brand}\n        strokeWidth={3.2}\n        strokeLinecap=\"round\"\n      />\n    );\n  }\n\n  return (\n    <g key={key}>\n      <circle cx={px} cy={py} r={2.2} fill={brand} />\n      <line x1={px} y1={py} x2={tipX} y2={tipY} stroke={brand} strokeWidth={3} strokeLinecap=\"round\" />\n      {marks}\n    </g>\n  );\n}\n\n// --- Station layer: maps data coordinates to pixels via the chart's own scales\nfunction WindBarbLayer({ brand }) {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g>\n      {stations.map((s) =>\n        barbGlyph(`s-${s.x}-${s.y}`, xScale(s.x), yScale(s.y), s.u, s.v, brand, STATION_GLYPH)\n      )}\n    </g>\n  );\n}\n\n// --- Legend: fixed pixel row under the title, outside the data area ---------\nfunction legendKey(width, brand, ink) {\n  const baseY = 148;\n  const items = [\n    { cx: width * 0.34, u: 0, v: 0, label: \"Calm (< 2.5 kt)\" },\n    { cx: width * 0.46, u: 0, v: -5, label: \"5 kt\" },\n    { cx: width * 0.58, u: 0, v: -25, label: \"25 kt\" },\n    { cx: width * 0.7, u: 0, v: -50, label: \"50 kt\" },\n  ];\n  return (\n    <g>\n      {items.map((it, i) => (\n        <g key={`legend-${i}`}>\n          {barbGlyph(`legend-glyph-${i}`, it.cx, baseY, it.u, it.v, brand, LEGEND_GLYPH)}\n          <text x={it.cx} y={baseY + 24} textAnchor=\"middle\" fontSize={13} fontFamily={FONT} fill={ink}>\n            {it.label}\n          </text>\n        </g>\n      ))}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const size = window.ANYPLOT_SIZE;\n  const brand = t.palette[0];\n\n  return (\n    <ChartContainer\n      width={size.width}\n      height={size.height}\n      series={[]}\n      margin={{ top: 182, right: 50, bottom: 74, left: 112 }}\n      xAxis={[\n        {\n          scaleType: \"linear\",\n          min: -100,\n          max: 900,\n          domainLimit: \"strict\",\n          label: \"Distance east of grid origin (km)\",\n          labelStyle: { fontSize: 16, fontFamily: FONT },\n          tickLabelStyle: { fontSize: 13, fontFamily: FONT },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"linear\",\n          min: -90,\n          max: 690,\n          domainLimit: \"strict\",\n          label: \"Distance north of grid origin (km)\",\n          labelStyle: { fontSize: 16, fontFamily: FONT },\n          tickLabelStyle: { fontSize: 13, fontFamily: FONT },\n          // Actual tick text stays 13px (tickLabelStyle above); this\n          // deprecated prop is what ChartsYAxis uses to reserve the label\n          // offset (labelRefPoint.x = tickFontSize + tickSize + 10), so it\n          // must reflect the rendered tick label width to avoid the axis\n          // label overlapping the tick numbers.\n          tickFontSize: 30,\n        },\n      ]}\n      disableAxisListener\n      skipAnimation\n    >\n      <ChartsXAxis position=\"bottom\" />\n      <ChartsYAxis position=\"left\" />\n      <text x={size.width / 2} y={44} textAnchor=\"middle\" fontSize={22} fontWeight={600} fontFamily={FONT} fill={t.ink}>\n        windbarb-basic · javascript · muix · anyplot.ai\n      </text>\n      {legendKey(size.width, brand, t.inkSoft)}\n      <WindBarbLayer brand={brand} />\n    </ChartContainer>\n  );\n}\n"}