A time series decomposition plot displays a time series broken down into its constituent components: the original series, trend, seasonal pattern, and residual noise. Each component is shown as a separate subplot stacked vertically, sharing a common time axis. This visualization is essential for understanding the underlying structure of time series data and identifying patterns that may not be visible in the raw series.

#' anyplot.ai
#' timeseries-decomposition: Time Series Decomposition Plot
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-09-09
library(ggplot2)
library(dplyr)
library(tidyr)
library(ragg)
set.seed(42)
# --- Theme tokens -------------------------------------------------------------
THEME <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED <- if (THEME == "light") "#6B6A63" else "#A8A79F"
# ggplot2 has no grid-alpha knob, so blend INK toward PAGE_BG by hand for a
# genuinely faint (~18%) gridline color instead of full-strength INK.
GRID_COLOR <- colorRampPalette(c(PAGE_BG, INK))(100)[18]
# Imprint palette (see prompts/default-style-guide.md "Categorical Palette")
IMPRINT_PALETTE <- c(
"#009E73", # 1 - brand green (Original)
"#C475FD", # 2 - lavender (Trend)
"#4467A3", # 3 - blue (Seasonal)
"#BD8233" # 4 - ochre (unused)
)
# --- Data: monthly retail sales over 10 years, additive decomposition --------
n_years <- 10
n_months <- n_years * 12
month_idx <- 0:(n_months - 1)
trend_component <- 520 + 7.5 * month_idx
seasonal_component <- 65 * sin(2 * pi * month_idx / 12) + 30 * cos(2 * pi * month_idx / 6)
noise <- rnorm(n_months, mean = 0, sd = 22)
retail_sales <- trend_component + seasonal_component + noise
dates <- seq(as.Date("2015-01-01"), by = "month", length.out = n_months)
sales_ts <- ts(retail_sales, start = c(2015, 1), frequency = 12)
decomp <- decompose(sales_ts, type = "additive")
component_levels <- c("Original", "Trend", "Seasonal", "Residual")
df <- tibble(
date = dates,
Original = as.numeric(decomp$x),
Trend = as.numeric(decomp$trend),
Seasonal = as.numeric(decomp$seasonal),
Residual = as.numeric(decomp$random)
) %>%
pivot_longer(cols = -date, names_to = "component", values_to = "value") %>%
mutate(component = factor(component, levels = component_levels))
component_colors <- c(
"Original" = IMPRINT_PALETTE[1],
"Trend" = IMPRINT_PALETTE[2],
"Seasonal" = IMPRINT_PALETTE[3],
"Residual" = INK_MUTED
)
zero_ref <- tibble(
component = factor("Residual", levels = component_levels),
yint = 0
)
# --- Title (fontsize scaled to length; baseline adjusted for the narrower
# 6in square canvas vs. the 8in landscape canvas the 67-char/12pt
# baseline was calibrated against, with an extra 0.85 trim so the title
# sits comfortably inside the panel instead of pressing against it) ----
title_text <- "timeseries-decomposition · r · ggplot2 · anyplot.ai"
title_len <- nchar(title_text)
square_baseline <- 67 * (6 / 8) * 0.85
title_fontsize <- if (title_len > square_baseline) {
round(12 * square_baseline / title_len)
} else {
12
}
title_fontsize <- max(title_fontsize, 8)
# --- Plot -----------------------------------------------------------------------
p <- ggplot(df, aes(x = date, y = value, color = component)) +
geom_line(
data = filter(df, component %in% c("Original", "Trend", "Seasonal")),
linewidth = 1.0
) +
geom_hline(
data = zero_ref, aes(yintercept = yint),
color = INK_SOFT, linewidth = 0.4, linetype = "dashed"
) +
geom_point(
data = filter(df, component == "Residual"),
size = 2.1, alpha = 0.75
) +
facet_wrap(~component, ncol = 1, scales = "free_y") +
scale_color_manual(values = component_colors, guide = "none") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = title_text,
x = "Date",
y = "Retail Sales (thousands $)"
) +
theme_minimal(base_size = 8) +
theme(
plot.background = element_rect(fill = PAGE_BG, color = PAGE_BG),
panel.background = element_rect(fill = PAGE_BG, color = NA),
panel.grid.major.x = element_blank(),
panel.grid.minor = element_blank(),
panel.grid.major.y = element_line(color = GRID_COLOR, linewidth = 0.25),
panel.spacing = unit(1.1, "lines"),
strip.background = element_rect(fill = ELEVATED_BG, color = NA),
strip.text = element_text(color = INK, size = 10, face = "bold"),
axis.title = element_text(color = INK, size = 10),
axis.text = element_text(color = INK_SOFT, size = 8),
axis.line = element_line(color = INK_SOFT),
plot.title = element_text(color = INK, size = title_fontsize, face = "bold"),
legend.position = "none"
)
# --- Save -----------------------------------------------------------------------
ggsave(
filename = sprintf("plot-%s.png", THEME),
plot = p,
device = ragg::agg_png,
width = 6,
height = 6,
units = "in",
dpi = 400
)
Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/timeseries-decomposition/ggplot2/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.
{
"spec_id": "timeseries-decomposition",
"language": "r",
"library": "ggplot2",
"page": "https://anyplot.ai/timeseries-decomposition/r/ggplot2",
"hub": "https://anyplot.ai/timeseries-decomposition",
"code_json": "https://api.anyplot.ai/specs/timeseries-decomposition/ggplot2/code",
"spec_json": "https://api.anyplot.ai/specs/timeseries-decomposition",
"render_light_png": "https://storage.googleapis.com/anyplot-images/plots/timeseries-decomposition/r/ggplot2/plot-light.png",
"render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/timeseries-decomposition/r/ggplot2/plot-dark.png",
"quality_score": 91.0,
"license": "MIT",
"guide": "https://anyplot.ai/llms.txt"
}Part of Time Series Decomposition Plot on anyplot.ai.