Zopra/Charts
Guide
Getting startedAll chartsWhich chart to use?Domain specific
Base charts7
XY ChartTrendCandlestickHeatmapState TimelineFlame GraphBar Gauge
Derived charts73
Time SeriesHistogramStatus HistoryStatGaugeHoneyCombTracesNode GraphHeikin-AshiRenkoKagiPoint & FigureParetoIshikawaControl ChartBland-AltmanLollipopDumbbellSlopeBumpBack-to-BackWaterfallBulletFunnelMekkoForestBarcodeBeeswarmRidgelineHorizonSpectrogramAttention MapWaffleVennUpSetWarped WaffleWord CloudGanttSankeySankey MatrixTreemap ForceChord DiagramCircosArc DiagramAlluvialSankey FunnelDendrogramIcicleSunburstSpiderNightingale RoseParallel CoordinatesOscilloscopeEfficient FrontierPhase PortraitPoincaré MapBifurcationH-R DiagramRamachandranHarkerRoot LocusRadiation PatternQuiverHodographSmith ChartEye DiagramSequence LogoTernaryTernary ContourPiperStüveTephigramChoropleth

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>

PropTypeDefault
dataVec<T>, Signal<Vec<T>>, Frame<N>, Signal<Frame<N>>, or your own Datasetrequired
x, y, value, x_end, y_endFn(&T) -> f64see below
calculateCalculationnone
x_fieldfield name, for a Framethe first field
bucket_layoutBucketLayoutAuto
bucket_scaleScaleKindnone: bucket rows are evenly spaced
colorHeatmapColorthe Oranges scheme, 64 steps
color_min / color_maxf64the smallest / largest shown value
cell_gapf32 px, 0–251.
cell_radiusf32 px0.
show_valueVisibilityModeAuto
cell_formatFn(f64) -> SharedStringnone
cell_decimalsusizeup to 3, trailing zeros dropped
hide_le / hide_gef641e-9 / none: empty buckets are hidden
x_axis / y_axisAxisConfignumeric
tooltipHeatmapTooltipHeatmapTooltip::single()
legendHeatmapLegenda colour bar at the bottom
exemplarsVec<(f64, f64)>none
exemplar_colorHsla / Rgbatranslucent magenta
selectionSelectionModeX
on_selectFn(SelectedRange, &mut Window, &mut App)none
class, key

Where the cells come from

You havePassCells
raw samples, such as one row per requestx, y and calculatethe samples are counted into buckets
one row per cell, already countedx, 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 histograma Frame: x first, then one field per bucket, named by its boundone 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 }
FieldValues
x_buckets / y_bucketsBuckets::Auto (about 20 columns and 10 rows), Buckets::Count(n) (about n, rounded to a tidy size), Buckets::Size(s) (exactly s wide)
y_scaleBucketScale::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 Prometheus le buckets 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

HeatmapColorCells 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
MethodEffectDefault
.steps(n)how many distinct colours, 2–12864
.reverse(bool)flips a schemefalse
.exponent(e)opacity mode: 0.1–2.0; below 1 makes low values stand out morelinear

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_gap leaves space between cells; it's ignored on cells too small to fit it.
  • show_value: Auto writes the value in cells wide enough for it, Always writes it everywhere, Never writes none.
  • cell_format or cell_decimals set the value text, used in cells, the tooltip and the legend.
  • hide_le hides cells at or below a value; the default 1e-9 hides empty buckets. Pass f64::NEG_INFINITY to show every cell. hide_ge hides cells at or above a value.
  • exemplars draws individual points over the cells, such as single slow requests.

Axes and time

Both axes take an AxisConfig. Three methods matter most here:

MethodEffect
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)
MethodShows
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.

SelectionModeDragging zooms
Xthe x axis
Ythe y axis
XYboth, 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

TypeValues
HeatmapSchemeDiverging: 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
BucketsAuto, Count(usize), Size(f64)
BucketScaleLinear, Log2, Log10
BucketLayoutAuto, Le, Ge
VisibilityModeAuto, Always, Never
SelectionModeX, Y, XY

