Skip to content
KONIGI

Dashboards / Visual representation / Heatmap

9 of 22

Heatmap

Thousands of events per second can't be drawn as points; the viewer needs density.

Updated September 10, 2026

Problem

Ten thousand requests happened this minute. Drawn as points they are a smear; reduced to an average they are a single line that describes none of them. The viewer needs to see the shape of the population and how that shape moves.

Solution

Bucket on both axes and encode the count as colour. Time across the bottom, the measured value up the side, and each cell coloured by how many events landed in it. What was an unreadable scatter becomes a band, and the interesting thing is usually that there are two bands rather than one.

That last part is why the pattern exists. A percentile line reports a position in the distribution and cannot show you that the distribution has two humps—a fast path and a slow path, cache hit and cache miss. The heatmap shows both, and shows one of them growing.

The configuration that decides whether it works is the y-axis bucketing. Grafana exposes it plainly: buckets are either pre-computed by the data source or Calculate from data in the panel, and the y scale is Linear, Logarithmic with base 2 or 10, or Symlog. Latency distributions are heavily skewed, so a linear y-axis crushes almost everything into a band at the bottom and leaves most of the panel empty. Log buckets spread the same numbers out and the structure appears. Honeycomb makes the same argument the other way round, from outliers: a log scale is how you stop a handful of enormous values from flattening everything else into one row.

Colour is the second decision. Grafana offers Scheme, where the bucket count drives the colour, and Opacity, where it drives transparency, plus Start color scale from value and End color scale at value for clamping. Clamping is what stops one catastrophic cell from consuming the whole ramp and turning every other cell the same shade of nothing.

Use when

The volume is high enough that individual events can’t be drawn, the distribution matters more than its summary, and the question is about shape over time.

Don’t use when

The volume is low. A heatmap over forty events is a sparse grid of near-identical cells, and a scatter plot would show the same thing with less machinery. Also avoid it when the audience reads the page from ten feet away—colour density needs a close look.

Trade-offs

Heatmaps take longer to learn than lines, and a viewer who hasn’t been taught to read one will see decoration. Exact values are gone: a cell says “many” and not “how many”, so the panel usually needs a hover or a companion table. Two heatmaps side by side are only comparable if they share a colour scale, and by default they don’t. And bucket boundaries are as consequential here as in a histogram while being even less visible, because the y-axis labels describe the edges of a grid nobody thinks of as buckets.

Checklist

  • Are the y buckets linear or log, and does the choice suit a skewed distribution?
  • If log, base 2 or base 10, and does the labelling make the base obvious?
  • Where do bucket boundaries fall relative to the values people care about?
  • Is the colour scale shared with any neighbouring heatmap, and if not, is that stated?
  • Is the scale clamped, and does one extreme cell dominate the ramp if it isn’t?
  • Does the colour ramp survive the viewer’s colour vision, and does it survive greyscale printing?
  • Can the viewer get from a dense region to the underlying events?
  • What does an empty cell look like, and can it be confused with a low-count cell?
  • Is a companion percentile or count available for people who need a number?
  • Does the panel say what one cell represents, in both axes?

Compare

Grafana treats it as a panel with a lot of knobs—bucket source, three y-scale families, two colour modes, clamps at both ends—which makes it the most configurable version and the easiest to leave on defaults that hide the data. Honeycomb builds its whole analysis loop on top of it: draw a box around the interesting region and BubbleUp compares the events inside against the baseline outside, so the heatmap is a selection surface rather than a picture. Datadog offers it as a distribution view alongside percentile lines, which lets a team keep the line they already trust and check it against the shape when it disagrees. Netdata mostly declines the pattern, because per-second resolution on ordinary charts already shows detail that other tools only recover by bucketing.

Histogram is one vertical slice of a heatmap, and the better choice when time doesn’t matter. Percentile summary is the same distribution reduced to numbers, and the pattern most often replaced by this one. Time series is what a heatmap becomes when the population collapses to a single value per interval. Sequential and diverging scales governs the colour ramp. Small multiples is what the Grafana heatmap examples above are actually demonstrating: hold the data still, change one setting, and read across.

