{"spec_id":"timeseries-forecast-uncertainty","library":"d3","language":"javascript","code":"// anyplot.ai\n// timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 60, bottom: 80, left: 120 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Monthly cloud-hosting spend: 4 years (48 months) of observed history plus a\n// 12-month forecast. The forecast series starts one month before its horizon\n// begins (index 47) so the historical and forecast lines join seamlessly.\nlet seed = 20260902;\nfunction lcg() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction gaussian() {\n  // Box-Muller, using the deterministic LCG above.\n  const u1 = Math.max(lcg(), 1e-9);\n  const u2 = lcg();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst historicalMonths = 48;\nconst forecastHorizon = 12;\nconst totalMonths = historicalMonths + forecastHorizon;\nconst forecastStart = historicalMonths - 1; // last historical month == first forecast month\n\nconst dates = Array.from({ length: totalMonths }, (_, i) => new Date(2023, i, 1));\n\nconst base = 42000; // USD / month\nconst monthlyGrowth = 190;\nconst seasonalAmplitude = 2800;\nconst noiseStd = 1100;\nconst sigmaBase = 750; // per-step forecast-uncertainty growth\n\nfunction centralValue(i) {\n  return base + monthlyGrowth * i + seasonalAmplitude * Math.sin((2 * Math.PI * i) / 12 - 1.2);\n}\n\nconst data = dates.map((date, i) => {\n  const central = centralValue(i);\n  const row = { date, actual: null, forecast: null, lower_80: null, upper_80: null, lower_95: null, upper_95: null };\n  if (i <= historicalMonths - 1) {\n    row.actual = Math.round(central + gaussian() * noiseStd);\n  }\n  if (i >= forecastStart) {\n    const h = i - forecastStart;\n    const sigma = sigmaBase * Math.sqrt(h);\n    row.forecast = Math.round(central);\n    row.lower_80 = Math.round(central - 1.2816 * sigma);\n    row.upper_80 = Math.round(central + 1.2816 * sigma);\n    row.lower_95 = Math.round(central - 1.96 * sigma);\n    row.upper_95 = Math.round(central + 1.96 * sigma);\n  }\n  return row;\n});\n\n// --- Colors ------------------------------------------------------------------\nconst historicalColor = t.palette[0]; // brand green — observed data\nconst forecastColor = t.palette[1]; // canonical position 2 — prediction + uncertainty family\n\n// --- SVG mount -----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales --------------------------------------------------------------------\nconst x = d3.scaleTime().domain(d3.extent(dates)).range([0, iw]);\nconst values = data.flatMap((d) => [d.actual, d.forecast, d.lower_95, d.upper_95]).filter((v) => v != null);\nconst y = d3\n  .scaleLinear()\n  .domain([d3.min(values) * 0.97, d3.max(values) * 1.03])\n  .nice()\n  .range([ih, 0]);\n\n// --- Gridlines -------------------------------------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Confidence bands (95% wider + lighter underneath, 80% narrower + darker on top) ---\nconst area95 = d3\n  .area()\n  .defined((d) => d.lower_95 != null)\n  .x((d) => x(d.date))\n  .y0((d) => y(d.lower_95))\n  .y1((d) => y(d.upper_95))\n  .curve(d3.curveMonotoneX);\n\nconst area80 = d3\n  .area()\n  .defined((d) => d.lower_80 != null)\n  .x((d) => x(d.date))\n  .y0((d) => y(d.lower_80))\n  .y1((d) => y(d.upper_80))\n  .curve(d3.curveMonotoneX);\n\ng.append(\"path\").datum(data).attr(\"d\", area95).attr(\"fill\", forecastColor).attr(\"fill-opacity\", 0.16).attr(\"stroke\", \"none\");\ng.append(\"path\").datum(data).attr(\"d\", area80).attr(\"fill\", forecastColor).attr(\"fill-opacity\", 0.32).attr(\"stroke\", \"none\");\n\n// --- Axes ------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(d3.timeMonth.every(6)).tickFormat(d3.timeFormat(\"%b %Y\")));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6).tickFormat(d3.format(\"$,.0f\")));\n\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\nxAxis.selectAll(\"text\").attr(\"dy\", \"1.4em\");\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 62)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Month\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -90)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Monthly Hosting Cost (USD)\");\n\n// --- Forecast-start marker ---------------------------------------------------\nconst markerX = x(dates[forecastStart]);\ng.append(\"line\")\n  .attr(\"x1\", markerX)\n  .attr(\"x2\", markerX)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"5,5\")\n  .attr(\"opacity\", 0.6);\n\ng.append(\"text\")\n  .attr(\"x\", markerX + 10)\n  .attr(\"y\", 16)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"Forecast start\");\n\n// --- Lines: solid historical, dashed forecast (drawn above the bands) --------\nconst lineActual = d3\n  .line()\n  .defined((d) => d.actual != null)\n  .x((d) => x(d.date))\n  .y((d) => y(d.actual))\n  .curve(d3.curveMonotoneX);\n\nconst lineForecast = d3\n  .line()\n  .defined((d) => d.forecast != null)\n  .x((d) => x(d.date))\n  .y((d) => y(d.forecast))\n  .curve(d3.curveMonotoneX);\n\ng.append(\"path\").datum(data).attr(\"d\", lineForecast).attr(\"fill\", \"none\").attr(\"stroke\", forecastColor).attr(\"stroke-width\", 3).attr(\"stroke-dasharray\", \"8,5\");\ng.append(\"path\").datum(data).attr(\"d\", lineActual).attr(\"fill\", \"none\").attr(\"stroke\", historicalColor).attr(\"stroke-width\", 3);\n\n// --- Legend (floating card, top-left of the plot area) -----------------------\nconst legendItems = [\n  { label: \"Historical\", type: \"line\", color: historicalColor, dash: null },\n  { label: \"Forecast\", type: \"line\", color: forecastColor, dash: \"8,5\" },\n  { label: \"80% confidence\", type: \"swatch\", color: forecastColor, opacity: 0.32 },\n  { label: \"95% confidence\", type: \"swatch\", color: forecastColor, opacity: 0.16 },\n];\n\nconst legend = g.append(\"g\").attr(\"transform\", `translate(16, 16)`);\nlegend\n  .append(\"rect\")\n  .attr(\"width\", 190)\n  .attr(\"height\", legendItems.length * 30 + 14)\n  .attr(\"rx\", 8)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"opacity\", 0.92);\n\nconst legendRows = legend\n  .selectAll(\"g.row\")\n  .data(legendItems)\n  .join(\"g\")\n  .attr(\"class\", \"row\")\n  .attr(\"transform\", (_, i) => `translate(16, ${20 + i * 30})`);\n\nlegendRows.each(function (d) {\n  const row = d3.select(this);\n  if (d.type === \"line\") {\n    row\n      .append(\"line\")\n      .attr(\"x1\", 0)\n      .attr(\"x2\", 24)\n      .attr(\"y1\", 0)\n      .attr(\"y2\", 0)\n      .attr(\"stroke\", d.color)\n      .attr(\"stroke-width\", 3)\n      .attr(\"stroke-dasharray\", d.dash);\n  } else {\n    row.append(\"rect\").attr(\"x\", 0).attr(\"y\", -8).attr(\"width\", 24).attr(\"height\", 16).attr(\"rx\", 3).attr(\"fill\", d.color).attr(\"fill-opacity\", d.opacity);\n  }\n  row.append(\"text\").attr(\"x\", 32).attr(\"y\", 5).attr(\"fill\", t.ink).style(\"font-size\", \"14px\").text(d.label);\n});\n\n// --- Title -------------------------------------------------------------------\nconst title = \"Cloud Hosting Costs · timeseries-forecast-uncertainty · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(14, Math.round(22 * Math.min(1, 67 / title.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}