heatmaps UI polish, finish feature gate + messaging, overlay bug fixes

This commit is contained in:
Francis Cao
2026-05-14 15:18:29 -07:00
parent cd20b3aafd
commit aa9b6c0ca3
4 changed files with 134 additions and 42 deletions
@@ -160,10 +160,6 @@
pointer-events: none;
}
.heatOverlay {
overflow: visible;
}
.canvasLoading {
position: absolute;
inset: 0;
@@ -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 (
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
<EmptyState message="No data available." />
</LoadingPanel>
);
}
return (
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
<Grid columns="320px 12px 1fr" minHeight="900px" className={styles.layoutGrid}>
@@ -167,15 +175,11 @@ function PageList({
mode: HeatmapMode;
hasSearch: boolean;
}) {
const { t, messages } = useMessages();
return (
<Column className={styles.pageList} gap="1">
<Heading size="lg">Pages</Heading>
<Column className={styles.pageListItems} gap="2">
{pages.length === 0 && (
<Text color="muted">{hasSearch ? 'No matching pages' : t(messages.noDataAvailable)}</Text>
)}
{pages.length === 0 && hasSearch && <Text color="muted">No matching pages</Text>}
{pages.map(p => (
<button
key={p.urlPath}
@@ -295,29 +299,29 @@ function HeatmapView({
onReady={handleSnapshotReady}
/>
)}
{showOverlay && (
<div className={styles.overlay}>
{visible.map((p, i) => {
const intensity = Math.min(1, p.count / maxCount);
const size = 24 + intensity * 36;
return (
<div
key={`${p.x}-${p.y}-${i}`}
className={styles.dot}
style={{
left: p.x * scale - size / 2,
top: p.y * scale - size / 2,
width: size,
height: size,
opacity: 0.25 + intensity * 0.55,
}}
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
/>
);
})}
</div>
)}
</div>
{showOverlay && (
<div className={`${styles.overlay} ${styles.heatOverlay}`}>
{visible.map((p, i) => {
const intensity = Math.min(1, p.count / maxCount);
const size = 24 + intensity * 36;
return (
<div
key={`${p.x}-${p.y}-${i}`}
className={styles.dot}
style={{
left: p.x * scale - size / 2,
top: p.y * scale - size / 2,
width: size,
height: size,
opacity: 0.25 + intensity * 0.55,
}}
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
/>
);
})}
</div>
)}
</div>
</div>
{snapshot && (
@@ -560,12 +564,8 @@ function ReplaySnapshot({
void finalize();
});
replayer.on('resize', (dimension: { width?: number; height?: number }) => {
resizeReplayFrame(
replayer,
dimension.width && Number.isFinite(dimension.width) ? dimension.width : width,
dimension.height && Number.isFinite(dimension.height) ? dimension.height : height,
);
replayer.on('resize', () => {
resizeReplayFrame(replayer, width, height);
});
setTimeout(() => {
@@ -661,6 +661,12 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
if (wrapper) {
wrapper.style.width = `${width}px`;
wrapper.style.height = `${height}px`;
wrapper.style.minWidth = `${width}px`;
wrapper.style.minHeight = `${height}px`;
wrapper.style.maxWidth = `${width}px`;
wrapper.style.maxHeight = `${height}px`;
wrapper.style.margin = '0';
wrapper.style.padding = '0';
wrapper.style.overflow = 'hidden';
}
@@ -669,6 +675,11 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
iframe.setAttribute('height', String(height));
iframe.style.width = `${width}px`;
iframe.style.height = `${height}px`;
iframe.style.minWidth = `${width}px`;
iframe.style.minHeight = `${height}px`;
iframe.style.maxWidth = `${width}px`;
iframe.style.maxHeight = `${height}px`;
iframe.style.margin = '0';
iframe.style.display = 'block';
}
@@ -678,7 +689,7 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
function EmptyState({ message }: { message?: string } = {}) {
return (
<Column alignItems="center" justifyContent="center" minHeight="360px" gap>
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
{!message && <Heading size="lg">Select a page</Heading>}
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
</Column>
);
@@ -1,9 +1,11 @@
'use client';
import { Column, Row, SearchField } from '@umami/react-zen';
import { Button, Column, Row, SearchField } from '@umami/react-zen';
import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { Panel } from '@/components/common/Panel';
import { useMobile } from '@/components/hooks';
import { useMessages, useMobile, useSubscription, useWebsite } from '@/components/hooks';
import { Flame } from '@/components/icons';
import { FilterButtons } from '@/components/input/FilterButtons';
import type { HeatmapMode } from '@/queries/sql';
import { Heatmap } from './Heatmap';
@@ -17,12 +19,36 @@ export function HeatmapsPage({ websiteId }: { websiteId: string }) {
const [mode, setMode] = useState<HeatmapMode>('click');
const [search, setSearch] = useState('');
const { isPhone } = useMobile();
const website = useWebsite();
const { t, labels, messages } = useMessages();
const { hasFeature, cloudMode } = useSubscription(website?.teamId);
const buttons = [
{ id: 'click', label: 'Clicks' },
{ id: 'scroll', label: 'Scroll' },
];
if (cloudMode && !hasFeature('replays')) {
return (
<Column gap="3">
<Panel>
<EmptyPlaceholder
icon={<Flame />}
title={t(messages.upgradeRequired, { plan: 'Business' })}
description="View click and scroll heatmaps for your pages."
>
<Button
variant="primary"
onPress={() => window.open(`${process.env.cloudUrl}/settings/billing`, '_blank')}
>
{t(labels.upgrade)}
</Button>
</EmptyPlaceholder>
</Panel>
</Column>
);
}
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
+60 -1
View File
@@ -80,6 +80,11 @@ interface HeatmapFilterContext {
queryParams: Record<string, any>;
}
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<HeatmapSnapshot | null> {
@@ -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<HeatmapSnapshot | null> {
@@ -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<SnapshotRow[]>(
`
@@ -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;