Charts

HeatmapChart

A grid of cells coloured by value, in one colour or two either side of a middle, with a colour scale and each cell's value on hover.

#chart#data#grid#no-js

Demo

Preview
Visits by weekday and hour
Visits by weekday and hour
00:0002:0004:0006:0008:0010:0012:0014:0016:0018:0020:0022:00
Mon1216206082104126118110705234
Tue1519236385107129121113735537
Wed1822266688110132124116765840
Thu2125296991113135127119796143
Fri2428327294116138130122826446
Sat202224425262726865473830
Sun232527455565757168504133
Correlation
Correlation
PriceAdsVisitsSignupsChurn
Price1.00-0.62-0.41-0.550.38
Ads-0.621.000.810.66-0.12
Visits-0.410.811.000.74-0.20
Signups-0.550.660.741.00-0.47
Churn0.38-0.12-0.20-0.471.00
Uptime by region
Uptime by region
JulAugSepOct
EU99.95%99.99%99.9%100%
US99.8%—99.97%99.99%
Asia99.5%99.7%99.92%99.98%

US in August had no data, so its cell is hatched.

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

Example

The code of the preview above.

demos/heatmap_chart.qwe
let heatmap_chart_demo_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]let heatmap_chart_demo_hours = for h in range(12) => `${h * 2 < 10 ? "0" : ""}${h * 2}:00`// Visits: busy on weekday afternoons, quiet at night and at the weekend.let heatmap_chart_demo_visits = for d in range(7) => for h in range(12) =>    round((d < 5 ? 1 : 0.45) * (h < 3 ? 12 + h * 4 : (h < 9 ? 60 + (h - 3) * 22 - (h > 6 ? (h - 6) * 30 : 0) : 70 - (h - 9) * 18)) + d * 3)let heatmap_chart_demo_names = ["Price", "Ads", "Visits", "Signups", "Churn"]view HeatmapChartDemo() =    <div.demo-stack>        <HeatmapChart label="Visits by weekday and hour" show_label={true}                      rows={heatmap_chart_demo_days} cols={heatmap_chart_demo_hours} values={heatmap_chart_demo_visits}/>        <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px">            <HeatmapChart label="Correlation" show_label={true} diverging={true} low={-1} high={1} decimals={2}                          cell_values={true} cell={40} rows={heatmap_chart_demo_names} cols={heatmap_chart_demo_names}                          values={[[1, -0.62, -0.41, -0.55, 0.38], [-0.62, 1, 0.81, 0.66, -0.12],                                   [-0.41, 0.81, 1, 0.74, -0.2], [-0.55, 0.66, 0.74, 1, -0.47], [0.38, -0.12, -0.2, -0.47, 1]]}/>            <HeatmapChart label="Uptime by region" show_label={true} color={3} suffix="%" cell_values={true} cell={34}                          rows={["EU", "US", "Asia"]} cols={["Jul", "Aug", "Sep", "Oct"]}                          values={[[99.95, 99.99, 99.9, 100], [99.8, null, 99.97, 99.99], [99.5, 99.7, 99.92, 99.98]]}/>        </div>        <p.demo-note>US in August had no data, so its cell is hatched.</p>    </div>

Source

