Cycle Plot (Seasonal Subseries) — D3.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 D3.js

JavaScript source (D3.js)

// anyplot.ai
// line-cycle-seasonal: Cycle Plot (Seasonal Subseries)
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 89/100 | Created: 2026-06-15
//# anyplot-orientation: landscape
// anyplot.ai
// line-cycle-seasonal: Cycle Plot (Seasonal Subseries)
// Library: d3 7.9.0 | JavaScript 22
// Quality: pending | Created: 2026-06-15

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

const margin = { top: 90, right: 55, bottom: 85, left: 90 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data: monthly temperature anomaly (°C), 2004–2023 ----------------------
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const N_YEARS = 20;
const START_YEAR = 2004;

let _seed = 42;
const rng = () => { _seed = (_seed * 1664525 + 1013904223) & 0xffffffff; return (_seed >>> 0) / 0xffffffff; };

// Seasonal baseline anomaly (°C) and total warming over the 20-year window per month
const BASE = [-0.08, 0.02, 0.12, 0.18, 0.15, 0.24, 0.30, 0.28, 0.20, 0.12, 0.04, -0.02];
const WARM = [ 0.55, 0.45, 0.42, 0.35, 0.30, 0.22, 0.20, 0.22, 0.35, 0.42, 0.52, 0.62];

const groups = MONTHS.map((month, mi) => {
  const pts = Array.from({ length: N_YEARS }, (_, yi) => ({
    yi,
    value: BASE[mi] + WARM[mi] * (yi / (N_YEARS - 1)) + (rng() - 0.5) * 0.28,
  }));
  return { month, mi, pts, mean: d3.mean(pts, d => d.value) };
});

// --- SVG mount --------------------------------------------------------------
const svg = d3.select("#container").append("svg")
  .attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Scales -----------------------------------------------------------------
const xBand = d3.scaleBand()
  .domain(MONTHS).range([0, iw])
  .paddingInner(0.12).paddingOuter(0.02);

const xWithin = d3.scaleLinear()
  .domain([0, N_YEARS - 1]).range([0, xBand.bandwidth()]);

const allVals = groups.flatMap(gr => gr.pts.map(d => d.value));
const [yMin, yMax] = d3.extent(allVals);
const yPad = (yMax - yMin) * 0.12;
const y = d3.scaleLinear()
  .domain([yMin - yPad, yMax + yPad]).nice()
  .range([ih, 0]);

// --- Y-axis with gridlines --------------------------------------------------
const yAx = g.append("g").call(
  d3.axisLeft(y).ticks(6).tickSize(-iw)
    .tickFormat(d => (d > 0 ? "+" : "") + d.toFixed(1) + "°C")
);
yAx.select(".domain").remove();
yAx.selectAll(".tick line").attr("stroke", t.grid).attr("stroke-dasharray", "4,3");
yAx.selectAll(".tick text")
  .attr("fill", t.inkSoft).style("font-size", "13px").attr("dx", "-4");

// Baseline zero reference
const y0 = y(0);
if (y0 >= 0 && y0 <= ih) {
  g.append("line")
    .attr("x1", 0).attr("x2", iw)
    .attr("y1", y0).attr("y2", y0)
    .attr("stroke", t.inkSoft).attr("stroke-width", 1)
    .attr("stroke-dasharray", "5,4").attr("opacity", 0.45);
}

// --- Groups -----------------------------------------------------------------
const LINE_COLOR = t.palette[0];  // Imprint green — first categorical series
const MEAN_COLOR = t.palette[1];  // Imprint lavender — seasonal mean reference lines

const lineGen = d3.line()
  .x(d => xWithin(d.yi)).y(d => y(d.value));

groups.forEach(gr => {
  const gx = xBand(gr.month);
  const gw = xBand.bandwidth();
  const gap = xBand.step() - gw;
  const gg = g.append("g").attr("transform", `translate(${gx},0)`);

  // Subtle separator between groups
  if (gr.mi > 0) {
    g.append("line")
      .attr("x1", gx - gap / 2).attr("x2", gx - gap / 2)
      .attr("y1", 0).attr("y2", ih)
      .attr("stroke", t.grid).attr("opacity", 0.7);
  }

  // Subseries line
  gg.append("path")
    .datum(gr.pts).attr("d", lineGen)
    .attr("fill", "none").attr("stroke", LINE_COLOR)
    .attr("stroke-width", 2).attr("opacity", 0.82);

  // Data dots
  gg.selectAll("circle").data(gr.pts).join("circle")
    .attr("cx", d => xWithin(d.yi)).attr("cy", d => y(d.value))
    .attr("r", 3).attr("fill", LINE_COLOR).attr("opacity", 0.72);

  // Seasonal mean line
  gg.append("line")
    .attr("x1", 0).attr("x2", gw)
    .attr("y1", y(gr.mean)).attr("y2", y(gr.mean))
    .attr("stroke", MEAN_COLOR).attr("stroke-width", 2.5);

  // Month label
  gg.append("text")
    .attr("x", gw / 2).attr("y", ih + 30)
    .attr("text-anchor", "middle")
    .attr("fill", t.inkSoft).style("font-size", "13px")
    .text(gr.month);
});

// Year range annotations
g.append("text")
  .attr("x", 0).attr("y", ih + 56)
  .attr("text-anchor", "start")
  .attr("fill", t.inkSoft).style("font-size", "11px").style("font-style", "italic")
  .text(START_YEAR);
g.append("text")
  .attr("x", iw).attr("y", ih + 56)
  .attr("text-anchor", "end")
  .attr("fill", t.inkSoft).style("font-size", "11px").style("font-style", "italic")
  .text(START_YEAR + N_YEARS - 1);

// --- Legend -----------------------------------------------------------------
const lgX = iw - 308;
[
  { label: "Annual value", color: LINE_COLOR, sw: 2 },
  { label: "Seasonal mean", color: MEAN_COLOR, sw: 2.5 },
].forEach((ld, i) => {
  const lx = lgX + i * 158;
  g.append("line")
    .attr("x1", lx).attr("x2", lx + 22)
    .attr("y1", -14).attr("y2", -14)
    .attr("stroke", ld.color).attr("stroke-width", ld.sw);
  g.append("text")
    .attr("x", lx + 28).attr("y", -9)
    .attr("fill", t.inkSoft).style("font-size", "12px")
    .text(ld.label);
});

// --- Y-axis label -----------------------------------------------------------
svg.append("text")
  .attr("transform", `translate(20,${margin.top + ih / 2}) rotate(-90)`)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft).style("font-size", "13px")
  .text("Temperature Anomaly (°C)");

// --- Title ------------------------------------------------------------------
const TITLE = "Temperature Anomaly by Month · line-cycle-seasonal · javascript · d3 · anyplot.ai";
const titlePx = Math.max(15, Math.round(22 * 67 / TITLE.length));
svg.append("text")
  .attr("x", width / 2).attr("y", 52)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", titlePx + "px").style("font-weight", "600")
  .text(TITLE);

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

Other implementations