Add scroll heatmaps.

This commit is contained in:
Mike Cao
2026-05-05 10:43:39 -07:00
parent c7f71e5758
commit 1b69ce6e90
13 changed files with 460 additions and 54 deletions
@@ -12,6 +12,7 @@ CREATE TABLE umami.heatmap_event
y Nullable(Int32),
viewport_w Nullable(Int32),
viewport_h Nullable(Int32),
page_h Nullable(Int32),
scroll_pct Nullable(UInt8),
created_at DateTime('UTC')
)
+1
View File
@@ -359,6 +359,7 @@ CREATE TABLE umami.heatmap_event
y Nullable(Int32),
viewport_w Nullable(Int32),
viewport_h Nullable(Int32),
page_h Nullable(Int32),
scroll_pct Nullable(UInt8),
created_at DateTime('UTC')
)
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "heatmap_event" ADD COLUMN "page_h" INTEGER;
+1
View File
@@ -413,6 +413,7 @@ model HeatmapEvent {
y Int? @db.Integer
viewportW Int? @map("viewport_w") @db.Integer
viewportH Int? @map("viewport_h") @db.Integer
pageH Int? @map("page_h") @db.Integer
scrollPct Int? @map("scroll_pct") @db.Integer
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+3
View File
@@ -139,6 +139,9 @@
"greater-than-equals": "Greater than or equals",
"grouped": "Grouped",
"growth": "Growth",
"heatmap": "Heatmap",
"heatmap-description": "Visualize where visitors click and how far they scroll on your pages.",
"heatmaps": "Heatmaps",
"hostname": "Hostname",
"hour": "Hour",
"includes": "Includes",
@@ -40,6 +40,34 @@
background: var(--interactive-bg-hover);
}
.toggleButtonSelected,
.toggleButtonSelected:hover {
background: var(--surface-inverted);
color: var(--surface-base);
border-color: var(--surface-inverted);
}
.scrollBand {
position: absolute;
left: 0;
right: 0;
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 2px 8px;
}
.scrollBandLabel {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.7);
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
}
.canvas {
position: relative;
overflow: hidden;
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useResultQuery, useWebsite } from '@/components/hooks';
import { formatLongNumber } from '@/lib/format';
import type { HeatmapPoint, HeatmapResult } from '@/queries/sql';
import type { HeatmapMode, HeatmapPoint, HeatmapResult } from '@/queries/sql';
import styles from './Heatmap.module.css';
const RENDER_WIDTH = 1024;
@@ -19,25 +19,38 @@ interface HeatmapProps {
websiteId: string;
urlPath: string;
onUrlPathChange: (urlPath: string) => void;
mode: HeatmapMode;
onModeChange: (mode: HeatmapMode) => void;
}
export function Heatmap({ websiteId, urlPath, onUrlPathChange }: HeatmapProps) {
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChange }: HeatmapProps) {
const website = useWebsite();
const { data, error, isLoading } = useResultQuery<HeatmapResult>('heatmap', {
websiteId,
urlPath: urlPath || undefined,
mode,
});
const pages = data?.pages ?? [];
const points = data?.points ?? [];
const scroll = data?.scroll;
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
<Grid columns="320px 1fr" gap height="100%">
<PageList pages={pages} selected={urlPath} onSelect={onUrlPathChange} />
<PageList pages={pages} selected={urlPath} onSelect={onUrlPathChange} mode={mode} />
<Column gap>
<ModeToggle mode={mode} onChange={onModeChange} />
{urlPath ? (
<HeatmapView domain={website?.domain ?? null} urlPath={urlPath} points={points} />
mode === 'scroll' ? (
<ScrollHeatmapView
domain={website?.domain ?? null}
urlPath={urlPath}
scroll={scroll}
/>
) : (
<HeatmapView domain={website?.domain ?? null} urlPath={urlPath} points={points} />
)
) : (
<EmptyState />
)}
@@ -47,14 +60,43 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange }: HeatmapProps) {
);
}
function ModeToggle({
mode,
onChange,
}: {
mode: HeatmapMode;
onChange: (mode: HeatmapMode) => void;
}) {
return (
<Row gap="2">
<button
type="button"
onClick={() => onChange('click')}
className={`${styles.toggleButton} ${mode === 'click' ? styles.toggleButtonSelected : ''}`}
>
Clicks
</button>
<button
type="button"
onClick={() => onChange('scroll')}
className={`${styles.toggleButton} ${mode === 'scroll' ? styles.toggleButtonSelected : ''}`}
>
Scroll
</button>
</Row>
);
}
function PageList({
pages,
selected,
onSelect,
mode,
}: {
pages: HeatmapResult['pages'];
selected: string;
onSelect: (urlPath: string) => void;
mode: HeatmapMode;
}) {
return (
<Column className={styles.pageList} gap="2">
@@ -69,7 +111,7 @@ function PageList({
>
<Row alignItems="center" justifyContent="space-between" gap="2">
<Text truncate>{p.urlPath}</Text>
<Text color="muted">{formatLongNumber(p.count)}</Text>
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
</Row>
</button>
))}
@@ -144,10 +186,7 @@ function HeatmapView({
</button>
)}
</Row>
<div
className={styles.canvas}
style={{ width: RENDER_WIDTH, height: renderHeight }}
>
<div className={styles.canvas} style={{ width: RENDER_WIDTH, height: renderHeight }}>
{showPage && iframeSrc && (
<iframe
className={styles.iframe}
@@ -184,11 +223,100 @@ function HeatmapView({
);
}
function EmptyState() {
function ScrollHeatmapView({
domain,
urlPath,
scroll,
}: {
domain: string | null;
urlPath: string;
scroll: HeatmapResult['scroll'] | undefined;
}) {
const [showPage, setShowPage] = useState(true);
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
return <EmptyState message="No scroll data for this page yet." />;
}
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
const scale = RENDER_WIDTH / viewportW;
const renderHeight = Math.round(pageH * scale);
const iframeSrc = domain ? `https://${domain}${urlPath}` : null;
// Cumulative reach: % of sessions that scrolled at least to depth D.
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
let remaining = totalSessions;
const cumulative = sortedBuckets.map(b => {
const reached = remaining;
remaining -= b.sessions;
return { depth: b.depth, reached, ratio: reached / totalSessions };
});
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">
{formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH}
{viewportH ? ` · viewport ${viewportH}` : ''}
</Text>
{iframeSrc && (
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowPage(v => !v)}
>
{showPage ? 'Hide page' : 'Show page'}
</button>
)}
</Row>
<div className={styles.canvas} style={{ width: RENDER_WIDTH, height: renderHeight }}>
{showPage && iframeSrc && (
<iframe
className={styles.iframe}
src={iframeSrc}
width={viewportW}
height={pageH}
style={{ transform: `scale(${scale})` }}
sandbox="allow-same-origin"
referrerPolicy="no-referrer"
/>
)}
<div className={styles.overlay}>
{cumulative.map((b, i) => {
const top = Math.round((b.depth / 100) * renderHeight);
const next = cumulative[i + 1];
const bottom = next ? Math.round((next.depth / 100) * renderHeight) : renderHeight;
const height = Math.max(0, bottom - top);
const intensity = b.ratio;
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
return (
<div
key={b.depth}
className={styles.scrollBand}
style={{
top,
height,
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
}}
title={`${b.depth}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
>
<span className={styles.scrollBandLabel}>
{b.depth}% · {Math.round(intensity * 100)}%
</span>
</div>
);
})}
</div>
</div>
</Column>
);
}
function EmptyState({ message }: { message?: string } = {}) {
return (
<Column alignItems="center" justifyContent="center" height="100%" gap>
<Heading size="lg">Select a page</Heading>
<Text color="muted">Choose a page from the list to view its click heatmap.</Text>
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
</Column>
);
}
@@ -3,16 +3,24 @@ import { Column } from '@umami/react-zen';
import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import type { HeatmapMode } from '@/queries/sql';
import { Heatmap } from './Heatmap';
export function HeatmapsPage({ websiteId }: { websiteId: string }) {
const [urlPath, setUrlPath] = useState<string>('');
const [mode, setMode] = useState<HeatmapMode>('click');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Panel height="900px" allowFullscreen>
<Heatmap websiteId={websiteId} urlPath={urlPath} onUrlPathChange={setUrlPath} />
<Heatmap
websiteId={websiteId}
urlPath={urlPath}
onUrlPathChange={setUrlPath}
mode={mode}
onModeChange={setMode}
/>
</Panel>
</Column>
);
+7 -7
View File
@@ -160,13 +160,12 @@ export const operatorParam = z.enum([
export const goalReportSchema = z.object({
type: z.literal('goal'),
parameters: z
.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
type: z.string(),
value: z.string(),
}),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
type: z.string(),
value: z.string(),
}),
});
export const funnelReportSchema = z.object({
@@ -275,6 +274,7 @@ export const heatmapReportSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
urlPath: z.string().max(500).optional(),
mode: z.enum(['click', 'scroll']).optional(),
}),
});
@@ -2,6 +2,7 @@ import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
const RRWEB_TYPE_INCREMENTAL = 3;
const RRWEB_TYPE_META = 4;
const RRWEB_TYPE_CUSTOM = 5;
const RRWEB_SOURCE_MOUSE_INTERACTION = 2;
const RRWEB_SOURCE_VIEWPORT_RESIZE = 4;
const RRWEB_MOUSE_CLICK = 2;
@@ -13,6 +14,7 @@ export interface ExtractedHeatmapEvent {
y: number | null;
viewportW: number | null;
viewportH: number | null;
pageH: number | null;
scrollPct: number | null;
urlPath: string;
createdAt: Date;
@@ -46,6 +48,36 @@ export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
continue;
}
if (ev.type === RRWEB_TYPE_CUSTOM && ev.data) {
if (ev.data.tag === 'url-change') {
const path = safePathname(ev.data.payload?.url);
if (path) urlPath = path;
continue;
}
if (ev.data.tag === 'scroll-progress' && ev.data.payload) {
const p = ev.data.payload;
const path = safePathname(p.url) ?? urlPath;
if (path === null) continue;
out.push({
eventType: HEATMAP_EVENT_TYPE.scroll,
nodeId: null,
x: null,
y: null,
viewportW: typeof p.viewportW === 'number' ? p.viewportW : viewportW,
viewportH: typeof p.viewportH === 'number' ? p.viewportH : viewportH,
pageH: typeof p.pageH === 'number' ? p.pageH : null,
scrollPct:
typeof p.scrollPct === 'number'
? Math.max(0, Math.min(100, Math.round(p.scrollPct)))
: null,
urlPath: path,
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
});
}
continue;
}
if (ev.type !== RRWEB_TYPE_INCREMENTAL || !ev.data) continue;
const d = ev.data;
@@ -68,6 +100,7 @@ export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
y: typeof d.y === 'number' ? Math.round(d.y) : null,
viewportW,
viewportH,
pageH: null,
scrollPct: null,
urlPath,
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
+163 -32
View File
@@ -7,11 +7,15 @@ const FUNCTION_NAME = 'getHeatmap';
const POINT_LIMIT = 5000;
const PAGE_LIMIT = 100;
const SCROLL_BUCKET_SIZE = 5;
export type HeatmapMode = 'click' | 'scroll';
export interface HeatmapParameters {
startDate: Date;
endDate: Date;
urlPath?: string;
mode?: HeatmapMode;
}
export interface HeatmapPage {
@@ -29,9 +33,22 @@ export interface HeatmapPoint {
count: number;
}
export interface HeatmapScrollBucket {
depth: number;
sessions: number;
}
export interface HeatmapResult {
mode: HeatmapMode;
pages: HeatmapPage[];
points: HeatmapPoint[];
scroll: {
buckets: HeatmapScrollBucket[];
totalSessions: number;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
};
}
export async function getHeatmap(
@@ -44,11 +61,20 @@ export async function getHeatmap(
});
}
const emptyScroll = (): HeatmapResult['scroll'] => ({
buckets: [],
totalSessions: 0,
pageH: null,
viewportW: null,
viewportH: null,
});
async function relationalQuery(
websiteId: string,
{ startDate, endDate, urlPath }: HeatmapParameters,
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = prisma;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const pages: HeatmapPage[] = await rawQuery(
`
@@ -64,17 +90,73 @@ async function relationalQuery(
order by count desc
limit ${PAGE_LIMIT}
`,
{
websiteId,
eventType: HEATMAP_EVENT_TYPE.click,
startDate,
endDate,
},
{ websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
if (!urlPath) {
return { pages, points: [] };
return { mode, pages, points: [], scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows: { depth: number | string; sessions: number | string }[] = await rawQuery(
`
select
(floor(max_pct / ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE})::int as depth,
count(*)::int as sessions
from (
select visit_id, max(scroll_pct) as max_pct
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and url_path = {{urlPath}}
and created_at between {{startDate}} and {{endDate}}
and scroll_pct is not null
group by visit_id
) per_session
group by depth
order by depth
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows: {
totalSessions: number | string;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[] = await rawQuery(
`
select
count(distinct visit_id)::int as "totalSessions",
(mode() within group (order by page_h))::int as "pageH",
(mode() within group (order by viewport_w))::int as "viewportW",
(mode() within group (order by viewport_h))::int as "viewportH"
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and url_path = {{urlPath}}
and created_at between {{startDate}} and {{endDate}}
and scroll_pct is not null
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
return {
mode,
pages,
points: [],
scroll: {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageH: dim?.pageH ?? null,
viewportW: dim?.viewportW ?? null,
viewportH: dim?.viewportH ?? null,
},
};
}
const rawPoints: HeatmapPoint[] = await rawQuery(
@@ -99,24 +181,19 @@ async function relationalQuery(
order by count desc
limit ${POINT_LIMIT}
`,
{
websiteId,
eventType: HEATMAP_EVENT_TYPE.click,
urlPath,
startDate,
endDate,
},
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
return { pages, points: rawPoints };
return { mode, pages, points: rawPoints, scroll: emptyScroll() };
}
async function clickhouseQuery(
websiteId: string,
{ startDate, endDate, urlPath }: HeatmapParameters,
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = clickhouse;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const pageRows = await rawQuery<
{ urlPath: string; count: string | number; sessions: string | number }[]
@@ -134,12 +211,7 @@ async function clickhouseQuery(
order by count desc
limit ${PAGE_LIMIT}
`,
{
websiteId,
eventType: HEATMAP_EVENT_TYPE.click,
startDate,
endDate,
},
{ websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
@@ -150,7 +222,72 @@ async function clickhouseQuery(
}));
if (!urlPath) {
return { pages, points: [] };
return { mode, pages, points: [], scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows = await rawQuery<{ depth: number | string; sessions: number | string }[]>(
`
select
intDiv(max_pct, ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE} as depth,
count() as sessions
from (
select visit_id, max(scroll_pct) as max_pct
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and url_path = {urlPath:String}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and scroll_pct is not null
group by visit_id
)
group by depth
order by depth
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows = await rawQuery<
{
totalSessions: number | string;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[]
>(
`
select
uniq(visit_id) as totalSessions,
toInt32OrNull(toString(arrayElement(topK(1)(page_h), 1))) as pageH,
toInt32OrNull(toString(arrayElement(topK(1)(viewport_w), 1))) as viewportW,
toInt32OrNull(toString(arrayElement(topK(1)(viewport_h), 1))) as viewportH
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and url_path = {urlPath:String}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and scroll_pct is not null
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
return {
mode,
pages,
points: [],
scroll: {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageH: dim?.pageH === null || dim?.pageH === undefined ? null : Number(dim.pageH),
viewportW:
dim?.viewportW === null || dim?.viewportW === undefined ? null : Number(dim.viewportW),
viewportH:
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
},
};
}
const pointRows = await rawQuery<
@@ -184,13 +321,7 @@ async function clickhouseQuery(
order by count desc
limit ${POINT_LIMIT}
`,
{
websiteId,
eventType: HEATMAP_EVENT_TYPE.click,
urlPath,
startDate,
endDate,
},
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
@@ -203,5 +334,5 @@ async function clickhouseQuery(
count: Number(p.count),
}));
return { pages, points };
return { mode, pages, points, scroll: emptyScroll() };
}
@@ -15,6 +15,7 @@ export interface HeatmapEventRow {
y: number | null;
viewportW: number | null;
viewportH: number | null;
pageH: number | null;
scrollPct: number | null;
createdAt: Date;
}
@@ -42,6 +43,7 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
y: r.y,
viewportW: r.viewportW,
viewportH: r.viewportH,
pageH: r.pageH,
scrollPct: r.scrollPct,
createdAt: r.createdAt,
})),
@@ -64,6 +66,7 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
y: r.y,
viewport_w: r.viewportW,
viewport_h: r.viewportH,
page_h: r.pageH,
scroll_pct: r.scrollPct,
created_at: getUTCString(r.createdAt),
}));
+69 -2
View File
@@ -149,11 +149,78 @@ import { record } from 'rrweb';
...(blockSelector && { blockSelector }),
});
let scrollUrl = location.href;
let maxScrollPct = 0;
let scrollTimer = null;
const computeScrollPct = () => {
const pageH = document.documentElement.scrollHeight;
const visible = window.scrollY + window.innerHeight;
return {
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
pageH,
};
};
const flushScroll = () => {
if (maxScrollPct <= 0) return;
record.addCustomEvent('scroll-progress', {
url: scrollUrl,
scrollPct: maxScrollPct,
viewportW: window.innerWidth,
viewportH: window.innerHeight,
pageH: document.documentElement.scrollHeight,
});
maxScrollPct = 0;
};
const onScroll = () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
const { pct } = computeScrollPct();
if (pct > maxScrollPct) maxScrollPct = pct;
scrollTimer = null;
}, 200);
};
const onUrlChange = () => {
if (location.href === scrollUrl) return;
flushScroll();
scrollUrl = location.href;
record.addCustomEvent('url-change', { url: scrollUrl });
};
const hookHistory = method => {
const orig = history[method];
history[method] = function (...args) {
const result = orig.apply(this, args);
onUrlChange();
return result;
};
};
hookHistory('pushState');
hookHistory('replaceState');
window.addEventListener('popstate', onUrlChange);
window.addEventListener('scroll', onScroll, { passive: true });
// Capture initial scroll position
{
const { pct } = computeScrollPct();
if (pct > maxScrollPct) maxScrollPct = pct;
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush(true);
if (document.visibilityState === 'hidden') {
flushScroll();
flush(true);
}
});
window.addEventListener('beforeunload', () => flush(true));
window.addEventListener('beforeunload', () => {
flushScroll();
flush(true);
});
};
if (document.readyState === 'complete') {