{"spec_id":"timeseries-forecast-uncertainty","library":"muix","language":"javascript","code":"// anyplot.ai\n// timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst BRAND = t.palette[0]; // one hue for the whole series family: actual, forecast, and its own uncertainty envelope\n\n// --- Data: 42 months of historical product demand + a 9-month-ahead forecast\n// (the forecast's first point overlaps the last historical point so the two\n// lines connect, per the spec). Uncertainty widens with horizon — the classic\n// \"fan out\" of a real ARIMA/Prophet-style demand-planning forecast. ---\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1103515245 + 12345) % 2147483648;\n    return state / 2147483648;\n  };\n}\nconst random = lcg(42);\n\nconst HIST_MONTHS = 42;\nconst FORECAST_HORIZON = 12;\nconst FORECAST_START = HIST_MONTHS - 1; // last historical index == first forecast index (overlap)\nconst TOTAL_MONTHS = HIST_MONTHS + FORECAST_HORIZON;\n\nconst dates = Array.from({ length: TOTAL_MONTHS }, (_, i) => new Date(2023, i, 1));\n\nconst BASE = 3200;\nconst TREND = 9;\nconst SEASON_AMP = 260;\nconst NOISE_AMP = 90;\nconst centerAt = (i) => BASE + TREND * i + SEASON_AMP * Math.sin((2 * Math.PI * i) / 12);\n\nconst actual = dates.map((_, i) =>\n  i <= FORECAST_START ? Math.round(centerAt(i) + (random() - 0.5) * 2 * NOISE_AMP) : null,\n);\nconst forecast = dates.map((_, i) => {\n  if (i < FORECAST_START) return null;\n  return i === FORECAST_START ? actual[FORECAST_START] : Math.round(centerAt(i));\n});\n\n// 80% / 95% normal-interval multipliers applied to a linearly growing sigma.\nconst Z80 = 1.2816;\nconst Z95 = 1.96;\nconst SIGMA_BASE = 70;\nconst SIGMA_GROWTH = 26;\n\nconst lower80 = [];\nconst lower95 = [];\nconst bandWidth80 = [];\nconst bandWidth95 = [];\ndates.forEach((_, i) => {\n  if (i < FORECAST_START) {\n    lower80.push(null);\n    lower95.push(null);\n    bandWidth80.push(null);\n    bandWidth95.push(null);\n    return;\n  }\n  const horizon = i - FORECAST_START;\n  const sigma = SIGMA_BASE + SIGMA_GROWTH * horizon;\n  const half80 = Z80 * sigma;\n  const half95 = Z95 * sigma;\n  lower80.push(forecast[i] - half80);\n  lower95.push(forecast[i] - half95);\n  bandWidth80.push(2 * half80);\n  bandWidth95.push(2 * half95);\n});\n\n// Stacked-band trick: an invisible series carries the lower-bound offset, and\n// a second series stacked on top of it supplies only the band's own width\n// (upper - lower). The visible area then spans exactly [lower, upper]. The\n// wider 95% band is declared first so the narrower 80% band draws on top of\n// it, giving the \"darker inner / lighter outer\" nesting the spec asks for.\n// The base/stroke hiding below (see `sx`) depends on `@mui/x-charts`'\n// internal `.MuiAreaElement-series-*` / `.MuiLineElement-series-*` class\n// names, which are not part of the library's public API and could change\n// on a version bump — there's no documented public hook for \"stack a series\n// but don't render its own line/fill\" as of 7.29.1.\nconst series = [\n  { id: \"lower95-base\", data: lower95, stack: \"ci95\", showMark: false, color: t.pageBg },\n  {\n    id: \"band95\",\n    label: \"95% confidence interval\",\n    data: bandWidth95,\n    stack: \"ci95\",\n    area: true,\n    showMark: false,\n    color: BRAND,\n  },\n  { id: \"lower80-base\", data: lower80, stack: \"ci80\", showMark: false, color: t.pageBg },\n  {\n    id: \"band80\",\n    label: \"80% confidence interval\",\n    data: bandWidth80,\n    stack: \"ci80\",\n    area: true,\n    showMark: false,\n    color: BRAND,\n  },\n  {\n    id: \"actual\",\n    label: \"Historical demand\",\n    data: actual,\n    showMark: false,\n    color: BRAND,\n    curve: \"monotoneX\",\n  },\n  {\n    id: \"forecast\",\n    label: \"Forecast (point estimate)\",\n    data: forecast,\n    showMark: false,\n    color: BRAND,\n    curve: \"monotoneX\",\n  },\n];\n\n// Tight y-axis floor: pad just below the lowest plotted value (across the\n// historical line and the wide 95% band) instead of a hand-picked constant,\n// so the margin below the data stays proportional as FORECAST_HORIZON changes.\nconst plottedLows = [...actual, ...lower95].filter((v) => v !== null);\nconst yMin = Math.floor((Math.min(...plottedLows) - 150) / 100) * 100;\n\nconst TITLE = \"Monthly Demand Forecast · timeseries-forecast-uncertainty · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_DEFAULT = 22;\nconst titleFontSize =\n  TITLE.length > 67 ? Math.round(TITLE_FONT_DEFAULT * (67 / TITLE.length)) : TITLE_FONT_DEFAULT;\nconst TITLE_H = 42;\nconst LEGEND_H = 34;\n\n// Hand-rolled legend: MUI X's built-in legend renders a flat, full-opacity\n// swatch per series, which can't show the 80%-vs-95% band nesting or the\n// solid-vs-dashed line distinction the spec calls for.\nfunction Legend() {\n  const items = [\n    { label: \"Historical demand\", kind: \"line-solid\" },\n    { label: \"Forecast (point estimate)\", kind: \"line-dashed\" },\n    { label: \"80% confidence interval\", kind: \"fill\", opacity: 0.42 },\n    { label: \"95% confidence interval\", kind: \"fill\", opacity: 0.2 },\n  ];\n  return (\n    <div style={{ height: LEGEND_H, display: \"flex\", alignItems: \"center\", gap: \"22px\", flexWrap: \"wrap\" }}>\n      {items.map((it) => (\n        <div key={it.label} style={{ display: \"flex\", alignItems: \"center\", gap: \"7px\" }}>\n          {it.kind === \"fill\" ? (\n            <span style={{ width: \"16px\", height: \"16px\", backgroundColor: BRAND, opacity: it.opacity, display: \"inline-block\" }} />\n          ) : (\n            <span\n              style={{\n                width: \"18px\",\n                height: 0,\n                borderTop: `3px ${it.kind === \"line-dashed\" ? \"dashed\" : \"solid\"} ${BRAND}`,\n                display: \"inline-block\",\n              }}\n            />\n          )}\n          <span style={{ fontSize: \"14px\", color: t.inkSoft }}>{it.label}</span>\n        </div>\n      ))}\n    </div>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) ------------\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const chartHeight = height - TITLE_H - LEGEND_H;\n  const Y_LABEL_W = 34;\n  const chartWidth = width - Y_LABEL_W;\n\n  return (\n    <div style={{ width, height, display: \"flex\", flexDirection: \"column\", backgroundColor: t.pageBg }}>\n      <div style={{ paddingLeft: \"84px\" }}>\n        <div\n          style={{\n            height: `${TITLE_H}px`,\n            lineHeight: `${TITLE_H}px`,\n            fontSize: `${titleFontSize}px`,\n            fontWeight: 600,\n            color: t.ink,\n          }}\n        >\n          {TITLE}\n        </div>\n        <Legend />\n      </div>\n      <div style={{ display: \"flex\", width, height: chartHeight }}>\n        <div style={{ width: Y_LABEL_W, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n          <span\n            style={{\n              display: \"inline-block\",\n              transform: \"rotate(-90deg)\",\n              whiteSpace: \"nowrap\",\n              fontSize: \"16px\",\n              color: t.ink,\n            }}\n          >\n            Demand (units / month)\n          </span>\n        </div>\n        <LineChart\n          width={chartWidth}\n          height={chartHeight}\n          skipAnimation\n          grid={{ horizontal: true }}\n          xAxis={[\n            {\n              data: dates,\n              scaleType: \"time\",\n              valueFormatter: (date) => date.toLocaleDateString(\"en-US\", { month: \"short\", year: \"2-digit\" }),\n              tickNumber: 10,\n              label: \"Month\",\n              labelStyle: { fontSize: 16 },\n              tickLabelStyle: { fontSize: 13 },\n            },\n          ]}\n          yAxis={[\n            {\n              min: yMin,\n              tickLabelStyle: { fontSize: 14 },\n              valueFormatter: (v) => v.toLocaleString(\"en-US\"),\n            },\n          ]}\n          series={series}\n          margin={{ top: 20, right: 40, bottom: 56, left: 65 }}\n          slotProps={{ legend: { hidden: true } }}\n          sx={{\n            \"& .MuiAreaElement-series-band95\": { fillOpacity: 0.2 },\n            \"& .MuiAreaElement-series-band80\": { fillOpacity: 0.42 },\n            \"& .MuiLineElement-series-lower95-base\": { display: \"none\" },\n            \"& .MuiLineElement-series-lower80-base\": { display: \"none\" },\n            \"& .MuiLineElement-series-band95\": { stroke: \"none\" },\n            \"& .MuiLineElement-series-band80\": { stroke: \"none\" },\n            \"& .MuiLineElement-series-forecast\": { strokeDasharray: \"10 6\" },\n            \"& .MuiLineElement-root\": { strokeWidth: 3 },\n            \"& .MuiChartsAxis-tickLabel\": { fill: t.inkSoft },\n            \"& .MuiChartsAxis-line\": { stroke: t.inkSoft },\n            \"& .MuiChartsGrid-line\": { stroke: t.grid, strokeWidth: 1 },\n          }}\n        >\n          <ChartsReferenceLine\n            x={dates[FORECAST_START]}\n            label=\"Forecast start\"\n            labelAlign=\"start\"\n            lineStyle={{ stroke: t.inkSoft, strokeDasharray: \"4 4\", strokeWidth: 1.5 }}\n            labelStyle={{ fontSize: 13, fill: t.inkSoft, fontStyle: \"italic\" }}\n          />\n        </LineChart>\n      </div>\n    </div>\n  );\n}\n"}