Charts

LollipopChart

Values as thin stems with a dot, up or across, sorted if asked, with each value on hover or beside its dot.

#chart#data#no-js

Demo

Preview
Customer score by month
Customer score by month
Value
Jan71
Feb73
Mar72
Apr76
May78
Jun77
Jul81
Aug83
Sep82
Oct85
Nov84
Dec88
Price change this year
Price change this year
Value
Olive oil31%
Eggs18%
Coffee12.5%
Bread4%
Milk-2.5%
Rice-6%
Pages per visit
Pages per visit
Value
Search4.2
Direct3.6
Email5.1
Social—
Ads2.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.qwe
main.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>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.