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.
Demo
| 00:00 | 02:00 | 04:00 | 06:00 | 08:00 | 10:00 | 12:00 | 14:00 | 16:00 | 18:00 | 20:00 | 22:00 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Mon | 12 | 16 | 20 | 60 | 82 | 104 | 126 | 118 | 110 | 70 | 52 | 34 |
| Tue | 15 | 19 | 23 | 63 | 85 | 107 | 129 | 121 | 113 | 73 | 55 | 37 |
| Wed | 18 | 22 | 26 | 66 | 88 | 110 | 132 | 124 | 116 | 76 | 58 | 40 |
| Thu | 21 | 25 | 29 | 69 | 91 | 113 | 135 | 127 | 119 | 79 | 61 | 43 |
| Fri | 24 | 28 | 32 | 72 | 94 | 116 | 138 | 130 | 122 | 82 | 64 | 46 |
| Sat | 20 | 22 | 24 | 42 | 52 | 62 | 72 | 68 | 65 | 47 | 38 | 30 |
| Sun | 23 | 25 | 27 | 45 | 55 | 65 | 75 | 71 | 68 | 50 | 41 | 33 |
| Price | Ads | Visits | Signups | Churn | |
|---|---|---|---|---|---|
| Price | 1.00 | -0.62 | -0.41 | -0.55 | 0.38 |
| Ads | -0.62 | 1.00 | 0.81 | 0.66 | -0.12 |
| Visits | -0.41 | 0.81 | 1.00 | 0.74 | -0.20 |
| Signups | -0.55 | 0.66 | 0.74 | 1.00 | -0.47 |
| Churn | 0.38 | -0.12 | -0.20 | -0.47 | 1.00 |
| Jul | Aug | Sep | Oct | |
|---|---|---|---|---|
| EU | 99.95% | 99.99% | 99.9% | 100% |
| US | 99.8% | — | 99.97% | 99.99% |
| Asia | 99.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.qwemain.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>}Related
SmallMultiplesThe same small line, area or bar chart for each group in a grid, on one shared scale, with each value on hover.6 views · 5 downloadsAreaChartFilled 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 downloads
Thread
Thread 0 posts
On HeatmapChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.