Charts

WaffleChart

Parts of a whole as a 10×10 grid of squares, one per percent, with a legend of values and shares; hovering picks a part out.

#chart#data#no-js

Demo

Preview
Electricity by source
Electricity by source
PartValueShare
Wind81.2 TWh33.7%
Gas62.5 TWh25.9%
Solar44.9 TWh18.6%
Nuclear31.7 TWh13.2%
Coal12.4 TWh5.1%
Hydro8.3 TWh3.4%
Seats sold
Seats sold
PartValueShare
Members2,38047.6%
Public1,74034.8%
Rest88017.6%

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

Example

The code of the preview above.

demos/waffle_chart.qwe
let waffle_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 32px 40px"view WaffleChartDemo() =    <div style={waffle_chart_demo_grid}>        <WaffleChart label="Electricity by source" show_label={true} suffix=" TWh" parts={[            { label: "Wind", value: 81.2 }, { label: "Gas", value: 62.5 }, { label: "Solar", value: 44.9 },            { label: "Nuclear", value: 31.7 }, { label: "Coal", value: 12.4 }, { label: "Hydro", value: 8.3 }]}/>        <WaffleChart label="Seats sold" show_label={true} size={150} total={5000} parts={[            { label: "Members", value: 2380 }, { label: "Public", value: 1740 }]}/>    </div>

Source

