WIP heatmap full-page coordinate and replay rendering work
This commit is contained in:
@@ -10,6 +10,9 @@ CREATE TABLE umami.heatmap_event
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
page_x Nullable(Int32),
|
||||
page_y Nullable(Int32),
|
||||
page_w Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
|
||||
@@ -412,6 +412,9 @@ CREATE TABLE umami.heatmap_event
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
page_x Nullable(Int32),
|
||||
page_y Nullable(Int32),
|
||||
page_w Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
|
||||
@@ -9,6 +9,9 @@ CREATE TABLE "heatmap_event" (
|
||||
"node_id" INTEGER,
|
||||
"x" INTEGER,
|
||||
"y" INTEGER,
|
||||
"page_x" INTEGER,
|
||||
"page_y" INTEGER,
|
||||
"page_w" INTEGER,
|
||||
"viewport_w" INTEGER,
|
||||
"viewport_h" INTEGER,
|
||||
"page_h" INTEGER,
|
||||
|
||||
@@ -411,6 +411,9 @@ model HeatmapEvent {
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
pageX Int? @map("page_x") @db.Integer
|
||||
pageY Int? @map("page_y") @db.Integer
|
||||
pageW Int? @map("page_w") @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.layoutGrid {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pageList {
|
||||
@@ -102,6 +102,10 @@
|
||||
.canvasWrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.snapshotControlRow {
|
||||
@@ -114,7 +118,7 @@
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasClip {
|
||||
@@ -125,6 +129,14 @@
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.snapshotClip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -137,7 +149,7 @@
|
||||
|
||||
.snapshot :global(.replayer-wrapper) {
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.snapshot :global(iframe) {
|
||||
|
||||
@@ -9,22 +9,29 @@ import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '
|
||||
import styles from './Heatmap.module.css';
|
||||
import 'rrweb/dist/replay/rrweb-replay.css';
|
||||
|
||||
const MAX_RENDER_WIDTH = 1024;
|
||||
const MAX_FIXED_WIDTH_OVERRUN = 160;
|
||||
const CLICK_EDGE_PADDING = 4;
|
||||
|
||||
function useElementWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
setWidth(el.getBoundingClientRect().width || el.clientWidth || 0);
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(w);
|
||||
const nextWidth = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(nextWidth);
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
@@ -44,6 +51,8 @@ interface ReplayInstance {
|
||||
interface ViewportBucket {
|
||||
width: number;
|
||||
height: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -201,21 +210,42 @@ function PageList({
|
||||
|
||||
function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
||||
if (!points.length) return null;
|
||||
const buckets = new Map<string, ViewportBucket>();
|
||||
const viewportBuckets = new Map<
|
||||
string,
|
||||
ViewportBucket & { maxPageW: number; maxPageH: number }
|
||||
>();
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
const viewportKey = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = viewportBuckets.get(viewportKey);
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
existing.maxPageW = Math.max(existing.maxPageW, p.pageW);
|
||||
existing.maxPageH = Math.max(existing.maxPageH, p.pageH);
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
viewportBuckets.set(viewportKey, {
|
||||
width: p.viewportW,
|
||||
height: p.viewportH,
|
||||
pageW: p.pageW,
|
||||
pageH: p.pageH,
|
||||
count: p.count,
|
||||
maxPageW: p.pageW,
|
||||
maxPageH: p.pageH,
|
||||
});
|
||||
}
|
||||
}
|
||||
let best: ViewportBucket | null = null;
|
||||
for (const b of buckets.values()) {
|
||||
let best: (ViewportBucket & { maxPageW: number; maxPageH: number }) | null = null;
|
||||
for (const b of viewportBuckets.values()) {
|
||||
if (!best || b.count > best.count) best = b;
|
||||
}
|
||||
return best;
|
||||
if (!best) return null;
|
||||
|
||||
return {
|
||||
width: best.width,
|
||||
height: best.height,
|
||||
pageW: best.maxPageW,
|
||||
pageH: best.maxPageH,
|
||||
count: best.count,
|
||||
};
|
||||
}
|
||||
|
||||
function HeatmapView({
|
||||
@@ -251,7 +281,7 @@ function HeatmapView({
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
}, [showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
@@ -261,9 +291,14 @@ function HeatmapView({
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewport.width, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewport.width;
|
||||
const renderHeight = Math.round(viewport.height * scale);
|
||||
const overlayGutter = Math.max(48, Math.round(viewport.width * 0.04));
|
||||
const maxPointX = visible.reduce((max, point) => Math.max(max, point.pageX), 0);
|
||||
const maxPointY = visible.reduce((max, point) => Math.max(max, point.pageY), 0);
|
||||
const baseWidth = Math.max(viewport.pageW, maxPointX + overlayGutter);
|
||||
const baseHeight = Math.max(viewport.pageH, maxPointY + overlayGutter);
|
||||
const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth;
|
||||
const scale = renderWidth / baseWidth;
|
||||
const renderHeight = Math.round(baseHeight * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
|
||||
@@ -287,30 +322,45 @@ function HeatmapView({
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
>
|
||||
<div className={styles.canvasClip}>
|
||||
<div className={styles.snapshotClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
width={baseWidth}
|
||||
height={baseHeight}
|
||||
scale={scale}
|
||||
allowWidthExpansion={viewport.pageW > viewport.width}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{visible.map((p, i) => {
|
||||
const intensity = Math.min(1, p.count / maxCount);
|
||||
const size = 24 + intensity * 36;
|
||||
const desiredSize = 24 + intensity * 36;
|
||||
const pointWidth = Math.max(baseWidth, p.pageW || 0, p.pageX);
|
||||
const pointHeight = Math.max(baseHeight, p.pageH || 0, p.pageY);
|
||||
const rawCenterX = (p.pageX / Math.max(1, pointWidth)) * renderWidth;
|
||||
const rawCenterY = (p.pageY / Math.max(1, pointHeight)) * renderHeight;
|
||||
const size = desiredSize;
|
||||
const centerX = Math.max(
|
||||
size / 2 + CLICK_EDGE_PADDING,
|
||||
Math.min(renderWidth - size / 2 - CLICK_EDGE_PADDING, rawCenterX),
|
||||
);
|
||||
const centerY = Math.max(
|
||||
size / 2 + CLICK_EDGE_PADDING,
|
||||
Math.min(renderHeight - size / 2 - CLICK_EDGE_PADDING, rawCenterY),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`${p.x}-${p.y}-${i}`}
|
||||
key={`${p.pageX}-${p.pageY}-${i}`}
|
||||
className={styles.dot}
|
||||
style={{
|
||||
left: p.x * scale - size / 2,
|
||||
top: p.y * scale - size / 2,
|
||||
left: centerX - size / 2,
|
||||
top: centerY - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: 0.25 + intensity * 0.55,
|
||||
@@ -323,7 +373,6 @@ function HeatmapView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{snapshot && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
@@ -356,20 +405,22 @@ function ScrollHeatmapView({
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
}, [showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
}
|
||||
|
||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageW || !scroll.pageH || !scroll.viewportW) {
|
||||
return <EmptyState message="No scroll data for this page yet." />;
|
||||
}
|
||||
|
||||
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewportW, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewportW;
|
||||
const renderHeight = Math.round(pageH * scale);
|
||||
const { buckets, totalSessions, pageW, pageH, viewportW, viewportH } = scroll;
|
||||
const baseWidth = pageW;
|
||||
const baseHeight = pageH;
|
||||
const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth;
|
||||
const scale = renderWidth / baseWidth;
|
||||
const renderHeight = Math.round(baseHeight * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
|
||||
@@ -382,9 +433,6 @@ function ScrollHeatmapView({
|
||||
return { depth: b.depth, reached, ratio: reached / totalSessions };
|
||||
});
|
||||
|
||||
// Build page-spanning bands. Each band covers a vertical slice of the page.
|
||||
// Intensity = fraction of sessions reaching the band's TOP edge (everyone who
|
||||
// got that far saw at least the start of the slice).
|
||||
type Band = { fromPct: number; toPct: number; reached: number; ratio: number };
|
||||
const bands: Band[] = [];
|
||||
const firstDepth = cumulative[0]?.depth ?? 100;
|
||||
@@ -421,9 +469,10 @@ function ScrollHeatmapView({
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewportW}
|
||||
width={pageW}
|
||||
height={pageH}
|
||||
scale={scale}
|
||||
allowWidthExpansion={pageW > viewportW}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
@@ -474,6 +523,7 @@ function ReplaySnapshot({
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
allowWidthExpansion = true,
|
||||
onReady,
|
||||
}: {
|
||||
websiteId: string;
|
||||
@@ -481,22 +531,39 @@ function ReplaySnapshot({
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
allowWidthExpansion?: boolean;
|
||||
onReady: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const replayerRef = useRef<ReplayInstance | null>(null);
|
||||
const contentWidthRef = useRef(width);
|
||||
const [contentWidth, setContentWidth] = useState(width);
|
||||
const { data } = useReplayQuery(websiteId, snapshot.replayId, {
|
||||
until: snapshot.timestamp,
|
||||
chunkIndex: snapshot.chunkIndex,
|
||||
eventIndex: snapshot.eventIndex,
|
||||
}) as { data?: ReplayData };
|
||||
|
||||
useEffect(() => {
|
||||
contentWidthRef.current = width;
|
||||
setContentWidth(width);
|
||||
}, [
|
||||
snapshot.chunkIndex,
|
||||
snapshot.eventIndex,
|
||||
snapshot.replayId,
|
||||
snapshot.timestamp,
|
||||
width,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const events = data?.events;
|
||||
if (!container || !events?.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let readyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let widthObserver: ResizeObserver | null = null;
|
||||
|
||||
import('rrweb').then(({ Replayer }) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
@@ -516,7 +583,70 @@ function ReplaySnapshot({
|
||||
replayerRef.current = replayer;
|
||||
let rebuilt = false;
|
||||
let waitingForStyles = false;
|
||||
let settled = false;
|
||||
let finalizeToken = 0;
|
||||
let observingWidth = false;
|
||||
|
||||
const getBoundedWidth = (nextWidth: number) => {
|
||||
const maxWidth = allowWidthExpansion ? Number.POSITIVE_INFINITY : width + MAX_FIXED_WIDTH_OVERRUN;
|
||||
return Math.min(Math.max(width, nextWidth), maxWidth);
|
||||
};
|
||||
|
||||
const commitWidth = (nextWidth: number) => {
|
||||
const stableWidth = Math.max(contentWidthRef.current, getBoundedWidth(nextWidth), width);
|
||||
|
||||
if (stableWidth === contentWidthRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
contentWidthRef.current = stableWidth;
|
||||
resizeReplayFrame(replayer, stableWidth, height);
|
||||
setContentWidth(stableWidth);
|
||||
};
|
||||
|
||||
const scheduleReady = (delay = 250) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer);
|
||||
}
|
||||
|
||||
readyTimer = setTimeout(() => {
|
||||
readyTimer = null;
|
||||
if (!cancelled) {
|
||||
onReady();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const startWidthObserver = () => {
|
||||
if (observingWidth || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = replayer.iframe?.contentDocument;
|
||||
const html = doc?.documentElement;
|
||||
const body = doc?.body;
|
||||
|
||||
if (!html && !body) {
|
||||
return;
|
||||
}
|
||||
|
||||
observingWidth = true;
|
||||
widthObserver = new ResizeObserver(() => {
|
||||
commitWidth(measureReplayWidth(replayer, width));
|
||||
scheduleReady();
|
||||
});
|
||||
|
||||
if (html) {
|
||||
widthObserver.observe(html);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
widthObserver.observe(body);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = () => {
|
||||
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
||||
@@ -526,28 +656,62 @@ function ReplaySnapshot({
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (settled || waitingForStyles || !rebuilt || cancelled) {
|
||||
const finalize = async (token: number) => {
|
||||
if (waitingForStyles || !rebuilt || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
|
||||
await waitForReplayLayout(replayer);
|
||||
|
||||
if (cancelled) {
|
||||
if (cancelled || token !== finalizeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
await waitForAnimationFrames(2);
|
||||
|
||||
if (cancelled) {
|
||||
if (cancelled || token !== finalizeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
onReady();
|
||||
await waitForAnimationFrames(2);
|
||||
|
||||
if (cancelled || token !== finalizeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
commitWidth(
|
||||
await measureStableReplayWidth(replayer, width, {
|
||||
maxWidth: allowWidthExpansion ? undefined : width + MAX_FIXED_WIDTH_OVERRUN,
|
||||
samples: 12,
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled || token !== finalizeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
startWidthObserver();
|
||||
scheduleReady();
|
||||
};
|
||||
|
||||
const scheduleFinalize = (delay = 250) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizeToken += 1;
|
||||
const token = finalizeToken;
|
||||
|
||||
if (finalizeTimer) {
|
||||
clearTimeout(finalizeTimer);
|
||||
}
|
||||
|
||||
finalizeTimer = setTimeout(() => {
|
||||
finalizeTimer = null;
|
||||
void finalize(token);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
replayer.on('load-stylesheet-start', () => {
|
||||
@@ -556,26 +720,35 @@ function ReplaySnapshot({
|
||||
|
||||
replayer.on('load-stylesheet-end', () => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
scheduleFinalize();
|
||||
});
|
||||
|
||||
replayer.on('fullsnapshot-rebuilded', () => {
|
||||
rebuilt = true;
|
||||
void finalize();
|
||||
scheduleFinalize();
|
||||
});
|
||||
|
||||
replayer.on('resize', () => {
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
scheduleFinalize();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
scheduleFinalize(0);
|
||||
}, 3500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (finalizeTimer) {
|
||||
clearTimeout(finalizeTimer);
|
||||
}
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer);
|
||||
}
|
||||
if (widthObserver) {
|
||||
widthObserver.disconnect();
|
||||
}
|
||||
if (replayerRef.current) {
|
||||
replayerRef.current.destroy();
|
||||
replayerRef.current = null;
|
||||
@@ -584,20 +757,34 @@ function ReplaySnapshot({
|
||||
container.innerHTML = '';
|
||||
}
|
||||
};
|
||||
}, [data?.events, height, onReady, snapshot.timestamp, width]);
|
||||
}, [allowWidthExpansion, data?.events, height, onReady, snapshot.timestamp, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replayerRef.current) {
|
||||
resizeReplayFrame(replayerRef.current, width, height);
|
||||
resizeReplayFrame(replayerRef.current, contentWidth, height);
|
||||
}
|
||||
}, [height, width]);
|
||||
}, [contentWidth, height]);
|
||||
|
||||
const fitScaleX = contentWidth > width ? width / Math.max(1, contentWidth) : 1;
|
||||
const snapshotScaleX = scale * fitScaleX;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.snapshot}
|
||||
style={{ width, height, transform: `scale(${scale})` }}
|
||||
style={{
|
||||
width: contentWidth,
|
||||
height,
|
||||
transform: `scale(${snapshotScaleX}, ${scale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: contentWidth,
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -641,17 +828,36 @@ async function waitForReplayLayout(replayer: ReplayInstance) {
|
||||
await waitForAnimationFrames(3);
|
||||
}
|
||||
|
||||
function syncReplayDocumentViewport(replayer: ReplayInstance, width: number, height: number) {
|
||||
async function measureStableReplayWidth(
|
||||
replayer: ReplayInstance,
|
||||
fallbackWidth: number,
|
||||
{ maxWidth, samples = 6 }: { maxWidth?: number; samples?: number } = {},
|
||||
) {
|
||||
let stableWidth = fallbackWidth;
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
await waitForAnimationFrames(1);
|
||||
stableWidth = Math.max(stableWidth, measureReplayWidth(replayer, fallbackWidth));
|
||||
}
|
||||
|
||||
return maxWidth ? Math.min(stableWidth, maxWidth) : stableWidth;
|
||||
}
|
||||
|
||||
function syncReplayDocumentViewport(replayer: ReplayInstance) {
|
||||
const doc = replayer.iframe?.contentDocument;
|
||||
const html = doc?.documentElement;
|
||||
const body = doc?.body;
|
||||
|
||||
if (html) {
|
||||
html.style.margin = '0';
|
||||
html.style.overflowX = 'visible';
|
||||
html.style.overflowY = 'hidden';
|
||||
}
|
||||
|
||||
if (body) {
|
||||
body.style.margin = '0';
|
||||
body.style.overflowX = 'visible';
|
||||
body.style.overflowY = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,7 +873,7 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
|
||||
wrapper.style.maxHeight = `${height}px`;
|
||||
wrapper.style.margin = '0';
|
||||
wrapper.style.padding = '0';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
wrapper.style.overflow = 'visible';
|
||||
}
|
||||
|
||||
if (iframe) {
|
||||
@@ -683,7 +889,66 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
|
||||
iframe.style.display = 'block';
|
||||
}
|
||||
|
||||
syncReplayDocumentViewport(replayer, width, height);
|
||||
syncReplayDocumentViewport(replayer);
|
||||
}
|
||||
|
||||
function measureReplayWidth(replayer: ReplayInstance, fallbackWidth: number) {
|
||||
const doc = replayer.iframe?.contentDocument;
|
||||
const win = replayer.iframe?.contentWindow;
|
||||
const root = doc?.documentElement as HTMLElement | undefined;
|
||||
const body = doc?.body as HTMLElement | undefined;
|
||||
const scrollingElement = doc?.scrollingElement as HTMLElement | undefined;
|
||||
const firstChild = body?.firstElementChild as HTMLElement | null;
|
||||
const wrapperWidth = replayer.wrapper?.scrollWidth || replayer.wrapper?.offsetWidth || 0;
|
||||
const iframeWidth = replayer.iframe?.scrollWidth || replayer.iframe?.offsetWidth || 0;
|
||||
|
||||
return Math.round(
|
||||
Math.max(
|
||||
fallbackWidth,
|
||||
measureDocumentWidth(doc),
|
||||
wrapperWidth,
|
||||
iframeWidth,
|
||||
win?.innerWidth || 0,
|
||||
measureElementWidth(root),
|
||||
measureElementWidth(body),
|
||||
measureElementWidth(scrollingElement),
|
||||
measureElementWidth(firstChild || undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function measureDocumentWidth(doc?: Document | null) {
|
||||
const body = doc?.body;
|
||||
|
||||
if (!body) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxRight = 0;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode as Element | null;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
|
||||
if (rect && rect.width > 0) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
}
|
||||
|
||||
node = walker.nextNode() as Element | null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(maxRight));
|
||||
}
|
||||
|
||||
function measureElementWidth(element?: HTMLElement | null) {
|
||||
if (!element) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return Math.max(element.scrollWidth, element.offsetWidth, element.clientWidth, rect.width);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message?: string } = {}) {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { json, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getReplayChunks } from '@/queries/sql';
|
||||
|
||||
const RRWEB_TYPE_FULL_SNAPSHOT = 2;
|
||||
const RRWEB_TYPE_META = 4;
|
||||
const SNAPSHOT_WINDOW_CHUNK_LIMIT = 6;
|
||||
const SNAPSHOT_WINDOW_MAX_CHUNKS = 96;
|
||||
|
||||
function getEventTimestamp(event: any): number | null {
|
||||
const timestamp = Number(event?.timestamp);
|
||||
|
||||
@@ -19,6 +24,102 @@ function parseOptionalInteger(value: string | null): number | undefined {
|
||||
return Number.isInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function trimChunksToCheckpoint(
|
||||
chunks: Awaited<ReturnType<typeof getReplayChunks>>,
|
||||
{ endChunkIndex, endEventIndex }: { endChunkIndex?: number; endEventIndex?: number },
|
||||
) {
|
||||
let lastMetaChunkIndex: number | null = null;
|
||||
let checkpointStartChunkIndex: number | null = null;
|
||||
let hasMeta = false;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (endChunkIndex !== undefined && chunk.chunkIndex > endChunkIndex) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let chunkEventIndex = 0; chunkEventIndex < chunk.events.length; chunkEventIndex++) {
|
||||
if (
|
||||
chunk.chunkIndex === endChunkIndex &&
|
||||
endEventIndex !== undefined &&
|
||||
chunkEventIndex > endEventIndex
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
const event = chunk.events[chunkEventIndex];
|
||||
|
||||
if (event?.type === RRWEB_TYPE_META) {
|
||||
lastMetaChunkIndex = chunk.chunkIndex;
|
||||
hasMeta = true;
|
||||
}
|
||||
|
||||
if (event?.type === RRWEB_TYPE_FULL_SNAPSHOT) {
|
||||
checkpointStartChunkIndex = lastMetaChunkIndex ?? chunk.chunkIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkpointStartChunkIndex === null) {
|
||||
return { chunks, foundCheckpoint: false, hasMeta };
|
||||
}
|
||||
|
||||
return {
|
||||
chunks: chunks.filter(chunk => chunk.chunkIndex >= checkpointStartChunkIndex),
|
||||
foundCheckpoint: true,
|
||||
hasMeta,
|
||||
};
|
||||
}
|
||||
|
||||
async function getReplaySnapshotChunks(
|
||||
websiteId: string,
|
||||
replayId: string,
|
||||
{ endAt, endChunkIndex, endEventIndex }: { endAt?: Date; endChunkIndex: number; endEventIndex?: number },
|
||||
) {
|
||||
let limit = SNAPSHOT_WINDOW_CHUNK_LIMIT;
|
||||
|
||||
while (limit <= SNAPSHOT_WINDOW_MAX_CHUNKS) {
|
||||
const descChunks = await getReplayChunks(websiteId, replayId, {
|
||||
endAt,
|
||||
endChunkIndex,
|
||||
limit,
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const chunks = [...descChunks].reverse();
|
||||
const { chunks: trimmedChunks, foundCheckpoint, hasMeta } = trimChunksToCheckpoint(chunks, {
|
||||
endChunkIndex,
|
||||
endEventIndex,
|
||||
});
|
||||
|
||||
if (foundCheckpoint || descChunks.length < limit) {
|
||||
if (hasMeta || trimmedChunks.length === 0) {
|
||||
return trimmedChunks;
|
||||
}
|
||||
|
||||
const initialChunks = await getReplayChunks(websiteId, replayId, {
|
||||
limit: 1,
|
||||
order: 'asc',
|
||||
});
|
||||
|
||||
if (!initialChunks.length) {
|
||||
return trimmedChunks;
|
||||
}
|
||||
|
||||
const initialChunk = initialChunks[0];
|
||||
|
||||
if (trimmedChunks.some(chunk => chunk.chunkIndex === initialChunk.chunkIndex)) {
|
||||
return trimmedChunks;
|
||||
}
|
||||
|
||||
return [initialChunk, ...trimmedChunks];
|
||||
}
|
||||
|
||||
limit *= 2;
|
||||
}
|
||||
|
||||
return getReplayChunks(websiteId, replayId, { endAt, endChunkIndex });
|
||||
}
|
||||
|
||||
function mergeReplayEvents(
|
||||
chunks: Awaited<ReturnType<typeof getReplayChunks>>,
|
||||
{
|
||||
@@ -90,7 +191,14 @@ export async function GET(
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const chunks = await getReplayChunks(websiteId, replayId, { endAt, endChunkIndex });
|
||||
const chunks =
|
||||
endChunkIndex !== undefined
|
||||
? await getReplaySnapshotChunks(websiteId, replayId, {
|
||||
endAt,
|
||||
endChunkIndex,
|
||||
endEventIndex,
|
||||
})
|
||||
: await getReplayChunks(websiteId, replayId, { endAt, endChunkIndex });
|
||||
const allEvents = mergeReplayEvents(chunks, { until, endChunkIndex, endEventIndex });
|
||||
const sessionId = chunks.length > 0 ? chunks[0].sessionId : null;
|
||||
const startedAt = chunks.length > 0 ? chunks[0].startedAt : null;
|
||||
|
||||
@@ -3,15 +3,16 @@ 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;
|
||||
|
||||
export interface ExtractedHeatmapEvent {
|
||||
eventType: number;
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
pageX: number | null;
|
||||
pageY: number | null;
|
||||
pageW: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageH: number | null;
|
||||
@@ -76,6 +77,9 @@ export function extractHeatmapEvents(
|
||||
nodeId: null,
|
||||
x: null,
|
||||
y: null,
|
||||
pageX: null,
|
||||
pageY: null,
|
||||
pageW: typeof p.pageW === 'number' ? p.pageW : 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,
|
||||
@@ -90,6 +94,31 @@ export function extractHeatmapEvents(
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (ev.data.tag === 'heatmap-click' && ev.data.payload) {
|
||||
const p = ev.data.payload;
|
||||
const path = safePathname(p.url) ?? urlPath;
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.click,
|
||||
nodeId: null,
|
||||
x: typeof p.x === 'number' ? Math.round(p.x) : null,
|
||||
y: typeof p.y === 'number' ? Math.round(p.y) : null,
|
||||
pageX: typeof p.pageX === 'number' ? Math.round(p.pageX) : null,
|
||||
pageY: typeof p.pageY === 'number' ? Math.round(p.pageY) : null,
|
||||
pageW: typeof p.pageW === 'number' ? Math.round(p.pageW) : null,
|
||||
viewportW: typeof p.viewportW === 'number' ? Math.round(p.viewportW) : viewportW,
|
||||
viewportH: typeof p.viewportH === 'number' ? Math.round(p.viewportH) : viewportH,
|
||||
pageH: typeof p.pageH === 'number' ? Math.round(p.pageH) : null,
|
||||
scrollPct: null,
|
||||
urlPath: path,
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -102,28 +131,6 @@ export function extractHeatmapEvents(
|
||||
if (typeof d.height === 'number') viewportH = d.height;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
d.source === RRWEB_SOURCE_MOUSE_INTERACTION &&
|
||||
d.type === RRWEB_MOUSE_CLICK &&
|
||||
urlPath !== null
|
||||
) {
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.click,
|
||||
nodeId: typeof d.id === 'number' ? d.id : null,
|
||||
x: typeof d.x === 'number' ? Math.round(d.x) : null,
|
||||
y: typeof d.y === 'number' ? Math.round(d.y) : null,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageH: null,
|
||||
scrollPct: null,
|
||||
urlPath,
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface HeatmapPoint {
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: number;
|
||||
@@ -52,6 +56,7 @@ export interface HeatmapResult {
|
||||
scroll: {
|
||||
buckets: HeatmapScrollBucket[];
|
||||
totalSessions: number;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -80,11 +85,6 @@ interface HeatmapFilterContext {
|
||||
queryParams: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SnapshotPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
@@ -98,6 +98,10 @@ async function relationalQuery(
|
||||
? `
|
||||
and x is not null
|
||||
and y is not null
|
||||
and page_x is not null
|
||||
and page_y is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
`
|
||||
@@ -153,6 +157,7 @@ async function relationalQuery(
|
||||
|
||||
const dimRows: {
|
||||
totalSessions: number | string;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -160,6 +165,7 @@ async function relationalQuery(
|
||||
`
|
||||
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"
|
||||
@@ -179,6 +185,7 @@ async function relationalQuery(
|
||||
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,
|
||||
@@ -189,9 +196,10 @@ async function relationalQuery(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -210,6 +218,10 @@ async function relationalQuery(
|
||||
h.node_id as "nodeId",
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as "pageX",
|
||||
h.page_y as "pageY",
|
||||
h.page_w as "pageW",
|
||||
h.page_h as "pageH",
|
||||
h.viewport_w as "viewportW",
|
||||
h.viewport_h as "viewportH",
|
||||
count(*)::int as count
|
||||
@@ -221,9 +233,22 @@ async function relationalQuery(
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
and h.x is not null
|
||||
and h.y is not null
|
||||
and h.page_x is not null
|
||||
and h.page_y 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.node_id, h.x, h.y, h.viewport_w, h.viewport_h
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
h.page_y,
|
||||
h.page_w,
|
||||
h.page_h,
|
||||
h.viewport_w,
|
||||
h.viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
@@ -232,16 +257,16 @@ async function relationalQuery(
|
||||
);
|
||||
|
||||
const viewport = pickSnapshotViewport(rawPoints);
|
||||
const point = pickRepresentativePoint(rawPoints, viewport);
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -256,9 +281,10 @@ async function getRelationalSnapshot(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
@@ -266,12 +292,20 @@ async function getRelationalSnapshot(
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const pageFilter =
|
||||
pageW && pageH
|
||||
? `
|
||||
and h.page_w = {{pageW}}
|
||||
and h.page_h = {{pageH}}
|
||||
`
|
||||
: '';
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
@@ -279,39 +313,15 @@ async function getRelationalSnapshot(
|
||||
and h.viewport_h = {{viewportH}}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {{pointX}}
|
||||
and h.y = {{pointY}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
with best_visit as (
|
||||
select
|
||||
h.visit_id as visit_id,
|
||||
count(*) as event_count,
|
||||
min(h.created_at) as first_seen
|
||||
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}}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
)
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp",
|
||||
h.replay_chunk_index as "chunkIndex",
|
||||
h.replay_event_index as "eventIndex"
|
||||
from heatmap_event h
|
||||
inner join best_visit bv on bv.visit_id = h.visit_id
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
@@ -322,10 +332,58 @@ async function getRelationalSnapshot(
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
and h.replay_chunk_index is not null
|
||||
and h.replay_event_index is not null
|
||||
and h.replay_time_ms is not null
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc nulls last,
|
||||
h.replay_event_index asc nulls last,
|
||||
h.replay_time_ms asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
const fallbackRows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp",
|
||||
h.replay_chunk_index as "chunkIndex",
|
||||
h.replay_event_index as "eventIndex"
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${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}}
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
case when h.replay_chunk_index is null then 1 else 0 end asc,
|
||||
h.replay_chunk_index asc nulls last,
|
||||
h.replay_event_index asc nulls last,
|
||||
h.created_at asc
|
||||
@@ -338,15 +396,15 @@ async function getRelationalSnapshot(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(rows[0]);
|
||||
return mapSnapshot(fallbackRows[0]);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
@@ -362,6 +420,10 @@ async function clickhouseQuery(
|
||||
? `
|
||||
and x is not null
|
||||
and y is not null
|
||||
and page_x is not null
|
||||
and page_y is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
`
|
||||
@@ -426,6 +488,7 @@ async function clickhouseQuery(
|
||||
const dimRows = await rawQuery<
|
||||
{
|
||||
totalSessions: number | string;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -434,6 +497,7 @@ async function clickhouseQuery(
|
||||
`
|
||||
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
|
||||
@@ -453,6 +517,7 @@ async function clickhouseQuery(
|
||||
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),
|
||||
@@ -465,9 +530,10 @@ async function clickhouseQuery(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -485,6 +551,10 @@ async function clickhouseQuery(
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: string | number;
|
||||
@@ -495,6 +565,10 @@ async function clickhouseQuery(
|
||||
h.node_id as nodeId,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as pageX,
|
||||
h.page_y as pageY,
|
||||
h.page_w as pageW,
|
||||
h.page_h as pageH,
|
||||
h.viewport_w as viewportW,
|
||||
h.viewport_h as viewportH,
|
||||
count() as count
|
||||
@@ -506,9 +580,22 @@ async function clickhouseQuery(
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and h.x is not null
|
||||
and h.y is not null
|
||||
and h.page_x is not null
|
||||
and h.page_y 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.node_id, h.x, h.y, h.viewport_w, h.viewport_h
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
h.page_y,
|
||||
h.page_w,
|
||||
h.page_h,
|
||||
h.viewport_w,
|
||||
h.viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
@@ -520,22 +607,26 @@ async function clickhouseQuery(
|
||||
nodeId: p.nodeId === null || p.nodeId === undefined ? null : Number(p.nodeId),
|
||||
x: Number(p.x),
|
||||
y: Number(p.y),
|
||||
pageX: Number(p.pageX),
|
||||
pageY: Number(p.pageY),
|
||||
pageW: Number(p.pageW),
|
||||
pageH: Number(p.pageH),
|
||||
viewportW: Number(p.viewportW),
|
||||
viewportH: Number(p.viewportH),
|
||||
count: Number(p.count),
|
||||
}));
|
||||
|
||||
const viewport = pickSnapshotViewport(points);
|
||||
const point = pickRepresentativePoint(points, viewport);
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -550,9 +641,10 @@ async function getClickhouseSnapshot(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
@@ -560,12 +652,20 @@ async function getClickhouseSnapshot(
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const pageFilter =
|
||||
pageW && pageH
|
||||
? `
|
||||
and h.page_w = {pageW:UInt32}
|
||||
and h.page_h = {pageH:UInt32}
|
||||
`
|
||||
: '';
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
@@ -573,39 +673,15 @@ async function getClickhouseSnapshot(
|
||||
and h.viewport_h = {viewportH:UInt32}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {pointX:UInt32}
|
||||
and h.y = {pointY:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
with best_visit as (
|
||||
select
|
||||
h.visit_id as visit_id,
|
||||
count() as event_count,
|
||||
min(h.created_at) as first_seen
|
||||
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}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
)
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp,
|
||||
h.replay_chunk_index as chunkIndex,
|
||||
h.replay_event_index as eventIndex
|
||||
from heatmap_event h
|
||||
inner join best_visit bv on bv.visit_id = h.visit_id
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
@@ -616,10 +692,58 @@ async function getClickhouseSnapshot(
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and h.replay_chunk_index is not null
|
||||
and h.replay_event_index is not null
|
||||
and h.replay_time_ms is not null
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc,
|
||||
h.replay_event_index asc,
|
||||
h.replay_time_ms asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
const fallbackRows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp,
|
||||
h.replay_chunk_index as chunkIndex,
|
||||
h.replay_event_index as eventIndex
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${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}
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
isNull(h.replay_chunk_index) asc,
|
||||
h.replay_chunk_index asc,
|
||||
h.replay_event_index asc,
|
||||
h.created_at asc
|
||||
@@ -632,63 +756,87 @@ async function getClickhouseSnapshot(
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(rows[0]);
|
||||
return mapSnapshot(fallbackRows[0]);
|
||||
}
|
||||
|
||||
function emptyScroll(): HeatmapResult['scroll'] {
|
||||
return {
|
||||
buckets: [],
|
||||
totalSessions: 0,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: null,
|
||||
viewportH: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickSnapshotViewport(points: HeatmapPoint[]): { width: number; height: number } | null {
|
||||
const buckets = new Map<string, { width: number; height: number; count: number }>();
|
||||
function pickSnapshotViewport(
|
||||
points: HeatmapPoint[],
|
||||
): { width: number; height: number; pageW: number; pageH: number } | null {
|
||||
const viewportBuckets = new Map<
|
||||
string,
|
||||
{
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
maxPageW: number;
|
||||
maxPageH: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
const viewportKey = `${p.viewportW}x${p.viewportH}`;
|
||||
const viewportBucket = viewportBuckets.get(viewportKey);
|
||||
|
||||
if (viewportBucket) {
|
||||
viewportBucket.count += p.count;
|
||||
viewportBucket.maxPageW = Math.max(viewportBucket.maxPageW, p.pageW);
|
||||
viewportBucket.maxPageH = Math.max(viewportBucket.maxPageH, p.pageH);
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
viewportBuckets.set(viewportKey, {
|
||||
width: p.viewportW,
|
||||
height: p.viewportH,
|
||||
count: p.count,
|
||||
maxPageW: p.pageW,
|
||||
maxPageH: p.pageH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let best: { width: number; height: number; count: number } | null = null;
|
||||
for (const bucket of buckets.values()) {
|
||||
if (!best || bucket.count > best.count) {
|
||||
best = bucket;
|
||||
let bestViewport:
|
||||
| {
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
maxPageW: number;
|
||||
maxPageH: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
for (const bucket of viewportBuckets.values()) {
|
||||
if (!bestViewport || bucket.count > bestViewport.count) {
|
||||
bestViewport = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { width: best.width, height: best.height } : null;
|
||||
}
|
||||
|
||||
function pickRepresentativePoint(
|
||||
points: HeatmapPoint[],
|
||||
viewport: { width: number; height: number } | null,
|
||||
): SnapshotPoint | null {
|
||||
if (!viewport) {
|
||||
if (!bestViewport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = points.find(
|
||||
point => point.viewportW === viewport.width && point.viewportH === viewport.height,
|
||||
);
|
||||
|
||||
return match ? { x: match.x, y: match.y } : null;
|
||||
return {
|
||||
width: bestViewport.width,
|
||||
height: bestViewport.height,
|
||||
pageW: bestViewport.maxPageW,
|
||||
pageH: bestViewport.maxPageH,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null {
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface HeatmapEventRow {
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
pageX: number | null;
|
||||
pageY: number | null;
|
||||
pageW: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageH: number | null;
|
||||
@@ -44,6 +47,9 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
nodeId: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
pageX: r.pageX,
|
||||
pageY: r.pageY,
|
||||
pageW: r.pageW,
|
||||
viewportW: r.viewportW,
|
||||
viewportH: r.viewportH,
|
||||
pageH: r.pageH,
|
||||
@@ -70,6 +76,9 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
node_id: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
page_x: r.pageX,
|
||||
page_y: r.pageY,
|
||||
page_w: r.pageW,
|
||||
viewport_w: r.viewportW,
|
||||
viewport_h: r.viewportH,
|
||||
page_h: r.pageH,
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface ReplayChunk {
|
||||
interface GetReplayChunksOptions {
|
||||
endAt?: Date;
|
||||
endChunkIndex?: number;
|
||||
startChunkIndex?: number;
|
||||
limit?: number;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export async function getReplayChunks(
|
||||
@@ -34,7 +37,7 @@ export async function getReplayChunks(
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
visitId: string,
|
||||
{ endAt, endChunkIndex }: GetReplayChunksOptions,
|
||||
{ endAt, endChunkIndex, startChunkIndex, limit, order = 'asc' }: GetReplayChunksOptions,
|
||||
): Promise<ReplayChunk[]> {
|
||||
const { rawQuery } = prisma;
|
||||
const endAtFilter = endAt
|
||||
@@ -48,6 +51,18 @@ async function relationalQuery(
|
||||
and chunk_index <= {{endChunkIndex}}
|
||||
`
|
||||
: '';
|
||||
const startChunkFilter =
|
||||
startChunkIndex !== undefined
|
||||
? `
|
||||
and chunk_index >= {{startChunkIndex}}
|
||||
`
|
||||
: '';
|
||||
const limitClause =
|
||||
limit !== undefined
|
||||
? `
|
||||
limit ${limit}
|
||||
`
|
||||
: '';
|
||||
|
||||
const chunks: {
|
||||
sessionId: string;
|
||||
@@ -71,10 +86,12 @@ async function relationalQuery(
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and visit_id = {{visitId::uuid}}
|
||||
${endAtFilter}
|
||||
${startChunkFilter}
|
||||
${endChunkFilter}
|
||||
order by chunk_index asc
|
||||
order by chunk_index ${order}
|
||||
${limitClause}
|
||||
`,
|
||||
{ websiteId, visitId, endAt, endChunkIndex },
|
||||
{ websiteId, visitId, endAt, endChunkIndex, startChunkIndex },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
@@ -87,7 +104,7 @@ async function relationalQuery(
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
visitId: string,
|
||||
{ endAt, endChunkIndex }: GetReplayChunksOptions,
|
||||
{ endAt, endChunkIndex, startChunkIndex, limit, order = 'asc' }: GetReplayChunksOptions,
|
||||
): Promise<ReplayChunk[]> {
|
||||
const { rawQuery } = clickhouse;
|
||||
const endAtFilter = endAt
|
||||
@@ -101,6 +118,18 @@ async function clickhouseQuery(
|
||||
and chunk_index <= {endChunkIndex:UInt32}
|
||||
`
|
||||
: '';
|
||||
const startChunkFilter =
|
||||
startChunkIndex !== undefined
|
||||
? `
|
||||
and chunk_index >= {startChunkIndex:UInt32}
|
||||
`
|
||||
: '';
|
||||
const limitClause =
|
||||
limit !== undefined
|
||||
? `
|
||||
limit ${limit}
|
||||
`
|
||||
: '';
|
||||
|
||||
const results = await rawQuery<
|
||||
{
|
||||
@@ -126,10 +155,12 @@ async function clickhouseQuery(
|
||||
prewhere website_id = {websiteId:UUID}
|
||||
and visit_id = {visitId:UUID}
|
||||
${endAtFilter}
|
||||
${startChunkFilter}
|
||||
${endChunkFilter}
|
||||
order by chunk_index asc
|
||||
order by chunk_index ${order}
|
||||
${limitClause}
|
||||
`,
|
||||
{ websiteId, visitId, endAt, endChunkIndex },
|
||||
{ websiteId, visitId, endAt, endChunkIndex, startChunkIndex },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
|
||||
@@ -49,8 +49,6 @@ export async function relationalQuery({
|
||||
for (const data of flattenedData) {
|
||||
const { sessionId, dataKey, ...props } = data;
|
||||
|
||||
// Try to update existing record using compound where clause
|
||||
// This is safer than using id from a previous query due to race conditions
|
||||
const updateResult = await client.sessionData.updateMany({
|
||||
where: {
|
||||
sessionId,
|
||||
|
||||
+116
-4
@@ -139,6 +139,54 @@ import { record } from 'rrweb';
|
||||
}
|
||||
};
|
||||
|
||||
const measureElementWidth = element => {
|
||||
if (!element) return 0;
|
||||
const rect = element.getBoundingClientRect?.();
|
||||
return Math.max(
|
||||
element.scrollWidth || 0,
|
||||
element.offsetWidth || 0,
|
||||
element.clientWidth || 0,
|
||||
rect?.width || 0,
|
||||
);
|
||||
};
|
||||
|
||||
const measureElementHeight = element => {
|
||||
if (!element) return 0;
|
||||
const rect = element.getBoundingClientRect?.();
|
||||
return Math.max(
|
||||
element.scrollHeight || 0,
|
||||
element.offsetHeight || 0,
|
||||
element.clientHeight || 0,
|
||||
rect?.height || 0,
|
||||
);
|
||||
};
|
||||
|
||||
const measureDocumentBounds = doc => {
|
||||
const body = doc?.body;
|
||||
if (!body) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.max(0, Math.round(maxRight)),
|
||||
height: Math.max(0, Math.round(maxBottom)),
|
||||
};
|
||||
};
|
||||
|
||||
const waitForSession = (attempts = 0) => {
|
||||
if (attempts > 50) return;
|
||||
|
||||
@@ -198,9 +246,39 @@ import { record } from 'rrweb';
|
||||
let maxScrollPct = 0;
|
||||
let scrollTimer = null;
|
||||
|
||||
const computePageMetrics = ({ includeBounds = false } = {}) => {
|
||||
const scrollingElement = document.scrollingElement || document.documentElement;
|
||||
const firstChild = document.body?.firstElementChild;
|
||||
const bounds = includeBounds ? measureDocumentBounds(document) : null;
|
||||
const pageW = Math.max(
|
||||
bounds?.width || 0,
|
||||
measureElementWidth(scrollingElement),
|
||||
measureElementWidth(document.documentElement),
|
||||
measureElementWidth(document.body),
|
||||
measureElementWidth(firstChild),
|
||||
);
|
||||
const pageH = Math.max(
|
||||
bounds?.height || 0,
|
||||
measureElementHeight(scrollingElement),
|
||||
measureElementHeight(document.documentElement),
|
||||
measureElementHeight(document.body),
|
||||
measureElementHeight(firstChild),
|
||||
);
|
||||
const scrollLeft = scrollingElement?.scrollLeft || window.scrollX || 0;
|
||||
const scrollTop = scrollingElement?.scrollTop || window.scrollY || 0;
|
||||
|
||||
return {
|
||||
pageW,
|
||||
pageH,
|
||||
scrollLeft,
|
||||
scrollTop,
|
||||
};
|
||||
};
|
||||
|
||||
const computeScrollPct = () => {
|
||||
const pageH = document.documentElement.scrollHeight;
|
||||
const visible = window.scrollY + window.innerHeight;
|
||||
const { pageH, scrollTop } = computePageMetrics();
|
||||
const visible = scrollTop + window.innerHeight;
|
||||
|
||||
return {
|
||||
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
|
||||
pageH,
|
||||
@@ -209,16 +287,50 @@ import { record } from 'rrweb';
|
||||
|
||||
const flushScroll = () => {
|
||||
if (maxScrollPct <= 0) return;
|
||||
const { pageW, pageH } = computePageMetrics({ includeBounds: true });
|
||||
|
||||
addCustomEvent('scroll-progress', {
|
||||
url: scrollUrl,
|
||||
scrollPct: maxScrollPct,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
pageH: document.documentElement.scrollHeight,
|
||||
pageW,
|
||||
pageH,
|
||||
});
|
||||
maxScrollPct = 0;
|
||||
};
|
||||
|
||||
const onClick = event => {
|
||||
if (!event.isTrusted || event.button !== 0) return;
|
||||
|
||||
const { pageW: rawPageW, pageH: rawPageH, scrollLeft, scrollTop } = computePageMetrics({
|
||||
includeBounds: true,
|
||||
});
|
||||
const pageX = Number.isFinite(event.pageX) ? event.pageX : event.clientX + scrollLeft;
|
||||
const pageY = Number.isFinite(event.pageY) ? event.pageY : event.clientY + scrollTop;
|
||||
const target = event.target;
|
||||
const targetRect =
|
||||
target && typeof target.getBoundingClientRect === 'function'
|
||||
? target.getBoundingClientRect()
|
||||
: null;
|
||||
const targetRight = targetRect ? targetRect.right + scrollLeft : 0;
|
||||
const targetBottom = targetRect ? targetRect.bottom + scrollTop : 0;
|
||||
const pageW = Math.max(rawPageW, Math.ceil(pageX), Math.ceil(targetRight));
|
||||
const pageH = Math.max(rawPageH, Math.ceil(pageY), Math.ceil(targetBottom));
|
||||
|
||||
addCustomEvent('heatmap-click', {
|
||||
url: location.href,
|
||||
x: Math.round(event.clientX),
|
||||
y: Math.round(event.clientY),
|
||||
pageX: Math.round(pageX),
|
||||
pageY: Math.round(pageY),
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (scrollTimer) return;
|
||||
scrollTimer = setTimeout(() => {
|
||||
@@ -248,8 +360,8 @@ import { record } from 'rrweb';
|
||||
hookHistory('replaceState');
|
||||
window.addEventListener('popstate', onUrlChange);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
document.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
|
||||
// Capture initial scroll position
|
||||
{
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
|
||||
Reference in New Issue
Block a user