Skip to content

Commit 058fe6c

Browse files
feat(altair): implement radar-multi (#10302)
## Implementation: `radar-multi` - python/altair Implements the **python/altair** version of `radar-multi`. **File:** `plots/radar-multi/implementations/python/altair.py` **Parent Issue:** #2026 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/32046647961)* --------- 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 b0c8a08 commit 058fe6c

2 files changed

Lines changed: 165 additions & 89 deletions

File tree

plots/radar-multi/implementations/python/altair.py

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" anyplot.ai
22
radar-multi: Multi-Series Radar Chart
3-
Library: altair 6.1.0 | Python 3.13.13
4-
Quality: 86/100 | Updated: 2026-05-07
3+
Library: altair 6.2.2 | Python 3.13.15
4+
Quality: 90/100 | Updated: 2026-08-17
55
"""
66

77
import importlib.util
@@ -10,6 +10,7 @@
1010

1111
import numpy as np
1212
import pandas as pd
13+
from PIL import Image
1314

1415

1516
# Explicitly import altair from site-packages to avoid shadowing
@@ -32,7 +33,7 @@
3233
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
3334
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
3435

35-
# Okabe-Ito palette (first series ALWAYS #009E73)
36+
# Imprint palette (first series ALWAYS #009E73)
3637
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]
3738

3839
# Data: Product comparison across key attributes
@@ -114,16 +115,36 @@
114115
series_list = ["Product A", "Product B", "Product C"]
115116
color_scale = alt.Scale(domain=series_list, range=IMPRINT)
116117

117-
# Chart dimensions for square output (base size with 3x scale factor)
118-
chart_width = 1600
119-
chart_height = 1600
118+
# Domain for axes, sized to the hexagon's own geometry (not a generic square):
119+
# label radius 125 reaches the full radius only at the top/bottom vertices
120+
# (Price/Support); the left/right vertices (Quality/Durability/Features/
121+
# Design, at +-30 deg off horizontal) only reach 125*cos(30deg). Deriving
122+
# separate x/y half-ranges from that geometry (plus a fixed text buffer)
123+
# keeps the margin tight and the view free of the dead space a flat +-160
124+
# square domain would leave on the hexagon's shorter horizontal axis.
125+
LABEL_R = 125
126+
BUFFER = 18
127+
x_half = LABEL_R * np.cos(np.pi / 6) + BUFFER
128+
y_half = LABEL_R + BUFFER
129+
axis_domain_x = [-x_half, x_half]
130+
axis_domain_y = [-y_half, y_half]
120131

121-
# Domain for axes
122-
axis_domain = [-160, 160]
132+
# Chart dimensions — square inner view (see prompts/library/altair.md "Canvas"),
133+
# aspect-matched to x_half:y_half so the hexagon renders undistorted.
134+
chart_width = 480
135+
chart_height = round(chart_width * y_half / x_half)
123136

124137
# Base encoding for x and y
125-
x_enc = alt.X("x:Q", scale=alt.Scale(domain=axis_domain), axis=None)
126-
y_enc = alt.Y("y:Q", scale=alt.Scale(domain=axis_domain), axis=None)
138+
x_enc = alt.X("x:Q", scale=alt.Scale(domain=axis_domain_x), axis=None)
139+
y_enc = alt.Y("y:Q", scale=alt.Scale(domain=axis_domain_y), axis=None)
140+
141+
# Legend-bound selection: click a series to isolate it, click again to
142+
# release. A distinctly altair/vega-lite interaction — not reproducible in
143+
# a static PNG library — that shows up in the saved interactive HTML.
144+
legend_selection = alt.selection_point(fields=["series"], bind="legend")
145+
fill_opacity = alt.condition(legend_selection, alt.value(0.25), alt.value(0.05))
146+
stroke_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))
147+
point_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))
127148

128149
# Grid hexagons
129150
grid_lines = (
@@ -142,34 +163,38 @@
142163
# Axis labels
143164
labels = (
144165
alt.Chart(label_df)
145-
.mark_text(fontSize=22, fontWeight="bold")
166+
.mark_text(fontSize=13, fontWeight="bold")
146167
.encode(x="x:Q", y="y:Q", text="category:N", color=alt.value(INK))
147168
)
148169

149170
# Grid value labels
150171
value_labels = (
151172
alt.Chart(value_label_df)
152-
.mark_text(fontSize=14, align="left", baseline="middle")
173+
.mark_text(fontSize=10, align="left", baseline="middle")
153174
.encode(x="x:Q", y="y:Q", text="value:N", color=alt.value(INK_SOFT))
154175
)
155176

156-
# Create filled polygons for each series
177+
# Create filled polygons for each series. `mark_area()` fills toward an
178+
# implicit baseline (it is designed for y=f(x) functions), so feeding it a
179+
# closed, non-monotonic radar-polygon path produces spurious fill spikes. A
180+
# `mark_line` with `interpolate="linear-closed"` instead closes the path as
181+
# a true polygon and fills it directly -- the standard Vega-Lite technique
182+
# for radar/spider charts.
157183
fill_layers = []
158184
for series_name, fill_color in zip(series_list, IMPRINT, strict=True):
159185
series_df = df[df["series"] == series_name].copy()
160186

161-
# Use mark_area for proper polygon fill
162187
fill_layer = (
163188
alt.Chart(series_df)
164-
.mark_area(fillOpacity=0.25, opacity=0.25)
165-
.encode(x=x_enc, y=y_enc, color=alt.value(fill_color), order="order:Q")
189+
.mark_line(interpolate="linear-closed", fill=fill_color, fillOpacity=0.25, strokeWidth=0)
190+
.encode(x=x_enc, y=y_enc, opacity=fill_opacity, order="order:Q")
166191
)
167192
fill_layers.append(fill_layer)
168193

169194
# Polygon outlines
170195
polygon_outline = (
171196
alt.Chart(df)
172-
.mark_line(strokeWidth=3, opacity=0.9)
197+
.mark_line(strokeWidth=3.5)
173198
.encode(
174199
x=x_enc,
175200
y=y_enc,
@@ -178,18 +203,19 @@
178203
scale=color_scale,
179204
legend=alt.Legend(
180205
title="Series",
181-
titleFontSize=20,
182-
labelFontSize=18,
206+
titleFontSize=10,
207+
labelFontSize=10,
183208
orient="right",
184209
offset=10,
185-
symbolSize=300,
186-
symbolStrokeWidth=3,
210+
symbolSize=120,
211+
symbolStrokeWidth=2,
187212
fillColor=ELEVATED_BG,
188213
strokeColor=INK_SOFT,
189214
labelColor=INK_SOFT,
190215
titleColor=INK,
191216
),
192217
),
218+
opacity=stroke_opacity,
193219
detail="series:N",
194220
order="order:Q",
195221
)
@@ -199,30 +225,49 @@
199225
points_df = df[df["order"] < n_categories].copy()
200226
points = (
201227
alt.Chart(points_df)
202-
.mark_circle(size=200, opacity=0.9)
228+
.mark_circle(size=160)
203229
.encode(
204230
x=x_enc,
205231
y=y_enc,
206232
color=alt.Color("series:N", scale=color_scale, legend=None),
233+
opacity=point_opacity,
207234
tooltip=["series:N", "category:N", "value:Q"],
208235
)
209236
)
210237

211238
# Combine all layers
212239
all_layers = [grid_lines, spokes] + fill_layers + [polygon_outline, points, labels, value_labels]
213240

241+
title_text = "radar-multi · python · altair · anyplot.ai"
242+
214243
chart = (
215244
alt.layer(*all_layers)
245+
.add_params(legend_selection)
216246
.properties(
217247
width=chart_width,
218248
height=chart_height,
219249
background=PAGE_BG,
220-
title=alt.Title("radar-multi · altair · pyplots.ai", fontSize=28, anchor="middle", offset=20),
250+
title=alt.Title(title_text, fontSize=16, anchor="middle", offset=20, color=INK),
221251
)
222252
.configure_view(strokeWidth=0, fill=PAGE_BG)
223253
.configure_legend(strokeColor=INK_SOFT, padding=15, labelColor=INK_SOFT, titleColor=INK)
224254
)
225255

226256
# Save as PNG and HTML with theme suffix
227-
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
257+
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
258+
259+
# PAD-only to the exact canonical target — never crop (see
260+
# prompts/library/altair.md "Canvas — hard rule, no deviation").
261+
TW, TH = 2400, 2400
262+
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
263+
_w, _h = _img.size
264+
if _w > TW or _h > TH:
265+
raise SystemExit(
266+
f"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. Shrink chart dims and re-render."
267+
)
268+
if _w < TW or _h < TH:
269+
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
270+
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
271+
_canvas.save(f"plot-{THEME}.png")
272+
228273
chart.save(f"plot-{THEME}.html")

0 commit comments

Comments
 (0)