Heatmap anatomy The same latency data bucketed two ways. On a linear y-axis everything crushes into a band at the bottom and most of the panel is empty. On logarithmic buckets the distribution separates into two bands—a fast path and a slow path—and the slow one is growing. Same events, two bucketings 0ms 240ms 480ms 720ms 960ms Linear buckets 10ms 38ms 143ms 542ms 2s Log buckets 1 2 1 THE Y-AXIS DECIDES EVERYTHING Latency distributions are heavily skewed, so linear buckets crush almost everything into one row and leave the panel empty above it. 2 TWO BANDS, NOT ONE A fast path and a slow path. Cache hit and cache miss. A percentile reports a position in the distribution and cannot show you that the distribution has two humps. Colour needs clamping too. Without it one catastrophic cell consumes the whole ramp and turns every other cell the same shade of nothing. And the second band here is getting wider from left to right, which is the finding.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A matrix where colour is the value. It only works if the ramp is monotonic in lightness and the legend carries real numbers, otherwise it is a mood board with axes.

Tokens
--card--muted-foreground--border--scale-seq-1--scale-seq-2--scale-seq-3--scale-seq-4--scale-seq-5
14:00 · 27ms–38ms · 5 events14:00 · 38ms–53ms · 65 events14:00 · 53ms–74ms · 5 events14:00 · 74ms–103ms · 1 events14:00 · 542ms–756ms · 1 events14:00 · 756ms–1s · 1 events14:00 · 1s–1s · 1 events14:01 · 27ms–38ms · 5 events14:01 · 38ms–53ms · 65 events14:01 · 53ms–74ms · 5 events14:01 · 74ms–103ms · 1 events14:01 · 542ms–756ms · 1 events14:01 · 756ms–1s · 1 events14:01 · 1s–1s · 1 events14:02 · 27ms–38ms · 5 events14:02 · 38ms–53ms · 65 events14:02 · 53ms–74ms · 5 events14:02 · 74ms–103ms · 1 events14:02 · 542ms–756ms · 1 events14:02 · 756ms–1s · 1 events14:02 · 1s–1s · 1 events14:03 · 27ms–38ms · 5 events14:03 · 38ms–53ms · 65 events14:03 · 53ms–74ms · 5 events14:03 · 74ms–103ms · 1 events14:03 · 542ms–756ms · 1 events14:03 · 756ms–1s · 5 events14:03 · 1s–1s · 1 events14:04 · 27ms–38ms · 5 events14:04 · 38ms–53ms · 65 events14:04 · 53ms–74ms · 5 events14:04 · 74ms–103ms · 1 events14:04 · 542ms–756ms · 1 events14:04 · 756ms–1s · 5 events14:04 · 1s–1s · 1 events14:05 · 27ms–38ms · 5 events14:05 · 38ms–53ms · 65 events14:05 · 53ms–74ms · 5 events14:05 · 74ms–103ms · 1 events14:05 · 542ms–756ms · 1 events14:05 · 756ms–1s · 5 events14:05 · 1s–1s · 1 events14:06 · 27ms–38ms · 5 events14:06 · 38ms–53ms · 65 events14:06 · 53ms–74ms · 5 events14:06 · 74ms–103ms · 1 events14:06 · 542ms–756ms · 1 events14:06 · 756ms–1s · 5 events14:06 · 1s–1s · 1 events14:07 · 27ms–38ms · 5 events14:07 · 38ms–53ms · 65 events14:07 · 53ms–74ms · 5 events14:07 · 74ms–103ms · 1 events14:07 · 542ms–756ms · 1 events14:07 · 756ms–1s · 5 events14:07 · 1s–1s · 1 events14:08 · 27ms–38ms · 5 events14:08 · 38ms–53ms · 65 events14:08 · 53ms–74ms · 5 events14:08 · 74ms–103ms · 1 events14:08 · 542ms–756ms · 1 events14:08 · 756ms–1s · 5 events14:08 · 1s–1s · 1 events14:08 · 1s–2s · 1 events14:09 · 27ms–38ms · 5 events14:09 · 38ms–53ms · 65 events14:09 · 53ms–74ms · 5 events14:09 · 74ms–103ms · 1 events14:09 · 542ms–756ms · 1 events14:09 · 756ms–1s · 18 events14:09 · 1s–1s · 1 events14:09 · 1s–2s · 1 events14:10 · 27ms–38ms · 5 events14:10 · 38ms–53ms · 65 events14:10 · 53ms–74ms · 5 events14:10 · 74ms–103ms · 1 events14:10 · 389ms–542ms · 1 events14:10 · 542ms–756ms · 1 events14:10 · 756ms–1s · 18 events14:10 · 1s–1s · 5 events14:10 · 1s–2s · 1 events14:11 · 27ms–38ms · 5 events14:11 · 38ms–53ms · 65 events14:11 · 53ms–74ms · 5 events14:11 · 74ms–103ms · 1 events14:11 · 389ms–542ms · 1 events14:11 · 542ms–756ms · 5 events14:11 · 756ms–1s · 18 events14:11 · 1s–1s · 5 events14:11 · 1s–2s · 1 events14:12 · 27ms–38ms · 5 events14:12 · 38ms–53ms · 65 events14:12 · 53ms–74ms · 5 events14:12 · 74ms–103ms · 1 events14:12 · 389ms–542ms · 1 events14:12 · 542ms–756ms · 5 events14:12 · 756ms–1s · 18 events14:12 · 1s–1s · 5 events14:12 · 1s–2s · 1 events14:13 · 27ms–38ms · 5 events14:13 · 38ms–53ms · 65 events14:13 · 53ms–74ms · 5 events14:13 · 74ms–103ms · 1 events14:13 · 389ms–542ms · 1 events14:13 · 542ms–756ms · 5 events14:13 · 756ms–1s · 18 events14:13 · 1s–1s · 5 events14:13 · 1s–2s · 1 events14:14 · 27ms–38ms · 5 events14:14 · 38ms–53ms · 65 events14:14 · 53ms–74ms · 5 events14:14 · 74ms–103ms · 1 events14:14 · 389ms–542ms · 1 events14:14 · 542ms–756ms · 5 events14:14 · 756ms–1s · 18 events14:14 · 1s–1s · 5 events14:14 · 1s–2s · 1 events14:15 · 27ms–38ms · 5 events14:15 · 38ms–53ms · 65 events14:15 · 53ms–74ms · 5 events14:15 · 74ms–103ms · 1 events14:15 · 389ms–542ms · 1 events14:15 · 542ms–756ms · 5 events14:15 · 756ms–1s · 18 events14:15 · 1s–1s · 5 events14:15 · 1s–2s · 1 events14:16 · 27ms–38ms · 5 events14:16 · 38ms–53ms · 65 events14:16 · 53ms–74ms · 5 events14:16 · 74ms–103ms · 1 events14:16 · 389ms–542ms · 1 events14:16 · 542ms–756ms · 5 events14:16 · 756ms–1s · 65 events14:16 · 1s–1s · 5 events14:16 · 1s–2s · 1 events14:17 · 27ms–38ms · 5 events14:17 · 38ms–53ms · 65 events14:17 · 53ms–74ms · 5 events14:17 · 74ms–103ms · 1 events14:17 · 389ms–542ms · 1 events14:17 · 542ms–756ms · 5 events14:17 · 756ms–1s · 65 events14:17 · 1s–1s · 5 events14:17 · 1s–2s · 1 events14:18 · 27ms–38ms · 5 events14:18 · 38ms–53ms · 65 events14:18 · 53ms–74ms · 5 events14:18 · 74ms–103ms · 1 events14:18 · 389ms–542ms · 1 events14:18 · 542ms–756ms · 5 events14:18 · 756ms–1s · 65 events14:18 · 1s–1s · 5 events14:18 · 1s–2s · 1 events14:19 · 27ms–38ms · 5 events14:19 · 38ms–53ms · 65 events14:19 · 53ms–74ms · 5 events14:19 · 74ms–103ms · 1 events14:19 · 389ms–542ms · 1 events14:19 · 542ms–756ms · 5 events14:19 · 756ms–1s · 65 events14:19 · 1s–1s · 5 events14:19 · 1s–2s · 1 events14:20 · 27ms–38ms · 5 events14:20 · 38ms–53ms · 65 events14:20 · 53ms–74ms · 5 events14:20 · 74ms–103ms · 1 events14:20 · 389ms–542ms · 1 events14:20 · 542ms–756ms · 5 events14:20 · 756ms–1s · 65 events14:20 · 1s–1s · 5 events14:20 · 1s–2s · 1 events14:21 · 27ms–38ms · 5 events14:21 · 38ms–53ms · 65 events14:21 · 53ms–74ms · 5 events14:21 · 74ms–103ms · 1 events14:21 · 389ms–542ms · 1 events14:21 · 542ms–756ms · 5 events14:21 · 756ms–1s · 65 events14:21 · 1s–1s · 18 events14:21 · 1s–2s · 1 events14:22 · 27ms–38ms · 5 events14:22 · 38ms–53ms · 65 events14:22 · 53ms–74ms · 5 events14:22 · 74ms–103ms · 1 events14:22 · 389ms–542ms · 1 events14:22 · 542ms–756ms · 18 events14:22 · 756ms–1s · 300 events14:22 · 1s–1s · 18 events14:22 · 1s–2s · 1 events14:23 · 27ms–38ms · 5 events14:23 · 38ms–53ms · 65 events14:23 · 53ms–74ms · 5 events14:23 · 74ms–103ms · 1 events14:23 · 389ms–542ms · 1 events14:23 · 542ms–756ms · 18 events14:23 · 756ms–1s · 300 events14:23 · 1s–1s · 18 events14:23 · 1s–2s · 1 events14:24 · 27ms–38ms · 5 events14:24 · 38ms–53ms · 65 events14:24 · 53ms–74ms · 5 events14:24 · 74ms–103ms · 1 events14:24 · 389ms–542ms · 1 events14:24 · 542ms–756ms · 18 events14:24 · 756ms–1s · 300 events14:24 · 1s–1s · 18 events14:24 · 1s–2s · 1 events14:25 · 27ms–38ms · 5 events14:25 · 38ms–53ms · 65 events14:25 · 53ms–74ms · 5 events14:25 · 74ms–103ms · 1 events14:25 · 389ms–542ms · 1 events14:25 · 542ms–756ms · 18 events14:25 · 756ms–1s · 300 events14:25 · 1s–1s · 18 events14:25 · 1s–2s · 1 events10ms38ms143ms542ms2s8s14:0014:1314:25
01393096+one cell is 1 min × one bucket (×1.39 per row) · ramp clamped at p99 = 300

