/* A Better Lou first-party heatmaps: the site-wide footer script. * * Heatmaps plan v7 section 5 (which carries PLAN_v4.1 sections 5.1-5.7, 5.9 and 5.10), the pinned interfaces in the * build's INTERFACES.md, and contracts/heatmap-events.v1.json, which is authoritative for every byte this sends. * * WHAT IT DOES. On reviewed pages of abetterlou.com (and the staging copy) it records WHERE people click and HOW FAR * they scroll, and posts small batches to heatmap-fn's own function URL (ENDPOINT below). Nothing else. * * WHAT IT NEVER READS OR SENDS. Typed text, form values, element text, labels, aria-*, alt, title, name, placeholder, * classes, unapproved ids or data-hm values, query strings, hashes, cookie values, click-id values, user agent, * screen sizes or clock times. The only strings that leave the page are registry tokens (site-code/heatmap-registry.json, * inlined below at build time), HTML tag names, and digits. It reads tagName, the id / data-hm / role / type / href / src * attributes, sibling positions, label.control, sizes and scroll offsets. It reads sessionStorage.abl_visit and never * writes it, and it writes no storage and no cookie at all. * * WHY IT IS PASSIVE. It runs on /checkout, which takes real money, and on /quiz/start-fb, whose quiz_click listener * requires !event.defaultPrevented. So the click listener is capture-phase AND passive, it never calls preventDefault, * stopPropagation or anything that changes the page, every handler is wrapped in try/catch, and any unexpected error * stops the script (fail closed) rather than leaving it half-running. It never uses keepalive on /checkout, so the * checkout's own keepalive telemetry keeps the browser's whole 64 KiB budget there. * * WHERE IT RUNS (plan section 3). Every condition must hold, and any exception means "do not run": * 1. location.hostname is abetterlou.com or a-better-lou-website.webflow.io * 2. window.top === window * 3/4. the canonical path (one decode, collapsed slashes, no trailing slash, lower case) matches ^/[a-z0-9/_.-]{0,200}$ * 5. the canonical path is in registry.routes * 6. window.ABL_HM_OFF !== true re-checked at every click, at pageshow and before every send * 7. localStorage.abl_hm_off !== '1' (team opt-out) re-checked likewise; Global Privacy Control is NOT consulted (D2) * 8. window.__ablHeatmap is unset (installs once); window.__ablHeatmap.version always holds the registry version * When 6 or 7 fails after installation the script discards its queues, removes its listeners and stops without sending. * * ES5 on purpose, like the other site snippets: no let, const, arrows, classes or template literals. * Built by tools/heatmap-build.mjs, which replaces the registry slot below; the unbuilt file does nothing. */ (function (w, d) { 'use strict'; var R = {"version":"2026-11-08.1","routes":["/","/about","/begin/quiz","/blog/a-clear-path-to-optimal-health-it-starts-in-your-blood","/blog/building-capacity-how-health-coaching-helps-your-strength-independence-and-confidence","/blog/feel-younger-in-your-joints-the-power-of-curcumin","/blog/maintaining-capacity-in-life","/blog/pain-is-not-a-life-sentence","/blog/testosterone-and-metabolism-in-men-what-the-evidence-shows","/blog/understanding-hormone-decline-in-men","/blog/what-creatine-can-do-for-your-body","/blog/what-is-health-coaching","/checkout","/contact","/faq","/hormone-optimization-trt","/learn","/membership/relaunch-your-life","/membership/vitality-trt","/membership/womens-hrt","/membership/your-weight-is-over","/more/keep-the-muscle","/more/keep-the-muscle-10","/more/keep-the-muscle-50","/more/keep-the-muscle-fb","/more/keep-the-muscle-gs","/more/keep-the-muscle-ig","/more/keep-the-muscle-ms","/more/keep-the-muscle-yt","/privacy-policy","/privacy-practices","/quiz/film","/quiz/hrt","/quiz/sema-fb","/quiz/start-fb","/quiz/tirz-fb","/quiz/trt","/store-confirmed","/store-confirmed-hrt","/store-confirmed-vitality","/telehealth-consent","/terms-conditions","/terms-conditions-service","/video-hosting"],"ids":{"abl-about-h":"Checkout: about you heading","abl-after-h":"Checkout: after you pay heading","abl-bill-h":"Checkout: billing heading","abl-card-h":"Checkout: card heading","abl-email-label":"Checkout: email label","abl-pay-h":"Checkout: pay heading","abl-phone":"Checkout: phone field","abl-pick-h":"Checkout: plan picker heading","abl-promo-box":"Checkout: promo box","abl-promo-error":"Checkout: promo error","abl-promo-input":"Checkout: promo code field","abl-state":"Checkout: state field","abl-terms-error":"Checkout: terms error","how-it-works":"Section: how it works","mission":"About: mission","philosophy":"About: philosophy","pricing":"Section: pricing","team":"About: team","vision":"About: vision"},"hm":{"ftr-call":"Footer: phone","ftr-email":"Footer: email","ftr-mbr-relaunch":"Footer: Relaunch your life","ftr-mbr-weight":"Footer: Your weight is over","ftr-trt":"Footer: TRT","glp-cta-start":"GLP-1 CTA: start","glp-plan-sema":"GLP-1 plans: semaglutide","glp-plan-tirz":"GLP-1 plans: tirzepatide","hdr-buy":"Header: buy GLP-1","hdr-buy-m":"Mobile menu: buy GLP-1","hdr-call":"Header: phone","hdr-call-m":"Mobile menu: phone","hdr-logo":"Header: logo","hdr-logo-m":"Mobile menu: logo","hdr-mbr-hrt":"Header: Women's HRT","hdr-mbr-hrt-m":"Mobile menu: Women's HRT","hdr-mbr-relaunch":"Header menu: Relaunch your life","hdr-mbr-relaunch-m":"Mobile menu: Relaunch your life","hdr-mbr-vitality":"Header: Vitality TRT","hdr-mbr-vitality-m":"Mobile menu: Vitality TRT","hdr-mbr-weight":"Header menu: Your weight is over","hdr-mbr-weight-m":"Mobile menu: Your weight is over","hdr-nav-about":"Header: About","hdr-nav-about-m":"Mobile menu: About","hdr-nav-contact":"Header: Contact","hdr-nav-contact-m":"Mobile menu: Contact","hdr-nav-home":"Header: Home","hdr-nav-home-m":"Mobile menu: Home","hdr-nav-learn":"Header: Learn","hdr-nav-learn-m":"Mobile menu: Learn","hdr-nav-membership":"Header: Membership menu","hdr-nav-membership-m":"Mobile menu: Membership","hdr-portal":"Header: Patient Portal","hdr-portal-m":"Mobile menu: Patient Portal","home-buy-1":"Home: buy button 1","home-buy-2":"Home: buy button 2","home-buy-3":"Home: buy button 3","home-buy-4":"Home: buy button 4","home-call-1":"Home: call button 1","home-call-2":"Home: call button 2","home-call-3":"Home: call button 3","home-call-4":"Home: call button 4","ktm-call":"Keep the Muscle: phone","ktm-start-today":"Keep the Muscle: Start Today","lpf-call":"Landing footer: phone","lpf-email":"Landing footer: email","mbr-buy-1":"Membership: buy button 1","mbr-buy-2":"Membership: buy button 2","mbr-call-1":"Membership: call button 1","mbr-plan-buy-1":"Membership plans: buy 1","mbr-plan-buy-2":"Membership plans: buy 2","mbr-plan-call-1":"Membership plans: call 1","mbr-plan-call-2":"Membership plans: call 2","modal-buy":"Talk to a provider modal: buy","modal-call":"Talk to a provider modal: call","modal-close":"Talk to a provider modal: close","qlp-care-call":"Quiz landing care team: call","qlp-care-email":"Quiz landing care team: email","qlp-care-text":"Quiz landing care team: text","qlp-cta-1":"Quiz landing CTA 1","qlp-cta-2":"Quiz landing CTA 2","qlp-hdr-call":"Quiz landing header: phone","qlp-hdr-cta":"Quiz landing header: start","qlp-hero-cta":"Quiz landing hero: start","qlp-hero-how":"Quiz landing hero: how it works","qlp-how-cta":"Quiz landing how it works: start","qlp-price-cta":"Quiz landing price: start","trt-faq":"TRT: FAQ","trt-hotspots":"TRT page: hotspots","trt-hs-1":"TRT page: hotspot 1","trt-hs-2":"TRT page: hotspot 2","trt-hs-3":"TRT page: hotspot 3","trt-hs-4":"TRT page: hotspot 4","trt-hs-next":"TRT page: hotspots next"},"hosts":{"abetterlou.canvasmedical.com":"portal","consult.abetterlou.com":"consult","facebook.com":"facebook","instagram.com":"instagram","legitscript.com":"legitscript","linkedin.com":"linkedin","twitter.com":"x","www.facebook.com":"facebook","www.instagram.com":"instagram","www.legitscript.com":"legitscript","www.linkedin.com":"linkedin","www.youtube.com":"youtube","x.com":"x","your-quiz.abetterlou.com":"quiz","youtu.be":"youtube","youtube.com":"youtube"},"sources":["an","audience_network","bing","facebook","fb","gdn","google","ig","impact","instagram","messenger","microsoft","msg","msn","youtube","yt"],"tags":["a","abbr","address","article","aside","audio","b","blockquote","br","button","canvas","caption","cite","code","col","colgroup","dd","del","details","dialog","div","dl","dt","em","fieldset","figcaption","figure","footer","form","g","h1","h2","h3","h4","h5","h6","header","hr","i","iframe","img","input","ins","label","legend","li","main","mark","menu","nav","noscript","ol","output","p","path","picture","pre","progress","q","s","section","select","small","source","span","strong","sub","summary","sup","svg","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","u","ul","use","video"]}; /*@@PURE-BEGIN@@ Everything up to PURE-END is pure: it uses only its arguments, the constants here and the language's own built-ins (plus URL in destOf and vFromUrl). tests/heatmap-client.test.mjs evaluates exactly this text. */ // heatmap-fn's own function URL + /h (plan v7 section 5, rev. 2026-10-28: no load balancer in front). // H6 reads the deployed URL back; if it differs, this line changes and the asset is rebuilt before H7. var ENDPOINT = 'https://us-central1-abl-conversions-pipeline.cloudfunctions.net/heatmap-fn/h'; var SITE_HOSTS = ['abetterlou.com', 'a-better-lou-website.webflow.io']; var ROUTE_RE = /^\/[a-z0-9\/_.-]{0,200}$/; var TOKEN_RE = /^[A-Za-z0-9_-]{1,64}$/; var LABEL_RE = /^[a-z0-9_-]{1,40}$/; var HEX8_RE = /^[0-9a-f]{8}$/; var LV_RE = /^20[0-9]{6}T[0-9]{6}$/; // sessionStorage.abl_visit as the site head mints it: randomUUID, or the fallback 'v' + base36. Never the quiz's q-. var VID_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|v[0-9a-z]{6,24})$/; var INPUT_TYPES = ['text', 'email', 'tel', 'number', 'search', 'checkbox', 'radio', 'submit', 'button', 'password']; var ROLES = ['button', 'link', 'tab', 'checkbox', 'radio', 'menuitem', 'switch']; var CLICK_KEYS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'im_ref']; var MAX_BATCH_CLICKS = 20; var MAX_BATCH_BYTES = 8000; var MAX_CLICKS = 120; var DR_MAX = 10000; var KEY_MAX = 400; var INDEX_MAX = 99999; var CLIMB = 6; var KEEPALIVE_BATCHES = 3; var KEEPALIVE_BYTES = 16000; var CHECKPOINT_MS = 15000; var DEAD_MS = 700; var RAGE_MS = 1000; var RAGE_PX = 40; var LABEL_MS = 100; function own(o, k) { return !!o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k); } function inList(list, v) { if (!list) return false; for (var i = 0; i < list.length; i++) if (list[i] === v) return true; return false; } /** Rule 3: one decode, collapse slashes, strip a trailing slash, lower case; null unless rule 4 holds. */ function canonPath(p) { var s; try { s = decodeURIComponent(String(p)); } catch (e) { return null; } s = s.replace(/\/{2,}/g, '/'); if (s.length > 1) s = s.replace(/\/+$/, ''); s = s.toLowerCase(); return ROUTE_RE.test(s) ? s : null; } /** A page-supplied canonicaliser (window.ABL_CANON_PATH) must pass the same test; no second decode. */ function canonWith(fn) { return function (p) { var s; try { s = fn(p); } catch (e) { return null; } return typeof s === 'string' && ROUTE_RE.test(s) ? s : null; }; } function vwbOf(width) { return width < 768 ? 'm' : (width < 1024 ? 't' : 'd'); } function dhOf(h) { return h < 2000 ? 'a' : (h < 5000 ? 'b' : (h < 10000 ? 'c' : 'e')); } /** round(1000 * n / total), clamped to 0..1000; 0 for anything not a positive finite ratio. */ function permille(n, total) { if (!(total > 0)) return 0; var v = Math.round(1000 * n / total); if (!(v > 0)) return 0; return v > 1000 ? 1000 : v; } function utf8Len(s) { var n = 0; for (var i = 0; i < s.length; i++) { var c = s.charCodeAt(i); if (c < 0x80) n += 1; else if (c < 0x800) n += 2; else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length && (s.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { n += 4; i++; } else n += 3; } return n; } function hexOf(bytes) { var out = ''; for (var i = 0; i < bytes.length; i++) out += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16); return out; } function pad2(n) { return (n < 10 ? '0' : '') + n; } /** The "Last Published: " comment's text -> UTC YYYYMMDDTHHMMSS, or '0'. */ function lvStamp(text) { var m = /^\s*Last Published:\s*([^\n]{1,160}?)\s*$/.exec(String(text)); if (!m) return '0'; var t = new Date(m[1]).getTime(); if (!(t === t)) return '0'; var x = new Date(t); var s = String(x.getUTCFullYear()) + pad2(x.getUTCMonth() + 1) + pad2(x.getUTCDate()) + 'T' + pad2(x.getUTCHours()) + pad2(x.getUTCMinutes()) + pad2(x.getUTCSeconds()); return LV_RE.test(s) ? s : '0'; } /** On /checkout the stamp is followed by '.' + the checkout.js ?v= + the checkout.css ?v=, only when both exist. */ function lvOf(stamp, onCheckout, jsV, cssV) { if (stamp === '0' || !onCheckout) return stamp; return HEX8_RE.test(jsV) && HEX8_RE.test(cssV) ? stamp + '.' + jsV + cssV : stamp; } /** The 8-hex ?v= of a URL whose path ends in /, else ''. */ function vFromUrl(href, base, file) { if (typeof href !== 'string') return ''; var u; try { u = new URL(href, base); } catch (e) { return ''; } var tail = '/' + file; if (u.pathname.slice(-tail.length) !== tail) return ''; var v = u.searchParams.get('v'); return typeof v === 'string' && HEX8_RE.test(v) ? v : ''; } /** The abl_attr cookie (written by the site head's attribution snippet) as an object, or null. */ function attrCookie(cookie) { var m = /(?:^|;\s*)abl_attr=([^;]+)/.exec(String(cookie || '')); if (!m) return null; try { var o = JSON.parse(decodeURIComponent(m[1])); return o && typeof o === 'object' ? o : null; } catch (e) { return null; } } /** utm_source from the URL first, then the cookie (page-views.html order); a registry source, 'other' or 'none'. */ function srcOf(params, attr, sources) { var v = params ? params.get('utm_source') : null; if (!v && attr && attr.abl_utm_source) v = attr.abl_utm_source; v = v ? String(v).replace(/^\s+|\s+$/g, '').toLowerCase() : ''; if (!v) return 'none'; return inList(sources, v) ? v : 'other'; } /** Whether any click id is PRESENT, in the URL or the cookie. The value itself is never read into the payload. */ function paidOf(params, attr) { for (var i = 0; i < CLICK_KEYS.length; i++) { if (params && params.get(CLICK_KEYS[i])) return true; if (attr && attr['abl_' + CLICK_KEYS[i]]) return true; } return false; } function vidOf(raw) { return typeof raw === 'string' && VID_RE.test(raw) ? raw : null; } function tagOf(el) { var t = el ? el.tagName : null; return typeof t === 'string' ? t.toLowerCase() : ''; } function isInteractive(el) { if (!el || el.nodeType !== 1) return false; var t = tagOf(el); if (t === 'a') return el.getAttribute('href') !== null; if (t === 'button' || t === 'input' || t === 'select' || t === 'textarea' || t === 'label' || t === 'summary') return true; var role = el.getAttribute('role'); return typeof role === 'string' && inList(ROLES, role.replace(/^\s+|\s+$/g, '').toLowerCase()); } /** * A list of choices (select, datalist, role=listbox) containing the target within 6 levels, else null. * A click inside one belongs to the list, never to an option: an option's position would record * which choice was made, for example a visitor's state (review 2026-10-27). */ function choiceListOf(target) { var el = target; for (var i = 0; el && i <= CLIMB; i++) { var t = tagOf(el); var role = el.getAttribute ? el.getAttribute('role') : null; if (t === 'select' || t === 'datalist') return el; if (typeof role === 'string' && role.replace(/^\s+|\s+$/g, '').toLowerCase() === 'listbox') return el; el = el.parentElement; } return null; } /** The element a click belongs to: its list of choices, else the target or its first interactive ancestor within 6 levels, else the target. */ function anchorOf(target) { var list = choiceListOf(target); if (list) return list; var el = target; for (var i = 0; el && i <= CLIMB; i++) { if (isInteractive(el)) return el; el = el.parentElement; } return target; } /** 'hm:' or '#' when the element is an approved landmark that is unique in the document, else null. */ function landmarkOf(el, reg, doc) { var v = el.getAttribute('data-hm'); if (v !== null && TOKEN_RE.test(v) && own(reg.hm, v) && doc.querySelectorAll('[data-hm="' + v + '"]').length === 1) { return 'hm:' + v; } var id = el.getAttribute('id'); if (id !== null && TOKEN_RE.test(id) && own(reg.ids, id) && doc.querySelectorAll('[id="' + id + '"]').length === 1) { return '#' + id; } return null; } /** One key segment: tag[type]:nth-of-type for registry tags, x@ otherwise. */ function segOf(el, reg) { var t = tagOf(el); var n = 1; var s; if (t && inList(reg.tags, t)) { for (s = el.previousElementSibling; s; s = s.previousElementSibling) if (tagOf(s) === t) n++; var bracket = ''; if (t === 'input') { var ty = el.getAttribute('type'); ty = typeof ty === 'string' ? ty.toLowerCase() : ''; if (inList(INPUT_TYPES, ty)) bracket = '[' + ty + ']'; } return n > INDEX_MAX ? null : t + bracket + ':' + n; } for (s = el.previousElementSibling; s; s = s.previousElementSibling) n++; return n > INDEX_MAX ? null : 'x@' + n; } /** The element key (plan 5.2): landmark head, then the full path from the landmark's child down to the anchor. */ function keyOf(anchor, reg, doc) { var body = doc.body; var segs = []; var len = 0; var head = null; var el = anchor; while (el && el !== body) { if (el.nodeType !== 1) return 'body'; head = landmarkOf(el, reg, doc); if (head) break; var seg = segOf(el, reg); if (seg === null) return '~deep'; segs.push(seg); len += seg.length + 1; if (len > KEY_MAX) return '~deep'; el = el.parentElement; } if (!el) return 'body'; var key = head || 'body'; for (var i = segs.length - 1; i >= 0; i--) key += '>' + segs[i]; return key.length > KEY_MAX ? '~deep' : key; } /** The destination of an a[href] anchor (plan 5.3), or null. */ function destOf(anchor, reg, loc, canon) { if (tagOf(anchor) !== 'a') return null; var href = anchor.getAttribute('href'); if (href === null) return null; var u; try { u = new URL(href, loc.href); } catch (e) { return null; } var p = u.protocol; if (p === 'tel:' || p === 'mailto:' || p === 'sms:') return p.slice(0, -1); if (p !== 'http:' && p !== 'https:') return null; if (p === loc.protocol && u.host === loc.host) { var c = canon(u.pathname); return c !== null && inList(reg.routes, c) ? 'p:' + c : 'p:other'; } var h = u.hostname.toLowerCase(); return own(reg.hosts, h) && LABEL_RE.test(reg.hosts[h]) && reg.hosts[h] !== 'other' ? 'x:' + reg.hosts[h] : 'x:other'; } /** Rage (plan 5.4): the same anchor OBJECT as at least 2 of the last 4 pointer clicks within 1 s and 40 px. */ function rageOf(hist, el, t, x, y) { var n = 0; for (var i = 0; i < hist.length; i++) { var h = hist[i]; var dx = h.x - x; var dy = h.y - y; if (h.el === el && t - h.t <= RAGE_MS && dx * dx + dy * dy <= RAGE_PX * RAGE_PX) n++; } return n >= 2; } /** One batch as JSON, fields in contract order; clicks carry only contract fields and i = their index. */ function batchJson(view, q, clicks) { var b = { v: 5, path: view.path, vwb: view.vwb, dh: view.dh, lv: view.lv, src: view.src, paid: view.paid }; if (view.vid) b.vid = view.vid; b.sm = view.sm; b.dr = view.dr; b.pv = view.pv; b.q = q; b.t = view.t; b.c = []; for (var i = 0; i < clicks.length; i++) { var c = clicks[i]; var o = { i: i, k: c.k }; if (c.d) o.d = c.d; o.m = c.m; o.cw = c.cw; o.ch = c.ch; if (c.m === 'p') { o.x = c.x; o.y = c.y; o.rx = c.rx; o.ry = c.ry; } if (c.r) o.r = 1; if (c.n) o.n = c.n; b.c.push(o); } return JSON.stringify(b); } /** The next batch: the longest prefix of `ready` within 20 clicks AND 8,000 UTF-8 bytes. n may be 0. */ function takeBatch(view, q, ready) { var n = ready.length < MAX_BATCH_CLICKS ? ready.length : MAX_BATCH_CLICKS; var body = batchJson(view, q, ready.slice(0, n)); var bytes = utf8Len(body); while (n > 0 && bytes > MAX_BATCH_BYTES) { n--; body = batchJson(view, q, ready.slice(0, n)); bytes = utf8Len(body); } return { body: body, n: n, bytes: bytes }; } /*@@PURE-END@@*/ // --- State (plan 5.1) --- var api = null; var path = null; var canon = canonPath; var onCheckout = false; var testMode = false; var lv = '0'; var src = 'none'; var paid = false; var pv = ''; var q = 0; var ready = []; var pending = []; var dr = 0; var recorded = 0; var maxExposedY = 0; var sent = { sm: -1, dr: -1, vwb: '', dh: '' }; var outstanding = 0; var disabled = false; var hist = []; var lastLabel = null; var listeners = []; var observer = null; var observing = false; var deadTimer = null; var interval = null; var rafPending = false; function noop() {} function now() { var p = w.performance; return p && typeof p.now === 'function' ? p.now() : new Date().getTime(); } /** Rules 6 and 7. Any exception counts as opted out. */ function optedOut() { try { if (w.ABL_HM_OFF === true) return true; return w.localStorage.getItem('abl_hm_off') === '1'; } catch (e) { return true; } } function listen(target, type, fn, opts) { target.addEventListener(type, fn, opts); listeners.push([target, type, fn, opts]); } /** Discard every queue, remove every listener, stop every timer; nothing is sent afterwards. */ function shutdown() { disabled = true; ready = []; pending = []; hist = []; lastLabel = null; for (var i = 0; i < listeners.length; i++) { try { listeners[i][0].removeEventListener(listeners[i][1], listeners[i][2], listeners[i][3]); } catch (e) { /* keep going */ } } listeners = []; try { if (observer) observer.disconnect(); } catch (e) { /* stopped anyway */ } observing = false; try { if (deadTimer !== null) w.clearTimeout(deadTimer); } catch (e) { /* stopped anyway */ } deadTimer = null; try { if (interval !== null) w.clearInterval(interval); } catch (e) { /* stopped anyway */ } interval = null; } /** Every handler runs through this: nothing runs once stopped, and any error stops the script (fail closed). */ function guard(fn) { return function (ev) { if (disabled) return; try { fn(ev); } catch (e) { shutdown(); } }; } function docH() { var h = d.documentElement ? d.documentElement.scrollHeight : 0; return h > 1 ? h : 1; } function scrollTop() { return w.pageYOffset || (d.documentElement ? d.documentElement.scrollTop : 0) || 0; } function scrollLeft() { return w.pageXOffset || (d.documentElement ? d.documentElement.scrollLeft : 0) || 0; } /** Plan 5.6: the deepest exposed pixel, never a percentage, so later layout growth cannot freeze 100%. */ function measure() { var y = scrollTop() + (w.innerHeight || 0); if (y > maxExposedY) maxExposedY = y; } function depth() { var h = docH(); return permille(maxExposedY < h ? maxExposedY : h, h); } function vwbNow() { return vwbOf(w.innerWidth || 0); } function dhNow() { return dhOf(docH()); } function view() { measure(); var vid = null; try { vid = vidOf(w.sessionStorage.getItem('abl_visit')); } catch (e) { vid = null; } return { path: path, vwb: vwbNow(), dh: dhNow(), lv: lv, src: src, paid: paid, vid: vid, sm: depth(), dr: dr, pv: pv, t: testMode }; } function post(body, bytes, keep) { var init = { method: 'POST', mode: 'cors', credentials: 'omit', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body }; var settled = false; if (keep) { init.keepalive = true; outstanding += bytes; } var done = function () { if (!keep || settled) return; settled = true; outstanding -= bytes; if (outstanding < 0) outstanding = 0; }; var p = w.fetch(ENDPOINT, init); if (p && typeof p.then === 'function') p.then(done, done); } /** * Plan 5.7. 'visible': plain fetch, every pending batch. 'teardown': off /checkout, keepalive, at most 3 batches and * only while outstanding keepalive bytes stay within 16,000; a batch that does not fit stays queued (still dirty). * On /checkout a teardown uses plain fetch only. q advances once per batch actually sent. */ function flush(mode) { if (disabled) return; if (optedOut()) { shutdown(); return; } var keep = mode === 'teardown' && !onCheckout; var v = view(); var emitted = 0; for (;;) { if (!(ready.length > 0 || v.sm !== sent.sm || v.dr !== sent.dr || v.vwb !== sent.vwb || v.dh !== sent.dh)) return; if (keep && emitted >= KEEPALIVE_BATCHES) return; var b = takeBatch(v, q, ready); if (b.n === 0 && ready.length > 0) { // Cannot happen with the contract's limits (a click is at most about 720 bytes); never loop on it. ready = ready.slice(1); if (dr < DR_MAX) dr++; v = view(); continue; } if (keep && outstanding + b.bytes > KEEPALIVE_BYTES) return; ready = ready.slice(b.n); q++; sent.sm = v.sm; sent.dr = v.dr; sent.vwb = v.vwb; sent.dh = v.dh; emitted++; post(b.body, b.bytes, keep); } } function visible() { return d.visibilityState !== 'hidden'; } function afterReady() { if (ready.length >= MAX_BATCH_CLICKS && visible()) flush('visible'); } // --- Dead clicks (plan 5.5): one shared observer, connected only while something is pending --- function settlePending(verdict) { if (pending.length === 0) return; var list = pending; pending = []; for (var i = 0; i < list.length; i++) { list[i].n = verdict; ready.push(list[i]); } idleWatch(); } function idleWatch() { if (pending.length > 0) return; if (observing) { observer.disconnect(); observing = false; } if (deadTimer !== null) { w.clearTimeout(deadTimer); deadTimer = null; } } function armDeadTimer() { if (deadTimer !== null || pending.length === 0) return; var wait = pending[0].due - now(); deadTimer = w.setTimeout(onDeadTimer, wait > 0 ? Math.ceil(wait) + 1 : 1); } var onDeadTimer = guard(function () { deadTimer = null; var t = now(); while (pending.length > 0 && pending[0].due <= t) { var c = pending.shift(); c.n = 'y'; ready.push(c); } idleWatch(); armDeadTimer(); afterReady(); }); var onMutation = guard(function () { settlePending('n'); afterReady(); }); // --- Clicks (plan 5.2-5.4) --- var onClick = guard(function (ev) { if (optedOut()) { shutdown(); return; } if (ev.isTrusted !== true) return; var target = ev.target; if (target && target.nodeType !== 1) target = target.parentElement || null; if (!target || target.nodeType !== 1) return; var a = anchorOf(target); var t = now(); var tag = tagOf(a); if (tag === 'input' && lastLabel && lastLabel.el === a && t - lastLabel.t <= LABEL_MS) { var ty = String(a.getAttribute('type') || '').toLowerCase(); if (ty === 'checkbox' || ty === 'radio') { lastLabel = null; return; } } if (recorded >= MAX_CLICKS) { if (dr < DR_MAX) dr++; return; } var c = { k: keyOf(a, R, d), d: destOf(a, R, w.location, canon), m: ev.detail === 0 ? 'k' : 'p', cw: vwbNow(), ch: dhNow() }; if (c.m === 'p') { var r = a.getBoundingClientRect(); var docX = ev.clientX + scrollLeft(); var docY = ev.clientY + scrollTop(); c.x = permille(ev.clientX, w.innerWidth || 0); c.y = permille(docY, docH()); c.rx = permille(ev.clientX - r.left, r.width); c.ry = permille(ev.clientY - r.top, r.height); if (rageOf(hist, a, t, docX, docY)) c.r = 1; hist.push({ el: a, t: t, x: docX, y: docY }); if (hist.length > 4) hist.shift(); } recorded++; lastLabel = null; if (tag === 'label') { var control = a.control; if (control) lastLabel = { el: control, t: t }; } if (isInteractive(a)) { ready.push(c); afterReady(); return; } c.due = t + DEAD_MS; pending.push(c); if (!observing) { observer.observe(d.documentElement, { childList: true, attributes: true, subtree: true }); observing = true; } armDeadTimer(); }); // --- Scroll, lifecycle, checkpoints (plan 5.6, 5.7) --- var measureFrame = guard(function () { rafPending = false; measure(); }); var onScroll = guard(function () { if (rafPending) return; if (typeof w.requestAnimationFrame !== 'function') { measure(); return; } rafPending = true; w.requestAnimationFrame(measureFrame); }); var onMeasure = guard(function () { measure(); }); var onTeardown = guard(function () { settlePending('u'); flush('teardown'); }); var onVisibility = guard(function () { if (d.visibilityState === 'hidden') { settlePending('u'); flush('teardown'); } else if (d.visibilityState === 'visible') { flush('visible'); } }); var onPageShow = guard(function () { if (optedOut()) { shutdown(); return; } measure(); }); var onCheckpoint = guard(function () { if (visible()) flush('visible'); }); function checkoutLv(stamp) { if (!onCheckout) return stamp; var js = ''; var css = ''; var i; var scripts = d.getElementsByTagName('script'); for (i = 0; i < scripts.length && !js; i++) js = vFromUrl(scripts[i].getAttribute('src'), w.location.href, 'checkout.js'); var links = d.getElementsByTagName('link'); for (i = 0; i < links.length && !css; i++) css = vFromUrl(links[i].getAttribute('href'), w.location.href, 'checkout.css'); return lvOf(stamp, true, js, css); } function pageStamp() { var nodes = d.childNodes; for (var i = 0; i < nodes.length; i++) { if (nodes[i].nodeType === 1) break; if (nodes[i].nodeType === 8) { var s = lvStamp(nodes[i].nodeValue); if (s !== '0') return s; } } return '0'; } function newPv() { var out = ''; try { var bytes = new Uint8Array(8); w.crypto.getRandomValues(bytes); out = hexOf(bytes); } catch (e) { out = ''; } while (out.length < 16) out += Math.floor(Math.random() * 16).toString(16); return out.slice(0, 16); } function install() { var loc = w.location; try { testMode = w.localStorage.getItem('abl_hm_test') === '1'; } catch (e) { testMode = false; } onCheckout = path === '/checkout'; var params = null; try { params = new URLSearchParams(loc.search || ''); } catch (e) { params = null; } var attr = null; try { attr = attrCookie(d.cookie); } catch (e) { attr = null; } src = srcOf(params, attr, R.sources); paid = paidOf(params, attr); lv = checkoutLv(pageStamp()); pv = newPv(); observer = new w.MutationObserver(onMutation); listen(d, 'click', onClick, { capture: true, passive: true }); listen(w, 'scroll', onScroll, { passive: true }); listen(w, 'resize', onMeasure, { passive: true }); listen(w, 'load', onMeasure, false); listen(w, 'pageshow', onPageShow, false); listen(w, 'pagehide', onTeardown, false); listen(d, 'visibilitychange', onVisibility, false); interval = w.setInterval(onCheckpoint, CHECKPOINT_MS); if (testMode) { api.pv = pv; api.state = function () { return { path: path, lv: lv, src: src, paid: paid, checkout: onCheckout, q: q, ready: ready.length, pending: pending.length, dr: dr, recorded: recorded, maxExposedY: maxExposedY, sent: { sm: sent.sm, dr: sent.dr, vwb: sent.vwb, dh: sent.dh }, outstanding: outstanding, observing: observing, listeners: listeners.length, disabled: disabled }; }; } // The q = 0 snapshot goes now, with plain fetch, so the server's page-view anchor is normally within a second of load. measure(); flush('visible'); } /** Rules 1-8, then install. */ function start() { if (w.__ablHeatmap) return; api = { version: R.version }; w.__ablHeatmap = api; if (!inList(SITE_HOSTS, w.location.hostname)) return; if (w.top !== w) return; if (typeof w.ABL_CANON_PATH === 'function') canon = canonWith(w.ABL_CANON_PATH); path = canon(w.location.pathname); if (path === null || !inList(R.routes, path)) return; if (optedOut()) return; if (typeof w.fetch !== 'function' || typeof w.MutationObserver !== 'function' || typeof URL !== 'function' || typeof URLSearchParams !== 'function') return; install(); } /** * A prerendered page may never be shown (Chrome prerenders from address-bar predictions with no speculation rules), * so while document.prerendering is true nothing is installed or sent; on prerenderingchange the page is real and the * normal start runs, rules 1-8 and the q = 0 snapshot included. */ function main() { if (!R || typeof R.version !== 'string') return; if (d.prerendering === true) { var activate = function () { try { d.removeEventListener('prerenderingchange', activate); start(); } catch (err) { try { shutdown(); } catch (e2) { noop(); } } }; d.addEventListener('prerenderingchange', activate); return; } start(); } try { main(); } catch (e) { try { shutdown(); } catch (e2) { noop(); } } })(window, document);