|
1 | 1 | """ anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import importlib.util |
|
10 | 10 |
|
11 | 11 | import numpy as np |
12 | 12 | import pandas as pd |
| 13 | +from PIL import Image |
13 | 14 |
|
14 | 15 |
|
15 | 16 | # Explicitly import altair from site-packages to avoid shadowing |
|
32 | 33 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
33 | 34 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
34 | 35 |
|
35 | | -# Okabe-Ito palette (first series ALWAYS #009E73) |
| 36 | +# Imprint palette (first series ALWAYS #009E73) |
36 | 37 | IMPRINT = ["#009E73", "#C475FD", "#4467A3"] |
37 | 38 |
|
38 | 39 | # Data: Product comparison across key attributes |
|
114 | 115 | series_list = ["Product A", "Product B", "Product C"] |
115 | 116 | color_scale = alt.Scale(domain=series_list, range=IMPRINT) |
116 | 117 |
|
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] |
120 | 131 |
|
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) |
123 | 136 |
|
124 | 137 | # 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)) |
127 | 148 |
|
128 | 149 | # Grid hexagons |
129 | 150 | grid_lines = ( |
|
142 | 163 | # Axis labels |
143 | 164 | labels = ( |
144 | 165 | alt.Chart(label_df) |
145 | | - .mark_text(fontSize=22, fontWeight="bold") |
| 166 | + .mark_text(fontSize=13, fontWeight="bold") |
146 | 167 | .encode(x="x:Q", y="y:Q", text="category:N", color=alt.value(INK)) |
147 | 168 | ) |
148 | 169 |
|
149 | 170 | # Grid value labels |
150 | 171 | value_labels = ( |
151 | 172 | alt.Chart(value_label_df) |
152 | | - .mark_text(fontSize=14, align="left", baseline="middle") |
| 173 | + .mark_text(fontSize=10, align="left", baseline="middle") |
153 | 174 | .encode(x="x:Q", y="y:Q", text="value:N", color=alt.value(INK_SOFT)) |
154 | 175 | ) |
155 | 176 |
|
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. |
157 | 183 | fill_layers = [] |
158 | 184 | for series_name, fill_color in zip(series_list, IMPRINT, strict=True): |
159 | 185 | series_df = df[df["series"] == series_name].copy() |
160 | 186 |
|
161 | | - # Use mark_area for proper polygon fill |
162 | 187 | fill_layer = ( |
163 | 188 | 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") |
166 | 191 | ) |
167 | 192 | fill_layers.append(fill_layer) |
168 | 193 |
|
169 | 194 | # Polygon outlines |
170 | 195 | polygon_outline = ( |
171 | 196 | alt.Chart(df) |
172 | | - .mark_line(strokeWidth=3, opacity=0.9) |
| 197 | + .mark_line(strokeWidth=3.5) |
173 | 198 | .encode( |
174 | 199 | x=x_enc, |
175 | 200 | y=y_enc, |
|
178 | 203 | scale=color_scale, |
179 | 204 | legend=alt.Legend( |
180 | 205 | title="Series", |
181 | | - titleFontSize=20, |
182 | | - labelFontSize=18, |
| 206 | + titleFontSize=10, |
| 207 | + labelFontSize=10, |
183 | 208 | orient="right", |
184 | 209 | offset=10, |
185 | | - symbolSize=300, |
186 | | - symbolStrokeWidth=3, |
| 210 | + symbolSize=120, |
| 211 | + symbolStrokeWidth=2, |
187 | 212 | fillColor=ELEVATED_BG, |
188 | 213 | strokeColor=INK_SOFT, |
189 | 214 | labelColor=INK_SOFT, |
190 | 215 | titleColor=INK, |
191 | 216 | ), |
192 | 217 | ), |
| 218 | + opacity=stroke_opacity, |
193 | 219 | detail="series:N", |
194 | 220 | order="order:Q", |
195 | 221 | ) |
|
199 | 225 | points_df = df[df["order"] < n_categories].copy() |
200 | 226 | points = ( |
201 | 227 | alt.Chart(points_df) |
202 | | - .mark_circle(size=200, opacity=0.9) |
| 228 | + .mark_circle(size=160) |
203 | 229 | .encode( |
204 | 230 | x=x_enc, |
205 | 231 | y=y_enc, |
206 | 232 | color=alt.Color("series:N", scale=color_scale, legend=None), |
| 233 | + opacity=point_opacity, |
207 | 234 | tooltip=["series:N", "category:N", "value:Q"], |
208 | 235 | ) |
209 | 236 | ) |
210 | 237 |
|
211 | 238 | # Combine all layers |
212 | 239 | all_layers = [grid_lines, spokes] + fill_layers + [polygon_outline, points, labels, value_labels] |
213 | 240 |
|
| 241 | +title_text = "radar-multi · python · altair · anyplot.ai" |
| 242 | + |
214 | 243 | chart = ( |
215 | 244 | alt.layer(*all_layers) |
| 245 | + .add_params(legend_selection) |
216 | 246 | .properties( |
217 | 247 | width=chart_width, |
218 | 248 | height=chart_height, |
219 | 249 | 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), |
221 | 251 | ) |
222 | 252 | .configure_view(strokeWidth=0, fill=PAGE_BG) |
223 | 253 | .configure_legend(strokeColor=INK_SOFT, padding=15, labelColor=INK_SOFT, titleColor=INK) |
224 | 254 | ) |
225 | 255 |
|
226 | 256 | # 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 | + |
228 | 273 | chart.save(f"plot-{THEME}.html") |
0 commit comments