Heatmap.tsxLog y buckets spread between the data's extent, a ramp clamped at a percentile, and a legend with the count each step starts at.

/**
 * Bucket on both axes and encode the count as colour. Time across the bottom,
 * the measured value up the side, and the y buckets logarithmic, because a
 * latency distribution is skewed and a linear axis crushes it into one row.
 *
 * Drawn by hand: recharts has no heatmap, and the colour step per cell is the
 * whole point.
 */
const STEP = ["fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"];
const SWATCH = ["bg-scale-seq-1", "bg-scale-seq-2", "bg-scale-seq-3", "bg-scale-seq-4", "bg-scale-seq-5"];

type Props = {
  /** counts[column][row], row 0 the lowest bucket. Pre-bucketed upstream. */
  counts: number[][];
  /** The y extent in ms. Rows are spread logarithmically between the two. */
  y: { min: number; max: number };
  x: { from: Date; stepMs: number };
  /** The ramp ends at this percentile of the non-empty cells, so one
   *  catastrophic cell cannot take the whole ramp for itself. */
  clampAt?: number;
  cell?: { w: number; h: number };
};

const ms = (v: number) => (v < 1000 ? `${Math.round(v)}ms` : `${Number((v / 1000).toPrecision(1))}s`);
const hhmm = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" });

