Charts

SmallMultiples

The same small line, area or bar chart for each group in a grid, on one shared scale, with each value on hover.

#chart#data#grid#dashboard#no-js

Demo

Preview
Weekly sales by store
Weekly sales by store
LisbonPortoMadridSevilleValenciaBilbao
W1$40,603$26,359$61,000$19,619$22,019$16,682
W2$46,392$26,840$57,515$17,928$17,609$14,897
W3$49,993$30,015$51,218$16,973$18,112$12,964
W4$49,815$33,785$45,967$18,157$23,222$12,117
W5$47,128$35,598$44,828$21,265$29,148$12,771
W6$45,102$34,386$47,754$24,627$31,481$14,204
W7$46,403$31,368$51,623$26,379$28,531$15,104
W8$51,184$29,131$52,599$25,877$22,606$14,563
W9$56,960$29,671$49,035$24,181$18,266$12,764
W10$60,497$32,881$42,720$23,256$18,871$10,840
W11$60,251$36,636$37,525$24,484$24,038$10,022
W12$57,546$38,395$36,474$27,616$29,934$10,700
Sign-ups by month, each on its own scale
Sign-ups by month, each on its own scale
FreeProTeamEnterprise
Jan1,840420964
Feb2,0104551043
Mar2,2105151306
Apr2,480590151—
May2,6506401887
Jun3,1208102459

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

Example

The code of the preview above.

demos/small_multiples.qwe
let small_multiples_demo_weeks = for i in range(12) => `W${i + 1}`// A store's weekly sales: a level, a trend and a wobble, the same on every run.fn small_multiples_demo_sales(level: num, trend: num, wobble: num) -> list =    for i in range(12) => round((level + trend * i + wobble * math.sin(i * 0.9 + level / 7)) * 1000)view SmallMultiplesDemo() =    <div.demo-stack>        <SmallMultiples label="Weekly sales by store" show_label={true} labels={small_multiples_demo_weeks} kind="area"                        prefix="$" columns={3}                        groups={[{ name: "Lisbon", values: small_multiples_demo_sales(42, 1.5, 5) },                                 { name: "Porto", values: small_multiples_demo_sales(30, 0.4, 4) },                                 { name: "Madrid", values: small_multiples_demo_sales(55, -1.2, 6) },                                 { name: "Seville", values: small_multiples_demo_sales(18, 0.9, 3) },                                 { name: "Valencia", values: small_multiples_demo_sales(24, 0.1, 7) },                                 { name: "Bilbao", values: small_multiples_demo_sales(15, -0.3, 2) }]}/>        <SmallMultiples label="Sign-ups by month, each on its own scale" show_label={true} kind="bar" shared={false} color={3} height={70}                        labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}                        groups={[{ name: "Free", values: [1840, 2010, 2210, 2480, 2650, 3120] },                                 { name: "Pro", values: [420, 455, 515, 590, 640, 810] },                                 { name: "Team", values: [96, 104, 130, 151, 188, 245] },                                 { name: "Enterprise", values: [4, 3, 6, null, 7, 9] }]}/>    </div>

Source

