{"spec_id":"timeseries-decomposition","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-decomposition: Time Series Decomposition Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-14\n\"\"\"\n\nimport os\nimport sys\nfrom io import BytesIO\nfrom pathlib import Path\n\n\n# Remove current directory from sys.path to avoid collision with this file\nscript_dir = str(Path(__file__).parent)\nsys.path = [p for p in sys.path if p != script_dir and p != \"\"]\n\nimport cairosvg\nimport numpy as np\nimport pandas as pd\nimport pygal\nfrom PIL import Image, ImageDraw, ImageFont\nfrom pygal.style import Style\nfrom statsmodels.tsa.seasonal import seasonal_decompose\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito palette for components\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\")\n\n# Data - Monthly CO2 measurements with clear trend and seasonality\nnp.random.seed(42)\ndates = pd.date_range(\"2020-01-01\", periods=72, freq=\"ME\")\n\n# Create realistic CO2-like data with trend, seasonality, and noise\ntrend = np.linspace(410, 430, 72)\nseasonal_pattern = 3 * np.sin(2 * np.pi * np.arange(72) / 12)\nnoise = np.random.normal(0, 0.5, 72)\nvalues = trend + seasonal_pattern + noise\n\n# Create time series and decompose\nts = pd.Series(values, index=dates)\ndecomposition = seasonal_decompose(ts, model=\"additive\", period=12)\n\n# Extract components\nobserved = decomposition.observed.values\ntrend_component = decomposition.trend.values\nseasonal_component = decomposition.seasonal.values\nresidual_component = decomposition.resid.values\n\n# Create x-axis labels\nx_labels = [d.strftime(\"%Y-%m\") if i % 6 == 0 else \"\" for i, d in enumerate(dates)]\n\n# Define components with their data, titles, colors, y-ranges, and y-axis labels\ncomponents = [\n    (\"Original Series (CO2 ppm)\", observed, IMPRINT[0], (405, 437), \"CO₂ (ppm)\"),\n    (\"Trend Component\", trend_component, IMPRINT[1], (405, 435), \"Trend (ppm)\"),\n    (\"Seasonal Component\", seasonal_component, IMPRINT[2], (-5, 5), \"Seasonal (ppm)\"),\n    (\"Residual Component\", residual_component, IMPRINT[3], (-3, 3), \"Residual (ppm)\"),\n]\n\n# Target: 4800 x 2700 px total (4 vertically stacked charts)\ntitle_height = 160\ny_label_width = 180\nchart_width = 4800 - y_label_width\nchart_height = (2700 - title_height) // 4\n\ncharts = []\ny_labels_list = []\nfor idx, (label, data, color, y_range, y_label) in enumerate(components):\n    # Replace NaN with None for pygal\n    clean_data = [None if np.isnan(v) else float(v) for v in data]\n    y_labels_list.append(y_label)\n\n    # Create custom style with component color and larger fonts\n    component_style = Style(\n        background=PAGE_BG,\n        plot_background=PAGE_BG,\n        foreground=INK,\n        foreground_strong=INK,\n        foreground_subtle=INK_MUTED,\n        colors=(color,),\n        font_family=\"sans-serif\",\n        title_font_size=28,\n        label_font_size=22,\n        major_label_font_size=18,\n        legend_font_size=16,\n        value_font_size=14,\n        stroke_width=3,\n    )\n\n    chart = pygal.Line(\n        width=chart_width,\n        height=chart_height,\n        style=component_style,\n        title=label,\n        x_title=\"Date\" if idx == 3 else \"\",\n        show_legend=False,\n        show_y_guides=True,\n        show_x_guides=True,\n        show_dots=False,\n        stroke_style={\"width\": 3},\n        range=y_range,\n        truncate_label=-1,\n        x_label_rotation=35 if idx == 3 else 0,\n        margin_left=20,\n        y_labels_major_count=6,\n        show_minor_y_labels=False,\n        dots_size=2,\n    )\n\n    # Only show x-labels on the bottom chart\n    if idx == 3:\n        chart.x_labels = x_labels\n    else:\n        chart.x_labels = [\"\"] * len(dates)\n\n    chart.add(label, clean_data)\n    charts.append(chart)\n\n# Render each chart to PNG and combine them vertically\nimages = []\nfor chart in charts:\n    svg_bytes = chart.render()\n    png_bytes = cairosvg.svg2png(bytestring=svg_bytes, output_width=chart_width, output_height=chart_height)\n    img = Image.open(BytesIO(png_bytes))\n    images.append(img)\n\n# Create combined image\ntotal_width = 4800\ntotal_height = 2700\n\ncombined = Image.new(\"RGB\", (total_width, total_height), PAGE_BG)\n\n# Load fonts with increased sizes\ntry:\n    title_font = ImageFont.truetype(\"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\", 88)\n    y_label_font = ImageFont.truetype(\"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\", 48)\nexcept OSError:\n    title_font = ImageFont.load_default()\n    y_label_font = ImageFont.load_default()\n\n# Add main title\ndraw = ImageDraw.Draw(combined)\ntitle_text = \"timeseries-decomposition · pygal · anyplot.ai\"\nbbox = draw.textbbox((0, 0), title_text, font=title_font)\ntitle_width = bbox[2] - bbox[0]\ntitle_x = (total_width - title_width) // 2\ndraw.text((title_x, 40), title_text, fill=INK, font=title_font)\n\n# Paste charts vertically with space for y-axis labels\nfor idx, img in enumerate(images):\n    y_position = title_height + idx * chart_height\n    combined.paste(img, (y_label_width, y_position))\n\n    # Draw rotated y-axis label on the left side\n    y_label_text = y_labels_list[idx]\n    label_img = Image.new(\"RGBA\", (500, 120), (255, 255, 255, 0))\n    label_draw = ImageDraw.Draw(label_img)\n    label_draw.text((0, 0), y_label_text, fill=INK, font=y_label_font)\n\n    # Crop to text bounds and rotate\n    label_bbox = label_img.getbbox()\n    if label_bbox:\n        label_img = label_img.crop(label_bbox)\n    label_img = label_img.rotate(90, expand=True)\n\n    # Center the rotated label vertically in the chart area\n    label_x = (y_label_width - label_img.width) // 2\n    label_y = y_position + (chart_height - label_img.height) // 2\n    combined.paste(label_img, (label_x, label_y), label_img)\n\n# Save final image\ncombined.save(f\"plot-{THEME}.png\", dpi=(300, 300))\n\n# Also save as HTML (interactive SVG)\nhtml_content = (\n    \"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <title>timeseries-decomposition · pygal · anyplot.ai</title>\n    <style>\n        body {\n            font-family: sans-serif;\n            background: \"\"\"\n    + PAGE_BG\n    + \"\"\";\n            margin: 20px;\n            color: \"\"\"\n    + INK\n    + \"\"\";\n        }\n        h1 {\n            text-align: center;\n            color: \"\"\"\n    + INK\n    + \"\"\";\n            font-size: 28px;\n            margin-bottom: 20px;\n        }\n        .charts {\n            display: flex;\n            flex-direction: column;\n            max-width: 1200px;\n            margin: 0 auto;\n        }\n        .chart {\n            width: 100%;\n            margin-bottom: 10px;\n        }\n        .chart svg {\n            width: 100%;\n            height: auto;\n        }\n    </style>\n</head>\n<body>\n    <h1>timeseries-decomposition · pygal · anyplot.ai</h1>\n    <div class=\"charts\">\n\"\"\"\n)\n\nfor chart in charts:\n    svg_data = chart.render(is_unicode=True)\n    svg_data = svg_data.replace('<?xml version=\"1.0\" encoding=\"utf-8\"?>', \"\")\n    html_content += f'        <div class=\"chart\">{svg_data}</div>\\n'\n\nhtml_content += \"\"\"    </div>\n</body>\n</html>\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(html_content)\n"}