Skip to content

Commit 636e746

Browse files
feat(pygal): implement timeseries-decomposition (#3030)
## Implementation: `timeseries-decomposition` - pygal Implements the **pygal** version of `timeseries-decomposition`. **File:** `plots/timeseries-decomposition/implementations/pygal.py` **Parent Issue:** #2992 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617483520)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent f1f434f commit 636e746

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
""" pyplots.ai
2+
timeseries-decomposition: Time Series Decomposition Plot
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-31
5+
"""
6+
7+
from io import BytesIO
8+
9+
import cairosvg
10+
import numpy as np
11+
import pandas as pd
12+
import pygal
13+
from PIL import Image, ImageDraw, ImageFont
14+
from pygal.style import Style
15+
from statsmodels.tsa.seasonal import seasonal_decompose
16+
17+
18+
# Data - Monthly CO2 measurements with clear trend and seasonality
19+
np.random.seed(42)
20+
dates = pd.date_range("2020-01-01", periods=72, freq="ME") # 6 years monthly
21+
22+
# Create realistic CO2-like data with trend, seasonality, and noise
23+
trend = np.linspace(410, 430, 72) # Rising trend (ppm)
24+
seasonal_pattern = 3 * np.sin(2 * np.pi * np.arange(72) / 12) # Annual cycle
25+
noise = np.random.normal(0, 0.5, 72)
26+
values = trend + seasonal_pattern + noise
27+
28+
# Create time series and decompose
29+
ts = pd.Series(values, index=dates)
30+
decomposition = seasonal_decompose(ts, model="additive", period=12)
31+
32+
# Extract components
33+
observed = decomposition.observed.values
34+
trend_component = decomposition.trend.values
35+
seasonal_component = decomposition.seasonal.values
36+
residual_component = decomposition.resid.values
37+
38+
# Create x-axis labels (show every 6 months for readability)
39+
x_labels = [d.strftime("%Y-%m") if i % 6 == 0 else "" for i, d in enumerate(dates)]
40+
41+
# Define components with their data, titles, colors, y-ranges, and y-axis labels
42+
components = [
43+
("Original Series (CO2 ppm)", observed, "#306998", (405, 437), "CO₂ (ppm)"),
44+
("Trend Component", trend_component, "#FFD43B", (405, 435), "Trend (ppm)"),
45+
("Seasonal Component", seasonal_component, "#44AA44", (-5, 5), "Seasonal (ppm)"),
46+
("Residual Component", residual_component, "#E74C3C", (-3, 3), "Residual (ppm)"),
47+
]
48+
49+
# Target: 4800 x 2700 px total (4 vertically stacked charts)
50+
# Reserve left margin for y-axis labels drawn manually
51+
title_height = 160
52+
y_label_width = 220
53+
chart_width = 4800 - y_label_width
54+
chart_height = (2700 - title_height) // 4
55+
56+
charts = []
57+
y_labels_list = []
58+
for idx, (label, data, color, y_range, y_label) in enumerate(components):
59+
# Replace NaN with None for pygal
60+
clean_data = [None if np.isnan(v) else float(v) for v in data]
61+
y_labels_list.append(y_label)
62+
63+
# Create custom style with component color and larger fonts for 4800x2700
64+
component_style = Style(
65+
background="white",
66+
plot_background="#fafafa",
67+
foreground="#333333",
68+
foreground_strong="#333333",
69+
foreground_subtle="#666666",
70+
colors=(color,),
71+
font_family="sans-serif",
72+
title_font_size=60,
73+
label_font_size=48,
74+
major_label_font_size=44,
75+
legend_font_size=44,
76+
value_font_size=36,
77+
stroke_width=5,
78+
)
79+
80+
chart = pygal.Line(
81+
width=chart_width,
82+
height=chart_height,
83+
style=component_style,
84+
title=label,
85+
x_title="Date" if idx == 3 else "",
86+
show_legend=False,
87+
show_y_guides=True,
88+
show_x_guides=True,
89+
show_dots=False,
90+
stroke_style={"width": 5},
91+
range=y_range,
92+
truncate_label=-1,
93+
x_label_rotation=35 if idx == 3 else 0,
94+
margin_left=20,
95+
y_labels_major_count=6,
96+
show_minor_y_labels=False,
97+
)
98+
99+
# Only show x-labels on the bottom chart
100+
if idx == 3:
101+
chart.x_labels = x_labels
102+
else:
103+
chart.x_labels = [""] * len(dates)
104+
105+
chart.add(label, clean_data)
106+
charts.append(chart)
107+
108+
# Render each chart to PNG and combine them vertically
109+
images = []
110+
for chart in charts:
111+
svg_bytes = chart.render()
112+
png_bytes = cairosvg.svg2png(bytestring=svg_bytes, output_width=chart_width, output_height=chart_height)
113+
img = Image.open(BytesIO(png_bytes))
114+
images.append(img)
115+
116+
# Create combined image (4800 x 2700)
117+
total_width = 4800
118+
total_height = 2700
119+
120+
combined = Image.new("RGB", (total_width, total_height), "white")
121+
122+
# Load fonts with increased sizes for better readability
123+
try:
124+
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 88)
125+
y_label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
126+
except OSError:
127+
title_font = ImageFont.load_default()
128+
y_label_font = ImageFont.load_default()
129+
130+
# Add main title
131+
draw = ImageDraw.Draw(combined)
132+
title_text = "timeseries-decomposition · pygal · pyplots.ai"
133+
bbox = draw.textbbox((0, 0), title_text, font=title_font)
134+
title_width = bbox[2] - bbox[0]
135+
title_x = (total_width - title_width) // 2
136+
draw.text((title_x, 40), title_text, fill="#333333", font=title_font)
137+
138+
# Paste charts vertically with space for y-axis labels
139+
for idx, img in enumerate(images):
140+
y_position = title_height + idx * chart_height
141+
combined.paste(img, (y_label_width, y_position))
142+
143+
# Draw rotated y-axis label on the left side
144+
y_label_text = y_labels_list[idx]
145+
label_img = Image.new("RGBA", (500, 120), (255, 255, 255, 0))
146+
label_draw = ImageDraw.Draw(label_img)
147+
label_draw.text((0, 0), y_label_text, fill="#333333", font=y_label_font)
148+
149+
# Crop to text bounds and rotate
150+
label_bbox = label_img.getbbox()
151+
if label_bbox:
152+
label_img = label_img.crop(label_bbox)
153+
label_img = label_img.rotate(90, expand=True)
154+
155+
# Center the rotated label vertically in the chart area
156+
label_x = (y_label_width - label_img.width) // 2
157+
label_y = y_position + (chart_height - label_img.height) // 2
158+
combined.paste(label_img, (label_x, label_y), label_img)
159+
160+
# Save final image
161+
combined.save("plot.png", dpi=(300, 300))
162+
163+
# Also save as HTML (interactive SVG)
164+
html_content = """<!DOCTYPE html>
165+
<html>
166+
<head>
167+
<title>timeseries-decomposition · pygal · pyplots.ai</title>
168+
<style>
169+
body { font-family: sans-serif; background: white; margin: 20px; }
170+
h1 { text-align: center; color: #333; font-size: 28px; margin-bottom: 20px; }
171+
.charts { display: flex; flex-direction: column; max-width: 1200px; margin: 0 auto; }
172+
.chart { width: 100%; margin-bottom: 10px; }
173+
.chart svg { width: 100%; height: auto; }
174+
</style>
175+
</head>
176+
<body>
177+
<h1>timeseries-decomposition · pygal · pyplots.ai</h1>
178+
<div class="charts">
179+
"""
180+
181+
for chart in charts:
182+
svg_data = chart.render(is_unicode=True)
183+
svg_data = svg_data.replace('<?xml version="1.0" encoding="utf-8"?>', "")
184+
html_content += f' <div class="chart">{svg_data}</div>\n'
185+
186+
html_content += """ </div>
187+
</body>
188+
</html>"""
189+
190+
with open("plot.html", "w") as f:
191+
f.write(html_content)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
library: pygal
2+
specification_id: timeseries-decomposition
3+
created: '2025-12-31T10:56:21Z'
4+
updated: '2025-12-31T11:37:43Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617483520
7+
issue: 2992
8+
python_version: 3.13.11
9+
library_version: 3.1.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/pygal/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/pygal/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/pygal/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent implementation of all four decomposition components with clear visual
17+
separation
18+
- Uses statsmodels seasonal_decompose for proper time series decomposition
19+
- Realistic CO2 data scenario with appropriate values and trends
20+
- Custom style configuration with proper font sizing for large canvas
21+
- Generates both PNG and interactive HTML output
22+
- Clean color scheme with distinct, colorblind-friendly colors for each component
23+
- Y-axis labels properly rotated and positioned for each subplot
24+
- X-axis date labels only on bottom chart with appropriate rotation to prevent overlap
25+
weaknesses:
26+
- Code structure is complex due to pygal limitations with multi-panel layouts (requires
27+
PIL composition)
28+
- Grid lines could be more subtle (current alpha appears fully opaque)
29+
- Left margin y-axis label area could be slightly narrower for better canvas utilization

0 commit comments

Comments
 (0)