Charts

RadarChart

Series as shapes over axes around a circle, with rings for the scale, to compare profiles; hovering a shape or its legend picks it out.

#chart#data#no-js

Demo

Preview
Phone ratings
Phone ratings
Model AModel BModel C
Camera8.56.57
Battery697.5
Screen976
Speed7.586
Price58.59.5
Support768
Skills this year
Skills this year
Value
Design72 pts
Writing58 pts
Code91 pts
Data64 pts
Speaking—

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

Example

The code of the preview above.

demos/radar_chart.qwe
let radar_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px 40px"view RadarChartDemo() =    <div style={radar_chart_demo_grid}>        <RadarChart label="Phone ratings" show_label={true} limit={10} levels={5}                    axes={["Camera", "Battery", "Screen", "Speed", "Price", "Support"]}                    series={[{ name: "Model A", values: [8.5, 6, 9, 7.5, 5, 7] },                             { name: "Model B", values: [6.5, 9, 7, 8, 8.5, 6] },                             { name: "Model C", values: [7, 7.5, 6, 6, 9.5, 8] }]}/>        <RadarChart label="Skills this year" show_label={true} size={300} suffix=" pts"                    axes={["Design", "Writing", "Code", "Data", "Speaking"]}                    series={[{ name: "Skills", values: [72, 58, 91, 64, null] }]}/>    </div>

Source

