{"spec_id":"timeseries-forecast-uncertainty","library":"echarts","language":"javascript","code":"// anyplot.ai\n// timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic monthly SaaS revenue forecast) ---------\n// Tiny LCG so the \"random\" noise is reproducible without a browser RNG.\nlet seed = 42;\nconst lcg = () => {\n  seed = (seed * 1103515245 + 12345) % 2147483648;\n  return seed / 2147483648;\n};\nconst gaussian = (std) => {\n  const u1 = 1 - lcg();\n  const u2 = lcg();\n  return std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\n\nconst MONTH_NAMES = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\",\n                      \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst HIST_MONTHS = 42;   // 3.5 years of history\nconst FCST_MONTHS = 12;   // 1 year forecast\nconst TOTAL_MONTHS = HIST_MONTHS + FCST_MONTHS;\nconst START_YEAR = 2022;\nconst START_MONTH = 0; // January\n\nconst categories = [];\nfor (let i = 0; i < TOTAL_MONTHS; i++) {\n  const monthIndex = (START_MONTH + i) % 12;\n  const year = START_YEAR + Math.floor((START_MONTH + i) / 12);\n  categories.push(`${MONTH_NAMES[monthIndex]} ${year}`);\n}\n\n// Underlying model: linear growth + yearly seasonality\nconst trendAt = (i) => 120 + i * 1.15;\nconst seasonalityAt = (i) => 14 * Math.sin((2 * Math.PI * i) / 12 - Math.PI / 2);\n\nconst actual = new Array(TOTAL_MONTHS).fill(null);\nconst forecast = new Array(TOTAL_MONTHS).fill(null);\nconst lower80 = new Array(TOTAL_MONTHS).fill(null);\nconst upper80 = new Array(TOTAL_MONTHS).fill(null);\nconst lower95 = new Array(TOTAL_MONTHS).fill(null);\nconst upper95 = new Array(TOTAL_MONTHS).fill(null);\n\nlet lastHistoricalValue = 0;\nfor (let i = 0; i < HIST_MONTHS; i++) {\n  const value = trendAt(i) + seasonalityAt(i) + gaussian(3.5);\n  actual[i] = Math.round(value * 10) / 10;\n  lastHistoricalValue = actual[i];\n}\n\n// Forecast continues the model; uncertainty widens with the horizon.\nforecast[HIST_MONTHS - 1] = lastHistoricalValue; // connect the two lines\nlower80[HIST_MONTHS - 1] = lastHistoricalValue;\nupper80[HIST_MONTHS - 1] = lastHistoricalValue;\nlower95[HIST_MONTHS - 1] = lastHistoricalValue;\nupper95[HIST_MONTHS - 1] = lastHistoricalValue;\n\nfor (let h = 1; h <= FCST_MONTHS; h++) {\n  const i = HIST_MONTHS - 1 + h;\n  const median = trendAt(i) + seasonalityAt(i);\n  const sigma = 4 + 2.1 * Math.sqrt(h);\n  forecast[i] = Math.round(median * 10) / 10;\n  lower80[i] = Math.round((median - 1.28 * sigma) * 10) / 10;\n  upper80[i] = Math.round((median + 1.28 * sigma) * 10) / 10;\n  lower95[i] = Math.round((median - 1.96 * sigma) * 10) / 10;\n  upper95[i] = Math.round((median + 1.96 * sigma) * 10) / 10;\n}\n\n// Stacked-area technique: an invisible \"floor\" series plus a visible \"span\"\n// series on top of it renders as a band between lower and upper bounds.\nconst band95 = upper95.map((u, i) => (u === null ? null : Math.round((u - lower95[i]) * 10) / 10));\nconst band80 = upper80.map((u, i) => (u === null ? null : Math.round((u - lower80[i]) * 10) / 10));\n\nconst 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\n// Tighten the y-axis to the actual data range instead of a fixed 0-250 span,\n// which left the ~100-215 band compressed into a narrow vertical strip.\nconst spannedValues = [...actual, ...lower95, ...upper95].filter((v) => v !== null);\nconst dataMin = Math.min(...spannedValues);\nconst dataMax = Math.max(...spannedValues);\nconst yAxisMin = Math.floor((dataMin - 10) / 10) * 10;\nconst yAxisMax = Math.ceil((dataMax + 10) / 10) * 10;\n\n// --- Init --------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option --------------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  color: t.palette,\n  title: {\n    text: \"timeseries-forecast-uncertainty · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 20,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 600 },\n  },\n  legend: {\n    data: [\"Actual\", \"Forecast\", \"80% CI\", \"95% CI\"],\n    top: 72,\n    left: \"center\",\n    itemWidth: 22,\n    itemHeight: 14,\n    textStyle: { color: t.ink, fontSize: 15 },\n  },\n  grid: { left: 90, right: 60, top: 140, bottom: 90, containLabel: true },\n  xAxis: {\n    type: \"category\",\n    data: categories,\n    boundaryGap: false,\n    axisLabel: { color: t.inkSoft, fontSize: 14, interval: 3 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: \"value\",\n    name: \"Revenue ($k)\",\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    min: yAxisMin,\n    max: yAxisMax,\n    axisLabel: { color: t.inkSoft, fontSize: 14, formatter: \"${value}k\" },\n    axisLine: { show: false },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  series: [\n    // 95% CI band (widest, drawn first so it sits behind the 80% band)\n    {\n      name: \"__lower95\",\n      type: \"line\",\n      data: lower95,\n      stack: \"ci95\",\n      symbol: \"none\",\n      silent: true,\n      lineStyle: { opacity: 0 },\n      tooltip: { show: false },\n    },\n    {\n      name: \"95% CI\",\n      type: \"line\",\n      data: band95,\n      stack: \"ci95\",\n      symbol: \"none\",\n      lineStyle: { opacity: 0 },\n      areaStyle: { color: t.palette[1], opacity: 0.15 },\n      // Legend swatch uses a higher alpha than the in-chart band so the\n      // 80%/95% entries stay visually distinct at a glance.\n      itemStyle: { color: hexToRgba(t.palette[1], 0.45) },\n    },\n    // 80% CI band (narrower, drawn on top of the 95% band)\n    {\n      name: \"__lower80\",\n      type: \"line\",\n      data: lower80,\n      stack: \"ci80\",\n      symbol: \"none\",\n      silent: true,\n      lineStyle: { opacity: 0 },\n      tooltip: { show: false },\n    },\n    {\n      name: \"80% CI\",\n      type: \"line\",\n      data: band80,\n      stack: \"ci80\",\n      symbol: \"none\",\n      lineStyle: { opacity: 0 },\n      areaStyle: { color: t.palette[1], opacity: 0.32 },\n      itemStyle: { color: hexToRgba(t.palette[1], 0.85) },\n    },\n    // Historical + forecast lines on top\n    {\n      name: \"Actual\",\n      type: \"line\",\n      data: actual,\n      symbol: \"none\",\n      lineStyle: { color: t.palette[0], width: 3.5 },\n      itemStyle: { color: t.palette[0] },\n      markLine: {\n        symbol: \"none\",\n        silent: true,\n        lineStyle: { color: t.inkSoft, type: \"dashed\", width: 2 },\n        label: {\n          formatter: \"Forecast start\",\n          color: t.inkSoft,\n          fontSize: 13,\n          position: \"insideEndTop\",\n        },\n        data: [{ xAxis: HIST_MONTHS - 1.5 }],\n      },\n    },\n    {\n      name: \"Forecast\",\n      type: \"line\",\n      data: forecast,\n      symbol: \"none\",\n      lineStyle: { color: t.palette[1], width: 3, type: \"dashed\" },\n      itemStyle: { color: t.palette[1] },\n    },\n  ],\n});\n"}