{"spec_id":"timeseries-decomposition","library":"d3","language":"javascript","code":"// anyplot.ai\n// timeseries-decomposition: Time Series Decomposition Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\nconst theme = window.ANYPLOT_THEME === \"dark\" ? \"dark\" : \"light\";\nconst muted = theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic LCG — no seeded RNG in the browser) -----\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nfunction approxNormal(rand) {\n  // Irwin-Hall sum-of-12-uniforms approximation of a standard normal draw\n  let sum = 0;\n  for (let i = 0; i < 12; i += 1) sum += rand();\n  return sum - 6;\n}\n\nconst PERIOD = 12; // monthly seasonality\nconst N = 10 * PERIOD; // 10 years of monthly retail sales\n\nconst rand = lcg(7);\nconst dates = [];\nconst sales = [];\n// Holiday-shopping seasonal shape: soft summer dip, sharp Nov/Dec spike\nconst SEASONAL_SHAPE = [\n  -3200, -2600, -800, 400, 1200, 1800, 900, -400, -1600, -600, 4200, 8600,\n];\nfor (let i = 0; i < N; i += 1) {\n  const d = new Date(Date.UTC(2015, i, 1));\n  dates.push(d);\n  const trendComponent = 42000 + 9500 * Math.log1p(i); // decelerating, saturating growth\n  const seasonalComponent = SEASONAL_SHAPE[d.getUTCMonth()];\n  const noiseComponent = 900 * approxNormal(rand);\n  sales.push(trendComponent + seasonalComponent + noiseComponent);\n}\n\n// --- Additive decomposition (centered moving average + seasonal averaging) --\nfunction centeredMovingAverage(values, period) {\n  const half = period / 2;\n  const out = new Array(values.length).fill(null);\n  for (let i = half; i < values.length - half; i += 1) {\n    let sum = values[i - half] * 0.5 + values[i + half] * 0.5;\n    for (let j = i - half + 1; j <= i + half - 1; j += 1) sum += values[j];\n    out[i] = sum / period;\n  }\n  return out;\n}\n\nconst trend = centeredMovingAverage(sales, PERIOD);\n\nconst seasonalSums = new Array(PERIOD).fill(0);\nconst seasonalCounts = new Array(PERIOD).fill(0);\nfor (let i = 0; i < N; i += 1) {\n  if (trend[i] === null) continue;\n  const idx = i % PERIOD;\n  seasonalSums[idx] += sales[i] - trend[i];\n  seasonalCounts[idx] += 1;\n}\nconst seasonalRaw = seasonalSums.map((s, idx) => s / seasonalCounts[idx]);\nconst seasonalMean = d3.mean(seasonalRaw);\nconst seasonalIndex = seasonalRaw.map((s) => s - seasonalMean);\nconst seasonal = dates.map((d) => seasonalIndex[d.getUTCMonth()]);\nconst residual = sales.map((v, i) =>\n  trend[i] === null ? null : v - trend[i] - seasonal[i],\n);\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 100, right: 60, bottom: 70, left: 130 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst panelGap = 28;\n\nconst panels = [\n  { key: \"original\", label: \"Original\", color: t.palette[0], values: sales },\n  { key: \"trend\", label: \"Trend\", color: t.palette[2], values: trend },\n  { key: \"seasonal\", label: \"Seasonal\", color: t.palette[1], values: seasonal },\n  { key: \"residual\", label: \"Residual\", color: muted, values: residual },\n];\nconst panelHeight = (ih - panelGap * (panels.length - 1)) / panels.length;\n\nconst x = d3.scaleUtc().domain(d3.extent(dates)).range([0, iw]);\nconst xTicks = d3.utcYear.every(1).range(dates[0], dates[N - 1]);\n\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\n// --- Title ----------------------------------------------------------------\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\", \"27px\")\n  .style(\"font-weight\", \"700\")\n  .text(\"timeseries-decomposition · javascript · d3 · anyplot.ai\");\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\n    \"Monthly retail sales, decomposed into trend, seasonal, and residual components\",\n  );\n\n// --- Panels -----------------------------------------------------------------\nconst panelGroups = svg\n  .selectAll(\".panel\")\n  .data(panels)\n  .join(\"g\")\n  .attr(\"class\", \"panel\")\n  .attr(\n    \"transform\",\n    (panel, i) =>\n      `translate(${margin.left},${margin.top + i * (panelHeight + panelGap)})`,\n  );\n\npanelGroups.each(function (panel, i) {\n  const isLast = i === panels.length - 1;\n  const g = d3.select(this);\n\n  const points = dates.map((d, j) => ({ date: d, value: panel.values[j] }));\n  const defined = points.filter((p) => p.value !== null);\n  let [lo, hi] = d3.extent(defined, (p) => p.value);\n  if (panel.key === \"seasonal\" || panel.key === \"residual\") {\n    const span = Math.max(Math.abs(lo), Math.abs(hi));\n    lo = -span;\n    hi = span;\n  }\n  const y = d3.scaleLinear().domain([lo, hi]).nice().range([panelHeight, 0]);\n\n  // Vertical gridlines shared across panels — trace one date through all four\n  g.selectAll(\".gridline\")\n    .data(xTicks)\n    .join(\"line\")\n    .attr(\"x1\", (d) => x(d))\n    .attr(\"x2\", (d) => x(d))\n    .attr(\"y1\", 0)\n    .attr(\"y2\", panelHeight)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1);\n\n  // Zero reference line for signed components\n  if (lo < 0 && hi > 0) {\n    g.append(\"line\")\n      .attr(\"x1\", 0)\n      .attr(\"x2\", iw)\n      .attr(\"y1\", y(0))\n      .attr(\"y2\", y(0))\n      .attr(\"stroke\", t.inkSoft)\n      .attr(\"stroke-width\", 1)\n      .attr(\"stroke-dasharray\", \"4,4\")\n      .attr(\"opacity\", 0.6);\n  }\n\n  if (panel.key === \"residual\") {\n    g.selectAll(\".resid-stem\")\n      .data(defined)\n      .join(\"line\")\n      .attr(\"x1\", (d) => x(d.date))\n      .attr(\"x2\", (d) => x(d.date))\n      .attr(\"y1\", y(0))\n      .attr(\"y2\", (d) => y(d.value))\n      .attr(\"stroke\", panel.color)\n      .attr(\"stroke-width\", 1.5)\n      .attr(\"opacity\", 0.55);\n    g.selectAll(\".resid-dot\")\n      .data(defined)\n      .join(\"circle\")\n      .attr(\"cx\", (d) => x(d.date))\n      .attr(\"cy\", (d) => y(d.value))\n      .attr(\"r\", 3.2)\n      .attr(\"fill\", panel.color);\n  } else {\n    const line = d3\n      .line()\n      .defined((d) => d.value !== null)\n      .x((d) => x(d.date))\n      .y((d) => y(d.value))\n      .curve(d3.curveMonotoneX);\n    g.append(\"path\")\n      .datum(points)\n      .attr(\"fill\", \"none\")\n      .attr(\"stroke\", panel.color)\n      .attr(\"stroke-width\", panel.key === \"original\" ? 3 : 2.5)\n      .attr(\"d\", line);\n\n    if (panel.key === \"seasonal\") {\n      // Callout on a representative holiday peak to sharpen the data story\n      const peakIndex = 4 * PERIOD + 11; // December, mid-series (avoids edge crowding)\n      const peakDate = dates[peakIndex];\n      const peakValue = seasonal[peakIndex];\n      g.append(\"circle\")\n        .attr(\"cx\", x(peakDate))\n        .attr(\"cy\", y(peakValue))\n        .attr(\"r\", 5)\n        .attr(\"fill\", \"none\")\n        .attr(\"stroke\", t.amber)\n        .attr(\"stroke-width\", 2);\n      g.append(\"text\")\n        .attr(\"x\", x(peakDate))\n        .attr(\"y\", y(peakValue) - 12)\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"fill\", t.amber)\n        .style(\"font-size\", \"12px\")\n        .style(\"font-weight\", \"600\")\n        .text(\"Holiday peak\");\n    }\n  }\n\n  // Y axis\n  const yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(4).tickSize(4));\n  yAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\n  yAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  yAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n  // X axis — tick labels only on the bottom panel, keep the domain line on all\n  const xAxisG = g\n    .append(\"g\")\n    .attr(\"transform\", `translate(0,${panelHeight})`)\n    .call(\n      d3\n        .axisBottom(x)\n        .tickValues(xTicks)\n        .tickFormat(isLast ? d3.utcFormat(\"%Y\") : () => \"\")\n        .tickSize(isLast ? 4 : 0),\n    );\n  xAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  xAxisG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  xAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n  // Panel label\n  g.append(\"text\")\n    .attr(\"x\", 0)\n    .attr(\"y\", -8)\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"17px\")\n    .style(\"font-weight\", \"600\")\n    .text(panel.label);\n});\n\n// --- Shared x-axis label -----------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Date\");\n\n// --- Shared y-axis unit label -------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 30)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Sales ($)\");\n"}