Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.

Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 23:14:47 +02:00
co-authored by Cursor
parent 406a90ae5b
commit 70fccc493e
25 changed files with 442 additions and 73 deletions
@@ -75,7 +75,7 @@ module Admin
@max_scroll = stats.maximum(:max_scroll_pct).to_i @max_scroll = stats.maximum(:max_scroll_pct).to_i
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0 @avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
@grid = AnalyticsPageCell::GRID_SIZE @grid = AnalyticsPageCell::GRID_SIZE
@preview_url = preview_url_for(@page_path) @snapshot = find_snapshot(@page_path, @filters[:device])
end end
private private
@@ -88,11 +88,14 @@ module Admin
nil nil
end end
def preview_url_for(path) def find_snapshot(page_path, device)
return nil if path.blank? scope = AnalyticsPageSnapshot.where(page_path: page_path)
return nil if path.include?(":") if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
end
"#{MatchLiveTv.app_public_url.chomp('/')}#{path}" scope.order(captured_at: :desc).detect { |s| s.image.attached? }
end end
end end
end end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Analytics
class SnapshotsController < ActionController::API
def create
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
width: params[:width],
height: params[:height],
image: params[:image],
remote_ip: request.remote_ip
).call
return head :too_many_requests if result.rate_limited
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class AnalyticsPageSnapshot < ApplicationRecord
self.table_name = "analytics_page_snapshots"
has_one_attached :image
validates :page_path, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :width, :height, numericality: { greater_than: 0 }
validates :captured_at, presence: true
end
@@ -21,6 +21,10 @@ module Analytics
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
AnalyticsPageSnapshot.where("captured_at < ?", retention.ago).find_each do |snap|
snap.image.purge if snap.image.attached?
snap.destroy!
end
end end
private private
@@ -0,0 +1,84 @@
# frozen_string_literal: true
module Analytics
class SnapshotIngest
MAX_BYTES = 1_800_000
MIN_REFRESH = 12.hours
RATE_LIMIT_PER_HOUR = 20
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
def initialize(path:, device:, width:, height:, image:, remote_ip:)
@path = path
@device = device.to_s
@width = width
@height = height
@image = image
@remote_ip = remote_ip.to_s.presence || "unknown"
end
def call
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
page_path = PathNormalizer.normalize(@path)
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
width = Integer(@width)
height = Integer(@height)
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
return Result.new(ok: true, skipped: true)
end
snap.width = width
snap.height = height
snap.captured_at = Time.current
snap.save!
snap.image.purge if snap.image.attached?
snap.image.attach(
io: image_io,
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
content_type: "image/jpeg"
)
Result.new(ok: true, skipped: false)
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
Result.new(ok: false, skipped: true, error: e.class.name)
end
private
def rate_limited?
key = "analytics:snapshot:#{@remote_ip}"
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
count = redis.incr(key)
redis.expire(key, 3600) if count == 1
count > RATE_LIMIT_PER_HOUR
rescue StandardError => e
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
false
end
def valid_image?
return false if @image.blank?
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
content_type.blank? || content_type.start_with?("image/")
end
def image_io
if @image.respond_to?(:tempfile)
@image.tempfile.rewind
@image.tempfile
else
StringIO.new(@image.read)
end
end
end
end
+29 -13
View File
@@ -95,18 +95,17 @@
<% if @cells.any? %> <% if @cells.any? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage"> <div class="admin-heatmap-stage" id="admin-heatmap-stage">
<% if @preview_url.present? %> <% if @snapshot&.image&.attached? %>
<iframe <div
class="admin-heatmap-frame" class="admin-heatmap-scaler"
src="<%= @preview_url %>" id="admin-heatmap-scaler"
title="<%= t("admin.analytics.show.preview_title") %>" data-design-width="<%= [@snapshot.width, 1].max %>"
loading="lazy" data-design-height="<%= [@snapshot.height, 1].max %>"
referrerpolicy="no-referrer" >
></iframe> <%= image_tag url_for(@snapshot.image),
<% else %> class: "admin-heatmap-shot",
<div class="admin-heatmap-fallback" aria-hidden="true"></div> id: "admin-heatmap-shot",
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.preview_unavailable") %></p> alt: t("admin.analytics.show.preview_title") %>
<% end %>
<canvas <canvas
id="admin-heatmap" id="admin-heatmap"
class="admin-heatmap-overlay" class="admin-heatmap-overlay"
@@ -115,7 +114,24 @@
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>" data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
></canvas> ></canvas>
</div> </div>
<script src="/admin-analytics-heatmap.js?v=2" defer></script> <p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta",
device: @snapshot.device,
at: l(@snapshot.captured_at, format: :short)) %>
</p>
<% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
<canvas
id="admin-heatmap"
class="admin-heatmap-overlay"
data-grid="<%= @grid %>"
data-max="<%= [@max_weight, 1].max %>"
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
></canvas>
<% end %>
</div>
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
<% else %> <% else %>
<p class="empty"><%= t("admin.analytics.show.no_points") %></p> <p class="empty"><%= t("admin.analytics.show.no_points") %></p>
<% end %> <% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=13"> <link rel="stylesheet" href="/admin.css?v=15">
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
<% end %> <% end %>
+1 -1
View File
@@ -26,7 +26,7 @@
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=3" defer></script> <script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=2" defer></script>
</body> </body>
</html> </html>
@@ -21,7 +21,7 @@
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=3" defer></script> <script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=2" defer></script>
</body> </body>
</html> </html>
+5 -3
View File
@@ -383,9 +383,11 @@ de:
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf der Live-Seite (aktuelles Layout). Grün = wenig, Rot = viel. heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
preview_title: Seitenvorschau preview_title: Seiten-Screenshot
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). Nur Overlay. preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
snapshot_meta: "Screenshot %{device} · %{at}"
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum. no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
+5 -3
View File
@@ -383,9 +383,11 @@ en:
scroll_hint: "Average %{avg}% · max %{max}%" scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map heatmap_moves: Mouse-move map
heatmap_clicks: Click map heatmap_clicks: Click map
heatmap_hint: Colored overlay on the live page (current layout). Green = low, red = high. heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
preview_title: Page preview preview_title: Page screenshot
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). Overlay only. preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
snapshot_meta: "Screenshot %{device} · captured %{at}"
no_points: No aggregated points for this layer in the period. no_points: No aggregated points for this layer in the period.
heatmap_title: Click map heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period. no_clicks: No aggregated clicks for this page in the period.
+5 -3
View File
@@ -383,9 +383,11 @@ es:
scroll_hint: "Media %{avg}% · máximo %{max}%" scroll_hint: "Media %{avg}% · máximo %{max}%"
heatmap_moves: Mapa de movimientos heatmap_moves: Mapa de movimientos
heatmap_clicks: Mapa de clics heatmap_clicks: Mapa de clics
heatmap_hint: Superposición de color sobre la página en vivo (layout actual). Verde = poco, rojo = mucho. heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
preview_title: Vista previa de la página preview_title: Captura de página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). Solo overlay. preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
snapshot_meta: "Captura %{device} · %{at}"
no_points: No hay puntos agregados para esta capa en el periodo. no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo. no_clicks: No hay clics agregados para esta página en el periodo.
+5 -3
View File
@@ -383,9 +383,11 @@ fr:
scroll_hint: "Moyenne %{avg}% · maximum %{max}%" scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la page live (mise en page actuelle). Vert = faible, rouge = fort. heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
preview_title: Aperçu de la page preview_title: Capture de page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). Overlay seul. preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
snapshot_meta: "Capture %{device} · %{at}"
no_points: Aucun point agrégé pour cette couche sur la période. no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période. no_clicks: Aucun clic agrégé pour cette page sur la période.
+5 -3
View File
@@ -404,9 +404,11 @@ it:
scroll_hint: "Media %{avg}% · massimo %{max}%" scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sulla pagina live (layout attuale). Verde = poco, rosso = molto. heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
preview_title: Anteprima pagina preview_title: Screenshot pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). Mostra solo loverlay. preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
snapshot_meta: "Screenshot %{device} · catturato %{at}"
no_points: Nessun punto aggregato per questo livello nel periodo. no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo. no_clicks: Nessun click aggregato per questa pagina nel periodo.
+1 -1
View File
@@ -186,7 +186,7 @@ en:
s4_2_row3_duration: 24 hours s4_2_row3_duration: 24 hours
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (first-party)" s4_2_row4_name_html: "Heatmap / scroll (first-party)"
s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts by page and device type (no user identifiers) s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts, plus page screenshots for heatmaps (no user identifiers)
s4_2_row4_duration: Aggregates kept up to 30 days s4_2_row4_duration: Aggregates kept up to 30 days
s4_2_row4_provider: Match Live TV (first-party) s4_2_row4_provider: Match Live TV (first-party)
s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}."
+1 -1
View File
@@ -186,7 +186,7 @@ it:
s4_2_row3_duration: 24 ore s4_2_row3_duration: 24 ore
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (prima parte)" s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente) s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll, più screenshot della pagina per le heatmaps (nessun identificativo utente)
s4_2_row4_duration: Aggregati fino a 30 giorni s4_2_row4_duration: Aggregati fino a 30 giorni
s4_2_row4_provider: Match Live TV (prima parte) s4_2_row4_provider: Match Live TV (prima parte)
s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}."
+1
View File
@@ -77,6 +77,7 @@ Rails.application.routes.draw do
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish" post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
post "analytics/events", to: "analytics/events#create" post "analytics/events", to: "analytics/events#create"
post "analytics/snapshot", to: "analytics/snapshots#create"
namespace :admin do namespace :admin do
get "login", to: "auth#new", as: :login get "login", to: "auth#new", as: :login
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateAnalyticsPageSnapshots < ActiveRecord::Migration[7.2]
def change
create_table :analytics_page_snapshots, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :page_path, null: false
t.string :device, null: false
t.integer :width, null: false, default: 0
t.integer :height, null: false, default: 0
t.datetime :captured_at, null: false
t.timestamps
end
add_index :analytics_page_snapshots, %i[page_path device], unique: true, name: "index_analytics_page_snapshots_unique"
end
end
+12 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -79,6 +79,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do
t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true
end end
create_table "analytics_page_snapshots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "page_path", null: false
t.string "device", null: false
t.integer "width", default: 0, null: false
t.integer "height", default: 0, null: false
t.datetime "captured_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["page_path", "device"], name: "index_analytics_page_snapshots_unique", unique: true
end
create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.date "day", null: false t.date "day", null: false
t.string "page_path", null: false t.string "page_path", null: false
+56 -18
View File
@@ -1,7 +1,6 @@
/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */ /*! Admin heatmap overlay on captured page screenshot. */
(function () { (function () {
function colorFor(t) { function colorFor(t) {
// green -> yellow -> red
if (t < 0.33) { if (t < 0.33) {
var k = t / 0.33; var k = t / 0.33;
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)]; return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
@@ -14,11 +13,36 @@
return [255, 152 * (1 - k3), 0]; return [255, 152 * (1 - k3), 0];
} }
function paint() { function fit() {
var canvas = document.getElementById("admin-heatmap"); var canvas = document.getElementById("admin-heatmap");
var stage = document.getElementById("admin-heatmap-stage"); var stage = document.getElementById("admin-heatmap-stage");
var scaler = document.getElementById("admin-heatmap-scaler");
var shot = document.getElementById("admin-heatmap-shot");
if (!canvas || !stage) return; if (!canvas || !stage) return;
var designW = 1200;
var designH = 900;
if (scaler) {
designW = parseInt(scaler.getAttribute("data-design-width"), 10) || designW;
designH = parseInt(scaler.getAttribute("data-design-height"), 10) || designH;
}
if (shot && shot.naturalWidth > 0) {
designW = shot.naturalWidth;
designH = shot.naturalHeight;
}
if (scaler) {
scaler.style.width = designW + "px";
scaler.style.height = designH + "px";
var stageW = Math.max(stage.clientWidth, 320);
var scale = Math.min(1, stageW / designW);
scaler.style.transform = "scale(" + scale + ")";
stage.style.height = Math.max(420, Math.round(designH * scale)) + "px";
} else {
designW = Math.max(stage.clientWidth, 320);
designH = Math.max(stage.clientHeight, 480);
}
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40; var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1; var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
var cells = []; var cells = [];
@@ -28,19 +52,17 @@
cells = []; cells = [];
} }
var width = Math.max(stage.clientWidth, 320); canvas.width = designW;
var height = Math.max(stage.clientHeight, 480); canvas.height = designH;
canvas.width = width; canvas.style.width = designW + "px";
canvas.height = height; canvas.style.height = designH + "px";
canvas.style.width = width + "px";
canvas.style.height = height + "px";
var ctx = canvas.getContext("2d"); var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, designW, designH);
var cellW = width / grid; var cellW = designW / grid;
var cellH = height / grid; var cellH = designH / grid;
var radius = Math.max(cellW, cellH) * 1.8; var radius = Math.max(cellW, cellH) * 2.2;
cells.forEach(function (cell) { cells.forEach(function (cell) {
var intensity = Math.min(1, cell.c / max); var intensity = Math.min(1, cell.c / max);
@@ -48,9 +70,16 @@
var cx = (cell.x + 0.5) * cellW; var cx = (cell.x + 0.5) * cellW;
var cy = (cell.y + 0.5) * cellH; var cy = (cell.y + 0.5) * cellH;
var rgb = colorFor(intensity); var rgb = colorFor(intensity);
var alpha = 0.28 + intensity * 0.55;
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); 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(
grd.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)"); 0,
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + "," + alpha + ")"
);
grd.addColorStop(
1,
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + ",0)"
);
ctx.fillStyle = grd; ctx.fillStyle = grd;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.arc(cx, cy, radius, 0, Math.PI * 2);
@@ -58,10 +87,19 @@
}); });
} }
function boot() {
var shot = document.getElementById("admin-heatmap-shot");
fit();
if (shot) {
if (shot.complete && shot.naturalWidth > 0) fit();
else shot.addEventListener("load", fit);
}
window.addEventListener("resize", fit);
}
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", paint); document.addEventListener("DOMContentLoaded", boot);
} else { } else {
paint(); boot();
} }
window.addEventListener("resize", paint);
})(); })();
+17 -11
View File
@@ -962,22 +962,29 @@ body.admin-body {
.admin-heatmap-stage { .admin-heatmap-stage {
position: relative; position: relative;
overflow: hidden; overflow: auto;
width: 100%;
min-height: 70vh; min-height: 70vh;
max-height: 85vh; max-height: 85vh;
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 10px; border-radius: 10px;
background: #111; background: #d7dbe2;
} }
.admin-heatmap-frame { .admin-heatmap-scaler {
position: absolute; position: relative;
inset: 0; transform-origin: top left;
width: 100%;
height: 100%;
border: 0;
background: #fff; background: #fff;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
}
.admin-heatmap-shot {
display: block;
width: 100%;
height: auto;
vertical-align: top;
pointer-events: none; pointer-events: none;
user-select: none;
} }
.admin-heatmap-fallback { .admin-heatmap-fallback {
@@ -1003,9 +1010,8 @@ body.admin-body {
.admin-heatmap-overlay { .admin-heatmap-overlay {
position: absolute; position: absolute;
inset: 0; left: 0;
width: 100%; top: 0;
height: 100%;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
+84
View File
@@ -3,12 +3,14 @@
"use strict"; "use strict";
var ENDPOINT = "/analytics/events"; var ENDPOINT = "/analytics/events";
var SNAPSHOT_ENDPOINT = "/analytics/snapshot";
var STORAGE_KEY = "mltv_cookie_consent"; var STORAGE_KEY = "mltv_cookie_consent";
var COOKIE_NAME = "mltv_cookie_consent"; var COOKIE_NAME = "mltv_cookie_consent";
var FLUSH_MS = 6000; var FLUSH_MS = 6000;
var MAX_QUEUE = 60; var MAX_QUEUE = 60;
var MOVE_THROTTLE_MS = 180; var MOVE_THROTTLE_MS = 180;
var MOVE_GRID = 40; var MOVE_GRID = 40;
var SNAPSHOT_DELAY_MS = 4000;
var queue = []; var queue = [];
var moveBuckets = {}; var moveBuckets = {};
@@ -195,6 +197,87 @@
}, FLUSH_MS); }, FLUSH_MS);
} }
function snapshotStorageKey() {
return "mltv_snap:" + location.pathname + ":" + deviceBucket();
}
function todayKey() {
var d = new Date();
return d.getUTCFullYear() + "-" + (d.getUTCMonth() + 1) + "-" + d.getUTCDate();
}
function alreadyCapturedToday() {
try {
return localStorage.getItem(snapshotStorageKey()) === todayKey();
} catch (e) {
return false;
}
}
function markCapturedToday() {
try {
localStorage.setItem(snapshotStorageKey(), todayKey());
} catch (e) { /* ignore */ }
}
function loadHtml2Canvas(cb) {
if (window.html2canvas) {
cb(window.html2canvas);
return;
}
var s = document.createElement("script");
s.src = "/vendor/html2canvas.min.js";
s.async = true;
s.onload = function () {
if (window.html2canvas) cb(window.html2canvas);
};
s.onerror = function () { /* ignore */ };
document.head.appendChild(s);
}
function captureSnapshot() {
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
loadHtml2Canvas(function (html2canvas) {
var size = pageSize();
html2canvas(document.documentElement, {
scale: Math.min(1, 1400 / Math.max(size.width, 1)),
useCORS: true,
allowTaint: false,
logging: false,
windowWidth: size.width,
windowHeight: size.height,
scrollX: 0,
scrollY: 0
})
.then(function (canvas) {
if (!canvas || !canvas.toBlob) return;
canvas.toBlob(
function (blob) {
if (!blob || blob.size < 8000 || blob.size > 1800000) return;
var fd = new FormData();
fd.append("image", blob, "snapshot.jpg");
fd.append("path", location.pathname);
fd.append("device", deviceBucket());
fd.append("width", String(Math.round(size.width)));
fd.append("height", String(Math.round(size.height)));
fetch(SNAPSHOT_ENDPOINT, {
method: "POST",
body: fd,
credentials: "same-origin"
})
.then(function (res) {
if (res.ok) markCapturedToday();
})
.catch(function () { /* ignore */ });
},
"image/jpeg",
0.72
);
})
.catch(function () { /* ignore */ });
});
}
function start() { function start() {
if (started) return; if (started) return;
if (excludedPath(location.pathname)) return; if (excludedPath(location.pathname)) return;
@@ -216,6 +299,7 @@
flush(true); flush(true);
}); });
scheduleFlush(); scheduleFlush();
setTimeout(captureSnapshot, SNAPSHOT_DELAY_MS);
} }
window.mltvSiteAnalyticsStart = start; window.mltvSiteAnalyticsStart = start;
File diff suppressed because one or more lines are too long
@@ -39,7 +39,6 @@ RSpec.describe "Admin analytics", type: :request do
get admin_analytics_page_path, params: { page_path: "/prezzi" } get admin_analytics_page_path, params: { page_path: "/prezzi" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("admin-heatmap") expect(response.body).to include("admin-heatmap")
expect(response.body).to include("admin-heatmap-frame")
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
end end
end end
@@ -0,0 +1,44 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Analytics snapshots", type: :request do
def jpeg_upload
path = Rails.root.join("tmp/analytics-snap-test.jpg")
FileUtils.mkdir_p(path.dirname)
# minimal valid-ish jpeg bytes for ActiveStorage
File.binwrite(path, "\xFF\xD8\xFF\xE0#{'x' * 12_000}\xFF\xD9")
Rack::Test::UploadedFile.new(path, "image/jpeg")
end
it "accetta uno screenshot e lo associa al path" do
post "/analytics/snapshot",
params: {
path: "/prezzi",
device: "desktop",
width: 1440,
height: 2200,
image: jpeg_upload
}
expect(response).to have_http_status(:accepted)
snap = AnalyticsPageSnapshot.find_by(page_path: "/prezzi", device: "desktop")
expect(snap).to be_present
expect(snap.image).to be_attached
expect(snap.width).to eq(1440)
end
it "rifiuta path esclusi" do
post "/analytics/snapshot",
params: {
path: "/admin/analytics",
device: "desktop",
width: 1440,
height: 900,
image: jpeg_upload
}
expect(response).to have_http_status(:unprocessable_content)
expect(AnalyticsPageSnapshot.count).to eq(0)
end
end