Charts

BoxPlot

Quartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.

#chart#data#no-js

Demo

Preview
Response time by service
Response time by service
ValuesLowQ1MedianQ3HighOutliers
Auth6260 ms106 ms122 ms137 ms181 ms43 ms, 187 ms, 194 ms, 196 ms, 330 ms, 362 ms
Search6094 ms162 ms216.5 ms256 ms321 msNone
Checkout62121 ms153.75 ms171 ms187.75 ms224 ms12 ms, 405 ms
Images6035 ms81.5 ms96.5 ms115.25 ms162 msNone
Reports61166 ms236 ms272 ms314 ms378 ms530 ms
Exam scores by class
Exam scores by class
ValuesLowQ1MedianQ3HighMeanOutliers
Class A1162768186.59480.55None
Class B124563.2571768270.3399
Class C107075.57981.758678.5None

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

Example

The code of the preview above.

demos/box_plot.qwe
// n numbers around `mid`, `spread` either side, from a seeded generator (the same on// every run): each the mean of two draws, so most fall near the middle.fn box_plot_demo_values(seed: int, n: int, mid: num, spread: num) -> list {    let s = seed    let out = []    while len(out) < n {        let total = 0        for k in range(2) {            s = (s * 1103515245 + 12345) % 2147483648            total += s / 2147483648        }        out = push(out, round(mid + (total / 2 - 0.5) * 2 * spread))    }    out}view BoxPlotDemo() =    <div.demo-stack>        <BoxPlot label="Response time by service" show_label={true} suffix=" ms"                 groups={[{ name: "Auth", values: box_plot_demo_values(7, 60, 120, 80) + [330, 362] },                          { name: "Search", values: box_plot_demo_values(11, 60, 220, 140) },                          { name: "Checkout", values: box_plot_demo_values(23, 60, 170, 60) + [12, 405] },                          { name: "Images", values: box_plot_demo_values(31, 60, 100, 70) },                          { name: "Reports", values: box_plot_demo_values(43, 60, 270, 120) + [530] }]}/>        <BoxPlot label="Exam scores by class" show_label={true} points={true} mean={true} zero={true} height={200}                 groups={[{ name: "Class A", values: [62, 71, 74, 78, 80, 81, 83, 85, 88, 90, 94] },                          { name: "Class B", values: [45, 58, 61, 64, 66, 70, 72, 73, 75, 79, 82, 99] },                          { name: "Class C", values: [70, 72, 75, 77, 78, 80, 81, 82, 84, 86] }]}/>    </div>

Source

