Agile Sprint Burndown Chart — Pygal

A sprint burndown chart tracks the amount of remaining work in an agile sprint over time, plotted as a step (or line) series that descends as tasks are completed. A straight ideal-burndown guideline runs diagonally from the total committed scope on day one down to zero on the final day, giving the team an at-a-glance reference for whether they are ahead of or behind schedule. It is one of the most widely used project-management visualizations in software development, surfacing scope creep, stalled progress, and end-of-sprint risk early enough to act on.

Agile Sprint Burndown Chart rendered with Pygal

Python source (Pygal)

""" anyplot.ai
burndown-sprint: Agile Sprint Burndown Chart
Library: pygal 3.1.0 | Python 3.13.13
Quality: 87/100 | Created: 2026-06-14
"""

import os
import re
import sys


# Remove script directory from sys.path to avoid self-import (file is named pygal.py)
_here = os.path.dirname(os.path.abspath(__file__))
if _here in sys.path:
    sys.path.remove(_here)

import cairosvg
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"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
WEEKEND_FILL = INK  # subtle non-working-day band
SCOPE_COLOR = "#C475FD"  # Imprint violet — scope-change annotation

IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")
ANYPLOT_NEUTRAL = INK  # reference lines / baseline (theme-adaptive)

title = "burndown-sprint · python · pygal · anyplot.ai"

# Data — 2-week sprint: 10 working days + 1 Sat/Sun weekend block
x_labels = ["Start", "Mon", "Tue", "Wed*", "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"]
# Ideal burndown: 4 pts/working day from 40 → 0; flat on Sat and Sun
ideal = [40, 36, 32, 28, 24, 20, 20, 20, 16, 12, 8, 4, 0]
# Actual remaining: scope change on Wed* (day 3) pushes line above ideal mid-sprint
actual = [40, 36, 30, 32, 27, 22, 22, 22, 16, 10, 5, 2, 0]

# Overlay landmark indices in x_labels
WEEKEND_START_IDX = 6  # Sat
WEEKEND_END_IDX = 7  # Sun
SCOPE_CHANGE_IDX = 3  # Wed*

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=(IMPRINT_PALETTE[0], ANYPLOT_NEUTRAL),
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=4,
)

chart = pygal.Line(
    width=3200,
    height=1800,
    style=custom_style,
    title=title,
    x_title="Sprint Day  (* Wed = scope +8 pts  |  Sat–Sun = weekend)",
    y_title="Remaining Story Points",
    show_x_guides=False,
    show_y_guides=True,
    show_dots=True,
    dots_size=8,
    stroke=True,
    x_label_rotation=0,
)

chart.x_labels = x_labels
chart.add("Actual", actual, stroke_style={"width": 5})
chart.add("Ideal", ideal, stroke_style={"width": 3, "dasharray": "8, 6"})

# Render to SVG then post-process: inject weekend band + scope-change marker
svg_str = chart.render().decode("utf-8")

# Extract x-coordinates from data-point circle elements (plot-group local space)
cx_vals = sorted(
    {int(round(float(m.group(1)))) for m in re.finditer(r"<circle\b[^>]+\bcx=[\"']([^\"']+)[\"']", svg_str)}
)  # 13 unique positions, one per x_label

# Estimate plot area height from maximum cy of data circles
cy_vals = [float(m.group(1)) for m in re.finditer(r"<circle\b[^>]+\bcy=[\"']([^\"']+)[\"']", svg_str)]
plot_height = int(max(cy_vals) + 80) if cy_vals else 1380

# Build SVG overlay elements (injected before the first data series group)
overlays = []
if len(cx_vals) >= 13:
    step = cx_vals[1] - cx_vals[0]

    # Weekend shading band: covers from midpoint before Sat to midpoint after Sun
    wx_left = cx_vals[WEEKEND_START_IDX] - step // 2
    wx_right = cx_vals[WEEKEND_END_IDX] + step // 2
    overlays.append(
        f'<rect x="{wx_left}" y="0" width="{wx_right - wx_left}" '
        f'height="{plot_height}" fill="{WEEKEND_FILL}" fill-opacity="0.07" stroke="none"/>'
    )

    # Scope-change vertical dashed marker at Wed* (index 3)
    sx = cx_vals[SCOPE_CHANGE_IDX]
    overlays.append(
        f'<line x1="{sx}" y1="0" x2="{sx}" y2="{plot_height}" '
        f'stroke="{SCOPE_COLOR}" stroke-width="4" stroke-dasharray="14,7" opacity="0.85"/>'
    )
    # Inline annotation label near the top of the marker line
    overlays.append(
        f'<text x="{sx + 14}" y="54" fill="{SCOPE_COLOR}" '
        f'font-family="sans-serif" font-size="38" font-weight="600" opacity="0.9">'
        f"Scope +8 pts</text>"
    )

if overlays:
    # Insert inside the plot group, immediately before the first data series
    for marker in ('<g class="series serie-0', '<g class="series '):
        pos = svg_str.find(marker)
        if pos >= 0:
            svg_str = svg_str[:pos] + "\n".join(overlays) + "\n" + svg_str[pos:]
            break

output_bytes = svg_str.encode("utf-8")
cairosvg.svg2png(bytestring=output_bytes, write_to=f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(output_bytes)

Part of Agile Sprint Burndown Chart on anyplot.ai.

Other implementations