ui/waffle_chart.qwe
// WaffleChart: parts of a whole as a 10 by 10 grid of squares, one square for each// percent, coloured part by part from the top left, with a legend of each part's// value and share. CSS only: hovering a square or a legend row picks its part out,// and screen readers get the parts as a table.////   <WaffleChart label="Energy mix" parts={[//       { label: "Wind", value: 38 }, { label: "Solar", value: 21 }, { label: "Gas", value: 29 }, { label: "Coal", value: 12 }]}/>//   <WaffleChart label="Seats sold" parts={[{ label: "Sold", value: 4120 }]} total={5000}/>//// parts: a list of { label, value } (values of 0 or less are left out); past eight,// the eighth and the rest are counted together as "Other". total: what the parts are// shares of, when they are not the whole (the squares left over stay empty);// otherwise the parts' sum. Each part gets its share of the 100 squares rounded so// that they add up (the largest remainders round up). legend: the list beside it// (true). prefix and suffix: around every value shown ("$", " GWh"); decimals: digits// after the point, always, instead of up to two as needed. size: the grid's width in// pixels (200; it shrinks to fit). label: what the chart shows; it names it for// screen readers and captions their table. show_label also writes it above.import ui/theme.qwe use ui_clslet waffle_chart_css = css`.ui-wafflec {  --ui-wafflec-1: light-dark(#2a78d6, #3987e5); --ui-wafflec-2: light-dark(#eb6834, #d95926);  --ui-wafflec-3: light-dark(#1baf7a, #199e70); --ui-wafflec-4: light-dark(#eda100, #c98500);  --ui-wafflec-5: light-dark(#e87ba4, #d55181); --ui-wafflec-6: light-dark(#008300, #008300);  --ui-wafflec-7: light-dark(#6250d6, #9085e9); --ui-wafflec-8: light-dark(#e34948, #e66767);  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-wafflec-title { flex-basis: 100%; margin: 0; font-size: 14px; font-weight: 600; }.ui-wafflec-s1 { --c: var(--ui-wafflec-1); } .ui-wafflec-s2 { --c: var(--ui-wafflec-2); } .ui-wafflec-s3 { --c: var(--ui-wafflec-3); }.ui-wafflec-s4 { --c: var(--ui-wafflec-4); } .ui-wafflec-s5 { --c: var(--ui-wafflec-5); } .ui-wafflec-s6 { --c: var(--ui-wafflec-6); }.ui-wafflec-s7 { --c: var(--ui-wafflec-7); } .ui-wafflec-s8 { --c: var(--ui-wafflec-8); }.ui-wafflec-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 3px; flex: none; max-width: 100%; aspect-ratio: 1; }.ui-wafflec-cell { border-radius: 2px; background: var(--c, color-mix(in srgb, var(--ui-border) 75%, transparent)); transition: opacity .15s ease; }.ui-wafflec-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; max-width: 340px; margin: 0; padding: 0; list-style: none; }.ui-wafflec-legend li { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; transition: opacity .15s ease; }.ui-wafflec-key { width: 10px; height: 10px; border-radius: 2px; background: var(--c, color-mix(in srgb, var(--ui-border) 75%, transparent)); }.ui-wafflec-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-wafflec-val { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-wafflec-pct { min-width: 3.2em; color: var(--ui-muted); text-align: right; font-variant-numeric: tabular-nums; }.ui-wafflec:has(.ui-wafflec-p1:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p1),.ui-wafflec:has(.ui-wafflec-p2:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p2),.ui-wafflec:has(.ui-wafflec-p3:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p3),.ui-wafflec:has(.ui-wafflec-p4:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p4),.ui-wafflec:has(.ui-wafflec-p5:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p5),.ui-wafflec:has(.ui-wafflec-p6:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p6),.ui-wafflec:has(.ui-wafflec-p7:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p7),.ui-wafflec:has(.ui-wafflec-p8:hover) :is(.ui-wafflec-cell, .ui-wafflec-legend li):not(.ui-wafflec-p8) { opacity: .25; }@media (prefers-reduced-motion: reduce) { .ui-wafflec-cell, .ui-wafflec-legend li { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn waffle_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 waffle_chart_group(n: int) -> str =    n < 1000 ? str(n) : waffle_chart_group(floor(n / 1000)) + "," + waffle_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 waffle_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)}` : `.${waffle_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${waffle_chart_group(floor(units / scale))}${tail}`}// A share out of 100, to one decimal: "42%", "12.5%", "0.4%".fn waffle_chart_share(p: num) -> str {    let tenths = round(p * 10)    `${floor(tenths / 10)}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}%`}// Whole numbers of squares for each share (out of `whole`, summing to `whole` when// the shares do): each rounded down, then one more for the largest remainders.fn waffle_chart_squares(shares: list, whole: int) -> list {    let floors = for s in shares => floor(s)    let left = whole - sum(floors)    let by_rest = sort_by(range(len(shares)), (i) => -(shares[i] - floors[i]))    let extra = take(by_rest, left < 0 ? 0 : left)    for i in range(len(shares)) => floors[i] + (any(extra, (k) => k == i) ? 1 : 0)}view WaffleChart(parts: list, total: num? = null, legend: bool = true, prefix: str = "", suffix: str = "",                 decimals: int? = null, size: int = 200, label: str = "Waffle chart", show_label: bool = false,                 class: str? = null) {    style global waffle_chart_css    let positive = parts | filter((p) => p.value != null && p.value > 0)    let shown = len(positive) > 8        ? take(positive, 7) + [{ label: "Other", value: sum(drop(positive, 7) | map((p) => p.value)) }]        : positive    let sum_parts = sum(shown | map((p) => p.value))    let whole = total != null && total > sum_parts ? total : sum_parts    let fmt = (v) => `${v < 0 ? "-" : ""}${prefix}${waffle_chart_num(abs(v), decimals)}${suffix}`    let shares = for p in shown => whole > 0 ? p.value * 100 / whole : 0    let filled = whole > 0 ? round(sum_parts * 100 / whole) : 0    let counts = waffle_chart_squares(shares, filled)    let starts = reduce(counts, [0], (acc, c) => acc + [acc[-1] + c])    let owner = (cell) => find(range(len(shown)), (i) => cell >= starts[i] && cell < starts[i + 1])    <figure class={ui_cls(["ui-wafflec", class])} aria-label={label}>        {show_label && <figcaption.ui-wafflec-title>{label}</figcaption>}        <div.ui-wafflec-grid style={`width: ${size}px`} aria-hidden="true">            {for cell in range(100) => {                let i = owner(cell)                <span class={ui_cls(["ui-wafflec-cell", i != null && `ui-wafflec-p${i + 1} ui-wafflec-s${i % 8 + 1}`])}                      title={i != null ? `${shown[i].label}: ${fmt(shown[i].value)} (${waffle_chart_share(shares[i])})` : null}></span>            }}        </div>        {legend && <ul.ui-wafflec-legend aria-hidden="true">            {for [i, p] in enumerate(shown) => <li class={`ui-wafflec-p${i + 1} ui-wafflec-s${i % 8 + 1}`}>                <span.ui-wafflec-key></span>                <span.ui-wafflec-name>{p.label}</span>                <span.ui-wafflec-val>{fmt(p.value)}</span>                <span.ui-wafflec-pct>{waffle_chart_share(shares[i])}</span>            </li>}            {whole > sum_parts && <li>                <span.ui-wafflec-key></span>                <span.ui-wafflec-name>Rest</span>                <span.ui-wafflec-val>{fmt(whole - sum_parts)}</span>                <span.ui-wafflec-pct>{waffle_chart_share((whole - sum_parts) * 100 / whole)}</span>            </li>}        </ul>}        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col">Part</th><th scope="col">Value</th><th scope="col">Share</th></tr></thead>            <tbody>                {for [i, p] in enumerate(shown) => <tr><th scope="row">{p.label}</th><td>{fmt(p.value)}</td><td>{waffle_chart_share(shares[i])}</td></tr>}                {whole > sum_parts && <tr><th scope="row">Rest</th><td>{fmt(whole - sum_parts)}</td><td>{waffle_chart_share((whole - sum_parts) * 100 / whole)}</td></tr>}            </tbody>        </table>    </figure>}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.