{"spec_id":"timeseries-decomposition","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// timeseries-decomposition: Time Series Decomposition Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n// \"muted\" semantic anchor (other/rest) — not in ANYPLOT_TOKENS, derive from theme\n// per prompts/default-style-guide.md \"Theme-adaptive Chrome\".\nconst INK_MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Monthly retail sales index, 8 years — long enough for several seasonal cycles.\nconst N_MONTHS = 96;\nconst START_YEAR = 2017;\nconst PERIOD = 12;\nconst BASE_LEVEL = 220;\nconst MONTHLY_GROWTH = 0.85;\n// Holiday-retail seasonal shape: winter dip, autumn/holiday build-up.\nconst SEASONAL_PATTERN = [-9, -11, -5, 1, 3, 5, 2, 0, -3, 4, 14, 19];\n\nlet lcgSeed = 42;\nfunction nextRandom() {\n  lcgSeed = (lcgSeed * 1664525 + 1013904223) % 4294967296;\n  return lcgSeed / 4294967296;\n}\n\nconst timestamps = [];\nconst values = [];\nfor (let i = 0; i < N_MONTHS; i++) {\n  const year = START_YEAR + Math.floor(i / PERIOD);\n  const month = i % PERIOD;\n  timestamps.push(Date.UTC(year, month, 1));\n\n  const trendTrue = BASE_LEVEL + MONTHLY_GROWTH * i;\n  const seasonalTrue = SEASONAL_PATTERN[month];\n  const noise = (nextRandom() - 0.5) * 8;\n  values.push(trendTrue + seasonalTrue + noise);\n}\n\n// --- Additive decomposition (centered 2x12 moving average) -----------------\nconst half = PERIOD / 2;\nconst trend = new Array(N_MONTHS).fill(null);\nfor (let i = half; i <= N_MONTHS - 1 - half; i++) {\n  let sum = 0.5 * values[i - half] + 0.5 * values[i + half];\n  for (let k = i - half + 1; k <= i + half - 1; k++) sum += values[k];\n  trend[i] = sum / PERIOD;\n}\n\nconst seasonalSums = new Array(PERIOD).fill(0);\nconst seasonalCounts = new Array(PERIOD).fill(0);\nfor (let i = 0; i < N_MONTHS; i++) {\n  if (trend[i] === null) continue;\n  const m = i % PERIOD;\n  seasonalSums[m] += values[i] - trend[i];\n  seasonalCounts[m] += 1;\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 seasonal = timestamps.map((_, i) => seasonalIndex[i % PERIOD]);\nconst residual = values.map((v, i) => (trend[i] === null ? null : v - trend[i] - seasonal[i]));\n\nconst originalSeries = timestamps.map((ts, i) => [ts, values[i]]);\nconst trendSeries = timestamps.map((ts, i) => [ts, trend[i]]);\nconst seasonalSeries = timestamps.map((ts, i) => [ts, seasonal[i]]);\nconst residualSeries = timestamps.map((ts, i) => [ts, residual[i]]);\n\n// --- Chart -------------------------------------------------------------------\n// Typographic hierarchy: \"Original\" is the primary reference series, so its\n// pane label reads bolder/larger; the three derived components share a\n// lighter secondary style.\nconst paneTitlePrimary = { color: t.ink, fontSize: \"17px\", fontWeight: \"700\" };\nconst paneTitleSecondary = { color: t.inkSoft, fontSize: \"13px\", fontWeight: \"500\" };\nconst axisLabelStyle = { color: t.inkSoft, fontSize: \"14px\" };\n\nfunction pane(top, text, primary) {\n  return {\n    top,\n    height: \"19%\",\n    offset: 0,\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    lineWidth: 0,\n    tickWidth: 0,\n    labels: { style: axisLabelStyle },\n    title: {\n      text,\n      align: \"high\",\n      rotation: 0,\n      textAlign: \"left\",\n      x: 0,\n      y: -8,\n      style: primary ? paneTitlePrimary : paneTitleSecondary,\n    },\n  };\n}\n\nHighcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    spacingTop: 12,\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"timeseries-decomposition · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Additive decomposition · 12-month centered moving average\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    type: \"datetime\",\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: { style: axisLabelStyle },\n    // Focal-point annotation: highlight one full seasonal cycle (2020) as a\n    // reference band spanning all four panes — the \"neutral\" semantic anchor\n    // (baseline/reference role) rendered as a low-opacity tint of the ink color.\n    plotBands: [\n      {\n        from: Date.UTC(2020, 0, 1),\n        to: Date.UTC(2021, 0, 1),\n        color: Highcharts.color(t.ink).setOpacity(0.05).get(),\n        label: {\n          text: \"One full seasonal cycle\",\n          align: \"center\",\n          verticalAlign: \"top\",\n          y: 14,\n          style: { color: t.inkSoft, fontSize: \"12px\", fontWeight: \"600\" },\n        },\n      },\n    ],\n  },\n  yAxis: [\n    pane(\"3%\", \"Original\", true),\n    pane(\"27%\", \"Trend\", false),\n    pane(\"51%\", \"Seasonal\", false),\n    pane(\"75%\", \"Residual\", false),\n  ],\n  legend: { enabled: false },\n  tooltip: {\n    shared: true,\n    xDateFormat: \"%b %Y\",\n    backgroundColor: t.elevatedBg,\n    style: { color: t.ink },\n  },\n  plotOptions: {\n    series: { animation: false, marker: { enabled: false } },\n    column: { borderWidth: 0 },\n  },\n  series: [\n    {\n      name: \"Original\",\n      type: \"line\",\n      yAxis: 0,\n      data: originalSeries,\n      color: t.palette[0],\n      lineWidth: 2.5,\n    },\n    {\n      name: \"Trend\",\n      type: \"line\",\n      yAxis: 1,\n      data: trendSeries,\n      color: t.palette[2],\n      lineWidth: 3,\n    },\n    {\n      name: \"Seasonal\",\n      type: \"line\",\n      yAxis: 2,\n      data: seasonalSeries,\n      color: t.palette[1],\n      lineWidth: 2.5,\n    },\n    {\n      name: \"Residual\",\n      type: \"column\",\n      yAxis: 3,\n      data: residualSeries,\n      color: INK_MUTED,\n    },\n  ],\n});\n"}