diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css index ad2bd56b0..84dd40f35 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -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; } diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index b57515111..a906886d0 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -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( - (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(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(); + + 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(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({ {bucket && ( - {formatLongNumber(bucket.clicks)} + {formatLongNumber(bucket.count)} )} @@ -456,30 +548,8 @@ function ClickHeatmapView({ isLoading: boolean; }) { const [snapshotReady, setSnapshotReady] = useState(false); - const [selectedScreenWidth, setSelectedScreenWidth] = useState(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 (
{ 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} > - + {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.'} + )} @@ -844,6 +925,7 @@ function SnapshotImage({ }, [imageUrl, onReady, snapshot.id]); const handleLoad = useCallback(() => onReady(), [onReady]); + const imageWidth = Math.max(snapshot.pageW, snapshot.viewportW); return (
@@ -853,6 +935,10 @@ function SnapshotImage({ alt="" draggable={false} onLoad={handleLoad} + style={{ + width: imageWidth, + height: snapshot.pageH, + }} />
); @@ -894,6 +980,7 @@ function IframeSnapshot({ title={iframeUrl} tabIndex={-1} loading="lazy" + scrolling="no" referrerPolicy="no-referrer" onLoad={handleLoad} onError={handleError} diff --git a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts index 92e43281c..f296cf1b1 100644 --- a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts +++ b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts @@ -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; diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts index 0a61255ec..932a7c294 100644 --- a/src/queries/sql/heatmap/getHeatmap.ts +++ b/src/queries/sql/heatmap/getHeatmap.ts @@ -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'); }