Charts

ViolinPlot

The shape of each group's values as a mirrored kernel density, with quartiles and median inside and the numbers on hover.

#chart#data#no-js

Demo

Preview
Session length by device
Session length by device
ValuesLowestQ1MedianQ3Highest
Mobile1202 min5 min6 min7.25 min10 min
Tablet804 min7 min10 min12.25 min18 min
Desktop1203 min8.75 min12 min25 min32 min
Exam scores by class
Exam scores by class
ValuesLowestQ1MedianQ3Highest
Class A1162768186.594
Class B124563.25717699
New class17777777777

Desktop has two kinds of visit, short and long, so its violin has two bulges; the new class has one score, a dot.

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

Example

The code of the preview above.

demos/violin_plot.qwe
// n numbers from a seeded generator (the same on every run), each the mean of two// draws, around `mid` and `spread` either side.fn violin_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 - 1) * spread))    }    out}view ViolinPlotDemo() =    <div.demo-stack>        <ViolinPlot label="Session length by device" show_label={true} suffix=" min" zero={true}                    groups={[{ name: "Mobile", values: violin_plot_demo_values(5, 120, 6, 5) },                             { name: "Tablet", values: violin_plot_demo_values(9, 80, 11, 8) },                             { name: "Desktop", values: violin_plot_demo_values(13, 70, 9, 6) + violin_plot_demo_values(17, 50, 26, 6) }]}/>        <ViolinPlot label="Exam scores by class" show_label={true} box={false} bandwidth={4} 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: "New class", values: [77] }]}/>        <p.demo-note>Desktop has two kinds of visit, short and long, so its violin has two bulges; the new class has one score, a dot.</p>    </div>

Source

