# Measure heatmaps

> Ask your agent to measure where visitors click on one page, which elements they click and how far down they scroll, from the browser SDK's autocapture and page tracking, with the people behind every cell, element and depth. A heatmap is a stored result, opened as a proof page, optionally drawn over a masked copy of the page.

Canonical page: https://anectico.com/docs/investigate/heatmaps/


A heatmap answers three questions about **one page**: where do visitors click, which elements do
they click, and how far down do they scroll. It reads the clicks, rage clicks, page views and page
leaves your site's browser SDK records, counts them, and gives you **the people behind every cell,
every element and every depth**.

It is one kind of the [typed product query](/docs/investigate/product-analytics): `heatmap`. Like
every other kind it is measured once, frozen under an execution key, and read again by result ID.
Your agent runs the measurement through MCP or the CLI. A heatmap is a stored result, and you open
it as a read-only proof page in the result viewer. There is no separate heatmap page.

## Ask your agent

> "Where do visitors click on `/pricing` on a phone-sized screen, over the last 7 days? Include
> rage clicks and send me the proof link."

> "How far down do people scroll the docs home page this week?"

| Job | MCP tool or action | CLI command |
| --- | --- | --- |
| Check heatmap modes, viewport classes and bounds | `get_product_query_capabilities` | `anectico analytics capabilities` |
| Measure clicks, elements or scroll depth of one page | `query_product_analytics` | `anectico analytics query` |
| Read a stored heatmap again | `get_analytics_result` | `anectico analytics result get` |
| Page the people behind a cell, an element or a depth | `list_analytics_participants` | `anectico analytics result participants` |
| Read the project's browser capture settings | `get_capture_settings` | `anectico capture-settings get` |
| Change them, including the page snapshot | `update_capture_settings` | `anectico capture-settings enable-page-snapshot`, `anectico capture-settings disable-page-snapshot`, `anectico capture-settings set-page-snapshot` |
| List which pages have a stored page copy | `list_page_backdrops` | `anectico replay page-backdrops list` |
| Delete a stored page copy | `delete_page_backdrop` | `anectico replay page-backdrops delete` |