ui/heatmap_chart.qwe
// HeatmapChart: a grid of cells, rows by columns, each coloured by its value (visits// by weekday and hour, a correlation matrix), with the colour scale under it. CSS// only: hovering a cell names its row, column and value, and screen readers get the// numbers as a table.////   <HeatmapChart label="Visits by hour" rows={["Mon", "Tue", "Wed"]} cols={hours} values={visits}/>//   <HeatmapChart label="Correlation" rows={names} cols={names} values={matrix} diverging={true}//                 low={-1} high={1} decimals={2} cell_values={true}/>//// rows: the labels down the side; cols: the labels along the bottom (when there are// many, only some are written). values: a list per row, of a number per column; a// null (or missing) value leaves its cell empty, hatched. low, high: the ends of the// colour scale (by default the lowest and highest value). diverging: two colours,// red below `center` (0) and blue above, fading to nothing at it, for values either// side of a middle; otherwise one colour from light to strong. color: that colour, 1// to 8 of the chart palette (1, blue). cell_values: each value written in its cell.// prefix and suffix: around every value shown ("$", "%"); decimals: digits after the// point, always, instead of up to two as needed. cell: a row's height in pixels (28).// legend: the colour scale under it, with its ends and middle (true). 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 heatmap_chart_css = css`.ui-hmap {  --ui-hmap-1: light-dark(#2a78d6, #3987e5); --ui-hmap-2: light-dark(#eb6834, #d95926);  --ui-hmap-3: light-dark(#1baf7a, #199e70); --ui-hmap-4: light-dark(#eda100, #c98500);  --ui-hmap-5: light-dark(#e87ba4, #d55181); --ui-hmap-6: light-dark(#008300, #008300);  --ui-hmap-7: light-dark(#6250d6, #9085e9); --ui-hmap-8: light-dark(#e34948, #e66767);  --ui-hmap-0: color-mix(in srgb, var(--ui-border) 45%, var(--ui-bg));  display: grid; gap: 10px; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-hmap-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-hmap-s1 { --c: var(--ui-hmap-1); } .ui-hmap-s2 { --c: var(--ui-hmap-2); } .ui-hmap-s3 { --c: var(--ui-hmap-3); }.ui-hmap-s4 { --c: var(--ui-hmap-4); } .ui-hmap-s5 { --c: var(--ui-hmap-5); } .ui-hmap-s6 { --c: var(--ui-hmap-6); }.ui-hmap-s7 { --c: var(--ui-hmap-7); } .ui-hmap-s8 { --c: var(--ui-hmap-8); }.ui-hmap-grid { display: grid; gap: 2px; align-items: stretch; }.ui-hmap-rl { align-self: center; max-width: 12em; padding-right: 6px; overflow: hidden; color: var(--ui-muted);  text-align: right; text-overflow: ellipsis; white-space: nowrap; }.ui-hmap-cl { min-width: 0; padding-top: 4px; overflow: hidden; color: var(--ui-muted); text-align: center;  text-overflow: ellipsis; white-space: nowrap; }.ui-hmap-cell { position: relative; display: grid; place-items: center; min-width: 0; border-radius: 3px;  background: color-mix(in srgb, var(--c) var(--v), var(--ui-hmap-0)); color: var(--ui-fg);  font-size: 11px; font-variant-numeric: tabular-nums; }.ui-hmap-cell:hover { outline: 2px solid var(--ui-fg); outline-offset: -1px; z-index: 1; }.ui-hmap-strong { color: #fff; }.ui-hmap-empty { background: repeating-linear-gradient(135deg, var(--ui-hmap-0) 0 4px, transparent 4px 7px); }.ui-hmap-val { overflow: hidden; max-width: 100%; padding: 0 2px; white-space: nowrap; }.ui-hmap-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-hmap-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-hmap-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-hmap-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-hmap-tip { bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); }.ui-hmap-l .ui-hmap-tip { left: 0; transform: none; }.ui-hmap-r .ui-hmap-tip { left: auto; right: 0; transform: none; }.ui-hmap-cell:hover .ui-hmap-tip { display: block; }.ui-hmap-legend { display: grid; grid-template-columns: 1fr 1fr 1fr; row-gap: 3px; width: min(240px, 100%);  margin-left: auto; color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-hmap-bar { grid-column: 1 / -1; height: 10px; border-radius: 3px;  background: linear-gradient(to right, color-mix(in srgb, var(--c) 8%, var(--ui-hmap-0)), var(--c)); }.ui-hmap-div .ui-hmap-bar { background: linear-gradient(to right, var(--ui-hmap-8), var(--ui-hmap-0), var(--c)); }.ui-hmap-legend span:nth-child(3) { text-align: center; }.ui-hmap-legend span:nth-child(4) { text-align: right; }`// n (a whole number, 0 or more) with at least `width` digits.fn heatmap_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 heatmap_chart_group(n: int) -> str =    n < 1000 ? str(n) : heatmap_chart_group(floor(n / 1000)) + "," + heatmap_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 heatmap_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)}` : `.${heatmap_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${heatmap_chart_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn heatmap_chart_pct(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// A colour's strength, out of 100, for a value t from 0 (the scale's start) to 1 (its end).fn heatmap_chart_strength(t: num, from: num) -> num {    let c = t < 0 ? 0 : (t > 1 ? 1 : t)    from + c * (100 - from)}view HeatmapChart(rows: list, cols: list, values: list, low: num? = null, high: num? = null,                  diverging: bool = false, center: num = 0, color: int = 1, cell_values: bool = false,                  prefix: str = "", suffix: str = "", decimals: int? = null, cell: int = 28,                  legend: bool = true, label: str = "Heatmap", show_label: bool = false, class: str? = null) {    style global heatmap_chart_css    let nr = len(rows)    let nc = len(cols)    let at = (i, j) => (values[i] ?? [])[j]    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${heatmap_chart_num(abs(v), decimals)}${suffix}`    let known = flatten(for i in range(nr) => for j in range(nc) => at(i, j)) | filter((v) => v != null)    let lo = low ?? (len(known) == 0 ? 0 : min(known))    let hi = high ?? (len(known) == 0 ? 1 : max(known))    // Diverging: the farther side from the center sets the full colour.    let reach0 = max([abs(hi - center), abs(center - lo)])    let reach = reach0 == 0 ? 1 : reach0    let span = hi - lo == 0 ? 1 : hi - lo    let tone = color < 1 || color > 8 ? 1 : color    // A cell's colour: { cls, v } (its palette class and strength).    let paint = (v) => diverging        ? { cls: v < center ? "ui-hmap-s8" : `ui-hmap-s${tone}`, v: heatmap_chart_strength(abs(v - center) / reach, 0) }        : { cls: `ui-hmap-s${tone}`, v: heatmap_chart_strength((v - lo) / span, 8) }    let every_nth = ceil(nc / 12)    let ends = diverging ? [center - reach, center, center + reach] : [lo, (lo + hi) / 2, hi]    <figure class={ui_cls(["ui-hmap", diverging && "ui-hmap-div", class])} aria-label={label}>        {show_label && <figcaption.ui-hmap-title>{label}</figcaption>}        <div.ui-hmap-grid aria-hidden="true"             style={`grid-template-columns: max-content repeat(${nc > 0 ? nc : 1}, minmax(0, 1fr)); grid-template-rows: repeat(${nr > 0 ? nr : 1}, ${cell}px) auto`}>            {for i in range(nr) => [                <span.ui-hmap-rl style={`grid-row: ${i + 1}`} title={str(rows[i])}>{rows[i]}</span>,                for j in range(nc) => {                    let v = at(i, j)                    let p = v == null ? null : paint(v)                    <div class={ui_cls(["ui-hmap-cell", p != null ? p.cls : "ui-hmap-empty", p != null && p.v > 58 && "ui-hmap-strong",                                        nc > 3 && j < nc / 4 && "ui-hmap-l", nc > 3 && j >= nc * 3 / 4 && "ui-hmap-r"])}                         style={`grid-row: ${i + 1}${p != null ? `; --v: ${heatmap_chart_pct(p.v)}%` : ""}`}>                        {cell_values && v != null && <span.ui-hmap-val>{fmt(v)}</span>}                        <span.ui-hmap-tip><strong>{rows[i]} · {cols[j]}</strong><p>{label}<b>{fmt(v)}</b></p></span>                    </div>                }            ]}            <span style={`grid-row: ${nr + 1}`}></span>            {for j in range(nc) => <span.ui-hmap-cl style={`grid-row: ${nr + 1}`} title={str(cols[j])}>{j % every_nth == 0 ? cols[j] : ""}</span>}        </div>        {legend && <div class={`ui-hmap-legend ui-hmap-s${tone}`} aria-hidden="true">            <span.ui-hmap-bar></span>            {for v in ends => <span>{fmt(v)}</span>}        </div>}        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col"></th>{for c in cols => <th scope="col">{c}</th>}</tr></thead>            <tbody>                {for i in range(nr) => <tr>                    <th scope="row">{rows[i]}</th>                    {for j in range(nc) => <td>{fmt(at(i, j))}</td>}                </tr>}            </tbody>        </table>    </figure>}
Thread
65Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.