|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | gauge-basic: Basic Gauge 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.14.4 |
| 4 | +Quality: 88/100 | Updated: 2026-04-25 |
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 - Sales performance gauge |
| 14 | +# Theme tokens |
| 15 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 16 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 17 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 18 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 19 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 20 | +INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
| 21 | + |
| 22 | +# Okabe-Ito zone colors (colorblind-safe red/yellow/green) |
| 23 | +ZONE_BAD = "#D55E00" # vermillion |
| 24 | +ZONE_WARN = "#E69F00" # orange |
| 25 | +ZONE_GOOD = "#009E73" # bluish green (brand) |
| 26 | + |
| 27 | +# Data — Sales performance gauge |
13 | 28 | value = 72 |
14 | 29 | min_value = 0 |
15 | 30 | max_value = 100 |
16 | 31 | thresholds = [30, 70] |
17 | 32 |
|
| 33 | +# Geometry: semi-circle from -pi/2 (left) to +pi/2 (right) |
| 34 | +boundaries = np.array([min_value] + thresholds + [max_value], dtype=float) |
| 35 | +boundary_angles = -np.pi / 2 + (boundaries - min_value) / (max_value - min_value) * np.pi |
| 36 | +needle_angle = -np.pi / 2 + (value - min_value) / (max_value - min_value) * np.pi |
18 | 37 |
|
19 | | -# Calculate angles for semi-circular gauge (180 degrees) |
20 | | -# Map min_value to -pi/2 (left, 9 o'clock) and max_value to pi/2 (right, 3 o'clock) |
21 | | -def value_to_angle(v): |
22 | | - ratio = (v - min_value) / (max_value - min_value) |
23 | | - return -np.pi / 2 + ratio * np.pi |
24 | | - |
25 | | - |
26 | | -# Create arc segments for color zones |
27 | | -zones = [] |
28 | | -zone_colors = ["#E53935", "#FFD43B", "#4CAF50"] # Red, Yellow, Green |
29 | | -boundaries = [min_value] + thresholds + [max_value] |
30 | | - |
31 | | -for i in range(len(boundaries) - 1): |
32 | | - start_angle = value_to_angle(boundaries[i]) |
33 | | - end_angle = value_to_angle(boundaries[i + 1]) |
34 | | - zones.append( |
35 | | - { |
36 | | - "zone": i, |
37 | | - "start": boundaries[i], |
38 | | - "end": boundaries[i + 1], |
39 | | - "startAngle": start_angle, |
40 | | - "endAngle": end_angle, |
41 | | - "color": zone_colors[i], |
42 | | - } |
43 | | - ) |
44 | | - |
45 | | -zones_df = pd.DataFrame(zones) |
| 38 | +# Zone arcs |
| 39 | +zones_df = pd.DataFrame( |
| 40 | + {"startAngle": boundary_angles[:-1], "endAngle": boundary_angles[1:], "color": [ZONE_BAD, ZONE_WARN, ZONE_GOOD]} |
| 41 | +) |
46 | 42 |
|
47 | | -# Create the gauge background arcs |
48 | 43 | gauge_arcs = ( |
49 | 44 | alt.Chart(zones_df) |
50 | | - .mark_arc(innerRadius=220, outerRadius=360, cornerRadius=6) |
| 45 | + .mark_arc(innerRadius=220, outerRadius=360, cornerRadius=6, stroke=PAGE_BG, strokeWidth=4) |
51 | 46 | .encode( |
52 | 47 | theta=alt.Theta("startAngle:Q", scale=None), |
53 | 48 | theta2="endAngle:Q", |
54 | 49 | color=alt.Color("color:N", scale=None, legend=None), |
55 | 50 | ) |
56 | 51 | ) |
57 | 52 |
|
58 | | -# Create needle indicator |
59 | | -needle_angle = value_to_angle(value) |
| 53 | +# Needle |
60 | 54 | needle_length = 300 |
61 | | -needle_x = needle_length * np.sin(needle_angle) |
62 | | -needle_y = needle_length * np.cos(needle_angle) |
63 | | - |
64 | | -needle_df = pd.DataFrame([{"x": 0, "y": 0, "x2": needle_x, "y2": needle_y}]) |
65 | | - |
| 55 | +needle_df = pd.DataFrame( |
| 56 | + [{"x": 0.0, "y": 0.0, "x2": needle_length * np.sin(needle_angle), "y2": needle_length * np.cos(needle_angle)}] |
| 57 | +) |
66 | 58 | needle = ( |
67 | 59 | alt.Chart(needle_df) |
68 | | - .mark_rule(color="#306998", strokeWidth=10, strokeCap="round") |
| 60 | + .mark_rule(color=INK, strokeWidth=10, strokeCap="round") |
69 | 61 | .encode( |
70 | | - x=alt.X("x:Q", scale=alt.Scale(domain=[-500, 500]), axis=None), |
71 | | - y=alt.Y("y:Q", scale=alt.Scale(domain=[-200, 500]), axis=None), |
| 62 | + x=alt.X("x:Q", scale=alt.Scale(domain=[-450, 450]), axis=None), |
| 63 | + y=alt.Y("y:Q", scale=alt.Scale(domain=[-260, 440]), axis=None), |
72 | 64 | x2="x2:Q", |
73 | 65 | y2="y2:Q", |
74 | 66 | ) |
75 | 67 | ) |
76 | 68 |
|
77 | | -# Center hub circle |
| 69 | +# Center hub (two-tone for definition) |
78 | 70 | hub_df = pd.DataFrame([{"x": 0, "y": 0}]) |
79 | | -hub = alt.Chart(hub_df).mark_circle(size=2000, color="#306998").encode(x="x:Q", y="y:Q") |
| 71 | +hub_outer = alt.Chart(hub_df).mark_circle(size=2400, color=INK).encode(x="x:Q", y="y:Q") |
| 72 | +hub_inner = alt.Chart(hub_df).mark_circle(size=400, color=PAGE_BG).encode(x="x:Q", y="y:Q") |
80 | 73 |
|
81 | | -# Value label with unit |
82 | | -value_label_df = pd.DataFrame([{"x": 0, "y": -120, "text": f"{value}%"}]) |
| 74 | +# Prominent value label (centered below hub) |
| 75 | +value_label_df = pd.DataFrame([{"x": 0, "y": -140, "text": f"{value}"}]) |
83 | 76 | value_label = ( |
84 | 77 | alt.Chart(value_label_df) |
85 | | - .mark_text(fontSize=80, fontWeight="bold", color="#306998") |
| 78 | + .mark_text(fontSize=88, fontWeight="bold", color=ZONE_GOOD, baseline="middle") |
86 | 79 | .encode(x="x:Q", y="y:Q", text="text:N") |
87 | 80 | ) |
88 | 81 |
|
89 | | -# Min and max labels positioned at arc ends |
90 | | -min_label_x = 400 * np.sin(value_to_angle(min_value)) |
91 | | -min_label_y = 400 * np.cos(value_to_angle(min_value)) |
92 | | -max_label_x = 400 * np.sin(value_to_angle(max_value)) |
93 | | -max_label_y = 400 * np.cos(value_to_angle(max_value)) |
| 82 | +context_label_df = pd.DataFrame([{"x": 0, "y": -220, "text": "Current Sales"}]) |
| 83 | +context_label = ( |
| 84 | + alt.Chart(context_label_df) |
| 85 | + .mark_text(fontSize=22, color=INK_MUTED, baseline="middle") |
| 86 | + .encode(x="x:Q", y="y:Q", text="text:N") |
| 87 | +) |
94 | 88 |
|
95 | | -labels_df = pd.DataFrame( |
| 89 | +# Min and max labels just below the arc ends |
| 90 | +range_label_radius = 290 |
| 91 | +range_labels_df = pd.DataFrame( |
96 | 92 | [ |
97 | | - {"x": min_label_x - 50, "y": min_label_y, "text": str(min_value)}, |
98 | | - {"x": max_label_x + 50, "y": max_label_y, "text": str(max_value)}, |
| 93 | + {"x": range_label_radius * np.sin(boundary_angles[0]), "y": -50, "text": str(min_value)}, |
| 94 | + {"x": range_label_radius * np.sin(boundary_angles[-1]), "y": -50, "text": str(max_value)}, |
99 | 95 | ] |
100 | 96 | ) |
101 | | -range_labels = alt.Chart(labels_df).mark_text(fontSize=36, color="#555555").encode(x="x:Q", y="y:Q", text="text:N") |
102 | | - |
103 | | -# Threshold labels on the arc |
104 | | -threshold_labels_data = [] |
105 | | -for t in thresholds: |
106 | | - angle = value_to_angle(t) |
107 | | - label_radius = 420 |
108 | | - threshold_labels_data.append({"x": label_radius * np.sin(angle), "y": label_radius * np.cos(angle), "text": str(t)}) |
| 97 | +range_labels = ( |
| 98 | + alt.Chart(range_labels_df) |
| 99 | + .mark_text(fontSize=26, color=INK_SOFT, fontWeight="bold") |
| 100 | + .encode(x="x:Q", y="y:Q", text="text:N") |
| 101 | +) |
109 | 102 |
|
110 | | -threshold_labels_df = pd.DataFrame(threshold_labels_data) |
| 103 | +# Threshold labels above the arc |
| 104 | +threshold_angles = -np.pi / 2 + (np.array(thresholds, dtype=float) - min_value) / (max_value - min_value) * np.pi |
| 105 | +threshold_label_radius = 400 |
| 106 | +threshold_labels_df = pd.DataFrame( |
| 107 | + { |
| 108 | + "x": threshold_label_radius * np.sin(threshold_angles), |
| 109 | + "y": threshold_label_radius * np.cos(threshold_angles), |
| 110 | + "text": [str(t) for t in thresholds], |
| 111 | + } |
| 112 | +) |
111 | 113 | threshold_labels = ( |
112 | | - alt.Chart(threshold_labels_df).mark_text(fontSize=32, color="#555555").encode(x="x:Q", y="y:Q", text="text:N") |
| 114 | + alt.Chart(threshold_labels_df) |
| 115 | + .mark_text(fontSize=24, color=INK_SOFT, fontWeight="bold", dy=-10) |
| 116 | + .encode(x="x:Q", y="y:Q", text="text:N") |
113 | 117 | ) |
114 | 118 |
|
115 | | -# Combine all layers |
| 119 | +# Compose layers |
116 | 120 | chart = ( |
117 | | - alt.layer(gauge_arcs, needle, hub, value_label, range_labels, threshold_labels) |
| 121 | + alt.layer(gauge_arcs, needle, hub_outer, hub_inner, threshold_labels, range_labels, value_label, context_label) |
118 | 122 | .properties( |
119 | | - width=1600, height=900, title=alt.Title("gauge-basic · altair · pyplots.ai", fontSize=48, anchor="middle") |
| 123 | + width=1600, |
| 124 | + height=900, |
| 125 | + background=PAGE_BG, |
| 126 | + title=alt.Title( |
| 127 | + "gauge-basic · altair · anyplot.ai", fontSize=28, anchor="middle", color=INK, fontWeight="normal" |
| 128 | + ), |
120 | 129 | ) |
121 | | - .configure_view(strokeWidth=0) |
| 130 | + .configure_view(strokeWidth=0, fill=PAGE_BG) |
122 | 131 | ) |
123 | 132 |
|
124 | 133 | # Save |
125 | | -chart.save("plot.png", scale_factor=3.0) |
126 | | -chart.save("plot.html") |
| 134 | +chart.save(f"plot-{THEME}.png", scale_factor=3.0) |
| 135 | +chart.save(f"plot-{THEME}.html") |
0 commit comments