{"spec_id":"windrose-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nwindrose-basic: Wind Rose Chart\nLibrary: bokeh 3.9.2 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-08-05\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette (canonical order) for speed bins, low to high\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data - Generate realistic wind data for a coastal weather station\nnp.random.seed(42)\nn_observations = 5000\n\n# Direction distribution favoring SW and W winds (common for coastal areas)\ndirection_weights = [0.08, 0.06, 0.05, 0.08, 0.10, 0.18, 0.25, 0.20]  # N, NE, E, SE, S, SW, W, NW\ndirections_idx = np.random.choice(8, size=n_observations, p=direction_weights)\ndirection_noise = np.random.uniform(-22.5, 22.5, n_observations)\ndirections = directions_idx * 45 + direction_noise\ndirections = directions % 360\n\n# Wind speed with Weibull distribution (realistic for wind data)\nspeeds = np.random.weibull(2.2, n_observations) * 6  # Scale for m/s\n\n# Define bins\ndirection_bins = np.linspace(0, 360, 9)  # 8 direction sectors\ndirection_labels = [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"]\nspeed_bins = [0, 3, 6, 9, 12, np.inf]  # m/s ranges\nspeed_labels = [\"0-3 m/s\", \"3-6 m/s\", \"6-9 m/s\", \"9-12 m/s\", \">12 m/s\"]\n\n# Aggregate data into direction/speed bins\ndir_indices = np.digitize(directions, direction_bins) - 1\ndir_indices = np.clip(dir_indices, 0, 7)\nspeed_indices = np.digitize(speeds, speed_bins) - 1\nspeed_indices = np.clip(speed_indices, 0, len(speed_bins) - 2)\n\n# Calculate frequencies for each direction/speed combination\nfrequencies = np.zeros((8, len(speed_bins) - 1))\nfor d_idx in range(8):\n    for s_idx in range(len(speed_bins) - 1):\n        frequencies[d_idx, s_idx] = np.sum((dir_indices == d_idx) & (speed_indices == s_idx))\n\n# Convert to percentages\nfrequencies = frequencies / n_observations * 100\n\n# Create figure - square format for polar-like display\np = figure(\n    width=2400,\n    height=2400,\n    title=\"windrose-basic · python · bokeh · anyplot.ai\",\n    x_range=(-35, 35),\n    y_range=(-35, 35),\n    match_aspect=True,  # keep wedges/circles round even though the legend eats frame width\n    tools=\"\",\n    toolbar_location=None,\n)\n\n# Style title and overall appearance\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.align = \"center\"\n\n# Theme-adaptive background\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Hide axes for polar plot\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\n\n# Draw concentric circles for reference (grid sits below the data)\nsector_width = 2 * np.pi / 8  # 45 degrees in radians\nfor radius in [5, 10, 15, 20, 25]:\n    theta_circle = np.linspace(0, 2 * np.pi, 100)\n    x_circle = radius * np.cos(theta_circle)\n    y_circle = radius * np.sin(theta_circle)\n    p.line(x_circle, y_circle, line_color=INK_SOFT, line_width=1.5, line_alpha=0.2)\n\n# Draw direction spokes (grid sits below the data)\nfor i in range(8):\n    angle = np.pi / 2 - i * sector_width  # Start from North (top), go clockwise\n    x_spoke = [0, 28 * np.cos(angle)]\n    y_spoke = [0, 28 * np.sin(angle)]\n    p.line(x_spoke, y_spoke, line_color=INK_SOFT, line_width=1.5, line_alpha=0.15)\n\n# Stack each direction's speed bins from the center outward\ncenter_angles = np.pi / 2 - np.arange(8) * sector_width  # North = up, clockwise\ninner_radii = np.zeros((8, len(speed_labels)))\nouter_radii = np.zeros((8, len(speed_labels)))\ncumulative = np.zeros(8)\nfor speed_idx in range(len(speed_labels)):\n    inner_radii[:, speed_idx] = cumulative\n    cumulative = cumulative + frequencies[:, speed_idx]\n    outer_radii[:, speed_idx] = cumulative\ntotal_freq = cumulative  # total stacked height per direction\n\n# Draw each speed bin as one vectorized annular_wedge glyph (bokeh's native\n# polar/radial primitive) spanning all 8 directions, instead of hand-built\n# polygons — a single ColumnDataSource + glyph call per bin.\nlegend_items = []\ngap = 0.02  # radians of separation between neighboring direction sectors\nfor speed_idx in range(len(speed_labels)):\n    mask = frequencies[:, speed_idx] > 0.1  # only draw significant bins\n    if not mask.any():\n        continue\n    source = ColumnDataSource(\n        data={\n            \"inner_radius\": inner_radii[mask, speed_idx],\n            \"outer_radius\": outer_radii[mask, speed_idx],\n            \"start_angle\": center_angles[mask] - sector_width / 2 + gap,\n            \"end_angle\": center_angles[mask] + sector_width / 2 - gap,\n        }\n    )\n    renderer = p.annular_wedge(\n        x=0,\n        y=0,\n        inner_radius=\"inner_radius\",\n        outer_radius=\"outer_radius\",\n        start_angle=\"start_angle\",\n        end_angle=\"end_angle\",\n        source=source,\n        fill_color=IMPRINT[speed_idx],\n        fill_alpha=0.85,\n        line_color=PAGE_BG,\n        line_width=1.5,\n    )\n    legend_items.append(LegendItem(label=speed_labels[speed_idx], renderers=[renderer]))\n\n# Highlight the dominant direction with a thin accent ring just outside its\n# stack — a deliberate emphasis technique beyond the data's natural sizing.\ndominant_idx = int(np.argmax(total_freq))\ndominant_angle = center_angles[dominant_idx]\np.arc(\n    x=0,\n    y=0,\n    radius=total_freq[dominant_idx] + 1.2,\n    start_angle=dominant_angle - sector_width / 2 + gap,\n    end_angle=dominant_angle + sector_width / 2 - gap,\n    line_color=INK,\n    line_width=4,\n    line_alpha=0.6,\n)\n\n# Frequency and direction labels are added last so they render on top of the\n# wedges — added earlier, bokeh's default draw order let the wedges paint over\n# the grid text wherever a sector's stacked height reached that far outward.\nfor radius in [5, 10, 15, 20, 25]:\n    p.text(\n        x=[radius + 0.5],\n        y=[0.5],\n        text=[f\"{radius}%\"],\n        text_font_size=\"34pt\",\n        text_color=INK_SOFT,\n        text_baseline=\"bottom\",\n    )\n\nfor i, label in enumerate(direction_labels):\n    angle = np.pi / 2 - i * sector_width  # Start from North (top), go clockwise\n    label_radius = 30\n    x_label = label_radius * np.cos(angle)\n    y_label = label_radius * np.sin(angle)\n    p.text(\n        x=[x_label],\n        y=[y_label],\n        text=[label],\n        text_font_size=\"42pt\",\n        text_font_style=\"bold\",\n        text_color=INK,\n        text_align=\"center\",\n        text_baseline=\"middle\",\n    )\n\n# Add legend with theme-adaptive styling\nlegend = Legend(\n    items=legend_items,\n    location=\"center\",\n    label_text_font_size=\"34pt\",\n    label_text_color=INK_SOFT,\n    spacing=14,\n    padding=20,\n    background_fill_alpha=0.95,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    border_line_width=2,\n)\np.add_layout(legend, \"right\")\n\n# Add subtitle with data info\np.text(\n    x=[0],\n    y=[-33],\n    text=[\"Wind Speed (m/s)\"],\n    text_font_size=\"26pt\",\n    text_color=INK_MUTED,\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\n\n# Save as HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome — Selenium 4 / Selenium Manager auto-resolves a working driver\nW, H = 2400, 2400\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\n# headless Chrome's --window-size sets the OUTER window, which still reserves a\n# phantom title-bar height even headless — pin the viewport exactly via CDP.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}