Basic Band Plot — Pygal

A band plot displays a filled region between two boundary lines, commonly used to show confidence intervals, prediction intervals, or ranges around a central trend line. The semi-transparent band provides visual representation of uncertainty or variability while maintaining visibility of underlying data or overlapping elements.

Basic Band Plot rendered with Pygal

Python source (Pygal)

""" anyplot.ai
band-basic: Basic Band Plot
Library: pygal 3.1.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-29
"""

import os
import sys


# Prevent self-import: script named 'pygal.py' would shadow the real package
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _here]

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens (Imprint palette — 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"

# Series order: band=green(0), center=blue(1) for contrast against green, wilting=red(2) semantic anchor
IMPRINT_PALETTE = ("#009E73", "#4467A3", "#AE3030", "#C475FD", "#BD8233", "#2ABCCD", "#954477", "#99B314")

# Data — soil moisture sensor readings with 95% confidence interval
np.random.seed(42)
n_points = 80
hours = np.linspace(0, 48, n_points)

# Realistic soil moisture pattern: starts high after rain, dips during dry spell,
# partially recovers at night, then drops before a second rain event
base_trend = 38 - 0.3 * hours + 4.0 * np.sin(2 * np.pi * hours / 24) + 8.0 * np.exp(-((hours - 40) ** 2) / 8)
noise = np.random.randn(n_points) * 0.6
y_raw = base_trend + noise

# Smooth with convolution, preserving array length with edge padding
kernel = np.ones(7) / 7
y_smooth = np.convolve(y_raw, kernel, mode="valid")
pad_left = (n_points - len(y_smooth)) // 2
pad_right = n_points - len(y_smooth) - pad_left
y_center = np.concatenate([np.full(pad_left, y_smooth[0]), y_smooth, np.full(pad_right, y_smooth[-1])])

# Confidence interval: wider during dry midday, narrower after rain
uncertainty = 1.2 + 0.8 * np.sin(2 * np.pi * hours / 24) ** 2 + 0.04 * hours
y_lower = y_center - uncertainty
y_upper = y_center + uncertainty

# Y-axis tick grid at clean 4% intervals
y_lo = 4 * (int(min(y_lower)) // 4)
y_hi = 4 * (int(max(y_upper)) // 4 + 1)
y_label_values = list(range(y_lo, y_hi + 1, 4))

# Custom style — Imprint palette, theme-adaptive chrome
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_PALETTE,
    opacity=".25",
    opacity_hover=".40",
    stroke_opacity="1",
    stroke_opacity_hover="1",
    stroke_width=2.5,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    value_colors=("transparent",),
    tooltip_font_size=32,
    font_family='Helvetica, Arial, "DejaVu Sans", sans-serif',
)

title = "band-basic · python · pygal · anyplot.ai"

chart = pygal.XY(
    style=custom_style,
    width=3200,
    height=1800,
    explicit_size=True,
    title=title,
    x_title="Time (hours)",
    y_title="Soil Moisture (%)",
    show_dots=False,
    show_x_guides=False,
    show_y_guides=True,
    fill=True,
    stroke=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=3,
    legend_box_size=28,
    truncate_legend=-1,
    x_label_rotation=0,
    range=(y_lo - 1, y_hi + 1),
    y_labels=y_label_values,
    x_labels=[0, 6, 12, 18, 24, 30, 36, 42, 48],
    x_labels_major=[0, 12, 24, 36, 48],
    show_minor_x_labels=True,
    show_minor_y_labels=False,
    print_values=False,
    x_value_formatter=lambda x: f"{x:.0f}h",
    value_formatter=lambda x: f"{x:.1f}%",
    tooltip_border_radius=8,
    margin_top=30,
    margin_bottom=50,
    margin_left=30,
    margin_right=50,
    spacing=18,
)


# Band as closed polygon: upper boundary forward, then lower boundary reversed
band_polygon = [(float(h), float(y)) for h, y in zip(hours, y_upper, strict=True)]
for h, y in zip(reversed(hours), reversed(y_lower), strict=True):
    band_polygon.append((float(h), float(y)))

chart.add("95% Confidence Band", band_polygon, stroke_style={"width": 0.5}, show_dots=False)

# Central trend line — blue (palette pos 1) for high contrast against green band, bold stroke
center_data = [(float(h), float(y)) for h, y in zip(hours, y_center, strict=True)]
chart.add(
    "Sensor Mean",
    center_data,
    fill=False,
    stroke=True,
    dots_size=0,
    stroke_style={"width": 10, "linecap": "round", "linejoin": "round"},
)

# Wilting point reference — semantic red for danger threshold
chart.add(
    "Wilting Point (25%)",
    [(0.0, 25.0), (48.0, 25.0)],
    fill=False,
    stroke=True,
    dots_size=0,
    formatter=lambda x: f"{x:.0f}%",
    stroke_style={"width": 5, "dasharray": "16,10", "linecap": "round"},
)

# Save PNG and interactive HTML
chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of Basic Band Plot on anyplot.ai.

Other implementations