Charts
Fitness
Three rings, the hours that filled them, and how a workout went.
- Move
- 486/600 KCAL
- Exercise
- 38/30 MIN
- Stand
- 10/12 HRS
import { ActivityRings, ChartFrame } from "@/components/charts"
/** Today's rings. Exercise is past its goal, so its ring laps itself. */
const today = {
move: { value: 486, goal: 600 },
exercise: { value: 38, goal: 30 },
stand: { value: 10, goal: 12 },
}
const rows = [
{ key: "move", label: "Move", unit: "KCAL", color: "var(--mb-chart-move)" },
{ key: "exercise", label: "Exercise", unit: "MIN", color: "var(--mb-chart-exercise)" },
{ key: "stand", label: "Stand", unit: "HRS", color: "var(--mb-chart-stand)" },
] as const
export default function ActivityRingsSummary() {
return (
<ChartFrame
title="Activity rings, today"
summary={rows
.map(({ key, label, unit }) => `${label} ${today[key].value} of ${today[key].goal} ${unit.toLowerCase()}, ${Math.round((today[key].value / today[key].goal) * 100)}%`)
.join(". ")}
style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "center", gap: 28 }}
>
<ActivityRings rings={today} size={168} />
<dl style={{ display: "grid", gap: 10, margin: 0 }}>
{rows.map(({ key, label, unit, color }) => (
<div key={key}>
<dt style={{ font: "var(--mb-text-subheadline)", fontWeight: 600 }}>{label}</dt>
<dd style={{ margin: 0, color, font: "var(--mb-text-title-2)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
{today[key].value}/{today[key].goal}
<span style={{ font: "var(--mb-text-subheadline)", fontFamily: "var(--mb-font-rounded)", fontWeight: 600 }}> {unit}</span>
</dd>
</div>
))}
</dl>
</ChartFrame>
)
}Activity rings
Move is the outer ring, Exercise the middle, Stand the inner. Each starts at twelve o'clock and runs clockwise with round caps over a dim track of its own color. Past its goal, a ring laps itself, and the end of the lap casts a shadow on the ring beneath it so you can see where it is.
ActivityRings draws them with ECharts gauge series. It's decorative: put the numbers beside it in text, as the example does, and wrap both in a ChartFrame with a summary.
import { ActivityRings } from "@/components/charts"
<ActivityRings rings={{ move: { value: 486, goal: 600 }, exercise: { value: 38, goal: 30 }, stand: { value: 10, goal: 12 } }} size={168} />For several sets in one canvas, like a week, use ringSeries(theme, rings, { center, size }) in your own option.
Ring colors come from --mb-chart-move, --mb-chart-exercise and --mb-chart-stand. In the Ember theme they follow its pink, green and cyan; in the System theme they're close to Apple Watch's.
Hours of the day
Each ring's day as 24 small bars. Missed Stand hours during the day are gray, so a gap reads as a gap rather than nothing. Only the last strip shows the hours.
| Hour | Move (kcal) | Exercise (min) | Stood |
|---|---|---|---|
| 12 AM | 0 | 0 | No |
| 1 AM | 0 | 0 | No |
| 2 AM | 0 | 0 | No |
| 3 AM | 0 | 0 | No |
| 4 AM | 0 | 0 | No |
| 5 AM | 0 | 0 | No |
| 6 AM | 8 | 0 | No |
| 7 AM | 46 | 9 | Yes |
| 8 AM | 62 | 11 | Yes |
| 9 AM | 18 | 0 | Yes |
| 10 AM | 12 | 0 | No |
| 11 AM | 24 | 2 | Yes |
| 12 PM | 38 | 4 | Yes |
| 1 PM | 16 | 0 | Yes |
| 2 PM | 14 | 0 | No |
| 3 PM | 12 | 0 | Yes |
| 4 PM | 58 | 5 | Yes |
| 5 PM | 84 | 7 | Yes |
| 6 PM | 41 | 0 | Yes |
| 7 PM | 22 | 0 | No |
| 8 PM | 15 | 0 | Yes |
| 9 PM | 9 | 0 | No |
| 10 PM | 4 | 0 | No |
| 11 PM | 0 | 0 | No |
"use client"
import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, EChart, type ChartColor, type ChartTheme } from "@/components/charts"
/** Today, hour by hour. Stand is whether you stood for a minute in that hour. */
const move = [0, 0, 0, 0, 0, 0, 8, 46, 62, 18, 12, 24, 38, 16, 14, 12, 58, 84, 41, 22, 15, 9, 4, 0]
const exercise = [0, 0, 0, 0, 0, 0, 0, 9, 11, 0, 0, 2, 4, 0, 0, 0, 5, 7, 0, 0, 0, 0, 0, 0]
const stood = [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0]
const hours = Array.from({ length: 24 }, (_, h) => `${h % 12 || 12} ${h < 12 ? "AM" : "PM"}`)
const labels = hours.map((label, h) => (h % 6 === 0 ? (h % 12 === 0 ? label : String(h % 12)) : ""))
function Strip({ values, color, max, axis, missed }: { values: readonly number[]; color: ChartColor; max: number; axis?: boolean; missed?: boolean }) {
const option = React.useCallback(
(theme: ChartTheme) => ({
...baseOption(theme),
grid: { left: 0, right: 0, top: 2, bottom: axis ? 20 : 2 },
xAxis: categoryAxis(theme, labels, {
axisLabel: { show: axis, color: theme.colors.axis, fontSize: theme.fontSize, interval: (i: number) => labels[i] !== "", align: "left" },
splitLine: { show: true, interval: (i: number) => i > 0 && i % 6 === 0, lineStyle: { color: theme.colors.grid, type: [2, 3] } },
}),
yAxis: { type: "value", show: false, max },
series: [
{
id: "hours",
type: "bar",
barWidth: "56%",
// A missed stand hour during the day still gets a gray bar, so the gaps read as gaps.
data: values.map((v, h) => ({
value: missed && v === 0 && h >= 7 && h <= 21 ? max : v,
itemStyle: { color: missed && v === 0 ? theme.colors.fill : theme.colors[color] },
})),
itemStyle: { borderRadius: 2 },
emphasis: { disabled: true },
},
],
}),
[values, color, max, axis, missed]
)
return <EChart option={option} height={axis ? 62 : 42} />
}
const rows = [
{ label: "Move", total: "486/600", unit: "KCAL", color: "move", values: move, max: 90 },
{ label: "Exercise", total: "38/30", unit: "MIN", color: "exercise", values: exercise, max: 12 },
{ label: "Stand", total: "10/12", unit: "HRS", color: "stand", values: stood, max: 1 },
] as const
export default function HourlyActivity() {
return (
<ChartFrame
title="Activity by hour, today"
summary="Most of today's Move and Exercise came from walks at 8 AM and 5 PM. You stood in 10 of 12 hours."
table={{
columns: ["Hour", "Move (kcal)", "Exercise (min)", "Stood"],
rows: hours.map((hour, h) => [hour, move[h]!, exercise[h]!, stood[h] ? "Yes" : "No"]),
}}
style={{ width: "100%", maxWidth: 520, gap: 14 }}
>
{rows.map((row, i) => (
<div key={row.label} style={{ display: "grid", gap: 4 }}>
<div style={{ font: "var(--mb-text-subheadline)", fontWeight: 600 }}>
{row.label}{" "}
<span style={{ color: `var(--mb-chart-${row.color})`, fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
{row.total} {row.unit}
</span>
</div>
<Strip values={row.values} color={row.color} max={row.max} axis={i === rows.length - 1} missed={row.label === "Stand"} />
</div>
))}
</ChartFrame>
)
}Heart rate zones
A workout's heart rate as a line colored by zone, and the time spent in each zone as capsules. The line's color comes from a vertical gradient with hard stops at each zone's lower bound, so it changes exactly where the heart rate crosses one.
| Zone | Heart rate | Time |
|---|---|---|
| Zone 1 | <125 BPM | 5:00 |
| Zone 2 | 125–137 BPM | 4:30 |
| Zone 3 | 138–150 BPM | 24:00 |
| Zone 4 | 151–163 BPM | 8:30 |
| Zone 5 | 164+ BPM | 0:30 |
"use client"
import * as React from "react"
import { baseOption, ChartFrame, ChartHeader, EChart, echarts, type ChartColor, type ChartTheme } from "@/components/charts"
/** Heart rate zones for this runner, with Apple Watch's colors. */
const zones = [
{ name: "Zone 1", range: "<125 BPM", from: 0, color: "cyan" },
{ name: "Zone 2", range: "125–137 BPM", from: 125, color: "green" },
{ name: "Zone 3", range: "138–150 BPM", from: 138, color: "yellow" },
{ name: "Zone 4", range: "151–163 BPM", from: 151, color: "orange" },
{ name: "Zone 5", range: "164+ BPM", from: 164, color: "pink" },
] as const satisfies { name: string; range: string; from: number; color: ChartColor }[]
/** A 42-minute outdoor run, every 30 seconds: warm-up, tempo, two hard intervals, cool-down. */
const heartRate = Array.from({ length: 85 }, (_, i) => {
const t = i / 2
const base = t < 6 ? 108 + t * 6 : t < 36 ? 146 + Math.sin(t * 0.8) * 4 : 146 - (t - 36) * 5
const interval = (t > 20 && t < 24) || (t > 28 && t < 32) ? 18 : 0
return Math.round(base + interval + Math.sin(i * 1.3) * 2)
})
const zoneOf = (bpm: number) => zones.findLastIndex((z) => bpm >= z.from)
const minutes = zones.map((_, z) => heartRate.filter((bpm) => zoneOf(bpm) === z).length / 2)
const clock = (m: number) => `${Math.floor(m)}:${String(Math.round((m % 1) * 60)).padStart(2, "0")}`
const average = Math.round(heartRate.reduce((a, b) => a + b, 0) / heartRate.length)
export default function WorkoutZones() {
const line = React.useCallback((theme: ChartTheme) => {
const low = Math.min(...heartRate)
const high = Math.max(...heartRate)
// Hard color stops at each zone boundary, mapped onto the line's own height.
const stops = zones.flatMap((zone, z) => {
const start = Math.min(Math.max((zone.from - low) / (high - low), 0), 1)
const end = z < zones.length - 1 ? Math.min(Math.max((zones[z + 1]!.from - low) / (high - low), 0), 1) : 1
const color = theme.colors[zone.color]
return [
{ offset: start, color },
{ offset: end, color },
]
})
return {
...baseOption(theme),
grid: { left: 0, right: 34, top: 8, bottom: 22 },
xAxis: {
type: "category",
data: heartRate.map((_, i) => i / 2),
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, interval: 19, formatter: (m: string) => `${m} min` },
},
yAxis: {
type: "value",
position: "right",
min: 100,
max: 180,
interval: 40,
axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize },
splitLine: { lineStyle: { color: theme.colors.grid } },
},
series: [
{
id: "heart-rate",
type: "line",
data: heartRate,
smooth: 0.3,
showSymbol: false,
lineStyle: { width: 2.5, cap: "round", color: new echarts.graphic.LinearGradient(0, 1, 0, 0, stops) },
},
],
}
}, [])
const bars = React.useCallback(
(theme: ChartTheme) => ({
...baseOption(theme),
grid: { left: 0, right: 40, top: 0, bottom: 0 },
xAxis: { type: "value", show: false, max: Math.max(...minutes) * 1.05 },
yAxis: {
type: "category",
inverse: true,
data: zones.map((z) => z.name),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
margin: 12,
formatter: (name: string) => `{zone|${name}}\n{range|${zones.find((z) => z.name === name)!.range}}`,
rich: {
zone: { color: theme.colors.label, fontSize: theme.fontSize + 2, fontWeight: 600, lineHeight: theme.fontSize + 6 },
range: { color: theme.colors.axis, fontSize: theme.fontSize, lineHeight: theme.fontSize + 4 },
},
},
},
series: [
{
id: "minutes",
type: "bar",
barWidth: 10,
data: minutes.map((m, z) => ({ value: m, itemStyle: { color: theme.colors[zones[z]!.color] } })),
itemStyle: { borderRadius: 99 },
showBackground: true,
backgroundStyle: { color: theme.colors.fill4, borderRadius: 99 },
label: {
show: true,
position: "right",
distance: 8,
color: theme.colors.label,
fontSize: theme.fontSize + 1,
fontWeight: 600,
formatter: ({ value }: { value: number }) => clock(value),
},
emphasis: { disabled: true },
},
],
}),
[]
)
return (
<ChartFrame
title="Heart rate zones, outdoor run"
summary={`A 42-minute run averaging ${average} BPM. Most of it, ${clock(Math.max(...minutes))}, was in ${zones[minutes.indexOf(Math.max(...minutes))]!.name}.`}
table={{ columns: ["Zone", "Heart rate", "Time"], rows: zones.map((z, i) => [z.name, z.range, clock(minutes[i]!)]) }}
style={{ width: "100%", maxWidth: 520 }}
>
<ChartHeader eyebrow="Average heart rate" value={average} unit="BPM" detail="Outdoor Run · 42 min" />
<EChart option={line} height={150} />
<EChart option={bars} height={190} />
</ChartFrame>
)
}This week
Seven sets of rings in one canvas, with today's letter filled. The table gives each day's three numbers.
| Day | Move (kcal) | Exercise (min) | Stand (hr) |
|---|---|---|---|
| Sunday | 412 of 600 | 22 of 30 | 9 of 12 |
| Monday | 655 of 600 | 41 of 30 | 12 of 12 |
| Tuesday | 590 of 600 | 31 of 30 | 11 of 12 |
| Wednesday | 802 of 600 | 64 of 30 | 13 of 12 |
| Thursday | 534 of 600 | 28 of 30 | 12 of 12 |
| Friday | 611 of 600 | 35 of 30 | 10 of 12 |
| Today | 486 of 600 | 38 of 30 | 10 of 12 |
"use client"
import * as React from "react"
import { baseOption, ChartFrame, EChart, ringSeries, type ChartTheme, type Rings } from "@/components/charts"
/** Sunday, November 8 to today. */
const week: { day: string; name: string; rings: Rings }[] = [
{ day: "S", name: "Sunday", rings: { move: { value: 412, goal: 600 }, exercise: { value: 22, goal: 30 }, stand: { value: 9, goal: 12 } } },
{ day: "M", name: "Monday", rings: { move: { value: 655, goal: 600 }, exercise: { value: 41, goal: 30 }, stand: { value: 12, goal: 12 } } },
{ day: "T", name: "Tuesday", rings: { move: { value: 590, goal: 600 }, exercise: { value: 31, goal: 30 }, stand: { value: 11, goal: 12 } } },
{ day: "W", name: "Wednesday", rings: { move: { value: 802, goal: 600 }, exercise: { value: 64, goal: 30 }, stand: { value: 13, goal: 12 } } },
{ day: "T", name: "Thursday", rings: { move: { value: 534, goal: 600 }, exercise: { value: 28, goal: 30 }, stand: { value: 12, goal: 12 } } },
{ day: "F", name: "Friday", rings: { move: { value: 611, goal: 600 }, exercise: { value: 35, goal: 30 }, stand: { value: 10, goal: 12 } } },
{ day: "S", name: "Today", rings: { move: { value: 486, goal: 600 }, exercise: { value: 38, goal: 30 }, stand: { value: 10, goal: 12 } } },
]
const closed = (r: Rings) => [r.move, r.exercise, r.stand].filter((ring) => ring.value >= ring.goal).length
export default function WeeklyRings() {
const option = React.useCallback(
(theme: ChartTheme) => ({
...baseOption(theme),
series: week.flatMap((d, i) =>
ringSeries(theme, d.rings, { id: `day-${i}`, size: 38, center: [`${((i + 0.5) / week.length) * 100}%`, "50%"] })
),
}),
[]
)
return (
<ChartFrame
title="Activity rings this week"
summary={`You closed all three rings on ${week.filter((d) => closed(d.rings) === 3).length} of 7 days.`}
table={{
columns: ["Day", "Move (kcal)", "Exercise (min)", "Stand (hr)"],
rows: week.map((d) => [d.name, `${d.rings.move.value} of ${d.rings.move.goal}`, `${d.rings.exercise.value} of ${d.rings.exercise.goal}`, `${d.rings.stand.value} of ${d.rings.stand.goal}`]),
}}
style={{ width: "100%", maxWidth: 420, gap: 6 }}
>
<EChart option={option} height={44} />
<div aria-hidden="true" style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", justifyItems: "center" }}>
{week.map((d, i) => (
<span
key={i}
style={{
display: "grid",
placeItems: "center",
width: 22,
height: 22,
borderRadius: "50%",
font: "var(--mb-text-footnote)",
fontWeight: 600,
color: i === week.length - 1 ? "var(--mb-bg)" : "var(--mb-label-secondary)",
background: i === week.length - 1 ? "var(--mb-label)" : "transparent",
}}
>
{d.day}
</span>
))}
</div>
</ChartFrame>
)
}Trends
Fitness compares your last 90 days with the last year. A filled arrow means a metric is going up; an outlined arrow means it's going down, and the words beside it say so too.
- Move512 KCAL/DAYUp from 468
- Exercise36 MIN/DAYUp from 29
- Stand11 HR/DAYDown from 12
- Distance4.2 MI/DAYUp from 3.6
- Walking Pace17′42″ /MIFaster than 18′05″
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"
/** The last 90 days against the last 365, as Fitness compares them. */
const trends = [
{ label: "Move", color: "var(--mb-chart-move)", now: "512", before: "468", unit: "KCAL/DAY", up: true },
{ label: "Exercise", color: "var(--mb-chart-exercise)", now: "36", before: "29", unit: "MIN/DAY", up: true },
{ label: "Stand", color: "var(--mb-chart-stand)", now: "11", before: "12", unit: "HR/DAY", up: false },
{ label: "Distance", color: "var(--mb-blue)", now: "4.2", before: "3.6", unit: "MI/DAY", up: true },
{ label: "Walking Pace", color: "var(--mb-teal)", now: "17′42″", before: "18′05″", unit: "/MI", up: true, note: "Faster than 18′05″" },
]
export default function FitnessTrends() {
return (
<ul aria-label="Trends, last 90 days compared with the last year" style={{ display: "grid", gap: 14, width: "100%", maxWidth: 420, margin: 0, padding: 0, listStyle: "none" }}>
{trends.map((t) => {
const Arrow = t.up ? ArrowUpIcon : ArrowDownIcon
return (
<li key={t.label} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span
aria-hidden="true"
style={{
display: "grid",
placeItems: "center",
flex: "none",
width: 30,
height: 30,
borderRadius: "50%",
// Going down is drawn as an outline, so it doesn't rely on color alone.
background: t.up ? t.color : "transparent",
boxShadow: t.up ? "none" : `inset 0 0 0 2px ${t.color}`,
color: t.up ? "var(--mb-bg)" : t.color,
}}
>
<Arrow style={{ width: 18, height: 18 }} strokeWidth={3} />
</span>
<div style={{ display: "grid", gap: 1, minWidth: 0 }}>
<span style={{ color: t.color, font: "var(--mb-text-subheadline)", fontWeight: 600 }}>{t.label}</span>
<span style={{ font: "var(--mb-text-title-3)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
{t.now}
<span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", fontWeight: 600 }}> {t.unit}</span>
</span>
</div>
<span style={{ marginInlineStart: "auto", color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", textAlign: "end" }}>
{"note" in t ? t.note : `${t.up ? "Up" : "Down"} from ${t.before}`}
</span>
</li>
)
})}
</ul>
)
}Accessibility
- Rings, strips and zones are named figures with summaries, like "Move 486 of 600 kcal, 81%", and tables of every value.
- The ring colors are always paired with the ring's name and numbers in text.
- Trend arrows are hidden from screen readers; each row says "Up from 468" or "Down from 12".