Group heatmap overlays by screen width
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user