From 6be5b614429d9d11934c67bf0b42f2765c0b1db7 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 7 May 2026 14:06:49 -0700 Subject: [PATCH] Wait for heatmap snapshot before overlay --- .../(reports)/heatmaps/Heatmap.module.css | 9 + .../(reports)/heatmaps/Heatmap.tsx | 155 ++++++++++++------ 2 files changed, 110 insertions(+), 54 deletions(-) diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css index b3c868063..269a52bba 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -86,6 +86,7 @@ position: absolute; top: 0; left: 0; + z-index: 1; transform-origin: top left; pointer-events: none; overflow: hidden; @@ -112,9 +113,17 @@ left: 0; right: 0; bottom: 0; + z-index: 3; pointer-events: none; } +.canvasLoading { + position: absolute; + inset: 0; + z-index: 2; + background: var(--surface-sunken); +} + .dot { position: absolute; border-radius: 50%; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index db1c54d45..3ebadf362 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Column, Grid, Heading, Row, Text } from '@umami/react-zen'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Column, Grid, Heading, Loading, Row, Text } from '@umami/react-zen'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useResultQuery } from '@/components/hooks'; import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery'; @@ -173,6 +173,7 @@ function HeatmapView({ snapshot: HeatmapSnapshot | null; }) { const [showPage, setShowPage] = useState(true); + const [snapshotReady, setSnapshotReady] = useState(false); const viewport = useMemo(() => pickViewport(points), [points]); @@ -187,6 +188,11 @@ function HeatmapView({ ); const [containerRef, containerWidth] = useElementWidth(); + const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + + useEffect(() => { + setSnapshotReady(!(showPage && snapshot)); + }, [containerWidth, showPage, snapshot]); if (!viewport || visible.length === 0) { return ; @@ -195,6 +201,8 @@ function HeatmapView({ const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0; const scale = renderWidth ? renderWidth / viewport.width : 0; const renderHeight = Math.round(viewport.height * scale); + const showSnapshot = renderWidth > 0 && showPage && !!snapshot; + const showOverlay = !showSnapshot || snapshotReady; return ( @@ -218,35 +226,39 @@ function HeatmapView({ className={styles.canvas} style={{ width: renderWidth || '100%', height: renderHeight || 0 }} > - {renderWidth > 0 && showPage && snapshot && ( + {showSnapshot && !snapshotReady && } + {showSnapshot && ( )} -
- {visible.map((p, i) => { - const intensity = Math.min(1, p.count / maxCount); - const size = 24 + intensity * 36; - return ( -
- ); - })} -
+ {showOverlay && ( +
+ {visible.map((p, i) => { + const intensity = Math.min(1, p.count / maxCount); + const size = 24 + intensity * 36; + return ( +
+ ); + })} +
+ )}
@@ -263,8 +275,14 @@ function ScrollHeatmapView({ snapshot: HeatmapSnapshot | null; }) { const [showPage, setShowPage] = useState(true); + const [snapshotReady, setSnapshotReady] = useState(false); const [containerRef, containerWidth] = useElementWidth(); + const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + + useEffect(() => { + setSnapshotReady(!(showPage && snapshot)); + }, [containerWidth, showPage, snapshot]); if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) { return ; @@ -274,6 +292,8 @@ function ScrollHeatmapView({ const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0; const scale = renderWidth ? renderWidth / viewportW : 0; 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. const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth); @@ -323,40 +343,44 @@ function ScrollHeatmapView({ className={styles.canvas} style={{ width: renderWidth || '100%', height: renderHeight || 0 }} > - {renderWidth > 0 && showPage && snapshot && ( + {showSnapshot && !snapshotReady && } + {showSnapshot && ( )} -
- {bands.map(b => { - const top = Math.round((b.fromPct / 100) * renderHeight); - const bottom = Math.round((b.toPct / 100) * renderHeight); - const height = Math.max(0, bottom - top); - const intensity = b.ratio; - const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red - return ( -
- - {b.fromPct}% · {Math.round(intensity * 100)}% - -
- ); - })} -
+ {showOverlay && ( +
+ {bands.map(b => { + const top = Math.round((b.fromPct / 100) * renderHeight); + const bottom = Math.round((b.toPct / 100) * renderHeight); + const height = Math.max(0, bottom - top); + const intensity = b.ratio; + const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red + return ( +
+ + {b.fromPct}% · {Math.round(intensity * 100)}% + +
+ ); + })} +
+ )} @@ -369,12 +393,14 @@ function ReplaySnapshot({ width, height, scale, + onReady, }: { websiteId: string; snapshot: HeatmapSnapshot; width: number; height: number; scale: number; + onReady: () => void; }) { const containerRef = useRef(null); const replayerRef = useRef(null); @@ -402,6 +428,7 @@ function ReplaySnapshot({ }); replayerRef.current = replayer; + let ready = false; const freeze = () => { const offset = Math.max(0, snapshot.timestamp - events[0].timestamp); @@ -410,10 +437,22 @@ function ReplaySnapshot({ resizeReplayFrame(replayer, width, height); }; - requestAnimationFrame(() => { - freeze(); - requestAnimationFrame(freeze); - }); + const handleReady = () => { + if (ready || cancelled) return; + ready = true; + + requestAnimationFrame(() => { + if (cancelled) return; + freeze(); + requestAnimationFrame(() => { + if (cancelled) return; + freeze(); + onReady(); + }); + }); + }; + + replayer.on('fullsnapshot-rebuilded', handleReady); }); return () => { @@ -426,7 +465,7 @@ function ReplaySnapshot({ container.innerHTML = ''; } }; - }, [data?.events, height, snapshot.timestamp, width]); + }, [data?.events, height, onReady, snapshot.timestamp, width]); useEffect(() => { if (replayerRef.current) { @@ -443,6 +482,14 @@ function ReplaySnapshot({ ); } +function CanvasLoading() { + return ( +
+ +
+ ); +} + function resizeReplayFrame(replayer: any, width: number, height: number) { const { iframe, wrapper } = replayer;