Wait for heatmap snapshot before overlay
This commit is contained in:
1 parent
abac89b57f
commit
6be5b61442
2 files changed
+110
-54
No files matched your search
@@ -86,6 +86,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -112,9 +113,17 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
z-index: 3;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvasLoading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Column, Grid, Heading, Row, Text } from '@umami/react-zen';
|
import { Column, Grid, Heading, Loading, Row, Text } from '@umami/react-zen';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { useResultQuery } from '@/components/hooks';
|
import { useResultQuery } from '@/components/hooks';
|
||||||
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
|
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
|
||||||
@@ -173,6 +173,7 @@ function HeatmapView({
|
|||||||
snapshot: HeatmapSnapshot | null;
|
snapshot: HeatmapSnapshot | null;
|
||||||
}) {
|
}) {
|
||||||
const [showPage, setShowPage] = useState(true);
|
const [showPage, setShowPage] = useState(true);
|
||||||
|
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||||
|
|
||||||
const viewport = useMemo(() => pickViewport(points), [points]);
|
const viewport = useMemo(() => pickViewport(points), [points]);
|
||||||
|
|
||||||
@@ -187,6 +188,11 @@ function HeatmapView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||||
|
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSnapshotReady(!(showPage && snapshot));
|
||||||
|
}, [containerWidth, showPage, snapshot]);
|
||||||
|
|
||||||
if (!viewport || visible.length === 0) {
|
if (!viewport || visible.length === 0) {
|
||||||
return <EmptyState />;
|
return <EmptyState />;
|
||||||
@@ -195,6 +201,8 @@ function HeatmapView({
|
|||||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||||
const scale = renderWidth ? renderWidth / viewport.width : 0;
|
const scale = renderWidth ? renderWidth / viewport.width : 0;
|
||||||
const renderHeight = Math.round(viewport.height * scale);
|
const renderHeight = Math.round(viewport.height * scale);
|
||||||
|
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||||
|
const showOverlay = !showSnapshot || snapshotReady;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
@@ -218,35 +226,39 @@ function HeatmapView({
|
|||||||
className={styles.canvas}
|
className={styles.canvas}
|
||||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||||
>
|
>
|
||||||
{renderWidth > 0 && showPage && snapshot && (
|
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||||
|
{showSnapshot && (
|
||||||
<ReplaySnapshot
|
<ReplaySnapshot
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
width={viewport.width}
|
width={viewport.width}
|
||||||
height={viewport.height}
|
height={viewport.height}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
onReady={handleSnapshotReady}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={styles.overlay}>
|
{showOverlay && (
|
||||||
{visible.map((p, i) => {
|
<div className={styles.overlay}>
|
||||||
const intensity = Math.min(1, p.count / maxCount);
|
{visible.map((p, i) => {
|
||||||
const size = 24 + intensity * 36;
|
const intensity = Math.min(1, p.count / maxCount);
|
||||||
return (
|
const size = 24 + intensity * 36;
|
||||||
<div
|
return (
|
||||||
key={`${p.x}-${p.y}-${i}`}
|
<div
|
||||||
className={styles.dot}
|
key={`${p.x}-${p.y}-${i}`}
|
||||||
style={{
|
className={styles.dot}
|
||||||
left: p.x * scale - size / 2,
|
style={{
|
||||||
top: p.y * scale - size / 2,
|
left: p.x * scale - size / 2,
|
||||||
width: size,
|
top: p.y * scale - size / 2,
|
||||||
height: size,
|
width: size,
|
||||||
opacity: 0.25 + intensity * 0.55,
|
height: size,
|
||||||
}}
|
opacity: 0.25 + intensity * 0.55,
|
||||||
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
|
}}
|
||||||
/>
|
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -263,8 +275,14 @@ function ScrollHeatmapView({
|
|||||||
snapshot: HeatmapSnapshot | null;
|
snapshot: HeatmapSnapshot | null;
|
||||||
}) {
|
}) {
|
||||||
const [showPage, setShowPage] = useState(true);
|
const [showPage, setShowPage] = useState(true);
|
||||||
|
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||||
|
|
||||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||||
|
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSnapshotReady(!(showPage && snapshot));
|
||||||
|
}, [containerWidth, showPage, snapshot]);
|
||||||
|
|
||||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
||||||
return <EmptyState message="No scroll data for this page yet." />;
|
return <EmptyState message="No scroll data for this page yet." />;
|
||||||
@@ -274,6 +292,8 @@ function ScrollHeatmapView({
|
|||||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||||
const scale = renderWidth ? renderWidth / viewportW : 0;
|
const scale = renderWidth ? renderWidth / viewportW : 0;
|
||||||
const renderHeight = Math.round(pageH * scale);
|
const renderHeight = Math.round(pageH * scale);
|
||||||
|
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||||
|
const showOverlay = !showSnapshot || snapshotReady;
|
||||||
|
|
||||||
// Cumulative reach: % of sessions that scrolled at least to depth D.
|
// Cumulative reach: % of sessions that scrolled at least to depth D.
|
||||||
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
|
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
|
||||||
@@ -323,40 +343,44 @@ function ScrollHeatmapView({
|
|||||||
className={styles.canvas}
|
className={styles.canvas}
|
||||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||||
>
|
>
|
||||||
{renderWidth > 0 && showPage && snapshot && (
|
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||||
|
{showSnapshot && (
|
||||||
<ReplaySnapshot
|
<ReplaySnapshot
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
width={viewportW}
|
width={viewportW}
|
||||||
height={pageH}
|
height={pageH}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
onReady={handleSnapshotReady}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={styles.overlay}>
|
{showOverlay && (
|
||||||
{bands.map(b => {
|
<div className={styles.overlay}>
|
||||||
const top = Math.round((b.fromPct / 100) * renderHeight);
|
{bands.map(b => {
|
||||||
const bottom = Math.round((b.toPct / 100) * renderHeight);
|
const top = Math.round((b.fromPct / 100) * renderHeight);
|
||||||
const height = Math.max(0, bottom - top);
|
const bottom = Math.round((b.toPct / 100) * renderHeight);
|
||||||
const intensity = b.ratio;
|
const height = Math.max(0, bottom - top);
|
||||||
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
|
const intensity = b.ratio;
|
||||||
return (
|
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
|
||||||
<div
|
return (
|
||||||
key={b.fromPct}
|
<div
|
||||||
className={styles.scrollBand}
|
key={b.fromPct}
|
||||||
style={{
|
className={styles.scrollBand}
|
||||||
top,
|
style={{
|
||||||
height,
|
top,
|
||||||
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
|
height,
|
||||||
}}
|
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
|
||||||
title={`${b.fromPct}–${b.toPct}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
|
}}
|
||||||
>
|
title={`${b.fromPct}–${b.toPct}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
|
||||||
<span className={styles.scrollBandLabel}>
|
>
|
||||||
{b.fromPct}% · {Math.round(intensity * 100)}%
|
<span className={styles.scrollBandLabel}>
|
||||||
</span>
|
{b.fromPct}% · {Math.round(intensity * 100)}%
|
||||||
</div>
|
</span>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -369,12 +393,14 @@ function ReplaySnapshot({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
scale,
|
scale,
|
||||||
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
snapshot: HeatmapSnapshot;
|
snapshot: HeatmapSnapshot;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
onReady: () => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const replayerRef = useRef<any>(null);
|
const replayerRef = useRef<any>(null);
|
||||||
@@ -402,6 +428,7 @@ function ReplaySnapshot({
|
|||||||
});
|
});
|
||||||
|
|
||||||
replayerRef.current = replayer;
|
replayerRef.current = replayer;
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
const freeze = () => {
|
const freeze = () => {
|
||||||
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
||||||
@@ -410,10 +437,22 @@ function ReplaySnapshot({
|
|||||||
resizeReplayFrame(replayer, width, height);
|
resizeReplayFrame(replayer, width, height);
|
||||||
};
|
};
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
const handleReady = () => {
|
||||||
freeze();
|
if (ready || cancelled) return;
|
||||||
requestAnimationFrame(freeze);
|
ready = true;
|
||||||
});
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
freeze();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
freeze();
|
||||||
|
onReady();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
replayer.on('fullsnapshot-rebuilded', handleReady);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -426,7 +465,7 @@ function ReplaySnapshot({
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [data?.events, height, snapshot.timestamp, width]);
|
}, [data?.events, height, onReady, snapshot.timestamp, width]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replayerRef.current) {
|
if (replayerRef.current) {
|
||||||
@@ -443,6 +482,14 @@ function ReplaySnapshot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CanvasLoading() {
|
||||||
|
return (
|
||||||
|
<div className={styles.canvasLoading}>
|
||||||
|
<Loading icon="dots" placement="center" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resizeReplayFrame(replayer: any, width: number, height: number) {
|
function resizeReplayFrame(replayer: any, width: number, height: number) {
|
||||||
const { iframe, wrapper } = replayer;
|
const { iframe, wrapper } = replayer;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user