ScatterChart
Points placed by two numbers, in coloured groups, with a least-squares trend line for each and every point's numbers on hover.
Demo
| Point | Ad spend | Sales |
|---|---|---|
| Jan | $1,200 | $8,400 |
| Feb | $1,800 | $11,200 |
| Mar | $2,600 | $15,100 |
| Apr | $2,200 | $12,900 |
| May | $3,400 | $17,800 |
| Jun | $4,100 | $22,600 |
| Jul | $3,800 | $19,400 |
| Aug | $900 | $7,600 |
| Sep | $4,600 | $23,900 |
| Group | Point | Height | Weight |
|---|---|---|---|
| Women | 1 | 152 cm | 49.2 kg |
| Women | 2 | 163 cm | 58.1 kg |
| Women | 3 | 174 cm | 66.9 kg |
| Women | 4 | 159 cm | 59.6 kg |
| Women | 5 | 170 cm | 68.4 kg |
| Women | 6 | 155 cm | 61.1 kg |
| Women | 7 | 166 cm | 58.9 kg |
| Women | 8 | 177 cm | 67.7 kg |
| Women | 9 | 162 cm | 60.4 kg |
| Women | 10 | 173 cm | 69.3 kg |
| Women | 11 | 158 cm | 62 kg |
| Women | 12 | 169 cm | 59.8 kg |
| Women | 13 | 154 cm | 52.5 kg |
| Women | 14 | 165 cm | 61.3 kg |
| Women | 15 | 176 cm | 70.1 kg |
| Women | 16 | 161 cm | 62.8 kg |
| Women | 17 | 172 cm | 71.6 kg |
| Women | 18 | 157 cm | 53.3 kg |
| Men | 1 | 164 cm | 67.2 kg |
| Men | 2 | 165 cm | 72 kg |
| Men | 3 | 166 cm | 76.8 kg |
| Men | 4 | 167 cm | 81.6 kg |
| Men | 5 | 168 cm | 73.4 kg |
| Men | 6 | 169 cm | 78.2 kg |
| Men | 7 | 170 cm | 83 kg |
| Men | 8 | 171 cm | 74.8 kg |
| Men | 9 | 172 cm | 79.6 kg |
| Men | 10 | 173 cm | 84.4 kg |
| Men | 11 | 174 cm | 76.2 kg |
| Men | 12 | 175 cm | 81 kg |
| Men | 13 | 176 cm | 85.8 kg |
| Men | 14 | 177 cm | 77.6 kg |
| Men | 15 | 178 cm | 82.4 kg |
| Men | 16 | 179 cm | 87.2 kg |
| Men | 17 | 180 cm | 92 kg |
| Men | 18 | 181 cm | 83.8 kg |
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/scatter_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/scatter_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/scatter_chart.qwe
// Made-up measurements: steady sequences around a straight line, so the trend has something to find.let scatter_chart_demo_women = for i in range(18) => ({ x: 152 + i * 37 % 26, y: round(-40 + (152 + i * 37 % 26) * 0.62 + (i * 13 % 11) - 5, 1) })let scatter_chart_demo_men = for i in range(18) => ({ x: 164 + i * 29 % 28, y: round(-58 + (164 + i * 29 % 28) * 0.8 + (i * 17 % 13) - 6, 1) })view ScatterChartDemo() = <div.demo-stack> <ScatterChart label="Ad spend and sales by month" show_label={true} x_label="Ad spend" y_label="Sales" x_prefix="$" prefix="$" trend={true} height={220} points={[ { x: 1200, y: 8400, label: "Jan" }, { x: 1800, y: 11200, label: "Feb" }, { x: 2600, y: 15100, label: "Mar" }, { x: 2200, y: 12900, label: "Apr" }, { x: 3400, y: 17800, label: "May" }, { x: 4100, y: 22600, label: "Jun" }, { x: 3800, y: 19400, label: "Jul" }, { x: 900, y: 7600, label: "Aug" }, { x: 4600, y: 23900, label: "Sep" }]}/> <ScatterChart label="Height and weight" show_label={true} x_label="Height" x_suffix=" cm" y_label="Weight" suffix=" kg" trend={true} height={220} groups={[{ name: "Women", points: scatter_chart_demo_women }, { name: "Men", points: scatter_chart_demo_men }]}/> </div>Source
ui/scatter_chart.qwe
// ScatterChart: points placed by two numbers, x across and y up, to show how one// goes with the other; in groups, each in a colour of its own, and with a straight// trend line (least squares) for each if asked. CSS only: hovering a point shows its// name and numbers, and screen readers get the points as a table.//// <ScatterChart label="Ad spend and sales" x_label="Spend" y_label="Sales" x_prefix="$" prefix="$"// points={[{ x: 1200, y: 8400, label: "March" }, { x: 2600, y: 15100 }]} trend={true}/>// <ScatterChart label="Height and weight" x_label="Height" x_suffix=" cm" y_label="Weight" suffix=" kg"// groups={[{ name: "Women", points: women }, { name: "Men", points: men }]}/>//// points: a list of { x, y, label } (label optional, shown on hover); or groups: a// list of { name, points } (up to eight, each in a colour of its own, with a legend).// Points with a null x or y are left out. trend: a dashed least-squares line through// each group's points, from its lowest x to its highest. x_label and y_label: what// the two numbers are, under and above the axes and in the tooltip ("x", "y").// x_prefix and x_suffix, prefix and suffix: around the x and y values shown ("$",// " cm"; the axes take the prefixes); decimals: digits after the point, always,// instead of up to two as needed. zero: starts both axes at 0; otherwise they span// the values. height: the plot's height in pixels (260). 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 scatter_chart_css = css`.ui-scatc { --ui-scatc-1: light-dark(#2a78d6, #3987e5); --ui-scatc-2: light-dark(#eb6834, #d95926); --ui-scatc-3: light-dark(#1baf7a, #199e70); --ui-scatc-4: light-dark(#eda100, #c98500); --ui-scatc-5: light-dark(#e87ba4, #d55181); --ui-scatc-6: light-dark(#008300, #008300); --ui-scatc-7: light-dark(#6250d6, #9085e9); --ui-scatc-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-scatc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-scatc-s1 { --c: var(--ui-scatc-1); } .ui-scatc-s2 { --c: var(--ui-scatc-2); } .ui-scatc-s3 { --c: var(--ui-scatc-3); }.ui-scatc-s4 { --c: var(--ui-scatc-4); } .ui-scatc-s5 { --c: var(--ui-scatc-5); } .ui-scatc-s6 { --c: var(--ui-scatc-6); }.ui-scatc-s7 { --c: var(--ui-scatc-7); } .ui-scatc-s8 { --c: var(--ui-scatc-8); }.ui-scatc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-scatc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-scatc-key { width: 10px; height: 10px; border-radius: 50%; background: var(--c); flex: none; }.ui-scatc-tkey { width: 16px; border-top: 1.5px dashed var(--ui-muted); }.ui-scatc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; }.ui-scatc-ylabel { grid-column: 1 / -1; margin-bottom: 8px; color: var(--ui-muted); font-size: 11px; font-weight: 600; }.ui-scatc-y { position: relative; min-width: 24px; }.ui-scatc-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-scatc-plot { position: relative; }.ui-scatc-grid { position: absolute; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-scatc-h { left: 0; right: 0; height: 1px; }.ui-scatc-v { top: 0; bottom: 0; width: 1px; }.ui-scatc-zero { background: var(--ui-strong); }.ui-scatc-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }.ui-scatc-trend { fill: none; stroke: var(--c); stroke-width: 1.5; stroke-dasharray: 5 4; }.ui-scatc-pt { position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: color-mix(in srgb, var(--c) 80%, transparent); box-shadow: 0 0 0 1.5px var(--ui-bg); transition: box-shadow .12s ease; }.ui-scatc-pt::before { content: ""; position: absolute; inset: -5px; border-radius: 50%; }.ui-scatc-pt:hover { z-index: 10; background: var(--c); box-shadow: 0 0 0 2px var(--ui-bg), 0 0 0 4px var(--c); }.ui-scatc-tip { position: absolute; z-index: 20; bottom: 14px; left: 50%; 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; transform: translateX(-50%); pointer-events: none; }.ui-scatc-pt:hover .ui-scatc-tip { display: block; }.ui-scatc-left .ui-scatc-tip { left: 0; transform: none; }.ui-scatc-right .ui-scatc-tip { left: auto; right: 0; transform: none; }.ui-scatc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-scatc-tip p { display: flex; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-scatc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-scatc-x { position: relative; height: 16px; margin-top: 6px; }.ui-scatc-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-scatc-x span:first-child { transform: none; }.ui-scatc-x span:last-child { transform: translateX(-100%); }.ui-scatc-xlabel { grid-column: 2; margin-top: 4px; color: var(--ui-muted); font-size: 11px; font-weight: 600; text-align: right; }@media (prefers-reduced-motion: reduce) { .ui-scatc-pt { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn scatter_chart_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 scatter_chart_group(n: int) -> str = n < 1000 ? str(n) : scatter_chart_group(floor(n / 1000)) + "," + scatter_chart_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 scatter_chart_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)}` : `.${scatter_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${scatter_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn scatter_chart_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 ? scatter_chart_num(v) : `${v < 0 ? "-" : ""}${scatter_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage for CSS (or a coordinate out of 100), to two decimals.fn scatter_chart_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 scatter_chart_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 }}// The least-squares line through points: { slope, intercept }, or null for fewer than// two points or when every x is the same.fn scatter_chart_fit(pts: list) -> map? { let n = len(pts) if n < 2 { return null } let mx = sum(pts | map((p) => p.x)) / n let my = sum(pts | map((p) => p.y)) / n let sxx = sum(pts | map((p) => (p.x - mx) * (p.x - mx))) if sxx == 0 { return null } let slope = sum(pts | map((p) => (p.x - mx) * (p.y - my))) / sxx { slope: slope, intercept: my - slope * mx }}view ScatterChart(points: list? = null, groups: list? = null, trend: bool = false, x_label: str = "x", y_label: str = "y", x_prefix: str = "", x_suffix: str = "", prefix: str = "", suffix: str = "", decimals: int? = null, zero: bool = false, height: int = 260, label: str = "Scatter chart", show_label: bool = false, class: str? = null) { style global scatter_chart_css let sets0 = groups ?? [{ name: label, points: points ?? [] }] let sets = for g in sets0 => ({ name: g.name, points: g.points | filter((p) => p.x != null && p.y != null) }) let m = len(sets) let all_pts = flatten(for g in sets => g.points) let xs = all_pts | map((p) => p.x) let ys = all_pts | map((p) => p.y) let xlo = len(xs) == 0 ? 0 : min(xs) let xhi = len(xs) == 0 ? 0 : max(xs) let ylo = len(ys) == 0 ? 0 : min(ys) let yhi = len(ys) == 0 ? 0 : max(ys) let sx = scatter_chart_scale(zero && xlo > 0 ? 0 : xlo, zero && xhi < 0 ? 0 : xhi) let sy = scatter_chart_scale(zero && ylo > 0 ? 0 : ylo, zero && yhi < 0 ? 0 : yhi) let px = (v) => (v - sx.lo) * 100 / (sx.hi - sx.lo) let py = (v) => (v - sy.lo) * 100 / (sy.hi - sy.lo) let xf = (v) => `${v < 0 ? "-" : ""}${x_prefix}${scatter_chart_num(abs(v), decimals)}${x_suffix}` let yf = (v) => `${v < 0 ? "-" : ""}${prefix}${scatter_chart_num(abs(v), decimals)}${suffix}` <figure class={ui_cls(["ui-scatc", class])} aria-label={label}> {show_label && <figcaption.ui-scatc-title>{label}</figcaption>} {(m > 1 || trend) && <ul.ui-scatc-legend aria-hidden="true"> {m > 1 && (for [j, g] in enumerate(sets) => <li><span class={`ui-scatc-key ui-scatc-s${j % 8 + 1}`}></span>{g.name}</li>)} {trend && <li><span.ui-scatc-tkey></span>Trend (least squares)</li>} </ul>} <div.ui-scatc-frame aria-hidden="true"> <span.ui-scatc-ylabel>{y_label}</span> <div.ui-scatc-y style={`height: ${height}px`}> {for t in sy.ticks => <span style={`bottom: ${scatter_chart_pct(py(t))}%`}>{t < 0 ? "-" : ""}{prefix}{scatter_chart_short(abs(t))}</span>} </div> <div.ui-scatc-plot style={`height: ${height}px`}> {for t in sy.ticks => <span class={ui_cls(["ui-scatc-grid ui-scatc-h", t == 0 && "ui-scatc-zero"])} style={`bottom: ${scatter_chart_pct(py(t))}%`}></span>} {for t in sx.ticks => <span class={ui_cls(["ui-scatc-grid ui-scatc-v", t == 0 && "ui-scatc-zero"])} style={`left: ${scatter_chart_pct(px(t))}%`}></span>} {trend && <svg.ui-scatc-svg viewBox="0 0 100 100" preserveAspectRatio="none"> {for [j, g] in enumerate(sets) => { let fit = scatter_chart_fit(g.points) let gx = g.points | map((p) => p.x) fit != null && <path class={`ui-scatc-trend ui-scatc-s${j % 8 + 1}`} vector-effect="non-scaling-stroke" d={`M${scatter_chart_pct(px(min(gx)))},${scatter_chart_pct(100 - py(fit.slope * min(gx) + fit.intercept))} L${scatter_chart_pct(px(max(gx)))},${scatter_chart_pct(100 - py(fit.slope * max(gx) + fit.intercept))}`}/> }} </svg>} {for [j, g] in enumerate(sets) => for p in g.points => { let left = px(p.x) <span class={ui_cls(["ui-scatc-pt", `ui-scatc-s${j % 8 + 1}`, left < 20 && "ui-scatc-left", left > 80 && "ui-scatc-right"])} style={`left: ${scatter_chart_pct(left)}%; top: ${scatter_chart_pct(100 - py(p.y))}%`}> <span.ui-scatc-tip> {(p.label != null || m > 1) && <strong>{p.label ?? g.name}{p.label != null && m > 1 ? ` · ${g.name}` : ""}</strong>} <p>{x_label}<b>{xf(p.x)}</b></p> <p>{y_label}<b>{yf(p.y)}</b></p> </span> </span> }} </div> <span></span> <div.ui-scatc-x>{for t in sx.ticks => <span style={`left: ${scatter_chart_pct(px(t))}%`}>{t < 0 ? "-" : ""}{x_prefix}{scatter_chart_short(abs(t))}</span>}</div> <span.ui-scatc-xlabel>{x_label}</span> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> {m > 1 && <th scope="col">Group</th>} <th scope="col">Point</th><th scope="col">{x_label}</th><th scope="col">{y_label}</th> </tr></thead> <tbody> {for g in sets => for [k, p] in enumerate(g.points) => <tr> {m > 1 && <td>{g.name}</td>} <th scope="row">{p.label ?? str(k + 1)}</th><td>{xf(p.x)}</td><td>{yf(p.y)}</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 ScatterChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.