OverviewIntroduction

Heatmaps

Aggregated click positions and scroll depth for any page, drawn on the page as it actually looked — rebuilt from a session recording, not framed from the live site.

Overview

A heatmap is three separate things, and most tools blur them together:

WhatQuestion it answersWhere it comes from
Click mapWhere on the page do people click?GET /client/v1/data/heatmap
Scroll mapHow far down does the audience actually get?GET /client/v1/data/heatmap/scroll
BackgroundWhat did the page look like?GET /client/v1/data/heatmap/snapshot
Page listWhich pages have data at all?GET /client/v1/data/heatmap/pages
Filter valuesWhich devices/sources reached this page?GET /client/v1/data/heatmap/facets

They are separate endpoints because they fail separately. A page with no recording still has click data worth reading, and a page with a background but no traffic should say "no clicks" rather than render an empty overlay and imply nobody was there.

Capture is off by default. Turn it on per client with PUT /client/v1/settings/heatmaps — it takes effect on the browser's next config fetch, with no change to the site's install snippet.

Start from the page list rather than guessing a URL: it is derived from the captured points, so every entry has data and nothing with data is missing. It also reports clicks and scroll_sessions separately, which is how you tell "nobody clicked here" from "this page's visitors haven't left yet".

The two coordinate systems

This is the part that decides whether a heatmap is trustworthy, so it is worth being explicit.

Horizontal is relative. x_rel is a fraction (0..1) of the viewport width the click was captured at. A click in the middle of a 1440px desktop and a click in the middle of a 360px phone are both 0.5 — the same cell, because in a responsive layout they hit the same thing.

Vertical is absolute. y_px is page pixels from the top of the document. It is deliberately not a percentage of page height: page length differs between visitors — lazy-loaded sections, cookie banners, a longer product list — so "60% down the page" is a different place for different people, and averaging it invents a hotspot nobody clicked.

Fixed elements are flagged, not fixed. A click on a sticky header or a floating chat button carries target_fixed: 1 and its y_px is viewport-relative. Draw those where the element sits, not at that page offset. Without the flag, one sticky nav becomes a vertical streak down the entire page — the classic broken heatmap.

Filtering

Every data endpoint takes the same session filters: device_type, utm_source, utm_medium, utm_campaign, country, and segment_id for a stored segment.

These resolve against the same session data the Sessions API reports, so the two always agree — including the referrer fallback, where an untagged but referred session reads as google.com / organic rather than blank. A heatmap filtered to google / organic and a sessions query filtered the same way select the same sessions, because neither re-derives the definition. Segment membership resolves through the identity graph, so a person-level segment catches rows captured under any identity that person used.

Build the controls from /heatmap/facets, not from a hardcoded list. It returns the values that exist for that page with the session count behind each:

{
  "data": {
    "page": "acme.com/pricing",
    "device_type": [{ "value": "mobile", "sessions": 412 }, { "value": "desktop", "sessions": 96 }],
    "utm_source":  [{ "value": "facebook", "sessions": 310 }],
    "utm_medium":  [{ "value": "cpc", "sessions": 310 }]
  }
}

Two rules that save a support ticket each:

  • Scope facets to the page, not the account. Offering desktop when no desktop visitor ever reached that page renders an empty heatmap, and empty-because-filtered is indistinguishable from broken.
  • Fetch facets unfiltered. If the facet call carries the active filters, picking one value empties the other dropdowns and there is no way back except a reload.

An unfiltered heatmap costs nothing extra: the session join is only evaluated when a filter is actually set.

Viewport windows

By default the click map mixes every screen size. That is usually what you want for a first look and wrong for a decision, because a 1200px layout and a 400px layout are different pages that happen to share a URL.

Pass viewport_min / viewport_max to read one breakpoint at a time:

# desktop only
curl "https://mythic-analytics.gulp.workers.dev/client/v1/data/heatmap?url=acme.com/pricing&viewport_min=1200" \
  -H "Authorization: Bearer sk_live_..."

Narrower window → truer positions, less data. Wider → more data, blurrier positions. There is no setting that gives you both.

Scroll depth, precisely

The scroll curve is cumulative from the deepest band upward, so sessions_reached reads "this many sessions saw at least this far down".

