Charts

ScatterChart

Points placed by two numbers, in coloured groups, with a least-squares trend line for each and every point's numbers on hover.

#chart#data#no-js

Demo

Preview
Ad spend and sales by month
Ad spend and sales by month
PointAd spendSales
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
Height and weight
Height and weight
GroupPointHeightWeight
Women1152 cm49.2 kg
Women2163 cm58.1 kg
Women3174 cm66.9 kg
Women4159 cm59.6 kg
Women5170 cm68.4 kg
Women6155 cm61.1 kg
Women7166 cm58.9 kg
Women8177 cm67.7 kg
Women9162 cm60.4 kg
Women10173 cm69.3 kg
Women11158 cm62 kg
Women12169 cm59.8 kg
Women13154 cm52.5 kg
Women14165 cm61.3 kg
Women15176 cm70.1 kg
Women16161 cm62.8 kg
Women17172 cm71.6 kg
Women18157 cm53.3 kg
Men1164 cm67.2 kg
Men2165 cm72 kg
Men3166 cm76.8 kg
Men4167 cm81.6 kg
Men5168 cm73.4 kg
Men6169 cm78.2 kg
Men7170 cm83 kg
Men8171 cm74.8 kg
Men9172 cm79.6 kg
Men10173 cm84.4 kg
Men11174 cm76.2 kg
Men12175 cm81 kg
Men13176 cm85.8 kg
Men14177 cm77.6 kg
Men15178 cm82.4 kg
Men16179 cm87.2 kg
Men17180 cm92 kg
Men18181 cm83.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.qwe
main.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>}
Thread
75Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.