Charts

GroupedBarChart

A bar per series side by side for each label, below zero too, with the group's values on hover and the bar under the pointer picked out.

#chart#data#dashboard#no-js

Demo

Preview
Signups by plan
Signups by plan
FreeProTeam
Q11,84042096
Q22,210515130
Q32,650640188
Q43,120810245
Budget and actual
Budget and actual
BudgetActual
Design$42,000$39,500
Eng$96,000$104,200
Sales$58,000$51,800
Change from last year
Change from last year
UnitsRevenue
North12%9%
South-4%-8%
East7.5%11%
West3%-2%

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

Example

The code of the preview above.

demos/grouped_bar_chart.qwe
let grouped_bar_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px"view GroupedBarChartDemo() =    <div.demo-stack>        <GroupedBarChart label="Signups by plan" show_label={true} labels={["Q1", "Q2", "Q3", "Q4"]}                         series={[{ name: "Free", values: [1840, 2210, 2650, 3120] },                                  { name: "Pro", values: [420, 515, 640, 810] },                                  { name: "Team", values: [96, 130, 188, 245] }]}/>        <div style={grouped_bar_chart_demo_grid}>            <GroupedBarChart label="Budget and actual" show_label={true} prefix="$" value_labels={true} height={180}                             labels={["Design", "Eng", "Sales"]}                             series={[{ name: "Budget", values: [42000, 96000, 58000] },                                      { name: "Actual", values: [39500, 104200, 51800] }]}/>            <GroupedBarChart label="Change from last year" show_label={true} suffix="%" value_labels={true} height={180}                             labels={["North", "South", "East", "West"]}                             series={[{ name: "Units", values: [12, -4, 7.5, 3] },                                      { name: "Revenue", values: [9, -8, 11, -2] }]}/>        </div>    </div>

Source

