|
1 | | -""" pyplots.ai |
| 1 | +"""anyplot.ai |
2 | 2 | line-navigator: Line Chart with Mini Navigator |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 86/100 | Created: 2026-01-20 |
| 3 | +Library: pygal 3.1.0 | Python 3.13.13 |
| 4 | +Quality: 86/100 | Updated: 2026-05-27 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import io |
| 8 | +import os |
| 9 | +import sys |
| 10 | + |
| 11 | + |
| 12 | +# Remove current directory from sys.path to prevent self-import conflict |
| 13 | +# (this file is named pygal.py, same as the library package) |
| 14 | +_cwd = os.path.abspath(".") |
| 15 | +sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _cwd] |
8 | 16 |
|
9 | 17 | import numpy as np |
10 | 18 | import pandas as pd |
11 | 19 | import pygal |
12 | | -from PIL import Image |
| 20 | +from PIL import Image, ImageDraw, ImageFont |
13 | 21 | from pygal.style import Style |
14 | 22 |
|
15 | 23 |
|
16 | | -# Data - Daily sensor readings over 3 years (1095 points) |
| 24 | +# Theme tokens |
| 25 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 26 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 27 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 28 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 29 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 30 | +INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
| 31 | + |
| 32 | +ANYPLOT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314") |
| 33 | + |
| 34 | + |
| 35 | +def _hex_to_rgb(h): |
| 36 | + h = h.lstrip("#") |
| 37 | + return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) |
| 38 | + |
| 39 | + |
| 40 | +# Data — daily sensor readings over 3 years (1095 points) |
17 | 41 | np.random.seed(42) |
18 | 42 | dates = pd.date_range("2022-01-01", periods=1095, freq="D") |
19 | | - |
20 | | -# Generate realistic sensor data with trend, seasonality, and noise |
21 | 43 | trend = np.linspace(50, 80, 1095) |
22 | | -seasonal = 15 * np.sin(2 * np.pi * np.arange(1095) / 365) # Yearly cycle |
| 44 | +seasonal = 15 * np.sin(2 * np.pi * np.arange(1095) / 365) |
23 | 45 | noise = np.random.normal(0, 5, 1095) |
24 | 46 | values = trend + seasonal + noise |
25 | 47 |
|
26 | | -# Define selected range for the navigator view (middle portion) |
27 | | -selection_start = 400 |
28 | | -selection_end = 600 |
29 | | - |
30 | | -# Create descriptive date range label for selected range |
31 | | -start_date = dates[selection_start].strftime("%b %Y") |
32 | | -end_date = dates[selection_end - 1].strftime("%b %Y") |
33 | | -# Use shorter label to avoid legend truncation |
34 | | -detail_label = f"{start_date} - {end_date}" |
35 | | - |
36 | | -# Custom style for pyplots |
37 | | -custom_style = Style( |
38 | | - background="white", |
39 | | - plot_background="white", |
40 | | - foreground="#333", |
41 | | - foreground_strong="#333", |
42 | | - foreground_subtle="#666", |
43 | | - colors=("#306998", "#FFD43B"), |
44 | | - title_font_size=56, |
45 | | - label_font_size=32, |
46 | | - major_label_font_size=28, |
47 | | - legend_font_size=32, |
48 | | - value_font_size=24, |
| 48 | +# Navigator selection: Feb 2023 – Aug 2023 |
| 49 | +selection_start, selection_end = 400, 600 |
| 50 | +start_label = dates[selection_start].strftime("%b %Y") |
| 51 | +end_label = dates[selection_end - 1].strftime("%b %Y") |
| 52 | +detail_label = f"{start_label} – {end_label}" |
| 53 | + |
| 54 | +TITLE = "line-navigator · python · pygal · anyplot.ai" |
| 55 | + |
| 56 | +# Main chart: 3200 × 1530 px (85 % of 1800) |
| 57 | +main_style = Style( |
| 58 | + background=PAGE_BG, |
| 59 | + plot_background=PAGE_BG, |
| 60 | + foreground=INK, |
| 61 | + foreground_strong=INK, |
| 62 | + foreground_subtle=INK_MUTED, |
| 63 | + colors=ANYPLOT_PALETTE, |
| 64 | + title_font_size=66, |
| 65 | + label_font_size=56, |
| 66 | + major_label_font_size=44, |
| 67 | + legend_font_size=44, |
| 68 | + value_font_size=36, |
49 | 69 | stroke_width=3, |
50 | | - opacity=".8", |
51 | | - opacity_hover=".9", |
52 | 70 | ) |
53 | 71 |
|
54 | | -# Main Chart - Shows selected range in detail |
55 | 72 | main_chart = pygal.Line( |
56 | | - width=4800, |
57 | | - height=2160, |
58 | | - style=custom_style, |
59 | | - title="line-navigator \u00b7 pygal \u00b7 pyplots.ai", |
| 73 | + width=3200, |
| 74 | + height=1530, |
| 75 | + style=main_style, |
| 76 | + title=TITLE, |
60 | 77 | x_title="Date", |
61 | 78 | y_title="Sensor Reading (mV)", |
62 | 79 | show_x_guides=False, |
|
69 | 86 | truncate_label=-1, |
70 | 87 | show_dots=False, |
71 | 88 | fill=False, |
72 | | - stroke_style={"width": 4}, |
| 89 | + stroke_style={"width": 3}, |
73 | 90 | interpolate="cubic", |
74 | 91 | margin=80, |
75 | 92 | ) |
76 | 93 |
|
77 | | -# Add selected range data to main chart |
78 | 94 | selected_values = list(values[selection_start:selection_end]) |
79 | | -selected_dates = [dates[i].strftime("%Y-%m-%d") for i in range(selection_start, selection_end)] |
80 | | - |
81 | | -# Sample labels for x-axis (show every 25th date to reduce overlap) |
82 | | -main_x_labels = [selected_dates[i] if i % 25 == 0 else "" for i in range(len(selected_dates))] |
| 95 | +n_sel = selection_end - selection_start # 200 |
| 96 | +selected_dates = [dates[i].strftime("%b %d") for i in range(selection_start, selection_end)] |
| 97 | +step = max(1, n_sel // 6) # ~33 → ~6 evenly-spaced labels |
| 98 | +main_x_labels = [selected_dates[i] if i % step == 0 else "" for i in range(n_sel)] |
83 | 99 | main_chart.x_labels = main_x_labels |
84 | 100 | main_chart.add(detail_label, selected_values) |
85 | 101 |
|
86 | | -# Navigator Chart - Shows full data extent with selection indicator |
| 102 | +# Navigator: 3200 × 270 px (15 % of 1800; ~17.6 % of main height) |
| 103 | +# Legend removed — selection range labelled via PIL annotation at top of pane |
87 | 104 | nav_style = Style( |
88 | | - background="#f0f0f0", |
89 | | - plot_background="#f0f0f0", |
90 | | - foreground="#333", |
91 | | - foreground_strong="#333", |
92 | | - foreground_subtle="#666", |
93 | | - colors=("#8AAEC7", "#E67E22"), # Lighter blue for full data, orange for selection |
94 | | - title_font_size=44, |
95 | | - label_font_size=36, |
96 | | - major_label_font_size=32, |
97 | | - legend_font_size=36, |
98 | | - value_font_size=28, |
| 105 | + background=ELEVATED_BG, |
| 106 | + plot_background=ELEVATED_BG, |
| 107 | + foreground=INK_SOFT, |
| 108 | + foreground_strong=INK_SOFT, |
| 109 | + foreground_subtle=INK_MUTED, |
| 110 | + colors=ANYPLOT_PALETTE, |
| 111 | + title_font_size=40, |
| 112 | + label_font_size=32, |
| 113 | + major_label_font_size=28, |
| 114 | + legend_font_size=30, |
| 115 | + value_font_size=22, |
99 | 116 | stroke_width=2, |
100 | | - opacity=".7", |
| 117 | + opacity=".75", |
101 | 118 | ) |
102 | 119 |
|
103 | 120 | nav_chart = pygal.Line( |
104 | | - width=4800, |
105 | | - height=540, |
| 121 | + width=3200, |
| 122 | + height=270, |
106 | 123 | style=nav_style, |
107 | | - title="Navigator - Full Data Range (2022-2024)", |
| 124 | + title=None, |
108 | 125 | x_title="", |
109 | 126 | y_title="", |
110 | 127 | show_x_guides=False, |
111 | 128 | show_y_guides=False, |
112 | 129 | show_y_labels=False, |
113 | | - show_legend=True, |
114 | | - legend_at_bottom=True, |
115 | | - legend_box_size=20, |
116 | | - truncate_legend=-1, |
117 | | - x_label_rotation=0, |
| 130 | + show_legend=False, |
118 | 131 | show_dots=False, |
119 | 132 | fill=True, |
120 | | - stroke_style={"width": 2}, |
121 | | - margin=50, |
| 133 | + stroke_style={"width": 1.5}, |
| 134 | + margin=30, |
122 | 135 | ) |
123 | 136 |
|
124 | | -# Add full data to navigator |
125 | | -nav_x_labels = [dates[i].strftime("%Y-%m") if i % 182 == 0 else "" for i in range(len(dates))] |
| 137 | +nav_x_labels = [dates[i].strftime("%Y") if i % 365 == 0 else "" for i in range(len(dates))] |
126 | 138 | nav_chart.x_labels = nav_x_labels |
127 | | -nav_chart.add("Full Dataset (2022-2024)", list(values)) |
| 139 | +nav_chart.add("Full Dataset (2022–2024)", list(values)) |
128 | 140 |
|
129 | | -# Create selection indicator as a separate series (highlighted area) |
130 | | -selection_indicator = [None] * len(values) |
| 141 | +selection_series = [None] * len(values) |
131 | 142 | for i in range(selection_start, selection_end): |
132 | | - selection_indicator[i] = values[i] |
133 | | -nav_chart.add(f"Selected: {start_date} - {end_date}", selection_indicator) |
| 143 | + selection_series[i] = values[i] |
| 144 | +nav_chart.add(f"Selected: {detail_label}", selection_series) |
134 | 145 |
|
135 | | -# Render charts to PNG bytes in memory (no temp files) |
136 | | -main_png_bytes = main_chart.render_to_png() |
137 | | -nav_png_bytes = nav_chart.render_to_png() |
| 146 | +# Render both charts to PNG bytes |
| 147 | +main_png = main_chart.render_to_png() |
| 148 | +nav_png = nav_chart.render_to_png() |
| 149 | +main_img = Image.open(io.BytesIO(main_png)) |
| 150 | +nav_img = Image.open(io.BytesIO(nav_png)) |
138 | 151 |
|
139 | | -# Combine both charts into single image using PIL |
140 | | -main_img = Image.open(io.BytesIO(main_png_bytes)) |
141 | | -nav_img = Image.open(io.BytesIO(nav_png_bytes)) |
| 152 | +# Resize to exact targets in case cairosvg introduces a pixel offset |
| 153 | +TARGET_W, TARGET_MAIN_H, TARGET_NAV_H = 3200, 1530, 270 |
| 154 | +if main_img.size != (TARGET_W, TARGET_MAIN_H): |
| 155 | + main_img = main_img.resize((TARGET_W, TARGET_MAIN_H), Image.LANCZOS) |
| 156 | +if nav_img.size != (TARGET_W, TARGET_NAV_H): |
| 157 | + nav_img = nav_img.resize((TARGET_W, TARGET_NAV_H), Image.LANCZOS) |
142 | 158 |
|
143 | | -# Create combined image without gap between charts |
144 | | -combined_height = main_img.height + nav_img.height |
145 | | -combined = Image.new("RGB", (main_img.width, combined_height), "white") |
146 | | -combined.paste(main_img, (0, 0)) |
147 | | -combined.paste(nav_img, (0, main_img.height)) |
| 159 | +# ── PIL post-processing ─────────────────────────────────────────────────────── |
148 | 160 |
|
149 | | -# Save final combined plot |
150 | | -combined.save("plot.png") |
151 | 161 |
|
152 | | -# Also save HTML version |
153 | | -main_chart_html = pygal.Line( |
| 162 | +# Try to load DejaVu font; fall back to PIL built-in |
| 163 | +def _load_font(size): |
| 164 | + for path in ( |
| 165 | + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| 166 | + "/usr/share/fonts/dejavu/DejaVuSans.ttf", |
| 167 | + "/usr/share/fonts/TTF/DejaVuSans.ttf", |
| 168 | + ): |
| 169 | + if os.path.exists(path): |
| 170 | + return ImageFont.truetype(path, size) |
| 171 | + return ImageFont.load_default() |
| 172 | + |
| 173 | + |
| 174 | +ann_font = _load_font(48) |
| 175 | +nav_font = _load_font(30) |
| 176 | + |
| 177 | +# Add date-range subtitle annotation to main chart |
| 178 | +# Placed just below the pygal-rendered title (estimated at y≈80–150) |
| 179 | +main_draw = ImageDraw.Draw(main_img) |
| 180 | +ann_text = f"Viewing: {detail_label} · Full dataset: Jan 2022 – Dec 2024" |
| 181 | +try: |
| 182 | + bbox = main_draw.textbbox((0, 0), ann_text, font=ann_font) |
| 183 | + text_w = bbox[2] - bbox[0] |
| 184 | +except AttributeError: |
| 185 | + text_w = len(ann_text) * 26 |
| 186 | +x_ann = max(80, (TARGET_W - text_w) // 2) |
| 187 | +main_draw.text((x_ann, 155), ann_text, fill=INK_SOFT, font=ann_font) |
| 188 | + |
| 189 | +# ── Navigator selection-window overlay ─────────────────────────────────────── |
| 190 | +# Estimate the navigator's data-area x-bounds. |
| 191 | +# pygal Line with margin=30, no y-labels: data region ≈ x[70, 3170]. |
| 192 | +NAV_LEFT, NAV_RIGHT = 70, 3170 |
| 193 | +NAV_PLOT_W = NAV_RIGHT - NAV_LEFT |
| 194 | +N_TOTAL = len(values) |
| 195 | + |
| 196 | +x_sel_s = int(NAV_LEFT + (selection_start / (N_TOTAL - 1)) * NAV_PLOT_W) |
| 197 | +x_sel_e = int(NAV_LEFT + ((selection_end - 1) / (N_TOTAL - 1)) * NAV_PLOT_W) |
| 198 | + |
| 199 | +ink_rgb = _hex_to_rgb(INK) |
| 200 | +lavender_rgb = _hex_to_rgb("#C475FD") |
| 201 | + |
| 202 | +# Composite transparent overlay onto navigator for selection window |
| 203 | +nav_rgba = nav_img.convert("RGBA") |
| 204 | +overlay = Image.new("RGBA", nav_img.size, (0, 0, 0, 0)) |
| 205 | +ov_draw = ImageDraw.Draw(overlay) |
| 206 | + |
| 207 | +# Shaded selection window |
| 208 | +ov_draw.rectangle([(x_sel_s, 5), (x_sel_e, TARGET_NAV_H - 5)], fill=(*lavender_rgb, 40)) |
| 209 | +# Solid boundary lines at selection start and end |
| 210 | +for x in (x_sel_s, x_sel_e): |
| 211 | + ov_draw.line([(x, 5), (x, TARGET_NAV_H - 5)], fill=(*ink_rgb, 200), width=3) |
| 212 | + |
| 213 | +nav_img = Image.alpha_composite(nav_rgba, overlay).convert("RGB") |
| 214 | + |
| 215 | +# Add selection range label inside the top margin of the navigator pane |
| 216 | +nav_draw = ImageDraw.Draw(nav_img) |
| 217 | +nav_label = f"Selected: {detail_label} · Full dataset: 2022–2024" |
| 218 | +try: |
| 219 | + nbbox = nav_draw.textbbox((0, 0), nav_label, font=nav_font) |
| 220 | + nav_text_w = nbbox[2] - nbbox[0] |
| 221 | +except AttributeError: |
| 222 | + nav_text_w = len(nav_label) * 16 |
| 223 | +x_nav_label = max(30, (TARGET_W - nav_text_w) // 2) |
| 224 | +nav_draw.text((x_nav_label, 6), nav_label, fill=INK_SOFT, font=nav_font) |
| 225 | + |
| 226 | +# ───────────────────────────────────────────────────────────────────────────── |
| 227 | + |
| 228 | +combined = Image.new("RGB", (TARGET_W, TARGET_MAIN_H + TARGET_NAV_H), PAGE_BG) |
| 229 | +combined.paste(main_img, (0, 0)) |
| 230 | +combined.paste(nav_img, (0, TARGET_MAIN_H)) |
| 231 | +combined.save(f"plot-{THEME}.png") |
| 232 | + |
| 233 | +# HTML output — interactive pygal charts in browser viewport dimensions |
| 234 | +main_html_chart = pygal.Line( |
154 | 235 | width=1200, |
155 | 236 | height=540, |
156 | | - style=custom_style, |
157 | | - title="line-navigator \u00b7 pygal \u00b7 pyplots.ai", |
| 237 | + style=main_style, |
| 238 | + title=TITLE, |
158 | 239 | x_title="Date", |
159 | 240 | y_title="Sensor Reading (mV)", |
160 | 241 | show_x_guides=False, |
|
165 | 246 | truncate_label=-1, |
166 | 247 | show_dots=False, |
167 | 248 | fill=False, |
| 249 | + stroke_style={"width": 3}, |
168 | 250 | interpolate="cubic", |
169 | 251 | ) |
170 | | -main_chart_html.x_labels = main_x_labels |
171 | | -main_chart_html.add(detail_label, selected_values) |
| 252 | +main_html_chart.x_labels = main_x_labels |
| 253 | +main_html_chart.add(detail_label, selected_values) |
172 | 254 |
|
173 | | -nav_chart_html = pygal.Line( |
174 | | - width=1200, |
175 | | - height=150, |
176 | | - style=nav_style, |
177 | | - title="Navigator - Full Data Range", |
178 | | - show_legend=True, |
179 | | - legend_at_bottom=True, |
180 | | - show_dots=False, |
181 | | - fill=True, |
| 255 | +nav_html_chart = pygal.Line( |
| 256 | + width=1200, height=150, style=nav_style, title=None, show_legend=False, show_dots=False, fill=True |
182 | 257 | ) |
183 | | -nav_chart_html.x_labels = nav_x_labels |
184 | | -nav_chart_html.add("Full Dataset (2022-2024)", list(values)) |
185 | | -nav_chart_html.add(f"Selected: {start_date} - {end_date}", selection_indicator) |
| 258 | +nav_html_chart.x_labels = nav_x_labels |
| 259 | +nav_html_chart.add("Full Dataset (2022–2024)", list(values)) |
| 260 | +nav_html_chart.add(f"Selected: {detail_label}", selection_series) |
186 | 261 |
|
187 | | -# Create HTML with both charts |
188 | 262 | html_content = f"""<!DOCTYPE html> |
189 | 263 | <html> |
190 | 264 | <head> |
191 | | - <title>line-navigator - pygal - pyplots.ai</title> |
| 265 | + <title>line-navigator · python · pygal · anyplot.ai</title> |
192 | 266 | <style> |
193 | | - body {{ font-family: Arial, sans-serif; margin: 20px; background: #fff; }} |
| 267 | + body {{ font-family: Arial, sans-serif; margin: 20px; background: {PAGE_BG}; color: {INK}; }} |
194 | 268 | .chart-container {{ max-width: 1200px; margin: 0 auto; }} |
195 | | - .main-chart {{ margin-bottom: 0; }} |
196 | | - .nav-chart {{ background: #f0f0f0; }} |
| 269 | + .nav-label {{ text-align: center; font-size: 13px; color: {INK_SOFT}; margin: 4px 0; }} |
197 | 270 | </style> |
198 | 271 | </head> |
199 | 272 | <body> |
200 | 273 | <div class="chart-container"> |
201 | | - <div class="main-chart"> |
202 | | - {main_chart_html.render(is_unicode=True)} |
203 | | - </div> |
204 | | - <div class="nav-chart"> |
205 | | - {nav_chart_html.render(is_unicode=True)} |
206 | | - </div> |
| 274 | + {main_html_chart.render(is_unicode=True)} |
| 275 | + <div class="nav-label">Selected: {detail_label} · Full dataset: 2022–2024</div> |
| 276 | + {nav_html_chart.render(is_unicode=True)} |
207 | 277 | </div> |
208 | 278 | </body> |
209 | 279 | </html> |
210 | 280 | """ |
211 | 281 |
|
212 | | -with open("plot.html", "w") as f: |
| 282 | +with open(f"plot-{THEME}.html", "w") as f: |
213 | 283 | f.write(html_content) |
0 commit comments