SmallMultiples
The same small line, area or bar chart for each group in a grid, on one shared scale, with each value on hover.
Demo
| Lisbon | Porto | Madrid | Seville | Valencia | Bilbao | |
|---|---|---|---|---|---|---|
| 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 |
| Free | Pro | Team | Enterprise | |
|---|---|---|---|---|
| Jan | 1,840 | 420 | 96 | 4 |
| Feb | 2,010 | 455 | 104 | 3 |
| Mar | 2,210 | 515 | 130 | 6 |
| Apr | 2,480 | 590 | 151 | — |
| May | 2,650 | 640 | 188 | 7 |
| Jun | 3,120 | 810 | 245 | 9 |
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.qwemain.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>}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 downloadsGroupedBarChartA 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.6 views · 4 downloads
Thread
Thread 0 posts
On SmallMultiples: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.