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.
Demo
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.qwemain.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>}Related
AreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBulletChartMeasures as thin bars against a target mark over grey bands from poor to good, a row each, with the band named on hover.5 views · 7 downloadsComboChartBars on a left axis and lines on a right axis over the same labels, for two measures of different size, with every value on hover.6 views · 6 downloadsFunnelChartStages of a process narrowing like a funnel, each with its count and share of the first, and the share that went on between them.5 views · 7 downloadsGaugeChartA half-circle dial of good, warning and bad bands with a needle at the value, and the value and its band's name under it.6 views · 7 downloadsHorizontalBarChartBars across with the labels down the side, sorted if asked, one picked out, values at their ends and on hover.6 views · 6 downloads
Thread
Thread 0 posts
On GroupedBarChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.