From 923b94ae8e7193bf32447a04f77d9fb13626f21a Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 7 May 2026 13:59:30 -0700 Subject: [PATCH] Render heatmaps from replay snapshots --- .../(reports)/heatmaps/Heatmap.module.css | 19 +- .../(reports)/heatmaps/Heatmap.tsx | 175 +++++++++----- src/queries/sql/heatmap/getHeatmap.ts | 225 ++++++++++++++++-- 3 files changed, 344 insertions(+), 75 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 96b578475..b3c868063 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -82,13 +82,28 @@ max-width: 100%; } -.iframe { +.snapshot { position: absolute; top: 0; left: 0; - border: 0; transform-origin: top left; pointer-events: none; + overflow: hidden; +} + +.snapshot :global(.replayer-wrapper) { + border: 0; + overflow: hidden; +} + +.snapshot :global(iframe) { + border: 0; + pointer-events: none; +} + +.snapshot :global(.replayer-mouse), +.snapshot :global(.replayer-mouse-tail) { + display: none !important; } .overlay { diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 493c1327b..db1c54d45 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -2,13 +2,14 @@ import { Column, Grid, Heading, Row, Text } from '@umami/react-zen'; import { useEffect, useMemo, useRef, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useResultQuery, useWebsite } from '@/components/hooks'; +import { useResultQuery } from '@/components/hooks'; +import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery'; import { formatLongNumber } from '@/lib/format'; -import type { HeatmapMode, HeatmapPoint, HeatmapResult } from '@/queries/sql'; +import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql'; import styles from './Heatmap.module.css'; +import 'rrweb/dist/replay/rrweb-replay.css'; const MAX_RENDER_WIDTH = 1024; -const IFRAME_SANDBOX = 'allow-same-origin allow-scripts allow-forms allow-popups'; function useElementWidth() { const ref = useRef(null); @@ -26,6 +27,10 @@ function useElementWidth() { return [ref, width] as const; } +interface ReplayData { + events: any[]; +} + interface ViewportBucket { width: number; height: number; @@ -41,7 +46,6 @@ interface HeatmapProps { } export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChange }: HeatmapProps) { - const website = useWebsite(); const { data, error, isLoading } = useResultQuery('heatmap', { websiteId, urlPath: urlPath || undefined, @@ -62,16 +66,14 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChang mode === 'scroll' ? ( ) : ( ) ) : ( @@ -142,27 +144,6 @@ function PageList({ ); } -function buildIframeSrc( - websiteId: string | null, - domain: string | null, - urlPath: string, -): string | null { - // Self-record: data was captured from this very umami instance, so iframe the - // current origin instead of the website's stored domain (which often points to prod). - if (typeof window !== 'undefined' && websiteId && websiteId === process.env.selfRecord) { - return `${window.location.origin}${urlPath}`; - } - if (!domain) return null; - if (/^https?:\/\//i.test(domain)) return `${domain}${urlPath}`; - const isLocal = /^(localhost|127\.|0\.0\.0\.0|\[::1\])(:|\/|$)/i.test(domain); - const protocol = isLocal - ? typeof window !== 'undefined' - ? window.location.protocol - : 'http:' - : 'https:'; - return `${protocol}//${domain}${urlPath}`; -} - function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { if (!points.length) return null; const buckets = new Map(); @@ -184,14 +165,12 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { function HeatmapView({ websiteId, - domain, - urlPath, points, + snapshot, }: { websiteId: string; - domain: string | null; - urlPath: string; points: HeatmapPoint[]; + snapshot: HeatmapSnapshot | null; }) { const [showPage, setShowPage] = useState(true); @@ -216,7 +195,6 @@ 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 iframeSrc = buildIframeSrc(websiteId, domain, urlPath); return ( @@ -225,7 +203,7 @@ function HeatmapView({ {visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '} clicks · viewport {viewport.width}×{viewport.height} - {iframeSrc && ( + {snapshot && (