Cycle Plot (Seasonal Subseries) — Chart.js

A cycle plot, also known as a seasonal subseries plot (introduced by William Cleveland), separates the seasonal pattern of a time series from the trend within each season. The series is split by seasonal period (e.g. one group per month or weekday); within each group the values are drawn in chronological order as a small line, and a horizontal reference line marks that group's mean. Comparing the mean lines across groups reveals the seasonal effect, while the slope of each subseries reveals the trend within that season — two patterns that are entangled in a standard time series plot.

Cycle Plot (Seasonal Subseries) rendered with Chart.js

JavaScript source (Chart.js)

// anyplot.ai
// line-cycle-seasonal: Cycle Plot (Seasonal Subseries)
// Library: chartjs 4.4.7 | JavaScript 22.22.3
// Quality: 92/100 | Created: 2026-06-15
//# anyplot-orientation: landscape

const t = window.ANYPLOT_TOKENS;

// --- Data -------------------------------------------------------------------
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const BASE_TEMPS = [2.1, 4.3, 8.7, 14.2, 19.1, 23.8, 26.2, 25.4, 20.3, 13.9, 7.8, 2.9];
const N_YEARS = 20;
const YEAR_START = 2000;
const GROUP_W = N_YEARS;
const GAP = 3;

// Park-Miller LCG — deterministic, no Date/Math.random
let _seed = 12345;
const rng = () => { _seed = (_seed * 48271) % 2147483647; return _seed / 2147483647; };

const gs = m => m * (GROUP_W + GAP);

// Monthly average temperatures across 20 years with warming trend + noise
const temps = MONTHS.map((_, m) =>
  Array.from({ length: N_YEARS }, (_, y) => {
    const trend = (y / (N_YEARS - 1)) * 1.8;
    const noise = (rng() - 0.5) * 2.4;
    return +(BASE_TEMPS[m] + trend + noise).toFixed(2);
  })
);

const means = temps.map(arr => arr.reduce((a, b) => a + b, 0) / arr.length);

// --- Mount ------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Datasets ---------------------------------------------------------------
// One scatter+line dataset per month (subseries)
const subDatasets = MONTHS.map((_, m) => ({
  label: MONTHS[m],
  data: temps[m].map((v, y) => ({ x: gs(m) + y, y: v })),
  showLine: true,
  tension: 0,
  borderColor: t.palette[0],
  backgroundColor: t.palette[0],
  borderWidth: 2,
  pointRadius: 3.5,
  pointHoverRadius: 0,
}));

// One 2-point line per month for the horizontal mean reference
const meanDatasets = MONTHS.map((_, m) => ({
  label: `_mean${m}`,
  data: [
    { x: gs(m), y: means[m] },
    { x: gs(m) + N_YEARS - 1, y: means[m] },
  ],
  showLine: true,
  tension: 0,
  borderColor: t.inkSoft,
  backgroundColor: "transparent",
  borderWidth: 2.5,
  pointRadius: 0,
}));

// --- Title ------------------------------------------------------------------
const TITLE = "Monthly Temperature Cycles · line-cycle-seasonal · javascript · chartjs · anyplot.ai";
const titleSize = Math.max(13, Math.round(22 * 67 / TITLE.length));

