Skip to content

Commit e1ec4bc

Browse files
feat(pygal): implement line-navigator (#7735)
## Implementation: `line-navigator` - python/pygal Implements the **python/pygal** version of `line-navigator`. **File:** `plots/line-navigator/implementations/python/pygal.py` **Parent Issue:** #3785 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26508239295)* --------- 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 4bb30bd commit e1ec4bc

2 files changed

Lines changed: 360 additions & 233 deletions

File tree

Lines changed: 187 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,79 @@
1-
""" pyplots.ai
1+
"""anyplot.ai
22
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
55
"""
66

77
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]
816

917
import numpy as np
1018
import pandas as pd
1119
import pygal
12-
from PIL import Image
20+
from PIL import Image, ImageDraw, ImageFont
1321
from pygal.style import Style
1422

1523

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)
1741
np.random.seed(42)
1842
dates = pd.date_range("2022-01-01", periods=1095, freq="D")
19-
20-
# Generate realistic sensor data with trend, seasonality, and noise
2143
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)
2345
noise = np.random.normal(0, 5, 1095)
2446
values = trend + seasonal + noise
2547

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,
4969
stroke_width=3,
50-
opacity=".8",
51-
opacity_hover=".9",
5270
)
5371

54-
# Main Chart - Shows selected range in detail
5572
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,
6077
x_title="Date",
6178
y_title="Sensor Reading (mV)",
6279
show_x_guides=False,
@@ -69,92 +86,156 @@
6986
truncate_label=-1,
7087
show_dots=False,
7188
fill=False,
72-
stroke_style={"width": 4},
89+
stroke_style={"width": 3},
7390
interpolate="cubic",
7491
margin=80,
7592
)
7693

77-
# Add selected range data to main chart
7894
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)]
8399
main_chart.x_labels = main_x_labels
84100
main_chart.add(detail_label, selected_values)
85101

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
87104
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,
99116
stroke_width=2,
100-
opacity=".7",
117+
opacity=".75",
101118
)
102119

103120
nav_chart = pygal.Line(
104-
width=4800,
105-
height=540,
121+
width=3200,
122+
height=270,
106123
style=nav_style,
107-
title="Navigator - Full Data Range (2022-2024)",
124+
title=None,
108125
x_title="",
109126
y_title="",
110127
show_x_guides=False,
111128
show_y_guides=False,
112129
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,
118131
show_dots=False,
119132
fill=True,
120-
stroke_style={"width": 2},
121-
margin=50,
133+
stroke_style={"width": 1.5},
134+
margin=30,
122135
)
123136

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))]
126138
nav_chart.x_labels = nav_x_labels
127-
nav_chart.add("Full Dataset (2022-2024)", list(values))
139+
nav_chart.add("Full Dataset (20222024)", list(values))
128140

129-
# Create selection indicator as a separate series (highlighted area)
130-
selection_indicator = [None] * len(values)
141+
selection_series = [None] * len(values)
131142
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)
134145

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))
138151

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)
142158

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 ───────────────────────────────────────────────────────
148160

149-
# Save final combined plot
150-
combined.save("plot.png")
151161

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(
154235
width=1200,
155236
height=540,
156-
style=custom_style,
157-
title="line-navigator \u00b7 pygal \u00b7 pyplots.ai",
237+
style=main_style,
238+
title=TITLE,
158239
x_title="Date",
159240
y_title="Sensor Reading (mV)",
160241
show_x_guides=False,
@@ -165,49 +246,38 @@
165246
truncate_label=-1,
166247
show_dots=False,
167248
fill=False,
249+
stroke_style={"width": 3},
168250
interpolate="cubic",
169251
)
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)
172254

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
182257
)
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 (20222024)", list(values))
260+
nav_html_chart.add(f"Selected: {detail_label}", selection_series)
186261

187-
# Create HTML with both charts
188262
html_content = f"""<!DOCTYPE html>
189263
<html>
190264
<head>
191-
<title>line-navigator - pygal - pyplots.ai</title>
265+
<title>line-navigator · python · pygal · anyplot.ai</title>
192266
<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}; }}
194268
.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; }}
197270
</style>
198271
</head>
199272
<body>
200273
<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)}
207277
</div>
208278
</body>
209279
</html>
210280
"""
211281

212-
with open("plot.html", "w") as f:
282+
with open(f"plot-{THEME}.html", "w") as f:
213283
f.write(html_content)

0 commit comments

Comments
 (0)