export function Heatmap({ counts, y, x, clampAt = 0.99, cell = { w: 10, h: 9 } }: Props) {
  const cols = counts.length;
  const rows = counts[0].length;
  const ratio = Math.pow(y.max / y.min, 1 / rows);
  const edge = (row: number) => y.min * Math.pow(ratio, row);

  // Clamp first, then five equal steps in log(count). Cells above the clamp
  // share the top colour rather than pushing everything else into the bottom.
  const nonEmpty = counts.flat().filter((c) => c > 0).sort((a, b) => a - b);
  const clamp = nonEmpty[Math.floor(clampAt * (nonEmpty.length - 1))] ?? 1;
  const top = Math.log1p(clamp);
  const step = (c: number) => Math.min(4, Math.floor((Math.log1p(c) / top) * 5));
  const lower = (k: number) => Math.ceil(Math.expm1((k / 5) * top));

  const GUTTER = 44, FOOT = 18;
  const W = GUTTER + cols * cell.w, H = rows * cell.h + FOOT;
  const time = (col: number) => hhmm(new Date(x.from.getTime() + col * x.stepMs));

  return (
    <figure className="m-0">
      <div className="overflow-x-auto">
        <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block text-[10px]" role="img" aria-label="Latency by minute">
          {counts.map((column, c) =>
            column.map((n, r) =>
              n > 0 && (
                <rect
                  key={`${c}-${r}`}
                  x={GUTTER + c * cell.w} y={(rows - 1 - r) * cell.h}
                  width={cell.w} height={cell.h}
                  className={STEP[step(n)]}
                >
                  <title>{`${time(c)} · ${ms(edge(r))}–${ms(edge(r + 1))} · ${n} events`}</title>
                </rect>
              ),
            ),
          )}
          <line x1={GUTTER} y1={rows * cell.h} x2={W} y2={rows * cell.h} className="stroke-border" />
          {/* A tick every four rows, labelled with the bucket's lower edge, so
              the base of the log is on the page rather than in the config. */}
          {Array.from({ length: Math.floor(rows / 4) + 1 }, (_, i) => i * 4).map((r) => (
            <text key={r} x={GUTTER - 6} y={(rows - r) * cell.h + 3} textAnchor="end" className="fill-muted-foreground tabular-nums">
              {ms(edge(r))}
            </text>
          ))}
          {[0, Math.floor(cols / 2), cols - 1].map((c) => (
            <text key={c} x={GUTTER + c * cell.w + cell.w / 2} y={H - 4} textAnchor="middle" className="fill-muted-foreground tabular-nums">
              {time(c)}
            </text>
          ))}
        </svg>
      </div>

      {/* The legend carries real numbers. Without them the ramp is a mood. */}
      <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
        <span className="flex items-center gap-1">
          <span className="size-2.5 rounded-[1px] border border-border" aria-hidden="true" /> 0
        </span>
        {SWATCH.map((cls, k) => (
          <span key={cls} className="flex items-center gap-1">
            <span className={`size-2.5 rounded-[1px] ${cls}`} aria-hidden="true" />
            {k === 4 ? `${lower(k)}+` : lower(k) || 1}
          </span>
        ))}
        <span className="ml-auto tabular-nums">
          one cell is {Math.round(x.stepMs / 60_000)} min × one bucket (×{ratio.toFixed(2)} per row) · ramp clamped at p{Math.round(clampAt * 100)} = {clamp}
        </span>
      </figcaption>
    </figure>
  );
}

