ViolinPlot
The shape of each group's values as a mirrored kernel density, with quartiles and median inside and the numbers on hover.
Demo
| Values | Lowest | Q1 | Median | Q3 | Highest | |
|---|---|---|---|---|---|---|
| Mobile | 120 | 2 min | 5 min | 6 min | 7.25 min | 10 min |
| Tablet | 80 | 4 min | 7 min | 10 min | 12.25 min | 18 min |
| Desktop | 120 | 3 min | 8.75 min | 12 min | 25 min | 32 min |
| Values | Lowest | Q1 | Median | Q3 | Highest | |
|---|---|---|---|---|---|---|
| Class A | 11 | 62 | 76 | 81 | 86.5 | 94 |
| Class B | 12 | 45 | 63.25 | 71 | 76 | 99 |
| New class | 1 | 77 | 77 | 77 | 77 | 77 |
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.qwemain.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>}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 ViolinPlot: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.