Horizon Chart — Pygal

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 Pygal

Renders

Python source (Pygal)

""" anyplot.ai
horizon-basic: Horizon Chart
Library: pygal 3.1.3 | Python 3.13.15
Quality: 93/100 | Updated: 2026-08-18
"""

import os
import sys

import numpy as np
import pandas as pd


# Temporarily remove current directory from path to avoid name collision
# with this file (pygal.py) shadowing the real "pygal" package on import.
_cwd = sys.path[0] if sys.path[0] else "."
if _cwd in sys.path:
    sys.path.remove(_cwd)

from pygal.graph.graph import Graph
from pygal.style import Style


# Restore path
sys.path.insert(0, _cwd)

# Theme configuration (see prompts/default-style-guide.md "Theme-adaptive Chrome")
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"
RULE = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# imprint_div endpoints (diverging, meaningful midpoint = the sector's own
# background) — never ColorBrewer / viridis / any other named cmap.
DIV_NEGATIVE = "#AE3030"
DIV_POSITIVE = "#4467A3"
DIV_MIDPOINT = PAGE_BG


def _lerp_hex(c0, c1, t):
    """Interpolate two hex colors — Imprint has no built-in cmap API, so
    continuous bands are built manually from the two imprint_div endpoints."""
    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))
    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))
    r, g, b = (int(round(a + (b - a) * t)) for a, b in ((r0, r1), (g0, g1), (b0, b1)))
    return f"#{r:02X}{g:02X}{b:02X}"


