Charts

DotPlot

Values as dots along one axis per row; with two or more series joined as a dumbbell, sorted by change if asked, with the change on hover.

#chart#data#no-js

Demo

Preview
Reading score, 2024 and 2025
Reading score, 2024 and 2025
20242025Change
Oak Hill5566+11
Northfield6170+9
Harbor4958+9
Westbrook6875+7
Riverside7274+2
Lakeview8077-3
Hours to first reply
Hours to first reply
Value
Sales1.2 h
Billing3.5 h
Support6 h
Security14 h
Salary range by level
Salary range by level
LowMedianHigh
Junior$52,000$61,000$70,000
Mid$74,000$86,000$99,000
Senior$98,000$114,000$135,000
Staff$131,000$152,000—

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

Example

The code of the preview above.

demos/dot_plot.qwe
let dot_plot_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px"view DotPlotDemo() =    <div.demo-stack>        <DotPlot label="Reading score, 2024 and 2025" show_label={true} sort="change"                 labels={["Northfield", "Riverside", "Oak Hill", "Lakeview", "Westbrook", "Harbor"]}                 series={[{ name: "2024", values: [61, 72, 55, 80, 68, 49] },                          { name: "2025", values: [70, 74, 66, 77, 75, 58] }]}/>        <div style={dot_plot_demo_grid}>            <DotPlot label="Hours to first reply" show_label={true} sort="asc" zero={true} suffix=" h"                     labels={["Billing", "Sales", "Support", "Security"]} values={[3.5, 1.2, 6, 14]}/>            <DotPlot label="Salary range by level" show_label={true} prefix="$"                     labels={["Junior", "Mid", "Senior", "Staff"]}                     series={[{ name: "Low", values: [52000, 74000, 98000, 131000] },                              { name: "Median", values: [61000, 86000, 114000, 152000] },                              { name: "High", values: [70000, 99000, 135000, null] }]}/>        </div>    </div>

Source

