diff --git a/db/clickhouse/migrations/12_add_heatmap.sql b/db/clickhouse/migrations/12_add_heatmap.sql new file mode 100644 index 000000000..de6439236 --- /dev/null +++ b/db/clickhouse/migrations/12_add_heatmap.sql @@ -0,0 +1,21 @@ +-- Create heatmap_event +CREATE TABLE umami.heatmap_event +( + heatmap_event_id UUID, + website_id UUID, + session_id UUID, + visit_id UUID, + url_path String, + event_type UInt8, + node_id Nullable(Int32), + x Nullable(Int32), + y Nullable(Int32), + viewport_w Nullable(Int32), + viewport_h Nullable(Int32), + scroll_pct Nullable(UInt8), + created_at DateTime('UTC') +) +ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY (website_id, url_path, event_type, created_at) + SETTINGS index_granularity = 8192; diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 0540c97b2..f587825b8 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -344,3 +344,25 @@ AS SELECT groupArrayState(data_type) AS property_types FROM umami.event_data GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; + +-- Create heatmap_event +CREATE TABLE umami.heatmap_event +( + heatmap_event_id UUID, + website_id UUID, + session_id UUID, + visit_id UUID, + url_path String, + event_type UInt8, + node_id Nullable(Int32), + x Nullable(Int32), + y Nullable(Int32), + viewport_w Nullable(Int32), + viewport_h Nullable(Int32), + scroll_pct Nullable(UInt8), + created_at DateTime('UTC') +) +ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY (website_id, url_path, event_type, created_at) + SETTINGS index_granularity = 8192; diff --git a/prisma/migrations/20_add_heatmap/migration.sql b/prisma/migrations/20_add_heatmap/migration.sql new file mode 100644 index 000000000..10266df15 --- /dev/null +++ b/prisma/migrations/20_add_heatmap/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "heatmap_event" ( + "heatmap_event_id" UUID NOT NULL, + "website_id" UUID NOT NULL, + "session_id" UUID NOT NULL, + "visit_id" UUID NOT NULL, + "url_path" VARCHAR(500) NOT NULL, + "event_type" INTEGER NOT NULL, + "node_id" INTEGER, + "x" INTEGER, + "y" INTEGER, + "viewport_w" INTEGER, + "viewport_h" INTEGER, + "scroll_pct" INTEGER, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "heatmap_event_pkey" PRIMARY KEY ("heatmap_event_id") +); + +-- CreateIndex +CREATE INDEX "heatmap_event_website_id_idx" ON "heatmap_event"("website_id"); +CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id"); +CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at"); +CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63c81ec0d..9cc0d946d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -88,6 +88,7 @@ model Website { sessionData SessionData[] sessionReplays SessionReplay[] sessionReplaysSaved SessionReplaySaved[] + heatmapEvents HeatmapEvent[] @@index([userId]) @@index([teamId]) @@ -398,4 +399,28 @@ model SessionReplaySaved { @@index([visitId]) @@index([websiteId, createdAt]) @@map("session_replay_saved") +} + +model HeatmapEvent { + id String @id() @map("heatmap_event_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + sessionId String @map("session_id") @db.Uuid + visitId String @map("visit_id") @db.Uuid + urlPath String @map("url_path") @db.VarChar(500) + eventType Int @map("event_type") @db.Integer + nodeId Int? @map("node_id") @db.Integer + x Int? @db.Integer + y Int? @db.Integer + viewportW Int? @map("viewport_w") @db.Integer + viewportH Int? @map("viewport_h") @db.Integer + scrollPct Int? @map("scroll_pct") @db.Integer + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + website Website @relation(fields: [websiteId], references: [id]) + + @@index([websiteId]) + @@index([visitId]) + @@index([websiteId, createdAt]) + @@index([websiteId, urlPath, eventType, createdAt]) + @@map("heatmap_event") } \ No newline at end of file diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css new file mode 100644 index 000000000..edea449db --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -0,0 +1,80 @@ +.pageList { + overflow-y: auto; + padding-right: 8px; + max-height: 100%; +} + +.pageButton { + width: 100%; + text-align: left; + border: 0; + background: transparent; + border-radius: 6px; + padding: 8px 10px; + cursor: pointer; + font: inherit; + color: inherit; +} + +.pageButton:hover { + background: var(--interactive-bg-hover); +} + +.pageButtonSelected, +.pageButtonSelected:hover { + background: var(--surface-inverted); + color: var(--surface-base); +} + +.toggleButton { + border: 1px solid var(--border-base); + background: var(--surface-base); + border-radius: 6px; + padding: 4px 10px; + cursor: pointer; + font: inherit; + color: inherit; +} + +.toggleButton:hover { + background: var(--interactive-bg-hover); +} + +.canvas { + position: relative; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: 8px; + background: var(--surface-sunken); +} + +.iframe { + position: absolute; + top: 0; + left: 0; + border: 0; + transform-origin: top left; + pointer-events: none; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.dot { + position: absolute; + border-radius: 50%; + background: radial-gradient( + circle, + rgba(255, 60, 0, 0.9) 0%, + rgba(255, 200, 0, 0.6) 45%, + rgba(255, 200, 0, 0) 80% + ); + pointer-events: auto; + mix-blend-mode: multiply; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx new file mode 100644 index 000000000..5e71c29cf --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -0,0 +1,194 @@ +'use client'; +import { Column, Grid, Heading, Row, Text } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useResultQuery, useWebsite } from '@/components/hooks'; +import { formatLongNumber } from '@/lib/format'; +import type { HeatmapPoint, HeatmapResult } from '@/queries/sql'; +import styles from './Heatmap.module.css'; + +const RENDER_WIDTH = 1024; + +interface ViewportBucket { + width: number; + height: number; + count: number; +} + +interface HeatmapProps { + websiteId: string; + urlPath: string; + onUrlPathChange: (urlPath: string) => void; +} + +export function Heatmap({ websiteId, urlPath, onUrlPathChange }: HeatmapProps) { + const website = useWebsite(); + const { data, error, isLoading } = useResultQuery('heatmap', { + websiteId, + urlPath: urlPath || undefined, + }); + + const pages = data?.pages ?? []; + const points = data?.points ?? []; + + return ( + + + + + {urlPath ? ( + + ) : ( + + )} + + + + ); +} + +function PageList({ + pages, + selected, + onSelect, +}: { + pages: HeatmapResult['pages']; + selected: string; + onSelect: (urlPath: string) => void; +}) { + return ( + + Pages + {pages.length === 0 && No data yet} + {pages.map(p => ( + + ))} + + ); +} + +function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { + if (!points.length) return null; + const buckets = new Map(); + for (const p of points) { + const key = `${p.viewportW}x${p.viewportH}`; + const existing = buckets.get(key); + if (existing) { + existing.count += p.count; + } else { + buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count }); + } + } + let best: ViewportBucket | null = null; + for (const b of buckets.values()) { + if (!best || b.count > best.count) best = b; + } + return best; +} + +function HeatmapView({ + domain, + urlPath, + points, +}: { + domain: string | null; + urlPath: string; + points: HeatmapPoint[]; +}) { + const [showPage, setShowPage] = useState(true); + + const viewport = useMemo(() => pickViewport(points), [points]); + + const visible = useMemo(() => { + if (!viewport) return []; + return points.filter(p => p.viewportW === viewport.width && p.viewportH === viewport.height); + }, [points, viewport]); + + const maxCount = useMemo( + () => visible.reduce((m, p) => (p.count > m ? p.count : m), 1), + [visible], + ); + + if (!viewport || visible.length === 0) { + return ; + } + + const scale = RENDER_WIDTH / viewport.width; + const renderHeight = Math.round(viewport.height * scale); + const iframeSrc = domain ? `https://${domain}${urlPath}` : null; + + return ( + + + + {visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '} + clicks · viewport {viewport.width}×{viewport.height} + + {iframeSrc && ( + + )} + +
+ {showPage && iframeSrc && ( +