demo.tsxHow it is called: twenty-six minutes of latency counts in twenty buckets, two bands, the slow one widening.

import { Heatmap } from "./Heatmap";

/**
 * Twenty-six minutes of request latency in twenty log buckets, from the
 * fastest request in the window (10 ms) to the slowest (7.76 s). Two bands:
 * a fast path around 40 ms and a slow one that starts near 300 ms and gets
 * wider from left to right. Each column is one minute, lowest bucket first,
 * with the count of events in each cell.
 */
const COUNT: Record<string, number> = { "0": 0, "1": 1, "2": 5, "3": 18, "4": 65, "5": 300 };
const MINUTES = [
  "00024210000011100000", "00024210000011100000", "00024210000011100000", "00024210000012100000",
  "00024210000012100000", "00024210000012100000", "00024210000012100000", "00024210000012100000",
  "00024210000012110000", "00024210000013110000", "00024210000113210000", "00024210000123210000",
  "00024210000123210000", "00024210000123210000", "00024210000123210000", "00024210000123210000",
  "00024210000124210000", "00024210000124210000", "00024210000124210000", "00024210000124210000",
  "00024210000124210000", "00024210000124310000", "00024210000135310000", "00024210000135310000",
  "00024210000135310000", "00024210000135310000",
].map((column) => [...column].map((d) => COUNT[d]));