ui/violin_plot.qwe
// ViolinPlot: the shape of each group's values, as a kernel density estimate drawn// mirrored on both sides (wide where values crowd, thin where they are few), with// the quartiles and median inside. CSS only: hovering a group shows its numbers, and// screen readers get them as a table.////   <ViolinPlot label="Session length" suffix=" min" groups={[//       { name: "Mobile", values: mobile }, { name: "Desktop", values: desktop }]}/>//   <ViolinPlot label="Delivery time" groups={regions} bandwidth={2} box={false} zero={true}/>//// groups: a list of { name, values }, each values a list of numbers (nulls are left// out); a group with fewer than two shows its value as a dot. The density is a// Gaussian kernel's, drawn between the group's lowest and highest value, each violin// as wide as its column allows at its densest. bandwidth: the kernel's width, in the// values' units (by default Silverman's rule, from each group's spread). box: a thin// box from the first to the third quartile with the median as a dot (true). 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 violin_plot_css = css`.ui-violin {  --ui-violin-1: light-dark(#2a78d6, #3987e5); --ui-violin-2: light-dark(#eb6834, #d95926);  --ui-violin-3: light-dark(#1baf7a, #199e70); --ui-violin-4: light-dark(#eda100, #c98500);  --ui-violin-5: light-dark(#e87ba4, #d55181); --ui-violin-6: light-dark(#008300, #008300);  --ui-violin-7: light-dark(#6250d6, #9085e9); --ui-violin-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-violin-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-violin-s1 { --c: var(--ui-violin-1); } .ui-violin-s2 { --c: var(--ui-violin-2); } .ui-violin-s3 { --c: var(--ui-violin-3); }.ui-violin-s4 { --c: var(--ui-violin-4); } .ui-violin-s5 { --c: var(--ui-violin-5); } .ui-violin-s6 { --c: var(--ui-violin-6); }.ui-violin-s7 { --c: var(--ui-violin-7); } .ui-violin-s8 { --c: var(--ui-violin-8); }.ui-violin-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-violin-y { position: relative; min-width: 24px; }.ui-violin-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-violin-plot { position: relative; }.ui-violin-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-violin-cols { position: absolute; inset: 0; display: flex; }.ui-violin-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-violin-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-violin-shape { position: absolute; top: 0; bottom: 0; left: 50%; width: min(80%, 110px); height: 100%; overflow: visible; transform: translateX(-50%); }.ui-violin-area { fill: color-mix(in srgb, var(--c) 30%, var(--ui-bg)); stroke: var(--c); stroke-width: 1.5; stroke-linejoin: round; }.ui-violin-col:hover .ui-violin-area { fill: color-mix(in srgb, var(--c) 45%, var(--ui-bg)); }.ui-violin-box { position: absolute; left: 50%; bottom: var(--a); height: var(--b); width: 6px; margin-left: -3px; min-height: 2px;  border-radius: 3px; background: var(--ui-fg); opacity: .75; }.ui-violin-whisker { position: absolute; left: 50%; bottom: var(--a); height: var(--b); width: 0; border-left: 1px solid var(--ui-fg); opacity: .6; }.ui-violin-median { position: absolute; left: 50%; bottom: var(--a); width: 8px; height: 8px; margin: 0 0 -4px -4px;  border-radius: 50%; background: var(--ui-bg); box-shadow: 0 0 0 1.5px var(--ui-fg); }.ui-violin-dot { position: absolute; left: 50%; bottom: var(--a); width: 8px; height: 8px; margin: 0 0 -4px -4px; border-radius: 50%; background: var(--c); }.ui-violin-x { display: flex; margin-top: 6px; }.ui-violin-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-violin-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-violin-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-violin-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-violin-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-violin-tip { top: 0; left: calc(50% + 50px); }.ui-violin-flip .ui-violin-tip { left: auto; right: calc(50% + 50px); }.ui-violin-col:hover .ui-violin-tip { display: block; }`// n (a whole number, 0 or more) with at least `width` digits.fn violin_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 violin_plot_group(n: int) -> str =    n < 1000 ? str(n) : violin_plot_group(floor(n / 1000)) + "," + violin_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 violin_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)}` : `.${violin_plot_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${violin_plot_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn violin_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 violin_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 ? violin_plot_num(v)        : `${v < 0 ? "-" : ""}${violin_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 violin_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 }}// What a violin is drawn from, of a list of numbers (one at least): { n, q1, median, q3,// min, max, bw (the kernel's width), shape (a list of [value, density from 0 to 1]) }.fn violin_plot_summary(xs: list, bandwidth: num?) -> map {    let n = len(xs)    let qs = stats.percentile(xs, [25, 50, 75])    let lo = min(xs)    let hi = max(xs)    let spread = n > 1 ? min([stats.stdev(xs), (qs[2] - qs[0]) / 1.34]) : 0    let rule = 0.9 * (spread > 0 ? spread : (n > 1 ? stats.stdev(xs) : 0)) * pow(n, -0.2)    let bw = bandwidth != null && bandwidth > 0 ? bandwidth : (rule > 0 ? rule : (hi - lo > 0 ? (hi - lo) / 10 : 1))    let steps = 48    let at = for k in range(steps + 1) => lo + (hi - lo) * k / steps    let dens = for v in at => sum(xs | map((x) => math.exp(-0.5 * (v - x) * (v - x) / (bw * bw))))    let top = max(dens)    {        n: n, q1: qs[0], median: qs[1], q3: qs[2], min: lo, max: hi, bw: bw,        shape: for [k, v] in enumerate(at) => [v, top > 0 ? dens[k] / top : 0],    }}view ViolinPlot(groups: list, bandwidth: num? = null, box: bool = true, zero: bool = false, prefix: str = "",                suffix: str = "", decimals: int? = null, height: int = 240, label: str = "Violin plot",                show_label: bool = false, class: str? = null) {    style global violin_plot_css    let fmt = (v) => `${v < 0 ? "-" : ""}${prefix}${violin_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 ? violin_plot_summary(xs, bandwidth) : null    let known = flatten(sets)    let lo0 = len(known) == 0 ? 0 : min(known)    let hi0 = len(known) == 0 ? 1 : max(known)    let sc = violin_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: ${violin_plot_pct(pos(v))}%`    let span = (a, b) => `--a: ${violin_plot_pct(pos(a))}%; --b: ${violin_plot_pct(pos(b) - pos(a))}%`    // The outline: down the right side from the top, and back up the left.    let outline = (s) => {        let right = reverse(s.shape) | map((p) => `${violin_plot_pct(50 + p[1] * 50)},${violin_plot_pct(100 - pos(p[0]))}`)        let left = s.shape | map((p) => `${violin_plot_pct(50 - p[1] * 50)},${violin_plot_pct(100 - pos(p[0]))}`)        `M${join(right, " L")} L${join(left, " L")} Z`    }    <figure class={ui_cls(["ui-violin", class])} aria-label={label}>        {show_label && <figcaption.ui-violin-title>{label}</figcaption>}        <div.ui-violin-frame aria-hidden="true">            <div.ui-violin-y style={`height: ${height}px`}>                {for t in sc.ticks => <span style={`bottom: ${violin_plot_pct(pos(t))}%`}>{t < 0 ? "-" : ""}{prefix}{violin_plot_short(abs(t))}</span>}            </div>            <div.ui-violin-plot style={`height: ${height}px`}>                {for t in sc.ticks => <span.ui-violin-grid style={`bottom: ${violin_plot_pct(pos(t))}%`}></span>}                <div.ui-violin-cols>                    {for i in range(n) => {                        let s = sums[i]                        <div class={ui_cls(["ui-violin-col", `ui-violin-s${i % 8 + 1}`, n > 1 && i > (n - 1) / 2 && "ui-violin-flip"])}>                            {s != null && [                                s.n > 1 && s.max > s.min                                    ? <svg.ui-violin-shape viewBox="0 0 100 100" preserveAspectRatio="none">                                          <path.ui-violin-area d={outline(s)} vector-effect="non-scaling-stroke"/>                                      </svg>                                    : <span.ui-violin-dot style={at(s.median)}></span>,                                box && s.n > 1 && [                                    <span.ui-violin-whisker style={span(s.min, s.max)}></span>,                                    <span.ui-violin-box style={span(s.q1, s.q3)}></span>,                                    <span.ui-violin-median style={at(s.median)}></span>                                ],                                <span.ui-violin-tip>                                    <strong>{groups[i].name}</strong>                                    <p>Highest<b>{fmt(s.max)}</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>Lowest<b>{fmt(s.min)}</b></p>                                    <p>Values<b>{s.n}</b></p>                                </span>                            ]}                        </div>                    }}                </div>            </div>            <span></span>            <div.ui-violin-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">Lowest</th><th scope="col">Q1</th>                <th scope="col">Median</th><th scope="col">Q3</th><th scope="col">Highest</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.min)}</td>, <td>{fmt(s.q1)}</td>, <td>{fmt(s.median)}</td>,                            <td>{fmt(s.q3)}</td>, <td>{fmt(s.max)}</td>                        ]}                    </tr>                }}            </tbody>        </table>    </figure>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.