Activity Rings Progress Chart in Plotly (Python)

The same plot in 14 other libraries — Python: Altair, Bokeh, lets-plot, Matplotlib, plotnine, Pygal, Seaborn; R: ggplot2; Julia: Makie.jl; JavaScript: Chart.js, D3.js, Apache ECharts, Highcharts, MUI X Charts. Compare all 15 side by side: Activity Rings Progress Chart in Python, R, Julia and JavaScript.

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 Plotly

Renders

Python source (Plotly)

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

import os

import numpy as np
import plotly.graph_objects as go


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

# Imprint categorical palette — first series always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — daily fitness summary (outer ring = primary metric)
metrics = [
    {"name": "Move", "value": 420, "goal": 600, "unit": "kcal"},
    {"name": "Exercise", "value": 25, "goal": 30, "unit": "min"},
    {"name": "Stand", "value": 9, "goal": 12, "unit": "hr"},
]
ring_colors = IMPRINT_PALETTE[:3]  # green, lavender, blue (outer → inner)

# Ring geometry — outer to inner
radii = [0.83, 0.58, 0.33]
line_width = 30  # px on the 600×600 logical canvas (→ 120 px in 2400×2400 output)
TRACK_ALPHA = 0.14 if THEME == "light" else 0.22  # higher in dark mode so track reads on near-black bg


def arc_xy(radius, fraction):
    fraction = min(fraction, 1.0)
    n = max(400, int(600 / radius))  # more points for inner (smaller) rings to stay smooth
    angles = np.linspace(90, 90 - fraction * 360, n)
    return (radius * np.cos(np.radians(angles))).tolist(), (radius * np.sin(np.radians(angles))).tolist()


def circle_xy(radius):
    n = max(400, int(600 / radius))
    angles = np.linspace(0, 360, n + 1)
    return (radius * np.cos(np.radians(angles))).tolist(), (radius * np.sin(np.radians(angles))).tolist()


def hex_to_rgba(h, alpha):
    r, g, b = int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
    return f"rgba({r},{g},{b},{alpha})"


# Title — 51 chars, below 67-char baseline → no shrink needed
title = "gauge-activity-rings · python · plotly · anyplot.ai"
n_chars = len(title)
title_size = max(11, round(16 * (67 / n_chars if n_chars > 67 else 1.0)))

# Figure
fig = go.Figure()

for metric, radius, color in zip(metrics, radii, ring_colors, strict=False):
    fraction = metric["value"] / metric["goal"]
    track_color = hex_to_rgba(color, TRACK_ALPHA)

    # Background track (full circle, faint)
    cx, cy = circle_xy(radius)
    fig.add_trace(
        go.Scatter(
            x=cx,
            y=cy,
            mode="lines",
            line={"color": track_color, "width": line_width},
            showlegend=False,
            hoverinfo="skip",
        )
    )

    # Progress arc (clockwise from 12 o'clock)
    ax, ay = arc_xy(radius, fraction)
    pct = fraction * 100
    legend_label = f"{metric['name']}  {metric['value']}/{metric['goal']} {metric['unit']}"
    fig.add_trace(
        go.Scatter(
            x=ax,
            y=ay,
            mode="lines",
            line={"color": color, "width": line_width},
            name=legend_label,
            hovertemplate=(
                f"<b>{metric['name']}</b><br>"
                f"{metric['value']} / {metric['goal']} {metric['unit']}<br>"
                f"{pct:.0f}% complete<extra></extra>"
            ),
        )
    )

    # Rounded end caps — markers matching line width at arc endpoints
    s_x = radius * np.cos(np.radians(90))
    s_y = radius * np.sin(np.radians(90))
    end_angle = 90 - fraction * 360
    e_x = radius * np.cos(np.radians(end_angle))
    e_y = radius * np.sin(np.radians(end_angle))

    fig.add_trace(
        go.Scatter(
            x=[s_x, e_x],
            y=[s_y, e_y],
            mode="markers",
            marker={"color": color, "size": line_width, "line": {"width": 0}},
            showlegend=False,
            hoverinfo="skip",
        )
    )

# Center annotation — average completion across all three rings
avg_pct = np.mean([m["value"] / m["goal"] for m in metrics]) * 100
fig.add_annotation(
    x=0, y=0.09, text=f"<b>{avg_pct:.0f}%</b>", font={"size": 28, "color": INK}, showarrow=False, xanchor="center"
)
fig.add_annotation(
    x=0, y=-0.10, text="avg. complete", font={"size": 11, "color": INK_MUTED}, showarrow=False, xanchor="center"
)

# Layout — square canvas (2400×2400 via width=600 height=600 scale=4)
fig.update_layout(
    title={
        "text": title,
        "font": {"size": title_size, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.98,
        "yanchor": "top",
    },
    autosize=False,
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    showlegend=True,
    legend={
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "font": {"color": INK_SOFT, "size": 11},
        "orientation": "h",
        "x": 0.5,
        "xanchor": "center",
        "y": -0.04,
        "yanchor": "top",
        "traceorder": "normal",
        "itemsizing": "constant",
    },
    margin={"l": 75, "r": 75, "t": 70, "b": 95},
    xaxis={
        "range": [-1.08, 1.08],
        "showgrid": False,
        "showticklabels": False,
        "zeroline": False,
        "showline": False,
        "scaleanchor": "y",
        "scaleratio": 1,
    },
    yaxis={"range": [-1.08, 1.08], "showgrid": False, "showticklabels": False, "zeroline": False, "showline": False},
)

# Save — square: width=600, height=600, scale=4 → 2400×2400 px
fig.write_image(f"plot-{THEME}.png", width=600, height=600, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/gauge-activity-rings/plotly/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "gauge-activity-rings",
  "language": "python",
  "library": "plotly",
  "page": "https://anyplot.ai/gauge-activity-rings/python/plotly",
  "hub": "https://anyplot.ai/gauge-activity-rings",
  "code_json": "https://api.anyplot.ai/specs/gauge-activity-rings/plotly/code",
  "spec_json": "https://api.anyplot.ai/specs/gauge-activity-rings",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/gauge-activity-rings/python/plotly/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/gauge-activity-rings/python/plotly/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/gauge-activity-rings/python/plotly/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/gauge-activity-rings/python/plotly/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Activity Rings Progress Chart on anyplot.ai.

Other implementations