Charts and tablesRadial goals
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callRadial goals
Each agent's share of its own target as a ring, with the overall score in the middle. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
| Agent | Done | Target | Share |
|---|
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add radial-goalsReact
npx designme add radial-goals --reactAstro
npx designme add radial-goals --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/radial-goals.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/radial-goals.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
Concentric rings, one for each agent, each filled to its share of that agent's own target, so agents with very different targets sit side by side. The middle shows the overall share, or the agent you point at. Every value is printed in the legend as done / target, a ring past its target goes round again with a marker where it ends, and a Week and Month switch changes the period.
- To show how several agents (or teams, or goals) are doing against targets of different sizes.
- On a weekly review where the question is who is on target and who is behind.
- When people need the exact numbers as well as the picture.
- A thick ring per goal, outermost first, in the chart palette, on a grey track. Three goals read best; the rings share the space between the edge and the readout, so the more goals there are, the thinner each ring.
- Past its target a ring goes round again from the top, its end cutting into the ring under it (in Soft) with a small marker on it, and its share shows as a green tag ("110%").
- The middle shows the average share across the goals, or the share of the goal you point at, counting to the new value.
- Legend rows print each goal's done / target with its unit and its share.
- Pointing at a ring or a row, or moving the focus to a row, lights both and dims the other rings.
- An optional range switch (
ranges,range,onRangeChange), and a line under the title that says how many are on target.
- Each legend row can take the focus and has a label with the numbers, like "Research agent: 18 of 24 reports, 75% of target".
- A visually hidden table carries every goal, its done and target values and its share, with the title as its caption.
- The rings are hidden from screen readers, since the rows and the table hold the same numbers.
- Values are always printed, so nothing depends on telling the ring colours apart.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { RadialGoals } from '@/components/designme/radial-goals/RadialGoals';React example
The demo above as a React component, 30 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the week, then the month.
import { useEffect, useRef, useState } from 'react';
import { RadialGoals, type RadialGoal } from './RadialGoals';
import { useStory } from '../lib/useScript';
const DATA: Record<'w' | 'm', RadialGoal[]> = {
w: [{ key: 'support', label: 'Support agent', unit: 'tickets', value: 412, target: 375 }, { key: 'qa', label: 'QA agent', unit: 'checks', value: 136, target: 160 },
{ key: 'research', label: 'Research agent', unit: 'reports', value: 18, target: 24, color: 'var(--dm-c4)' }],
m: [{ key: 'support', label: 'Support agent', unit: 'tickets', value: 1490, target: 1600 }, { key: 'qa', label: 'QA agent', unit: 'checks', value: 702, target: 640 },
{ key: 'research', label: 'Research agent', unit: 'reports', value: 71, target: 96, color: 'var(--dm-c4)' }]
};
const EMPTY = DATA.w.map(g => ({ ...g, value: 0 }));
export default function RadialGoalsDemo() {
const ref = useRef<HTMLDivElement>(null);
const [range, setRange] = useState<'w' | 'm'>('w');
// the rings sweep in a moment after the card appears
const [drawn, setDrawn] = useState(false);
useEffect(() => { const t = setTimeout(() => setDrawn(true), 260); return () => clearTimeout(t); }, []);
useStory(ref, async ({ wait }) => { await wait(7000); setRange('m'); await wait(2000); setRange('w'); });
const on = DATA[range].filter(g => g.value >= g.target).length;
return (
<div ref={ref} style={{ width: '100%' }}>
<RadialGoals goals={drawn ? DATA[range] : EMPTY} subtitle={`${range === 'w' ? 'This week' : 'This month'}, ${on} of ${DATA[range].length} on target`}
ranges={[{ value: 'w', label: 'Week' }, { value: 'm', label: 'Month' }]} range={range} onRangeChange={setRange} />
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its radial-goals.js to show your own data. Attributes like class go on its root element.
---
import RadialGoals from '../components/designme/radial-goals/RadialGoals.astro';
---
<RadialGoals />
<RadialGoals data-style="soft" class="my-class" />Props
Everything RadialGoals takes.
| Prop | Type | What it does |
|---|---|---|
goalsrequired | RadialGoal[] | Two to five goals, outermost first. Three read best: the fewer the rings, the thicker they are |
title | string | |
subtitle | string | The line under the title. Defaults to "1 of 3 on target" |
ranges | { value: R; label: string }[] | A switch for the period, like Week and Month |
range | R | |
onRangeChange | (range: R) => void | |
className | string |
Also takes the props of string = string>.
Code
The full source, as the CLI installs it.
RadialGoals.tsxReact · 93 lines
'use client';
import { useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useTween } from '../lib/hooks';
import { Seg, Swap } from '../lib/motion';
import '../designme.css';
import './radial-goals.css';
export interface RadialGoal {
key: string;
/** Like "Support agent" */
label: string;
/** What it counts, like "tickets" */
unit: string;
value: number;
target: number;
/** Defaults to the chart palette in order */
color?: string;
}
export interface RadialGoalsProps<R extends string = string> {
/** Two to five goals, outermost first. Three read best: the fewer the rings, the thicker they are */
goals: RadialGoal[];
title?: string;
/** The line under the title. Defaults to "1 of 3 on target" */
subtitle?: string;
/** A switch for the period, like Week and Month */
ranges?: { value: R; label: string }[];
range?: R;
onRangeChange?: (range: R) => void;
className?: string;
}
const PAL = ['var(--dm-c1)', 'var(--dm-c3)', 'var(--dm-c4)', 'var(--dm-c2)', 'var(--dm-c5)'];
const pc = (x: number) => Math.round(x * 100) + '%';
/** Concentric rings, one per agent, each its share of its own target; past the target a ring goes round again with a marker. */
export function RadialGoals<R extends string = string>({ goals, title = 'Agent goals', subtitle, ranges, range, onRangeChange, className }: RadialGoalsProps<R>) {
const [focus, setFocus] = useState(-1);
// the rings share the band from the outer edge (85) to the readout in the middle (34) with a 4 unit gap between them, so
// three goals get rings 14 units wide; never wider than 16
const W = Math.min(16, (51 - 4 * (goals.length - 1)) / Math.max(1, goals.length));
const RAD = goals.map((_, i) => 85 - W / 2 - i * (W + 4));
const share = (g: RadialGoal) => g.target ? g.value / g.target : 0;
const overall = goals.reduce((t, g) => t + share(g), 0) / Math.max(1, goals.length);
const mid = useTween(focus < 0 ? overall : share(goals[focus]), 600, 0);
const on = goals.filter(g => share(g) >= 1).length;
return (
<div data-dm="" className={cx('dm-rgx dm-panel', className)}>
<div className="dm-rgx-h">
<div className="dm-rgx-t"><b>{title}</b><Swap className="dm-muted dm-rgx-sub">{subtitle ?? `${on} of ${goals.length} on target`}</Swap></div>
{ranges && range !== undefined && onRangeChange && <Seg className="dm-rgx-sg" label="Range" options={ranges} value={range} onChange={onRangeChange} />}
</div>
<div className="dm-rgx-b">
<div className={cx('dm-rgx-ring', focus >= 0 && 'dm-hov')}>
<svg viewBox="0 0 180 180" aria-hidden="true" style={{ ['--dm-rw' as string]: W } as CSSProperties}>
{goals.map((g, i) => { const s = share(g), c = g.color ?? PAL[i % PAL.length], an = (s > 1 ? s - 1 : s) * 2 * Math.PI;
return (
<g key={g.key} className={cx(i === focus && 'dm-on', s > 1 && 'dm-lap')} style={{ stroke: c }} onPointerEnter={e => { if (e.pointerType !== 'touch') setFocus(i); }} onPointerLeave={() => setFocus(-1)}>
<circle className="dm-tr" cx={90} cy={90} r={RAD[i]} />
<circle className="dm-fl" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.max(0, Math.min(1, s) * 100 - .01)} 100`} />
{/* past the target the ring goes round again from the top, and a marker sits where it ends */}
{/* under the lap's end only, a band of the surface a little wider than the ring, so the end cuts into the ring under it */}
<circle className="dm-ovb" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.min(3, Math.max(0, s - 1) * 100)} 100`} strokeDashoffset={-Math.max(0, (s - 1) * 100 - 3)} />
<circle className="dm-ov" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.max(0, s - 1) * 100} 100`} />
<circle className="dm-mk" r={Math.max(2.5, W * .29)} cx={90 + Math.cos(an) * RAD[i]} cy={90 + Math.sin(an) * RAD[i]} style={{ stroke: c }} />
</g>
); })}
</svg>
<div className="dm-rgx-c"><b className="dm-num">{pc(mid)}</b><Swap className="dm-muted">{focus < 0 ? 'of targets' : goals[focus].label.replace(/ agent$/, '')}</Swap></div>
</div>
<div className="dm-rgx-lg">
{goals.map((g, i) => { const s = share(g);
return (
<div key={g.key} className={cx('dm-rgx-r', i === focus && 'dm-on')} tabIndex={0} aria-label={`${g.label}: ${g.value} of ${g.target} ${g.unit}, ${pc(s)} of target`}
onPointerEnter={e => { if (e.pointerType !== 'touch') setFocus(i); }} onPointerLeave={e => { if (!e.currentTarget.parentElement?.contains(document.activeElement)) setFocus(-1); }}
onFocus={() => setFocus(i)} onBlur={() => setFocus(-1)}>
<i style={{ background: g.color ?? PAL[i % PAL.length] }} /><span className="dm-n">{g.label}</span>
<span className="dm-p dm-num">{s >= 1 ? <span className="dm-tag dm-ok">{pc(s)}</span> : pc(s)}</span>
<span className="dm-v dm-num">{g.value.toLocaleString('en-US')} / {g.target.toLocaleString('en-US')} {g.unit}</span>
</div>
); })}
</div>
</div>
<div className="dm-rgx-sr"><table>
<caption>{title}</caption>
<thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead>
<tbody>{goals.map(g => <tr key={g.key}><th>{g.label}</th><td>{g.value} {g.unit}</td><td>{g.target}</td><td>{pc(share(g))}</td></tr>)}</tbody>
</table></div>
</div>
);
}
radial-goals.cssReact · 38 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Radial goals ---------- */
/* one ring per agent, each its share of its own target; past the target the ring goes round again, with a marker where it ends */
.dm-rgx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.dm-rgx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.dm-rgx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-rgx-t b{font-weight:500}
.dm-rgx-t .dm-muted{font-size:12px}
.dm-rgx-sg button{font-size:12px;height:26px;padding:0 10px}
.dm-rgx-b{display:flex;align-items:center;gap:var(--dm-s-5);flex-wrap:wrap;justify-content:center}
.dm-rgx-ring{position:relative;width:172px;height:172px;flex:none}
.dm-rgx-ring svg{width:100%;height:100%;transform:rotate(-90deg);overflow:visible}
.dm-rgx-ring circle{fill:none;stroke-width:var(--dm-rw,14);transition:opacity .3s}
.dm-rgx-ring .dm-tr{stroke:var(--dm-chip)}
.dm-rgx-ring .dm-fl,.dm-rgx-ring .dm-ov,.dm-rgx-ring .dm-ovb{stroke-linecap:round;transition:stroke-dasharray .9s var(--dm-ease),stroke-dashoffset .9s var(--dm-ease),opacity .3s}
/* the end of a lap past the target sits on a slightly wider band of the surface, so it cuts into the ring under it and reads as a second lap */
.dm-rgx-ring .dm-ovb{stroke:var(--dm-surface);stroke-width:calc(var(--dm-rw,14) * 1px + 3px)}
.dm-rgx-ring .dm-mk{fill:var(--dm-surface);stroke-width:2.5;transition:opacity .3s}
.dm-rgx-ring.dm-hov g:not(.dm-on) :is(.dm-fl,.dm-ov,.dm-mk){opacity:.25}
/* a ring short of its target has no lap and no marker */
.dm-rgx-ring g:not(.dm-lap) :is(.dm-ov,.dm-ovb,.dm-mk){opacity:0!important}
.dm-rgx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:1.3;text-align:center}
.dm-rgx-c b{font-size:14px;font-weight:500}
.dm-rgx-c .dm-muted{font-size:11px;max-width:72px}
.dm-rgx-lg{flex:1 1 180px;min-width:0;display:flex;flex-direction:column;gap:2px}
.dm-rgx-r{display:grid;grid-template-columns:auto 1fr auto;column-gap:9px;row-gap:1px;align-items:center;width:100%;padding:6px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));font-size:13px;text-align:left;transition:background .2s;cursor:default}
.dm-rgx-r.dm-on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-rgx-r:hover{background:var(--dm-row)}}
.dm-rgx-r i{width:8px;height:8px;border-radius:50%}
.dm-rgx-r .dm-n{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-rgx-r .dm-p{font-size:12.5px;color:var(--dm-fg2);text-align:right}
.dm-rgx-r .dm-v{grid-column:2/4;font-size:12px;color:var(--dm-muted)}
.dm-rgx-r .dm-tag{height:18px;font-size:11px}
.dm-rgx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.dm-rgx{container-type:inline-size}
@container (max-width:300px){.dm-rgx-lg{min-width:0}.dm-rgx-h{flex-wrap:wrap}}
.dm-rgx-sr table{table-layout:fixed;width:1px;border-collapse:collapse}
RadialGoals.astroAstro · 34 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Radial goals: Each agent's share of its own target as a ring, with the overall score in the middle.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change radial-goals.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './radial-goals.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="radial-goals" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="rgx panel">
<div class="rgx-h">
<div class="rgx-t"><b>Agent goals</b><span class="muted rgx-sub">This week</span></div>
<div class="seg2 rgx-sg" role="group" aria-label="Range"><button aria-pressed="true" data-v="w">Week</button><button aria-pressed="false" data-v="m">Month</button></div>
</div>
<div class="rgx-b">
<div class="rgx-ring"><svg viewBox="0 0 180 180" aria-hidden="true"></svg><div class="rgx-c"><b class="num">0%</b><span class="muted">of targets</span></div></div>
<div class="rgx-lg"></div>
</div>
<div class="rgx-sr"><table><caption>Agent goals</caption><thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead><tbody></tbody></table></div>
</div>
</Fragment></div>
</div>
<script>
import './radial-goals.js';
</script>
radial-goals.htmlHTML · 16 lines
<div class="card" data-c="radial-goals">
<div class="stage-in">
<div class="rgx panel">
<div class="rgx-h">
<div class="rgx-t"><b>Agent goals</b><span class="muted rgx-sub">This week</span></div>
<div class="seg2 rgx-sg" role="group" aria-label="Range"><button aria-pressed="true" data-v="w">Week</button><button aria-pressed="false" data-v="m">Month</button></div>
</div>
<div class="rgx-b">
<div class="rgx-ring"><svg viewBox="0 0 180 180" aria-hidden="true"></svg><div class="rgx-c"><b class="num">0%</b><span class="muted">of targets</span></div></div>
<div class="rgx-lg"></div>
</div>
<div class="rgx-sr"><table><caption>Agent goals</caption><thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
</div>
radial-goals.cssHTML · 37 lines
/* ---------- Radial goals ---------- */
/* one ring per agent, each its share of its own target; past the target the ring goes round again, with a marker where it ends */
.rgx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.rgx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.rgx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.rgx-t b{font-weight:500}
.rgx-t .muted{font-size:12px}
.rgx-sg button{font-size:12px;height:26px;padding:0 10px}
.rgx-b{display:flex;align-items:center;gap:var(--dm-s-5);flex-wrap:wrap;justify-content:center}
.rgx-ring{position:relative;width:172px;height:172px;flex:none}
.rgx-ring svg{width:100%;height:100%;transform:rotate(-90deg);overflow:visible}
.rgx-ring circle{fill:none;stroke-width:var(--dm-rw,14);transition:opacity .3s}
.rgx-ring .tr{stroke:var(--dm-chip)}
.rgx-ring .fl,.rgx-ring .ov,.rgx-ring .ovb{stroke-linecap:round;transition:stroke-dasharray .9s var(--dm-ease),stroke-dashoffset .9s var(--dm-ease),opacity .3s}
/* the end of a lap past the target sits on a slightly wider band of the surface, so it cuts into the ring under it and reads as a second lap */
.rgx-ring .ovb{stroke:var(--dm-surface);stroke-width:calc(var(--dm-rw,14) * 1px + 3px)}
.rgx-ring .mk{fill:var(--dm-surface);stroke-width:2.5;transition:opacity .3s}
.rgx-ring.hov g:not(.on) :is(.fl,.ov,.mk){opacity:.25}
/* a ring short of its target has no lap and no marker */
.rgx-ring g:not(.lap) :is(.ov,.ovb,.mk){opacity:0!important}
.rgx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:1.3;text-align:center}
.rgx-c b{font-size:14px;font-weight:500}
.rgx-c .muted{font-size:11px;max-width:72px}
.rgx-lg{flex:1 1 180px;min-width:0;display:flex;flex-direction:column;gap:2px}
.rgx-r{display:grid;grid-template-columns:auto 1fr auto;column-gap:9px;row-gap:1px;align-items:center;width:100%;padding:6px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));font-size:13px;text-align:left;transition:background .2s;cursor:default}
.rgx-r.on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.rgx-r:hover{background:var(--dm-row)}}
.rgx-r i{width:8px;height:8px;border-radius:50%}
.rgx-r .n{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rgx-r .p{font-size:12.5px;color:var(--dm-fg2);text-align:right}
.rgx-r .v{grid-column:2/4;font-size:12px;color:var(--dm-muted)}
.rgx-r .tag{height:18px;font-size:11px}
.rgx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.rgx{container-type:inline-size}
@container (max-width:300px){.rgx-lg{min-width:0}.rgx-h{flex-wrap:wrap}}
.rgx-sr table{table-layout:fixed;width:1px;border-collapse:collapse}
radial-goals.jsHTML · 50 lines
DM.def('radial-goals', (r, c) => {
// each agent, what it counts, and what it did against its target this week and this month
const A = [
{ n:'Support agent', u:'tickets', c:'var(--dm-c1)', w:[412, 375], m:[1490, 1600] },
{ n:'QA agent', u:'checks', c:'var(--dm-c3)', w:[136, 160], m:[702, 640] },
{ n:'Research agent', u:'reports', c:'var(--dm-c4)', w:[18, 24], m:[71, 96] }
];
const svg = $('.rgx-ring svg', r), ring = $('.rgx-ring', r), lg = $('.rgx-lg', r), cb = $('.rgx-c b', r), cl = $('.rgx-c .muted', r), sub = $('.rgx-sub', r), tb = $('.rgx-sr tbody', r);
// three thick rings, 14 units wide with a 4 unit gap between them, leaving room in the middle for the readout
const RAD = [78, 60, 42];
svg.innerHTML = A.map((a, i) => `<g data-i="${i}" style="stroke:${a.c}"><circle class="tr" cx="90" cy="90" r="${RAD[i]}"/><circle class="fl" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="ovb" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="ov" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="mk" r="4" cx="${90 + RAD[i]}" cy="90"/></g>`).join('');
lg.innerHTML = A.map((a, i) => `<div class="rgx-r" data-i="${i}" tabindex="0"><i style="background:${a.c}"></i><span class="n">${a.n}</span><span class="p num"></span><span class="v num"></span></div>`).join('');
const G = $$('g', svg), rows = $$('.rgx-r', lg);
let k = 'w', focus = -1, shown = 0;
const share = a => a[k][0] / a[k][1], pc = x => Math.round(x * 100) + '%';
const overall = () => A.reduce((t, a) => t + share(a), 0) / A.length;
function paint(){
A.forEach((a, i) => { const s = share(a), g = G[i];
// (a hair short of the share: a dash that ends exactly on a quarter of the circle draws a stray notch in Chrome)
$('.fl', g).setAttribute('stroke-dasharray', `${Math.max(0, Math.min(1, s) * 100 - .01)} 100`);
// past the target the ring goes round again from the top, and a marker sits where it ends
const lap = Math.max(0, s - 1) * 100, cut = Math.min(3, lap), ovb = $('.ovb', g);
$('.ov', g).setAttribute('stroke-dasharray', `${lap} 100`);
// under the lap's end only, a band of the surface a little wider than the ring, so the end cuts into the ring under it
ovb.setAttribute('stroke-dasharray', `${cut} 100`); ovb.setAttribute('stroke-dashoffset', -(lap - cut)); g.classList.toggle('lap', s > 1);
const mk = $('.mk', g), an = (s > 1 ? s - 1 : s) * 2 * Math.PI; mk.setAttribute('cx', 90 + Math.cos(an) * RAD[i]); mk.setAttribute('cy', 90 + Math.sin(an) * RAD[i]); mk.style.stroke = a.c;
const row = rows[i]; $('.p', row).innerHTML = s >= 1 ? `<span class="tag ok">${pc(s)}</span>` : pc(s); $('.v', row).textContent = `${a[k][0].toLocaleString('en-US')} / ${a[k][1].toLocaleString('en-US')} ${a.u}`;
row.setAttribute('aria-label', `${a.n}: ${a[k][0]} of ${a[k][1]} ${a.u}, ${pc(s)} of target`); });
tb.innerHTML = A.map(a => `<tr><th>${a.n}</th><td>${a[k][0]} ${a.u}</td><td>${a[k][1]}</td><td>${pc(share(a))}</td></tr>`).join('');
centre();
const on = A.filter(a => share(a) >= 1).length; swap(sub, `${k === 'w' ? 'This week' : 'This month'}, ${on} of ${A.length} on target`);
}
// the middle shows the overall share, or the agent in focus
function centre(){ const t = focus < 0 ? overall() : share(A[focus]), from = shown; shown = t; tween(from, t, 600, x => cb.textContent = pc(x)); swap(cl, focus < 0 ? 'of targets' : A[focus].n.replace(' agent', '')); }
const hl = i => { if (focus === i) return; focus = i; ring.classList.toggle('hov', i >= 0); G.forEach((g, j) => g.classList.toggle('on', j === i)); rows.forEach((x, j) => x.classList.toggle('on', j === i)); centre(); };
[...G, ...rows].forEach(el => { const i = +el.dataset.i; c.on(el, 'pointerenter', e => { if (e.pointerType !== 'touch') hl(i); }); c.on(el, 'pointerleave', () => { if (!lg.contains(document.activeElement)) hl(-1); }); });
rows.forEach((x, i) => { c.on(x, 'focus', () => hl(i)); c.on(x, 'blur', () => hl(-1)); });
seg($('.rgx-sg', r), v => { k = v; paint(); });
cb.textContent = '0%';
c.later(260, paint);
// the preview's story: read the agent furthest behind, then the one past its target, see the month, then back to the week
if (r === c.st) c.story(async s => {
await s.beat(5);
await s.point(rows[2]); hl(2); await s.beat(3);
await s.point(rows[0]); hl(0); await s.beat(3);
hl(-1); await s.tap($('.rgx-sg [data-v="m"]', r)); await s.beat(4);
await s.tap($('.rgx-sg [data-v="w"]', r)); s.away(); await s.beat(3);
});
});
Questions
What people ask about it.
- How many rings fit?
- Two to five, and three read best. The rings share one band between the edge and the readout in the middle, so three are 14 units thick, four are about 10 and five are 7; more than five would be too thin to read.
- How is the overall share worked out?
- It is the average of each goal's share, so a goal past its target can lift the average above another's shortfall.
- Can I leave out the range switch?
- Yes. Leave out
ranges, oronRangeChange, and the header shows the title and its line only.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.