{"spec_id":"timeseries-forecast-uncertainty","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst N_HISTORY = 40;\nconst N_FORECAST = 12;\nconst N_TOTAL = N_HISTORY + N_FORECAST;\nconst FORECAST_START = N_HISTORY - 1; // last historical index, where forecast begins\n\nconst MONTH_NAMES = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n];\nconst START_YEAR = 2022;\n\nconst labels = [];\nfor (let i = 0; i < N_TOTAL; i++) {\n  labels.push(`${MONTH_NAMES[i % 12]} ${START_YEAR + Math.floor(i / 12)}`);\n}\n\n// Monthly SaaS revenue ($k): trend + seasonality\nconst BASE = 420;\nconst TREND = 4.2;\nconst SEASONAL_AMPLITUDE = 28;\nconst trendValue = (i) =>\n  BASE + TREND * i + SEASONAL_AMPLITUDE * Math.sin((2 * Math.PI * i) / 12);\n\nconst actual = [];\nconst forecast = [];\nfor (let i = 0; i < N_TOTAL; i++) {\n  if (i < N_HISTORY) {\n    const noise = (rand() - 0.5) * 18;\n    actual.push(Math.round((trendValue(i) + noise) * 10) / 10);\n    forecast.push(i === FORECAST_START ? actual[i] : null);\n  } else {\n    actual.push(null);\n    forecast.push(Math.round(trendValue(i) * 10) / 10);\n  }\n}\n\n// Uncertainty widens with forecast horizon\nconst lower80 = [];\nconst upper80 = [];\nconst lower95 = [];\nconst upper95 = [];\nfor (let i = 0; i < N_TOTAL; i++) {\n  if (i < FORECAST_START) {\n    lower80.push(null);\n    upper80.push(null);\n    lower95.push(null);\n    upper95.push(null);\n    continue;\n  }\n  const horizon = i - FORECAST_START;\n  const spread80 = 12 + horizon * 3.2;\n  const spread95 = 20 + horizon * 5.4;\n  const center = forecast[i];\n  lower80.push(Math.round((center - spread80) * 10) / 10);\n  upper80.push(Math.round((center + spread80) * 10) / 10);\n  lower95.push(Math.round((center - spread95) * 10) / 10);\n  upper95.push(Math.round((center + spread95) * 10) / 10);\n}\n\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\nconst historicalColor = t.palette[0]; // #009E73 brand green — always first series\nconst forecastColor = t.palette[2]; // #4467A3 blue — forecast + uncertainty family\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// Draws the forecast-start divider natively on the canvas (spec-required marker)\nconst forecastDividerPlugin = {\n  id: \"forecastDivider\",\n  afterDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const xPixel = scales.x.getPixelForValue(FORECAST_START);\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 2;\n    ctx.setLineDash([6, 5]);\n    ctx.beginPath();\n    ctx.moveTo(xPixel, chartArea.top);\n    ctx.lineTo(xPixel, chartArea.bottom);\n    ctx.stroke();\n    ctx.setLineDash([]);\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"14px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Forecast start\", xPixel + 10, chartArea.top + 18);\n    ctx.restore();\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"line\",\n  data: {\n    labels,\n    datasets: [\n      {\n        label: \"95% Confidence\",\n        data: upper95,\n        borderWidth: 0,\n        pointRadius: 0,\n        fill: \"+1\",\n        backgroundColor: hexToRgba(forecastColor, 0.12),\n        tension: 0.25,\n      },\n      {\n        label: \"95% CI lower\",\n        data: lower95,\n        borderWidth: 0,\n        pointRadius: 0,\n        fill: false,\n        tension: 0.25,\n      },\n      {\n        label: \"80% Confidence\",\n        data: upper80,\n        borderWidth: 0,\n        pointRadius: 0,\n        fill: \"+1\",\n        backgroundColor: hexToRgba(forecastColor, 0.3),\n        tension: 0.25,\n      },\n      {\n        label: \"80% CI lower\",\n        data: lower80,\n        borderWidth: 0,\n        pointRadius: 0,\n        fill: false,\n        tension: 0.25,\n      },\n      {\n        label: \"Historical\",\n        data: actual,\n        borderColor: historicalColor,\n        backgroundColor: historicalColor,\n        borderWidth: 3,\n        pointRadius: 0,\n        fill: false,\n        tension: 0.25,\n      },\n      {\n        label: \"Forecast\",\n        data: forecast,\n        borderColor: forecastColor,\n        backgroundColor: forecastColor,\n        borderWidth: 3,\n        borderDash: [8, 5],\n        pointRadius: 0,\n        fill: false,\n        tension: 0.25,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 10, right: 20 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"timeseries-forecast-uncertainty · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          filter: (item) => !item.text.includes(\"lower\"),\n        },\n      },\n    },\n    scales: {\n      x: {\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          autoSkip: true,\n          maxRotation: 0,\n        },\n        grid: { display: false },\n        title: {\n          display: true,\n          text: \"Month\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Monthly Revenue ($ thousands)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [forecastDividerPlugin],\n});\n"}