ui/radar_chart.qwe
// RadarChart: one or more series over the same measures, each measure an axis out// from the centre and each series a shape joining its values, to compare profiles// (a product's ratings, a player's skills). CSS only: hovering a shape or its legend// row picks it out, each point names its value, and screen readers get the numbers// as a table.////   <RadarChart label="Phone ratings" axes={["Camera", "Battery", "Screen", "Speed", "Price"]}//       series={[{ name: "Model A", values: [8, 6, 9, 7, 5] }, { name: "Model B", values: [6, 9, 7, 8, 8] }]} limit={10}/>//// axes: the measures' names, clockwise from the top (three or more). series: a list// of { name, values }, one value per axis (up to eight, each in a colour of its own,// with a legend when more than one); a null counts as 0. limit: the value at the// rim, instead of the highest value rounded up; the axes start at 0. levels: how// many rings mark the scale (4), each labelled on the top axis. fill: shades each// shape (true). prefix and suffix: around every value shown; decimals: digits after// the point, always, instead of up to two as needed. size: the chart's width in// pixels (360; it shrinks to fit). 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 radar_chart_css = css`.ui-radarc {  --ui-radarc-1: light-dark(#2a78d6, #3987e5); --ui-radarc-2: light-dark(#eb6834, #d95926);  --ui-radarc-3: light-dark(#1baf7a, #199e70); --ui-radarc-4: light-dark(#eda100, #c98500);  --ui-radarc-5: light-dark(#e87ba4, #d55181); --ui-radarc-6: light-dark(#008300, #008300);  --ui-radarc-7: light-dark(#6250d6, #9085e9); --ui-radarc-8: light-dark(#e34948, #e66767);  display: grid; gap: 10px; align-content: start; justify-items: center; min-width: 0; margin: 0;  font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-radarc-title { justify-self: start; margin: 0; font-size: 14px; font-weight: 600; }.ui-radarc-s1 { --c: var(--ui-radarc-1); } .ui-radarc-s2 { --c: var(--ui-radarc-2); } .ui-radarc-s3 { --c: var(--ui-radarc-3); }.ui-radarc-s4 { --c: var(--ui-radarc-4); } .ui-radarc-s5 { --c: var(--ui-radarc-5); } .ui-radarc-s6 { --c: var(--ui-radarc-6); }.ui-radarc-s7 { --c: var(--ui-radarc-7); } .ui-radarc-s8 { --c: var(--ui-radarc-8); }.ui-radarc-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-radarc-legend li { display: inline-flex; align-items: center; gap: 6px; transition: opacity .15s ease; }.ui-radarc-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }.ui-radarc-svg { display: block; max-width: 100%; height: auto; overflow: visible; }.ui-radarc-ring { fill: none; stroke: color-mix(in srgb, var(--ui-border) 80%, transparent); stroke-width: 1; }.ui-radarc-ring.ui-radarc-rim { stroke: var(--ui-strong); }.ui-radarc-spoke { stroke: color-mix(in srgb, var(--ui-border) 80%, transparent); stroke-width: 1; }.ui-radarc-axis { fill: var(--ui-fg); font-size: 12px; }.ui-radarc-tick { fill: var(--ui-muted); font-size: 10px; font-variant-numeric: tabular-nums;  paint-order: stroke; stroke: var(--ui-bg); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; }.ui-radarc-shape { fill: var(--c); fill-opacity: .14; stroke: var(--c); stroke-width: 2; stroke-linejoin: round; }.ui-radarc-nofill .ui-radarc-shape { fill-opacity: 0; }.ui-radarc-series { transition: opacity .15s ease; }.ui-radarc-series:hover .ui-radarc-shape { fill-opacity: .3; }.ui-radarc-pt { fill: var(--c); stroke: var(--ui-bg); stroke-width: 1.5; }.ui-radarc-pt:hover { stroke: var(--c); stroke-width: 5; stroke-opacity: .35; }.ui-radarc:has(.ui-radarc-p1:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p1),.ui-radarc:has(.ui-radarc-p2:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p2),.ui-radarc:has(.ui-radarc-p3:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p3),.ui-radarc:has(.ui-radarc-p4:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p4),.ui-radarc:has(.ui-radarc-p5:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p5),.ui-radarc:has(.ui-radarc-p6:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p6),.ui-radarc:has(.ui-radarc-p7:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p7),.ui-radarc:has(.ui-radarc-p8:hover) :is(.ui-radarc-series, .ui-radarc-legend li):not(.ui-radarc-p8) { opacity: .2; }@media (prefers-reduced-motion: reduce) { .ui-radarc-series, .ui-radarc-legend li { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn radar_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 radar_chart_group(n: int) -> str =    n < 1000 ? str(n) : radar_chart_group(floor(n / 1000)) + "," + radar_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 radar_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)}` : `.${radar_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${radar_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn radar_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 ? radar_chart_num(v)        : `${v < 0 ? "-" : ""}${radar_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// The axis from lo to hi, widened to round ticks: about four steps of 1, 2, 2.5 or 5 x 10^n.fn radar_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 }}// A coordinate for SVG, to two decimals.fn radar_chart_len(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// The point at distance r along axis k of n (axis 0 points up, the rest clockwise).fn radar_chart_at(k: int, n: int, r: num) -> list = [r * math.sin(k * 2 * math.pi / n), -r * math.cos(k * 2 * math.pi / n)]view RadarChart(axes: list, series: list, limit: num? = null, levels: int = 4, fill: bool = true,                prefix: str = "", suffix: str = "", decimals: int? = null, size: int = 360,                label: str = "Radar chart", show_label: bool = false, class: str? = null) {    style global radar_chart_css    let n = len(axes)    let m = len(series)    let at = (s, k) => s.values[k] != null && s.values[k] > 0 ? s.values[k] : 0    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${radar_chart_num(abs(v), decimals)}${suffix}`    let top0 = max([0] + flatten(for s in series => for k in range(n) => at(s, k)))    let outer = limit != null && limit > 0 ? limit : radar_chart_scale(0, top0 == 0 ? 1 : top0).hi    let rings = levels < 1 ? 1 : levels    let rim = 100    let pt = (k, v) => radar_chart_at(k, n, rim * (v > outer ? outer : v) / outer)    let poly = (pts) => join(pts | map((p) => `${radar_chart_len(p[0])},${radar_chart_len(p[1])}`), " ")    <figure class={ui_cls(["ui-radarc", !fill && "ui-radarc-nofill", class])} aria-label={label}>        {show_label && <figcaption.ui-radarc-title>{label}</figcaption>}        {m > 1 && <ul.ui-radarc-legend aria-hidden="true">            {for [j, s] in enumerate(series) => <li class={`ui-radarc-p${j + 1} ui-radarc-s${j % 8 + 1}`}><span.ui-radarc-key></span>{s.name}</li>}        </ul>}        <svg.ui-radarc-svg width={size} height={radar_chart_len(size * 0.8)} viewBox="-180 -122 360 252" aria-hidden="true">            {for i in range(rings) => <polygon class={ui_cls(["ui-radarc-ring", i == rings - 1 && "ui-radarc-rim"])}                points={poly(for k in range(n) => radar_chart_at(k, n, rim * (i + 1) / rings))}/>}            {for k in range(n) => {                let [x, y] = radar_chart_at(k, n, rim)                <line.ui-radarc-spoke x1="0" y1="0" x2={radar_chart_len(x)} y2={radar_chart_len(y)}/>            }}            {for [k, name] in enumerate(axes) => {                let [x, y] = radar_chart_at(k, n, rim + 12)                <text.ui-radarc-axis x={radar_chart_len(x)} y={radar_chart_len(y)} dy={y < -rim * 0.9 ? "-0.1em" : (y > rim * 0.9 ? "0.8em" : "0.32em")}                      text-anchor={x > 5 ? "start" : (x < -5 ? "end" : "middle")}>{name}</text>            }}            {for [j, s] in enumerate(series) => <g class={`ui-radarc-series ui-radarc-p${j + 1} ui-radarc-s${j % 8 + 1}`}>                <polygon.ui-radarc-shape points={poly(for k in range(n) => pt(k, at(s, k)))}/>                {for k in range(n) => {                    let [x, y] = pt(k, at(s, k))                    <circle.ui-radarc-pt cx={radar_chart_len(x)} cy={radar_chart_len(y)} r="3.5">                        <title>{m > 1 ? `${s.name}, ` : ""}{axes[k]}: {fmt(s.values[k])}</title>                    </circle>                }}            </g>}            {for i in range(rings) => <text.ui-radarc-tick x="4" y={radar_chart_len(-rim * (i + 1) / rings)} dy="-0.3em">{prefix}{radar_chart_short(outer * (i + 1) / rings)}</text>}        </svg>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col"></th>{for s in series => <th scope="col">{m > 1 ? s.name : "Value"}</th>}</tr></thead>            <tbody>                {for [k, name] in enumerate(axes) => <tr><th scope="row">{name}</th>{for s in series => <td>{fmt(s.values[k])}</td>}</tr>}            </tbody>        </table>    </figure>}
Thread
64Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.