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:
@@ -75,7 +75,7 @@ module Admin
|
||||
@max_scroll = stats.maximum(:max_scroll_pct).to_i
|
||||
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
|
||||
@grid = AnalyticsPageCell::GRID_SIZE
|
||||
@preview_url = preview_url_for(@page_path)
|
||||
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||
end
|
||||
|
||||
private
|
||||
@@ -88,11 +88,14 @@ module Admin
|
||||
nil
|
||||
end
|
||||
|
||||
def preview_url_for(path)
|
||||
return nil if path.blank?
|
||||
return nil if path.include?(":")
|
||||
def find_snapshot(page_path, device)
|
||||
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
AnalyticsPageCell.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
|
||||
|
||||
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
|
||||
@@ -95,27 +95,43 @@
|
||||
|
||||
<% if @cells.any? %>
|
||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||
<% if @preview_url.present? %>
|
||||
<iframe
|
||||
class="admin-heatmap-frame"
|
||||
src="<%= @preview_url %>"
|
||||
title="<%= t("admin.analytics.show.preview_title") %>"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
<% if @snapshot&.image&.attached? %>
|
||||
<div
|
||||
class="admin-heatmap-scaler"
|
||||
id="admin-heatmap-scaler"
|
||||
data-design-width="<%= [@snapshot.width, 1].max %>"
|
||||
data-design-height="<%= [@snapshot.height, 1].max %>"
|
||||
>
|
||||
<%= image_tag url_for(@snapshot.image),
|
||||
class: "admin-heatmap-shot",
|
||||
id: "admin-heatmap-shot",
|
||||
alt: t("admin.analytics.show.preview_title") %>
|
||||
<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>
|
||||
</div>
|
||||
<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.preview_unavailable") %></p>
|
||||
<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 %>
|
||||
<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>
|
||||
</div>
|
||||
<script src="/admin-analytics-heatmap.js?v=2" defer></script>
|
||||
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<%= 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) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -383,9 +383,11 @@ de:
|
||||
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
||||
heatmap_moves: Mausbewegungs-Karte
|
||||
heatmap_clicks: Klickkarte
|
||||
heatmap_hint: Farbiges Overlay auf der Live-Seite (aktuelles Layout). Grün = wenig, Rot = viel.
|
||||
preview_title: Seitenvorschau
|
||||
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). Nur Overlay.
|
||||
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
|
||||
preview_title: Seiten-Screenshot
|
||||
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.
|
||||
heatmap_title: Klickkarte
|
||||
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
||||
|
||||
@@ -383,9 +383,11 @@ en:
|
||||
scroll_hint: "Average %{avg}% · max %{max}%"
|
||||
heatmap_moves: Mouse-move map
|
||||
heatmap_clicks: Click map
|
||||
heatmap_hint: Colored overlay on the live page (current layout). Green = low, red = high.
|
||||
preview_title: Page preview
|
||||
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). Overlay only.
|
||||
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
|
||||
preview_title: Page screenshot
|
||||
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.
|
||||
heatmap_title: Click map
|
||||
no_clicks: No aggregated clicks for this page in the period.
|
||||
|
||||
@@ -383,9 +383,11 @@ es:
|
||||
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
||||
heatmap_moves: Mapa de movimientos
|
||||
heatmap_clicks: Mapa de clics
|
||||
heatmap_hint: Superposición de color sobre la página en vivo (layout actual). Verde = poco, rojo = mucho.
|
||||
preview_title: Vista previa de la página
|
||||
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). Solo overlay.
|
||||
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
|
||||
preview_title: Captura de página
|
||||
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.
|
||||
heatmap_title: Mapa de clics
|
||||
no_clicks: No hay clics agregados para esta página en el periodo.
|
||||
|
||||
@@ -383,9 +383,11 @@ fr:
|
||||
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
||||
heatmap_moves: Carte des mouvements
|
||||
heatmap_clicks: Carte des clics
|
||||
heatmap_hint: Superposition colorée sur la page live (mise en page actuelle). Vert = faible, rouge = fort.
|
||||
preview_title: Aperçu de la page
|
||||
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). Overlay seul.
|
||||
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
|
||||
preview_title: Capture de page
|
||||
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.
|
||||
heatmap_title: Carte des clics
|
||||
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
||||
|
||||
@@ -404,9 +404,11 @@ it:
|
||||
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
||||
heatmap_moves: Mappa movimenti mouse
|
||||
heatmap_clicks: Mappa click
|
||||
heatmap_hint: Overlay colorato sulla pagina live (layout attuale). Verde = poco, rosso = molto.
|
||||
preview_title: Anteprima pagina
|
||||
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). Mostra solo l’overlay.
|
||||
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
|
||||
preview_title: Screenshot pagina
|
||||
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.
|
||||
heatmap_title: Mappa click
|
||||
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
||||
|
||||
@@ -186,7 +186,7 @@ en:
|
||||
s4_2_row3_duration: 24 hours
|
||||
s4_2_row3_provider: Google
|
||||
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_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}."
|
||||
|
||||
@@ -186,7 +186,7 @@ it:
|
||||
s4_2_row3_duration: 24 ore
|
||||
s4_2_row3_provider: Google
|
||||
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_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}."
|
||||
|
||||
@@ -77,6 +77,7 @@ Rails.application.routes.draw do
|
||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
||||
|
||||
post "analytics/events", to: "analytics/events#create"
|
||||
post "analytics/snapshot", to: "analytics/snapshots#create"
|
||||
|
||||
namespace :admin do
|
||||
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
|
||||
Generated
+12
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# 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
|
||||
enable_extension "pgcrypto"
|
||||
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
|
||||
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|
|
||||
t.date "day", null: false
|
||||
t.string "page_path", null: false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */
|
||||
/*! Admin heatmap overlay on captured page screenshot. */
|
||||
(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)];
|
||||
@@ -14,11 +13,36 @@
|
||||
return [255, 152 * (1 - k3), 0];
|
||||
}
|
||||
|
||||
function paint() {
|
||||
function fit() {
|
||||
var canvas = document.getElementById("admin-heatmap");
|
||||
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;
|
||||
|
||||
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 max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
||||
var cells = [];
|
||||
@@ -28,19 +52,17 @@
|
||||
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";
|
||||
canvas.width = designW;
|
||||
canvas.height = designH;
|
||||
canvas.style.width = designW + "px";
|
||||
canvas.style.height = designH + "px";
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.clearRect(0, 0, designW, designH);
|
||||
|
||||
var cellW = width / grid;
|
||||
var cellH = height / grid;
|
||||
var radius = Math.max(cellW, cellH) * 1.8;
|
||||
var cellW = designW / grid;
|
||||
var cellH = designH / grid;
|
||||
var radius = Math.max(cellW, cellH) * 2.2;
|
||||
|
||||
cells.forEach(function (cell) {
|
||||
var intensity = Math.min(1, cell.c / max);
|
||||
@@ -48,9 +70,16 @@
|
||||
var cx = (cell.x + 0.5) * cellW;
|
||||
var cy = (cell.y + 0.5) * cellH;
|
||||
var rgb = colorFor(intensity);
|
||||
var alpha = 0.28 + intensity * 0.55;
|
||||
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)");
|
||||
grd.addColorStop(
|
||||
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.beginPath();
|
||||
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") {
|
||||
document.addEventListener("DOMContentLoaded", paint);
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
paint();
|
||||
boot();
|
||||
}
|
||||
window.addEventListener("resize", paint);
|
||||
})();
|
||||
|
||||
+17
-11
@@ -962,22 +962,29 @@ body.admin-body {
|
||||
|
||||
.admin-heatmap-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
min-height: 70vh;
|
||||
max-height: 85vh;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
background: #111;
|
||||
background: #d7dbe2;
|
||||
}
|
||||
|
||||
.admin-heatmap-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
.admin-heatmap-scaler {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
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;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-heatmap-fallback {
|
||||
@@ -1003,9 +1010,8 @@ body.admin-body {
|
||||
|
||||
.admin-heatmap-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"use strict";
|
||||
|
||||
var ENDPOINT = "/analytics/events";
|
||||
var SNAPSHOT_ENDPOINT = "/analytics/snapshot";
|
||||
var STORAGE_KEY = "mltv_cookie_consent";
|
||||
var COOKIE_NAME = "mltv_cookie_consent";
|
||||
var FLUSH_MS = 6000;
|
||||
var MAX_QUEUE = 60;
|
||||
var MOVE_THROTTLE_MS = 180;
|
||||
var MOVE_GRID = 40;
|
||||
var SNAPSHOT_DELAY_MS = 4000;
|
||||
|
||||
var queue = [];
|
||||
var moveBuckets = {};
|
||||
@@ -195,6 +197,87 @@
|
||||
}, 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() {
|
||||
if (started) return;
|
||||
if (excludedPath(location.pathname)) return;
|
||||
@@ -216,6 +299,7 @@
|
||||
flush(true);
|
||||
});
|
||||
scheduleFlush();
|
||||
setTimeout(captureSnapshot, SNAPSHOT_DELAY_MS);
|
||||
}
|
||||
|
||||
window.mltvSiteAnalyticsStart = start;
|
||||
|
||||
+20
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" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("admin-heatmap")
|
||||
expect(response.body).to include("admin-heatmap-frame")
|
||||
expect(response.body).to include("/prezzi")
|
||||
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
|
||||
Reference in New Issue
Block a user