ActivityRings
Progress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.
Demo
Friday
- Move: 420 of 600 kcal, 70 percent.
- Exercise: 45 of 30 min, 150 percent, goal reached.
- Stand: 12 of 12 h, 100 percent, goal reached.
Round again
- Steps: 25000 of 10000 steps, 250 percent, goal reached.
- Water: 4 of 2 L, 200 percent, goal reached.
- No goal: 5 of 0, 0 percent.
- Text: 0 of 10, 0 percent.
- Below: 0 of 10, 0 percent.
A value that changes
- Move: 590 of 600 kcal, 98 percent.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/activity_rings.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/activity_rings.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/activity_rings.qwe
let activity_rings_demo_day = [ { label: "Move", value: 420, goal: 600, unit: "kcal" }, { label: "Exercise", value: 45, goal: 30, unit: "min", tone: "success" }, { label: "Stand", value: 12, goal: 12, unit: "h", tone: "info" },]component ActivityRingsDemoLive() { state move = 590 <div.demo-stack> <div id="activity-rings-live"> <ActivityRings title="A value that changes" size={160} rings={[{ label: "Move", value: move, goal: 600, unit: "kcal", tone: "danger" }]}/> </div> <div.demo-row><Button id="activity-rings-add" size="lg" variant="soft" on_click={() => { move += 20 }}>Add 20 kcal</Button></div> </div>}view ActivityRingsDemo() = <div.demo-stack> <div id="activity-rings-day"><ActivityRings title="Friday" center="Fri" rings={activity_rings_demo_day}/></div> <div id="activity-rings-far"><ActivityRings title="Round again" rings={[ { label: "Steps", value: 25000, goal: 10000, unit: "steps", tone: "warning" }, { label: "Water", value: 4, goal: 2, unit: "L" }, ]} size={140}/></div> <div id="activity-rings-bad"><ActivityRings rings={[null, "x", { value: 3 }, { label: "No goal", value: 5, goal: 0 }, { label: "Text", value: "many", goal: 10 }, { label: "Below", value: -4, goal: 10 }]} size={120}/></div> <ActivityRingsDemoLive/> </div>Source
ui/activity_rings.qwe
// ActivityRings: progress towards a few goals as rings inside each other, as a watch shows// a day's moving, exercise and standing. A ring past its goal goes round again over// itself, in a lighter colour with a shaded head, so 150% and 50% do not look alike. The// rings draw themselves once, as the page opens, by CSS alone; a value that changes later// slides there and does not start over. The legend says each ring in words, and screen// readers get a whole sentence for each, not a picture.//// <ActivityRings title="Today" center="Fri" rings={[// { label: "Move", value: 420, goal: 600, unit: "kcal" },// { label: "Exercise", value: 45, goal: 30, unit: "min", tone: "success" },// { label: "Stand", value: 9, goal: 12, unit: "h", tone: "info" },// ]}/>//// rings: each { label, value, goal, unit?, tone? }, the first on the outside. tone is// "accent", "success", "warning", "danger" or "info", and they take those in turn when// left out. A goal of 0 or less counts as nothing done, a value that is not a number as 0,// and an entry with no label is left out; six are drawn at most. title: what the// rings are, for screen readers and over the legend. center: a word in the middle.// size: how wide the rings are, in pixels (200), a little wider in a wide container.// class.//// A view: it adds nothing to the page's script. As the page opens it runs whether or not// it can be seen, so rings far down a long page will have finished when they are reached.import ui/theme.qwe use ui_clslet activity_rings_css = css`.ui-arings { container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-arings-in { display: flex; flex-direction: column; align-items: center; gap: 14px; }.ui-arings-pic { position: relative; flex: none; width: min(100%, var(--ui-arings-size)); aspect-ratio: 1; }.ui-arings-pic svg { display: block; width: 100%; height: 100%; transform: rotate(-90deg); overflow: visible; }.ui-arings-pic circle { fill: none; }.ui-arings-track { stroke: color-mix(in srgb, var(--t) 16%, transparent); }.ui-arings-arc { stroke: var(--t); stroke-linecap: round; transition: stroke-dasharray .45s ease; animation: ui-arings-draw .9s cubic-bezier(.3, .7, .2, 1) backwards; }.ui-arings-lap { stroke: color-mix(in srgb, var(--t) 74%, #fff); animation-delay: .85s; animation-duration: .6s; }.ui-arings-pic .ui-arings-head { fill: color-mix(in srgb, var(--t) 74%, #fff); stroke: rgba(0, 0, 0, .32); stroke-width: 1.5; animation: ui-arings-show .2s 1.3s backwards; }.ui-arings-none { visibility: hidden; }@keyframes ui-arings-draw { from { stroke-dashoffset: var(--ui-arings-d); opacity: 0; } 6% { opacity: 1; } }@keyframes ui-arings-show { from { opacity: 0; } }.ui-arings-center { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 650; font-size: 13px; color: var(--ui-muted); pointer-events: none; }.ui-arings-side { width: 100%; min-width: 0; }.ui-arings-title { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--ui-muted); }.ui-arings-legend { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-arings-item { position: relative; display: grid; grid-template-columns: 12px 1fr auto; align-items: baseline; gap: 2px 8px; }.ui-arings-dot { align-self: center; width: 12px; height: 12px; border-radius: 50%; background: var(--t); }.ui-arings-name { font-weight: 600; overflow-wrap: anywhere; }.ui-arings-pct { font-weight: 650; font-variant-numeric: tabular-nums; color: var(--t); }.ui-arings-of { grid-column: 2 / -1; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-arings-tone-accent { --t: var(--ui-accent); }.ui-arings-tone-success { --t: var(--ui-success); }.ui-arings-tone-warning { --t: var(--ui-warning); }.ui-arings-tone-danger { --t: var(--ui-danger); }.ui-arings-tone-info { --t: var(--ui-info); }@container (min-width: 360px) { .ui-arings-in { flex-direction: row; gap: 22px; } .ui-arings-pic { width: min(52%, var(--ui-arings-size)); }}@container (min-width: 620px) { .ui-arings-in { gap: 40px; } .ui-arings-pic { width: calc(var(--ui-arings-size) * 1.4); } .ui-arings-legend { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; } .ui-arings-pct { font-size: 20px; }}@media (prefers-reduced-motion: reduce) { .ui-arings-arc, .ui-arings-pic .ui-arings-head { animation: none; transition: none; }}`let activity_rings_tones = ["accent", "success", "warning", "danger", "info"]// A number from whatever came, else 0: never below 0, never NaN or endless.fn activity_rings_num(x) -> float { let t = type_of(x) if t != "int" && t != "float" && t != "dec" { return 0.0 } let f = float(x) if f != f || f <= 0 || f > 1000000000 { return 0.0 } f}// How far along, in percent: past 100 is a goal passed, and 9999 is as far as it goes.fn activity_rings_pct(value, goal) -> float { let g = activity_rings_num(goal) if g <= 0 { return 0.0 } let p = activity_rings_num(value) * 100 / g p > 9999 ? 9999.0 : p}// What the two arcs show out of the 100 of a ring: the first lap, then what is left of the// last one, a whole lap when it lands exactly on the goal again.fn activity_rings_laps(pct: num) { let rest = pct % 100 { base: pct > 100 ? 100 : round(pct, 2), lap: pct <= 100 ? 0 : (rest == 0 ? 100 : round(rest, 2)), }}// The thickness of each ring and the gap for n of them, in a picture 200 wide with a free middle.fn activity_rings_widths(n: int) { let each = 84 / max([n, 1]) let w = min([round(each * 0.84, 1), 20]) { w, gap: min([round(each * 0.16, 1), 4]) }}// Where the end of a lap is, as seen before the picture is turned a quarter back.fn activity_rings_head(lap: num, r: num) { let a = lap / 100 * 2 * math.pi { x: round(100 + r * math.cos(a), 2), y: round(100 + r * math.sin(a), 2) }}// A value without its trailing zeros, to one decimal.fn activity_rings_show(x) -> str { let r = round(activity_rings_num(x) * 10) / 10 r == floor(r) ? str(floor(r)) : str(r)}fn activity_rings_said(r) -> str { let unit = filled(r.unit) ?? "" let of = `${r.label}: ${activity_rings_show(r.value)} of ${activity_rings_show(r.goal)}${unit != "" ? " " + unit : ""}, ${round(r.pct)} percent` r.pct >= 100 ? `${of}, goal reached.` : `${of}.`}// The rings that can be drawn, each with all it needs.fn activity_rings_clean(rings) { if type_of(rings) != "list" { return [] } let named = rings | filter((r) => type_of(r) == "map" && filled(get(r, "label")) != null) | take(6) let sized = activity_rings_widths(len(named)) for [i, r] in enumerate(named) => { let tone = get(r, "tone") let pct = activity_rings_pct(get(r, "value"), get(r, "goal")) let radius = round(98 - sized.w / 2 - i * (sized.w + sized.gap), 1) { i, label: str(r.label), value: get(r, "value"), goal: get(r, "goal"), unit: get(r, "unit"), pct, radius, w: sized.w, tone: contains(activity_rings_tones, tone) ? tone : activity_rings_tones[i % len(activity_rings_tones)], ...activity_rings_laps(pct), } }}view ActivityRings(rings = [], title: str? = null, center: str? = null, size: int = 200, class: str? = null) { style global activity_rings_css let shown = activity_rings_clean(rings) <figure class={ui_cls(["ui-arings", class])} style={`--ui-arings-size: ${size}px; margin: 0`}> <div.ui-arings-in> <div.ui-arings-pic aria-hidden="true"> <svg viewBox="0 0 200 200"> {for r in shown => <g key={r.i} class={`ui-arings-tone-${r.tone}`}> <circle.ui-arings-track cx="100" cy="100" r={r.radius} stroke-width={r.w}/> <circle cx="100" cy="100" r={r.radius} stroke-width={r.w} pathLength="100" class={ui_cls(["ui-arings-arc", r.base == 0 && "ui-arings-none"])} style={`stroke-dasharray: ${r.base} 100; --ui-arings-d: ${r.base}`}/> <circle cx="100" cy="100" r={r.radius} stroke-width={r.w} pathLength="100" class={ui_cls(["ui-arings-arc", "ui-arings-lap", r.lap == 0 && "ui-arings-none"])} style={`stroke-dasharray: ${r.lap} 100; --ui-arings-d: ${r.lap}`}/> <circle cx={activity_rings_head(r.lap, r.radius).x} cy={activity_rings_head(r.lap, r.radius).y} r={round(r.w / 2, 1)} class={ui_cls(["ui-arings-head", r.lap == 0 && "ui-arings-none"])}/> </g>} </svg> {filled(center) != null && <span.ui-arings-center>{center}</span>} </div> <figcaption.ui-arings-side> {filled(title) != null && <p.ui-arings-title>{title}</p>} <ul.ui-arings-legend> {for r in shown => <li key={r.i} class={`ui-arings-item ui-arings-tone-${r.tone}`}> <span.ui-arings-dot aria-hidden="true"></span> <span.ui-arings-name aria-hidden="true">{r.label}</span> <span.ui-arings-pct aria-hidden="true">{round(r.pct)}%</span> <span.ui-arings-of aria-hidden="true">{activity_rings_show(r.value)} / {activity_rings_show(r.goal)} {filled(r.unit) ?? ""}</span> <span.ui-sr>{activity_rings_said(r)}</span> </li>} </ul> </figcaption> </div> </figure>}Related
SleepChartStages through a night as a chart read by finger, with totals, right across midnight and a change of the clocks.4 views · 8 downloadsWaterTrackerTap a glass to add what was drunk, with undo, a goal and a day that starts empty at the phone's own midnight.2 views · 7 downloadsWeightTrendA smoothed trend over noisy weigh-ins, in kg or lb, with a goal line, read by finger, and holes left as holes.3 views · 5 downloadsWorkoutTimerIntervals of work and rest with a large countdown, felt as well as seen, the screen kept on, and time that stays right when the screen sleeps.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloads
Thread
Thread 0 posts
On ActivityRings: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.