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.
Demo
| 2024 | 2025 | Change | |
|---|---|---|---|
| Oak Hill | 55 | 66 | +11 |
| Northfield | 61 | 70 | +9 |
| Harbor | 49 | 58 | +9 |
| Westbrook | 68 | 75 | +7 |
| Riverside | 72 | 74 | +2 |
| Lakeview | 80 | 77 | -3 |
| Value | |
|---|---|
| Sales | 1.2 h |
| Billing | 3.5 h |
| Support | 6 h |
| Security | 14 h |
| Low | Median | High | |
|---|---|---|---|
| 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.qwemain.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>}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 · 5 downloadsBoxPlotQuartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.5 views · 7 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 · 8 downloads
Thread
Thread 0 posts
On DotPlot: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.