{"spec_id":"windbarb-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nwindbarb-basic: Wind Barb Plot for Meteorological Data\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 81/100 | Created: 2026-05-19\n\"\"\"\n\nimport math\nimport os\nimport sys\nimport xml.etree.ElementTree as ET\n\n\n# Drop script dir from sys.path so `import pygal` resolves to the library, not this file\nsys.path[:] = [p for p in sys.path if p not in (\"\", \".\", os.path.dirname(os.path.abspath(__file__)))]\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nBRAND = \"#009E73\"\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(BRAND,),\n    title_font_size=28,\n    label_font_size=18,\n    major_label_font_size=16,\n    legend_font_size=16,\n    value_font_size=14,\n    stroke_width=2,\n)\n\n# Data: synthetic surface wind observations on a 12×7 station grid\n# Domain: western North America, simulating a mid-latitude cyclone\nnp.random.seed(42)\nNX, NY = 12, 7\nlons = np.linspace(-125, -70, NX)\nlats = np.linspace(25, 55, NY)\nlon_g, lat_g = np.meshgrid(lons, lats)\nslons = lon_g.ravel()\nslats = lat_g.ravel()\nN = len(slons)\n\nLOW_LON, LOW_LAT = -100.0, 40.0\nrel_lon = slons - LOW_LON\nrel_lat = slats - LOW_LAT\ndist = np.sqrt(rel_lon**2 + rel_lat**2) + 0.5\nangle_to_low = np.arctan2(rel_lon, rel_lat)\ndirs = (np.degrees(angle_to_low + math.pi / 2) + np.random.normal(0, 12, N)) % 360\nspeeds = np.clip(22 + 180 / dist + np.random.normal(0, 4, N), 3, 70)\n# Two calm stations (speed < 2.5 kt)\nspeeds[10] = 1.5\nspeeds[45] = 1.5\n\nSTAFF, BSPC, BFULL, BHALF = 72, 16, 44, 22\n\n\ndef barb_elems(cx, cy, spd, dirn):\n    \"\"\"Return list of SVG element strings for one meteorological wind barb.\"\"\"\n    if spd < 2.5:\n        return [f'<circle cx=\"{cx:.1f}\" cy=\"{cy:.1f}\" r=\"14\" fill=\"none\" stroke=\"{BRAND}\" stroke-width=\"3.5\"/>']\n    out = [f'<circle cx=\"{cx:.1f}\" cy=\"{cy:.1f}\" r=\"5\" fill=\"{BRAND}\"/>']\n    dr = math.radians(dirn)\n    sdx, sdy = math.sin(dr), -math.cos(dr)\n    xt, yt = cx + sdx * STAFF, cy + sdy * STAFF\n    out.append(\n        f'<line x1=\"{cx:.1f}\" y1=\"{cy:.1f}\" x2=\"{xt:.1f}\" y2=\"{yt:.1f}\" '\n        f'stroke=\"{BRAND}\" stroke-width=\"3.5\" stroke-linecap=\"round\"/>'\n    )\n    bpx, bpy = -sdy, sdx\n    sp5 = round(spd / 5) * 5\n    n50, sp5 = divmod(sp5, 50)\n    n10, sp5 = divmod(sp5, 10)\n    n5 = sp5 // 5\n    pos = 0.0\n    for _ in range(n50):\n        bx, by = xt - sdx * pos, yt - sdy * pos\n        tx, ty = bx + bpx * BFULL, by + bpy * BFULL\n        nx2, ny2 = bx - sdx * BSPC, by - sdy * BSPC\n        out.append(\n            f'<polygon points=\"{bx:.1f},{by:.1f} {tx:.1f},{ty:.1f} {nx2:.1f},{ny2:.1f}\" fill=\"{BRAND}\" stroke=\"none\"/>'\n        )\n        pos += BSPC\n    for _ in range(n10):\n        bx, by = xt - sdx * pos, yt - sdy * pos\n        tx, ty = bx + bpx * BFULL, by + bpy * BFULL\n        out.append(\n            f'<line x1=\"{bx:.1f}\" y1=\"{by:.1f}\" x2=\"{tx:.1f}\" y2=\"{ty:.1f}\" '\n            f'stroke=\"{BRAND}\" stroke-width=\"3.5\" stroke-linecap=\"round\"/>'\n        )\n        pos += BSPC\n    for _ in range(n5):\n        bx, by = xt - sdx * pos, yt - sdy * pos\n        tx, ty = bx + bpx * BHALF, by + bpy * BHALF\n        out.append(\n            f'<line x1=\"{bx:.1f}\" y1=\"{by:.1f}\" x2=\"{tx:.1f}\" y2=\"{ty:.1f}\" '\n            f'stroke=\"{BRAND}\" stroke-width=\"3.5\" stroke-linecap=\"round\"/>'\n        )\n        pos += BSPC\n    return out\n\n\nclass WindBarbXY(pygal.XY):\n    \"\"\"pygal.XY subclass overlaying meteorological wind barb symbols.\n\n    Data points are hidden (show_dots=False); wind barbs are injected via\n    barb_elems() into the plot node using pygal's internal coordinate view.\n    \"\"\"\n\n    def __init__(self, station_winds, **kw):\n        self._station_winds = station_winds  # set before super().__init__\n        super().__init__(**kw)\n\n    def _draw(self):\n        super()._draw()\n        # After super()._draw(), self.view maps data coords to plot-area pixels\n        # and self.nodes['plot'] is the SVG group with translate(ml, mt)\n        barb_node = self.svg.node(self.nodes[\"plot\"], tag=\"g\", id=\"wind-barbs\")\n        for lon, lat, spd, dirn in self._station_winds:\n            px, py = self.view.x(lon), self.view.y(lat)\n            for s in barb_elems(px, py, spd, dirn):\n                barb_node.append(ET.fromstring(s.encode()))\n        self._draw_wind_legend()\n\n    def _draw_wind_legend(self):\n        \"\"\"Draw wind speed legend in the bottom margin below the plot area.\"\"\"\n        # Place legend near the bottom of the SVG (absolute coordinates)\n        lx = self.margin_box.left\n        ly = self.height - 90\n        leg = self.svg.node(self.nodes[\"graph\"], tag=\"g\", id=\"wind-legend\")\n        for j, (spd, lbl) in enumerate([(5, \"5 kt\"), (10, \"10 kt\"), (20, \"20 kt\"), (50, \"50 kt\")]):\n            ix = lx + j * 340\n            for s in barb_elems(ix, ly, spd, 0):  # northerly sample barbs\n                leg.append(ET.fromstring(s.encode()))\n            txt = ET.SubElement(leg, \"text\")\n            txt.set(\"x\", str(ix + 100))\n            txt.set(\"y\", str(ly + 10))\n            txt.set(\"fill\", INK_MUTED)\n            txt.set(\"font-size\", \"26\")\n            txt.set(\"font-family\", \"sans-serif\")\n            txt.set(\"text-anchor\", \"start\")\n            txt.text = lbl\n        cx_calm = lx + 4 * 340\n        leg.append(\n            ET.fromstring(\n                f'<circle cx=\"{cx_calm}\" cy=\"{ly}\" r=\"14\" fill=\"none\" stroke=\"{BRAND}\" stroke-width=\"3.5\"/>'.encode()\n            )\n        )\n        calm_txt = ET.SubElement(leg, \"text\")\n        calm_txt.set(\"x\", str(cx_calm + 28))\n        calm_txt.set(\"y\", str(ly + 10))\n        calm_txt.set(\"fill\", INK_MUTED)\n        calm_txt.set(\"font-size\", \"26\")\n        calm_txt.set(\"font-family\", \"sans-serif\")\n        calm_txt.text = \"Calm (<2.5 kt)\"\n\n\nstation_winds = [(float(slons[i]), float(slats[i]), float(speeds[i]), float(dirs[i])) for i in range(N)]\n\nchart = WindBarbXY(\n    station_winds=station_winds,\n    style=custom_style,\n    width=4800,\n    height=2700,\n    title=\"windbarb-basic · python · pygal · anyplot.ai\",\n    x_title=\"Longitude\",\n    y_title=\"Latitude\",\n    show_dots=False,\n    stroke=False,\n    show_legend=False,\n    show_x_guides=True,\n    show_y_guides=True,\n    margin_bottom=220,\n)\n# Two corner points establish the axis data range (dots hidden via show_dots=False)\nchart.add(\"wind\", [(float(lons[0]), float(lats[0])), (float(lons[-1]), float(lats[-1]))])\nchart.x_labels = [f\"{abs(int(lon))}°W\" for lon in lons]\nchart.y_labels = [f\"{lat:.0f}°N\" for lat in lats]\n\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}