Charts

DonutChart

The parts of a whole as a ring or a pie, the total in the middle, and a legend with values and shares; hovering picks a part out.

#chart#data#no-js

Demo

Preview
  • Search4,21048.6%
  • Direct2,38027.5%
  • Social1,12012.9%
  • Email6407.4%
  • Referral3103.6%
Budget this month
  • Payroll$48,200.0073.1%
  • Hosting$6,350.509.6%
  • Marketing$9,100.0013.8%
  • Tools$2,275.003.5%
Plan mix
  • Free6,12073.1%
  • Pro1,84022%
  • Team4104.9%
  • Photos41.2 GB49.5%
  • Videos28.7 GB34.5%
  • Documents6.4 GB7.7%
  • Mail3.1 GB3.7%
  • Backups1.8 GB2.2%
  • Apps0.9 GB1.1%
  • Music0.6 GB0.7%
  • Other0.6 GB0.7%

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/donut_chart.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/donut_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/donut_chart.qwe
view DonutChartDemo() =    <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px 40px">        <DonutChart label="Traffic by source" parts={[            { label: "Search", value: 4210 }, { label: "Direct", value: 2380 }, { label: "Social", value: 1120 },            { label: "Email", value: 640 }, { label: "Referral", value: 310 }]}/>        <DonutChart label="Budget this month" show_label={true} prefix="$" decimals={2} total_label="Spent" size={150} thickness={12} parts={[            { label: "Payroll", value: 48200 }, { label: "Hosting", value: 6350.5 }, { label: "Marketing", value: 9100 },            { label: "Tools", value: 2275 }]}/>        <DonutChart label="Plan mix" show_label={true} pie={true} size={140} parts={[            { label: "Free", value: 6120 }, { label: "Pro", value: 1840 }, { label: "Team", value: 410 }]}/>        <DonutChart label="Storage" suffix=" GB" center="82%" size={150} parts={[            { label: "Photos", value: 41.2 }, { label: "Videos", value: 28.7 }, { label: "Documents", value: 6.4 },            { label: "Mail", value: 3.1 }, { label: "Backups", value: 1.8 }, { label: "Apps", value: 0.9 },            { label: "Music", value: 0.6 }, { label: "Notes", value: 0.2 }, { label: "Other files", value: 0.4 }]}/>    </div>

Source