ui/small_multiples.qwe
// SmallMultiples: the same small chart for each group, in a grid, on one shared// scale, so the groups compare at a glance (sales by store, visits by country): a// line, an area or bars per panel, each with its name and latest value. CSS only:// hovering a panel shows the value at each label; screen readers get the numbers// as a table.////   <SmallMultiples label="Weekly sales by store" labels={weeks} groups={[//       { name: "Lisbon", values: [12, 15, 14, 18] }, { name: "Porto", values: [8, 9, 11, 10] }]}/>//   <SmallMultiples label="Signups" labels={months} groups={countries} kind="bar" shared={false}/>//// labels: the x axis, in order; the first and last are written under each panel.// groups: a list of { name, values }, a panel each, one value per label; a null// leaves a gap. kind: "line" (default), "area" or "bar". shared: every panel on one// scale (true), so heights compare across them; false gives each its own. columns:// how many panels across (by default as many as fit at 200px or more). color: 1 to// 8, from the chart palette (1, blue). height: each plot's height in pixels (90).// prefix and suffix: around every value shown ("$", "%"); decimals: digits after// the point, always, instead of up to two as needed. 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 small_multiples_css = css`.ui-multiples {  --ui-multiples-1: light-dark(#2a78d6, #3987e5); --ui-multiples-2: light-dark(#eb6834, #d95926);  --ui-multiples-3: light-dark(#1baf7a, #199e70); --ui-multiples-4: light-dark(#eda100, #c98500);  --ui-multiples-5: light-dark(#e87ba4, #d55181); --ui-multiples-6: light-dark(#008300, #008300);  --ui-multiples-7: light-dark(#6250d6, #9085e9); --ui-multiples-8: light-dark(#e34948, #e66767);  display: grid; gap: 10px; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-multiples-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-multiples-s1 { --c: var(--ui-multiples-1); } .ui-multiples-s2 { --c: var(--ui-multiples-2); } .ui-multiples-s3 { --c: var(--ui-multiples-3); }.ui-multiples-s4 { --c: var(--ui-multiples-4); } .ui-multiples-s5 { --c: var(--ui-multiples-5); } .ui-multiples-s6 { --c: var(--ui-multiples-6); }.ui-multiples-s7 { --c: var(--ui-multiples-7); } .ui-multiples-s8 { --c: var(--ui-multiples-8); }.ui-multiples-grid { display: grid; gap: 14px 18px; }.ui-multiples-panel { min-width: 0; padding: 10px 12px 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-multiples-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 8px; }.ui-multiples-name { overflow: hidden; color: var(--ui-muted); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }.ui-multiples-last { font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-multiples-plot { position: relative; }.ui-multiples-grid-line { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-multiples-grid-line span { position: absolute; z-index: 2; left: 0; bottom: 2px; color: var(--ui-muted); font-size: 10px;  font-variant-numeric: tabular-nums; text-shadow: 0 0 3px var(--ui-surface), 0 0 2px var(--ui-surface); pointer-events: none; }.ui-multiples-grid-line .ui-multiples-top { top: 2px; bottom: auto; }.ui-multiples-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-multiples-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.ui-multiples-area { fill: var(--c); opacity: .16; stroke: none; }.ui-multiples-cols { position: absolute; inset: 0; display: flex; }.ui-multiples-col { position: relative; flex: 1; min-width: 0; --x: 50%; }.ui-multiples-line-kind .ui-multiples-col:first-child:not(:last-child) { flex: .5; --x: 0%; }.ui-multiples-line-kind .ui-multiples-col:last-child:not(:first-child) { flex: .5; --x: 100%; }.ui-multiples-col::before { content: ""; position: absolute; top: 0; bottom: 0; left: var(--x); width: 1px; background: var(--ui-strong); opacity: 0; }.ui-multiples-line-kind .ui-multiples-col:hover::before { opacity: 1; }.ui-multiples-dot { position: absolute; left: var(--x); display: none; width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;  border-radius: 50%; background: var(--c); box-shadow: 0 0 0 2px var(--ui-surface); }.ui-multiples-col:hover .ui-multiples-dot { display: block; }.ui-multiples-bar { position: absolute; left: 15%; right: 15%; bottom: var(--a); height: var(--b); min-height: 1px;  border-radius: 2px 2px 0 0; background: var(--c); }.ui-multiples-col:hover .ui-multiples-bar { filter: brightness(1.12); }.ui-multiples-x { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; color: var(--ui-muted); font-size: 11px; }.ui-multiples-x span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-multiples-tip { position: absolute; z-index: 20; bottom: calc(100% + 6px); left: var(--x); display: none; padding: 4px 8px;  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: translateX(-50%); white-space: nowrap; pointer-events: none; }.ui-multiples-tip b { margin-left: 6px; font-variant-numeric: tabular-nums; }.ui-multiples-col:first-child .ui-multiples-tip { transform: none; }.ui-multiples-col:last-child .ui-multiples-tip { transform: translateX(-100%); }.ui-multiples-col:hover .ui-multiples-tip { display: block; }`// n (a whole number, 0 or more) with at least `width` digits.fn small_multiples_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 small_multiples_group(n: int) -> str =    n < 1000 ? str(n) : small_multiples_group(floor(n / 1000)) + "," + small_multiples_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 small_multiples_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)}` : `.${small_multiples_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${small_multiples_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn small_multiples_pct(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn small_multiples_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 ? small_multiples_num(v)        : `${v < 0 ? "-" : ""}${small_multiples_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// The axis from lo to hi, widened to round ticks: about `steps` steps of 1, 2, 2.5 or 5 x 10^n.fn small_multiples_scale(lo: num, hi: num, steps: int = 4) -> map {    let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo)) : hi - lo    let raw = span / steps    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 }}view SmallMultiples(labels: list, groups: list, kind: str = "line", shared: bool = true, columns: int? = null,                    color: int = 1, height: int = 90, prefix: str = "", suffix: str = "", decimals: int? = null,                    label: str = "Small multiples", show_label: bool = false, class: str? = null) {    style global small_multiples_css    let n = len(labels)    let bars = kind == "bar"    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${small_multiples_num(abs(v), decimals)}${suffix}`    let known = (g) => (g.values ?? []) | filter((v) => v != null)    let scale_of = (vs) => {        let lo0 = len(vs) == 0 ? 0 : min(vs)        let hi0 = len(vs) == 0 ? 1 : max(vs)        small_multiples_scale(bars || kind == "area" ? (lo0 > 0 ? 0 : lo0) : lo0, bars || kind == "area" ? (hi0 < 0 ? 0 : hi0) : hi0, 2)    }    let common = scale_of(flatten(groups | map(known)))    let scales = groups | map((g) => shared ? common : scale_of(known(g)))    let tone = `ui-multiples-s${color < 1 || color > 8 ? 1 : color}`    let x = (i) => n == 1 ? 50 : i * 100 / (n - 1)    let latest = (g) => find(reverse(g.values ?? []), (v) => v != null)    let cols = columns != null && columns > 0 ? `repeat(${columns}, minmax(0, 1fr))` : "repeat(auto-fill, minmax(200px, 1fr))"    <figure class={ui_cls(["ui-multiples", tone, !bars && "ui-multiples-line-kind", class])} aria-label={label}>        {show_label && <figcaption.ui-multiples-title>{label}</figcaption>}        <div.ui-multiples-grid style={`grid-template-columns: ${cols}`} aria-hidden="true">            {for [p, g] in enumerate(groups) => {                let sc = scales[p]                let top = (v) => (sc.hi - v) * 100 / (sc.hi - sc.lo)                let vals = for i in range(n) => (g.values ?? [])[i]                let starts = range(n) | filter((i) => vals[i] != null && (i == 0 || vals[i - 1] == null))                let runs = for st in starts => range(st, (find(range(st, n), (k) => k == n - 1 || vals[k + 1] == null) ?? st) + 1)                let pt = (i) => `${small_multiples_pct(x(i))},${small_multiples_pct(top(vals[i]))}`                let base = small_multiples_pct(sc.lo <= 0 && sc.hi >= 0 ? top(0) : 100)                <div.ui-multiples-panel data-name={str(g.name)}>                    <div.ui-multiples-head><span.ui-multiples-name>{g.name}</span><span.ui-multiples-last>{fmt(latest(g))}</span></div>                    <div.ui-multiples-plot style={`height: ${height}px`}>                        {for t in sc.ticks => <span.ui-multiples-grid-line style={`top: ${small_multiples_pct(top(t))}%`}>                            {(t == sc.ticks[0] || t == sc.ticks[-1]) && <span class={t == sc.ticks[-1] ? "ui-multiples-top" : null}>{t < 0 ? "-" : ""}{prefix}{small_multiples_short(abs(t))}</span>}                        </span>}                        {!bars && <svg.ui-multiples-svg viewBox="0 0 100 100" preserveAspectRatio="none">                            {kind == "area" && (for r in runs => <path.ui-multiples-area                                d={`M${join(map(r, pt), " L")} L${small_multiples_pct(x(r[-1]))},${base} L${small_multiples_pct(x(r[0]))},${base} Z`}/>)}                            <path.ui-multiples-line d={join(for r in runs => "M" + join(map(r, pt), " L") + (len(r) == 1 ? " l0.01,0" : ""), " ")}                                vector-effect="non-scaling-stroke"/>                        </svg>}                        <div.ui-multiples-cols>                            {for i in range(n) => <div.ui-multiples-col>                                {bars && vals[i] != null && {                                    let a = top(vals[i] < 0 ? 0 : vals[i])                                    let b = top(vals[i] < 0 ? vals[i] : 0)                                    <span.ui-multiples-bar style={`--a: ${small_multiples_pct(100 - b)}%; --b: ${small_multiples_pct(b - a)}%`}></span>                                }}                                {!bars && vals[i] != null && <span.ui-multiples-dot style={`top: ${small_multiples_pct(top(vals[i]))}%`}></span>}                                <span.ui-multiples-tip>{labels[i]}<b>{fmt(vals[i])}</b></span>                            </div>}                        </div>                    </div>                    <div.ui-multiples-x><span>{n > 0 ? labels[0] : ""}</span><span>{n > 1 ? labels[n - 1] : ""}</span></div>                </div>            }}        </div>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col"></th>{for g in groups => <th scope="col">{g.name}</th>}</tr></thead>            <tbody>                {for i in range(n) => <tr><th scope="row">{labels[i]}</th>{for g in groups => <td>{fmt((g.values ?? [])[i])}</td>}</tr>}            </tbody>        </table>    </figure>}
Thread
65Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.