Heatmap im Portal (Funktionen -> Analyse): die echte Website laeuft jetzt in realistischen Geraeterahmen, gross MacBook oder iPad (Umschalter oben), daneben das iPhone, jeweils in echter Breite (1512 / 820 / 402 px), verkleinert und im Geraet scrollbar, mit den Klicks der passenden Geraeteklasse als Overlay. MacBook und iPhone stammen aus den Rahmen der neuen Website (device-mockups), das iPad ist im gleichen Stil nachgebaut (public/geraete.css/.js). - heatmap-link: embed: true haengt __wk_embed=1 an; w.js reicht das als data-embed an h.js. - h.js eingebettet (nur in einem Rahmen): keine Leiste, Links und Formulare gesperrt (kein Seitenwechsel, also keine Zaehlung und kein Banner), Scrollleisten aus, Klickzahl per postMessage nur an die Herkunft von h.js (project.webklar.com). - iframe mit Sandbox allow-scripts allow-same-origin (keine Formulare, Pop-ups, keine Navigation des Portals); Laden erst, wenn die Heatmap in Sicht kommt. Laesst sich eine Website nicht einbetten, kommt nach 20 s ein Hinweis mit "In neuem Tab oeffnen" (alter Weg). Banner-Skript: der Einstellungsknopf nach der Wahl ist jetzt ein angeknabberter Keks unten rechts (Inline-SVG, 44 px). Er dreht sich langsam, haelt beim Darueberfahren/Fokus an und zeigt dann "Datenschutz-Einstellungen" (auch sein Name fuer Screenreader, passend zum Bannertext); bei reduzierter Bewegung dreht er sich nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
/* WEBklar Analyse h.js - generiert aus client/core.js + client/h.js (node client/build.js), nicht von Hand aendern */
|
||
(function () {
|
||
'use strict';
|
||
// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
|
||
// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
|
||
|
||
const CONSENT_KEY = 'wk-consent';
|
||
const CONSENT_MAX_AGE = 183 * 86400000;
|
||
const BANNER_VERSION = '2026-10-02';
|
||
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
|
||
const BANNER_LINK_LABEL = 'Datenschutz';
|
||
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||
|
||
function vpClass(width) {
|
||
const w = Number(width);
|
||
if (w < 768) return 'm';
|
||
if (w < 1024) return 't';
|
||
return 'd';
|
||
}
|
||
|
||
function cleanPath(href) {
|
||
let p = '/';
|
||
const h = String(href == null ? '' : href);
|
||
if (h.charAt(0) === '/') {
|
||
p = h.replace(/[?#].*$/, '') || '/'; // wie location.pathname, auch '//foo'
|
||
} else {
|
||
try { p = new URL(h, 'http://x.invalid/').pathname || '/'; } catch (e) { /* bleibt '/' */ }
|
||
}
|
||
try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
|
||
if (p.charAt(0) !== '/') p = '/' + p;
|
||
return p.slice(0, 300);
|
||
}
|
||
|
||
const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
|
||
|
||
function refHost(referrer, ownHost) {
|
||
if (!referrer) return '';
|
||
try {
|
||
const u = new URL(String(referrer));
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
||
const h = u.hostname.toLowerCase();
|
||
if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
|
||
return h;
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
// Nur Tag-Namen mit :nth-of-type, keine IDs/Klassen (koennten personenbezogene Texte enthalten).
|
||
function cssPath(el) {
|
||
const parts = [];
|
||
let n = el;
|
||
while (n && n.tagName && parts.length < 6) {
|
||
const tag = String(n.tagName).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||
if (!tag) break;
|
||
let idx = 1;
|
||
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
||
if (String(s.tagName).toLowerCase() === String(n.tagName).toLowerCase()) idx++;
|
||
}
|
||
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
||
if (tag === 'body' || tag === 'html') break;
|
||
n = n.parentNode;
|
||
}
|
||
let out = parts.join(' > ');
|
||
while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
|
||
return out.slice(0, 200);
|
||
}
|
||
|
||
function parseHref(a) {
|
||
const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
|
||
return String(href).trim();
|
||
}
|
||
|
||
function linkHost(a) {
|
||
try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
|
||
}
|
||
|
||
function contactKind(a, ownHost) {
|
||
const href = parseHref(a);
|
||
if (!href) return null;
|
||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
|
||
if (!scheme) return null; // relativ, Anker
|
||
const s = scheme[1].toLowerCase();
|
||
if (s === 'tel') return 'tel';
|
||
if (s === 'mailto') return 'mail';
|
||
if (s === 'whatsapp') return 'wa';
|
||
if (s !== 'http' && s !== 'https') return null;
|
||
const host = linkHost(a);
|
||
if (!host) return null;
|
||
if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
|
||
const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
|
||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
||
}
|
||
|
||
function readConsent(storage, now) {
|
||
try {
|
||
const raw = storage.getItem(CONSENT_KEY);
|
||
if (!raw) return null;
|
||
const c = JSON.parse(raw);
|
||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
|
||
const age = now - c.t;
|
||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||
} catch (e) { return null; }
|
||
}
|
||
|
||
function contrastOk(hex) {
|
||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
||
const lin = (i) => {
|
||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||
};
|
||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
|
||
return 1.05 / (L + 0.05) >= 4.5;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
|
||
// data-embed="1" (nur in einem Rahmen): Ansicht im Geraeterahmen des Kundenportals. Dann ohne Leiste,
|
||
// Links und Formulare gesperrt, und die Klickzahl geht per postMessage nur an das Portal (Herkunft von h.js).
|
||
|
||
function main() {
|
||
const me = document.currentScript;
|
||
const token = me && me.getAttribute('data-token');
|
||
const src = String((me && (me.src || me.getAttribute('src'))) || '').replace(/[?#].*$/, '');
|
||
const base = src.replace(/[^/]*$/, '');
|
||
if (!token || !base) return;
|
||
|
||
const de = document.documentElement;
|
||
let portal = '';
|
||
try { portal = new URL(base).origin; } catch (e) { /* ohne Portal keine Einbettung */ }
|
||
const embed = !!portal && me.getAttribute('data-embed') === '1' && window.parent && window.parent !== window;
|
||
function tell(state, count) {
|
||
if (!embed) return;
|
||
try { window.parent.postMessage({ wk: 'heat', state: state, count: count }, portal); } catch (e) { /* egal */ }
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
const cs = canvas.style;
|
||
cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.pointerEvents = 'none'; cs.zIndex = '2147483646';
|
||
document.body.appendChild(canvas);
|
||
|
||
let points = [];
|
||
let lastW = 0, lastH = 0, timer = 0;
|
||
|
||
// Farbverlauf: transparent -> blau -> gruen -> gelb -> rot
|
||
const ramp = (function () {
|
||
const c = document.createElement('canvas'); c.width = 256; c.height = 1;
|
||
const g = c.getContext('2d');
|
||
const lg = g.createLinearGradient(0, 0, 256, 0);
|
||
lg.addColorStop(0.25, 'rgb(0,0,255)'); lg.addColorStop(0.55, 'rgb(0,255,0)'); lg.addColorStop(0.85, 'rgb(255,255,0)'); lg.addColorStop(1, 'rgb(255,0,0)');
|
||
g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
|
||
return g.getImageData(0, 0, 256, 1).data;
|
||
})();
|
||
|
||
if (embed) {
|
||
// Nur ansehen: kein Seitenwechsel (er wuerde zaehlen und das Banner zeigen)
|
||
const block = function (e) {
|
||
const t = e.target;
|
||
if (e.type === 'submit' || (t && t.closest && t.closest('a,area'))) { e.preventDefault(); e.stopPropagation(); }
|
||
};
|
||
window.addEventListener('click', block, true);
|
||
window.addEventListener('submit', block, true);
|
||
// Handy und Tablet haben ueberlagernde Scrollleisten; so stimmt die Breite wie auf dem Geraet
|
||
const st = document.createElement('style');
|
||
st.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}';
|
||
(document.head || de).appendChild(st);
|
||
start(function (n) { tell('ready', n); }, function () { tell('error', 0); });
|
||
return;
|
||
}
|
||
|
||
const host = document.createElement('div');
|
||
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
|
||
const root = host.attachShadow({ mode: 'open' });
|
||
const style = document.createElement('style');
|
||
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'bar';
|
||
const msg = document.createElement('span');
|
||
msg.textContent = 'Heatmap wird geladen ...';
|
||
const close = document.createElement('button');
|
||
close.setAttribute('type', 'button');
|
||
close.textContent = 'Schließen';
|
||
bar.appendChild(msg); bar.appendChild(close);
|
||
root.appendChild(style); root.appendChild(bar);
|
||
document.body.appendChild(host);
|
||
|
||
function draw() {
|
||
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
|
||
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
|
||
lastW = w; lastH = h;
|
||
// Zeichenflaeche begrenzen (Speicher): hoechstens ca. 4 Mio. Pixel
|
||
const scale = Math.min(1, Math.sqrt(4e6 / (w * h)));
|
||
const cw = Math.max(1, Math.floor(w * scale)), ch = Math.max(1, Math.floor(h * scale));
|
||
canvas.width = cw; canvas.height = ch;
|
||
cs.width = w + 'px'; cs.height = h + 'px';
|
||
const g = canvas.getContext('2d');
|
||
g.clearRect(0, 0, cw, ch);
|
||
const r = Math.max(6, 30 * scale);
|
||
for (const p of points) {
|
||
const x = p.x * cw, y = p.y * ch;
|
||
const rg = g.createRadialGradient(x, y, 0, x, y, r);
|
||
rg.addColorStop(0, 'rgba(0,0,0,0.25)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
|
||
g.fillStyle = rg; g.fillRect(x - r, y - r, r * 2, r * 2);
|
||
}
|
||
// Deckkraft-Stufen in Farben umsetzen
|
||
const img = g.getImageData(0, 0, cw, ch);
|
||
const d = img.data;
|
||
for (let i = 3; i < d.length; i += 4) {
|
||
const a = d[i];
|
||
if (!a) continue;
|
||
const k = Math.min(255, a * 3) * 4;
|
||
d[i - 3] = ramp[k]; d[i - 2] = ramp[k + 1]; d[i - 1] = ramp[k + 2];
|
||
d[i] = Math.min(200, 60 + a);
|
||
}
|
||
g.putImageData(img, 0, 0);
|
||
}
|
||
|
||
function redrawIfChanged() {
|
||
if (Math.max(de.scrollWidth, de.clientWidth) !== lastW || Math.max(de.scrollHeight, de.clientHeight) !== lastH) draw();
|
||
}
|
||
function stop() {
|
||
clearInterval(timer);
|
||
window.removeEventListener('resize', draw);
|
||
host.remove(); canvas.remove();
|
||
}
|
||
close.addEventListener('click', stop);
|
||
start(function (n) { msg.textContent = 'Heatmap – ' + n + ' Klicks (letzte 30 Tage)'; },
|
||
function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
|
||
|
||
// Punkte laden, zeichnen und bei Groessenaenderung neu zeichnen
|
||
function start(onReady, onError) {
|
||
window.addEventListener('resize', draw);
|
||
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
|
||
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
|
||
.then(function (j) {
|
||
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
|
||
draw();
|
||
timer = setInterval(redrawIfChanged, 1000);
|
||
onReady(points.length);
|
||
})
|
||
.catch(function () { onError(); });
|
||
}
|
||
}
|
||
|
||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||
|
||
})();
|