Activity Rings Progress Chart — Altair

An activity rings chart displays goal completion for several metrics at once using concentric radial progress arcs, in the style popularized by fitness trackers. Each ring is a partial arc with rounded end caps that starts at the top (12 o'clock) and sweeps clockwise in proportion to how close the metric is to its goal, with a distinct color per metric and a faint background track showing the full circle. It is instantly recognizable and ideal for at-a-glance comparison of progress across a small set of related goals, filling the general radial-progress gap that needle gauges (`gauge-basic`) and polar bars (`polar-bar`) do not cover.

Activity Rings Progress Chart rendered with Altair

Python source (Altair)

""" anyplot.ai
gauge-activity-rings: Activity Rings Progress Chart
Library: altair 6.2.1 | Python 3.13.13
Quality: 86/100 | Created: 2026-06-14
"""

import importlib
import os
import sys


# Drop script directory from sys.path so `altair` package resolves, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")
np = importlib.import_module("numpy")
pd = importlib.import_module("pandas")
Image = importlib.import_module("PIL.Image")

# Theme tokens
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"
# Background track opacity — bumped for dark theme where it would otherwise vanish
TRACK_OPACITY = 0.18 if THEME == "light" else 0.28

# Imprint palette — positions 1-3 for three activity rings
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — daily fitness tracker goals
metrics = ["Move", "Exercise", "Stand"]
values = [420, 25, 9]
goals = [600, 30, 12]
units = ["kcal", "min", "hr"]
colors = IMPRINT_PALETTE[:3]

fractions = [v / g for v, g in zip(values, goals, strict=False)]
# Ring geometry: outer ring = Move (primary), inner = Stand
outer_radii = [185, 130, 75]
inner_radii = [150, 95, 40]

TWO_PI = 2 * np.pi
VIEW_CX = 250  # view center x (width / 2)
VIEW_CY = 230  # view center y (height / 2)

# Title with adaptive fontsize
title = "gauge-activity-rings · python · altair · anyplot.ai"
n = len(title)
title_size = max(11, round(16 * (67 / n if n > 67 else 1.0)))

# Index of highest-completion ring for visual storytelling emphasis (DE-03)
best_idx = fractions.index(max(fractions))

# Build arc layers inline — no helper function (KISS / CQ-01)
layers = []
for metric, val, goal, unit, color, r_out, r_in, frac in zip(
    metrics, values, goals, units, colors, outer_radii, inner_radii, fractions, strict=False
):
    tip = f"{val} / {goal} {unit}  ({round(frac * 100)}%)"
    full_df = pd.DataFrame([{"s": 0.0, "e": TWO_PI}])
    arc_df = pd.DataFrame([{"s": 0.0, "e": min(frac, 1.0) * TWO_PI, "metric": metric, "tip": tip}])
    track = (
        alt.Chart(full_df)
        .mark_arc(innerRadius=r_in, outerRadius=r_out, cornerRadius=22)
        .encode(
            theta=alt.Theta("s:Q", scale=None), theta2="e:Q", color=alt.value(color), opacity=alt.value(TRACK_OPACITY)
        )
    )
    arc = (
        alt.Chart(arc_df)
        .mark_arc(innerRadius=r_in, outerRadius=r_out, cornerRadius=22)
        .encode(
            theta=alt.Theta("s:Q", scale=None),
            theta2="e:Q",
            color=alt.value(color),
            opacity=alt.value(1.0),
            tooltip=[alt.Tooltip("metric:N", title="Activity"), alt.Tooltip("tip:N", title="Progress")],
        )
    )
    layers.append(track + arc)

# Center labels: stacked metric lines inside the inner hole
# Best-performing ring gets a ★ prefix and slightly larger font for storytelling (DE-03)
center_y_positions = [VIEW_CY - 20, VIEW_CY, VIEW_CY + 20]
center_labels = []
for i, (metric, frac, color, cy) in enumerate(zip(metrics, fractions, colors, center_y_positions, strict=False)):
    is_best = i == best_idx
    label_text = f"{'★ ' if is_best else ''}{metric}  {round(frac * 100)}%"
    label_size = 14 if is_best else 12
    lbl_df = pd.DataFrame([{"txt": label_text}])
    center_labels.append(
        alt.Chart(lbl_df)
        .mark_text(fontSize=label_size, fontWeight="bold", align="center", baseline="middle")
        .encode(x=alt.value(VIEW_CX), y=alt.value(cy), text="txt:N", color=alt.value(color))
    )

all_layers = layers + center_labels
chart = (
    alt.layer(*all_layers)
    .properties(
        width=500,
        height=460,
        background=PAGE_BG,
        title=alt.TitleParams(text=title, fontSize=title_size, color=INK, anchor="middle"),
    )
    .configure_view(fill=PAGE_BG, stroke=None)
    .configure_title(color=INK, fontSize=title_size)
)

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

# Pad to exact square target (2400×2400)
TW, TH = 2400, 2400
_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 {TW}×{TH}. Shrink .properties(width=, height=) 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 interactive HTML
chart.save(f"plot-{THEME}.html")

Part of Activity Rings Progress Chart on anyplot.ai.

Other implementations