{"spec_id":"timeseries-forecast-uncertainty","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-19\n\"\"\"\n\nimport os\nimport sys\nsys.path[:] = [p for p in sys.path if p not in (\"\", \".\", os.path.dirname(__file__))]\n\nimport cairosvg\nimport numpy as np\nimport pandas as pd\nimport pygal\nfrom pygal.style import Style\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\nBRAND = \"#009E73\"\nFORECAST_COLOR = \"#C475FD\"\n\nnp.random.seed(42)\nmonths = 42\ndates = pd.date_range(\"2022-01-01\", periods=months, freq=\"MS\")\nsplit_idx = 36\n\nt = np.arange(split_idx)\nbase = 1000 + 50 * t + 100 * np.sin(2 * np.pi * t / 12)\nnoise = np.random.normal(0, 30, split_idx)\nactual = base + noise\n\nt_forecast = np.arange(split_idx, months)\nforecast_base = actual[-1] + 30 * (t_forecast - split_idx)\nforecast_values = forecast_base + 50 * np.sin(2 * np.pi * t_forecast / 12)\n\nupper_95 = forecast_values + 150\nlower_95 = forecast_values - 150\nupper_80 = forecast_values + 100\nlower_80 = forecast_values - 100\n\nx_labels = [d.strftime(\"%b %y\") for d in dates]\n\nCI_ALPHA = \"0.25\" if THEME == \"dark\" else \"0.20\"\nCI_95_FILL = f\"rgba(196, 117, 253, {CI_ALPHA})\"\n\n# Series order is driven by the background-erase CI technique: upper_95 fill\n# (orange) then lower_95 fill (PAGE_BG) produces the CI band; BRAND historical\n# line must be drawn last (on top) so the erase fill doesn't cover it.\ncustom_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=(CI_95_FILL, PAGE_BG, INK, INK, BRAND, FORECAST_COLOR),\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\nchart = pygal.Line(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    show_x_guides=False,\n    show_y_guides=True,\n    show_legend=True,\n    legend_at_bottom=True,\n    dots_size=2,\n    print_values=False,\n    # pygal auto-calculates truncation from chart_width / len(x_labels) / font_size;\n    # with 42 data points this resolves to 1 char → every label collapses to \"…\".\n    truncate_label=-1,\n)\n\nchart.title = \"timeseries-forecast-uncertainty · python · pygal · anyplot.ai\"\nchart.x_title = \"Month\"\nchart.y_title = \"Sales ($)\"\n\n# Sparse labels: show every 6th month; x_labels_major marks Jan 25 as the\n# forecast boundary with a prominent guide line.\nsparse_labels = [x_labels[i] if i % 6 == 0 else \"\" for i in range(len(x_labels))]\nchart.x_labels = sparse_labels\nchart.x_labels_major = [\"Jan 25\"]\n\n# Background-erase: CI series carry actual values in the history region so the\n# erase fill covers exactly the same area as the orange fill → no net band there.\nupper_95_full = list(actual) + list(upper_95)\nlower_95_full = list(actual) + list(lower_95)\n\nchart.add(\"95% CI\", upper_95_full, fill=True, show_legend=True, stroke_dasharray=(0,))\nchart.add(None, lower_95_full, fill=True, show_legend=False, stroke_dasharray=(0,))\nchart.add(\"80% CI\", [None] * split_idx + list(upper_80), fill=False, show_legend=True, stroke_dasharray=(4, 4))\nchart.add(None, [None] * split_idx + list(lower_80), fill=False, show_legend=False, stroke_dasharray=(4, 4))\nchart.add(\"Historical (observed)\", list(actual) + [None] * (months - split_idx), fill=False, stroke_dasharray=(0,))\nchart.add(\"Forecast (projected)\", [None] * split_idx + list(forecast_values), fill=False, stroke_dasharray=(5, 5))\n\n# pygal hardcodes stroke:black for guide lines — invisible on dark backgrounds.\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\nsvg_str = svg_str.replace(\n    \"stroke-dasharray:4,4;stroke:black}\", f\"stroke-dasharray:4,4;stroke:{INK_MUTED};stroke-opacity:0.5}}\"\n)\nsvg_str = svg_str.replace(\"stroke-dasharray:6,6;stroke:black}\", f\"stroke-dasharray:8,5;stroke:{INK};stroke-width:1.5}}\")\n\ncairosvg.svg2png(bytestring=svg_str.encode(), write_to=f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_str)\n"}