{"spec_id":"timeseries-decomposition","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// timeseries-decomposition: Time Series Decomposition Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: monthly retail sales, 2018-2025 (in-memory, deterministic) ------\nconst N_MONTHS = 96;\nconst START_YEAR = 2018;\nconst labels = Array.from({ length: N_MONTHS }, (_, i) => {\n  const year = START_YEAR + Math.floor(i / 12);\n  const month = (i % 12) + 1;\n  return `${year}-${String(month).padStart(2, \"0\")}`;\n});\n\n// Fixed-seed LCG -> Box-Muller gaussian (browser has no seeded RNG)\nlet lcgSeed = 42;\nfunction uniformRandom() {\n  lcgSeed = (lcgSeed * 1103515245 + 12345) % 2147483648;\n  return lcgSeed / 2147483648;\n}\nfunction gaussianNoise(std) {\n  const u1 = Math.max(uniformRandom(), 1e-9);\n  const u2 = uniformRandom();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) * std;\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\n// Underlying growth (steady) + holiday-season seasonality (Jan=0 .. Dec=11)\nconst GROWTH_BASE = 120;\nconst GROWTH_SLOPE = 1.15;\nconst SEASONAL_PATTERN = [-9, -6, -2, 1, 3, 5, 6, 4, 2, 4, 11, 18];\nconst NOISE_STD = 3.5;\n\nconst salesKUsd = Array.from({ length: N_MONTHS }, (_, i) => {\n  const growth = GROWTH_BASE + GROWTH_SLOPE * i;\n  const seasonal = SEASONAL_PATTERN[i % 12];\n  return growth + seasonal + gaussianNoise(NOISE_STD);\n});\n\n// --- Classical additive decomposition (centered 2x12 moving average) -------\nconst HALF_PERIOD = 6;\nconst trend = new Array(N_MONTHS).fill(null);\nfor (let i = HALF_PERIOD; i < N_MONTHS - HALF_PERIOD; i++) {\n  let sum = 0.5 * salesKUsd[i - HALF_PERIOD] + 0.5 * salesKUsd[i + HALF_PERIOD];\n  for (let k = -(HALF_PERIOD - 1); k <= HALF_PERIOD - 1; k++) sum += salesKUsd[i + k];\n  trend[i] = sum / 12;\n}\n\nconst detrended = salesKUsd.map((v, i) => (trend[i] === null ? null : v - trend[i]));\nconst seasonalIndex = Array.from({ length: 12 }, (_, m) => {\n  const vals = detrended.filter((v, i) => i % 12 === m && v !== null);\n  return vals.reduce((a, b) => a + b, 0) / vals.length;\n});\nconst seasonalMean = seasonalIndex.reduce((a, b) => a + b, 0) / 12;\nconst centeredSeasonalIndex = seasonalIndex.map((v) => v - seasonalMean);\nconst seasonal = Array.from({ length: N_MONTHS }, (_, i) => centeredSeasonalIndex[i % 12]);\n\nconst residual = salesKUsd.map((v, i) => (trend[i] === null ? null : v - trend[i] - seasonal[i]));\nconst zeroLine = new Array(N_MONTHS).fill(0);\n\n// --- Layout: four stacked panels sharing one time axis ----------------------\nconst container = document.getElementById(\"container\");\ncontainer.style.display = \"flex\";\ncontainer.style.flexDirection = \"column\";\ncontainer.style.boxSizing = \"border-box\";\ncontainer.style.padding = \"10px 22px 4px\";\ncontainer.style.backgroundColor = t.pageBg;\n\nconst PANELS = [\n  { key: \"original\", title: \"Original\", axisLabel: \"Sales ($k)\", data: salesKUsd, kind: \"line\", showMainTitle: true, flex: 1.2 },\n  { key: \"trend\", title: \"Trend\", axisLabel: \"Sales ($k)\", data: trend, kind: \"line\", showMainTitle: false, flex: 1 },\n  { key: \"seasonal\", title: \"Seasonal\", axisLabel: \"Effect ($k)\", data: seasonal, kind: \"line\", showMainTitle: false, flex: 1 },\n  { key: \"residual\", title: \"Residual\", axisLabel: \"Sales ($k)\", data: residual, kind: \"points\", showMainTitle: false, flex: 1 },\n];\n\nPANELS.forEach((panel, idx) => {\n  const row = document.createElement(\"div\");\n  row.style.flex = `${panel.flex} 1 0`;\n  row.style.minHeight = \"0\";\n  row.style.position = \"relative\";\n  row.style.borderBottom = idx < PANELS.length - 1 ? `1px solid ${t.grid}` : \"none\";\n  row.style.paddingBottom = idx < PANELS.length - 1 ? \"4px\" : \"0\";\n  container.appendChild(row);\n\n  const canvas = document.createElement(\"canvas\");\n  row.appendChild(canvas);\n\n  const isBottom = idx === PANELS.length - 1;\n  const isOriginal = panel.key === \"original\";\n  const datasets = [\n    {\n      label: panel.title,\n      data: panel.data,\n      borderColor: t.palette[0],\n      backgroundColor: isOriginal\n        ? (context) => {\n            const { chartArea, ctx } = context.chart;\n            if (!chartArea) return hexToRgba(t.palette[0], 0.2);\n            const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);\n            gradient.addColorStop(0, hexToRgba(t.palette[0], 0.3));\n            gradient.addColorStop(1, hexToRgba(t.palette[0], 0.02));\n            return gradient;\n          }\n        : t.palette[0],\n      borderWidth: panel.kind === \"line\" ? 3 : 0,\n      showLine: panel.kind === \"line\",\n      pointRadius: panel.kind === \"line\" ? 0 : 4,\n      pointHoverRadius: 0,\n      spanGaps: false,\n      tension: 0.15,\n      fill: isOriginal,\n    },\n  ];\n  if (panel.key === \"residual\") {\n    datasets.push({\n      label: \"Zero reference\",\n      data: zeroLine,\n      borderColor: t.ink,\n      borderWidth: 1.5,\n      borderDash: [6, 5],\n      pointRadius: 0,\n      showLine: true,\n    });\n  }\n\n  new Chart(canvas, {\n    type: \"line\",\n    data: { labels, datasets },\n    options: {\n      responsive: true,\n      maintainAspectRatio: false,\n      animation: false,\n      plugins: {\n        title: {\n          display: panel.showMainTitle,\n          text: \"timeseries-decomposition · javascript · chartjs · anyplot.ai\",\n          color: t.ink,\n          font: { size: 22, weight: \"500\" },\n          padding: { bottom: 8 },\n        },\n        subtitle: {\n          display: true,\n          text: panel.title,\n          color: t.ink,\n          align: \"start\",\n          font: { size: 19, weight: \"600\" },\n          padding: { bottom: 6 },\n        },\n        legend: { display: false },\n      },\n      scales: {\n        x: {\n          ticks: {\n            display: isBottom,\n            color: t.inkSoft,\n            font: { size: 15 },\n            maxRotation: 0,\n            autoSkip: true,\n            maxTicksLimit: 12,\n          },\n          grid: { color: t.grid, drawTicks: false },\n          title: { display: isBottom, text: \"Month\", color: t.ink, font: { size: 17 } },\n        },\n        y: {\n          ticks: { color: t.inkSoft, font: { size: 15 } },\n          grid: { display: false },\n          title: { display: true, text: panel.axisLabel, color: t.ink, font: { size: 17 } },\n        },\n      },\n    },\n  });\n});\n"}