Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.
Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */
|
||||
(function () {
|
||||
function colorFor(t) {
|
||||
// green -> yellow -> red
|
||||
if (t < 0.33) {
|
||||
var k = t / 0.33;
|
||||
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
|
||||
}
|
||||
if (t < 0.66) {
|
||||
var k2 = (t - 0.33) / 0.33;
|
||||
return [255, 235 - (235 - 152) * k2, 0];
|
||||
}
|
||||
var k3 = (t - 0.66) / 0.34;
|
||||
return [255, 152 * (1 - k3), 0];
|
||||
}
|
||||
|
||||
function paint() {
|
||||
var canvas = document.getElementById("admin-heatmap");
|
||||
var stage = document.getElementById("admin-heatmap-stage");
|
||||
if (!canvas || !stage) return;
|
||||
|
||||
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
|
||||
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
||||
var cells = [];
|
||||
try {
|
||||
cells = JSON.parse(canvas.getAttribute("data-cells") || "[]");
|
||||
} catch (e) {
|
||||
cells = [];
|
||||
}
|
||||
|
||||
var width = Math.max(stage.clientWidth, 320);
|
||||
var height = Math.max(stage.clientHeight, 480);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = width + "px";
|
||||
canvas.style.height = height + "px";
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
var cellW = width / grid;
|
||||
var cellH = height / grid;
|
||||
var radius = Math.max(cellW, cellH) * 1.8;
|
||||
|
||||
cells.forEach(function (cell) {
|
||||
var intensity = Math.min(1, cell.c / max);
|
||||
if (intensity <= 0) return;
|
||||
var cx = (cell.x + 0.5) * cellW;
|
||||
var cy = (cell.y + 0.5) * cellH;
|
||||
var rgb = colorFor(intensity);
|
||||
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
grd.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (0.2 + intensity * 0.55) + ")");
|
||||
grd.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", paint);
|
||||
} else {
|
||||
paint();
|
||||
}
|
||||
window.addEventListener("resize", paint);
|
||||
})();
|
||||
@@ -960,20 +960,54 @@ body.admin-body {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.admin-heatmap-wrap {
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
.admin-heatmap-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 70vh;
|
||||
max-height: 85vh;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
background: #0d0d12;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.admin-heatmap {
|
||||
display: block;
|
||||
.admin-heatmap-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-heatmap-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, #1e293b 0%, transparent 45%),
|
||||
radial-gradient(circle at 80% 10%, #0f172a 0%, transparent 40%),
|
||||
#0d0d12;
|
||||
}
|
||||
|
||||
.admin-heatmap-fallback-note {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.admin-heatmap-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/*! Match Live TV first-party site analytics (click + scroll). Requires analytics cookie consent. */
|
||||
/*! Match Live TV first-party site analytics (click + mouse move + scroll). Requires analytics cookie consent. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var ENDPOINT = "/analytics/events";
|
||||
var STORAGE_KEY = "mltv_cookie_consent";
|
||||
var COOKIE_NAME = "mltv_cookie_consent";
|
||||
var FLUSH_MS = 8000;
|
||||
var MAX_QUEUE = 40;
|
||||
var FLUSH_MS = 6000;
|
||||
var MAX_QUEUE = 60;
|
||||
var MOVE_THROTTLE_MS = 180;
|
||||
var MOVE_GRID = 40;
|
||||
|
||||
var queue = [];
|
||||
var moveBuckets = {};
|
||||
var maxScroll = 0;
|
||||
var flushTimer = null;
|
||||
var started = false;
|
||||
var lastMoveAt = 0;
|
||||
|
||||
function excludedPath(pathname) {
|
||||
var p = pathname || "/";
|
||||
@@ -44,6 +48,15 @@
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function pageSize() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
return {
|
||||
width: Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1),
|
||||
height: Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1)
|
||||
};
|
||||
}
|
||||
|
||||
function scrollPct() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
@@ -56,8 +69,7 @@
|
||||
doc.clientHeight || 0
|
||||
);
|
||||
var view = window.innerHeight || doc.clientHeight || 0;
|
||||
var maxScrollable = Math.max(height - view, 1);
|
||||
var pct = ((scrollTop + view) / height) * 100;
|
||||
var pct = ((scrollTop + view) / Math.max(height, 1)) * 100;
|
||||
if (scrollTop <= 0 && view >= height) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(pct)));
|
||||
}
|
||||
@@ -65,10 +77,31 @@
|
||||
function pushEvent(evt) {
|
||||
if (!started) return;
|
||||
queue.push(evt);
|
||||
if (queue.length >= MAX_QUEUE) flush(true);
|
||||
if (queue.length >= MAX_QUEUE) flush(false);
|
||||
}
|
||||
|
||||
function flushMovesIntoQueue() {
|
||||
var keys = Object.keys(moveBuckets);
|
||||
if (!keys.length) return;
|
||||
var device = deviceBucket();
|
||||
var path = location.pathname;
|
||||
var ts = Date.now();
|
||||
keys.forEach(function (key) {
|
||||
var parts = key.split(",");
|
||||
var cx = parseInt(parts[0], 10);
|
||||
var cy = parseInt(parts[1], 10);
|
||||
var n = moveBuckets[key];
|
||||
if (!n) return;
|
||||
// cell center in %
|
||||
var x = ((cx + 0.5) / MOVE_GRID) * 100;
|
||||
var y = ((cy + 0.5) / MOVE_GRID) * 100;
|
||||
pushEvent({ type: "move", path: path, device: device, x: x, y: y, n: n, ts: ts });
|
||||
});
|
||||
moveBuckets = {};
|
||||
}
|
||||
|
||||
function flush(useBeacon) {
|
||||
flushMovesIntoQueue();
|
||||
if (!queue.length || !hasAnalyticsConsent()) {
|
||||
queue = [];
|
||||
return;
|
||||
@@ -107,10 +140,7 @@
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
var width = Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1);
|
||||
var height = Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1);
|
||||
var size = pageSize();
|
||||
var x = event.pageX;
|
||||
var y = event.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") return;
|
||||
@@ -119,12 +149,28 @@
|
||||
type: "click",
|
||||
path: location.pathname,
|
||||
device: deviceBucket(),
|
||||
x: Math.max(0, Math.min(100, (x / width) * 100)),
|
||||
y: Math.max(0, Math.min(100, (y / height) * 100)),
|
||||
x: Math.max(0, Math.min(100, (x / size.width) * 100)),
|
||||
y: Math.max(0, Math.min(100, (y / size.height) * 100)),
|
||||
ts: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
var now = Date.now();
|
||||
if (now - lastMoveAt < MOVE_THROTTLE_MS) return;
|
||||
lastMoveAt = now;
|
||||
var size = pageSize();
|
||||
var x = event.pageX;
|
||||
var y = event.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") return;
|
||||
var xPct = Math.max(0, Math.min(100, (x / size.width) * 100));
|
||||
var yPct = Math.max(0, Math.min(100, (y / size.height) * 100));
|
||||
var cx = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((xPct / 100) * MOVE_GRID)));
|
||||
var cy = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((yPct / 100) * MOVE_GRID)));
|
||||
var key = cx + "," + cy;
|
||||
moveBuckets[key] = (moveBuckets[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
var pct = scrollPct();
|
||||
if (pct > maxScroll) maxScroll = pct;
|
||||
@@ -157,6 +203,7 @@
|
||||
maxScroll = scrollPct();
|
||||
trackPageview();
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("mousemove", onMouseMove, { passive: true });
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden") {
|
||||
|
||||
Reference in New Issue
Block a user