ui/box_plot.qwe
// BoxPlot: how each group's values spread, from the raw values: a box from the first// to the third quartile with the median across it, whiskers out to the furthest values// within 1.5 times the box's height, and the values beyond those as outliers. CSS// only: hovering a group shows its five numbers, and screen readers get them as a table.////   <BoxPlot label="Response time" suffix=" ms" groups={[//       { name: "API", values: api_times }, { name: "Web", values: web_times }]}/>//   <BoxPlot label="Scores" groups={classes} points={true} mean={true} zero={true}/>//// groups: a list of { name, values }, each values a list of numbers (nulls are left// out); a group with none shows nothing. Quartiles are worked out as numpy's// percentile does (linear between values). points: every value drawn as a dot over// its box, spread sideways so they can be seen. mean: the mean marked with a// diamond. zero: the axis starts at 0; otherwise it spans the values. prefix and// suffix: around every value shown ("$", " ms"; 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 (240). 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 box_plot_css = css`.ui-boxplot {  --ui-boxplot-1: light-dark(#2a78d6, #3987e5); --ui-boxplot-2: light-dark(#eb6834, #d95926);  --ui-boxplot-3: light-dark(#1baf7a, #199e70); --ui-boxplot-4: light-dark(#eda100, #c98500);  --ui-boxplot-5: light-dark(#e87ba4, #d55181); --ui-boxplot-6: light-dark(#008300, #008300);  --ui-boxplot-7: light-dark(#6250d6, #9085e9); --ui-boxplot-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-boxplot-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-boxplot-s1 { --c: var(--ui-boxplot-1); } .ui-boxplot-s2 { --c: var(--ui-boxplot-2); } .ui-boxplot-s3 { --c: var(--ui-boxplot-3); }.ui-boxplot-s4 { --c: var(--ui-boxplot-4); } .ui-boxplot-s5 { --c: var(--ui-boxplot-5); } .ui-boxplot-s6 { --c: var(--ui-boxplot-6); }.ui-boxplot-s7 { --c: var(--ui-boxplot-7); } .ui-boxplot-s8 { --c: var(--ui-boxplot-8); }.ui-boxplot-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-boxplot-y { position: relative; min-width: 24px; }.ui-boxplot-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-boxplot-plot { position: relative; }.ui-boxplot-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-boxplot-cols { position: absolute; inset: 0; display: flex; }.ui-boxplot-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-boxplot-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-boxplot-whisker { position: absolute; left: 50%; bottom: var(--a); height: var(--b); width: 0; border-left: 1.5px solid var(--c); }.ui-boxplot-cap { position: absolute; left: 50%; bottom: var(--a); width: min(20%, 20px); height: 0;  border-top: 1.5px solid var(--c); transform: translate(-50%, 50%); }.ui-boxplot-box { position: absolute; left: 50%; bottom: var(--a); height: var(--b); width: min(44%, 56px); min-height: 2px;  border: 1.5px solid var(--c); border-radius: 3px; background: color-mix(in srgb, var(--c) 18%, var(--ui-bg)); transform: translateX(-50%); }.ui-boxplot-median { position: absolute; left: 50%; bottom: var(--a); width: min(44%, 56px); height: 0;  border-top: 2.5px solid var(--c); transform: translate(-50%, 50%); }.ui-boxplot-mean { position: absolute; left: 50%; bottom: var(--a); width: 8px; height: 8px; margin: 0 0 -4px -4px;  background: var(--ui-bg); border: 1.5px solid var(--ui-fg); transform: rotate(45deg); }.ui-boxplot-out { position: absolute; left: 50%; bottom: var(--a); width: 7px; height: 7px; margin: 0 0 -3.5px -3.5px;  border: 1.5px solid var(--c); border-radius: 50%; background: var(--ui-bg); }.ui-boxplot-pt { position: absolute; left: calc(50% + var(--j)); bottom: var(--a); width: 4px; height: 4px; margin: 0 0 -2px -2px;  border-radius: 50%; background: var(--ui-fg); opacity: .45; }.ui-boxplot-x { display: flex; margin-top: 6px; }.ui-boxplot-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-boxplot-tip { position: absolute; z-index: 20; display: none; min-width: 120px; 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-boxplot-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-boxplot-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-boxplot-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-boxplot-tip { top: 0; left: calc(50% + 34px); }.ui-boxplot-flip .ui-boxplot-tip { left: auto; right: calc(50% + 34px); }.ui-boxplot-col:hover .ui-boxplot-tip { display: block; }`// n (a whole number, 0 or more) with at least `width` digits.fn box_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 box_plot_group(n: int) -> str =    n < 1000 ? str(n) : box_plot_group(floor(n / 1000)) + "," + box_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 box_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)}` : `.${box_plot_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${box_plot_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn box_plot_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 box_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 ? box_plot_num(v)        : `${v < 0 ? "-" : ""}${box_plot_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 box_plot_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 }}// The numbers a box is drawn from, of a list of numbers (one at least): { n, q1, median,// q3, low, high (the whiskers' ends), outliers, mean, min, max }.fn box_plot_summary(xs: list) -> map {    let qs = stats.percentile(xs, [25, 50, 75])    let iqr = qs[2] - qs[0]    let inside = xs | filter((v) => v >= qs[0] - 1.5 * iqr && v <= qs[2] + 1.5 * iqr)    {        n: len(xs), q1: qs[0], median: qs[1], q3: qs[2],        low: min(inside), high: max(inside),        outliers: sort(xs | filter((v) => v < qs[0] - 1.5 * iqr || v > qs[2] + 1.5 * iqr)),        mean: stats.fmean(xs), min: min(xs), max: max(xs),    }}view BoxPlot(groups: list, points: bool = false, mean: bool = false, zero: bool = false, prefix: str = "",             suffix: str = "", decimals: int? = null, height: int = 240, label: str = "Box plot",             show_label: bool = false, class: str? = null) {    style global box_plot_css    let fmt = (v) => `${v < 0 ? "-" : ""}${prefix}${box_plot_num(abs(v), decimals)}${suffix}`    let n = len(groups)    let sets = for g in groups => (g.values ?? []) | filter((v) => v != null)    let sums = for xs in sets => len(xs) > 0 ? box_plot_summary(xs) : null    let known = flatten(sets)    let lo0 = len(known) == 0 ? 0 : min(known)    let hi0 = len(known) == 0 ? 1 : max(known)    let sc = box_plot_scale(zero && lo0 > 0 ? 0 : lo0, zero && hi0 < 0 ? 0 : hi0)    let pos = (v) => (v - sc.lo) * 100 / (sc.hi - sc.lo)    let at = (v) => `--a: ${box_plot_pct(pos(v))}%`    let span = (a, b) => `--a: ${box_plot_pct(pos(a))}%; --b: ${box_plot_pct(pos(b) - pos(a))}%`    <figure class={ui_cls(["ui-boxplot", class])} aria-label={label}>        {show_label && <figcaption.ui-boxplot-title>{label}</figcaption>}        <div.ui-boxplot-frame aria-hidden="true">            <div.ui-boxplot-y style={`height: ${height}px`}>                {for t in sc.ticks => <span style={`bottom: ${box_plot_pct(pos(t))}%`}>{t < 0 ? "-" : ""}{prefix}{box_plot_short(abs(t))}</span>}            </div>            <div.ui-boxplot-plot style={`height: ${height}px`}>                {for t in sc.ticks => <span.ui-boxplot-grid style={`bottom: ${box_plot_pct(pos(t))}%`}></span>}                <div.ui-boxplot-cols>                    {for i in range(n) => {                        let s = sums[i]                        <div class={ui_cls(["ui-boxplot-col", `ui-boxplot-s${i % 8 + 1}`, n > 1 && i > (n - 1) / 2 && "ui-boxplot-flip"])}>                            {s != null && [                                <span.ui-boxplot-whisker style={span(s.low, s.high)}></span>,                                <span.ui-boxplot-cap style={at(s.low)}></span>,                                <span.ui-boxplot-cap style={at(s.high)}></span>,                                <span.ui-boxplot-box style={span(s.q1, s.q3)}></span>,                                <span.ui-boxplot-median style={at(s.median)}></span>,                                for v in s.outliers => <span.ui-boxplot-out style={at(v)}></span>,                                points && (for [k, v] in enumerate(sets[i]) =>                                    <span.ui-boxplot-pt style={`${at(v)}; --j: ${(k * 13 + 7) % 37 - 18}px`}></span>),                                mean && <span.ui-boxplot-mean style={at(s.mean)}></span>,                                <span.ui-boxplot-tip>                                    <strong>{groups[i].name}</strong>                                    <p>High<b>{fmt(s.high)}</b></p>                                    <p>Q3<b>{fmt(s.q3)}</b></p>                                    <p>Median<b>{fmt(s.median)}</b></p>                                    <p>Q1<b>{fmt(s.q1)}</b></p>                                    <p>Low<b>{fmt(s.low)}</b></p>                                    {mean && <p>Mean<b>{fmt(s.mean)}</b></p>}                                    <p>Values<b>{s.n}</b></p>                                    {len(s.outliers) > 0 && <p>Outliers<b>{join(map(s.outliers, fmt), ", ")}</b></p>}                                </span>                            ]}                        </div>                    }}                </div>            </div>            <span></span>            <div.ui-boxplot-x>{for g in groups => <span title={str(g.name)}>{g.name}</span>}</div>        </div>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr>                <th scope="col"></th><th scope="col">Values</th><th scope="col">Low</th><th scope="col">Q1</th>                <th scope="col">Median</th><th scope="col">Q3</th><th scope="col">High</th>                {mean && <th scope="col">Mean</th>}<th scope="col">Outliers</th>            </tr></thead>            <tbody>                {for i in range(n) => {                    let s = sums[i]                    <tr>                        <th scope="row">{groups[i].name}</th>                        {s == null ? <td>0</td> : [                            <td>{s.n}</td>, <td>{fmt(s.low)}</td>, <td>{fmt(s.q1)}</td>, <td>{fmt(s.median)}</td>,                            <td>{fmt(s.q3)}</td>, <td>{fmt(s.high)}</td>, mean && <td>{fmt(s.mean)}</td>,                            <td>{len(s.outliers) > 0 ? join(map(s.outliers, fmt), ", ") : "None"}</td>                        ]}                    </tr>                }}            </tbody>        </table>    </figure>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.