export default function Demo() {
  return (
    <div className="rounded-lg border bg-card p-3">
      <Heatmap
        counts={MINUTES}
        y={{ min: 10, max: 7764 }}
        x={{ from: new Date("2026-09-15T14:00:00Z"), stepMs: 60_000 }}
      />
    </div>
  );
}
What it renders. Identical markup in all three panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Honeycomb

Query-first; heatmaps and BubbleUp replace the dashboard-of-panels model with draw-a-region cross-filtering.

Honeycomb — Query / HEATMAP(duration_ms)
Heatmap. Linear y-axis, so the band holding most requests is six percent of the panel height and the empty top half gets the rest. Search across panels. The schema is the navigation, and it opens with a filter box rather than a tree you're expected to browse. Dashboard builder. No panel to configure—the query is the page. WHERE trace.parent_id does-not-exist is how you say root spans only. Tabs as genres. Five readings of one result: overview, BubbleUp, correlations, traces, raw. Each tab is a different question, not a different subject. Freshness indicator. When data last arrived, not when the page last ran. The one of the three ages that actually matters. Time-range picker. Absolute, with the granularity stated beside it, and arrows that step to the previous window rather than retyping it. Data source badge. Elapsed query time and 11,710,335 rows examined. The panel reporting its own cost and scope, which almost nothing else does.
Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
My heatmap entry cites Honeycomb as the argument for log-scale y-buckets, so it's worth recording that this is Honeycomb's own sandbox rendering the same chart on a linear axis. The result is the failure the argument warns about: the ticks run 0 to 3500 evenly, the dense band where almost every request actually lives is squashed into the bottom sixth of the panel, and the top half is mostly empty. What survives anyway is the thing a percentile can't tell you. The solid band under a second doesn't move across the whole window, while from about 07:00 a separate purple tail climbs to 3000ms and keeps going. Two populations, one of them fine and one of them deteriorating. A p95 line over this data would have risen and said nothing about which. The other thing worth stealing: the footer reports elapsed query time and that it examined 11,710,335 rows, so the panel tells you what it cost and how much it looked at. Cookie banner and a no-signup onboarding modal were removed to take the shot; nothing of the product's own UI was.
  • Heatmap Linear y-axis, so the band holding most requests is six percent of the panel height and the empty top half gets the rest.
  • Dashboard builder No panel to configure—the query is the page. WHERE trace.parent_id does-not-exist is how you say root spans only.
  • Tabs as genres Five readings of one result: overview, BubbleUp, correlations, traces, raw. Each tab is a different question, not a different subject.
  • Search across panels The schema is the navigation, and it opens with a filter box rather than a tree you're expected to browse.
  • Time-range picker Absolute, with the granularity stated beside it, and arrows that step to the previous window rather than retyping it.
  • Data source badge Elapsed query time and 11,710,335 rows examined. The panel reporting its own cost and scope, which almost nothing else does.
  • Freshness indicator When data last arrived, not when the page last ran. The one of the three ages that actually matters.
Show 1 more example Hide the rest

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Grafana Heatmaps September 9, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
Grafana's own teaching dashboard for the heatmap panel, and it teaches well because it holds the data constant and varies exactly one thing. The top-left panel is the source series. The eight heatmaps are all those same numbers under different y-bucket scales: linear, log2, log10, each with and without a split, two of them clamped to a 700-15k range. Read across the grid and the argument makes itself. The linear version crushes almost everything into a band near the bottom of the axis; the log versions spread the same distribution across the full height and the dense region moves. Same data, different readings, and nothing on any single panel tells you which bucketing you're looking at except the title someone remembered to write.
  • Time series The source series. Every heatmap on the page is built from this.
  • Heatmap Linear y buckets, which is the default and the one that hides the low end.
  • Small multiples Nine panels, one variable changed. The comparison is the content.
  • Sequential and diverging scales Every panel carries its own ramp legend, 1 to 30, so the colour is readable per panel but not across them.