// --- Plugin: range bands (behind data), separators, month labels ------------
const groupPlugin = {
  id: "cycleGroups",
  // Draw min-max range envelope behind the subseries lines
  beforeDatasetsDraw(chart) {
    const { ctx } = chart;
    const xScale = chart.scales.x;
    const yScale = chart.scales.y;
    ctx.save();
    MONTHS.forEach((_, m) => {
      const vals = temps[m];
      const minV = Math.min(...vals);
      const maxV = Math.max(...vals);
      const x0 = xScale.getPixelForValue(gs(m));
      const x1 = xScale.getPixelForValue(gs(m) + N_YEARS - 1);
      const yTop = yScale.getPixelForValue(maxV);
      const yBot = yScale.getPixelForValue(minV);
      ctx.fillStyle = "rgba(0,158,115,0.08)";
      ctx.fillRect(x0, yTop, x1 - x0, yBot - yTop);
    });
    ctx.restore();
  },
  afterDraw(chart) {
    const { ctx, chartArea } = chart;
    const xScale = chart.scales.x;
    ctx.save();

    MONTHS.forEach((month, m) => {
      const x0 = xScale.getPixelForValue(gs(m));
      const x1 = xScale.getPixelForValue(gs(m) + N_YEARS - 1);

      // Subtle vertical separator between groups
      if (m > 0) {
        const prevX1 = xScale.getPixelForValue(gs(m - 1) + N_YEARS - 1);
        const sepX = (prevX1 + x0) / 2;
        ctx.beginPath();
        ctx.strokeStyle = t.grid;
        ctx.lineWidth = 1;
        ctx.moveTo(sepX, chartArea.top);
        ctx.lineTo(sepX, chartArea.bottom);
        ctx.stroke();
      }

      // Month label drawn below the x-axis tick labels
      ctx.font = "bold 14px system-ui, sans-serif";
      ctx.fillStyle = t.ink;
      ctx.textAlign = "center";
      ctx.textBaseline = "top";
      ctx.fillText(month, (x0 + x1) / 2, xScale.bottom + 6);
    });

    ctx.restore();
  },
};

// --- Chart ------------------------------------------------------------------
new Chart(canvas, {
  type: "scatter",
  plugins: [groupPlugin],
  data: { datasets: [...subDatasets, ...meanDatasets] },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: { top: 16, right: 30, bottom: 60, left: 10 } },
    plugins: {
      title: {
        display: true,
        text: TITLE,
        color: t.ink,
        font: { size: titleSize, weight: "600" },
        padding: { top: 8, bottom: 4 },
      },
      subtitle: {
        display: true,
        text: "Upward slopes in every group reveal consistent +1.8°C warming across all seasons (2000–2019)",
        color: t.inkSoft,
        font: { size: Math.max(11, Math.round(titleSize * 0.78)), style: "italic" },
        padding: { bottom: 14 },
      },
      legend: {
        display: true,
        labels: {
          color: t.ink,
          font: { size: 13 },
          boxWidth: 30,
          padding: 16,
          generateLabels: () => [
            {
              text: "Annual subseries",
              fillStyle: t.palette[0],
              strokeStyle: t.palette[0],
              fontColor: t.ink,
              lineWidth: 2,
              hidden: false,
              datasetIndex: 0,
            },
            {
              text: "Monthly mean",
              fillStyle: "transparent",
              strokeStyle: t.inkSoft,
              fontColor: t.ink,
              lineWidth: 2.5,
              hidden: false,
              datasetIndex: 12,
            },
          ],
        },
      },
      tooltip: { enabled: false },
    },
    scales: {
      x: {
        type: "linear",
        min: gs(0) - 1,
        max: gs(11) + N_YEARS,
        afterBuildTicks(scale) {
          const ticks = [];
          for (let m = 0; m < 12; m++) {
            ticks.push({ value: gs(m) });
          }
          scale.ticks = ticks;
        },
        ticks: {
          color: t.inkSoft,
          font: { size: 13 },
          maxRotation: 0,
          callback(value) {
            for (let m = 0; m < 12; m++) {
              if (value === gs(m)) return `${YEAR_START}`;
            }
            return null;
          },
        },
        grid: { display: false },
        border: { display: false },
      },
      y: {
        ticks: {
          color: t.inkSoft,
          font: { size: 13 },
          callback: v => `${v.toFixed(0)}°C`,
        },
        grid: { color: t.grid },
        title: {
          display: true,
          text: "Avg Monthly Temperature (°C)",
          color: t.ink,
          font: { size: 14 },
        },
        border: { display: false },
      },
    },
  },
});

Part of Cycle Plot (Seasonal Subseries) on anyplot.ai.

Other implementations