HeatmapChart
A matrix of cells coloured by magnitude on a single-hue sequential ramp, for spotting concentration across two categorical dimensions. Ships a ramp legend, since a cell's colour can't be read back to a number without one.
<UiHeatmapChart :data="..." :xKey="..." :yKey="..." :valueKey="..." :ariaLabel="..." />Needs the charts module
This component ships in @jgastager/bundy-ui/charts, a separate module so projects that never draw a chart don't carry the paint classes these need. Nothing extra to install — just add the module entry. See Charts.
Data
requireddata is one object per cell, not one per row of the matrix — the grid is reconstructed from the xKey/yKey pairs you supply. A combination you leave out becomes a gap rather than an empty cell, which is the honest rendering of “not measured”; don't fill it with a zero, since that claims a measurement you don't have. Wednesday morning is missing below.
A cell you don't supply a row for is simply absent. That's the honest rendering of "not measured" — filling the gap with a zero claims a measurement you don't have.
X key
requiredxKey names the field holding each cell's column. It's a plain key into your objects, so the chart never has to know the shape of your data beyond the three fields you point it at.
Y key
requiredyKey names the field holding each cell's row. Rows read top to bottom in the order they first appear in data, so sort the array if the order matters — a time-of-day axis wants morning at the top whatever order the rows arrived in.
Value key
requiredvalueKey names the numeric field that picks each cell's shade. It has to be a number — the ramp is a quantitative encoding, so a string that happens to look numeric has nowhere to sit on the scale.
ARIA label
requiredariaLabel is required rather than optional: a matrix of coloured rectangles is completely opaque to a screen reader, so this is the only description of the chart that exists. Describe what the shape shows — where the concentration is — rather than naming the chart type.
ARIA description
ariaDescription carries the detail the label shouldn't — the range, the sampling, an anomaly worth naming. It's read after the label, so write the two as a headline and a paragraph rather than repeating one inside the other.
Color
color is the family the ramp is built from. One semantic name goes in and the shades are stepped out of it for you, so the ramp stays a single hue in lightness order by construction — which is what makes a cell readable as “more” rather than merely “different”. A multi-hue ramp would only say the latter.
Colors are the library's own semantic ones — primary, secondary, success, warning, error, info, pending, neutral — and any shade of them, primary-400 through primary-950, exactly as the bg-* utilities spell them. Set color per series (or per item) to pin one; leave it unset to take the next slot of the default sequence. neutral and secondary are never handed out automatically, so an unlabelled series never lands on them.
color is one semantic name, stepped into a ramp from a dim tint up to the full color. Building it that way rather than taking a list of hex steps keeps it a single hue in lightness order by construction — which is what makes a cell readable as "more" rather than merely "different".
Steps
steps sets how many shades the ramp is quantized into. Fewer steps make each band easy to name back to a range; more make the gradient smoother but the individual cells harder to read off the legend. Five is usually the ceiling for a legend somebody actually uses.
Ramp
ramp spells the shades out exactly, replacing the span generated from color and steps. Reach for it when that span is the wrong part of the family — a contribution graph wants its darkest step close to the page, which color alone can't express. List them dim to bright: the ordering *is* the encoding, and a list out of lightness order silently stops meaning “more”.
ramp overrides the shades generated from color and steps, for when the generated span is the wrong part of the family — color="success" starts at 800, which is too light for a cell meaning "one" on a dark page. List them dim to bright; the ordering is the encoding, and a list out of lightness order stops meaning "more" without erroring.
Domain
domain is the pair of bounds the ramp spans, defaulting to the data's own extent. Pin it when several heatmaps need to be comparable with each other, or when zero should anchor the dim end so a quiet cell reads as quiet rather than as “lowest here”.
domain pins the scale's ends. Without it each heatmap normalizes to its own extent, so two of them side by side are not comparable even when they look it.
Zero color
zeroColor paints cells that are exactly zero apart from the ramp entirely. Absence is a different kind of thing from “the least of something”: a day with no commits isn't a slow day, and giving it the dimmest step says it was. Setting it also re-spans the ramp over the non-zero values, so the counts that do exist get every step available to them.
zero-color takes cells at exactly zero out of the ramp. Absence and "the least of something" are different claims, and a calendar heatmap is where that bites: a day with no commits isn't a slow day. Setting it also re-spans the ramp over the non-zero values, so the counts that exist use every step rather than surrendering one to a shade nothing reaches.
Radius
radius rounds each cell's corners in pixels. Small cells want a small value or they turn into dots — a calendar heatmap sits around two, a five-by-three matrix like this one can take the default six.
X label
xLabel titles the horizontal axis, naming the dimension the columns cross. Worth adding whenever the tick labels alone are ambiguous — “Mon, Tue, Wed” could be a schedule or a history.
Y label
yLabel titles the vertical axis. Together the two titles say what the matrix crosses, which is the one thing a heatmap can't show on its own — the cells encode magnitude, not what the axes mean.
Value format
valueFormat turns a cell's number into the text shown in the tooltip and on the legend's two ends. It's where the unit belongs — “124 sessions” rather than a bare 124 — since nothing else on a heatmap says what's being counted.
X format
xFormat takes a column key and returns its tick label — and returning an empty string drops that tick entirely. That escape hatch is what keeps a long axis readable: a calendar heatmap has a column per week and no room to name them all, so labelling only the weeks that open a month is the usual answer.
x-format and y-format may return an empty string to drop a tick label, and the margins are reserved from the formatted text — so blanking one reclaims its room. That's how a year-long calendar heatmap labels twelve months across fifty-three columns instead of smearing all fifty-three.
Y format
yFormat is the same for the row labels — shorten them, or return an empty string to drop a tick on a matrix with more rows than the axis has room to name.
Tooltip
tooltip is the panel that follows the pointer, on by default, and it's the only place a cell's exact number appears. Turn it off for a decorative tile where the shape is the whole point and there's nothing to look up.
Hovering dims every cell except the one under the pointer to 0.7, rather than drawing a ring on it — a ring is a dot, which reads as a stray mark sitting on the cell.
The hover fade is a CSS transition over the library's own 250ms duration, so it costs nothing and honours prefers-reduced-motion. There's no animate prop and nothing tweens on a data change — the marks are plain SVG elements Vue patches directly.
Legend
legend is the ramp strip under the plot, on by default and worth keeping: a cell's shade can't be read back to a number from anything else on the chart. Drop it only alongside tooltip, when you've decided the chart is a texture rather than a reading.
The ramp legend is on by default and worth keeping. A bar's length can be read back to a number from the axis; a cell's colour can't be read back from anything else.
Legend position
legendPosition moves the ramp legend above the plot instead of below it. Top works well when the legend is what a reader needs before the cells mean anything — an unfamiliar unit, or a ramp that isn't the obvious dim-to-bright.
Guides
guides draws the axis labels and reserves the margins they need, and is on by default. Turning it off leaves a bare matrix filling the whole box — the shape for a dashboard tile, where the surrounding card already says what the axes are.
guides="false" drops the axes, ticks, labels and grid *and* the margins they reserve, so the marks fill the whole box. That plus a small height is the inline-sparkline shape — there's no separate component for it.
Height
height is the plot's height in pixels — width always comes from the container. It's ignored once aspectRatio is set, and it's kept through the loading and empty states, so a dashboard doesn't reflow as its panels resolve.
Aspect ratio
aspectRatio sizes the chart from its container's width instead of a fixed height — useful for a heatmap in a responsive grid, where a fixed height leaves cells squat on wide screens and cramped on narrow ones.
Loading
loading replaces the plot with a skeleton of exactly the same height, so the page doesn't jump when the data lands. An empty data array, by contrast, gets the empty state — the two are separate conditions and worth driving separately.
A commit history
The calendar heatmap, laid out the way a contribution graph is: one column per week, one row per weekday, a year across. A quiet day here is a real measured zero, not a gap — the opposite of the case below — so every day gets a cell. zero-color paints those apart from the ramp: a day with no commits isn't a slow day, and it shouldn't read as the palest green. The ramp then re-spans the days that do have commits, so the counts use every step available to them. ramp spells the shades out — dark green to bright — where the span generated from color alone would start too light for a cell that means "one commit". x-format returns an empty string for most columns so only the weeks that open a month are labelled; fifty-three labels would be a smear. Small cells want a small radius.
API Reference
Generated from the component's source — props, slots and emits as the component actually declares them.
Props
datarequiredRecord<string, unknown>[]ariaLabelrequiredstringChartColorFamily'primary'ChartColorName[]ChartColorName(value: string) => string(value: string) => stringTypes
interface ChartPlot {
x: number;
y: number;
width: number;
height: number;
/** Right edge, since marks need it more often than the width. */
right: number;
/** Bottom edge, which is also the x axis' baseline. */
bottom: number;
}type ChartColorFamily = "primary" | "secondary" | "success" | "warning" | "error" | "info" | "pending" | "neutral";type ChartColorName = ChartColorFamily | `${ChartColorFamily}-${ChartColorShade}`;