{"spec_id":"rose-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nrose-basic: Basic Rose Chart\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-07-25\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme\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\"\n\n# Data - Monthly rainfall in mm (12-month cyclical pattern)\nmonths = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\nrainfall = [78, 52, 68, 45, 35, 28, 22, 30, 55, 85, 92, 88]\n\nn = len(months)\nangle_step = 360 / n\nstart_angles = [i * angle_step for i in range(n)]\nend_angles = [(i + 1) * angle_step for i in range(n)]\n\ndf = pd.DataFrame(\n    {\"month\": months, \"value\": rainfall, \"startAngle\": np.radians(start_angles), \"endAngle\": np.radians(end_angles)}\n)\n\nmax_val = 100\nchart_radius = 190\n\n# theta channel identity scale — without it, Altair auto-fits each layer's theta\n# domain independently to a 0..2*pi range, which rotates/stretches every layer by a\n# different amount and desyncs the month/value labels from their wedges. An explicit\n# domain=range=[0, 2*pi] scale disables that auto-fit so raw radians (0 = 12 o'clock,\n# increasing clockwise — the mark_arc convention) pass through unchanged everywhere.\nTHETA_SCALE = alt.Scale(domain=[0, 2 * np.pi], range=[0, 2 * np.pi])\n\n# Radial gridlines at 25, 50, 75, 100 mm.\n# (mark_point / mark_line silently collapse the \"radius\" channel to 0 in this\n# vl-convert version — only arc-family marks honor it correctly. A literal full\n# sweep via alt.value(2*pi) also triggers a vl-convert autosize bug that balloons\n# the exported canvas, so each ring is a near-full unfilled arc (0 -> 2*pi minus a\n# hair) driven by data columns instead, which renders as a clean circle.)\ngrid_values = [25, 50, 75, 100]\ngrid_data = pd.DataFrame({\"value\": grid_values, \"start\": [0.0] * 4, \"end\": [2 * np.pi - 0.001] * 4})\n\ngridlines = (\n    alt.Chart(grid_data)\n    .mark_arc(filled=False, stroke=INK_SOFT, strokeWidth=1.0, strokeOpacity=0.35, strokeDash=[6, 4])\n    .encode(\n        theta=alt.Theta(\"start:Q\", scale=THETA_SCALE),\n        theta2=alt.Theta2(\"end:Q\"),\n        radius=alt.Radius(\"value:Q\", scale=alt.Scale(type=\"linear\", domain=[0, max_val], range=[0, chart_radius])),\n    )\n)\n\n# Grid labels — the rose layer draws *after* gridlines/labels, so a ring label\n# sitting over a taller petal gets silently painted over; each label is anchored at\n# the wedge *boundary* (never a midpoint, which is where month/value labels sit)\n# nearest the shortest petals (Jun 28 mm / Jul 22 mm / Aug 30 mm), found by\n# checking every boundary's neighboring petal height. 25/50 mm use the Jul/Aug\n# boundary, 75/100 mm the Jun/Jul boundary — both comfortably below every ring radius.\ngrid_label_inner = pd.DataFrame({\"value\": [25, 50], \"label\": [\"25 mm\", \"50 mm\"], \"theta\": [np.radians(210)] * 2})\ngrid_label_outer = pd.DataFrame({\"value\": [75, 100], \"label\": [\"75 mm\", \"100 mm\"], \"theta\": [np.pi] * 2})\n_grid_radius_scale = alt.Scale(type=\"linear\", domain=[0, max_val], range=[0, chart_radius])\n\ngrid_labels = alt.Chart(grid_label_inner).mark_text(fontSize=10, dy=6, align=\"center\", baseline=\"top\").encode(\n    theta=alt.Theta(\"theta:Q\", scale=THETA_SCALE),\n    radius=alt.Radius(\"value:Q\", scale=_grid_radius_scale),\n    text=\"label:N\",\n    color=alt.value(INK_SOFT),\n) + alt.Chart(grid_label_outer).mark_text(fontSize=10, dy=6, align=\"center\", baseline=\"top\").encode(\n    theta=alt.Theta(\"theta:Q\", scale=THETA_SCALE),\n    radius=alt.Radius(\"value:Q\", scale=_grid_radius_scale),\n    text=\"label:N\",\n    color=alt.value(INK_SOFT),\n)\n\n# Rose chart segments — imprint_seq (sequential Imprint gradient) for value-based color encoding\nrose = (\n    alt.Chart(df)\n    .mark_arc(stroke=PAGE_BG, strokeWidth=2, innerRadius=0)\n    .encode(\n        theta=alt.Theta(\"startAngle:Q\", stack=None, scale=THETA_SCALE),\n        theta2=alt.Theta2(\"endAngle:Q\"),\n        radius=alt.Radius(\"value:Q\", scale=alt.Scale(type=\"linear\", domain=[0, max_val], range=[0, chart_radius])),\n        color=alt.Color(\"value:Q\", scale=alt.Scale(domain=[0, max_val], range=[\"#009E73\", \"#4467A3\"]), legend=None),\n        tooltip=[alt.Tooltip(\"month:N\", title=\"Month\"), alt.Tooltip(\"value:Q\", title=\"Rainfall (mm)\")],\n    )\n)\n\n# Value labels near segment tips — fixed additive offset keeps spacing consistent for small segments\nmid_angles = [(i + 0.5) * angle_step for i in range(n)]\nmid_angles_rad = np.radians(mid_angles)\n\nlabel_radii = [v + 12 for v in rainfall]\n\nlabel_data = pd.DataFrame({\"month\": months, \"value\": rainfall, \"theta\": mid_angles_rad, \"labelRadius\": label_radii})\n\nvalue_labels = (\n    alt.Chart(label_data)\n    .mark_text(fontSize=11, fontWeight=\"bold\")\n    .encode(\n        theta=alt.Theta(\"theta:Q\", scale=THETA_SCALE),\n        radius=alt.Radius(\n            \"labelRadius:Q\", scale=alt.Scale(type=\"linear\", domain=[0, max_val], range=[0, chart_radius])\n        ),\n        text=alt.Text(\"value:Q\"),\n        color=alt.value(INK),\n    )\n)\n\n# Month labels at outer edge — just beyond the 100 mm gridline\nmonth_label_data = pd.DataFrame({\"month\": months, \"theta\": mid_angles_rad, \"labelRadius\": [112.0] * n})\n\nmonth_labels = (\n    alt.Chart(month_label_data)\n    .mark_text(fontSize=14, fontWeight=\"bold\")\n    .encode(\n        theta=alt.Theta(\"theta:Q\", scale=THETA_SCALE),\n        radius=alt.Radius(\n            \"labelRadius:Q\", scale=alt.Scale(type=\"linear\", domain=[0, max_val], range=[0, chart_radius])\n        ),\n        text=alt.Text(\"month:N\"),\n        color=alt.value(INK),\n    )\n)\n\n# Combine all layers\nchart = (\n    alt.layer(gridlines, grid_labels, rose, value_labels, month_labels)\n    .properties(\n        width=500,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=\"rose-basic · python · altair · anyplot.ai\", fontSize=16, anchor=\"middle\", offset=14, color=INK\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_axis(grid=False, domain=False, ticks=False, labels=False, title=None)\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Canvas hard rule: pad (never crop) up to the exact 2400x2400 target.\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}