Skip to content

Commit dd8e543

Browse files
feat(highcharts): implement timeseries-decomposition (#3044)
## Implementation: `timeseries-decomposition` - highcharts Implements the **highcharts** version of `timeseries-decomposition`. **File:** `plots/timeseries-decomposition/implementations/highcharts.py` **Parent Issue:** #2992 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617484846)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent f59bdb1 commit dd8e543

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
""" pyplots.ai
2+
timeseries-decomposition: Time Series Decomposition Plot
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import base64
8+
import json
9+
import tempfile
10+
import time
11+
import urllib.request
12+
from pathlib import Path
13+
14+
import numpy as np
15+
import pandas as pd
16+
from highcharts_core.chart import Chart
17+
from highcharts_core.options import HighchartsOptions
18+
from highcharts_core.options.series.area import LineSeries
19+
from selenium import webdriver
20+
from selenium.webdriver.chrome.options import Options
21+
from statsmodels.tsa.seasonal import seasonal_decompose
22+
23+
24+
# Data - Monthly airline passengers (classic time series dataset)
25+
np.random.seed(42)
26+
dates = pd.date_range(start="2018-01-01", periods=120, freq="MS") # 10 years monthly
27+
28+
# Generate realistic airline passenger data with trend, seasonality, and noise
29+
trend = np.linspace(100, 300, 120) # Upward trend over time
30+
seasonal = 40 * np.sin(2 * np.pi * np.arange(120) / 12) # Annual seasonality
31+
noise = np.random.normal(0, 15, 120) # Random noise
32+
passengers = trend + seasonal + noise
33+
34+
# Create time series and decompose
35+
ts = pd.Series(passengers, index=dates)
36+
decomposition = seasonal_decompose(ts, model="additive", period=12)
37+
38+
# Extract components
39+
observed = decomposition.observed
40+
trend_comp = decomposition.trend
41+
seasonal_comp = decomposition.seasonal
42+
residual = decomposition.resid
43+
44+
# Convert to lists for Highcharts (handle NaN values in trend/residual)
45+
timestamps = [int(d.timestamp() * 1000) for d in dates]
46+
observed_data = [[t, float(v)] for t, v in zip(timestamps, observed, strict=True)]
47+
trend_data = [[t, float(v) if not np.isnan(v) else None] for t, v in zip(timestamps, trend_comp, strict=True)]
48+
seasonal_data = [[t, float(v)] for t, v in zip(timestamps, seasonal_comp, strict=True)]
49+
residual_data = [[t, float(v) if not np.isnan(v) else None] for t, v in zip(timestamps, residual, strict=True)]
50+
51+
# Colors - colorblind-safe palette
52+
primary_blue = "#306998"
53+
secondary_yellow = "#FFD43B"
54+
purple = "#9467BD"
55+
teal = "#17BECF"
56+
57+
# Chart dimensions - single output image
58+
chart_width = 4800
59+
total_height = 2700
60+
subplot_height = total_height // 4 # 675px each
61+
62+
# Download Highcharts JS
63+
highcharts_url = "https://code.highcharts.com/highcharts.js"
64+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
65+
highcharts_js = response.read().decode("utf-8")
66+
67+
68+
def create_chart_config(
69+
container_id, title_text, subtitle_text, y_title, data, color, series_name, is_first=False, is_last=False
70+
):
71+
"""Create a Highcharts chart configuration using highcharts-core."""
72+
chart = Chart(container=container_id)
73+
chart.options = HighchartsOptions()
74+
75+
# Set options via highcharts-core
76+
chart.options.chart = {
77+
"type": "line",
78+
"width": chart_width,
79+
"height": subplot_height,
80+
"backgroundColor": "#ffffff",
81+
"marginLeft": 150,
82+
"marginRight": 100,
83+
"marginTop": 100 if is_first else 60,
84+
"marginBottom": 100 if is_last else 50,
85+
}
86+
87+
if is_first:
88+
chart.options.title = {"text": title_text, "style": {"fontSize": "40px", "fontWeight": "bold"}}
89+
chart.options.subtitle = {"text": subtitle_text, "style": {"fontSize": "34px"}}
90+
else:
91+
chart.options.title = {"text": subtitle_text, "style": {"fontSize": "34px", "fontWeight": "bold"}}
92+
93+
chart.options.x_axis = {
94+
"type": "datetime",
95+
"labels": {"style": {"fontSize": "20px"}, "format": "{value:%Y-%m}"},
96+
"title": {"text": "Date", "style": {"fontSize": "24px", "fontWeight": "bold"}} if is_last else {"text": None},
97+
"gridLineWidth": 1,
98+
"gridLineColor": "rgba(0,0,0,0.1)",
99+
"lineWidth": 2,
100+
}
101+
102+
chart.options.y_axis = {
103+
"title": {"text": y_title, "style": {"fontSize": "24px", "fontWeight": "bold"}},
104+
"labels": {"style": {"fontSize": "20px"}},
105+
"gridLineWidth": 1,
106+
"gridLineColor": "rgba(0,0,0,0.15)",
107+
"lineWidth": 2,
108+
}
109+
110+
# Enable minimal legend showing component name
111+
chart.options.legend = {
112+
"enabled": True,
113+
"align": "right",
114+
"verticalAlign": "top",
115+
"layout": "horizontal",
116+
"floating": True,
117+
"x": -50,
118+
"y": 15 if is_first else 5,
119+
"itemStyle": {"fontSize": "22px", "fontWeight": "normal"},
120+
}
121+
122+
chart.options.credits = {"enabled": False}
123+
124+
# Add series using highcharts-core LineSeries
125+
series = LineSeries()
126+
series.data = data
127+
series.name = series_name
128+
series.color = color
129+
series.line_width = 4
130+
series.marker = {"enabled": False}
131+
chart.add_series(series)
132+
133+
# Return config dict for manual JS generation (more reliable for multi-chart)
134+
return {
135+
"container": container_id,
136+
"options": {
137+
"chart": {
138+
"type": "line",
139+
"width": chart_width,
140+
"height": subplot_height,
141+
"backgroundColor": "#ffffff",
142+
"marginLeft": 150,
143+
"marginRight": 100,
144+
"marginTop": 100 if is_first else 60,
145+
"marginBottom": 100 if is_last else 50,
146+
},
147+
"title": {"text": title_text, "style": {"fontSize": "40px", "fontWeight": "bold"}}
148+
if is_first
149+
else {"text": subtitle_text, "style": {"fontSize": "34px", "fontWeight": "bold"}},
150+
"subtitle": {"text": subtitle_text, "style": {"fontSize": "34px"}} if is_first else None,
151+
"xAxis": {
152+
"type": "datetime",
153+
"labels": {"style": {"fontSize": "20px"}, "format": "{value:%Y-%m}"},
154+
"title": {"text": "Date", "style": {"fontSize": "24px", "fontWeight": "bold"}}
155+
if is_last
156+
else {"text": None},
157+
"gridLineWidth": 1,
158+
"gridLineColor": "rgba(0,0,0,0.1)",
159+
"lineWidth": 2,
160+
},
161+
"yAxis": {
162+
"title": {"text": y_title, "style": {"fontSize": "24px", "fontWeight": "bold"}},
163+
"labels": {"style": {"fontSize": "20px"}},
164+
"gridLineWidth": 1,
165+
"gridLineColor": "rgba(0,0,0,0.15)",
166+
"lineWidth": 2,
167+
},
168+
"legend": {
169+
"enabled": True,
170+
"align": "right",
171+
"verticalAlign": "top",
172+
"layout": "horizontal",
173+
"floating": True,
174+
"x": -50,
175+
"y": 15 if is_first else 5,
176+
"itemStyle": {"fontSize": "22px", "fontWeight": "normal"},
177+
},
178+
"credits": {"enabled": False},
179+
"series": [
180+
{"name": series_name, "data": data, "color": color, "lineWidth": 4, "marker": {"enabled": False}}
181+
],
182+
},
183+
}
184+
185+
186+
# Create all four chart configurations using highcharts-core
187+
chart_configs = [
188+
create_chart_config(
189+
"container1",
190+
"timeseries-decomposition · highcharts · pyplots.ai",
191+
"Original Series",
192+
"Passengers (thousands)",
193+
observed_data,
194+
primary_blue,
195+
"Original",
196+
is_first=True,
197+
),
198+
create_chart_config(
199+
"container2", "", "Trend Component", "Trend (thousands)", trend_data, secondary_yellow, "Trend"
200+
),
201+
create_chart_config(
202+
"container3", "", "Seasonal Component", "Seasonal Effect (thousands)", seasonal_data, purple, "Seasonal"
203+
),
204+
create_chart_config(
205+
"container4", "", "Residual Component", "Residual (thousands)", residual_data, teal, "Residual", is_last=True
206+
),
207+
]
208+
209+
# Build HTML with all 4 charts stacked vertically
210+
containers_html = "\n".join(
211+
[
212+
f'<div id="{cfg["container"]}" style="width: {chart_width}px; height: {subplot_height}px;"></div>'
213+
for cfg in chart_configs
214+
]
215+
)
216+
217+
# Build direct JavaScript calls (no DOMContentLoaded wrapper for headless)
218+
scripts_js = "\n".join(
219+
[f"Highcharts.chart('{cfg['container']}', {json.dumps(cfg['options'])});" for cfg in chart_configs]
220+
)
221+
222+
html_content = f"""<!DOCTYPE html>
223+
<html>
224+
<head>
225+
<meta charset="utf-8">
226+
<script>{highcharts_js}</script>
227+
<style>
228+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
229+
html, body {{ width: {chart_width}px; height: {total_height}px; background-color: #ffffff; overflow: hidden; }}
230+
</style>
231+
</head>
232+
<body>
233+
{containers_html}
234+
<script>
235+
{scripts_js}
236+
</script>
237+
</body>
238+
</html>"""
239+
240+
# Save and screenshot using CDP for full page capture
241+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
242+
f.write(html_content)
243+
temp_path = f.name
244+
245+
chrome_options = Options()
246+
chrome_options.add_argument("--headless=new")
247+
chrome_options.add_argument("--no-sandbox")
248+
chrome_options.add_argument("--disable-dev-shm-usage")
249+
chrome_options.add_argument("--disable-gpu")
250+
chrome_options.add_argument("--hide-scrollbars")
251+
252+
driver = webdriver.Chrome(options=chrome_options)
253+
driver.get(f"file://{temp_path}")
254+
time.sleep(6) # Wait for all charts to render
255+
256+
# Use CDP to capture full page screenshot at exact dimensions
257+
screenshot_config = {
258+
"captureBeyondViewport": True,
259+
"clip": {"x": 0, "y": 0, "width": chart_width, "height": total_height, "scale": 1},
260+
}
261+
result = driver.execute_cdp_cmd("Page.captureScreenshot", screenshot_config)
262+
screenshot_data = base64.b64decode(result["data"])
263+
264+
with open("plot.png", "wb") as f:
265+
f.write(screenshot_data)
266+
267+
driver.quit()
268+
Path(temp_path).unlink()
269+
270+
# Also save interactive HTML
271+
with open("plot.html", "w", encoding="utf-8") as f:
272+
html_portable = f"""<!DOCTYPE html>
273+
<html>
274+
<head>
275+
<meta charset="utf-8">
276+
<script src="https://code.highcharts.com/highcharts.js"></script>
277+
<style>
278+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
279+
body {{ background-color: #ffffff; }}
280+
</style>
281+
</head>
282+
<body>
283+
{containers_html}
284+
<script>
285+
{scripts_js}
286+
</script>
287+
</body>
288+
</html>"""
289+
f.write(html_portable)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
library: highcharts
2+
specification_id: timeseries-decomposition
3+
created: '2025-12-31T11:00:24Z'
4+
updated: '2025-12-31T11:20:52Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617484846
7+
issue: 2992
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/timeseries-decomposition/highcharts/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent colorblind-safe palette with distinct colors for each component (blue,
17+
yellow, purple, teal)
18+
- Clear vertical stacking of all four decomposition components with shared time
19+
axis
20+
- Proper use of statsmodels seasonal_decompose for accurate decomposition
21+
- Good handling of NaN values in trend and residual components
22+
- Well-formatted datetime axis labels (YYYY-MM format)
23+
- Generates both static PNG and interactive HTML output
24+
- Realistic airline passenger dataset with appropriate 10-year monthly span
25+
weaknesses:
26+
- Code uses a helper function (create_chart_config) which slightly violates the
27+
KISS principle for plot implementations
28+
- Individual subplot heights appear somewhat compressed - could benefit from slightly
29+
taller subplots
30+
- Legend text could be slightly larger for better readability at full resolution

0 commit comments

Comments
 (0)