{"spec_id":"timeseries-decomposition","library":"echarts","language":"javascript","code":"// anyplot.ai\n// timeseries-decomposition: Time Series Decomposition Plot\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: 10 years of monthly e-commerce revenue (in-memory, deterministic) ---\nconst START_YEAR = 2014;\nconst NUM_MONTHS = 120; // 10 years, monthly cadence -> 10 full seasonal cycles\n\nconst dateLabels = [];\nfor (let idx = 0; idx < NUM_MONTHS; idx++) {\n  const year = START_YEAR + Math.floor(idx / 12);\n  const month = String((idx % 12) + 1).padStart(2, \"0\");\n  dateLabels.push(`${year}-${month}`);\n}\n\n// Holiday-season lift baked into the synthetic series (Jan..Dec deviation, $)\nconst SEASONAL_TRUE = [-4000, -3500, -1500, -500, 500, 1000, 500, -500, 0, 1500, 3500, 4500];\n\nlet lcgSeed = 42;\nconst nextRandom = () => {\n  lcgSeed = (lcgSeed * 1103515245 + 12345) & 0x7fffffff;\n  return lcgSeed / 0x7fffffff;\n};\n\nconst revenue = [];\nfor (let idx = 0; idx < NUM_MONTHS; idx++) {\n  const trendTrue = 28000 + 280 * idx;\n  const seasonTrue = SEASONAL_TRUE[idx % 12];\n  const noise = (nextRandom() - 0.5) * 3000;\n  revenue.push(Math.round(trendTrue + seasonTrue + noise));\n}\n\n// --- Classical additive decomposition (period = 12) --------------------------\nconst PERIOD = 12;\nconst HALF = PERIOD / 2;\n\nconst trendRaw = new Array(NUM_MONTHS).fill(null);\nfor (let idx = HALF; idx < NUM_MONTHS - HALF; idx++) {\n  let sum = 0.5 * revenue[idx - HALF] + 0.5 * revenue[idx + HALF];\n  for (let k = idx - HALF + 1; k <= idx + HALF - 1; k++) sum += revenue[k];\n  trendRaw[idx] = sum / PERIOD;\n}\n\nconst seasonalSums = new Array(PERIOD).fill(0);\nconst seasonalCounts = new Array(PERIOD).fill(0);\nfor (let idx = 0; idx < NUM_MONTHS; idx++) {\n  if (trendRaw[idx] !== null) {\n    const m = idx % PERIOD;\n    seasonalSums[m] += revenue[idx] - trendRaw[idx];\n    seasonalCounts[m] += 1;\n  }\n}\nconst seasonalRaw = seasonalSums.map((s, m) => s / seasonalCounts[m]);\nconst seasonalMean = seasonalRaw.reduce((a, b) => a + b, 0) / PERIOD;\nconst seasonalIndex = seasonalRaw.map((s) => s - seasonalMean);\n\nconst trendData = trendRaw.map((v) => (v === null ? null : Math.round(v)));\nconst seasonalData = dateLabels.map((_, idx) => Math.round(seasonalIndex[idx % PERIOD]));\nconst residualData = revenue.map((v, idx) =>\n  trendRaw[idx] === null ? null : Math.round(v - trendRaw[idx] - seasonalIndex[idx % PERIOD])\n);\n\n// --- Storytelling annotations: peak season + largest residual outlier --------\nconst seasonalPeakIdx = seasonalData.indexOf(Math.max(...seasonalData));\nlet residualPeakIdx = 0;\nlet residualPeakAbs = -Infinity;\nresidualData.forEach((v, idx) => {\n  if (v !== null && Math.abs(v) > residualPeakAbs) {\n    residualPeakAbs = Math.abs(v);\n    residualPeakIdx = idx;\n  }\n});\n\n// --- Init ---------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Shared axis/grid styling --------------------------------------------------\nconst dollarFormatter = (v) => (v < 0 ? \"-$\" : \"$\") + Math.abs(v).toLocaleString(\"en-US\");\n\nconst makeXAxis = (gridIndex, showLabels) => ({\n  type: \"category\",\n  gridIndex,\n  data: dateLabels,\n  boundaryGap: false,\n  axisLine: { show: showLabels, lineStyle: { color: t.inkSoft } },\n  axisTick: { show: false },\n  axisLabel: {\n    show: showLabels,\n    color: t.inkSoft,\n    fontSize: 14,\n    formatter: (value) => (value.endsWith(\"-01\") ? value.slice(0, 4) : \"\"),\n  },\n  splitLine: { show: true, lineStyle: { color: t.grid } },\n});\n\nconst makeYAxis = (gridIndex, name) => ({\n  type: \"value\",\n  gridIndex,\n  scale: true,\n  name,\n  nameLocation: \"middle\",\n  nameGap: 46,\n  nameRotate: 90,\n  nameTextStyle: { color: t.inkSoft, fontSize: 13 },\n  axisLine: { show: true, lineStyle: { color: t.inkSoft } },\n  axisTick: { show: false },\n  axisLabel: { color: t.inkSoft, fontSize: 14, formatter: dollarFormatter },\n  splitLine: { show: true, lineStyle: { color: t.grid } },\n});\n\nconst makePanelLabel = (top, text) => ({\n  text,\n  left: 116,\n  top,\n  textStyle: { color: t.ink, fontSize: 16, fontWeight: 600 },\n});\n\n// --- Option ---------------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  tooltip: { trigger: \"axis\" },\n  axisPointer: { link: [{ xAxisIndex: \"all\" }] },\n  title: [\n    {\n      text: \"timeseries-decomposition · javascript · echarts · anyplot.ai\",\n      left: \"center\",\n      top: 20,\n      textStyle: { color: t.ink, fontSize: 22 },\n    },\n    makePanelLabel(70, \"Original\"),\n    makePanelLabel(274, \"Trend\"),\n    makePanelLabel(478, \"Seasonal\"),\n    makePanelLabel(682, \"Residual\"),\n  ],\n  grid: [\n    { left: 116, right: 50, top: 94, height: 160 },\n    { left: 116, right: 50, top: 298, height: 160 },\n    { left: 116, right: 50, top: 502, height: 160 },\n    { left: 116, right: 50, top: 706, height: 160 },\n  ],\n  xAxis: [makeXAxis(0, false), makeXAxis(1, false), makeXAxis(2, false), makeXAxis(3, true)],\n  yAxis: [\n    makeYAxis(0, \"Revenue (USD)\"),\n    makeYAxis(1, \"Revenue (USD)\"),\n    makeYAxis(2, \"Deviation (USD)\"),\n    makeYAxis(3, \"Residual (USD)\"),\n  ],\n  series: [\n    {\n      name: \"Original\",\n      type: \"line\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      data: revenue,\n      symbol: \"none\",\n      lineStyle: { width: 3, color: t.palette[0] },\n      itemStyle: { color: t.palette[0] },\n    },\n    {\n      name: \"Trend\",\n      type: \"line\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: trendData,\n      connectNulls: false,\n      symbol: \"none\",\n      lineStyle: { width: 3, color: t.palette[1] },\n      itemStyle: { color: t.palette[1] },\n    },\n    {\n      name: \"Seasonal\",\n      type: \"line\",\n      xAxisIndex: 2,\n      yAxisIndex: 2,\n      data: seasonalData,\n      symbol: \"none\",\n      lineStyle: { width: 3, color: t.palette[2] },\n      itemStyle: { color: t.palette[2] },\n      markPoint: {\n        symbolSize: 10,\n        itemStyle: { color: t.palette[2], borderColor: t.pageBg, borderWidth: 2 },\n        label: { show: true, position: \"top\", color: t.ink, fontSize: 12, fontWeight: 600, formatter: \"Peak season\" },\n        data: [{ coord: [dateLabels[seasonalPeakIdx], seasonalData[seasonalPeakIdx]] }],\n      },\n    },\n    {\n      name: \"Residual\",\n      type: \"line\",\n      xAxisIndex: 3,\n      yAxisIndex: 3,\n      data: residualData,\n      connectNulls: false,\n      symbol: \"none\",\n      lineStyle: { width: 2, color: t.palette[3] },\n      itemStyle: { color: t.palette[3] },\n      markLine: {\n        symbol: \"none\",\n        silent: true,\n        label: { show: false },\n        lineStyle: { color: t.ink, type: \"dashed\", width: 1 },\n        data: [{ yAxis: 0 }],\n      },\n      markPoint: {\n        symbolSize: 10,\n        itemStyle: { color: t.palette[3], borderColor: t.pageBg, borderWidth: 2 },\n        label: {\n          show: true,\n          position: \"top\",\n          color: t.ink,\n          fontSize: 12,\n          fontWeight: 600,\n          formatter: \"Largest outlier\",\n        },\n        data: [{ coord: [dateLabels[residualPeakIdx], residualData[residualPeakIdx]] }],\n      },\n    },\n  ],\n});\n"}