|
1 | 1 | """ anyplot.ai |
2 | 2 | radar-multi: Multi-Series Radar Chart |
3 | | -Library: matplotlib 3.10.9 | Python 3.13.13 |
4 | | -Quality: 91/100 | Updated: 2026-05-07 |
| 3 | +Library: matplotlib 3.11.1 | Python 3.13.15 |
| 4 | +Quality: 86/100 | Updated: 2026-08-17 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
|
17 | 17 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
18 | 18 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
19 | 19 |
|
20 | | -# Okabe-Ito palette (first three series) |
| 20 | +# Imprint palette (first three series, canonical order) |
21 | 21 | IMPRINT = ["#009E73", "#C475FD", "#4467A3"] |
22 | 22 |
|
23 | | -# Data: Product comparison across key attributes |
| 23 | +# Data: product comparison across key attributes — Product B is the overall leader |
24 | 24 | categories = ["Performance", "Battery Life", "Camera", "Display", "Build Quality", "Value"] |
25 | 25 | products = { |
26 | 26 | "Product A": [85, 70, 90, 88, 75, 65], |
27 | 27 | "Product B": [72, 95, 78, 82, 88, 80], |
28 | 28 | "Product C": [90, 60, 85, 75, 70, 90], |
29 | 29 | } |
| 30 | +leader_idx = 1 # Product B carries the highest average score — given visual emphasis |
30 | 31 |
|
31 | 32 | # Number of variables |
32 | 33 | n_categories = len(categories) |
|
36 | 37 | angles += angles[:1] # Close the polygon |
37 | 38 |
|
38 | 39 | # Create figure (square format for radar) |
39 | | -fig, ax = plt.subplots(figsize=(10, 10), subplot_kw={"polar": True}, facecolor=PAGE_BG) |
| 40 | +fig, ax = plt.subplots(figsize=(6, 6), dpi=400, subplot_kw={"polar": True}, facecolor=PAGE_BG) |
40 | 41 | ax.set_facecolor(PAGE_BG) |
41 | 42 |
|
| 43 | +# Orient the radar with the first category at the top, going clockwise, so |
| 44 | +# no category label lands due-east where the legend sits |
| 45 | +ax.set_theta_zero_location("N") |
| 46 | +ax.set_theta_direction(-1) |
| 47 | + |
| 48 | +# Per-series visual weight — the leader draws bolder and on top, guiding the eye |
| 49 | +# to the strongest overall performer without adding any text annotation |
| 50 | +markers = ["o", "s", "^"] |
| 51 | +linewidths = [2.5, 2.5, 2.5] |
| 52 | +markersizes = [8, 8, 8] |
| 53 | +fill_alphas = [0.18, 0.18, 0.18] |
| 54 | +zorders = [1, 1, 1] # below the default radial-grid/tick-label zorder (~2.5) |
| 55 | +linewidths[leader_idx] = 3.5 |
| 56 | +markersizes[leader_idx] = 12 |
| 57 | +fill_alphas[leader_idx] = 0.35 |
| 58 | +zorders[leader_idx] = 1.5 |
| 59 | + |
42 | 60 | # Plot each product |
43 | 61 | for idx, (product, values) in enumerate(products.items()): |
44 | 62 | values_closed = values + values[:1] # Close the polygon |
45 | | - ax.plot(angles, values_closed, "o-", linewidth=3, label=product, color=IMPRINT[idx], markersize=10) |
46 | | - ax.fill(angles, values_closed, alpha=0.25, color=IMPRINT[idx]) |
47 | | - |
48 | | -# Set category labels at each axis |
| 63 | + ax.plot( |
| 64 | + angles, |
| 65 | + values_closed, |
| 66 | + marker=markers[idx], |
| 67 | + linestyle="-", |
| 68 | + linewidth=linewidths[idx], |
| 69 | + label=product, |
| 70 | + color=IMPRINT[idx], |
| 71 | + markersize=markersizes[idx], |
| 72 | + markeredgecolor=PAGE_BG, |
| 73 | + markeredgewidth=1, |
| 74 | + zorder=zorders[idx], |
| 75 | + ) |
| 76 | + ax.fill(angles, values_closed, alpha=fill_alphas[idx], color=IMPRINT[idx], zorder=zorders[idx]) |
| 77 | + |
| 78 | +# Set category labels at each axis, nudged outward and aligned by their |
| 79 | +# rendered screen position (accounting for the N-zero/clockwise rotation |
| 80 | +# above) so labels on the right lean left, labels on the left lean right, |
| 81 | +# and top/bottom labels stay centered — none collide with their own markers |
49 | 82 | ax.set_xticks(angles[:-1]) |
50 | | -ax.set_xticklabels(categories, fontsize=18, fontweight="bold", color=INK) |
51 | | - |
52 | | -# Set radial grid |
| 83 | +tick_labels = ax.set_xticklabels(categories, fontsize=13, fontweight="bold", color=INK) |
| 84 | +ax.tick_params(axis="x", pad=14) |
| 85 | +for label, raw_angle in zip(tick_labels, angles[:-1], strict=True): |
| 86 | + cos_disp = np.cos(np.pi / 2 - raw_angle) |
| 87 | + if cos_disp > 0.3: |
| 88 | + label.set_horizontalalignment("left") |
| 89 | + elif cos_disp < -0.3: |
| 90 | + label.set_horizontalalignment("right") |
| 91 | + else: |
| 92 | + label.set_horizontalalignment("center") |
| 93 | + |
| 94 | +# Set radial grid — fewer labels (25/50/75/100) placed in the empty sector |
| 95 | +# between Display and Build Quality, away from every category axis, so they |
| 96 | +# no longer collide with each other or with the data. Labels keep an opaque |
| 97 | +# halo so they stay legible where polygon fills cross the radial axis |
53 | 98 | ax.set_ylim(0, 100) |
54 | | -ax.set_yticks([20, 40, 60, 80, 100]) |
55 | | -ax.set_yticklabels(["20", "40", "60", "80", "100"], fontsize=16, color=INK_SOFT) |
| 99 | +ax.set_yticks([25, 50, 75, 100]) |
| 100 | +r_tick_labels = ax.set_yticklabels(["25", "50", "75", "100"], fontsize=13, color=INK_SOFT) |
| 101 | +# set_rlabel_position takes a RAW (pre-rotation) angle; raw=210 (Build |
| 102 | +# Quality's own axis) renders at display angle 240 = between Display (270) |
| 103 | +# and Build Quality (210) after the theta_zero_location/direction rotation |
| 104 | +ax.set_rlabel_position(210) |
| 105 | +for label in r_tick_labels: |
| 106 | + label.set_bbox({"facecolor": PAGE_BG, "edgecolor": "none", "alpha": 0.9, "pad": 1.5}) |
56 | 107 |
|
57 | 108 | # Grid styling |
58 | 109 | ax.yaxis.grid(True, linestyle="-", alpha=0.15, linewidth=0.8, color=INK_SOFT) |
59 | 110 | ax.xaxis.grid(True, linestyle="-", alpha=0.15, linewidth=0.8, color=INK_SOFT) |
60 | 111 |
|
61 | | -# Title |
62 | | -ax.set_title("radar-multi · matplotlib · anyplot.ai", fontsize=24, fontweight="medium", color=INK, pad=40) |
| 112 | +# Title — figure-level (not axes-level) so it stays centered on the full |
| 113 | +# canvas regardless of how the legend reshapes the polar axes below |
| 114 | +fig.suptitle("radar-multi · python · matplotlib · anyplot.ai", fontsize=12, fontweight="medium", color=INK, y=0.97) |
63 | 115 |
|
64 | | -# Legend |
65 | | -leg = ax.legend(loc="upper left", bbox_to_anchor=(1.05, 1.0), fontsize=16, framealpha=1.0) |
| 116 | +# Legend outside the circular grid, vertically centered — leader label |
| 117 | +# rendered bold to echo its bolder polygon |
| 118 | +leg = ax.legend(loc="center left", bbox_to_anchor=(1.08, 0.5), fontsize=13, framealpha=1.0) |
66 | 119 | leg.get_frame().set_facecolor(ELEVATED_BG) |
67 | 120 | leg.get_frame().set_edgecolor(INK_SOFT) |
68 | 121 | leg.get_frame().set_linewidth(0.8) |
69 | | -for text in leg.get_texts(): |
| 122 | +for idx, text in enumerate(leg.get_texts()): |
70 | 123 | text.set_color(INK) |
71 | | - |
72 | | -plt.tight_layout() |
73 | | -plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
| 124 | + if idx == leader_idx: |
| 125 | + text.set_fontweight("bold") |
| 126 | + |
| 127 | +# Explicit margins (not tight_layout) so the polar axes sit vertically |
| 128 | +# centered in the square canvas — top/bottom margins balanced, right margin |
| 129 | +# reserved for the legend |
| 130 | +fig.subplots_adjust(left=0.32, right=0.66, top=0.80, bottom=0.20) |
| 131 | +plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG) |
0 commit comments