The measurement tools are MCP read actions. Your agent finds them with `list_read_actions` and runs
them with `execute_read_action`; the two writes are write actions. Reading any heatmap result needs
`agents:content:read` in addition to `analytics:read`, `analytics:query` and `persons:read` (see
[Permissions](#permissions)). Capture settings need `ingestion:pipelines:read` to read and
`ingestion:pipelines:write` to change. Listing page copies needs `replay:read`, reading a copy needs
`replay:content:read` and deleting one needs `replay:delete`. Over MCP the key also needs `mcp:read`
(reads) or `mcp:write` (writes).

## What your agent gets back

The result comes back under **`heatmap`**, with `state`, `closed` and one section for the mode you
asked for: `clicks` (`totals`, `positions`, `cells`, `other`, `viewports`), `elements` (`rows`,
which include the missing-selector row, `other`, `viewports`) or `scroll` (`page_views`,
`measured_views`, `bands`, `leaves`). Every cell, element row and band carries
`{people, unresolved, selection_id}`, and the `selection_id` pages the people behind it. Trimmed
examples are in [Run it and read the result](#run-it-and-read-the-result).

Over MCP, the result carries a link to the proof page in its `links`, in the entry titled
"Analytics result" (the address is its `href`). Every tool in the table that reads this stored
result carries the same link. The CLI prints the result and no link.

## Open the proof

Open the link from your agent. It opens the stored heatmap in the read-only result viewer, with the
project named in the page header. See [Proof pages](/docs/agents/proof-pages) and
[Result viewer](/docs/agents/result-viewer).

The page never measures again. It shows what was stored, when it was measured (in UTC) and when the
stored result expires. Its one action is **Copy link**. To look at another page, mode, viewport
class or window, ask your agent to measure again with a new definition and a new execution key.

You see:

- A headline with the click count (or the page-view count for scroll depth), with tiles for the
  **Clickers** (people, with unresolved identities shown beside them as **+N unresolved**) and the
  clicks placed on the grid. A state notice above the numbers says when the result is still
  measuring or has insufficient coverage; an unavailable result shows no numbers at all.
- For **clicks**, the grid: one square per cell that holds clicks, darker for more. When the
  definition names one viewport class and a [page snapshot](#the-page-behind-the-grid) is stored,
  the grid is drawn over the masked copy of the page, with the capture date beneath it. Otherwise it
  is drawn on the abstract page, a tall rectangle that stands for the whole document, with the
  rulers in percent. A legend states the real least and most clicks of a cell, and the page says
  plainly that this is positions on the page, not a screenshot. Each cell has a tooltip with its
  figures, and large cells print their count, so no number depends on colour. The same cells are
  listed, ranked, in a table beside it. Cells can be reached with the keyboard: tab to the grid,
  move between cells with the arrow keys, and press Enter to open the people.
- A note that accounts for the clicks that carried no position or an invalid one, and says to turn
  on `capturePointer` and to check the project's **Browser capture** settings.
- When rage clicks are counted, a toggle that shows them as dashed outlines with a corner marker.
- When more than one viewport class contributed, a notice that the layouts were merged, with each
  class and its clicks. To look at one class, ask your agent to measure again with
  `viewport_width_bucket`.
- For **elements**, a ranked table: the selector in monospace, the tag, clicks, rage clicks if
  counted, and the clickers. The **Missing** row, the **Other** row and a **Truncated** note are
  shown plainly.
- For **scroll depth**, a vertical chart of the share of measured views that reached 10% to 100%
  of the page, with the number of measured views as the denominator. Views with no leave are stated
  beside it and never drawn as depth zero. The same bands are in a table below.
- A window with no clicks (or no page views) shows an empty state that explains what to enable —
  autocapture, `capturePointer`, page tracking — with links to the SDK reference and to the
  project's browser capture settings. If the person who opens the link lacks the event-content
  permission, the page says so and names `agents:content:read`.

## What a heatmap is here, and what it is not

**A heatmap is made of positions and elements.** The measurement never reads your page: it counts
events. What you get:

- **Clicks** are counted in a grid: a column and a row are a share of the page's width and height,
  counted from the top left, never pixels of a particular layout. By default the proof page draws
  that grid on an abstract page, a tall rectangle that stands for the whole document, with the
  rulers in percent. If you turn on [page snapshots](#the-page-behind-the-grid), it draws the same
  grid over a masked copy of the page instead.
- **Elements** are counted by the element that was clicked, named by the CSS path the SDK computed
  for it (such as `button#buy`) and its tag. No visible text, label or attribute of any element is
  ever read or returned.
- **Scroll depth** is counted in ten fixed bands from the top of the page to the bottom.

No screenshot is ever taken, and nothing is loaded from your site when you open a heatmap.

## What it reads, and what to turn on

A heatmap reads these events from the browser SDK and nothing else:

| Mode | Events read | What it needs from your site |
| --- | --- | --- |
| `clicks`, `elements` | `$autocapture` events whose type is `click`, and, if you ask for them, `$rageclick` | [Autocapture](/docs/reference/javascript-sdk#opt-in-autocapture) turned on, with `click` among its `domEvents`. |
| `clicks` (positions) | The click's `$pointer_document_x_percent`, `$pointer_document_y_percent` and `$viewport_width_bucket` | `capturePointer: true` in autocapture. Without it a click has no position and sits in no cell. |
| `scroll` | `$pageview` and `$pageleave` | [Page tracking](/docs/reference/javascript-sdk#opt-in-spa-page-tracking). The page leave carries how deep the visitor scrolled. |

A `change` or a `submit` is autocaptured under the same event name as a click, and is **not** a
click: it is never counted. Everything is recorded only after the visitor has agreed to product
analytics.

Turn them on once, with the same declared route templates for both:

```typescript
const routes = ['/', '/pricing', '/orders/:id'];
events.trackPageViews({ routes, serviceName: 'storefront' });
startAutocapture(events, {
  routes,                       // the same templates as page tracking
  serviceName: 'storefront',    // so a page's views and clicks answer to the same service filter
  domEvents: ['click'],
  capturePointer: true,         // click positions: required for the click grid
});
```

Three things can keep a heatmap empty or without positions, and the proof page names each:

1. **Autocapture is opt-in.** It starts nothing on import, and nothing is recorded until your
   application starts it.
2. **The project's capture settings can narrow your code.** If a project's
   [browser capture settings](/docs/manage/ingestion-pipelines#browser-capture-settings) switch
   autocapture off, or switch **capture pointer position** off, a site that obeys the settings
   (`remoteConfig`) collects no clicks or no positions however it was written. The settings can
   only make a site collect less than its code asks, never more. In the Console, open **Privacy**
   (`/privacy`) and choose the **Browser capture** tab; your agent can read them with
   `get_capture_settings` or `anectico capture-settings get`. See
   [the Console guide](/docs/manage/console#privacy).
3. **Set `serviceName` on both** page tracking and autocapture if you filter a heatmap by service:
   the filter reads each event's own `$service_name`, so an event sent without it is not found.

Elements and areas you [block or mask](/docs/reference/javascript-sdk#opt-in-autocapture) send no
click, or send one without text, and a heatmap shows exactly that.

To check that clicks and positions arrive, click once on a page you declared, then ask your agent
for a `clicks` heatmap of that page over the last hour. The click must be counted in `totals`, and
`positions.positioned` must count it. If your mouse click is counted in `positions.without_position`
instead, pointer capture is off in your code or in the project's capture settings.

## The page behind the grid

Positions are easier to read over the page they fall on. Anectico can keep **one masked copy of
each page** for that purpose, and the proof page then draws the click grid over it. It is off until
you turn it on.

### What the copy is

A structural copy of the page as one visitor's browser rendered it: its elements, their classes
and the layout rules of your stylesheets, so the layout is recognisable. It is **not a screenshot
and not a recording**. By default it is a wireframe:

- **Every visible character is replaced by `x`.** Words keep their length and lines keep their
  wrapping, and nothing can be read. The server refuses a copy that contains readable text while
  this is on, whatever sent it.
- **Images are left out** and keep their space as empty boxes.
- **No input value is ever copied**: not a text field, a text area, a selected option or an
  editable region. This cannot be switched off.
- **Blocked areas are empty boxes.** Anything you [block](/docs/reference/javascript-sdk#opt-in-autocapture),
  and any selector you list under block, is replaced by a box of the same size with nothing inside.
- **Only layout is copied.** A copy holds a fixed list of ordinary page elements, the attributes
  that shape layout (classes, inline styles, sizes, table spans) and style rules. It holds no
  element `id`, name, title, alternative text, placeholder, link address, data attribute or
  framework attribute, so text kept in those is not copied either, whether or not text is masked.
  Three kinds of name you choose are copied as written, with or without masking: class names,
  custom property names (`--brand-color`) and layer or container names. Avoid putting personal
  data in any of them. Nothing else you name is copied: style values are limited to numbers and
  standard CSS keywords.
- **Text in style sheets is not copied.** A style rule keeps no quoted text, so text a page
  generates from its CSS (`content: "…"`) is left out, with or without masking.
- **Nothing in it can run or load.** Scripts, event handlers, frames, fonts, animations and every
  reference to another address are left out in the browser, and the server refuses a copy that
  contains anything outside the list, whatever sent it. The proof page shows the copy in an isolated
  frame that can run no script, make no request and take no focus.

One copy is kept per **page (route template), viewport class and service**; a newer capture
replaces the older one. It is stored with your session-replay data and expires with it.

### Turn it on

Two switches, and both are needed:

1. **In the project.** Enable the page snapshot in the project's capture settings. In the Console,
   open **Privacy** (`/privacy`), choose the **Browser capture** tab and use the **Heatmap page
   snapshots** card (see [the Console guide](/docs/manage/console#privacy)). Your agent can do the
   same with MCP's `update_capture_settings`, and from a terminal:

   ```bash
   anectico --project PROJECT_UUID capture-settings enable-page-snapshot
   ```

   `capture-settings set-page-snapshot` sets the whole section, `update_capture_settings` takes a
   `page_snapshot` object with only the fields to change, and REST takes the section as
   `page_snapshot` in `PUT /api/v1/projects/{projectId}/capture-settings`:

   | Field | Default | Meaning |
   | --- | --- | --- |
   | `enabled` | `false` | The switch. While `false` no copy is asked for or accepted. |
   | `mask_all_text` | `true` | Replace every visible character by `x`. |
   | `mask_selectors` | none | CSS selectors whose text is always replaced. At most 50. |
   | `block_selectors` | none | CSS selectors replaced by an empty box. At most 50. |
   | `inline_images` | `false` | Embed images in the copy, where the browser is allowed to read them. |
   | `sample_percent` | `10` | How often a page that needs a copy asks for one, 1 to 100: the share of minutes in which its visitors are asked. Everyone visiting the page in the same minute gets the same answer. |
   | `refresh_days` | `7` | How old a copy may be, 1 to 365 days, before a new one is asked for. |

   These settings are a ceiling: your site can mask and block more than they say, never less.
   `capture-settings disable-page-snapshot` is the kill switch. It takes effect at once, without
   a redeploy: no copy is asked for, and one that still arrives is refused.

2. **In your site.** Start page snapshots with the same route templates and service name as page
   tracking and autocapture, and the key you use for session replay:

   ```typescript
   import { startPageSnapshots } from '@anectico/sdk/page-snapshot';

   startPageSnapshots({
     endpoint: appEndpoint,
     apiKey: replayKey,            // a key holding only replay:write
     collection,                   // nothing is captured without the visitor's replay consent
     routes,                       // the same templates as page tracking
     serviceName: 'storefront',
   });
   ```

A copy is kept only for a page your project has recorded page views or clicks for in the last 30
days, under the same route template and service name. A copy is taken once the page has loaded and
stopped changing, and again after a route change, but
only when the project wants one for that page and viewport class. It is taken in short steps while
the browser is idle, and given up if the visitor moves to another page first. A visitor's browser
uploads at most one per page per visit, and most visits upload none.

### See it

Ask your agent for a `clicks` heatmap with **one viewport class** (`viewport_width_bucket`). If a
copy exists for that page and class, the proof page draws the grid over it, with a line saying that
the image was **captured from a visitor's browser, and when**. Without a viewport class the layouts
of every width are merged and no single picture is true of them, so the abstract page is drawn. The
abstract page is also what you see when there is no copy yet, or when you lack permission to read
replay content.

Reading the copy needs `replay:content:read`; listing which pages have one needs `replay:read`.
List and delete them with `anectico replay page-backdrops list` and
`anectico replay page-backdrops delete`, MCP's `list_page_backdrops` and `delete_page_backdrop`, or
`GET` and `DELETE /api/v1/replay-page-backdrops`. The proof page only shows the copy; it has no button that deletes
it. Deleting one returns that page to the abstract rectangle until the next capture.

### What to keep in mind

- **It is one visitor's page.** The copy has that visitor's page height, their signed-in state and
  whatever variant they were shown. Other visitors' pages may be taller or shorter, and each click
  is a share of *its own* page, so a cell may not sit exactly on the element in the copy.
- **It may be older than the clicks.** The caption states the capture date. After a redesign,
  delete the copy or wait for the refresh.
- **It is a visitor's upload, not a verified picture.** The copy is sent by a browser using the key
  embedded in your site, which anyone visiting the site can read. Anectico checks that the copy can
  do nothing and, when text is masked, that it contains no readable text; it cannot check that the
  copy shows your real page. If an image looks wrong, ask your agent to delete it.
- **It will not look exactly like your page.** Text is drawn in a generic font (your font names
  are not copied), so line breaks can differ. A grid laid out with named areas or named lines falls
  back to automatic placement, which can move or resize its regions. Web fonts, animations, shadows,
  visual filters, CSS counters and detailed vector graphics are left out, and style rules that select an element by its `id` do not apply,
  because ids are not copied.
- **A project keeps copies for up to 500 pages**, counting each viewport class and service
  separately. When a new page needs room, a copy nobody has opened in a heatmap is dropped first,
  then the one opened longest ago.
- **Embedded images are limited in size.** With `inline_images` on, an image larger than 4,096
  pixels a side or four megapixels is left out.
- **If a page image ever stops the page from responding, it is skipped the next time** in that
  browser. The proof page says so and lets you try drawing it again. To remove the image, ask your
  agent to delete the page backdrop; that needs `replay:delete`.
- **Some content is not copied:** drawings on a canvas, video, content of embedded frames,
  components that hide their internals (closed shadow DOM), and stylesheets served from another
  address that the browser may not read. These appear empty or unstyled.
- **A very large page is copied in part or not at all.** A copy holds at most 20,000 elements and
  pieces of text, and stops there; one larger than 2 MiB is not sent.
- **Selector masking is done in the visitor's browser.** `mask_all_text` is verified by the server;
  `mask_selectors` and `block_selectors` cannot be, so leave `mask_all_text` on unless you have
  checked what your pages show.
- The catch-all `/[unmapped]` page never gets a copy: it is many pages under one name.

When you [erase a person](/docs/manage/erase-a-person), every copy their browser captured is
deleted with the rest. A copy is not part of a person's data export: it describes your page, not
the visitor.

## Define a measurement

A heatmap definition is the common definition (`version`, `window`, `timezone`, optional
`environment`) plus one `heatmap` object. A heatmap has **no interval**: its window is one span.

| Field | Meaning |
| --- | --- |
| `heatmap.page` | **Required.** One declared route template, compared exactly with the event's `$pathname`: `/pricing`, `/orders/:id`. Never a URL, a query string or a title. `/[unmapped]` is the SDK's label for every path no template matched, and is a page like any other. It starts with `/` and is at most 256 bytes. |
| `heatmap.mode` | **Required.** `clicks`, `elements` or `scroll`. |
| `heatmap.viewport_width_bucket` | Optional, `clicks` and `elements` only: one viewport width class, named by its lower edge in pixels — `0`, `576`, `768`, `1024` or `1440`. **`0` is the smallest class, not "unset"**; leave the field out for every class together. |
| `heatmap.service_name` | Read only the events whose `$service_name` equals it (1 to 128 bytes). |
| `heatmap.include_rage_clicks` | `clicks` and `elements` only: also count rage clicks, beside clicks and never added to them. |
| `heatmap.grid` | `clicks` only: `{"columns": 20, "rows": 40}`, the resolution of the grid. 1 to 50 columns by 1 to 200 rows, **both set together**. Left out, it is 20 by 40. |
| `heatmap.limit` | `elements` only: how many elements to return, 1 to 100; empty or `0` means 20. |

Person-property filters, audiences (`cohorts`) and `environment` restrict **which people** are
measured and apply as usual. A heatmap is **person-unit only**: the account unit is refused with
`QUERY_UNIT_UNSUPPORTED`. A field that belongs to another mode is refused, not ignored.

### Examples

Where people click on the pricing page, on the default 20 by 40 grid, in a 7-day window:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/pricing", "mode": "clicks"}
}
```

Clicks on a phone-sized layout only, on a coarser 10 by 20 grid, with rage clicks:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {
    "page": "/pricing",
    "mode": "clicks",
    "viewport_width_bucket": 0,
    "include_rage_clicks": true,
    "grid": {"columns": 10, "rows": 20}
  }
}
```

The ten most clicked elements of an order page, for one service, in production only:

```json
{
  "version": 2,
  "timezone": "UTC",
  "environment": "production",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/orders/:id", "mode": "elements", "service_name": "storefront", "limit": 10}
}
```

How far people scroll the documentation home page:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/docs", "mode": "scroll"}
}
```

## Clicks

### The grid and the cell rule

A click has a **position** when it carries both `$pointer_document_x_percent` and
`$pointer_document_y_percent` as numbers from 0 to 100: its place on the whole document, as a share
of the document's width and height. The cell it counts in is

```
column = min(floor(x * columns / 100), columns - 1)
row    = min(floor(y * rows    / 100), rows    - 1)
```

both counted from **0** at the top left. **A cell owns its lower boundary.** A click exactly on the
line between two cells belongs to the cell on the right or below it, `0` belongs to the first cell
and `100` to the last. On a 10 by 10 grid a click at (10%, 10%) is in column 1, row 1, and one at
(49.9%, 19.9%) is in column 4, row 1. (The proof page numbers cells from 1, so that cell is "column 2,
row 2" there.)

A cell nobody clicked is **never returned**. A result lists the cells that hold at least one click
(or rage click), most clicked first, then by rage clicks, then by row and column.

### Clicks without a position

Every measured click is in exactly one of three groups, reported in `positions`:

| Group | Meaning |
| --- | --- |
| `positioned` | Carried a valid position; it is in exactly one cell. |
| `without_position` | Carried no position because pointer capture was off, or because a keyboard or assistive activation had no physical pointer location. It is counted in the totals and the elements, and in **no cell**. |
| `invalid` | Carried only one coordinate, a value that is not a number, or a number outside 0 to 100. Counted, and in **no cell**. |

`positioned + without_position + invalid` is always the click total. Clicks without a position are
never dropped and never placed anywhere; the proof page says how many there are and what to do: turn
on `capturePointer`, and check that the project's capture settings allow it. If rage clicks are
counted, `rage_positions` accounts for them the same way.

Keyboard and assistive activations remain without a position even with pointer capture enabled.
They retain the viewport width class when capture is enabled, so viewport filters can still count
them. Real mouse, touch and pen clicks at coordinate zero are valid positioned clicks.

### Rage clicks

A rage click is the SDK's record that one element received three physical mouse, touch or pen clicks
within one second and 40 pixels. Keyboard and assistive activations without a physical pointer do
not contribute to the burst. The burst's clicks are **already counted as clicks**, so `rage_clicks`
is a separate count, reported beside `clicks` and **never added to it**. A cell or element reports its
own `rage_clicks`; they are zero unless the
definition sets `include_rage_clicks`. A cell that only holds rage clicks is returned, with zero
clicks. If [click friction](/docs/investigate/customer-experience#opt-in-to-click-friction) is
running, a burst on an element it labels is reported by friction and not as a rage click, so it is
not counted here.

### Viewports, and why not to blend layouts

A page is laid out differently at 400 px and at 1440 px: the same position can be a different
button, or empty space. `$viewport_width_bucket` is the lower edge of the visitor's viewport width
class — **under 576 px** (`0`), **576–767 px**, **768–1023 px**, **1024–1439 px** and **1440 px and
wider**. A click carries it only when pointer capture is on.

- With `viewport_width_bucket`, `clicks` and `elements` count **only** the clicks of that class.
- Without it they count the clicks of **every class together**. The result then says so: `viewports`
  lists every class with its clicks and `included: true`, and the clicks that carried no valid class
  as one `unknown` entry. **A blended grid describes none of the layouts**, so look at one class at
  a time when more than one contributed.

Either way `viewports` lists every class seen on the page, with `included: false` for those a
filter left out, so a filter never hides what it removed. The people of the whole measurement — the
window population — are everyone who clicked on the page in any class; `totals.clickers` is the
filtered subset.

### Other and truncation

A result returns at most **2,000 cells**. A finer grid on a busy page can hold more cells with
clicks than that; the rest are one `other` entry with `truncated: true`. Its count is how many cells
were left out, and its clickers are the **distinct** people over all of them. The default 20 by 40
grid has 800 cells and is never truncated. The cells and `other` together account for every
positioned click.

## Elements

The `elements` mode counts clicks by the clicked element. Each row is:

| Field | Meaning |
| --- | --- |
| `selector` | The click's `$selector`: the SDK's stable CSS path, 1 to 512 bytes. |
| `tag_name` | The element's `$tag_name`. If the clicks of one selector disagree, the lexicographically smallest; empty when none carried a valid one. |
| `measures` | `clicks`, `rage_clicks` and `clickers`, as for a cell. |

Rows are ranked by clicks, then rage clicks, then selector. Three rows are special:

- **The missing-selector row** (`missing: true`, empty `selector`) holds the clicks that carried no
  valid selector: none, an empty one, or one longer than 512 bytes. It is always returned and does
  **not** count against `limit`.
- **`other`** (with `truncated: true`) is the distinct union of the elements `limit` left out.
- The rows, the missing row and `other` together account for every measured click.

The viewport rule above applies to elements too: without `viewport_width_bucket` the classes are
merged and `viewports` says which.

**No element text.** A selector and a tag are all that is known of an element. Its text, label and
attributes are customer content that heatmaps never read, so a result, a report or an export can
never carry them.

## Scroll

The `scroll` mode reads the page's `$pageview` and `$pageleave` events under the same rule as
[web analytics](/docs/investigate/web-analytics):

- A page leave that reports its time and scroll depth is **measured**. It joins the latest page view
  of the same visitor, session and page with a strictly earlier timestamp.
- A page view's **depth is the maximum** `$scroll_depth_percent` of its measured leaves. A page that
  becomes hidden and visible again can leave several times, so the deepest wins. The depth is the
  bottom of the viewport as a share of the page height; a page that fits the viewport reports 100.
- A view with **no measured leave has no depth**. It is counted in `views_without_leave`, in no band,
  and **never as depth zero**.

`page_views = measured_views + views_without_leave`. The result carries `visitors` (everyone with a
page view), `leaves` — every page leave of the page accounted for as `attributed`, `unattributed`
(no earlier page view to join) or `invalid` (no usable time or depth) — and ten **bands**, at 10%,
20% … 100%. A view **reaches** a band when its depth is **at least** the threshold, so a depth
exactly on a threshold reaches it and `views` never rises from one band to the next. Each band
reports the views that reached it and the distinct visitors who own them.

The share of measured views that reached a depth is `views / measured_views`. The platform computes
no percentage: the proof page shows it with the denominator, and shows a dash, not 0%, when no view
was measured.

## The people behind every count

Every population in a result — the totals, **every returned cell**, every element row including the
missing one, each `other` entry, and every scroll band — carries `{people, unresolved,
selection_id}`. `people` are resolved people; `unresolved` are identities that never resolved to a
person: an occurrence has no identifier, or its identifier has no person mapping. An
anonymous visitor can still be a resolved person. They are counted **separately and never
merged**. The `selection_id` pages the resolved people behind the count, with the same call as any
other kind, and each selection can be exported or turned into a derived audience.

A person who clicked in several cells is in each of their selections, so the people of cells and
elements do not add up to the total. Anonymous clickers with resolved identifiers count in
`people` and can be listed. If none of the identifiers resolves to a person, `people` is 0 and
`unresolved` carries the unmatched count.

## Result states

Missing data is never a measured zero. The result has a state and a `closed` flag (the end of the
window against the frozen observation boundary):

| State | Meaning |
| --- | --- |
| `PRODUCT_HEATMAP_STATE_MEASURED` | The window has ended and platform coverage is sufficient. |
| `PRODUCT_HEATMAP_STATE_IMMATURE` | The window has not ended. The counts observed so far are disclosed and may still grow. |
| `PRODUCT_HEATMAP_STATE_INSUFFICIENT_EVIDENCE` | The window has ended but platform coverage is not sufficient, for example because events were still arriving. The observed counts are disclosed and may undercount. |
| `PRODUCT_HEATMAP_STATE_UNAVAILABLE` | Platform coverage is unavailable. **Nothing is disclosed**: no cells, elements, bands, totals or selections. |

Only `MEASURED` means complete; the proof page states the other states in words above the numbers.

## Permissions

Page routes, element selectors and click positions describe your product and are customer-supplied
content. Reading **any** heatmap result therefore needs the event-content permission
`agents:content:read` in addition to `analytics:read`, `analytics:query` and `persons:read` — at
execution, and on every later read of the result, its people and its exports. Without it the query
is refused with `403` and a message naming what is missing. See
[permissions](/docs/reference/permissions).

## What is refused

A refused definition returns `400 InvalidArgument` with a message that begins with a stable code.

| Code | Refused |
| --- | --- |
| `QUERY_HEATMAP_INVALID` | A missing or malformed `page`; an unknown `mode`; a `viewport_width_bucket` that is not 0, 576, 768, 1024 or 1440; a `grid` with only one of columns and rows; a field of another mode (`grid` outside `clicks`, `limit` outside `elements`, a viewport or `include_rage_clicks` with `scroll`); a `service_name` that is blank or too long; a `breakdown`, a `comparison` window, top-level event filters, a `filter_group` or account filters. |
| `QUERY_HEATMAP_LIMIT` | A `grid` above 50 columns by 200 rows, or a `limit` above 100. |
| `QUERY_UNIT_UNSUPPORTED` | The account unit. |

Event-property filters are refused because one would remove some clicks of a cell, or some leaves of
a view, and leave the rest. To narrow by an event property, use the page, the viewport class or the
service.

## Where a heatmap result is accepted

| Feature | Heatmaps |
| --- | --- |
| [Result export](/docs/manage/save-and-export-data) | Accepted: the people of the totals, a cell, an element, the other entry or a band export like any other population. |
| [Saved insights](/docs/investigate/saved-insights) and [dashboard widgets](/docs/investigate/service-health-and-dashboards) | The recipe is saved, with a relative window if you like, and shown as its own result. A widget shows a compact summary: the totals and the top cells, elements or bands, not the full grid. |
| [Scheduled reports](/docs/investigate/scheduled-reports) | Accepted: **counts and the top elements only**. A click grid is never listed in a report; a merge of viewport classes is stated in a note; views without a depth are a figure beside the bands. |
| [Remeasurement](/docs/investigate/product-analytics#remeasure-an-explicit-original-result) | Accepted through MCP, the CLI and REST. |
| [Metric watches](/docs/investigate/metric-watches) | Refused with `UNSUPPORTED_RESULT_SELECTOR`: a watch names no cell, element or band. |
| [Live screens](/docs/investigate/live-screens) | Refused with `UNSUPPORTED_RESULT_SELECTOR`. |

There is **no per-click drill-down**: `analytics result contribution` refuses a heatmap selection, so
only the roster of people behind a count is available. To watch one visit, use
[session replay](/docs/investigate/session-replay).

## Limits

| Limit | Value |
| --- | --- |
| Grid (`heatmap.grid`) | 1 to 50 columns by 1 to 200 rows; 20 by 40 when omitted |
| Cells returned by one clicks result | 2,000, most clicked first; the rest in `other` |
| Elements returned (`heatmap.limit`) | 1 to 100; 20 when omitted. The missing-selector row is extra |
| Page | 256 bytes |
| Service name | 128 bytes |
| One selector | 512 bytes; longer is counted as no selector |
| One tag name | 64 bytes; longer is no tag |
| Scroll bands | Ten, fixed: 10% to 100% |

The measurement limits shared by every kind (measurements in flight, time to run, result lifetime)
are in [Limits](/docs/reference/limits). A measurement whose people would add up to more than 20,000,000
participant rows across all of its selections fails with
`PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET` rather than publishing part of a result; use a
coarser grid or a shorter window. `anectico analytics capabilities` reports the `heatmap` kind with
its `heatmap_modes`, `heatmap_viewport_width_buckets` and `heatmap_scroll_bands`, and the
`max_heatmap_columns`, `max_heatmap_rows`, `default_heatmap_columns`, `default_heatmap_rows`,
`max_heatmap_cells`, `max_heatmap_elements`, `default_heatmap_elements` and
`max_heatmap_participant_rows` bounds.

## Run it and read the result

```bash
anectico --project PROJECT_UUID analytics query --file heatmap.json --execution-key EXECUTION_UUID
```

MCP's `query_product_analytics` takes the same definition, and REST is `POST /api/v1/analytics/query`
with `project_id`, `execution_key` and `definition`. The response carries the result under
**`heatmap`**, and only when `status` is `PRODUCT_RESULT_STATUS_READY`. Read a result again with
`anectico analytics result get RESULT_UUID`, MCP's `get_analytics_result` or
`GET /api/v1/analytics/results/{result_id}`. Counts are decimal strings.

A trimmed `clicks` result on a 10 by 10 grid, for a page where a visitor clicked `button#buy` three
times near the top left of a wide layout and another clicked it once on a narrow one, someone else
clicked a link further right, one click carried no position and one carried an impossible one:

```json
{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "heatmap": {
    "state": "PRODUCT_HEATMAP_STATE_MEASURED",
    "closed": true,
    "clicks": {
      "totals": {
        "clicks": "7",
        "rage_clicks": "0",
        "clickers": {"people": "0", "unresolved": "4", "selection_id": "SELECTION_UUID"}
      },
      "positions": {"positioned": "5", "without_position": "1", "invalid": "1"},
      "rage_positions": null,
      "cells": [
        {"column": 1, "row": 1, "measures": {"clicks": "4", "rage_clicks": "0", "clickers": {"people": "0", "unresolved": "2", "selection_id": "SELECTION_UUID"}}},
        {"column": 5, "row": 2, "measures": {"clicks": "1", "rage_clicks": "0", "clickers": {"people": "0", "unresolved": "1", "selection_id": "SELECTION_UUID"}}}
      ],
      "other": null,
      "truncated": false,
      "viewports": [
        {"bucket": 576, "unknown": false, "clicks": "1", "rage_clicks": "0", "included": true},
        {"bucket": 1440, "unknown": false, "clicks": "6", "rage_clicks": "0", "included": true}
      ]
    }
  }
}
```

Here five of seven clicks have a position, one has none and one is invalid; the busiest cell,
column 1 and row 1, holds four clicks from two clickers; two layouts are merged (the narrow one with
one click, the wide one with six); and no clicker's identifier resolved to a person, so every clicker is `unresolved`.
Asking again for `viewport_width_bucket: 576` would count one click in one cell and list the 1440
class with `included: false`.

A trimmed `scroll` result for a page with three views, two of which reported a leave:

```json
{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "heatmap": {
    "state": "PRODUCT_HEATMAP_STATE_MEASURED",
    "closed": true,
    "scroll": {
      "page_views": "3",
      "measured_views": "2",
      "views_without_leave": "1",
      "visitors": {"people": "3", "unresolved": "0", "selection_id": "SELECTION_UUID"},
      "bands": [
        {"depth_percent": 10, "views": "2", "visitors": {"people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
        {"depth_percent": 40, "views": "1", "visitors": {"people": "1", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
        {"depth_percent": 100, "views": "0", "visitors": {"people": "0", "unresolved": "0", "selection_id": "SELECTION_UUID"}}
      ],
      "leaves": {"leaves": "3", "attributed": "3", "unattributed": "0", "invalid": "0"}
    }
  }
}
```

(All ten bands are returned; three are shown.) Both measured views reached 10%, only one reached
40%, none reached 100%, and the third view has no depth at all. The shares are 100%, 50% and 0% of
the two measured views.

### Page the people behind a count

```bash
anectico --project PROJECT_UUID analytics result participants RESULT_UUID \
  --selection-id SELECTION_UUID --limit 50
```

MCP's `list_analytics_participants` and `GET /api/v1/analytics/results/{result_id}/participants`
take the same `selection_id`, `limit` and `cursor`; continue with `next_cursor` and keep the result,
selection and limit unchanged. A selection lists **resolved people only**.

## What heatmaps do not do

- **No screenshot, and nothing loaded from your site.** The measurement reads no page content. The
  optional [page behind the grid](#the-page-behind-the-grid) is a masked structural copy that a
  visitor's browser captured: a wireframe unless you turn images on, off unless you turn it on, and
  never an input value. The cells are shares of the document's width and height either way.
- **No element text or labels.** Only a selector and a tag are known of an element; text, labels and
  attributes are never read.
- **No device, browser or operating-system split.** The only split by device is the viewport
  width class. There is no country, device type or browser dimension.
- **No mouse movement, hover, attention or scroll-position replay.** Heatmaps count clicks, rage
  clicks, page views and page leaves. Where the pointer went between clicks is not recorded.
- **No session-level overlay.** A heatmap aggregates visitors. To watch one visit, use
  [session replay](/docs/investigate/session-replay).
- **Only what was captured.** A visitor who declined product analytics is not measured, an element
  you blocked sends nothing, and clicks outside one of your declared routes are recorded under the
  literal `/[unmapped]`.

To measure which pages visitors come from and leave, see [web analytics](/docs/investigate/web-analytics);
for how real users experience your pages, see [customer experience](/docs/investigate/customer-experience).