Depth is max scroll y + viewport height — a visitor who never scrolled still saw one viewport. That makes the depth_px: 0 band equal to every measured session: it is the denominator, not a data point.

{
  "data": {
    "sessions": 1204,
    "page_height": 5200,
    "bands": [
      { "depth_px": 0,    "sessions_reached": 1204, "reached_pct": 1.0 },
      { "depth_px": 900,  "sessions_reached": 1204, "reached_pct": 1.0 },
      { "depth_px": 2000, "sessions_reached": 602,  "reached_pct": 0.5 },
      { "depth_px": 3300, "sessions_reached": 121,  "reached_pct": 0.1 }
    ]
  }
}

Read that as: half the audience never got past 2000px, and the CTA at 3300px was seen by one visitor in ten.

One measurement is taken per session per page, when the visitor leaves it. A session whose final beacon never landed (browser killed the tab) is simply absent — this is a curve over sessions we measured, not over all pageviews. Compare sessions against your pageview count for that page to see the gap.

Drawing it

The background comes from session replay, not from framing your site. That means no X-Frame-Options or CSP negotiation, pages behind a login work, and — the one that matters most — the DOM is the page as it was when the clicks happened, not as it is today after a redesign.

Candidate recordings are the ones whose landing page matches the URL, so a page visitors only reach mid-funnel returns 404 — pass an explicit session_id from GET /client/v1/data/replays for those.

Pin rrweb-player to exactly 2.0.0-alpha.20. The "stable" 2.0.x/2.1.x ESM builds ship without the replay engine and crash on load (Cannot read properties of undefined (reading 'on')).

Four traps, all of which look like working code

Every one of these produces a page that renders — just wrong. They are worth reading before you write the renderer, because three of them fail silently.

TrapWhat you seeWhy
Sizing the stage from snapshot.heightEvery click below the fold is missingThat height is one viewport, not the document. Use page_height from the scroll endpoint, or the deepest y_px you received
Overriding the player's transform once, on mountBackground aligned sometimes, shifted by half the page other timesrrweb-player re-applies its own centering (top/left: 50% + translate(-50%,-50%)) after mount, on its own schedule. A one-shot inline override is a race
Painting each scroll band as its own stripeThin colour bands separated by unpainted pageThe scroll series is sparse — one row per band where a session's max landed. 4 sessions on a 17,000px page is 4 rows
Leaving the recorded cursor visibleA dot that reads as a data pointThe player draws .replayer-mouse from the recording

The background

Beat the player's layout with a stylesheet, not inline styles — an !important rule outlives every re-layout the player does, so there is no timing to get right:

/* scale and page size arrive as CSS variables, so a resize never re-applies rules */
.hm-stage .rr-player {
  width: 100% !important; height: 100% !important;
  padding: 0 !important; border: 0 !important; border-radius: 0 !important;
  box-shadow: none !important; background: transparent !important;
  overflow: hidden !important; position: relative !important;
}
.hm-stage .replayer-wrapper {
  position: absolute !important; top: 0 !important; left: 0 !important; margin: 0 !important;
  width: var(--hm-w) !important; height: var(--hm-h) !important;   /* the DOCUMENT, not the viewport */
  transform: scale(var(--hm-scale, 1)) !important;
  transform-origin: top left !important;                          /* origin must match the canvas origin */
}
.hm-stage .replayer-wrapper iframe {
  width: var(--hm-w) !important; height: var(--hm-h) !important; border: 0 !important;
}
.hm-stage .rr-controller,
.hm-stage .replayer-mouse,
.hm-stage .replayer-mouse-tail { display: none !important; }
import rrwebPlayer from 'rrweb-player'
import 'rrweb-player/dist/style.css'

const [snap, heat, scroll] = await Promise.all([
  fetch(`${base}/client/v1/data/heatmap/snapshot?url=${page}`, { headers }).then((r) => r.json()),
  fetch(`${base}/client/v1/data/heatmap?url=${page}`, { headers }).then((r) => r.json()),
  fetch(`${base}/client/v1/data/heatmap/scroll?url=${page}`, { headers }).then((r) => r.json()),
])

