Skip to content

Commit cc9e1f0

Browse files
feat(altair): implement rose-basic (#5596)
## Implementation: `rose-basic` - python/altair Implements the **python/altair** version of `rose-basic`. **File:** `plots/rose-basic/implementations/python/altair.py` **Parent Issue:** #1003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25151891021)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 3ee48f1 commit cc9e1f0

2 files changed

Lines changed: 215 additions & 268 deletions

File tree

Lines changed: 46 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,89 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
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
55
"""
66

7+
import os
8+
79
import altair as alt
810
import numpy as np
911
import pandas as pd
1012

1113

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)
1321
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
1422
rainfall = [78, 52, 68, 45, 35, 28, 22, 30, 55, 85, 92, 88]
1523

1624
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
2025
angle_step = 360 / n
2126
start_angles = [-90 + i * angle_step for i in range(n)]
2227
end_angles = [-90 + (i + 1) * angle_step for i in range(n)]
2328

24-
# Create DataFrame with explicit angles
2529
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)}
3331
)
3432

35-
# Max value for radius scaling - use 100 for nicer gridline values
3633
max_val = 100
34+
chart_radius = 460
3735

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
5537
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})
5739

58-
# Chart radius for the radial visualization
59-
chart_radius = 450
60-
61-
# Radial gridlines - concentric circles using mark_arc
6240
gridlines = (
6341
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])
6543
.encode(
66-
theta=alt.value(2 * np.pi), # Full circle
44+
theta=alt.value(2 * np.pi),
6745
radius=alt.Radius("value:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])),
6846
)
6947
)
7048

71-
# Grid labels positioned at 3 o'clock position (right side) to avoid overlap with data
49+
# Grid labels at 3 o'clock position
7250
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)}
7952
)
8053

8154
grid_labels = (
8255
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")
8457
.encode(
8558
theta=alt.Theta("theta:Q"),
8659
radius=alt.Radius("value:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])),
8760
text="label:N",
61+
color=alt.value(INK_SOFT),
8862
)
8963
)
9064

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)
9266
rose = (
9367
alt.Chart(df)
94-
.mark_arc(stroke="#ffffff", strokeWidth=2, innerRadius=0)
68+
.mark_arc(stroke=PAGE_BG, strokeWidth=2, innerRadius=0)
9569
.encode(
9670
theta=alt.Theta("startAngle:Q", stack=None),
9771
theta2=alt.Theta2("endAngle:Q"),
9872
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),
10474
tooltip=[alt.Tooltip("month:N", title="Month"), alt.Tooltip("value:Q", title="Rainfall (mm)")],
10575
)
10676
)
10777

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)]
11080
mid_angles_rad = np.radians(mid_angles)
11181

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]
12583

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})
13485

135-
# Text labels showing values on each segment using polar coordinates
136-
text = (
86+
value_labels = (
13787
alt.Chart(label_data)
13888
.mark_text(fontSize=20, fontWeight="bold")
13989
.encode(
@@ -142,11 +92,13 @@
14292
"labelRadius:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])
14393
),
14494
text=alt.Text("value:Q"),
145-
color=alt.value("#333333"),
95+
color=alt.value(INK),
14696
)
14797
)
14898

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+
150102
month_labels = (
151103
alt.Chart(month_label_data)
152104
.mark_text(fontSize=22, fontWeight="bold")
@@ -156,66 +108,23 @@
156108
"labelRadius:Q", scale=alt.Scale(type="linear", domain=[0, max_val], range=[0, chart_radius])
157109
),
158110
text=alt.Text("month:N"),
159-
color=alt.value("#333333"),
111+
color=alt.value(INK),
160112
)
161113
)
162114

163115
# Combine all layers
164116
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)
168125
.configure_axis(grid=False, domain=False, ticks=False, labels=False, title=None)
169126
)
170127

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

Comments
 (0)