LollipopChart
Values as thin stems with a dot, up or across, sorted if asked, with each value on hover or beside its dot.
Demo
| Value | |
|---|---|
| Jan | 71 |
| Feb | 73 |
| Mar | 72 |
| Apr | 76 |
| May | 78 |
| Jun | 77 |
| Jul | 81 |
| Aug | 83 |
| Sep | 82 |
| Oct | 85 |
| Nov | 84 |
| Dec | 88 |
| Value | |
|---|---|
| Olive oil | 31% |
| Eggs | 18% |
| Coffee | 12.5% |
| Bread | 4% |
| Milk | -2.5% |
| Rice | -6% |
| Value | |
|---|---|
| Search | 4.2 |
| Direct | 3.6 |
| 5.1 | |
| Social | — |
| Ads | 2.4 |
Scores start the axis at 70 (zero=false); Social has no data.
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/lollipop_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/lollipop_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/lollipop_chart.qwe
let lollipop_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px"view LollipopChartDemo() = <div.demo-stack> <LollipopChart label="Customer score by month" show_label={true} zero={false} value_labels={true} labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]} values={[71, 73, 72, 76, 78, 77, 81, 83, 82, 85, 84, 88]}/> <div style={lollipop_chart_demo_grid}> <LollipopChart label="Price change this year" show_label={true} horizontal={true} sort="desc" suffix="%" value_labels={true} labels={["Coffee", "Bread", "Milk", "Eggs", "Rice", "Olive oil"]} values={[12.5, 4, -2.5, 18, -6, 31]}/> <LollipopChart label="Pages per visit" show_label={true} height={160} decimals={1} labels={["Search", "Direct", "Email", "Social", "Ads"]} values={[4.2, 3.6, 5.1, null, 2.4]}/> </div> <p.demo-note>Scores start the axis at 70 (zero=false); Social has no data.</p> </div>Source
ui/lollipop_chart.qwe
// LollipopChart: a value for each label as a thin stem from the zero line with a dot// at its end: a bar chart with less ink, for many categories or values close// together. Up or across, optionally sorted. CSS only: hovering one picks it out and// shows its value, and screen readers get the numbers as a table.//// <LollipopChart label="Score by team" labels={["Ops", "Sales", "Support"]} values={[72, 64, 91]}/>// <LollipopChart label="Change in price" labels={products} values={changes} suffix="%"// horizontal={true} sort="desc" value_labels={true}/>//// labels: the categories; values: one number for each (a null leaves its place// empty; below 0 a stem hangs under, or left of, the zero line). horizontal: stems run// across with the labels on the left (better for long labels and many categories).// sort: "desc" (largest first), "asc", or "none" to keep the order given (the// default). value_labels: each value beyond its dot. zero: starts the axis at 0// (true); false spans the values, the stems then rising from the axis' start.// prefix and suffix: around every value shown ("$", "%"; the axis takes the prefix);// decimals: digits after the point, always, instead of up to two as needed. height:// the plot's height in pixels when the stems stand up (220). 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 lollipop_chart_css = css`.ui-lollic { --ui-lollic-1: light-dark(#2a78d6, #3987e5); display: grid; gap: 10px; align-content: start; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-lollic-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-lollic-v { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 10px; }.ui-lollic-y { position: relative; min-width: 24px; }.ui-lollic-y span, .ui-lollic-hx span { position: absolute; color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-lollic-y span { right: 0; transform: translateY(50%); }.ui-lollic-plot { position: relative; }.ui-lollic-grid { position: absolute; left: 0; right: 0; bottom: var(--a); height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-lollic-zero { background: var(--ui-strong); }.ui-lollic-cols { position: absolute; inset: 0; display: flex; }.ui-lollic-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-lollic-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-lollic-stem { position: absolute; left: 50%; bottom: var(--a); width: 2px; height: var(--b); margin-left: -1px; background: var(--ui-lollic-1); }.ui-lollic-dot { position: absolute; left: 50%; bottom: var(--e); width: 12px; height: 12px; margin: 0 0 -6px -6px; border-radius: 50%; background: var(--ui-lollic-1); box-shadow: 0 0 0 2px var(--ui-bg); transition: transform .12s ease; }.ui-lollic-col:hover .ui-lollic-dot, .ui-lollic-row:hover .ui-lollic-dot { transform: scale(1.3); }.ui-lollic-val { position: absolute; left: 50%; bottom: calc(var(--e) + 10px); transform: translateX(-50%); color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-lollic-val.ui-lollic-under { bottom: auto; top: calc(100% - var(--e) + 10px); }.ui-lollic-x { display: flex; margin-top: 6px; }.ui-lollic-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-lollic-tip { position: absolute; z-index: 20; display: none; padding: 6px 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-muted); font-size: 12px; white-space: nowrap; pointer-events: none; }.ui-lollic-tip b { padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-lollic-col .ui-lollic-tip { top: 0; left: 50%; transform: translate(-50%, -100%); }.ui-lollic-col:first-child .ui-lollic-tip { left: 0; transform: translateY(-100%); }.ui-lollic-col:last-child .ui-lollic-tip { left: auto; right: 0; transform: translateY(-100%); }.ui-lollic-col:hover .ui-lollic-tip, .ui-lollic-row:hover .ui-lollic-tip { display: block; }.ui-lollic-h { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); column-gap: 10px; align-items: center; }.ui-lollic-vals { padding-right: 52px; }.ui-lollic-rl { max-width: 16em; overflow: hidden; color: var(--ui-muted); font-size: 13px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.ui-lollic-hgrid { position: relative; grid-column: 2; align-self: stretch; }.ui-lollic-hgrid .ui-lollic-grid { top: 0; bottom: 0; left: var(--a); right: auto; width: 1px; height: auto; }.ui-lollic-row { position: relative; grid-column: 2; height: 26px; border-radius: 4px; }.ui-lollic-row:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-lollic-row .ui-lollic-stem { top: 50%; bottom: auto; left: var(--a); width: var(--b); height: 2px; margin: -1px 0 0; }.ui-lollic-row .ui-lollic-dot { top: 50%; bottom: auto; left: var(--e); margin: -6px 0 0 -6px; }.ui-lollic-row .ui-lollic-val { top: 50%; bottom: auto; left: calc(var(--e) + 12px); transform: translateY(-50%); }.ui-lollic-row .ui-lollic-val.ui-lollic-under { left: auto; right: calc(100% - var(--e) + 12px); }.ui-lollic-row .ui-lollic-tip { bottom: calc(100% + 2px); right: 0; }.ui-lollic-hx { position: relative; grid-column: 2; height: 16px; margin-top: 4px; }.ui-lollic-hx span { top: 0; transform: translateX(-50%); }.ui-lollic-hx span:first-child { transform: none; }.ui-lollic-hx span:last-child:not(:first-child) { transform: translateX(-100%); }@media (prefers-reduced-motion: reduce) { .ui-lollic-dot { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn lollipop_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 lollipop_chart_group(n: int) -> str = n < 1000 ? str(n) : lollipop_chart_group(floor(n / 1000)) + "," + lollipop_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 lollipop_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)}` : `.${lollipop_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${lollipop_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn lollipop_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 ? lollipop_chart_num(v) : `${v < 0 ? "-" : ""}${lollipop_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 lollipop_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 lollipop_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 }}view LollipopChart(labels: list, values: list, horizontal: bool = false, sort: str = "none", value_labels: bool = false, zero: bool = true, prefix: str = "", suffix: str = "", decimals: int? = null, height: int = 220, label: str = "Lollipop chart", show_label: bool = false, class: str? = null) { style global lollipop_chart_css let n = len(labels) let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${lollipop_chart_num(abs(v), decimals)}${suffix}` let key = (i) => values[i] ?? 0 let order = sort == "desc" ? sort_by(range(n), (i) => -key(i)) : (sort == "asc" ? sort_by(range(n), (i) => key(i)) : range(n)) let known = values | filter((v) => v != null) let lo0 = len(known) == 0 ? 0 : min(known) let hi0 = len(known) == 0 ? 0 : max(known) // Across, room on the left for the labels of values below 0. let room = horizontal && value_labels && lo0 < 0 ? (hi0 - lo0) * 0.25 : 0 let sc = lollipop_chart_scale(zero && lo0 > 0 ? 0 : lo0 - room, zero && hi0 < 0 ? 0 : hi0) let pos = (v) => (v - sc.lo) * 100 / (sc.hi - sc.lo) let base = sc.lo <= 0 && sc.hi >= 0 ? pos(0) : 0 let tick = (t) => `${t < 0 ? "-" : ""}${prefix}${lollipop_chart_short(abs(t))}` // A stem from the base to v (--a where it starts, --b its length) and the dot's place (--e). let geo = (v) => { let end = pos(v) `--a: ${lollipop_chart_pct(end < base ? end : base)}%; --b: ${lollipop_chart_pct(abs(end - base))}%; --e: ${lollipop_chart_pct(end)}%` } let pop = (i) => { let v = values[i] v != null && [ <span.ui-lollic-stem></span>, <span.ui-lollic-dot data-value={str(v)}></span>, value_labels && <span class={ui_cls(["ui-lollic-val", pos(v) < base && "ui-lollic-under"])}>{fmt(v)}</span> ] } <figure class={ui_cls(["ui-lollic", class])} aria-label={label}> {show_label && <figcaption.ui-lollic-title>{label}</figcaption>} {horizontal ? <div class={ui_cls(["ui-lollic-h", value_labels && "ui-lollic-vals"])} aria-hidden="true"> <div.ui-lollic-hgrid style={`grid-row: 1 / span ${n > 0 ? n : 1}`}> {for t in sc.ticks => <span class={ui_cls(["ui-lollic-grid", t == 0 && "ui-lollic-zero"])} style={`--a: ${lollipop_chart_pct(pos(t))}%`}></span>} </div> {for [r, i] in enumerate(order) => [ <span.ui-lollic-rl style={`grid-row: ${r + 1}`} title={str(labels[i])}>{labels[i]}</span>, <div.ui-lollic-row style={`grid-row: ${r + 1}; ${values[i] == null ? "" : geo(values[i])}`}> {pop(i)} <span.ui-lollic-tip>{labels[i]}<b>{fmt(values[i])}</b></span> </div> ]} <div.ui-lollic-hx>{for t in sc.ticks => <span style={`left: ${lollipop_chart_pct(pos(t))}%`}>{tick(t)}</span>}</div> </div> : <div.ui-lollic-v aria-hidden="true"> <div.ui-lollic-y style={`height: ${height}px`}> {for t in sc.ticks => <span style={`bottom: ${lollipop_chart_pct(pos(t))}%`}>{tick(t)}</span>} </div> <div.ui-lollic-plot style={`height: ${height}px`}> {for t in sc.ticks => <span class={ui_cls(["ui-lollic-grid", t == 0 && "ui-lollic-zero"])} style={`--a: ${lollipop_chart_pct(pos(t))}%`}></span>} <div.ui-lollic-cols> {for i in order => <div.ui-lollic-col style={values[i] == null ? null : geo(values[i])}> {pop(i)} <span.ui-lollic-tip>{labels[i]}<b>{fmt(values[i])}</b></span> </div>} </div> </div> <span></span> <div.ui-lollic-x>{for i in order => <span title={str(labels[i])}>{labels[i]}</span>}</div> </div>} <table.ui-sr> <caption>{label}</caption> <thead><tr><th scope="col"></th><th scope="col">Value</th></tr></thead> <tbody> {for i in order => <tr><th scope="row">{labels[i]}</th><td>{fmt(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 downloadsBarChartBars up or across, one series or several grouped or stacked, on round ticks, with values on hover and a table for screen readers.7 views · 7 downloadsBollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 6 downloadsBoxPlotQuartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.5 views · 8 downloadsBrushChartA large chart of a range with an overview below, where a window dragged or moved by keys picks the range shown.6 views · 5 downloadsBubbleChartPoints placed by two numbers and sized by a third, in coloured groups, labelled when big enough, with all three numbers on hover.7 views · 9 downloads
Thread
Thread 0 posts
On LollipopChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.