Charts/Base
Heatmap
<Heatmap /> · 14 examples
A grid of cells shaded by count or value: either a distribution over time, or a value for every pair of two categories.
Overview
A line chart of average latency is a polite lie. It says the service takes 120 ms, and does not mention that half the requests take 40 and a stubborn few take four seconds. A heatmap keeps the whole population. Time runs along x, the measured value runs up y, and each cell is shaded by how many samples landed in it. Instead of one line you get a landscape, and landscapes have mountains.
Shaded grids are older than computers. In the 1870s the French statistician Toussaint Loua shaded a table of social statistics for the districts of Paris, one of the early cases of a matrix drawn as colours rather than numbers. Monitoring tools later gave the idea a second life as the picture of latency over time, and now every on-call engineer has stared at one at three in the morning, trying to decide if that orange smear is new.
The chart does two jobs, and it helps to keep them apart. In sample mode you hand it raw events and it counts them into buckets. In cell mode you hand it a number per pair of categories (day and hour, region and service) and it paints each one. The colour scale is the same in both. The question changes from where did my samples land? to what is the value here?
Props
API
<Heatmap> shows how values are spread over two dimensions, usually how many samples fell into each time × value bucket. It's a port of Grafana's Heatmap panel; see Coming from Grafana.
use zopra_charts::*;<Heatmap>- Where the cells come from
- Colour
- Cells
- Axes and time
- Tooltip and legend
- Zooming and selection
- Enums and types
- Coming from Grafana
Not sure which option fits? See the guide.
<Heatmap>
| Prop | Type | Default |
|---|---|---|
data | Vec<T>, Signal<Vec<T>>, Frame<N>, Signal<Frame<N>>, or your own Dataset | required |
x, y, value, x_end, y_end | Fn(&T) -> f64 | see below |
calculate | Calculation | none |
x_field | field name, for a Frame | the first field |
bucket_layout | BucketLayout | Auto |
bucket_scale | ScaleKind | none: bucket rows are evenly spaced |
color | HeatmapColor | the Oranges scheme, 64 steps |
color_min / color_max | f64 | the smallest / largest shown value |
cell_gap | f32 px, 0–25 | 1. |
cell_radius | f32 px | 0. |
show_value | VisibilityMode | Auto |
cell_format | Fn(f64) -> SharedString | none |
cell_decimals | usize | up to 3, trailing zeros dropped |
hide_le / hide_ge | f64 | 1e-9 / none: empty buckets are hidden |
x_axis / y_axis | AxisConfig | numeric |
tooltip | HeatmapTooltip | HeatmapTooltip::single() |
legend | HeatmapLegend | a colour bar at the bottom |
exemplars | Vec<(f64, f64)> | none |
exemplar_color | Hsla / Rgba | translucent magenta |
selection | SelectionMode | X |
on_select | Fn(SelectedRange, &mut Window, &mut App) | none |
class, key |
Where the cells come from
| You have | Pass | Cells |
|---|---|---|
| raw samples, such as one row per request | x, y and calculate | the samples are counted into buckets |
| one row per cell, already counted | x, y and value (plus x_end / y_end if cells have different sizes) | one cell per row, starting at x / y |
| bucket fields, such as a Prometheus histogram | a Frame: x first, then one field per bucket, named by its bound | one row of cells per field |
Calculation follows Grafana's "Calculate from data":
Calculation { x_buckets: Buckets::Size(60_000.), y_buckets: Buckets::Count(20), y_scale: BucketScale::Linear }| Field | Values |
|---|---|
x_buckets / y_buckets | Buckets::Auto (about 20 columns and 10 rows), Buckets::Count(n) (about n, rounded to a tidy size), Buckets::Size(s) (exactly s wide) |
y_scale | BucketScale::Linear, Log2 or Log10. With a log scale, Buckets::Size(n) splits each power into n buckets |
With a time x axis, column sizes are rounded to whole seconds, minutes, hours and so on.
Bucket fields. By default each field becomes one evenly spaced row, labelled with the field name:
BucketLayout::Le: names are upper bounds, as Prometheuslebuckets are; the label sits on the row's top edge.Ge: names are lower bounds.Auto: labels sit in the middle of each row.
Set bucket_scale to place the rows by the numbers in their names instead, on that scale. +Inf is drawn as one more bucket.
Colour
HeatmapColor | Cells are coloured |
|---|---|
HeatmapColor::scheme(HeatmapScheme::…) | along one of Grafana's 38 colour schemes |
HeatmapColor::theme(Scheme::…) | along a scheme from your theme |
HeatmapColor::opacity(color) | in one colour, more opaque for higher values |
| Method | Effect | Default |
|---|---|---|
.steps(n) | how many distinct colours, 2–128 | 64 |
.reverse(bool) | flips a scheme | false |
.exponent(e) | opacity mode: 0.1–2.0; below 1 makes low values stand out more | linear |
The scheme colours are exactly Grafana's (d3-scale-chromatic). As in Grafana, the diverging and cyclical schemes are flipped. Single-hue schemes (Blues, Oranges, …) are flipped in dark mode, so high values are bright on a dark background. Multi-hue schemes (Viridis, Magma, Turbo, …) are flipped in light mode.
color_min / color_max fix the ends of the colour range. Values beyond them use the end colours.
Cells
cell_gapleaves space between cells; it's ignored on cells too small to fit it.show_value:Autowrites the value in cells wide enough for it,Alwayswrites it everywhere,Neverwrites none.cell_formatorcell_decimalsset the value text, used in cells, the tooltip and the legend.hide_lehides cells at or below a value; the default1e-9hides empty buckets. Passf64::NEG_INFINITYto show every cell.hide_gehides cells at or above a value.exemplarsdraws individual points over the cells, such as single slow requests.
Axes and time
Both axes take an AxisConfig. Three methods matter most here:
| Method | Effect |
|---|---|
AxisConfig::new().time() | values are milliseconds since the Unix epoch. Ticks fall on round times and read HH:mm:ss, HH:mm, MM/DD HH:mm, MM/DD, YYYY-MM or YYYY depending on the span. Shown in UTC |
.utc_offset(minutes) | shows times in another time zone, such as 120 for UTC+2 |
.reverse(true) | turns the axis upside down |
The x axis spans exactly the first to the last bucket.
Tooltip and legend
HeatmapTooltip::single().y_histogram(true).color_scale(true)| Method | Shows |
|---|---|
HeatmapTooltip::single() | the bucket's x range, y range and value |
HeatmapTooltip::hidden() | no tooltip |
.y_histogram(true) | the whole column as a small bar chart, the hovered bucket highlighted |
.color_scale(true) | the colour scale with the value marked on it |
.max_width(px) / .max_height(px) | size limits |
HeatmapLegend | |
|---|---|
HeatmapLegend::shown() / hidden() | the colour bar with the lowest and highest value, marking the hovered value |
.right() | beside the chart instead of below |
.width(px) | its width when on the right |
Zooming and selection
Drag across the cells to zoom in, and double-click to zoom back out.
SelectionMode | Dragging zooms |
|---|---|
X | the x axis |
Y | the y axis |
XY | both, using the box you draw |
on_select receives the range too: a SelectedRange { x: (from, to), y: Option<(from, to)> }. Use it to move your app's time range to the selection.
Enums and types
| Type | Values |
|---|---|
HeatmapScheme | Diverging: BrBG, PiYG, PRGn, PuOr, RdBu, RdGy, RdYlBu, RdYlGn, Spectral. Single hue: Blues, Greens, Greys, Oranges (default), Purples, Reds. Multi-hue: Turbo, Cividis, Viridis, Magma, Inferno, Plasma, Warm, Cool, Cubehelix, BuGn, BuPu, GnBu, OrRd, PuBuGn, PuBu, PuRd, RdPu, YlGnBu, YlGn, YlOrBr, YlOrRd. Cyclical: Rainbow, Sinebow. HeatmapScheme::ALL lists them |
Buckets | Auto, Count(usize), Size(f64) |
BucketScale | Linear, Log2, Log10 |
BucketLayout | Auto, Le, Ge |
VisibilityMode | Auto, Always, Never |
SelectionMode | X, Y, XY |
Coming from Grafana
| Grafana option | Here |
|---|---|
| Calculate from data, X / Y buckets, Y bucket scale | calculate |
| Heatmap rows: value field, layout, Y bucket scale | a Frame, bucket_layout, bucket_scale |
| Colour mode, scheme, fill, scale, exponent, steps, reverse, start / end colour from value | color, color_min, color_max |
| Cell gap, Show values, Hide cells with values ≤ / ≥, Cell value unit and decimals | cell_gap, show_value, hide_le / hide_ge, cell_format / cell_decimals |
| Y axis placement, label, width, min, max, reverse, unit, decimals | y_axis |
| Tooltip mode, Show histogram (Y axis), Show colour scale | tooltip |
| Legend | legend |
| Exemplars colour | exemplars, exemplar_color |
| Selection mode | selection, on_select |
Not available here: units from Grafana's unit registry (use cell_format and AxisConfig::format), data links, and annotations.
Run them
14 examples
Press Run to load an example into the live render at the top of the page. Open one to read its code.
How to read it
- 01Start with the brightest band, which is where most of the samples are. A line through its middle is roughly the median you would have plotted anyway.
- 02Look at the width of that band. A thin band means consistent behaviour. A fat one means the average is hiding variation.
- 03Then look for second bands and sudden shifts. A new band that appears at a certain time is a new code path, a new customer, or a new bug.
- 04Hover a cell for its value and bucket range. Drag to zoom in time (or in a box, with
SelectionMode::XY) and useexemplarsto jump to individual slow requests.
When to use it, and when not to
Reach for it when
- You want how a distribution changes over time, not how a single statistic does: latency, request size, job duration.
- You have a value for every pair of two categories, such as day by hour, region by service, or feature by cohort.
- Hundreds of thousands of samples would overwhelm a scatter plot. Counting them is cheaper and clearer.
- You suspect two populations (fast and slow paths, cache hits and misses) and want to see them as two bands.
Skip it when
- You want one number's trajectory. Trend or Time Series will be faster to read.
- You need exact values. Colour is a poor ruler. Use a table or turn on
show_valuefor small grids. - The data is mostly one value. The scale will paint everything the same shade and call it a day.
- States rather than counts, such as up or down. State Timeline merges equal neighbours into readable blocks.
The math behind it
Counting samples into buckets
Given samples , the chart picks a bucket width for each axis. With Buckets::Auto it aims for about 20 buckets across x and 10 up y: the raw step is , which is then rounded down to the nearest nice value (1, 2, 2.5, 5 times a power of ten, and for time axes a ladder of seconds, minutes, hours and days). Buckets::Count(n) uses the same rule with your , and Buckets::Size uses your width as given.
A sample falls into the bucket whose lower edge is its value rounded down to a multiple of the width . The cell count is the number of samples in it.
Log buckets
Latencies bunch near zero and trail off for miles. With y_scale: BucketScale::Log2 or Log10 the y axis is bucketed in the exponent instead. With base and a split per power, the bucket index is the floor of , and the bucket edges are . Non-positive samples cannot be placed on a log axis and are dropped.
From count to colour
The colour range runs from color_min to color_max, which default to the smallest and largest cell in the data. A cell value becomes a position in , clamped, and the position picks one of palette steps (64 by default). In opacity mode the same becomes an alpha, linear or raised to an exponent, over your chosen fill colour.
Cells at or below hide_le, or at or above hide_ge, are not drawn at all, so near-empty buckets and wash-out outliers can be removed without changing the scale. If the range has zero width, everything gets the top colour.
Histogram buckets as input
Prometheus and OpenTelemetry export histograms as cumulative-looking bucket fields with an upper bound (le). Set bucket_layout={BucketLayout::Le} and the chart reads each field as an ordinal row. The tooltip's y_histogram then shows the whole column for the x under your cursor, which is where bimodal distributions give themselves away.
Real life usage
The mean was fine. The mean is always fine.
Ravi ran platform reliability for a food delivery app in Hyderabad, where the busiest hour of the day is dinner and the second busiest is also dinner, for a different part of the city. His dashboard had one line for p50, one for p99, and a green status light that had not changed in eleven weeks.
The support queue disagreed with the light. Customers in a few neighbourhoods kept writing that the app felt slow on Friday nights, and the p99 line, when anyone looked at it, did a small shrug at about 900 milliseconds. Fridays, according to the dashboard, were fine.
Ravi's suspicion was that Friday was not slower so much as different: a second kind of request that the percentile lines blended into noise. To check that, he needed to see the shape of the whole distribution, minute by minute, for four hours around dinner.
He replaced the two lines with a <Heatmap> from zopra-charts: raw request durations as samples, one-minute x buckets, base-2 log buckets on y so the long tail got as much room as the bulk, and a Viridis scale with 16 steps so the bands would be easy to count.
On Friday evening the heatmap grew a second band at around 1.6 seconds, thin at 7 pm and thick by 8:30, floating above the main band like a small orange cloud. The p99 line sat in the gap between the two and averaged them out.
Hovering the cells with exemplars turned on led straight to traces, and the traces led to a payment retry that was waiting for a fixed 1.5-second timeout before trying a second provider. The timeout was introduced in a change titled minor cleanup.
Shortening it to 300 milliseconds removed the second band entirely, and Friday support tickets about slowness dropped by roughly two thirds. The green status light is still green. Ravi has stopped trusting it and instead trusts the picture of the orange cloud that is no longer there.
Pitfalls
- Colour range drifts. The scale defaults to the data's own min and max, so two charts of different days are not comparable. Fix
color_minandcolor_maxwhen they should be. - Too many steps. 64 smooth steps look nice and are impossible to compare. Eight to sixteen make bands easy to tell apart.
- Rainbow palettes. They create edges that are not in the data. Use Viridis, Magma or Cividis for fine differences, and a single-hue scheme for more-is-hotter.
- Bucket size mismatch. Buckets much smaller than your sample count per bucket give noisy cells. Start with
Buckets::Autoand only then set aSize. - Log axes drop non-positive values. Zero and negative samples are silently excluded from log buckets.
From around the web