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.

""" anyplot.ai
gauge-activity-rings: Activity Rings Progress Chart
Library: bokeh 3.9.1 | Python 3.13.13
Quality: 87/100 | Created: 2026-06-14
"""
import os
import sys
# Running as `python bokeh.py` adds this file's directory to sys.path[0],
# shadowing the installed bokeh package. Strip it before importing.
_this_dir = os.path.dirname(os.path.abspath(__file__))
_orig_path = sys.path.copy()
sys.path = [p for p in sys.path if os.path.abspath(p) != _this_dir]
import math
import time
from pathlib import Path
from bokeh.io import output_file, save
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
sys.path = _orig_path
# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Imprint palette — positions 1-3 (theme-independent data colors)
COLORS = ["#009E73", "#C475FD", "#4467A3"]
# Data: daily fitness goal tracking
METRICS = ["Move", "Exercise", "Stand"]
VALUES = [420, 28, 9]
GOALS = [600, 30, 12]
UNITS = ["kcal", "min", "hr"]
FRACTIONS = [v / g for v, g in zip(VALUES, GOALS, strict=False)]
# Ring geometry — outer to inner, centered at (CX, CY)
CX, CY = 0.0, 0.10
OUTER_RADII = [0.80, 0.56, 0.32]
RING_WIDTH = 0.18
INNER_RADII = [r - RING_WIDTH for r in OUTER_RADII]
MID_RADII = [(o + i) / 2 for o, i in zip(OUTER_RADII, INNER_RADII, strict=False)]
CAP_R = 0.05 # radius of rounded end-cap circles — kept < ring gap (0.06) to avoid bleed
title = "gauge-activity-rings · python · bokeh · anyplot.ai"
W, H = 2400, 2400
# Figure — square canvas, axes hidden, match_aspect ensures circles are circular
p = figure(
width=W,
height=H,
title=title,
toolbar_location=None,
x_range=(-1.4, 1.4),
y_range=(-1.4, 1.4),
match_aspect=True,
min_border_bottom=60,
min_border_left=60,
min_border_top=100,
min_border_right=60,
)
p.axis.visible = False
p.grid.visible = False
p.outline_line_color = None
p.outline_line_width = 0
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.title.text_color = INK
p.title.text_font_size = "50pt"
p.title.align = "center"
# Draw rings outer → inner
for color, frac, outer_r, inner_r, mid_r in zip(COLORS, FRACTIONS, OUTER_RADII, INNER_RADII, MID_RADII, strict=False):
# Faint background track: full circle at low opacity
p.annular_wedge(
x=CX,
y=CY,
inner_radius=inner_r,
outer_radius=outer_r,
start_angle=0,
end_angle=2 * math.pi,
color=color,
alpha=0.18 if THEME == "dark" else 0.13,
line_color=None,
)
display_frac = min(frac, 1.0)
if display_frac > 0.001:
# Arc sweeps clockwise from 12 o'clock (π/2) to end angle
start_a = math.pi / 2
end_a = start_a - display_frac * 2 * math.pi
p.annular_wedge(
x=CX,
y=CY,
inner_radius=inner_r,
outer_radius=outer_r,
start_angle=start_a,
end_angle=end_a,
direction="clock",
color=color,
alpha=1.0,
line_color=None,
)
# Rounded end caps: circles placed at the midline of the ring band
p.circle(
x=CX + mid_r * math.cos(start_a),
y=CY + mid_r * math.sin(start_a),
radius=CAP_R,
color=color,
alpha=1.0,
line_color=None,
)
p.circle(
x=CX + mid_r * math.cos(end_a),
y=CY + mid_r * math.sin(end_a),
radius=CAP_R,
color=color,
alpha=1.0,
line_color=None,
)
# Center text: average completion across all rings
avg_pct = int(sum(FRACTIONS) / len(FRACTIONS) * 100)
p.text(
x=[CX],
y=[CY + 0.05],
text=[f"{avg_pct}%"],
text_color=INK,
text_font_size="62pt",
text_font_style="bold",
text_align="center",
text_baseline="middle",
)
p.text(
x=[CX],
y=[CY - 0.08],
text=["avg complete"],
text_color=INK_MUTED,
text_font_size="28pt",
text_align="center",
text_baseline="middle",
)
# Legend below the rings: dot · name · value/goal · percentage
leg_y0 = CY - OUTER_RADII[0] - 0.22
gap = 0.19
dot_r = 0.048
lx_left = -0.78
for i, (metric, val, goal, unit, color, frac) in enumerate(
zip(METRICS, VALUES, GOALS, UNITS, COLORS, FRACTIONS, strict=False)
):
ly = leg_y0 - i * gap
p.circle(x=lx_left, y=ly, radius=dot_r, color=color, alpha=1.0, line_color=None)
p.text(
x=[lx_left + 0.12],
y=[ly],
text=[metric],
text_color=INK,
text_font_size="34pt",
text_font_style="bold",
text_align="left",
text_baseline="middle",
)
p.text(
x=[lx_left + 0.52],
y=[ly],
text=[f"{val} / {goal} {unit}"],
text_color=INK_SOFT,
text_font_size="30pt",
text_align="left",
text_baseline="middle",
)
p.text(
x=[0.70],
y=[ly],
text=[f"{int(frac * 100)}%"],
text_color=color,
text_font_size="36pt",
text_font_style="bold",
text_align="right",
text_baseline="middle",
)
# Save interactive HTML artifact
output_file(f"plot-{THEME}.html")
save(p)
# Screenshot via headless Chrome (Selenium 4 — auto-resolves driver)
# Window height needs +143px to compensate for Chrome's internal viewport offset,
# so the viewport (screenshot) comes out at exactly W×H = 2400×2400.
SW, SH = W, H + 143
opts = Options()
for arg in (
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
f"--window-size={SW},{SH}",
"--hide-scrollbars",
):
opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(SW, SH)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()
Part of Activity Rings Progress Chart on anyplot.ai.