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.
Demo
- Search4,21048.6%
- Direct2,38027.5%
- Social1,12012.9%
- Email6407.4%
- Referral3103.6%
- Payroll$48,200.0073.1%
- Hosting$6,350.509.6%
- Marketing$9,100.0013.8%
- Tools$2,275.003.5%
- 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.qwemain.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>}Related
AreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBarChartBars up or across, one series or several grouped or stacked, on round ticks, with values on hover and a table for screen readers.7 views · 7 downloadsBollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 5 downloadsBoxPlotQuartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.5 views · 7 downloadsBrushChartA large chart of a range with an overview below, where a window dragged or moved by keys picks the range shown.6 views · 5 downloadsBubbleChartPoints placed by two numbers and sized by a third, in coloured groups, labelled when big enough, with all three numbers on hover.7 views · 8 downloads
Thread
Thread 0 posts
On DonutChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.