weathervane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/weathervane.js ADDED
@@ -0,0 +1,1026 @@
1
+ /*!
2
+ * Weathervane v0.1.0
3
+ * A lightweight, zero-backend analytics utility.
4
+ *
5
+ * Weathervane does NOT send data anywhere. It observes user behavior
6
+ * (pageviews, content exposure, clicks, forms, sessions, web vitals)
7
+ * and emits structured browser CustomEvents that you can forward to
8
+ * any destination (GA4, PostHog, Segment, your own API, ...).
9
+ *
10
+ * window.addEventListener('vane:event', function (e) {
11
+ * console.log(e.detail.event_name, e.detail);
12
+ * });
13
+ *
14
+ * License: MIT
15
+ */
16
+ (function (window, document) {
17
+ 'use strict';
18
+
19
+ if (!window || !document) return;
20
+ if (window.vane && window.vane.__loaded) return;
21
+
22
+ var VERSION = '0.1.0';
23
+ var existing = window.vane; // may hold a _queue from an async loader snippet
24
+
25
+ // ---------------------------------------------------------------------
26
+ // Configuration
27
+ // ---------------------------------------------------------------------
28
+
29
+ var DEFAULTS = {
30
+ // Emission
31
+ eventPrefix: 'vane', // events fire as `${prefix}:event` and `${prefix}:${event_name}`
32
+ historySize: 100, // recent events kept for on(..., { replay: true })
33
+
34
+ // Feature toggles
35
+ enableAutoPageview: true, // emit `pageview` on DOM ready
36
+ enableDynamicPageview: true, // emit `pageview_dynamic` on SPA navigation
37
+ enableContentTracking: true, // data-vane-content lifecycle tracking
38
+ enableLinkTracking: true, // emit `link_click` for every <a href> click
39
+ enableFormTracking: true, // emit `form_submit` / `form_abandon`
40
+ enableWebVitals: true, // collect FCP / LCP / CLS / FID
41
+ trackShadowDom: true, // scan open shadow roots, retarget composed events
42
+
43
+ // Session management
44
+ sessionTimeout: 30, // minutes of inactivity before a new session
45
+
46
+ // Content tracking
47
+ contentExposureLimit: 1000, // default ms in viewport before `content_view`
48
+ largeContentViewportFill: 0.65, // taller-than-viewport content counts as visible
49
+ // when it fills this fraction of the viewport
50
+
51
+ // Form tracking
52
+ formAbandonThreshold: 3000, // min ms of engagement before `form_abandon` fires
53
+
54
+ // Development
55
+ debug: false
56
+ };
57
+
58
+ var config = assign({}, DEFAULTS);
59
+ var ready = false;
60
+
61
+ // ---------------------------------------------------------------------
62
+ // Small utilities
63
+ // ---------------------------------------------------------------------
64
+
65
+ function assign(target) {
66
+ for (var i = 1; i < arguments.length; i++) {
67
+ var src = arguments[i];
68
+ if (!src) continue;
69
+ for (var key in src) {
70
+ if (Object.prototype.hasOwnProperty.call(src, key)) target[key] = src[key];
71
+ }
72
+ }
73
+ return target;
74
+ }
75
+
76
+ function now() { return Date.now(); }
77
+
78
+ function randomBytes(length) {
79
+ var bytes = new Uint8Array(length);
80
+ if (window.crypto && window.crypto.getRandomValues) {
81
+ window.crypto.getRandomValues(bytes);
82
+ } else {
83
+ for (var i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
84
+ }
85
+ return bytes;
86
+ }
87
+
88
+ // RFC 4122 v4 UUID — used for client / session / page-view / instance ids
89
+ function uuid() {
90
+ if (window.crypto && window.crypto.randomUUID) {
91
+ try { return window.crypto.randomUUID(); } catch (e) { /* insecure context */ }
92
+ }
93
+ var b = randomBytes(16);
94
+ b[6] = (b[6] & 0x0f) | 0x40;
95
+ b[8] = (b[8] & 0x3f) | 0x80;
96
+ var hex = '';
97
+ for (var i = 0; i < 16; i++) hex += (b[i] + 0x100).toString(16).slice(1);
98
+ return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) +
99
+ '-' + hex.slice(16, 20) + '-' + hex.slice(20);
100
+ }
101
+
102
+ // ULID — lexicographically sortable event ids for time-series friendliness
103
+ var ULID_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
104
+ function ulid() {
105
+ var ts = now();
106
+ var out = '';
107
+ for (var i = 0; i < 10; i++) {
108
+ out = ULID_CHARS[ts % 32] + out;
109
+ ts = Math.floor(ts / 32);
110
+ }
111
+ var rand = randomBytes(16);
112
+ for (var j = 0; j < 16; j++) out += ULID_CHARS[rand[j] % 32];
113
+ return out;
114
+ }
115
+
116
+ function truncate(str, max) {
117
+ str = (str || '').replace(/\s+/g, ' ').trim();
118
+ return str.length > max ? str.slice(0, max) : str;
119
+ }
120
+
121
+ function log() {
122
+ if (!config.debug || !window.console) return;
123
+ var args = ['%c vane ', 'background:#5b5bd6;color:#fff;border-radius:3px'];
124
+ for (var i = 0; i < arguments.length; i++) args.push(arguments[i]);
125
+ console.log.apply(console, args);
126
+ }
127
+
128
+ // Listener bookkeeping so destroy() can tear everything down
129
+ var cleanups = [];
130
+ function listen(target, type, handler, options) {
131
+ target.addEventListener(type, handler, options);
132
+ cleanups.push(function () { target.removeEventListener(type, handler, options); });
133
+ }
134
+
135
+ function whenDomReady(fn) {
136
+ if (document.readyState === 'loading') {
137
+ listen(document, 'DOMContentLoaded', fn);
138
+ } else {
139
+ fn();
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------
144
+ // Storage (localStorage with in-memory fallback — no cookies, ever)
145
+ // ---------------------------------------------------------------------
146
+
147
+ var memoryStore = {};
148
+
149
+ function storageGet(key) {
150
+ try {
151
+ var value = window.localStorage.getItem(key);
152
+ if (value !== null) return value;
153
+ } catch (e) { /* unavailable or blocked */ }
154
+ return Object.prototype.hasOwnProperty.call(memoryStore, key) ? memoryStore[key] : null;
155
+ }
156
+
157
+ function storageSet(key, value) {
158
+ memoryStore[key] = value;
159
+ try { window.localStorage.setItem(key, value); } catch (e) { /* memory fallback only */ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------
163
+ // Identity & session
164
+ // ---------------------------------------------------------------------
165
+
166
+ var CID_KEY = 'vane_cid';
167
+ var SID_KEY = 'vane_sid';
168
+
169
+ var clientId = null;
170
+ var session = { id: null, lastActive: 0, lastPersist: 0 };
171
+ var userId = null;
172
+ var pageViewId = null;
173
+ var pageStartTime = now();
174
+ var context = {};
175
+
176
+ function loadClientId() {
177
+ clientId = storageGet(CID_KEY);
178
+ if (!clientId) clientId = uuid();
179
+ storageSet(CID_KEY, clientId);
180
+ }
181
+
182
+ function persistSession() {
183
+ storageSet(SID_KEY, session.id + '.' + session.lastActive);
184
+ session.lastPersist = now();
185
+ }
186
+
187
+ function loadSession() {
188
+ var raw = storageGet(SID_KEY);
189
+ var sid = null;
190
+ var lastActive = 0;
191
+ if (raw) {
192
+ var parts = raw.split('.');
193
+ sid = parts[0] || null;
194
+ lastActive = parseInt(parts[1], 10) || 0;
195
+ }
196
+ var expired = !sid || (now() - lastActive) > config.sessionTimeout * 60000;
197
+ if (expired) {
198
+ startNewSession(sid ? 'timeout' : 'new', sid);
199
+ } else {
200
+ session.id = sid;
201
+ session.lastActive = now();
202
+ persistSession();
203
+ }
204
+ }
205
+
206
+ function startNewSession(reason, previousId) {
207
+ var prev = previousId !== undefined ? previousId : session.id;
208
+ session.id = uuid();
209
+ session.lastActive = now();
210
+ persistSession();
211
+ emit('session_start', { reason: reason, previous_session_id: prev || null });
212
+ }
213
+
214
+ function touchSession() {
215
+ var t = now();
216
+ if (t - session.lastActive > config.sessionTimeout * 60000) {
217
+ startNewSession('timeout');
218
+ return;
219
+ }
220
+ session.lastActive = t;
221
+ if (t - session.lastPersist > 10000) persistSession();
222
+ }
223
+
224
+ // ---------------------------------------------------------------------
225
+ // Context collection (page, device, UTM, performance)
226
+ // ---------------------------------------------------------------------
227
+
228
+ function pageContext() {
229
+ var loc = window.location;
230
+ return {
231
+ url: loc.href,
232
+ path: loc.pathname,
233
+ title: document.title || null,
234
+ referrer: document.referrer || null,
235
+ search: loc.search || null,
236
+ hash: loc.hash || null
237
+ };
238
+ }
239
+
240
+ function parseBrowser(ua) {
241
+ var match;
242
+ if ((match = ua.match(/Edg(?:e|A|iOS)?\/([\d.]+)/))) return { name: 'Edge', version: match[1] };
243
+ if ((match = ua.match(/OPR\/([\d.]+)/))) return { name: 'Opera', version: match[1] };
244
+ if ((match = ua.match(/SamsungBrowser\/([\d.]+)/))) return { name: 'Samsung Internet', version: match[1] };
245
+ if ((match = ua.match(/(?:Firefox|FxiOS)\/([\d.]+)/))) return { name: 'Firefox', version: match[1] };
246
+ if ((match = ua.match(/(?:Chrome|CriOS)\/([\d.]+)/))) return { name: 'Chrome', version: match[1] };
247
+ if (/Safari\//.test(ua) && (match = ua.match(/Version\/([\d.]+)/))) return { name: 'Safari', version: match[1] };
248
+ return { name: 'unknown', version: null };
249
+ }
250
+
251
+ function detectDeviceType(ua) {
252
+ if (/iPad|Tablet|PlayBook/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) return 'tablet';
253
+ if (/Mobi|iPhone|iPod|Android/i.test(ua)) return 'mobile';
254
+ return 'desktop';
255
+ }
256
+
257
+ var staticDevice = null;
258
+ function deviceContext() {
259
+ if (!staticDevice) {
260
+ var ua = navigator.userAgent || '';
261
+ var browser = parseBrowser(ua);
262
+ var timezone = null;
263
+ try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (e) { /* no Intl */ }
264
+ staticDevice = {
265
+ user_agent: ua,
266
+ browser_name: browser.name,
267
+ browser_version: browser.version,
268
+ language: navigator.language || null,
269
+ timezone: timezone,
270
+ screen_width: window.screen ? window.screen.width : null,
271
+ screen_height: window.screen ? window.screen.height : null,
272
+ device_type: detectDeviceType(ua),
273
+ device_memory: navigator.deviceMemory || null,
274
+ hardware_concurrency: navigator.hardwareConcurrency || null,
275
+ cookie_enabled: !!navigator.cookieEnabled
276
+ };
277
+ }
278
+ return assign({}, staticDevice, {
279
+ viewport_width: window.innerWidth,
280
+ viewport_height: window.innerHeight,
281
+ online: navigator.onLine !== false
282
+ });
283
+ }
284
+
285
+ var currentUtm = null;
286
+ function parseUtm() {
287
+ var out = {};
288
+ var found = false;
289
+ try {
290
+ var params = new URLSearchParams(window.location.search);
291
+ var keys = ['source', 'medium', 'campaign', 'term', 'content'];
292
+ for (var i = 0; i < keys.length; i++) {
293
+ var value = params.get('utm_' + keys[i]);
294
+ if (value) { out['utm_' + keys[i]] = value; found = true; }
295
+ }
296
+ } catch (e) { /* URLSearchParams unavailable */ }
297
+ return found ? out : null;
298
+ }
299
+
300
+ function navTiming() {
301
+ try {
302
+ var nav = performance.getEntriesByType('navigation')[0];
303
+ if (!nav) return {};
304
+ return {
305
+ page_load_time: nav.loadEventEnd > 0 ? Math.round(nav.loadEventEnd) : null,
306
+ dom_content_loaded_time: nav.domContentLoadedEventEnd > 0 ? Math.round(nav.domContentLoadedEventEnd) : null,
307
+ navigation_type: nav.type || null
308
+ };
309
+ } catch (e) { return {}; }
310
+ }
311
+
312
+ function connectionInfo() {
313
+ var conn = navigator.connection;
314
+ if (!conn) return {};
315
+ return {
316
+ connection_type: conn.effectiveType || null,
317
+ connection_speed: typeof conn.downlink === 'number' ? conn.downlink : null
318
+ };
319
+ }
320
+
321
+ // ---------------------------------------------------------------------
322
+ // Web vitals (FCP, LCP, CLS, FID)
323
+ // ---------------------------------------------------------------------
324
+
325
+ var vitals = {
326
+ first_contentful_paint: null,
327
+ largest_contentful_paint: null,
328
+ cumulative_layout_shift: null,
329
+ first_input_delay: null
330
+ };
331
+ var vitalsObservers = [];
332
+
333
+ function observeVitals() {
334
+ if (!config.enableWebVitals || typeof PerformanceObserver === 'undefined') return;
335
+
336
+ function observe(type, handler) {
337
+ try {
338
+ var observer = new PerformanceObserver(function (list) {
339
+ handler(list.getEntries());
340
+ });
341
+ observer.observe({ type: type, buffered: true });
342
+ vitalsObservers.push(observer);
343
+ } catch (e) { /* entry type unsupported */ }
344
+ }
345
+
346
+ observe('paint', function (entries) {
347
+ for (var i = 0; i < entries.length; i++) {
348
+ if (entries[i].name === 'first-contentful-paint') {
349
+ vitals.first_contentful_paint = Math.round(entries[i].startTime);
350
+ }
351
+ }
352
+ });
353
+ observe('largest-contentful-paint', function (entries) {
354
+ var last = entries[entries.length - 1];
355
+ if (last) vitals.largest_contentful_paint = Math.round(last.startTime);
356
+ });
357
+ observe('layout-shift', function (entries) {
358
+ for (var i = 0; i < entries.length; i++) {
359
+ if (!entries[i].hadRecentInput) {
360
+ vitals.cumulative_layout_shift =
361
+ Math.round(((vitals.cumulative_layout_shift || 0) + entries[i].value) * 10000) / 10000;
362
+ }
363
+ }
364
+ });
365
+ observe('first-input', function (entries) {
366
+ var first = entries[0];
367
+ if (first && vitals.first_input_delay === null) {
368
+ vitals.first_input_delay = Math.round(first.processingStart - first.startTime);
369
+ }
370
+ });
371
+ }
372
+
373
+ // ---------------------------------------------------------------------
374
+ // Scroll depth
375
+ // ---------------------------------------------------------------------
376
+
377
+ var scrollDepth = 0;
378
+ var scrollTickPending = false;
379
+
380
+ function updateScrollDepth() {
381
+ scrollTickPending = false;
382
+ var doc = document.documentElement;
383
+ var scrollable = doc.scrollHeight - window.innerHeight;
384
+ var pct = scrollable > 0
385
+ ? Math.min(100, Math.round((window.scrollY / scrollable) * 100))
386
+ : 100;
387
+ if (pct > scrollDepth) scrollDepth = pct;
388
+ }
389
+
390
+ function observeScroll() {
391
+ listen(window, 'scroll', function () {
392
+ if (scrollTickPending) return;
393
+ scrollTickPending = true;
394
+ window.requestAnimationFrame(updateScrollDepth);
395
+ }, { passive: true });
396
+ }
397
+
398
+ // ---------------------------------------------------------------------
399
+ // Event emission
400
+ // ---------------------------------------------------------------------
401
+
402
+ var eventHistory = [];
403
+ var pendingEvents = []; // track() calls made before init()
404
+
405
+ function buildPayload(eventName, properties) {
406
+ return {
407
+ event_id: ulid(),
408
+ event_name: eventName,
409
+ timestamp: new Date().toISOString(),
410
+ client_id: clientId,
411
+ session_id: session.id,
412
+ page_view_id: pageViewId,
413
+ user_id: userId,
414
+ properties: properties || {},
415
+ page: pageContext(),
416
+ device: deviceContext(),
417
+ utm: currentUtm,
418
+ performance: assign({}, vitals),
419
+ engagement: {
420
+ scroll_depth: scrollDepth,
421
+ time_on_page: now() - pageStartTime
422
+ },
423
+ context: assign({}, context),
424
+ sdk: { name: 'weathervane', version: VERSION }
425
+ };
426
+ }
427
+
428
+ function dispatch(type, payload) {
429
+ var event;
430
+ try {
431
+ event = new CustomEvent(type, { detail: payload });
432
+ } catch (e) {
433
+ event = document.createEvent('CustomEvent');
434
+ event.initCustomEvent(type, false, false, payload);
435
+ }
436
+ window.dispatchEvent(event);
437
+ }
438
+
439
+ function emit(eventName, properties) {
440
+ if (!ready) {
441
+ pendingEvents.push([eventName, properties]);
442
+ return null;
443
+ }
444
+ var payload = buildPayload(eventName, properties);
445
+ eventHistory.push(payload);
446
+ if (eventHistory.length > config.historySize) {
447
+ eventHistory.splice(0, eventHistory.length - config.historySize);
448
+ }
449
+ dispatch(config.eventPrefix + ':event', payload);
450
+ dispatch(config.eventPrefix + ':' + eventName, payload);
451
+ log(eventName, payload);
452
+ return payload;
453
+ }
454
+
455
+ // ---------------------------------------------------------------------
456
+ // Pageviews & SPA navigation
457
+ // ---------------------------------------------------------------------
458
+
459
+ var lastUrl = window.location.href;
460
+ var historyPatched = false;
461
+
462
+ function startPageView(eventName, extraProps) {
463
+ pageViewId = uuid();
464
+ pageStartTime = now();
465
+ scrollDepth = 0;
466
+ currentUtm = parseUtm();
467
+ emit(eventName, assign({}, extraProps));
468
+ }
469
+
470
+ function onNavigate(trigger) {
471
+ if (window.location.href === lastUrl) return;
472
+ lastUrl = window.location.href;
473
+ touchSession();
474
+ flushFormAbandons();
475
+ if (!config.enableDynamicPageview) return;
476
+ var props = { navigation_trigger: trigger };
477
+ if (trigger === 'hashchange') props.new_hash = window.location.hash || null;
478
+ startPageView('pageview_dynamic', props);
479
+ }
480
+
481
+ function patchHistory() {
482
+ if (historyPatched || !window.history || !window.history.pushState) return;
483
+ historyPatched = true;
484
+ ['pushState', 'replaceState'].forEach(function (method) {
485
+ var original = window.history[method];
486
+ window.history[method] = function () {
487
+ var result = original.apply(this, arguments);
488
+ onNavigate(method);
489
+ return result;
490
+ };
491
+ cleanups.push(function () { window.history[method] = original; });
492
+ });
493
+ listen(window, 'popstate', function () { onNavigate('popstate'); });
494
+ listen(window, 'hashchange', function () { onNavigate('hashchange'); });
495
+ }
496
+
497
+ // ---------------------------------------------------------------------
498
+ // Content tracking (data-vane-content)
499
+ // ---------------------------------------------------------------------
500
+
501
+ var contentStates = new Map();
502
+ var intersectionObserver = null;
503
+ var mutationObserver = null;
504
+
505
+ function contentDepth(el) {
506
+ var total = document.documentElement.scrollHeight;
507
+ if (!total) return 0;
508
+ var top = el.getBoundingClientRect().top + window.scrollY;
509
+ return Math.max(0, Math.min(100, Math.round((top / total) * 100)));
510
+ }
511
+
512
+ function contentProps(el, state) {
513
+ return {
514
+ content_name: state.name,
515
+ content_type: state.type,
516
+ segment: state.segment,
517
+ content_instance: state.instanceId,
518
+ content_depth: contentDepth(el),
519
+ exposure_limit: state.exposureLimit
520
+ };
521
+ }
522
+
523
+ // The composed event path lets delegated handlers see through open shadow
524
+ // roots, where e.target is retargeted to the host element.
525
+ function eventPath(e) {
526
+ if (e.composedPath) {
527
+ try { return e.composedPath(); } catch (err) { /* fall through */ }
528
+ }
529
+ var path = [];
530
+ var node = e.target;
531
+ while (node) {
532
+ path.push(node);
533
+ node = node.parentNode;
534
+ }
535
+ return path;
536
+ }
537
+
538
+ function findInPath(path, fromIndex, predicate) {
539
+ for (var i = fromIndex; i < path.length; i++) {
540
+ var node = path[i];
541
+ if (node && node.nodeType === 1 && predicate(node)) return { el: node, index: i };
542
+ }
543
+ return null;
544
+ }
545
+
546
+ function registerContent(el) {
547
+ if (!config.enableContentTracking || contentStates.has(el)) return;
548
+ var name = el.getAttribute('data-vane-content');
549
+ if (!name) return;
550
+ var state = {
551
+ name: name,
552
+ type: el.getAttribute('data-vane-type') || null,
553
+ segment: el.getAttribute('data-vane-segment') || null,
554
+ exposureLimit: parseInt(el.getAttribute('data-vane-exposure'), 10) || config.contentExposureLimit,
555
+ instanceId: uuid(),
556
+ served: true,
557
+ viewed: false,
558
+ clicked: false,
559
+ accumulated: 0,
560
+ visibleSince: null,
561
+ resumeOnShow: false,
562
+ timer: null
563
+ };
564
+ contentStates.set(el, state);
565
+ emit('content_serve', contentProps(el, state));
566
+ if (intersectionObserver) intersectionObserver.observe(el);
567
+ }
568
+
569
+ function scanContent(root) {
570
+ if (!root) root = document;
571
+ if (root.nodeType === 1) {
572
+ if (config.enableContentTracking && root.hasAttribute('data-vane-content')) {
573
+ registerContent(root);
574
+ }
575
+ if (config.trackShadowDom && root.shadowRoot) attachRoot(root.shadowRoot);
576
+ }
577
+ if (!root.querySelectorAll) return;
578
+ if (config.enableContentTracking) {
579
+ var els = root.querySelectorAll('[data-vane-content]');
580
+ for (var i = 0; i < els.length; i++) registerContent(els[i]);
581
+ }
582
+ if (config.trackShadowDom) {
583
+ var all = root.querySelectorAll('*');
584
+ for (var j = 0; j < all.length; j++) {
585
+ if (all[j].shadowRoot) attachRoot(all[j].shadowRoot);
586
+ }
587
+ }
588
+ }
589
+
590
+ // Each open shadow root needs its own MutationObserver target and its own
591
+ // `submit` listener (submit events are composed: false and never cross the
592
+ // shadow boundary). Clicks and focusin are composed and stay delegated.
593
+ var observedRoots = [];
594
+
595
+ function attachRoot(root) {
596
+ if (observedRoots.indexOf(root) !== -1) return;
597
+ observedRoots.push(root);
598
+ if (mutationObserver) {
599
+ var target = root === document ? (document.body || document.documentElement) : root;
600
+ mutationObserver.observe(target, {
601
+ childList: true,
602
+ subtree: true,
603
+ attributes: true,
604
+ attributeFilter: ['data-vane-content']
605
+ });
606
+ }
607
+ if (root !== document && config.enableFormTracking) {
608
+ listen(root, 'submit', onSubmit, true);
609
+ }
610
+ scanContent(root);
611
+ }
612
+
613
+ function patchAttachShadow() {
614
+ if (!window.Element || !Element.prototype.attachShadow) return;
615
+ var original = Element.prototype.attachShadow;
616
+ Element.prototype.attachShadow = function (init) {
617
+ var root = original.call(this, init);
618
+ // Closed roots stay private: their events are retargeted beyond
619
+ // recovery anyway, so only open roots are tracked.
620
+ if (init && init.mode === 'open') attachRoot(root);
621
+ return root;
622
+ };
623
+ cleanups.push(function () { Element.prototype.attachShadow = original; });
624
+ }
625
+
626
+ function isContentVisible(entry, viewportHeight) {
627
+ if (!entry.isIntersecting) return false;
628
+ var rect = entry.boundingClientRect;
629
+ // Standard elements must be (essentially) fully visible. Elements taller
630
+ // than the viewport count as visible once they fill enough of it.
631
+ if (rect.height <= viewportHeight) return entry.intersectionRatio >= 0.98;
632
+ return entry.intersectionRect.height >= viewportHeight * config.largeContentViewportFill;
633
+ }
634
+
635
+ function scheduleViewTimer(el, state) {
636
+ var remaining = Math.max(0, state.exposureLimit - state.accumulated);
637
+ clearTimeout(state.timer);
638
+ state.timer = setTimeout(function () {
639
+ if (state.viewed || state.visibleSince === null) return;
640
+ state.viewed = true;
641
+ var exposure = state.accumulated + (now() - state.visibleSince);
642
+ emit('content_view', assign(contentProps(el, state), {
643
+ exposure_time: Math.round(exposure)
644
+ }));
645
+ if (intersectionObserver) intersectionObserver.unobserve(el);
646
+ }, remaining);
647
+ }
648
+
649
+ function pauseContent(state) {
650
+ if (state.visibleSince !== null) {
651
+ state.accumulated += now() - state.visibleSince;
652
+ state.visibleSince = null;
653
+ }
654
+ clearTimeout(state.timer);
655
+ }
656
+
657
+ function onIntersect(entries) {
658
+ var viewportHeight = window.innerHeight;
659
+ for (var i = 0; i < entries.length; i++) {
660
+ var entry = entries[i];
661
+ var state = contentStates.get(entry.target);
662
+ if (!state || state.viewed) continue;
663
+ var visible = isContentVisible(entry, viewportHeight);
664
+ if (visible && state.visibleSince === null) {
665
+ state.visibleSince = now();
666
+ scheduleViewTimer(entry.target, state);
667
+ } else if (!visible && state.visibleSince !== null) {
668
+ pauseContent(state);
669
+ }
670
+ }
671
+ }
672
+
673
+ function setupDomTracking() {
674
+ if (!config.enableContentTracking && !config.trackShadowDom) return;
675
+
676
+ if (config.enableContentTracking && typeof IntersectionObserver !== 'undefined') {
677
+ intersectionObserver = new IntersectionObserver(onIntersect, {
678
+ threshold: [0, 0.25, 0.5, 0.65, 0.75, 0.9, 0.98, 1]
679
+ });
680
+ cleanups.push(function () { intersectionObserver.disconnect(); intersectionObserver = null; });
681
+ }
682
+
683
+ if (typeof MutationObserver !== 'undefined') {
684
+ mutationObserver = new MutationObserver(function (mutations) {
685
+ for (var i = 0; i < mutations.length; i++) {
686
+ var mutation = mutations[i];
687
+ if (mutation.type === 'attributes') {
688
+ registerContent(mutation.target);
689
+ continue;
690
+ }
691
+ for (var j = 0; j < mutation.addedNodes.length; j++) {
692
+ var node = mutation.addedNodes[j];
693
+ if (node.nodeType === 1) scanContent(node);
694
+ }
695
+ // Declarative shadow DOM streamed in after load: when the parser
696
+ // finishes a <template shadowrootmode>, it attaches the root and
697
+ // removes the template — this removal is the only observable trace.
698
+ if (config.trackShadowDom) {
699
+ for (var k = 0; k < mutation.removedNodes.length; k++) {
700
+ var removed = mutation.removedNodes[k];
701
+ if (removed.nodeType === 1 && removed.tagName === 'TEMPLATE' &&
702
+ (removed.hasAttribute('shadowrootmode') || removed.hasAttribute('shadowroot')) &&
703
+ mutation.target && mutation.target.nodeType === 1 && mutation.target.shadowRoot) {
704
+ attachRoot(mutation.target.shadowRoot);
705
+ }
706
+ }
707
+ }
708
+ }
709
+ });
710
+ cleanups.push(function () { mutationObserver.disconnect(); mutationObserver = null; });
711
+ }
712
+
713
+ if (config.trackShadowDom) patchAttachShadow();
714
+ attachRoot(document);
715
+
716
+ // Tab switches don't change intersection state, so pause/resume manually
717
+ listen(document, 'visibilitychange', function () {
718
+ if (document.visibilityState === 'hidden') {
719
+ contentStates.forEach(function (state) {
720
+ if (state.visibleSince !== null) {
721
+ pauseContent(state);
722
+ state.resumeOnShow = true;
723
+ }
724
+ });
725
+ } else {
726
+ contentStates.forEach(function (state, el) {
727
+ if (state.resumeOnShow && !state.viewed) {
728
+ state.resumeOnShow = false;
729
+ state.visibleSince = now();
730
+ scheduleViewTimer(el, state);
731
+ }
732
+ });
733
+ }
734
+ });
735
+ }
736
+
737
+ // ---------------------------------------------------------------------
738
+ // Click tracking (content clicks + links)
739
+ // ---------------------------------------------------------------------
740
+
741
+ function linkType(href) {
742
+ if (/^mailto:/i.test(href)) return 'email';
743
+ if (/^tel:/i.test(href)) return 'phone';
744
+ return 'web';
745
+ }
746
+
747
+ function onClick(e) {
748
+ var path = eventPath(e);
749
+ if (!path.length) return;
750
+ touchSession();
751
+
752
+ if (config.enableContentTracking) {
753
+ var clickHit = findInPath(path, 0, function (el) {
754
+ return el.hasAttribute('data-vane-content-click');
755
+ });
756
+ if (clickHit) {
757
+ var clickEl = clickHit.el;
758
+ var containerHit = findInPath(path, clickHit.index, function (el) {
759
+ return el.hasAttribute('data-vane-content');
760
+ });
761
+ var container = containerHit ? containerHit.el : null;
762
+ var state = container ? contentStates.get(container) : null;
763
+ if (state) state.clicked = true;
764
+ var props = {
765
+ click_id: clickEl.getAttribute('data-vane-content-click'),
766
+ element_tag: clickEl.tagName.toLowerCase(),
767
+ element_text: truncate(clickEl.innerText || clickEl.textContent, 100) || null
768
+ };
769
+ if (container && state) assign(props, contentProps(container, state));
770
+ emit('content_click', props);
771
+ }
772
+ }
773
+
774
+ if (config.enableLinkTracking) {
775
+ var anchorHit = findInPath(path, 0, function (el) {
776
+ return el.tagName === 'A' && el.getAttribute('href');
777
+ });
778
+ if (anchorHit) {
779
+ var anchor = anchorHit.el;
780
+ var rawHref = anchor.getAttribute('href') || '';
781
+ emit('link_click', {
782
+ url: anchor.href,
783
+ text: truncate(anchor.innerText || anchor.getAttribute('aria-label'), 100) || null,
784
+ target: anchor.getAttribute('target') || null,
785
+ href: truncate(rawHref.split(/[?#]/)[0], 200),
786
+ link_type: linkType(rawHref),
787
+ is_external: !!anchor.host && anchor.host !== window.location.host
788
+ });
789
+ }
790
+ }
791
+ }
792
+
793
+ // ---------------------------------------------------------------------
794
+ // Form tracking (submit + abandonment)
795
+ // ---------------------------------------------------------------------
796
+
797
+ var formEngagement = new Map();
798
+
799
+ function formProps(form) {
800
+ var fields = form.querySelectorAll('input, select, textarea');
801
+ var fieldTypes = [];
802
+ var required = 0;
803
+ for (var i = 0; i < fields.length; i++) {
804
+ var type = fields[i].type || fields[i].tagName.toLowerCase();
805
+ if (fieldTypes.indexOf(type) === -1) fieldTypes.push(type);
806
+ if (fields[i].required) required++;
807
+ }
808
+ return {
809
+ form_name: form.getAttribute('name') || form.id || 'unnamed',
810
+ form_action: form.getAttribute('action') || window.location.pathname,
811
+ form_method: (form.getAttribute('method') || 'GET').toUpperCase(),
812
+ field_count: fields.length,
813
+ field_types: fieldTypes,
814
+ required_fields: required,
815
+ optional_fields: fields.length - required,
816
+ form_type: form.getAttribute('data-vane-form-type') || null,
817
+ form_category: form.getAttribute('data-vane-form-category') || null,
818
+ form_step: form.getAttribute('data-vane-form-step') || null,
819
+ form_funnel: form.getAttribute('data-vane-form-funnel') || null,
820
+ form_value: form.getAttribute('data-vane-form-value') || null,
821
+ form_goal: form.getAttribute('data-vane-form-goal') || null,
822
+ form_segment: form.getAttribute('data-vane-form-segment') || null
823
+ };
824
+ }
825
+
826
+ function onFocusIn(e) {
827
+ var field = eventPath(e)[0];
828
+ if (!field || field.nodeType !== 1 || !field.form) return;
829
+ if (!formEngagement.has(field.form)) {
830
+ formEngagement.set(field.form, { start: now() });
831
+ }
832
+ }
833
+
834
+ function onSubmit(e) {
835
+ var form = e.target;
836
+ if (!form || form.tagName !== 'FORM') return;
837
+ var props = formProps(form);
838
+ var engagement = formEngagement.get(form);
839
+ if (engagement) {
840
+ props.completion_time = now() - engagement.start;
841
+ formEngagement.delete(form);
842
+ }
843
+ emit('form_submit', props);
844
+ }
845
+
846
+ function flushFormAbandons() {
847
+ if (!config.enableFormTracking) return;
848
+ formEngagement.forEach(function (engagement, form) {
849
+ var engagementTime = now() - engagement.start;
850
+ if (engagementTime >= config.formAbandonThreshold) {
851
+ emit('form_abandon', assign(formProps(form), { engagement_time: engagementTime }));
852
+ }
853
+ });
854
+ formEngagement.clear();
855
+ }
856
+
857
+ // ---------------------------------------------------------------------
858
+ // Public API
859
+ // ---------------------------------------------------------------------
860
+
861
+ function init(options) {
862
+ if (ready) {
863
+ log('init() called twice — ignoring');
864
+ return api;
865
+ }
866
+ assign(config, options || {});
867
+ ready = true;
868
+
869
+ loadClientId();
870
+ currentUtm = parseUtm();
871
+ observeVitals();
872
+ observeScroll();
873
+ loadSession(); // may emit session_start
874
+
875
+ if (config.enableDynamicPageview) patchHistory();
876
+ listen(document, 'click', onClick, true);
877
+ if (config.enableFormTracking) {
878
+ listen(document, 'focusin', onFocusIn, true);
879
+ listen(document, 'submit', onSubmit, true);
880
+ listen(window, 'pagehide', flushFormAbandons);
881
+ }
882
+ listen(document, 'keydown', touchSession, { passive: true });
883
+ listen(window, 'scroll', touchSession, { passive: true });
884
+ listen(window, 'pagehide', persistSession);
885
+
886
+ whenDomReady(function () {
887
+ if (config.enableAutoPageview) {
888
+ startPageView('pageview', assign({ navigation_trigger: 'initial' }, navTiming(), connectionInfo()));
889
+ } else {
890
+ pageViewId = uuid();
891
+ }
892
+ setupDomTracking();
893
+ });
894
+
895
+ // Replay anything tracked before init
896
+ var queued = pendingEvents.splice(0);
897
+ for (var i = 0; i < queued.length; i++) emit(queued[i][0], queued[i][1]);
898
+
899
+ log('initialized', config);
900
+ return api;
901
+ }
902
+
903
+ function track(eventName, properties) {
904
+ if (!eventName || typeof eventName !== 'string') {
905
+ log('track() requires an event name string');
906
+ return null;
907
+ }
908
+ return emit(eventName, properties);
909
+ }
910
+
911
+ function trackPageView() {
912
+ startPageView('pageview', { navigation_trigger: 'manual' });
913
+ }
914
+
915
+ function on(eventName, callback, options) {
916
+ options = options || {};
917
+ var type = config.eventPrefix + ':' + (eventName === '*' ? 'event' : eventName);
918
+ var handler = function (e) { callback(e.detail, e); };
919
+ window.addEventListener(type, handler);
920
+ if (options.replay) {
921
+ for (var i = 0; i < eventHistory.length; i++) {
922
+ if (eventName === '*' || eventHistory[i].event_name === eventName) {
923
+ callback(eventHistory[i], null);
924
+ }
925
+ }
926
+ }
927
+ return function off() { window.removeEventListener(type, handler); };
928
+ }
929
+
930
+ function setUserId(id) {
931
+ var previous = userId;
932
+ userId = id || null;
933
+ if (userId && userId !== previous) {
934
+ emit('user_identify', { user_id: userId, previous_user_id: previous });
935
+ }
936
+ }
937
+
938
+ function setContext(key, value) {
939
+ if (value === undefined || value === null) {
940
+ delete context[key];
941
+ } else {
942
+ context[key] = value;
943
+ }
944
+ }
945
+
946
+ function destroy() {
947
+ flushFormAbandons();
948
+ contentStates.forEach(function (state) { clearTimeout(state.timer); });
949
+ contentStates.clear();
950
+ formEngagement.clear();
951
+ vitalsObservers.forEach(function (observer) {
952
+ try { observer.disconnect(); } catch (e) { /* already disconnected */ }
953
+ });
954
+ vitalsObservers = [];
955
+ cleanups.forEach(function (fn) {
956
+ try { fn(); } catch (e) { /* best effort */ }
957
+ });
958
+ cleanups = [];
959
+ observedRoots = [];
960
+ historyPatched = false;
961
+ ready = false;
962
+ log('destroyed');
963
+ }
964
+
965
+ var api = {
966
+ __loaded: true,
967
+ version: VERSION,
968
+ init: init,
969
+ track: track,
970
+ trackPageView: trackPageView,
971
+ on: on,
972
+ setUserId: setUserId,
973
+ getUserId: function () { return userId; },
974
+ setContext: setContext,
975
+ getContext: function () { return assign({}, context); },
976
+ clearContext: function () { context = {}; },
977
+ newSession: function () { startNewSession('manual'); },
978
+ getClientId: function () { return clientId; },
979
+ getSessionId: function () { return session.id; },
980
+ getPageViewId: function () { return pageViewId; },
981
+ getHistory: function () { return eventHistory.slice(); },
982
+ getContentState: function () {
983
+ var out = [];
984
+ contentStates.forEach(function (state) {
985
+ out.push({
986
+ content_name: state.name,
987
+ content_type: state.type,
988
+ segment: state.segment,
989
+ served: state.served,
990
+ viewed: state.viewed,
991
+ clicked: state.clicked,
992
+ exposure_limit: state.exposureLimit
993
+ });
994
+ });
995
+ return out;
996
+ },
997
+ isReady: function () { return ready; },
998
+ destroy: destroy
999
+ };
1000
+
1001
+ // Replace any loader stub and replay its queued calls
1002
+ var queuedCalls = existing && existing._queue;
1003
+ window.vane = api;
1004
+ if (queuedCalls && queuedCalls.length) {
1005
+ for (var q = 0; q < queuedCalls.length; q++) {
1006
+ var call = queuedCalls[q];
1007
+ if (typeof api[call[0]] === 'function') api[call[0]].apply(api, call[1]);
1008
+ }
1009
+ }
1010
+
1011
+ // Auto-init unless explicitly disabled via
1012
+ // <script src="weathervane.js" data-vane-auto="false">
1013
+ // or `window.vaneConfig = { autoInit: false }` before this script loads.
1014
+ var currentScript = document.currentScript;
1015
+ var autoDisabled =
1016
+ (currentScript && currentScript.getAttribute('data-vane-auto') === 'false') ||
1017
+ (window.vaneConfig && window.vaneConfig.autoInit === false);
1018
+ if (!autoDisabled && !ready) {
1019
+ init(window.vaneConfig || {});
1020
+ }
1021
+
1022
+ if (typeof module !== 'undefined' && module.exports) {
1023
+ module.exports = api;
1024
+ }
1025
+ })(typeof window !== 'undefined' ? window : null,
1026
+ typeof document !== 'undefined' ? document : null);