ui/grouped_bar_chart.qwe
// GroupedBarChart: for each label, a bar per series side by side, on one axis with// round ticks, so the series can be compared label by label. CSS only: hovering a// group lists its values and picks out the bar under the pointer, and screen readers// get the numbers as a table.////   <GroupedBarChart label="Signups by plan" labels={["Q1", "Q2", "Q3", "Q4"]}//       series={[{ name: "Free", values: [1840, 2210, 2650, 3120] }, { name: "Pro", values: [420, 515, 640, 810] }]}/>//   <GroupedBarChart label="Budget vs actual" labels={teams} prefix="$" value_labels={true}//       series={[{ name: "Budget", values: budget }, { name: "Actual", values: actual }]}/>//// labels: the groups, in order. series: a list of { name, values } (up to eight, each// in a colour of its own, with a legend), one value per label; a null counts as 0,// and values below 0 hang under the zero line. value_labels: each bar's value at its// end, short ("$42k"). limit: the axis' top, instead of the highest value rounded up// (to compare several charts on one scale). prefix and suffix: around every value// shown ("$", "%"; the axis takes the prefix); decimals: digits after the point,// always (2 for money), instead of up to two as needed. height: the plot's height in// pixels (240). label: what the chart shows; it names it for screen readers and// captions their table. show_label also writes it above the chart.import ui/theme.qwe use ui_clslet grouped_bar_chart_css = css`.ui-gbarc {  --ui-gbarc-1: light-dark(#2a78d6, #3987e5); --ui-gbarc-2: light-dark(#eb6834, #d95926);  --ui-gbarc-3: light-dark(#1baf7a, #199e70); --ui-gbarc-4: light-dark(#eda100, #c98500);  --ui-gbarc-5: light-dark(#e87ba4, #d55181); --ui-gbarc-6: light-dark(#008300, #008300);  --ui-gbarc-7: light-dark(#6250d6, #9085e9); --ui-gbarc-8: light-dark(#e34948, #e66767);  display: grid; gap: 10px; align-content: start; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-gbarc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-gbarc-s1 { --c: var(--ui-gbarc-1); } .ui-gbarc-s2 { --c: var(--ui-gbarc-2); } .ui-gbarc-s3 { --c: var(--ui-gbarc-3); }.ui-gbarc-s4 { --c: var(--ui-gbarc-4); } .ui-gbarc-s5 { --c: var(--ui-gbarc-5); } .ui-gbarc-s6 { --c: var(--ui-gbarc-6); }.ui-gbarc-s7 { --c: var(--ui-gbarc-7); } .ui-gbarc-s8 { --c: var(--ui-gbarc-8); }.ui-gbarc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-gbarc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-gbarc-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }.ui-gbarc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-gbarc-y { position: relative; min-width: 24px; }.ui-gbarc-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-gbarc-plot { position: relative; }.ui-gbarc-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-gbarc-grid.ui-gbarc-zero { background: var(--ui-strong); }.ui-gbarc-cols { position: absolute; inset: 0; display: flex; }.ui-gbarc-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-gbarc-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-gbarc-bars { position: absolute; inset: 0 12%; display: flex; justify-content: center; gap: 2px; }.ui-gbarc-slot { position: relative; flex: 1; max-width: 36px; }.ui-gbarc-bar { position: absolute; left: 0; right: 0; bottom: var(--a); height: var(--b); min-height: 1px;  background: var(--c); border-radius: 4px 4px 0 0; transition: opacity .12s ease; }.ui-gbarc-neg { border-radius: 0 0 4px 4px; }.ui-gbarc-col:has(.ui-gbarc-slot:hover) .ui-gbarc-slot:not(:hover) .ui-gbarc-bar { opacity: .45; }.ui-gbarc-col:has(.ui-gbarc-p1:hover) .ui-gbarc-tip p:not(.ui-gbarc-p1),.ui-gbarc-col:has(.ui-gbarc-p2:hover) .ui-gbarc-tip p:not(.ui-gbarc-p2),.ui-gbarc-col:has(.ui-gbarc-p3:hover) .ui-gbarc-tip p:not(.ui-gbarc-p3),.ui-gbarc-col:has(.ui-gbarc-p4:hover) .ui-gbarc-tip p:not(.ui-gbarc-p4),.ui-gbarc-col:has(.ui-gbarc-p5:hover) .ui-gbarc-tip p:not(.ui-gbarc-p5),.ui-gbarc-col:has(.ui-gbarc-p6:hover) .ui-gbarc-tip p:not(.ui-gbarc-p6),.ui-gbarc-col:has(.ui-gbarc-p7:hover) .ui-gbarc-tip p:not(.ui-gbarc-p7),.ui-gbarc-col:has(.ui-gbarc-p8:hover) .ui-gbarc-tip p:not(.ui-gbarc-p8) { opacity: .5; }.ui-gbarc-val { position: absolute; left: 50%; bottom: calc(var(--a) + 3px); transform: translateX(-50%);  color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-gbarc-val.ui-gbarc-under { bottom: auto; top: calc(100% - var(--a) + 3px); }.ui-gbarc-x { display: flex; margin-top: 6px; }.ui-gbarc-x span { flex: 1; min-width: 0; padding: 0 2px; overflow: hidden; color: var(--ui-muted); text-align: center;  text-overflow: ellipsis; white-space: nowrap; }.ui-gbarc-tip { position: absolute; z-index: 20; top: 0; left: 50%; display: none; min-width: 120px; padding: 7px 10px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  box-shadow: var(--ui-shadow); color: var(--ui-fg); font-size: 12px; transform: translate(-50%, -100%); pointer-events: none; }.ui-gbarc-col:first-child .ui-gbarc-tip { left: 0; transform: translateY(-100%); }.ui-gbarc-col:last-child .ui-gbarc-tip { left: auto; right: 0; transform: translateY(-100%); }.ui-gbarc-col:hover .ui-gbarc-tip { display: block; }.ui-gbarc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-gbarc-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-gbarc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }@media (prefers-reduced-motion: reduce) { .ui-gbarc-bar { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn grouped_bar_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 grouped_bar_chart_group(n: int) -> str =    n < 1000 ? str(n) : grouped_bar_chart_group(floor(n / 1000)) + "," + grouped_bar_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 grouped_bar_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)}` : `.${grouped_bar_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${grouped_bar_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn grouped_bar_chart_short(v: num) -> str {    let a = abs(v)    let unit = a >= 1000000000 ? 1000000000 : (a >= 1000000 ? 1000000 : (a >= 1000 ? 1000 : 1))    let tenths = round(a * 10 / unit)    let mark = unit == 1000000000 ? "B" : (unit == 1000000 ? "M" : "k")    unit == 1 ? grouped_bar_chart_num(v)        : `${v < 0 ? "-" : ""}${grouped_bar_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage for CSS (or a coordinate out of 100), to two decimals.fn grouped_bar_chart_pct(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// The axis from lo to hi, widened to round ticks: about four steps of 1, 2, 2.5 or 5 x 10^n.fn grouped_bar_chart_scale(lo: num, hi: num) -> map {    let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo)) : hi - lo    let raw = span / 4    let mag = 1    while mag * 10 <= raw { mag = mag * 10 }    while mag > raw { mag = mag / 10 }    let r = raw / mag    let step = (r <= 1 ? 1 : (r <= 2 ? 2 : (r <= 2.5 ? 2.5 : (r <= 5 ? 5 : 10)))) * mag    let a = floor(lo / step + 0.000001)    let b0 = ceil(hi / step - 0.000001)    let b = b0 == a ? a + 1 : b0    { lo: a * step, hi: b * step, ticks: for i in range(b - a + 1) => (a + i) * step }}// The highest of a list of numbers.fn grouped_bar_chart_top(xs: list) -> num = max(xs)view GroupedBarChart(labels: list, series: list, value_labels: bool = false, limit: num? = null,                     prefix: str = "", suffix: str = "", decimals: int? = null, height: int = 240,                     label: str = "Grouped bar chart", show_label: bool = false, class: str? = null) {    style global grouped_bar_chart_css    let n = len(labels)    let m = len(series)    let at = (s, i) => s.values[i] ?? 0    let fmt = (v) => `${v < 0 ? "-" : ""}${prefix}${grouped_bar_chart_num(abs(v), decimals)}${suffix}`    let all_values = [0] + flatten(for s in series => for i in range(n) => at(s, i))    let hi = grouped_bar_chart_top(all_values)    let sc = grouped_bar_chart_scale(min(all_values), limit != null && limit > hi ? limit : hi)    let pos = (v) => (v - sc.lo) * 100 / (sc.hi - sc.lo)    let zero = pos(0)    <figure class={ui_cls(["ui-gbarc", class])} aria-label={label}>        {show_label && <figcaption.ui-gbarc-title>{label}</figcaption>}        {m > 1 && <ul.ui-gbarc-legend aria-hidden="true">            {for [j, s] in enumerate(series) => <li><span class={`ui-gbarc-key ui-gbarc-s${j % 8 + 1}`}></span>{s.name}</li>}        </ul>}        <div.ui-gbarc-frame aria-hidden="true">            <div.ui-gbarc-y style={`height: ${height}px`}>                {for t in sc.ticks => <span style={`bottom: ${grouped_bar_chart_pct(pos(t))}%`}>{t < 0 ? "-" : ""}{prefix}{grouped_bar_chart_short(abs(t))}</span>}            </div>            <div.ui-gbarc-plot style={`height: ${height}px`}>                {for t in sc.ticks => <span class={ui_cls(["ui-gbarc-grid", t == 0 && "ui-gbarc-zero"])}                                            style={`bottom: ${grouped_bar_chart_pct(pos(t))}%`}></span>}                <div.ui-gbarc-cols>                    {for i in range(n) => <div.ui-gbarc-col>                        <span.ui-gbarc-bars>                            {for [j, s] in enumerate(series) => {                                let v = at(s, i)                                let from = v < 0 ? pos(v) : zero                                let to = v < 0 ? zero : pos(v)                                <span class={`ui-gbarc-slot ui-gbarc-p${j + 1}`}>                                    {v != 0 && <span class={ui_cls(["ui-gbarc-bar", `ui-gbarc-s${j % 8 + 1}`, v < 0 && "ui-gbarc-neg"])}                                        style={`--a: ${grouped_bar_chart_pct(from)}%; --b: ${grouped_bar_chart_pct(to - from)}%`}                                        data-value={str(v)}></span>}                                    {value_labels && <span class={ui_cls(["ui-gbarc-val", v < 0 && "ui-gbarc-under"])}                                        style={`--a: ${grouped_bar_chart_pct(v < 0 ? from : to)}%`}>{v < 0 ? "-" : ""}{prefix}{grouped_bar_chart_short(abs(v))}{suffix}</span>}                                </span>                            }}                        </span>                        <span.ui-gbarc-tip>                            <strong>{labels[i]}</strong>                            {for [j, s] in enumerate(series) => <p class={`ui-gbarc-p${j + 1}`}>                                <span class={`ui-gbarc-key ui-gbarc-s${j % 8 + 1}`}></span>{s.name}<b>{fmt(at(s, i))}</b>                            </p>}                        </span>                    </div>}                </div>            </div>            <span></span>            <div.ui-gbarc-x>{for l in labels => <span title={str(l)}>{l}</span>}</div>        </div>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col"></th>{for s in series => <th scope="col">{s.name}</th>}</tr></thead>            <tbody>                {for i in range(n) => <tr>                    <th scope="row">{labels[i]}</th>                    {for s in series => <td>{fmt(at(s, i))}</td>}                </tr>}            </tbody>        </table>    </figure>}
Thread
64Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.