Horizon Chart — Seaborn

A horizon chart displays many time series compactly by folding values into color-coded bands, preserving local resolution while minimizing vertical space. It divides the y-axis into bands and uses color intensity to encode magnitude, allowing dozens of series to be compared in limited space. This technique is particularly effective when monitoring many metrics simultaneously where traditional line charts would become unreadable.

Horizon Chart rendered with Seaborn

Python source (Seaborn)

""" anyplot.ai
horizon-basic: Horizon Chart
Library: seaborn 0.13.2 | Python 3.13.13
Quality: 78/100 | Updated: 2026-05-07
"""

import os

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd


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"

# Data - Stock price deviations from 20-day moving average (5 stocks over 90 trading days)
np.random.seed(42)
trading_days = 90
stocks = ["TECH", "FINANCE", "ENERGY", "HEALTHCARE", "RETAIL"]

# Generate realistic stock deviation patterns with seasonality
data = []
for stock_idx, stock in enumerate(stocks):
    np.random.seed(42 + stock_idx)
    if stock == "TECH":
        base = 8 * np.sin(np.linspace(0, 4 * np.pi, trading_days)) + 3
        noise = np.random.randn(trading_days) * 5
        values = base + noise
    elif stock == "FINANCE":
        base = np.zeros(trading_days)
        noise = np.random.randn(trading_days) * 6
        volatility_spikes = np.random.choice([-8, 0, 8], trading_days, p=[0.15, 0.7, 0.15])
        values = base + noise + volatility_spikes
    elif stock == "ENERGY":
        base = -5 * np.ones(trading_days)
        trend = np.linspace(-5, 5, trading_days)
        noise = np.random.randn(trading_days) * 4
        values = base + trend + noise
    elif stock == "HEALTHCARE":
        base = 6 * np.cos(np.linspace(0, 3 * np.pi, trading_days))
        noise = np.random.randn(trading_days) * 4
        values = base + noise
    else:
        base = np.zeros(trading_days)
        drift = np.linspace(-8, 8, trading_days)
        noise = np.random.randn(trading_days) * 3
        values = base + drift + noise

    values = np.clip(values, -15, 15)
    for day, v in enumerate(values):
        data.append({"day": day, "stock": stock, "deviation": v})

df = pd.DataFrame(data)

# Horizon chart parameters
n_bands = 3
band_height = 15 / n_bands

# Create figure
fig, axes = plt.subplots(len(stocks), 1, figsize=(16, 9), sharex=True)
fig.patch.set_facecolor(PAGE_BG)
fig.subplots_adjust(hspace=0.06)

# Color palettes for positive (green) and negative (red)
positive_colors = ["#E8F5E9", "#66BB6A", "#2E7D32"]
negative_colors = ["#FFEBEE", "#EF5350", "#C62828"]

for idx, stock in enumerate(stocks):
    ax = axes[idx]
    ax.set_facecolor(PAGE_BG)
    stock_data = df[df["stock"] == stock]
    x = np.arange(len(stock_data))
    values = stock_data["deviation"].values

    ax.set_xlim(0, len(x))
    ax.set_ylim(0, band_height)

    positive_vals = np.maximum(values, 0)
    negative_vals = np.abs(np.minimum(values, 0))

    # Draw negative bands (red)
    for band_idx in range(n_bands):
        band_min = band_idx * band_height
        neg_folded = np.clip(negative_vals - band_min, 0, band_height)
        neg_mask = (negative_vals > band_min) & (values < 0)
        neg_y = np.where(neg_mask, neg_folded, np.nan)
        ax.fill_between(x, 0, neg_y, color=negative_colors[band_idx], alpha=0.85, linewidth=0)

    # Draw positive bands (green)
    for band_idx in range(n_bands):
        band_min = band_idx * band_height
        pos_folded = np.clip(positive_vals - band_min, 0, band_height)
        pos_mask = (positive_vals > band_min) & (values > 0)
        pos_y = np.where(pos_mask, pos_folded, np.nan)
        ax.fill_between(x, 0, pos_y, color=positive_colors[band_idx], alpha=0.85, linewidth=0)

    ax.set_ylabel(stock, fontsize=16, rotation=0, ha="right", va="center", labelpad=15, color=INK)
    ax.set_yticks([])

    # Enhanced grid for better time tracking
    ax.grid(True, axis="x", alpha=0.2, linewidth=0.8, color=INK_SOFT)
    ax.set_axisbelow(True)

    # Style spines
    for spine in ["top", "right", "left"]:
        ax.spines[spine].set_visible(False)
    ax.spines["bottom"].set_color(INK_SOFT)
    ax.spines["bottom"].set_visible(idx == len(stocks) - 1)
    ax.tick_params(axis="x", colors=INK_SOFT, labelsize=16, bottom=(idx == len(stocks) - 1))

# X-axis formatting
tick_positions = np.arange(0, trading_days, 15)
tick_labels = [f"Day {i}" for i in tick_positions]
axes[-1].set_xticks(tick_positions)
axes[-1].set_xticklabels(tick_labels, fontsize=16, color=INK_SOFT)
axes[-1].set_xlabel("Trading Days (90-day period)", fontsize=20, color=INK)

# Title
fig.suptitle("horizon-basic · seaborn · anyplot.ai", fontsize=24, y=0.98, fontweight="medium", color=INK)

# Legend
legend_patches = [
    mpatches.Patch(color=positive_colors[0], label="Low positive (0–5 pp)"),
    mpatches.Patch(color=positive_colors[1], label="Medium positive (5–10 pp)"),
    mpatches.Patch(color=positive_colors[2], label="High positive (10–15 pp)"),
    mpatches.Patch(color=negative_colors[0], label="Low negative (0–5 pp)"),
    mpatches.Patch(color=negative_colors[1], label="Medium negative (5–10 pp)"),
    mpatches.Patch(color=negative_colors[2], label="High negative (10–15 pp)"),
]
fig.legend(
    handles=legend_patches,
    loc="upper right",
    bbox_to_anchor=(0.98, 0.92),
    fontsize=14,
    title="Deviation from 20-day MA (percentage points)",
    title_fontsize=14,
    framealpha=0.95,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    ncol=2,
)

plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

Part of Horizon Chart on anyplot.ai.

Other implementations