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 33b1ca5b7..2213fdfc7 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -160,10 +160,6 @@ pointer-events: none; } -.heatOverlay { - overflow: visible; -} - .canvasLoading { position: absolute; inset: 0; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 24bb63de4..6365122fa 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -2,7 +2,7 @@ import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useResultQuery } 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, HeatmapSnapshot } from '@/queries/sql'; @@ -115,6 +115,14 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H onUrlPathChange(filteredPages[0].urlPath); }, [filteredPages, isLoading, onUrlPathChange, urlPath]); + if (!isLoading && pages.length === 0) { + return ( + + + + ); + } + return ( @@ -167,15 +175,11 @@ function PageList({ mode: HeatmapMode; hasSearch: boolean; }) { - const { t, messages } = useMessages(); - return ( Pages - {pages.length === 0 && ( - {hasSearch ? 'No matching pages' : t(messages.noDataAvailable)} - )} + {pages.length === 0 && hasSearch && No matching pages} {pages.map(p => ( + + + + ); + } + return ( diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts index fae50e95a..9169f861a 100644 --- a/src/queries/sql/heatmap/getHeatmap.ts +++ b/src/queries/sql/heatmap/getHeatmap.ts @@ -80,6 +80,11 @@ interface HeatmapFilterContext { queryParams: Record; } +interface SnapshotPoint { + x: number; + y: number; +} + async function relationalQuery( websiteId: string, parameters: HeatmapParameters, @@ -186,6 +191,7 @@ async function relationalQuery( endDate, viewportW: scroll.viewportW, viewportH: scroll.viewportH, + point: null, filterContext, }); @@ -226,6 +232,7 @@ async function relationalQuery( ); const viewport = pickSnapshotViewport(rawPoints); + const point = pickRepresentativePoint(rawPoints, viewport); const snapshot = await getRelationalSnapshot(rawQuery, { websiteId, eventType, @@ -234,6 +241,7 @@ async function relationalQuery( endDate, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, + point, filterContext, }); @@ -250,6 +258,7 @@ async function getRelationalSnapshot( endDate, viewportW, viewportH, + point, filterContext, }: { websiteId: string; @@ -259,6 +268,7 @@ async function getRelationalSnapshot( endDate: Date; viewportW: number | null; viewportH: number | null; + point: SnapshotPoint | null; filterContext: HeatmapFilterContext; }, ): Promise { @@ -269,6 +279,12 @@ async function getRelationalSnapshot( and h.viewport_h = {{viewportH}} ` : ''; + const pointFilter = point + ? ` + and h.x = {{pointX}} + and h.y = {{pointY}} + ` + : ''; const rows: SnapshotRow[] = await rawQuery( ` @@ -284,6 +300,7 @@ async function getRelationalSnapshot( 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 @@ -306,6 +323,7 @@ async function getRelationalSnapshot( and h.url_path = {{urlPath}} and h.created_at between {{startDate}} and {{endDate}} ${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, @@ -313,7 +331,18 @@ async function getRelationalSnapshot( h.created_at asc limit 1 `, - { ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH }, + { + ...filterContext.queryParams, + websiteId, + eventType, + urlPath, + startDate, + endDate, + viewportW, + viewportH, + pointX: point?.x, + pointY: point?.y, + }, FUNCTION_NAME, ); @@ -438,6 +467,7 @@ async function clickhouseQuery( endDate, viewportW: scroll.viewportW, viewportH: scroll.viewportH, + point: null, filterContext, }); @@ -496,6 +526,7 @@ async function clickhouseQuery( })); const viewport = pickSnapshotViewport(points); + const point = pickRepresentativePoint(points, viewport); const snapshot = await getClickhouseSnapshot(rawQuery, { websiteId, eventType, @@ -504,6 +535,7 @@ async function clickhouseQuery( endDate, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, + point, filterContext, }); @@ -520,6 +552,7 @@ async function getClickhouseSnapshot( endDate, viewportW, viewportH, + point, filterContext, }: { websiteId: string; @@ -529,6 +562,7 @@ async function getClickhouseSnapshot( endDate: Date; viewportW: number | null; viewportH: number | null; + point: SnapshotPoint | null; filterContext: HeatmapFilterContext; }, ): Promise { @@ -539,6 +573,12 @@ 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( ` @@ -554,6 +594,7 @@ async function getClickhouseSnapshot( 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 @@ -576,6 +617,7 @@ async function getClickhouseSnapshot( and h.url_path = {urlPath:String} and h.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${viewportFilter} + ${pointFilter} order by isNull(h.replay_chunk_index) asc, h.replay_chunk_index asc, @@ -592,6 +634,8 @@ async function getClickhouseSnapshot( endDate, viewportW, viewportH, + pointX: point?.x, + pointY: point?.y, }, FUNCTION_NAME, ); @@ -632,6 +676,21 @@ function pickSnapshotViewport(points: HeatmapPoint[]): { width: number; height: return best ? { width: best.width, height: best.height } : null; } +function pickRepresentativePoint( + points: HeatmapPoint[], + viewport: { width: number; height: number } | null, +): SnapshotPoint | null { + if (!viewport) { + return null; + } + + const match = points.find( + point => point.viewportW === viewport.width && point.viewportH === viewport.height, + ); + + return match ? { x: match.x, y: match.y } : null; +} + function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null { if (!row) { return null;