Heatmap block
A row × column grid of values, shaded on a sequential color ramp from light to dark — for spotting where activity concentrates across two dimensions at once, like weekday × hour.
How it works
The chart renders as a single server-generated PNG (no client-side chart library, no JavaScript) with an always-visible HTML data table beneath it — the same non-negotiable pairing every chart in this family commits to. A heatmap has no series or category-share shape the way its siblings do: magnitude is encoded purely by color intensity along one sequential ramp, never by a second identity color, so its "legend" above the table is a gradient bar (real CSS, not a swatch list) labeled with 0 at one end and the grid's actual maximum value at the other — showing what shade corresponds to what magnitude.
Rows are authored in "long format": one Row, one Column, and one Value per authored row — a cell of the grid, not a full row or column at once. This is the natural fit for a matrix given that project data can't nest arrays inside arrays; the block groups every authored cell into a row × column matrix at render time. Distinct row and column labels are kept in first-seen authoring order (never sorted) — a heatmap listing weekdays keeps Monday first because you typed it first, not because of any alphabetical or chronological inference.
A true missing combination — a (row, column) pair you never authored a value for at all — renders as a distinct flat gray "no data" cell in the image, and a "No data" swatch appears in the legend whenever the grid has at least one such gap. This is deliberately different from how a zero would render: a zero is a real, dark-appropriate value on the ramp (the low end, but still measured), while a gap means nobody ever recorded anything for that cell — conflating the two would make "we have no data here" look identical to "activity here is exactly zero," which is a materially different claim. The data table only ever lists cells you actually authored a value for; a genuine gap contributes no row to the table either, since there's no value to show for it.
Cells show their exact value as text directly inside the grid image only while the grid stays small — up to 40 total cells (rows × columns). Past that, the ramp shading alone carries the magnitude in the image itself, and the always-visible data table becomes the place to read exact numbers, since cramming legible text into a shrinking number of increasingly small cells stops working well before the grid gets large. The grid is capped at 12 distinct rows and 24 distinct columns; an additional new row or column identity past those caps is dropped rather than folded into an existing one, since merging two genuinely different rows or columns together would misrepresent what each one measured.
The ramp defaults to a hue derived from the project's brand color; Ramp color overrides it with your own.
This block is authored-rows only — it has no data-source-binding or campaign-response-binding mode. See Binding charts to real data for which four chart types do support binding.
Configurable fields:
- Chart title — shown above the image.
- Cells (row, column, value) — the row-list; one grid cell per authored row.
- Ramp color — overrides the default brand-derived ramp hue.
- Background color, padding, border, border radius on the card.
- Independent color/font-size pairs for the title and the legend/table text.
Examples
Subject
When your RSVPs are opening
An events digest can heatmap RSVP activity by weekday × hour-of-day (a classic 7×24 shape, comfortably inside the row/column caps) to show organizers when invites actually get opened and acted on. A support-ops report might grid ticket volume by day-of-week × category, making it obvious which category spikes on which day without a dozen separate bar charts. A sports-team season report could heatmap attendance by squad × month, with genuine "no data" gaps (a squad that didn't exist yet in an early month) rendering distinctly gray rather than looking like a zero-attendance month that would misrepresent the team's actual turnout.
What the static fallback looks like
<div style="margin:12px 0;padding:16px;background-color:#ffffff;border-radius:8px">
<p style="margin:0 0 8px;font-weight:600;font-size:15px;color:#111827">Activity by day and time</p>
<img src="https://mailinapp.com/api/analytics/chart?d=eyJ0eXBlIjoi...&s=3f9a2b1c..." width="560" height="360"
alt="Heatmap: 3 rows × 4 columns, values from 0 to 15"
style="display:block;max-width:100%;height:auto;margin:8px 0" />
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:0 0 6px">
<span style="font-size:12px;color:#6b7280">0</span>
<span aria-hidden="true" style="display:inline-block;width:120px;height:10px;border-radius:5px;background:linear-gradient(to right,#eef2ff,#3730a3)"></span>
<span style="font-size:12px;color:#6b7280">15</span>
</div>
<table style="width:100%;border-collapse:collapse;margin-top:4px">
<tr>
<th style="text-align:left;padding:4px 8px;font-weight:600;font-size:12px;color:#6b7280">Row</th>
<th style="text-align:left;padding:4px 8px;font-weight:600;font-size:12px;color:#6b7280">Column</th>
<th style="text-align:left;padding:4px 8px;font-weight:600;font-size:12px;color:#6b7280">Value</th>
</tr>
<tr>
<td style="text-align:left;padding:4px 8px;font-size:12px;color:#6b7280">Tue</td>
<td style="text-align:left;padding:4px 8px;font-size:12px;color:#6b7280">Afternoon</td>
<td style="text-align:left;padding:4px 8px;font-size:12px;color:#6b7280">15</td>
</tr>
</table>
</div>
The gradient legend labels the ramp's 0 and max endpoints; the table lists Row/Column/Value for every cell that actually has data — a real gap contributes no row here at all.