|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | rose-basic: Basic Rose Chart |
3 | | -Library: altair 6.0.0 | Python 3.13.11 |
4 | | -Quality: 92/100 | Created: 2025-12-23 |
| 3 | +Library: altair 6.1.0 | Python 3.13.13 |
| 4 | +Quality: 87/100 | Updated: 2026-04-30 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import altair as alt |
8 | 10 | import numpy as np |
9 | 11 | import pandas as pd |
10 | 12 |
|
11 | 13 |
|
12 | | -# Data - Monthly rainfall in mm (cyclical 12-month pattern) |
| 14 | +# Theme |
| 15 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 16 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 17 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 18 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 19 | + |
| 20 | +# Data - Monthly rainfall in mm (12-month cyclical pattern) |
13 | 21 | months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] |
14 | 22 | rainfall = [78, 52, 68, 45, 35, 28, 22, 30, 55, 85, 92, 88] |
15 | 23 |
|
16 | 24 | n = len(months) |
17 | | - |
18 | | -# Calculate angles starting at 12 o'clock (top) and going clockwise |
19 | | -# -90 degrees offset to start at top, then proceeding clockwise |
20 | 25 | angle_step = 360 / n |
21 | 26 | start_angles = [-90 + i * angle_step for i in range(n)] |
22 | 27 | end_angles = [-90 + (i + 1) * angle_step for i in range(n)] |
23 | 28 |
|
24 | | -# Create DataFrame with explicit angles |
25 | 29 | df = pd.DataFrame( |
26 | | - { |
27 | | - "month": months, |
28 | | - "value": rainfall, |
29 | | - "order": range(n), |
30 | | - "startAngle": np.radians(start_angles), |
31 | | - "endAngle": np.radians(end_angles), |
32 | | - } |
| 30 | + {"month": months, "value": rainfall, "startAngle": np.radians(start_angles), "endAngle": np.radians(end_angles)} |
33 | 31 | ) |
34 | 32 |
|
35 | | -# Max value for radius scaling - use 100 for nicer gridline values |
36 | 33 | max_val = 100 |
| 34 | +chart_radius = 460 |
37 | 35 |
|
38 | | -# Color palette - colorblind-friendly distinct colors |
39 | | -colors = [ |
40 | | - "#306998", # Python Blue (Jan) |
41 | | - "#FFD43B", # Python Yellow (Feb) |
42 | | - "#4ECDC4", # Teal (Mar) |
43 | | - "#FF6B6B", # Coral (Apr) |
44 | | - "#95E1D3", # Mint (May) |
45 | | - "#F38181", # Salmon (Jun) |
46 | | - "#A8D5BA", # Sage (Jul) |
47 | | - "#FFC93C", # Gold (Aug) |
48 | | - "#5D9CEC", # Sky Blue (Sep) |
49 | | - "#AC92EB", # Lavender (Oct) |
50 | | - "#EC87C0", # Pink (Nov) |
51 | | - "#48CFAD", # Seafoam (Dec) |
52 | | -] |
53 | | - |
54 | | -# Create radial gridlines data (concentric circles at 25, 50, 75, 100 mm) |
| 36 | +# Radial gridlines at 25, 50, 75, 100 mm |
55 | 37 | grid_values = [25, 50, 75, 100] |
56 | | -grid_data = pd.DataFrame({"value": grid_values, "label": [f"{v}" for v in grid_values]}) |
| 38 | +grid_data = pd.DataFrame({"value": grid_values}) |
57 | 39 |
|
58 | | -# Chart radius for the radial visualization |
59 | | -chart_radius = 450 |
60 | | - |
61 | | -# Radial gridlines - concentric circles using mark_arc |
62 | 40 | gridlines = ( |
63 | 41 | alt.Chart(grid_data) |
64 | | - .mark_arc(filled=False, stroke="#cccccc", strokeWidth=1.5, strokeDash=[6, 4]) |
| 42 | + .mark_arc(filled=False, stroke=INK_SOFT, strokeWidth=1.0, strokeOpacity=0.35, strokeDash=[6, 4]) |
65 | 43 | .encode( |
66 | | - theta=alt.value(2 * np.pi), # Full circle |
| 44 | + theta=alt.value(2 * np.pi), |
67 | 45 | radius=alt.Radius("value:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])), |
68 | 46 | ) |
69 | 47 | ) |
70 | 48 |
|
71 | | -# Grid labels positioned at 3 o'clock position (right side) to avoid overlap with data |
| 49 | +# Grid labels at 3 o'clock position |
72 | 50 | grid_label_data = pd.DataFrame( |
73 | | - { |
74 | | - "value": grid_values, |
75 | | - "label": [f"{v} mm" for v in grid_values], |
76 | | - # Position labels at 3 o'clock (right) - angle = 0 degrees |
77 | | - "theta": [0.0] * len(grid_values), |
78 | | - } |
| 51 | + {"value": grid_values, "label": [f"{v} mm" for v in grid_values], "theta": [0.0] * len(grid_values)} |
79 | 52 | ) |
80 | 53 |
|
81 | 54 | grid_labels = ( |
82 | 55 | alt.Chart(grid_label_data) |
83 | | - .mark_text(fontSize=18, fontWeight="bold", dx=12, color="#666666", align="left", baseline="middle") |
| 56 | + .mark_text(fontSize=18, dx=10, align="left", baseline="middle") |
84 | 57 | .encode( |
85 | 58 | theta=alt.Theta("theta:Q"), |
86 | 59 | radius=alt.Radius("value:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])), |
87 | 60 | text="label:N", |
| 61 | + color=alt.value(INK_SOFT), |
88 | 62 | ) |
89 | 63 | ) |
90 | 64 |
|
91 | | -# Rose chart using mark_arc with explicit angles to start at 12 o'clock |
| 65 | +# Rose chart segments — viridis colormap for value-based color encoding (12 categories) |
92 | 66 | rose = ( |
93 | 67 | alt.Chart(df) |
94 | | - .mark_arc(stroke="#ffffff", strokeWidth=2, innerRadius=0) |
| 68 | + .mark_arc(stroke=PAGE_BG, strokeWidth=2, innerRadius=0) |
95 | 69 | .encode( |
96 | 70 | theta=alt.Theta("startAngle:Q", stack=None), |
97 | 71 | theta2=alt.Theta2("endAngle:Q"), |
98 | 72 | radius=alt.Radius("value:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])), |
99 | | - color=alt.Color( |
100 | | - "month:N", |
101 | | - scale=alt.Scale(domain=months, range=colors), |
102 | | - legend=None, # Legend disabled to control canvas size; colors are self-explanatory with labels |
103 | | - ), |
| 73 | + color=alt.Color("value:Q", scale=alt.Scale(scheme="viridis"), legend=None), |
104 | 74 | tooltip=[alt.Tooltip("month:N", title="Month"), alt.Tooltip("value:Q", title="Rainfall (mm)")], |
105 | 75 | ) |
106 | 76 | ) |
107 | 77 |
|
108 | | -# Calculate label positions - midpoint angle for each segment |
109 | | -mid_angles = [(-90 + (i + 0.5) * angle_step) for i in range(n)] |
| 78 | +# Value labels near segment tips |
| 79 | +mid_angles = [-90 + (i + 0.5) * angle_step for i in range(n)] |
110 | 80 | mid_angles_rad = np.radians(mid_angles) |
111 | 81 |
|
112 | | -# For small values that cluster together (Jun-Aug: 28, 22, 30), push labels further out |
113 | | -# to prevent crowding; larger values can have labels closer to segment edge |
114 | | -label_radii = [] |
115 | | -for v in rainfall: |
116 | | - if v < 35: |
117 | | - # Small segments - push label further outside segment |
118 | | - label_radii.append(max(v * 1.35, 45)) |
119 | | - else: |
120 | | - # Normal segments - position just outside |
121 | | - label_radii.append(v * 1.15) |
122 | | - |
123 | | -# Create label data with theta and radius for polar positioning |
124 | | -label_data = pd.DataFrame({"month": months, "value": rainfall, "theta": mid_angles_rad, "labelRadius": label_radii}) |
| 82 | +label_radii = [max(v * 1.35, 45) if v < 35 else v * 1.15 for v in rainfall] |
125 | 83 |
|
126 | | -# Create month labels positioned at outer edge of chart |
127 | | -month_label_data = pd.DataFrame( |
128 | | - { |
129 | | - "month": months, |
130 | | - "theta": mid_angles_rad, |
131 | | - "labelRadius": [115] * n, # Just outside the 100mm gridline |
132 | | - } |
133 | | -) |
| 84 | +label_data = pd.DataFrame({"month": months, "value": rainfall, "theta": mid_angles_rad, "labelRadius": label_radii}) |
134 | 85 |
|
135 | | -# Text labels showing values on each segment using polar coordinates |
136 | | -text = ( |
| 86 | +value_labels = ( |
137 | 87 | alt.Chart(label_data) |
138 | 88 | .mark_text(fontSize=20, fontWeight="bold") |
139 | 89 | .encode( |
|
142 | 92 | "labelRadius:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius]) |
143 | 93 | ), |
144 | 94 | text=alt.Text("value:Q"), |
145 | | - color=alt.value("#333333"), |
| 95 | + color=alt.value(INK), |
146 | 96 | ) |
147 | 97 | ) |
148 | 98 |
|
149 | | -# Month labels at outer edge |
| 99 | +# Month labels at outer edge — just beyond the 100 mm gridline |
| 100 | +month_label_data = pd.DataFrame({"month": months, "theta": mid_angles_rad, "labelRadius": [115.0] * n}) |
| 101 | + |
150 | 102 | month_labels = ( |
151 | 103 | alt.Chart(month_label_data) |
152 | 104 | .mark_text(fontSize=22, fontWeight="bold") |
|
156 | 108 | "labelRadius:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius]) |
157 | 109 | ), |
158 | 110 | text=alt.Text("month:N"), |
159 | | - color=alt.value("#333333"), |
| 111 | + color=alt.value(INK), |
160 | 112 | ) |
161 | 113 | ) |
162 | 114 |
|
163 | 115 | # Combine all layers |
164 | 116 | chart = ( |
165 | | - alt.layer(gridlines, grid_labels, rose, text, month_labels) |
166 | | - .properties(title=alt.Title(text="rose-basic · altair · pyplots.ai", fontSize=32, anchor="middle", offset=15)) |
167 | | - .configure_view(strokeWidth=0) |
| 117 | + alt.layer(gridlines, grid_labels, rose, value_labels, month_labels) |
| 118 | + .properties( |
| 119 | + width=1200, |
| 120 | + height=1200, |
| 121 | + background=PAGE_BG, |
| 122 | + title=alt.Title(text="rose-basic · altair · anyplot.ai", fontSize=32, anchor="middle", offset=20, color=INK), |
| 123 | + ) |
| 124 | + .configure_view(strokeWidth=0, fill=PAGE_BG) |
168 | 125 | .configure_axis(grid=False, domain=False, ticks=False, labels=False, title=None) |
169 | 126 | ) |
170 | 127 |
|
171 | | -# Save chart - Altair radial charts position content in upper portion |
172 | | -# Use high scale factor to ensure quality, then crop to center the content |
173 | | -chart.save("plot_raw.png", scale_factor=3.0) |
174 | | -chart.save("plot.html") |
175 | | - |
176 | | -# Post-process: crop to center the radial chart and resize to 3600x3600 (square format) |
177 | | -from PIL import Image |
178 | | - |
179 | | - |
180 | | -img = Image.open("plot_raw.png") |
181 | | -width, height = img.size |
182 | | - |
183 | | -# Altair radial charts center content horizontally but place it in the upper portion vertically |
184 | | -# The radial center is approximately at 36% from the top of the rendered image |
185 | | -content_center_y = int(height * 0.36) |
186 | | -content_center_x = width // 2 |
187 | | - |
188 | | -# Use a crop size that captures all content including outer month labels with some padding |
189 | | -crop_size = min(width, int(height * 0.80)) |
190 | | - |
191 | | -# Center the crop on the content |
192 | | -left = content_center_x - crop_size // 2 |
193 | | -top = content_center_y - crop_size // 2 |
194 | | -right = left + crop_size |
195 | | -bottom = top + crop_size |
196 | | - |
197 | | -# Adjust if crop extends beyond image boundaries |
198 | | -if left < 0: |
199 | | - left = 0 |
200 | | - right = crop_size |
201 | | -if top < 0: |
202 | | - top = 0 |
203 | | - bottom = crop_size |
204 | | -if right > width: |
205 | | - right = width |
206 | | - left = width - crop_size |
207 | | -if bottom > height: |
208 | | - bottom = height |
209 | | - top = height - crop_size |
210 | | - |
211 | | -cropped = img.crop((left, top, right, bottom)) |
212 | | - |
213 | | -# Resize to target 3600x3600 |
214 | | -final = cropped.resize((3600, 3600), Image.Resampling.LANCZOS) |
215 | | -final.save("plot.png") |
216 | | - |
217 | | -# Clean up temp file |
218 | | -import os |
219 | | - |
220 | | - |
221 | | -os.remove("plot_raw.png") |
| 128 | +# Save |
| 129 | +chart.save(f"plot-{THEME}.png", scale_factor=3.0) |
| 130 | +chart.save(f"plot-{THEME}.html") |
0 commit comments