Coming from Grafana

Grafana optionHere
Calculate from data, X / Y buckets, Y bucket scalecalculate
Heatmap rows: value field, layout, Y bucket scalea Frame, bucket_layout, bucket_scale
Colour mode, scheme, fill, scale, exponent, steps, reverse, start / end colour from valuecolor, color_min, color_max
Cell gap, Show values, Hide cells with values ≤ / ≥, Cell value unit and decimalscell_gap, show_value, hide_le / hide_ge, cell_format / cell_decimals
Y axis placement, label, width, min, max, reverse, unit, decimalsy_axis
Tooltip mode, Show histogram (Y axis), Show colour scaletooltip
Legendlegend
Exemplars colourexemplars, exemplar_color
Selection modeselection, 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

  1. 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.
  2. 02Look at the width of that band. A thin band means consistent behaviour. A fat one means the average is hiding variation.
  3. 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.
  4. 04Hover a cell for its value and bucket range. Drag to zoom in time (or in a box, with SelectionMode::XY) and use exemplars to 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_value for 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 (xr,yr)(x_r, y_r)(xr​,yr​), 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 (max⁡−min⁡)/(n−1)(\max - \min)/(n-1)(max−min)/(n−1), 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 nnn, 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 Δ\DeltaΔ. The cell count is the number of samples in it.

b(x)=Δx⌊xΔx⌋,Nij=#{ r:b(xr)=xi, b(yr)=yj }b(x) = \Delta_x \left\lfloor \frac{x}{\Delta_x} \right\rfloor, \qquad N_{ij} = \#\{\, r : b(x_r) = x_i,\ b(y_r) = y_j \,\}b(x)=Δx​⌊Δx​x​⌋,Nij​=#{r:b(xr​)=xi​, b(yr​)=yj​}

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 BBB and a split qqq per power, the bucket index is the floor of qlog⁡Byq \log_B yqlogB​y, and the bucket edges are Bk/qB^{k/q}Bk/q. Non-positive samples cannot be placed on a log axis and are dropped.

j(y)=⌊qlog⁡By⌋,edgek=Bk/qj(y) = \left\lfloor q \log_B y \right\rfloor, \qquad \text{edge}_k = B^{k/q}j(y)=⌊qlogB​y⌋,edgek​=Bk/q

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 NNN becomes a position ttt in [0,1][0, 1][0,1], clamped, and the position picks one of SSS palette steps (64 by default). In opacity mode the same ttt 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.

t=clamp ⁣(N−cmin⁡cmax⁡−cmin⁡, 0, 1),index=round(t (S−1)),α=tγt = \mathrm{clamp}\!\left(\frac{N - c_{\min}}{c_{\max} - c_{\min}},\,0,\,1\right), \qquad \text{index} = \mathrm{round}\bigl(t\,(S-1)\bigr), \qquad \alpha = t^{\gamma}t=clamp(cmax​−cmin​N−cmin​​,0,1),index=round(t(S−1)),α=tγ

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_min and color_max when 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::Auto and only then set a Size.
  • Log axes drop non-positive values. Zero and negative samples are silently excluded from log buckets.

From around the web

Watch and read

YouTubeSeaborn Heatmap - How to Visualise Correlations and Data With Heatmaps in PythonAndy McDonaldPractical heatmap tutorial for correlation matrices, using Python's seaborn.

Keep going

Related charts

State TimelineWhen each moment has a state, not a count, and equal neighbours should merge.HistogramThe distribution at a single moment, with each bucket drawn as a bar.RidgelineSeveral distributions stacked with overlap, when you have a handful of groups instead of a time axis.SpectrogramA heatmap of signal strength over frequency and time.
PreviousCandlestickNextState Timeline

On this page

  • Live
  • Overview
  • API
  • Examples
  • How to read it
  • When to use it
  • The math
  • Story
  • Pitfalls
  • Watch and read
  • Related