Cycle Plot (Seasonal Subseries) — lets-plot

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 lets-plot

Python source (lets-plot)

""" anyplot.ai
line-cycle-seasonal: Cycle Plot (Seasonal Subseries)
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 86/100 | Created: 2026-06-15
"""

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_segment,
    geom_text,
    ggplot,
    ggsize,
    labs,
    scale_x_continuous,
    theme,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID_LINE = "#D8D6D0" if THEME == "light" else "#3A3A36"

BRAND = "#009E73"  # Imprint palette position 1 — always first series

# Data: monthly mean temperature (°C) for a temperate European city, 1991–2020
np.random.seed(42)
years = np.arange(1991, 2021)  # 30 years
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

# Seasonal baseline + gentle warming trend (0.045 °C/year → ~1.3 °C over 30 years)
seasonal_base = np.array([-1.0, 1.2, 5.5, 10.8, 15.6, 19.4, 21.3, 20.9, 16.7, 11.2, 5.0, 0.8])
warming = 0.045

records = []
for m_idx in range(12):
    for y_idx, year in enumerate(years):
        temp = seasonal_base[m_idx] + warming * y_idx + np.random.normal(0, 0.7)
        records.append(
            {
                "month_num": m_idx + 1,
                "month": month_names[m_idx],
                "year": year,
                "y_idx": y_idx,
                "temperature": round(temp, 2),
            }
        )

df = pd.DataFrame(records)

# x-positions: each month group occupies GROUP_WIDTH units (30 pts + 2-unit gap)
GROUP_WIDTH = 32
df["x_pos"] = (df["month_num"] - 1) * GROUP_WIDTH + df["y_idx"]

# Per-month mean reference line endpoints
means = df.groupby("month_num")["temperature"].mean().reset_index()
means["x_start"] = (means["month_num"] - 1) * GROUP_WIDTH - 0.5
means["x_end"] = means["x_start"] + 30.0
means["x_center"] = (means["month_num"] - 1) * GROUP_WIDTH + 14.5
means["label"] = means["temperature"].round(1).astype(str) + "°C"
means["label_y"] = means["temperature"] + 0.8

# Subtle vertical dividers between month groups
divider_xs = [(m * GROUP_WIDTH - 1.0) for m in range(1, 12)]
y_lo = df["temperature"].min() - 1.5
y_hi = df["temperature"].max() + 1.5
dividers = pd.DataFrame({"x": divider_xs, "xend": divider_xs, "y": [y_lo] * 11, "yend": [y_hi] * 11})

# X-axis tick at center of each month group
tick_breaks = [(m - 1) * GROUP_WIDTH + 14.5 for m in range(1, 13)]

title = "line-cycle-seasonal · python · letsplot · anyplot.ai"
# 52 chars < 67 baseline → title_size stays at default 16
title_size = 16

anyplot_theme = theme(
    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    panel_background=element_rect(fill=PAGE_BG),
    panel_border=element_blank(),
    panel_grid_major=element_line(color=GRID_LINE, size=0.25),
    panel_grid_minor=element_blank(),
    axis_title=element_text(color=INK, size=12),
    axis_text=element_text(color=INK_SOFT, size=10),
    axis_line=element_line(color=INK_SOFT),
    plot_title=element_text(color=INK, size=title_size),
    legend_position="none",
)

plot = (
    ggplot()
    # Subtle vertical dividers between month groups
    + geom_segment(data=dividers, mapping=aes(x="x", xend="xend", y="y", yend="yend"), color=INK_MUTED, size=0.4)
    # Subseries: chronological line per month
    + geom_line(data=df, mapping=aes(x="x_pos", y="temperature", group="month_num"), color=BRAND, size=0.9)
    # Seasonal mean reference lines (key visual for comparing seasons)
    + geom_segment(
        data=means, mapping=aes(x="x_start", xend="x_end", y="temperature", yend="temperature"), color=INK, size=1.5
    )
    # Mean temperature annotations — letsplot geom_text labels each season's average
    + geom_text(
        data=means,
        mapping=aes(x="x_center", y="label_y", label="label"),
        color=INK_SOFT,
        size=3.5,
        vjust=0.5,
        hjust=0.5,
    )
    + scale_x_continuous(breaks=tick_breaks, labels=month_names)
    + labs(x="Month", y="Temperature (°C)", title=title)
    + anyplot_theme
    + ggsize(800, 450)
)

ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

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

Other implementations