class HorizonChart(Graph):
    """Custom Horizon Chart for pygal — folds signed deviations into
    imprint_div color bands. Pygal has no native horizon chart type, so this
    subclasses Graph and draws directly onto the SVG canvas (the documented
    pygal mechanism for a chart type outside the stock catalog)."""

    def __init__(self, *args, **kwargs):
        self.series_data = kwargs.pop("series_data", {})
        self.time_labels = kwargs.pop("time_labels", [])
        self.n_bands = kwargs.pop("n_bands", 3)
        self.pos_colors = kwargs.pop("pos_colors", [])
        self.neg_colors = kwargs.pop("neg_colors", [])
        super().__init__(*args, **kwargs)

    def _plot(self):
        """Draw the horizon chart: one row per series, folded bands per cell."""
        if not self.series_data:
            return

        series_names = list(self.series_data.keys())
        n_series = len(series_names)
        n_points = len(self.time_labels)

        plot_width = self.view.width
        plot_height = self.view.height

        # Layout margins tuned for the 3200x1800 canvas — margin_left must
        # fit the longest series label ("Consumer Discretionary") at
        # label_font_size without overflowing past the canvas edge.
        margin_left = 520
        margin_right = 50
        margin_top = 140
        margin_bottom = 130

        available_width = plot_width - margin_left - margin_right
        available_height = plot_height - margin_top - margin_bottom

        row_height = available_height / n_series
        band_gap = row_height * 0.10
        actual_row_height = row_height - band_gap
        cell_width = available_width / n_points

        x_offset = self.view.x(0) + margin_left
        y_offset = self.view.y(n_series) + margin_top

        plot_node = self.nodes["plot"]
        horizon_group = self.svg.node(plot_node, class_="horizon-chart")

        # Global min/max drives the fold — every row shares one scale so
        # band color intensity is comparable sector-to-sector.
        all_values = [v for values in self.series_data.values() for v in values]
        global_max = max(abs(v) for v in all_values)
        band_size = global_max / self.n_bands

        label_font_size = min(34, int(actual_row_height * 0.34))

        for i, series_name in enumerate(series_names):
            values = self.series_data[series_name]
            row_y = y_offset + i * row_height

            # Zebra striping for row-to-row scan-ability
            bg_rect = self.svg.node(
                horizon_group, "rect", x=x_offset, y=row_y, width=available_width, height=actual_row_height, rx=4
            )
            bg_rect.set("fill", ELEVATED_BG if i % 2 == 0 else PAGE_BG)
            bg_rect.set("stroke", RULE)
            bg_rect.set("stroke-width", "1.5")

            # Series (sector) label
            text_node = self.svg.node(
                horizon_group, "text", x=x_offset - 22, y=row_y + actual_row_height / 2 + label_font_size * 0.32
            )
            text_node.set("text-anchor", "end")
            text_node.set("fill", INK)
            text_node.set("style", f"font-size:{label_font_size}px;font-weight:600;font-family:sans-serif")
            text_node.text = series_name

            # Horizon bands, one folded stack per time point
            for j, value in enumerate(values):
                cell_x = x_offset + j * cell_width
                is_positive = value >= 0
                remaining = abs(value)

                for band_idx in range(self.n_bands):
                    band_value = min(remaining, band_size)
                    if band_value <= 0:
                        break

                    height_ratio = band_value / band_size
                    band_height = (actual_row_height / self.n_bands) * height_ratio
                    band_y = row_y + actual_row_height - (actual_row_height / self.n_bands) * (band_idx + height_ratio)
                    color = (self.pos_colors if is_positive else self.neg_colors)[band_idx]

                    rect = self.svg.node(
                        horizon_group, "rect", x=cell_x, y=band_y, width=cell_width + 0.5, height=band_height, rx=3
                    )
                    rect.set("fill", color)
                    rect.set("stroke", "none")

                    # Real SVG hover tooltip (native browser behavior in the
                    # interactive HTML output — not a simulated/fake one).
                    tip = self.svg.node(rect, "title")
                    tip.text = f"{series_name} · {self.time_labels[j]}: {value:+.1f}pp vs benchmark"

                    remaining -= band_size

        # Subtle vertical grid at regular intervals for time readability
        grid_interval = max(1, n_points // 12)
        for j in range(0, n_points + 1, grid_interval):
            grid_x = x_offset + j * cell_width
            line = self.svg.node(
                horizon_group, "line", x1=grid_x, y1=y_offset, x2=grid_x, y2=y_offset + n_series * row_height
            )
            line.set("stroke", RULE)
            line.set("stroke-width", "1")
            line.set("stroke-dasharray", "4,4")

        # X-axis tick labels
        x_label_font_size = 30
        label_interval = max(1, n_points // 12)
        for j in range(0, n_points, label_interval):
            label_x = x_offset + j * cell_width + cell_width / 2
            label_y = y_offset + n_series * row_height + 42

            text_node = self.svg.node(horizon_group, "text", x=label_x, y=label_y)
            text_node.set("text-anchor", "middle")
            text_node.set("fill", INK_MUTED)
            text_node.set("style", f"font-size:{x_label_font_size}px;font-family:sans-serif")
            text_node.text = self.time_labels[j]

        # X-axis title
        x_title_font_size = 40
        text_node = self.svg.node(
            horizon_group, "text", x=x_offset + available_width / 2, y=y_offset + n_series * row_height + 95
        )
        text_node.set("text-anchor", "middle")
        text_node.set("fill", INK_SOFT)
        text_node.set("style", f"font-size:{x_title_font_size}px;font-weight:600;font-family:sans-serif")
        text_node.text = "Trading Day (2024)"

        # Diverging color-scale legend (imprint_div): a single compact strip
        # from full red (strong underperformance) through the neutral
        # benchmark swatch to full blue (strong outperformance).
        swatch = 52
        gap = 12
        n_neg = len(self.neg_colors)
        n_pos = len(self.pos_colors)
        stops = list(reversed(self.neg_colors)) + [None] + self.pos_colors
        legend_width = len(stops) * (swatch + gap) - gap
        legend_x = x_offset + available_width - legend_width
        legend_y = self.view.y(n_series) + 48
        legend_font_size = 32

        for k, color in enumerate(stops):
            sx = legend_x + k * (swatch + gap)
            rect = self.svg.node(horizon_group, "rect", x=sx, y=legend_y, width=swatch, height=swatch, rx=5)
            if color is None:
                rect.set("fill", ELEVATED_BG)
                rect.set("stroke", INK_MUTED)
                rect.set("stroke-width", "1.5")
            else:
                rect.set("fill", color)
                rect.set("stroke", "none")

        # Center each label under its own color group (not the whole legend
        # bar) so a wider, more legible font never collides across groups.
        neg_block_width = n_neg * (swatch + gap) - gap
        pos_block_start = legend_x + (n_neg + 1) * (swatch + gap)
        pos_block_width = n_pos * (swatch + gap) - gap
        neg_center_x = legend_x + neg_block_width / 2
        pos_center_x = pos_block_start + pos_block_width / 2

        left_label = self.svg.node(horizon_group, "text", x=neg_center_x, y=legend_y - 16)
        left_label.set("text-anchor", "middle")
        left_label.set("fill", INK_MUTED)
        left_label.set("style", f"font-size:{legend_font_size}px;font-family:sans-serif")
        left_label.text = "Underperform"

        right_label = self.svg.node(horizon_group, "text", x=pos_center_x, y=legend_y - 16)
        right_label.set("text-anchor", "middle")
        right_label.set("fill", INK_MUTED)
        right_label.set("style", f"font-size:{legend_font_size}px;font-family:sans-serif")
        right_label.text = "Outperform"

    def _compute(self):
        """Establish the data-space box for view.x()/view.y() scaling."""
        n_series = len(self.series_data) if self.series_data else 1
        n_points = len(self.time_labels) if self.time_labels else 1
        self._box.xmin = 0
        self._box.xmax = n_points
        self._box.ymin = 0
        self._box.ymax = n_series


# Data: cumulative sector performance vs. a market benchmark over one
# trading quarter (realistic, non-controversial finance scenario; a
# different time window and domain than the sibling 24h/seed-42 server
# metrics used elsewhere in the catalog).
np.random.seed(42)

trading_days = pd.bdate_range("2024-01-02", periods=126)
time_labels = [d.strftime("%b %d") for d in trading_days]
n_points = len(time_labels)

# (sector, daily drift pp, daily volatility pp) — each sector's cumulative
# excess return vs. the benchmark is a drifted random walk.
sector_params = [
    ("Technology", 0.14, 1.3),
    ("Consumer Discretionary", 0.09, 1.1),
    ("Financials", 0.07, 0.9),
    ("Industrials", 0.05, 0.8),
    ("Healthcare", 0.04, 0.7),
    ("Utilities", -0.03, 0.5),
    ("Real Estate", -0.06, 1.0),
    ("Energy", -0.08, 1.4),
]

sector_returns = {}
for sector_name, drift, volatility in sector_params:
    daily_excess_return = np.random.normal(drift, volatility, n_points)
    sector_returns[sector_name] = np.cumsum(daily_excess_return).tolist()

# Rank rows by final cumulative excess return (best performer on top) so the
# chart reads as a leaderboard — a clearer focal point than declaration order.
sector_returns = dict(sorted(sector_returns.items(), key=lambda kv: kv[1][-1], reverse=True))

n_bands = 3
pos_colors = [_lerp_hex(DIV_MIDPOINT, DIV_POSITIVE, (i + 1) / n_bands) for i in range(n_bands)]
neg_colors = [_lerp_hex(DIV_MIDPOINT, DIV_NEGATIVE, (i + 1) / n_bands) for i in range(n_bands)]

# Title — scale fontsize down if the descriptive prefix pushes past the
# 67-char baseline the style guide's default (66) is tuned for.
title = "Sector Performance vs Benchmark · horizon-basic · python · pygal · anyplot.ai"
title_font_size = round(66 * min(1.0, 67 / len(title)))

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=(DIV_POSITIVE,),
    title_font_size=title_font_size,
    legend_font_size=44,
    label_font_size=56,
    major_label_font_size=44,
    value_font_size=36,
    font_family="sans-serif",
)

chart = HorizonChart(
    width=3200,
    height=1800,
    style=custom_style,
    title=title,
    series_data=sector_returns,
    time_labels=time_labels,
    n_bands=n_bands,
    pos_colors=pos_colors,
    neg_colors=neg_colors,
    show_legend=False,
    margin=50,
    margin_top=50,
    margin_bottom=50,
    show_x_labels=False,
    show_y_labels=False,
)

# Dummy series to trigger the render pipeline (_plot draws everything else)
chart.add("", [0])

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Retrieve this implementation

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

Part of Horizon Chart on anyplot.ai.

Other implementations