Agile Sprint Burndown Chart — Altair

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 Altair

Python source (Altair)

""" anyplot.ai
burndown-sprint: Agile Sprint Burndown Chart
Library: altair 6.2.1 | Python 3.13.13
Quality: 91/100 | Created: 2026-06-14
"""

import os
import sys


# Prevent self-import: the script is named altair.py, which shadows the library
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir]

import altair as alt
import pandas as pd
from PIL import Image


# Theme tokens (see prompts/default-style-guide.md)
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"
ANYPLOT_AMBER = "#DDCC77"  # Imprint amber — scope change warning

BRAND = "#009E73"  # Imprint palette position 1 — actual remaining (first series)

# Data — 10-working-day sprint Jan 6–17 2025; +8 pt scope change on Jan 9 (Thu)
# Story: team starts at 40 pts, burns down well, scope jumps on day 4, recovers to finish at 0
sprint_dates = pd.to_datetime(
    [
        "2025-01-06",
        "2025-01-07",
        "2025-01-08",
        "2025-01-09",
        "2025-01-10",
        "2025-01-11",
        "2025-01-12",
        "2025-01-13",
        "2025-01-14",
        "2025-01-15",
        "2025-01-16",
        "2025-01-17",
    ]
)
actual_pts = [40, 36, 30, 36, 29, 29, 29, 22, 15, 9, 4, 0]

df_actual = pd.DataFrame({"date": sprint_dates, "remaining": actual_pts, "series": "Actual Remaining"})
df_ideal = pd.DataFrame(
    {"date": pd.to_datetime(["2025-01-06", "2025-01-17"]), "remaining": [40, 0], "series": "Ideal Burndown"}
)
weekend_bands = pd.DataFrame(
    {"start": pd.to_datetime(["2025-01-11"]), "end": pd.to_datetime(["2025-01-13"]), "top": [46.0], "bottom": [0.0]}
)
scope_df = pd.DataFrame({"date": pd.to_datetime(["2025-01-09"]), "label": ["+8 pts"]})

# Shared color scale — actual (green first series) vs ideal (muted)
color_scale = alt.Scale(domain=["Actual Remaining", "Ideal Burndown"], range=[BRAND, INK_SOFT])
series_legend = alt.Legend(title=None, symbolType="stroke", symbolStrokeWidth=3, labelFontSize=10)

y_scale = alt.Scale(domain=[0, 46])
x_axis = alt.Axis(format="%b %d", labelAngle=-35, title="Sprint Date")
y_axis = alt.Axis(title="Story Points Remaining")

# Weekend shading using data coordinates — spans full y-range unobtrusively
weekend_layer = (
    alt.Chart(weekend_bands)
    .mark_rect(opacity=0.08, color=INK_MUTED)
    .encode(x="start:T", x2="end:T", y="top:Q", y2="bottom:Q")
)

# Ideal burndown — straight dashed diagonal reference line
ideal_layer = (
    alt.Chart(df_ideal)
    .mark_line(strokeDash=[10, 5], strokeWidth=2)
    .encode(
        x=alt.X("date:T", axis=x_axis),
        y=alt.Y("remaining:Q", scale=y_scale, axis=y_axis),
        color=alt.Color("series:N", scale=color_scale, legend=series_legend),
    )
)

# Actual remaining — step-after series (work done in discrete chunks)
actual_layer = (
    alt.Chart(df_actual)
    .mark_line(interpolate="step-after", strokeWidth=4)
    .encode(
        x=alt.X("date:T", axis=x_axis),
        y=alt.Y("remaining:Q", scale=y_scale, axis=y_axis),
        color=alt.Color("series:N", scale=color_scale, legend=series_legend),
        tooltip=[
            alt.Tooltip("date:T", title="Date", format="%b %d"),
            alt.Tooltip("remaining:Q", title="Pts Remaining"),
        ],
    )
)

# Scope change: amber dashed vertical rule + label
scope_rule = alt.Chart(scope_df).mark_rule(strokeDash=[6, 3], strokeWidth=2, color=ANYPLOT_AMBER).encode(x="date:T")
scope_label = (
    alt.Chart(scope_df)
    .mark_text(align="left", dx=6, baseline="top", color=ANYPLOT_AMBER, fontSize=11, fontWeight="bold")
    .encode(x="date:T", y=alt.value(30), text="label:N")
)

# Ahead/behind schedule annotations — makes the reading explicit per spec
# Team is briefly ahead Jan 07–08 (actual < ideal), then behind after scope jump
ahead_df = pd.DataFrame({"date": pd.to_datetime(["2025-01-08"]), "remaining": [31.5], "label": ["↓ Ahead"]})
ahead_annotation = (
    alt.Chart(ahead_df)
    .mark_text(align="center", dy=-4, fontSize=9, color=BRAND, fontStyle="italic")
    .encode(x="date:T", y=alt.Y("remaining:Q", scale=y_scale), text="label:N")
)
behind_df = pd.DataFrame({"date": pd.to_datetime(["2025-01-14"]), "remaining": [17.0], "label": ["↑ Behind"]})
behind_annotation = (
    alt.Chart(behind_df)
    .mark_text(align="left", dx=4, fontSize=9, color=INK_MUTED, fontStyle="italic")
    .encode(x="date:T", y=alt.Y("remaining:Q", scale=y_scale), text="label:N")
)

# Compose and configure
title_str = "burndown-sprint · python · altair · anyplot.ai"

chart = (
    alt.layer(weekend_layer, ideal_layer, actual_layer, scope_rule, scope_label, ahead_annotation, behind_annotation)
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        padding={"left": 0, "right": 0, "top": 0, "bottom": 0},
        title=alt.Title(text=title_str, fontSize=16, color=INK, anchor="start", offset=10),
    )
    .configure_view(fill=PAGE_BG, stroke=None, continuousWidth=620, continuousHeight=320)
    .configure_axisX(
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        grid=False,
        labelColor=INK_SOFT,
        labelFontSize=10,
        titleColor=INK,
        titleFontSize=12,
    )
    .configure_axisY(
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        gridColor=INK,
        gridOpacity=0.12,
        labelColor=INK_SOFT,
        labelFontSize=10,
        titleColor=INK,
        titleFontSize=12,
    )
    .configure_title(color=INK, fontSize=16)
    .configure_legend(
        fillColor=ELEVATED_BG,
        strokeColor=INK_SOFT,
        labelColor=INK_SOFT,
        labelFontSize=10,
        orient="top-right",
        padding=8,
    )
)

# Save PNG
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

# Pad to exact 3200×1800 (vl-convert may undershoot)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

# Save HTML (interactive)
chart.save(f"plot-{THEME}.html")

Part of Agile Sprint Burndown Chart on anyplot.ai.

Other implementations