When to use heatmap
Heatmap is the right choice when you want to render a measure across two categorical or temporal dimensions at once. Common uses: day-of-week vs hour-of-day for activity patterns; channel vs region for revenue density; product category vs month for seasonality.
Use grid instead when the audience needs to read the actual numbers and one of the dimensions is high-cardinality. Use scatter when the dimensions are continuous rather than categorical.
Mapping
mapping.x— required. Column-axis field (one tick per distinct value).mapping.y— required. Row-axis field.mapping.value— required. Numeric field that drives the color intensity of each cell.
mapping:
x: hour_of_day
y: day_of_week
value: order_count
Time-bucket extractions like hour-of-day and day-of-week are dialect functions — compute them as columns in a raw-SQL source so the heatmap consumes ready-made fields (this shape also ports to Postgres/MySQL by swapping the extraction functions):
# models/ec_orders_activity.malloy
##! experimental.parameters
source: ec_orders_activity() is ecommerce.sql("""
SELECT
EXTRACT(HOUR FROM created_at) AS hour_of_day,
FORMAT_TIMESTAMP('%a', created_at) AS day_of_week,
MIN(EXTRACT(DAYOFWEEK FROM created_at)) AS day_order,
COUNT(*) AS order_count
FROM `bigquery-public-data.thelook_ecommerce.orders`
GROUP BY 1, 2
""") extend {
view: by_day_hour is {
select: hour_of_day, day_of_week, order_count, day_order
order_by: day_order asc, hour_of_day asc
}
}
chart shortcuts
The chart block is typed and closed.
chart.show_cell_labels— boolean. Renders the cell value inside each cell. Style the labels withchart.labelbelow.chart.cross_filter_emit—"x"or"y". Heatmap has two categorical axes, so you must pick which one's value is emitted on click. Required wheneverchart.cross_filteris not explicitlyfalse; the schema rejects a chart block that has neither.chart.cross_filter— boolean, defaulttrue. Set tofalseto disable click emission entirely; in that casecross_filter_emitis not required.chart.height— pixel height of the viz container.
Color scale
chart.visual_map is an object that controls the color gradient and the optional color-scale legend:
show— boolean. Show the color-scale legend on the side of the chart.orient—"horizontal"|"vertical".min,max— explicit floor / ceiling. Override the data-derived range — useful when several heatmaps on the same dashboard need to share a scale.left/right/top/bottom— pixel number or percent string for placing the color-scale legend.in_range.color— array of hex strings: the gradient stops from low to high. Two stops produce a simple low-to-high gradient.text_style— text styling for the color-scale labels.
chart:
visual_map:
show: true
orient: vertical
in_range:
color: ["#e0f7f4", "#0d9488"]
Cell labels
chart.label styles the per-cell numeric label when chart.show_cell_labels is on:
position,rotate,color,font_size,font_weight.formatter— string template (no callbacks). Use{c}for the value.distance,align,vertical_align,clip.
Cell labels are useful when the heatmap is small and the audience needs the exact number; on dense heatmaps the labels become noise — leave them off and rely on the tooltip.
Legend & tooltip
chart.legend and chart.tooltip share the same shape as on bar. For heatmap the tooltip with trigger: item reveals one cell at a time with both axis values and the cell value.
Axes
chart.x_axis and chart.y_axis share the same shape:
name,name_location,name_gap.axis_label.show,axis_label.rotate,axis_label.interval,axis_label.color,axis_label.font_size,axis_label.font_weight,axis_label.formatter,axis_label.max_chars.
format
format.valueorformat[<value_field_name>]— pattern for cell labels and tooltip values.formatat the root — fallback.
Cross-filter behavior
- Click emits a value only when
chart.cross_filter_emitis set to"x"or"y". - The chosen axis becomes the cross-filter field; the clicked label becomes the value.
- The clicked field must be declared as a parameter on a source signature in this viz's own model file.
- Disable per viz with
chart.cross_filter: false(or simply leavecross_filter_emitunset).
See Cross-filtering for the full mechanism.
Worked examples
Activity by hour and day of week:
id: orders_by_day_hour
title: Orders by Day and Hour
query: "models/ec_orders.malloy::by_day_hour"
type: heatmap
mapping:
x: hour_of_day
y: day_of_week
value: order_count
chart:
height: 320
show_cell_labels: true
cross_filter_emit: x
visual_map:
show: true
orient: vertical
in_range:
color: ["#e0f7f4", "#0d9488"]
x_axis:
name: Hour
y_axis:
name: Day
format:
order_count: "#,##0"
published: true
Shared color scale across multiple heatmaps (set explicit min / max):
chart:
visual_map:
show: true
min: 0
max: 5000
in_range:
color: ["#e0f7f4", "#0d9488"]
Design notes
- When an outlier dominates the color scale, set
visual_map.maxto clip it — values above the cap render at the top color. - For labels over dark cells, set a contrasting
chart.label.color, or turn cell labels off and rely on the tooltip. - A heatmap emits one axis per click (
cross_filter_emit: "x"or"y") — when the interaction needs both coordinates, use a grid. - The exposed
visual_map.in_range.coloris a low-to-high gradient — for diverging scales, encode the divergence in the value itself (e.g. a signed delta).