Horizon Chart — lets-plot

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

Renders

Python source (lets-plot)

""" anyplot.ai
horizon-basic: Horizon Chart
Library: letsplot 4.11.0 | Python 3.13.15
Quality: 84/100 | Updated: 2026-08-18
"""

import os
import shutil

import numpy as np
import pandas as pd
from lets_plot import *


LetsPlot.setup_html()

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Imprint palette (first series ALWAYS #009E73)
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD"]

# Data - Server metrics over 7 days for multiple servers
np.random.seed(42)

n_points = 168  # 7 days of hourly data
n_series = 6
series_names = ["Server A", "Server B", "Server C", "Server D", "Server E", "Server F"]

# Create time series data
hours = np.arange(n_points)

# Generate realistic CPU usage deviation data with different patterns per server
data_records = []
for i, name in enumerate(series_names):
    # Base sinusoidal pattern with phase shift per server (daily cycle)
    base = 18 * np.sin(2 * np.pi * hours / 24 + i * np.pi / 3)
    # Add weekly pattern
    weekly = 10 * np.sin(2 * np.pi * hours / 168 + i * 0.5)
    # Add noise
    noise = np.random.randn(n_points) * 1.5
    # Add occasional spikes
    spikes = np.zeros(n_points)
    spike_indices = np.random.choice(n_points, size=4, replace=False)
    spikes[spike_indices] = np.random.choice([-1, 1], size=4) * np.random.uniform(12, 20, size=4)
    raw = base + weekly + noise + spikes
    # Light rolling smoothing so folded bands read as clean intensity
    # mountains instead of a jagged sawtooth
    values = pd.Series(raw).rolling(window=5, center=True, min_periods=1).mean().to_numpy()

    for h, val in zip(hours, values, strict=True):
        data_records.append({"hour": h, "value": val, "series": name})

df = pd.DataFrame(data_records)

# Order facets by descending volatility so the most erratic server leads the grid
series_order = df.groupby("series")["value"].std().sort_values(ascending=False).index.tolist()

# Horizon chart parameters - fold values into bands
n_bands = 3
max_val = df["value"].abs().max()
band_size = max_val / n_bands

# Create horizon-folded data for visualization
# Each band clips values to its range and overlays them
horizon_records = []

# Band labels for legend
band_labels = {"pos0": "+Low", "pos1": "+Medium", "pos2": "+High", "neg0": "-Low", "neg1": "-Medium", "neg2": "-High"}

for series in series_order:
    series_data = df[df["series"] == series]
    values = series_data["value"].values
    hours_arr = series_data["hour"].values

    for band in range(n_bands):
        low = band * band_size

        # Process positive values
        pos = np.clip(np.maximum(values, 0) - low, 0, band_size)
        # Process negative values (mirror to positive for display)
        neg = np.clip(np.maximum(-values, 0) - low, 0, band_size)

        for h, pv, nv in zip(hours_arr, pos, neg, strict=True):
            if pv > 0.01:
                horizon_records.append({"hour": h, "value": pv, "series": series, "band": f"pos{band}"})
            if nv > 0.01:
                horizon_records.append({"hour": h, "value": nv, "series": series, "band": f"neg{band}"})

horizon_df = pd.DataFrame(horizon_records)
horizon_df["series"] = pd.Categorical(horizon_df["series"], categories=series_order, ordered=True)

# Band intensity colors: tints interpolated from the Imprint semantic anchors
# (brand green = positive/gain, brand blue = negative/loss) toward white, so
# band colors stay theme-independent while intensity still reads clearly.
# Blue is used instead of red for the negative direction to avoid a
# red-green color-vision-deficiency pairing against the brand-green positive.
brand_rgb = tuple(int(IMPRINT[0][i : i + 2], 16) for i in (1, 3, 5))
loss_rgb = tuple(int(IMPRINT[2][i : i + 2], 16) for i in (1, 3, 5))

colors = {}
for band_idx in range(n_bands):
    frac = (band_idx + 1) / n_bands
    pos_rgb = tuple(round(255 + (brand_rgb[c] - 255) * frac) for c in range(3))
    neg_rgb = tuple(round(255 + (loss_rgb[c] - 255) * frac) for c in range(3))
    colors[f"pos{band_idx}"] = "#{:02X}{:02X}{:02X}".format(*pos_rgb)
    colors[f"neg{band_idx}"] = "#{:02X}{:02X}{:02X}".format(*neg_rgb)

# Distinctive lets-plot feature: custom tooltip content (unavailable in plotnine)
horizon_tooltips = layer_tooltips().title("@series").format("@value", ".1f").line("Band|@band").line("Deviation|@value")

# Create the horizon chart
plot = (
    ggplot(horizon_df, aes(x="hour", y="value", fill="band"))
    + geom_area(position="identity", alpha=0.85, color=PAGE_BG, size=0.1, tooltips=horizon_tooltips)
    + scale_fill_manual(values=colors, labels=band_labels)
    # Stack every series as a single, full-width, thin horizontal strip
    # (one row per series) rather than a large multi-column grid — this is
    # the defining "minimize vertical space" trait of a horizon chart.
    + facet_grid(y="series", y_order=0)
    # Only the meaningful zero baseline is labeled per row — with six thin
    # stacked strips, a top+bottom tick on every row would collide with the
    # neighboring row's ticks.
    + scale_y_continuous(breaks=[0], labels=["0"])
    + scale_x_continuous(breaks=[0, 24, 48, 72, 96, 120, 144], labels=["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
    + labs(
        title="horizon-basic · python · letsplot · anyplot.ai",
        x="Day of Week",
        y="Folded Value (stacked bands)",
        fill="Band Intensity",
    )
    + theme_minimal()
    + 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=INK_SOFT, size=0.2),
        panel_grid_minor=element_blank(),
        panel_spacing_y=3,
        strip_spacing_y=3,
        plot_title=element_text(size=20, face="bold", color=INK),
        axis_title=element_text(size=14, color=INK),
        axis_text_x=element_text(size=11, color=INK_SOFT),
        axis_text_y=element_text(size=9, color=INK_SOFT),
        axis_line=element_line(color=INK_SOFT, size=0.3),
        strip_text=element_text(size=10, face="bold", color=INK),
        legend_position="right",
        legend_background=element_rect(fill=PAGE_BG, color=INK_SOFT),
        legend_title=element_text(size=12, face="bold", color=INK),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_key_size=12,
    )
    + ggsize(800, 450)
)

# Save as PNG (scale 4x for 3200x1800)
ggsave(plot, f"plot-{THEME}.png", scale=4)

# Save as HTML for interactive version
ggsave(plot, f"plot-{THEME}.html")

# Move files from lets-plot-images subdirectory to current directory
if os.path.exists("lets-plot-images"):
    for filename in [f"plot-{THEME}.png", f"plot-{THEME}.html"]:
        src = os.path.join("lets-plot-images", filename)
        if os.path.exists(src):
            shutil.move(src, filename)
    if not os.listdir("lets-plot-images"):
        shutil.rmtree("lets-plot-images")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/horizon-basic/letsplot/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": "horizon-basic",
  "language": "python",
  "library": "letsplot",
  "page": "https://anyplot.ai/horizon-basic/python/letsplot",
  "hub": "https://anyplot.ai/horizon-basic",
  "code_json": "https://api.anyplot.ai/specs/horizon-basic/letsplot/code",
  "spec_json": "https://api.anyplot.ai/specs/horizon-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/horizon-basic/python/letsplot/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/horizon-basic/python/letsplot/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/horizon-basic/python/letsplot/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/horizon-basic/python/letsplot/plot-dark.html",
  "quality_score": 84.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Horizon Chart on anyplot.ai.

Other implementations