const captureWidth = snap.data.width          // the x denominator
// The document height. page_height only rides scroll points, so a clicks-only
// view has to fall back to the deepest cell it received.
const pageHeight = Math.max(
  scroll.data.page_height || 0,
  ...heat.data.points.map((c) => c.y_px + 300),
  snap.data.height || 0,
)

// Fit the width, then shrink further if the canvas would exceed the browser's
// maximum area — Safari caps it near 16M px and fails by rendering NOTHING.
let scale = Math.min(1, stage.clientWidth / captureWidth)
const area = captureWidth * pageHeight * scale * scale
if (area > 12e6) scale *= Math.sqrt(12e6 / area)

stage.classList.add('hm-stage')
stage.style.setProperty('--hm-w', `${captureWidth}px`)
stage.style.setProperty('--hm-h', `${pageHeight}px`)
stage.style.setProperty('--hm-scale', String(scale))
stage.style.width = `${captureWidth * scale}px`
stage.style.height = `${pageHeight * scale}px`

new rrwebPlayer({
  target: host,   // a child of stage, behind the canvas
  props: {
    events: snap.data.events, width: captureWidth, height: pageHeight,
    autoPlay: false, showController: false, mouseTail: false, skipInactive: false,
  },
}).pause()

The click map

Density has to accumulate — draw each cell as a radial alpha gradient in lighter composite mode, then colorize the accumulated alpha in one pass. Colouring each blob individually cannot show that two nearby cells overlap, which is the whole point of a heatmap.

const max = Math.max(...cells.map((c) => c.clicks))
ctx.globalCompositeOperation = 'lighter'
for (const cell of cells) {
  const x = cell.x_rel * captureWidth * scale
  const y = cell.y_px * scale        // already page pixels — never scale by page height
  // sqrt keeps one monster hotspot from flattening everything else to invisible
  const weight = Math.sqrt(cell.clicks / max)
  const g = ctx.createRadialGradient(x, y, 0, x, y, 24)
  g.addColorStop(0, `rgba(0,0,0,${0.15 + 0.85 * weight})`)
  g.addColorStop(1, 'rgba(0,0,0,0)')
  ctx.fillStyle = g
  ctx.beginPath(); ctx.arc(x, y, 24, 0, Math.PI * 2); ctx.fill()
}
ctx.globalCompositeOperation = 'source-over'
// then map accumulated alpha -> a sequential ramp (light->dark, one direction;
// a blue-through-red rainbow reads as four categories and its midpoints invert)

Cells with target_fixed: 1 belong wherever the sticky element sits, not at y_px on the page — draw them separately or skip them.

The scroll map

The series is sparse and cumulative, so each row fills from the previous row's depth down to its own, using its own reached_pct. Reach decreases monotonically, so a depth between two rows has the deeper row's value.

const sorted = [...scroll.data.bands].sort((a, b) => a.depth_px - b.depth_px)
let top = 0
for (const b of sorted) {
  const bottom = Math.min(height, b.depth_px * scale)
  if (bottom > top) {
    ctx.fillStyle = rampColor(b.reached_pct)   // 0.45 alpha keeps the page readable
    ctx.fillRect(0, top, width, bottom - top)
  }
  top = Math.max(top, bottom)
}
// Below the deepest band, reach is genuinely ZERO. Leave it unpainted and label
// it — "nobody ever saw this" is the most useful thing a scrollmap says, and
// filling it with the last colour claims someone read to the bottom.

What this is not

  • Not per-visitor. Both endpoints return aggregates. To watch one person, use session replay.
  • Not mouse movement. Clicks and scroll depth only. Move maps are the highest-volume, lowest-signal type and are not captured.
  • Not element-aware. These are pixels, not selectors. For "which button got clicked, by name", query $autocapture events through the Data API — that surface knows element text and href, and survives a layout change that moves the pixel.
  • Not retroactive. Points exist from the moment capture is enabled; there is no backfill of past traffic.

Base URL

https://mythic-analytics.gulp.workers.dev/client/v1/data/heatmap

Authentication

The same key system as the rest of /client/v1: an agency key (ak_, bind a location with ?location_id= or the X-Location-Id header) or a location secret key (sk_, auto-scoped to its own location). All five endpoints are reads and accept either; toggling capture is a Settings write and needs an agency key.