Group heatmap overlays by screen width

This commit is contained in:
Mike Cao
2026-06-22 17:26:51 -07:00
parent cced845be1
commit 5ebb49a8f1
4 changed files with 345 additions and 186 deletions
@@ -131,7 +131,7 @@
max-width: 100%;
max-height: 75vh;
overflow-x: hidden;
overflow-y: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
@@ -186,6 +186,7 @@
height: 100%;
border: 0;
background: var(--surface-base);
overflow: hidden;
pointer-events: none;
}
@@ -19,9 +19,7 @@ import { formatLongNumber } from '@/lib/format';
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
import styles from './Heatmap.module.css';
const CLICK_EDGE_PERCENT = 1.5;
const SCROLL_BUCKET_SIZE = 10;
const CANVAS_MAX_HEIGHT_RATIO = 0.75;
const SCREEN_WIDTH_BUCKETS = [320, 375, 425, 768, 1024, 1440, 1920] as const;
interface ScreenWidthBucket {
@@ -30,11 +28,23 @@ interface ScreenWidthBucket {
pageW: number;
pageH: number;
positions: number;
clicks: number;
count: number;
minViewportW: number;
maxViewportW: number;
}
interface ScreenWidthMetric {
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
count: number;
}
interface ScreenWidthBucketOptions {
pageSize?: 'max' | 'weightedAverage';
}
interface HeatmapProps {
websiteId: string;
urlPath: string;
@@ -202,34 +212,42 @@ function getScreenWidthBucketWidth(viewportW: number) {
}, SCREEN_WIDTH_BUCKETS[0]);
}
function getScreenWidthBuckets(points: HeatmapPoint[]): ScreenWidthBucket[] {
if (!points.length) {
function getScreenWidthBuckets(
metrics: ScreenWidthMetric[],
options: ScreenWidthBucketOptions = {},
): ScreenWidthBucket[] {
if (!metrics.length) {
return [];
}
const buckets = new Map<
number,
ScreenWidthBucket & {
weightedPageW: number;
weightedPageH: number;
weightedViewportH: number;
}
>();
const pageSize = options.pageSize ?? 'max';
for (const point of points) {
const width = getScreenWidthBucketWidth(point.viewportW);
const scale = width / Math.max(1, point.viewportW);
const scaledViewportH = point.viewportH * scale;
const scaledPageW = Math.max(width, point.pageW * scale);
const scaledPageH = Math.max(scaledViewportH, point.pageH * scale);
for (const metric of metrics) {
const width = getScreenWidthBucketWidth(metric.viewportW);
const scale = width / Math.max(1, metric.viewportW);
const scaledViewportH = metric.viewportH * scale;
const scaledPageW = Math.max(width, metric.pageW * scale);
const scaledPageH = Math.max(scaledViewportH, metric.pageH * scale);
const existing = buckets.get(width);
if (existing) {
existing.positions += 1;
existing.clicks += point.count;
existing.count += metric.count;
existing.pageW = Math.max(existing.pageW, scaledPageW);
existing.pageH = Math.max(existing.pageH, scaledPageH);
existing.weightedViewportH += scaledViewportH * point.count;
existing.minViewportW = Math.min(existing.minViewportW, point.viewportW);
existing.maxViewportW = Math.max(existing.maxViewportW, point.viewportW);
existing.weightedPageW += scaledPageW * metric.count;
existing.weightedPageH += scaledPageH * metric.count;
existing.weightedViewportH += scaledViewportH * metric.count;
existing.minViewportW = Math.min(existing.minViewportW, metric.viewportW);
existing.maxViewportW = Math.max(existing.maxViewportW, metric.viewportW);
continue;
}
@@ -239,28 +257,46 @@ function getScreenWidthBuckets(points: HeatmapPoint[]): ScreenWidthBucket[] {
pageW: scaledPageW,
pageH: scaledPageH,
positions: 1,
clicks: point.count,
minViewportW: point.viewportW,
maxViewportW: point.viewportW,
weightedViewportH: scaledViewportH * point.count,
count: metric.count,
minViewportW: metric.viewportW,
maxViewportW: metric.viewportW,
weightedPageW: scaledPageW * metric.count,
weightedPageH: scaledPageH * metric.count,
weightedViewportH: scaledViewportH * metric.count,
});
}
return SCREEN_WIDTH_BUCKETS.map(width => buckets.get(width))
.filter((bucket): bucket is ScreenWidthBucket & { weightedViewportH: number } =>
Boolean(bucket),
.filter(
(
bucket,
): bucket is ScreenWidthBucket & {
weightedPageW: number;
weightedPageH: number;
weightedViewportH: number;
} => Boolean(bucket),
)
.map(({ weightedViewportH, ...bucket }) => ({
.map(({ weightedPageW, weightedPageH, weightedViewportH, ...bucket }) => ({
...bucket,
viewportH: Math.max(1, Math.round(weightedViewportH / Math.max(1, bucket.clicks))),
pageW: Math.max(bucket.width, Math.round(bucket.pageW)),
pageH: Math.max(640, Math.round(bucket.pageH)),
viewportH: Math.max(1, Math.round(weightedViewportH / Math.max(1, bucket.count))),
pageW: Math.max(
bucket.width,
Math.round(
pageSize === 'weightedAverage' ? weightedPageW / Math.max(1, bucket.count) : bucket.pageW,
),
),
pageH: Math.max(
640,
Math.round(
pageSize === 'weightedAverage' ? weightedPageH / Math.max(1, bucket.count) : bucket.pageH,
),
),
}));
}
function getDefaultScreenWidthBucket(buckets: ScreenWidthBucket[]) {
return buckets.reduce<ScreenWidthBucket | null>(
(best, bucket) => (!best || bucket.clicks > best.clicks ? bucket : best),
(best, bucket) => (!best || bucket.count > best.count ? bucket : best),
null,
);
}
@@ -315,19 +351,78 @@ function getNormalizedBucketPoints(points: HeatmapPoint[], bucket: ScreenWidthBu
return Array.from(groupedPoints.values());
}
function useSelectedScreenWidthBucket(screenWidthBuckets: ScreenWidthBucket[]) {
const [selectedScreenWidth, setSelectedScreenWidth] = useState<number | null>(null);
const defaultScreenWidth = useMemo(
() => getDefaultScreenWidthBucket(screenWidthBuckets)?.width ?? null,
[screenWidthBuckets],
);
useEffect(() => {
const availableWidths = new Set(screenWidthBuckets.map(bucket => bucket.width));
setSelectedScreenWidth(current => {
if (!defaultScreenWidth) {
return null;
}
return current && availableWidths.has(current) ? current : defaultScreenWidth;
});
}, [defaultScreenWidth, screenWidthBuckets]);
const viewport = useMemo(() => {
const activeWidth = selectedScreenWidth ?? defaultScreenWidth;
return screenWidthBuckets.find(bucket => bucket.width === activeWidth) ?? null;
}, [defaultScreenWidth, screenWidthBuckets, selectedScreenWidth]);
return { viewport, setSelectedScreenWidth };
}
function getScrollScreenWidthMetrics(scroll: HeatmapResult['scroll'] | undefined) {
return (
scroll?.buckets.map(bucket => ({
pageW: bucket.pageW,
pageH: bucket.pageH,
viewportW: bucket.viewportW,
viewportH: bucket.viewportH,
count: bucket.sessions,
})) ?? []
);
}
function getSelectedScrollBuckets(
scroll: HeatmapResult['scroll'] | undefined,
bucket: ScreenWidthBucket | null,
) {
if (!scroll || !bucket) {
return [];
}
const sessionsByDepth = new Map<number, number>();
for (const row of scroll.buckets) {
if (getScreenWidthBucketWidth(row.viewportW) !== bucket.width) {
continue;
}
sessionsByDepth.set(row.depth, (sessionsByDepth.get(row.depth) ?? 0) + row.sessions);
}
return Array.from(sessionsByDepth.entries())
.map(([depth, sessions]) => ({ depth, sessions }))
.sort((a, b) => a.depth - b.depth);
}
function useCanvasFit(renderWidth: number, renderHeight: number) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [available, setAvailable] = useState({ width: 0, height: 0 });
const [availableWidth, setAvailableWidth] = useState(0);
useEffect(() => {
const updateAvailableSize = () => {
const rect = wrapperRef.current?.getBoundingClientRect();
const width = rect?.width ?? 0;
const height = window.innerHeight * CANVAS_MAX_HEIGHT_RATIO;
const width = wrapperRef.current?.clientWidth ?? 0;
setAvailable(current =>
current.width === width && current.height === height ? current : { width, height },
);
setAvailableWidth(current => (current === width ? current : width));
};
updateAvailableSize();
@@ -349,10 +444,7 @@ function useCanvasFit(renderWidth: number, renderHeight: number) {
const safeWidth = Math.max(1, renderWidth);
const safeHeight = Math.max(1, renderHeight);
const scale =
available.width && available.height
? Math.min(1, available.width / safeWidth, available.height / safeHeight)
: 1;
const scale = availableWidth ? Math.min(1, availableWidth / safeWidth) : 1;
return {
wrapperRef,
@@ -412,7 +504,7 @@ function ScreenWidthSelect({
<ScreenWidthValue width={width} />
{bucket && (
<Text color="muted" className={styles.screenWidthCount}>
{formatLongNumber(bucket.clicks)}
{formatLongNumber(bucket.count)}
</Text>
)}
</Row>
@@ -456,30 +548,8 @@ function ClickHeatmapView({
isLoading: boolean;
}) {
const [snapshotReady, setSnapshotReady] = useState(false);
const [selectedScreenWidth, setSelectedScreenWidth] = useState<number | null>(null);
const screenWidthBuckets = useMemo(() => getScreenWidthBuckets(points), [points]);
const defaultScreenWidth = useMemo(
() => getDefaultScreenWidthBucket(screenWidthBuckets)?.width ?? null,
[screenWidthBuckets],
);
useEffect(() => {
const availableWidths = new Set(screenWidthBuckets.map(bucket => bucket.width));
setSelectedScreenWidth(current => {
if (!defaultScreenWidth) {
return null;
}
return current && availableWidths.has(current) ? current : defaultScreenWidth;
});
}, [defaultScreenWidth, screenWidthBuckets]);
const viewport = useMemo(() => {
const activeWidth = selectedScreenWidth ?? defaultScreenWidth;
return screenWidthBuckets.find(bucket => bucket.width === activeWidth) ?? null;
}, [defaultScreenWidth, screenWidthBuckets, selectedScreenWidth]);
const { viewport, setSelectedScreenWidth } = useSelectedScreenWidthBucket(screenWidthBuckets);
const visible = useMemo(() => {
if (!viewport) {
@@ -505,14 +575,14 @@ function ClickHeatmapView({
const maxPointY = visible.reduce((max, point) => Math.max(max, point.pageY), 0);
const baseWidth = Math.max(viewport?.pageW ?? 0, maxPointX + overlayGutter, 1);
const baseHeight = Math.max(viewport?.pageH ?? 0, maxPointY + overlayGutter, 640);
const renderWidth = viewport?.pageW ?? snapshot?.pageW ?? baseWidth;
const renderHeight = viewport?.pageH ?? snapshot?.pageH ?? baseHeight;
const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX);
const renderWidth = viewport?.width ?? snapshot?.viewportW ?? baseWidth;
const renderHeight = Math.max(viewport?.pageH ?? 0, snapshot?.pageH ?? 0, baseHeight);
const hasMeasuredWidth = Boolean(viewport?.width || snapshot?.viewportW || maxPointX);
const fit = useCanvasFit(renderWidth, renderHeight);
const canvasWidth = hasMeasuredWidth ? `${fit.width}px` : '100%';
const canvasHeight = hasMeasuredWidth ? `${fit.height}px` : undefined;
const overlayPageW = viewport?.pageW ?? snapshot?.pageW ?? baseWidth;
const overlayPageH = viewport?.pageH ?? snapshot?.pageH ?? baseHeight;
const overlayPageW = renderWidth;
const overlayPageH = renderHeight;
const shouldRenderSnapshot = renderWidth > 0 && hasSnapshot;
const showOverlay = !shouldRenderSnapshot || snapshotReady;
const totalClicks = visible.reduce((sum, point) => sum + point.count, 0);
@@ -591,25 +661,17 @@ function ClickHeatmapView({
{visible.map((point, index) => {
const intensity = Math.min(1, point.count / maxCount);
const desiredSize = 24 + intensity * 36;
const rawCenterX = (point.pageX / Math.max(1, overlayPageW)) * 100;
const rawCenterY = (point.pageY / Math.max(1, overlayPageH)) * 100;
const size = desiredSize;
const centerX = Math.max(
CLICK_EDGE_PERCENT,
Math.min(100 - CLICK_EDGE_PERCENT, rawCenterX),
);
const centerY = Math.max(
CLICK_EDGE_PERCENT,
Math.min(100 - CLICK_EDGE_PERCENT, rawCenterY),
);
const centerX = Math.max(0, Math.min(overlayPageW, point.pageX));
const centerY = Math.max(0, Math.min(overlayPageH, point.pageY));
return (
<div
key={`${point.pageX}-${point.pageY}-${index}`}
className={styles.dot}
style={{
left: `${centerX}%`,
top: `${centerY}%`,
left: centerX,
top: centerY,
width: size,
height: size,
transform: 'translate(-50%, -50%)',
@@ -647,30 +709,44 @@ function ScrollHeatmapView({
useEffect(() => {
setSnapshotReady(!hasSnapshot);
}, [hasSnapshot, snapshot?.id]);
const {
buckets = [],
totalSessions = 0,
pageW = 0,
pageH = 0,
viewportW = 0,
viewportH = 0,
} = scroll ?? {};
const scrollMetrics = useMemo(() => getScrollScreenWidthMetrics(scroll), [scroll]);
const screenWidthBuckets = useMemo(
() => getScreenWidthBuckets(scrollMetrics, { pageSize: 'weightedAverage' }),
[scrollMetrics],
);
const { viewport, setSelectedScreenWidth } = useSelectedScreenWidthBucket(screenWidthBuckets);
const selectedBuckets = useMemo(
() => getSelectedScrollBuckets(scroll, viewport),
[scroll, viewport],
);
const totalSessions = viewport?.count ?? 0;
const pageW = viewport?.pageW ?? scroll?.pageW ?? 0;
const pageH = viewport?.pageH ?? scroll?.pageH ?? 0;
const viewportW = viewport?.width ?? scroll?.viewportW ?? 0;
const viewportH = viewport?.viewportH ?? scroll?.viewportH ?? 0;
const baseWidth = Math.max(pageW, 1);
const baseHeight = Math.max(pageH, 640);
const renderWidth = snapshot?.pageW ?? baseWidth;
const renderHeight = snapshot?.pageH ?? baseHeight;
const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW);
const renderWidth = viewport?.width ?? snapshot?.viewportW ?? viewportW ?? baseWidth;
const renderHeight = baseHeight;
const hasMeasuredWidth = Boolean(viewport?.width || snapshot?.viewportW || viewportW || pageW);
const fit = useCanvasFit(renderWidth, renderHeight);
const canvasWidth = hasMeasuredWidth ? `${fit.width}px` : '100%';
const canvasHeight = hasMeasuredWidth ? `${fit.height}px` : undefined;
const shouldRenderSnapshot = renderWidth > 0 && hasSnapshot;
const showOverlay = !shouldRenderSnapshot || snapshotReady;
const hasScrollData = Boolean(scroll && totalSessions > 0 && pageW && pageH && viewportW);
const hasScrollData = Boolean(
viewport && selectedBuckets.length > 0 && totalSessions > 0 && pageW && pageH && viewportW,
);
const showLoading = isLoading;
const bucketDescription = viewport
? viewport.minViewportW === viewport.maxViewportW
? `Recorded at ${viewport.minViewportW}px wide`
: `Grouped recorded widths from ${viewport.minViewportW}px to ${viewport.maxViewportW}px`
: undefined;
type Band = { fromPct: number; toPct: number; reached: number; ratio: number };
const bands: Band[] = [];
const sessionsByDepth = new Map(buckets.map(bucket => [bucket.depth, bucket.sessions]));
const sessionsByDepth = new Map(selectedBuckets.map(bucket => [bucket.depth, bucket.sessions]));
let dropped = 0;
for (let depth = 0; depth < 100; depth += SCROLL_BUCKET_SIZE) {
@@ -707,11 +783,16 @@ function ScrollHeatmapView({
gap
className={styles.summaryHeader}
>
<Text color="muted" className={styles.summaryStat}>
<Text color="muted" className={styles.summaryStat} title={bucketDescription}>
{hasScrollData
? `${formatLongNumber(totalSessions)} sessions - page ${pageW}x${pageH}${viewportH ? ` - viewport ${viewportW}x${viewportH}` : ''}`
? `${formatLongNumber(totalSessions)} sessions - page ${pageW}x${pageH}${viewportH ? ` - viewport ${viewportW}x${viewportH}` : ''} - screen width ${viewport.width}px`
: 'No scroll data for this page yet.'}
</Text>
<ScreenWidthSelect
buckets={screenWidthBuckets}
value={viewport?.width ?? null}
onChange={setSelectedScreenWidth}
/>
</Row>
)}
@@ -844,6 +925,7 @@ function SnapshotImage({
}, [imageUrl, onReady, snapshot.id]);
const handleLoad = useCallback(() => onReady(), [onReady]);
const imageWidth = Math.max(snapshot.pageW, snapshot.viewportW);
return (
<div className={styles.snapshot}>
@@ -853,6 +935,10 @@ function SnapshotImage({
alt=""
draggable={false}
onLoad={handleLoad}
style={{
width: imageWidth,
height: snapshot.pageH,
}}
/>
</div>
);
@@ -894,6 +980,7 @@ function IframeSnapshot({
title={iframeUrl}
tabIndex={-1}
loading="lazy"
scrolling="no"
referrerPolicy="no-referrer"
onLoad={handleLoad}
onError={handleError}
@@ -698,16 +698,16 @@ export async function ensureHeatmapSnapshot({
return null;
}
if (!process.env.PLAYWRIGHT_URL?.trim()) {
return null;
}
const existing = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
if (existing?.status === SNAPSHOT_STATUS.ready && existing.hasImage) {
return mapSnapshot(websiteId, existing);
}
if (!process.env.PLAYWRIGHT_URL?.trim()) {
return null;
}
const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null;
const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY;
+159 -88
View File
@@ -47,6 +47,10 @@ export interface HeatmapPoint {
export interface HeatmapScrollBucket {
depth: number;
sessions: number;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
}
export interface HeatmapSnapshotIframe {
@@ -117,6 +121,7 @@ async function relationalQuery(
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`;
const rawPages: HeatmapPage[] = await rawQuery(
@@ -146,13 +151,30 @@ async function relationalQuery(
}
if (mode === 'scroll') {
const bucketRows: { depth: number | string; sessions: number | string }[] = await rawQuery(
const bucketRows: {
depth: number | string;
sessions: number | string;
pageW: number | string;
pageH: number | string;
viewportW: number | string;
viewportH: number | string;
}[] = await rawQuery(
`
select
(floor(max_pct / ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE})::int as depth,
count(*)::int as sessions
count(*)::int as sessions,
page_w::int as "pageW",
page_h::int as "pageH",
viewport_w::int as "viewportW",
viewport_h::int as "viewportH"
from (
select h.visit_id, max(h.scroll_pct) as max_pct
select
h.visit_id,
max(h.scroll_pct) as max_pct,
(mode() within group (order by h.page_w))::int as page_w,
(mode() within group (order by h.page_h))::int as page_h,
(mode() within group (order by h.viewport_w))::int as viewport_w,
(mode() within group (order by h.viewport_h))::int as viewport_h
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {{websiteId::uuid}}
@@ -161,58 +183,43 @@ async function relationalQuery(
and h.created_at between {{startDate}} and {{endDate}}
${filterContext.filterQuery}
and h.scroll_pct is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by h.visit_id
) per_session
group by depth
group by depth, page_w, page_h, viewport_w, viewport_h
order by depth
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows: {
totalSessions: number | string;
pageW: number | null;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[] = await rawQuery(
`
select
count(distinct h.visit_id)::int as "totalSessions",
(mode() within group (order by h.page_w))::int as "pageW",
(mode() within group (order by h.page_h))::int as "pageH",
(mode() within group (order by h.viewport_w))::int as "viewportW",
(mode() within group (order by h.viewport_h))::int as "viewportH"
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {{websiteId::uuid}}
and h.event_type = {{eventType}}
and h.url_path = {{urlPath}}
and h.created_at between {{startDate}} and {{endDate}}
${filterContext.filterQuery}
and h.scroll_pct is not null
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
const scrollBuckets = bucketRows.map(r => ({
depth: Number(r.depth),
sessions: Number(r.sessions),
pageW: Number(r.pageW),
pageH: Number(r.pageH),
viewportW: Number(r.viewportW),
viewportH: Number(r.viewportH),
}));
const viewport = pickScrollSnapshotViewport(scrollBuckets);
const scroll = {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageW: dim?.pageW ?? null,
pageH: dim?.pageH ?? null,
viewportW: dim?.viewportW ?? null,
viewportH: dim?.viewportH ?? null,
buckets: scrollBuckets,
totalSessions: scrollBuckets.reduce((sum, bucket) => sum + bucket.sessions, 0),
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
};
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: scroll.viewportW,
viewportH: scroll.viewportH,
pageW: scroll.pageW,
pageH: scroll.pageH,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return {
@@ -307,6 +314,7 @@ async function clickhouseQuery(
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`;
const pageRows = await rawQuery<
@@ -345,13 +353,32 @@ async function clickhouseQuery(
}
if (mode === 'scroll') {
const bucketRows = await rawQuery<{ depth: number | string; sessions: number | string }[]>(
const bucketRows = await rawQuery<
{
depth: number | string;
sessions: number | string;
pageW: number | string;
pageH: number | string;
viewportW: number | string;
viewportH: number | string;
}[]
>(
`
select
intDiv(max_pct, ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE} as depth,
count() as sessions
count() as sessions,
pageW,
pageH,
viewportW,
viewportH
from (
select h.visit_id, max(h.scroll_pct) as max_pct
select
h.visit_id,
max(h.scroll_pct) as max_pct,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_w), 1))) as pageW,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_h), 1))) as pageH,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_w), 1))) as viewportW,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_h), 1))) as viewportH
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {websiteId:UUID}
@@ -360,62 +387,47 @@ async function clickhouseQuery(
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterContext.filterQuery}
and h.scroll_pct is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by h.visit_id
)
group by depth
where pageW is not null
and pageH is not null
and viewportW is not null
and viewportH is not null
group by depth, pageW, pageH, viewportW, viewportH
order by depth
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows = await rawQuery<
{
totalSessions: number | string;
pageW: number | null;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[]
>(
`
select
uniq(h.visit_id) as totalSessions,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_w), 1))) as pageW,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_h), 1))) as pageH,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_w), 1))) as viewportW,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_h), 1))) as viewportH
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {websiteId:UUID}
and h.event_type = {eventType:UInt8}
and h.url_path = {urlPath:String}
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterContext.filterQuery}
and h.scroll_pct is not null
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
const scrollBuckets = bucketRows.map(r => ({
depth: Number(r.depth),
sessions: Number(r.sessions),
pageW: Number(r.pageW),
pageH: Number(r.pageH),
viewportW: Number(r.viewportW),
viewportH: Number(r.viewportH),
}));
const viewport = pickScrollSnapshotViewport(scrollBuckets);
const scroll = {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageW: dim?.pageW === null || dim?.pageW === undefined ? null : Number(dim.pageW),
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),
buckets: scrollBuckets,
totalSessions: scrollBuckets.reduce((sum, bucket) => sum + bucket.sessions, 0),
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
};
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: scroll.viewportW,
viewportH: scroll.viewportH,
pageW: scroll.pageW,
pageH: scroll.pageH,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return {
@@ -659,6 +671,65 @@ function pickSnapshotViewport(
};
}
function pickScrollSnapshotViewport(
buckets: HeatmapScrollBucket[],
): { width: number; height: number; pageW: number; pageH: number } | null {
const viewportBuckets = new Map<
string,
{
width: number;
height: number;
sessions: number;
maxPageW: number;
maxPageH: number;
}
>();
for (const bucket of buckets) {
const viewportKey = `${bucket.viewportW}x${bucket.viewportH}`;
const viewportBucket = viewportBuckets.get(viewportKey);
if (viewportBucket) {
viewportBucket.sessions += bucket.sessions;
viewportBucket.maxPageW = Math.max(viewportBucket.maxPageW, bucket.pageW);
viewportBucket.maxPageH = Math.max(viewportBucket.maxPageH, bucket.pageH);
} else {
viewportBuckets.set(viewportKey, {
width: bucket.viewportW,
height: bucket.viewportH,
sessions: bucket.sessions,
maxPageW: bucket.pageW,
maxPageH: bucket.pageH,
});
}
}
let bestViewport: {
width: number;
height: number;
sessions: number;
maxPageW: number;
maxPageH: number;
} | null = null;
for (const bucket of viewportBuckets.values()) {
if (!bestViewport || bucket.sessions > bestViewport.sessions) {
bestViewport = bucket;
}
}
if (!bestViewport) {
return null;
}
return {
width: bestViewport.width,
height: bestViewport.height,
pageW: bestViewport.maxPageW,
pageH: bestViewport.maxPageH,
};
}
function getHeatmapPathFilters(filters: QueryFilters) {
return filtersObjectToArray(filters).filter(filter => filter.name === 'path');
}