ui/dot_plot.qwe
// DotPlot: a row for each label with its values as dots along one axis; with two or// more series the dots of a row are joined by a bar (a dumbbell), so the gap between// them reads at a glance: before and after, plan and actual, men and women. CSS// only: hovering a row shows its values (and, for two series, the change), and screen// readers get the numbers as a table.////   <DotPlot label="Time to first reply (h)" labels={teams} values={[3.5, 6, 1.2]}/>//   <DotPlot label="Score before and after" labels={schools} sort="change"//       series={[{ name: "2024", values: before }, { name: "2025", values: after }]}/>//// labels: one row each. values: one series' numbers, one per label; or series: a list// of { name, values } (up to eight, each in a colour of its own, with a legend). A// null leaves its dot out. sort: "desc" or "asc" by the first series' values,// "change" by how much the last series moved from the first (most up first), or// "none" to keep the order given (the default). zero: starts the axis at 0; otherwise// it spans the values. 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. 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 dot_plot_css = css`.ui-dotplot {  --ui-dotplot-1: light-dark(#2a78d6, #3987e5); --ui-dotplot-2: light-dark(#eb6834, #d95926);  --ui-dotplot-3: light-dark(#1baf7a, #199e70); --ui-dotplot-4: light-dark(#eda100, #c98500);  --ui-dotplot-5: light-dark(#e87ba4, #d55181); --ui-dotplot-6: light-dark(#008300, #008300);  --ui-dotplot-7: light-dark(#6250d6, #9085e9); --ui-dotplot-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-dotplot-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-dotplot-s1 { --c: var(--ui-dotplot-1); } .ui-dotplot-s2 { --c: var(--ui-dotplot-2); } .ui-dotplot-s3 { --c: var(--ui-dotplot-3); }.ui-dotplot-s4 { --c: var(--ui-dotplot-4); } .ui-dotplot-s5 { --c: var(--ui-dotplot-5); } .ui-dotplot-s6 { --c: var(--ui-dotplot-6); }.ui-dotplot-s7 { --c: var(--ui-dotplot-7); } .ui-dotplot-s8 { --c: var(--ui-dotplot-8); }.ui-dotplot-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-dotplot-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-dotplot-key { width: 10px; height: 10px; border-radius: 50%; background: var(--c); flex: none; }.ui-dotplot-body { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); column-gap: 12px; align-items: center; padding-right: 8px; }.ui-dotplot-rl { max-width: 16em; overflow: hidden; color: var(--ui-muted); font-size: 13px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.ui-dotplot-grids { position: relative; grid-column: 2; align-self: stretch; }.ui-dotplot-grid { position: absolute; top: 0; bottom: 0; left: var(--a); width: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-dotplot-zero { background: var(--ui-strong); }.ui-dotplot-row { position: relative; grid-column: 2; height: 28px; border-radius: 4px; }.ui-dotplot-row::before { content: ""; position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dotted var(--ui-border); }.ui-dotplot-row:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-dotplot-bar { position: absolute; top: 50%; left: var(--a); width: var(--b); height: 4px; margin-top: -2px; border-radius: 2px;  background: color-mix(in srgb, var(--ui-strong) 70%, transparent); }.ui-dotplot-dot { position: absolute; top: 50%; left: var(--x); width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;  background: var(--c); box-shadow: 0 0 0 2px var(--ui-bg); transition: transform .12s ease; }.ui-dotplot-row:hover .ui-dotplot-dot { transform: scale(1.25); }.ui-dotplot-tip { position: absolute; z-index: 20; bottom: calc(100% + 2px); right: 0; display: none; min-width: 130px; 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; pointer-events: none; }.ui-dotplot-row:hover .ui-dotplot-tip { display: block; }.ui-dotplot-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-dotplot-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-dotplot-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-dotplot-tip .ui-dotplot-change { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--ui-border); }.ui-dotplot-x { position: relative; grid-column: 2; height: 16px; margin-top: 4px; }.ui-dotplot-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }@media (prefers-reduced-motion: reduce) { .ui-dotplot-dot { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn dot_plot_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 dot_plot_group(n: int) -> str =    n < 1000 ? str(n) : dot_plot_group(floor(n / 1000)) + "," + dot_plot_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 dot_plot_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)}` : `.${dot_plot_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${dot_plot_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn dot_plot_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 ? dot_plot_num(v)        : `${v < 0 ? "-" : ""}${dot_plot_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage for CSS (or a coordinate out of 100), to two decimals.fn dot_plot_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 dot_plot_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 DotPlot(labels: list, values: list? = null, series: list? = null, sort: str = "none", zero: bool = false,             prefix: str = "", suffix: str = "", decimals: int? = null, label: str = "Dot plot",             show_label: bool = false, class: str? = null) {    style global dot_plot_css    let sets = series ?? [{ name: label, values: values ?? [] }]    let m = len(sets)    let n = len(labels)    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${dot_plot_num(abs(v), decimals)}${suffix}`    let signed = (v) => `${v > 0 ? "+" : (v < 0 ? "-" : "")}${prefix}${dot_plot_num(abs(v), decimals)}${suffix}`    let change = (i) => sets[m - 1].values[i] != null && sets[0].values[i] != null ? sets[m - 1].values[i] - sets[0].values[i] : null    let lead = (i) => sets[0].values[i] ?? 0    let order = sort == "desc" ? sort_by(range(n), (i) => -lead(i))        : (sort == "asc" ? sort_by(range(n), (i) => lead(i))        : (sort == "change" ? sort_by(range(n), (i) => -(change(i) ?? 0)) : range(n)))    let known = flatten(for s in sets => for i in range(n) => s.values[i]) | filter((v) => v != null)    let lo0 = len(known) == 0 ? 0 : min(known)    let hi0 = len(known) == 0 ? 0 : max(known)    let sc = dot_plot_scale(zero && lo0 > 0 ? 0 : lo0, zero && hi0 < 0 ? 0 : hi0)    let pos = (v) => dot_plot_pct((v - sc.lo) * 100 / (sc.hi - sc.lo))    <figure class={ui_cls(["ui-dotplot", class])} aria-label={label}>        {show_label && <figcaption.ui-dotplot-title>{label}</figcaption>}        {m > 1 && <ul.ui-dotplot-legend aria-hidden="true">            {for [j, s] in enumerate(sets) => <li><span class={`ui-dotplot-key ui-dotplot-s${j % 8 + 1}`}></span>{s.name}</li>}        </ul>}        <div.ui-dotplot-body aria-hidden="true">            <div.ui-dotplot-grids style={`grid-row: 1 / span ${n > 0 ? n : 1}`}>                {for t in sc.ticks => <span class={ui_cls(["ui-dotplot-grid", t == 0 && "ui-dotplot-zero"])} style={`--a: ${pos(t)}%`}></span>}            </div>            {for [r, i] in enumerate(order) => {                let here = sets | map((s) => s.values[i]) | filter((v) => v != null)                [                    <span.ui-dotplot-rl style={`grid-row: ${r + 1}`} title={str(labels[i])}>{labels[i]}</span>,                    <div.ui-dotplot-row style={`grid-row: ${r + 1}`}>                        {len(here) > 1 && <span.ui-dotplot-bar style={`--a: ${pos(min(here))}%; --b: ${dot_plot_pct((max(here) - min(here)) * 100 / (sc.hi - sc.lo))}%`}></span>}                        {for [j, s] in enumerate(sets) => s.values[i] != null && <span class={`ui-dotplot-dot ui-dotplot-s${j % 8 + 1}`}                            style={`--x: ${pos(s.values[i])}%`} data-value={str(s.values[i])}></span>}                        <span.ui-dotplot-tip>                            <strong>{labels[i]}</strong>                            {for [j, s] in enumerate(sets) => <p>                                {m > 1 && <span class={`ui-dotplot-key ui-dotplot-s${j % 8 + 1}`}></span>}                                {m > 1 ? s.name : label}<b>{fmt(s.values[i])}</b>                            </p>}                            {m == 2 && change(i) != null && <p.ui-dotplot-change>Change<b>{signed(change(i))}</b></p>}                        </span>                    </div>                ]            }}            <div.ui-dotplot-x>{for t in sc.ticks => <span style={`left: ${pos(t)}%`}>{t < 0 ? "-" : ""}{prefix}{dot_plot_short(abs(t))}</span>}</div>        </div>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr>                <th scope="col"></th>                {for s in sets => <th scope="col">{m > 1 ? s.name : "Value"}</th>}                {m == 2 && <th scope="col">Change</th>}            </tr></thead>            <tbody>                {for i in order => <tr>                    <th scope="row">{labels[i]}</th>                    {for s in sets => <td>{fmt(s.values[i])}</td>}                    {m == 2 && <td>{change(i) == null ? "—" : signed(change(i))}</td>}                </tr>}            </tbody>        </table>    </figure>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.