ui/donut_chart.qwe
// DonutChart: the parts of a whole as a ring (or a pie), with the total in the// middle and a legend of each part's value and share. CSS only: hovering a part,// on the ring or in the legend, picks it out and shows it in the middle.////   <DonutChart label="Traffic by source" parts={[//       { label: "Search", value: 4210 }, { label: "Direct", value: 2380 }, { label: "Social", value: 1120 }]}/>//   <DonutChart label="Budget" parts={spending} prefix="$" total_label="Spent" size={140}/>//   <DonutChart label="Plan mix" parts={plans} pie={true} legend={false}/>//// parts: a list of { label, value }, in the order to draw them, clockwise from the// top; past eight, the eighth and the rest are counted together as "Other". Parts// of 0 or less are left off the ring. prefix and suffix: around every value shown// ("$", " GB"); decimals: digits after the point, always (2 for money), instead of up// to two as needed. total_label: under the total in the middle ("Total"); center:// other text for the middle instead of the total. size: its width in pixels (180; it// shrinks to fit). thickness: the ring's, as a share of the width out of 100 (16).// pie: a full pie, with nothing in the middle. legend: the list beside it, with// each part's value and percentage (true; false keeps it for screen readers only).// label: what it shows, for screen readers, with the total; show_label also writes// it above.import ui/theme.qwe use ui_clslet donut_chart_css = css`.ui-dn { --ui-dn-1: light-dark(#2a78d6, #3987e5); --ui-dn-2: light-dark(#eb6834, #d95926);  --ui-dn-3: light-dark(#1baf7a, #199e70); --ui-dn-4: light-dark(#eda100, #c98500);  --ui-dn-5: light-dark(#e87ba4, #d55181); --ui-dn-6: light-dark(#008300, #008300);  --ui-dn-7: light-dark(#6250d6, #9085e9); --ui-dn-8: var(--ui-strong);  display: flex; flex-wrap: wrap; align-items: center; gap: 16px 28px; min-width: 0; margin: 0;  font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-dn-title { flex-basis: 100%; margin: 0; font-size: 14px; font-weight: 600; }.ui-dn-s1 { --c: var(--ui-dn-1); } .ui-dn-s2 { --c: var(--ui-dn-2); } .ui-dn-s3 { --c: var(--ui-dn-3); }.ui-dn-s4 { --c: var(--ui-dn-4); } .ui-dn-s5 { --c: var(--ui-dn-5); } .ui-dn-s6 { --c: var(--ui-dn-6); }.ui-dn-s7 { --c: var(--ui-dn-7); } .ui-dn-s8 { --c: var(--ui-dn-8); }.ui-dn-ring { position: relative; flex: none; max-width: 100%; aspect-ratio: 1; }.ui-dn-ring svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }.ui-dn-track { fill: none; stroke: var(--ui-subtle); }.ui-dn-seg { fill: none; stroke: var(--c); stroke-width: var(--w); transition: stroke-width .15s ease, opacity .15s ease; }.ui-dn-seg:hover { stroke-width: calc(var(--w) + 4px); }.ui-dn-center { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; text-align: center; }.ui-dn-center > span { grid-area: 1 / 1; display: grid; max-width: 64%; }.ui-dn-center > span:not(.ui-dn-total) { visibility: hidden; }.ui-dn-center b { font-size: clamp(13px, calc(var(--d) * .105), 26px); font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-dn-center small { overflow: hidden; color: var(--ui-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.ui-dn-legend { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 6px 10px; align-items: center;  flex: 1 1 200px; min-width: 0; margin: 0; padding: 0; list-style: none; }.ui-dn-legend li { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; }.ui-dn-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); }.ui-dn-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-dn-val { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-dn-pct { min-width: 3.2em; color: var(--ui-muted); text-align: right; font-variant-numeric: tabular-nums; }.ui-dn-legend li > * { transition: opacity .15s ease; }.ui-dn:has(.ui-dn-p:hover) .ui-dn-total { visibility: hidden; }.ui-dn:has(.ui-dn-p1:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p1, .ui-dn-p1 > *),.ui-dn:has(.ui-dn-p2:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p2, .ui-dn-p2 > *),.ui-dn:has(.ui-dn-p3:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p3, .ui-dn-p3 > *),.ui-dn:has(.ui-dn-p4:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p4, .ui-dn-p4 > *),.ui-dn:has(.ui-dn-p5:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p5, .ui-dn-p5 > *),.ui-dn:has(.ui-dn-p6:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p6, .ui-dn-p6 > *),.ui-dn:has(.ui-dn-p7:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p7, .ui-dn-p7 > *),.ui-dn:has(.ui-dn-p8:hover) :is(.ui-dn-seg, .ui-dn-legend li > *):not(.ui-dn-p8, .ui-dn-p8 > *) { opacity: .3; }.ui-dn:has(.ui-dn-p1:hover) .ui-dn-c1, .ui-dn:has(.ui-dn-p2:hover) .ui-dn-c2, .ui-dn:has(.ui-dn-p3:hover) .ui-dn-c3,.ui-dn:has(.ui-dn-p4:hover) .ui-dn-c4, .ui-dn:has(.ui-dn-p5:hover) .ui-dn-c5, .ui-dn:has(.ui-dn-p6:hover) .ui-dn-c6,.ui-dn:has(.ui-dn-p7:hover) .ui-dn-c7, .ui-dn:has(.ui-dn-p8:hover) .ui-dn-c8 { visibility: visible; }.ui-dn:has(.ui-dn-p1:hover) .ui-dn-seg.ui-dn-p1, .ui-dn:has(.ui-dn-p2:hover) .ui-dn-seg.ui-dn-p2,.ui-dn:has(.ui-dn-p3:hover) .ui-dn-seg.ui-dn-p3, .ui-dn:has(.ui-dn-p4:hover) .ui-dn-seg.ui-dn-p4,.ui-dn:has(.ui-dn-p5:hover) .ui-dn-seg.ui-dn-p5, .ui-dn:has(.ui-dn-p6:hover) .ui-dn-seg.ui-dn-p6,.ui-dn:has(.ui-dn-p7:hover) .ui-dn-seg.ui-dn-p7, .ui-dn:has(.ui-dn-p8:hover) .ui-dn-seg.ui-dn-p8 { stroke-width: calc(var(--w) + 4px); }@media (prefers-reduced-motion: reduce) { .ui-dn-seg, .ui-dn-legend li > * { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn donut_chart_pad(n: int, width: int) -> str {    let s = str(n)    len(s) < width ? repeat("0", width - len(s)) + s : s}// A whole number with commas: 1234567 is "1,234,567".fn donut_chart_group(n: int) -> str =    n < 1000 ? str(n) : donut_chart_group(floor(n / 1000)) + "," + donut_chart_pad(n % 1000, 3)// A value as it reads, with commas: `places` digits after the point, or up to two as// needed when that is null: 48210 is "48,210", 6350.5 is "6,350.5" ("6,350.50" with 2).// Worked out in whole numbers, so the server and the browser write the same text.fn donut_chart_num(v: num, places: int? = null) -> str {    let digits = places == null ? 2 : (places < 0 ? 0 : (places > 4 ? 4 : places))    let scale = [1, 10, 100, 1000, 10000][digits]    let units = round(abs(v) * scale)    let frac = units % scale    let tail = digits == 0 || (places == null && frac == 0) ? ""        : (places == null && frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${donut_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${donut_chart_group(floor(units / scale))}${tail}`}// A share out of 100, to one decimal: "42%", "12.5%", "0.4%".fn donut_chart_share(p: num) -> str {    let tenths = round(p * 10)    `${floor(tenths / 10)}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}%`}// A length for SVG, to three decimals.fn donut_chart_len(n: num) -> str {    let r = round(n * 1000) / 1000    r == floor(r) ? str(floor(r)) : str(r)}view DonutChart(parts: list, label: str = "Donut chart", show_label: bool = false, total_label: str = "Total",                center: str? = null, prefix: str = "", suffix: str = "", decimals: int? = null, size: int = 180,                thickness: int = 16, pie: bool = false, legend: bool = true, class: str? = null) {    style global donut_chart_css    let shown = len(parts) > 8        ? take(parts, 7) + [{ label: "Other", value: sum(drop(parts, 7) | map((p) => p.value > 0 ? p.value : 0)) }]        : parts    let fmt = (v) => `${v < 0 ? "-" : ""}${prefix}${donut_chart_num(abs(v), decimals)}${suffix}`    let total = sum(shown | map((p) => p.value > 0 ? p.value : 0))    let share = (v) => total > 0 && v > 0 ? v * 100 / total : 0    let width = pie ? 50 : (thickness < 4 ? 4 : (thickness > 40 ? 40 : thickness))    let r = pie ? 25 : 48 - width / 2    let drawn = len(shown | filter((p) => p.value > 0))    let gap = drawn > 1 ? 0.6 : 0    let starts = reduce(shown, [], (acc, p) => acc + [len(acc) == 0 ? 0 : acc[-1] + share(shown[len(acc) - 1].value)])    let color = (i) => len(parts) > 8 && i == 7 ? 8 : i + 1    <figure class={ui_cls(["ui-dn", class])} aria-label={`${label}, ${lower(total_label)} ${fmt(total)}`}>        {show_label && <figcaption.ui-dn-title>{label}</figcaption>}        <div.ui-dn-ring style={`width: ${size}px; --d: ${size}px`} aria-hidden="true">            <svg viewBox="0 0 100 100" style={`--w: ${width}px`}>                <circle.ui-dn-track cx="50" cy="50" r={donut_chart_len(r)} stroke-width={str(width)}/>                {for [i, p] in enumerate(shown) => p.value > 0 && <circle                    class={`ui-dn-seg ui-dn-p ui-dn-p${i + 1} ui-dn-s${color(i)}`} cx="50" cy="50" r={donut_chart_len(r)}                    pathLength="100" stroke-dasharray={`${donut_chart_len(share(p.value) - gap)} ${donut_chart_len(100 - share(p.value) + gap)}`}                    stroke-dashoffset={donut_chart_len(-starts[i] - gap / 2)}>                    <title>{p.label}: {fmt(p.value)} ({donut_chart_share(share(p.value))})</title>                </circle>}            </svg>            {!pie && <div.ui-dn-center>                <span.ui-dn-total><b>{center ?? fmt(total)}</b><small>{center == null ? total_label : ""}</small></span>                {for [i, p] in enumerate(shown) => <span class={`ui-dn-c${i + 1}`}>                    <b>{donut_chart_share(share(p.value))}</b><small>{p.label}</small>                </span>}            </div>}        </div>        <ul class={ui_cls(["ui-dn-legend", !legend && "ui-sr"])}>            {for [i, p] in enumerate(shown) => <li class={`ui-dn-p ui-dn-p${i + 1} ui-dn-s${color(i)}`}>                <span.ui-dn-key aria-hidden="true"></span>                <span.ui-dn-name>{p.label}</span>                <span.ui-dn-val>{fmt(p.value)}</span>                <span.ui-dn-pct>{donut_chart_share(share(p.value))}</span>            </li>}        </ul>    </figure>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

On DonutChart: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.