clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
152 lines (135 loc) • 7.46 kB
text/typescript
import { Event, Setting } from "@clarity-types/data";
import { InteractionState, RegionData, RegionState, RegionQueue, RegionVisibility } from "@clarity-types/layout";
import { time } from "@src/core/time";
import * as dom from "@src/layout/dom";
import encode from "@src/layout/encode";
export let state: RegionState[] = [];
let regionMap: WeakMap<Node, string> = null; // Maps region nodes => region name
let regions: { [key: number]: RegionData } = {};
let queue: RegionQueue[] = [];
let watch = false;
let observer: IntersectionObserver = null;
export function start(): void {
reset();
observer = null;
regionMap = new WeakMap();
regions = {};
queue = [];
watch = window["IntersectionObserver"] ? true : false;
}
export function observe(node: Node, name: string): void {
if (regionMap.has(node) === false) {
regionMap.set(node, name);
observer = observer === null && watch ? new IntersectionObserver(handler, {
// Get notified as intersection continues to change
// This allows us to process regions that get partially hidden during the lifetime of the page
// See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
// By default, intersection observers only fire an event when even a single pixel is visible and not thereafter.
threshold: [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
}) : observer;
if (observer && node && node.nodeType === Node.ELEMENT_NODE) {
observer.observe(node as Element);
}
}
}
export function exists(node: Node): boolean {
// Check if regionMap is not null before looking up a node
// Since, dom module stops after region module, it's possible that we may set regionMap to be null
// and still attempt to call exists on a late coming DOM mutation (or addition), effectively causing a script error
return regionMap && regionMap.has(node);
}
export function track(id: number, event: Event): void {
let node = dom.getNode(id);
let data = id in regions ? regions[id] : { id, visibility: RegionVisibility.Rendered, interaction: InteractionState.None, name: regionMap.get(node) };
// Determine the interaction state based on incoming event
let interaction = InteractionState.None;
switch (event) {
case Event.Click: interaction = InteractionState.Clicked; break;
case Event.Input: interaction = InteractionState.Input; break;
}
// Process updates to this region, if applicable
process(node, data, interaction, data.visibility);
}
export function compute(): void {
// Process any regions where we couldn't resolve an "id" for at the time of last intersection observer event
// This could happen in cases where elements are not yet processed by Clarity's virtual DOM but browser reports a change, regardless.
// For those cases we add them to the queue and re-process them below
let q = [];
for (let r of queue) {
let id = dom.getId(r.node);
if (id) {
r.state.data.id = id;
regions[id] = r.state.data;
state.push(r.state);
} else { q.push(r); }
}
queue = q;
// Schedule encode only when we have at least one valid data entry
if (state.length > 0) { encode(Event.Region); }
}
function handler(entries: IntersectionObserverEntry[]): void {
for (let entry of entries) {
let target = entry.target;
let rect = entry.boundingClientRect;
let overlap = entry.intersectionRect;
let viewport = entry.rootBounds;
// Only capture regions that have non-zero width or height to avoid tracking and sending regions
// that cannot ever be seen by the user. In some cases, websites will have a multiple copy of the same region
// like search box - one for desktop, and another for mobile. In those cases, CSS media queries determine which one should be visible.
// Also, if these regions ever become non-zero width or height (through AJAX, user action or orientation change) - we will automatically start monitoring them from that point onwards
if (regionMap.has(target) && rect.width + rect.height > 0 && viewport && viewport.width > 0 && viewport.height > 0) {
let id = target ? dom.getId(target) : null;
let data = id in regions ? regions[id] : { id, name: regionMap.get(target), interaction: InteractionState.None, visibility: RegionVisibility.Rendered };
// For regions that have relatively smaller area, we look at intersection ratio and see the overlap relative to element's area
// However, for larger regions, area of regions could be bigger than viewport and therefore comparison is relative to visible area
let viewportRatio = overlap ? (overlap.width * overlap.height * 1.0) / (viewport.width * viewport.height) : 0;
let visible = viewportRatio > Setting.ViewportIntersectionRatio || entry.intersectionRatio > Setting.IntersectionRatio;
// If an element is either visible or was visible and has been scrolled to the end
// i.e. Scrolled to end is determined by if the starting position of the element + the window height is more than the total element height.
// starting position is relative to the viewport - so Intersection observer returns a negative value for rect.top to indicate that the element top is above the viewport
let scrolledToEnd = (visible || data.visibility == RegionVisibility.Visible) && Math.abs(rect.top) + viewport.height > rect.height;
// Process updates to this region, if applicable
process(target, data, data.interaction,
(scrolledToEnd ?
RegionVisibility.ScrolledToEnd :
(visible ? RegionVisibility.Visible : RegionVisibility.Rendered)));
// Stop observing this element now that we have already received scrolled signal
if (data.visibility >= RegionVisibility.ScrolledToEnd && observer) { observer.unobserve(target); }
}
}
if (state.length > 0) { encode(Event.Region); }
}
function process(n: Node, d: RegionData, s: InteractionState, v: RegionVisibility): void {
// Check if received a state that supersedes existing state
let updated = s > d.interaction || v > d.visibility;
d.interaction = s > d.interaction ? s : d.interaction;
d.visibility = v > d.visibility ? v : d.visibility;
// If the corresponding node is already discovered, update the internal state
// Otherwise, track it in a queue to reprocess later.
if (d.id) {
if ((d.id in regions && updated) || !(d.id in regions)) {
regions[d.id] = d;
state.push(clone(d));
}
} else {
// Get the time before adding to queue to ensure accurate event time
queue.push({node: n, state: clone(d)});
}
}
function clone(r: RegionData): RegionState {
return { time: time(), data: { id: r.id, interaction: r.interaction, visibility: r.visibility, name: r.name }};
}
export function reset(): void {
state = [];
}
export function stop(): void {
reset();
regionMap = null;
regions = {};
queue = [];
if (observer) {
observer.disconnect();
observer = null;
}
watch = false;
}