/* eslint-disable @typescript-eslint/no-explicit-any */ //TODO: CI-1975 reset any types to something better, requires quite a bit of digging /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver} */ type ObserverOptions = { root?: HTMLElement // The element that is used as the viewport for checking visibility of the target. null referes to browser viewport. rootMargin: string // Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). threshold: number[] // Either a single number or an array of numbers which indicate at what percentage of the target's visibility the observer's callback should be executed. } // TODO CI-1975 some of these already appear on Body, should be put in one place type PostTrackerConfig = { options?: ObserverOptions // The options object passed into the IntersectionObserver() constructor let you control the circumstances under which the observer's callback is invoked query: string // The query string used to query elements from the DOM. returnVisibleElement: boolean // When this is set to true, listeners of the visible event will get the target element id and the element. Defaults to false minMillisecondsToReport?: number //The minimum time in milliseconds a DOM object must be visible on the users view port to report a View event as seen. defaults to 5000 observerUpdateEventString?: string // The DOM event to listen for(on the document level) to update the elements being observed. liveBlogWrapperQuery?: string // HTMLElement query string for the liveblogwrapper element. Used to get the element (document.querySelector) to listen for DOM events liveBlogWrapper?: HTMLElement // liveblogwrapper element onEntersViewport: (args: { timestamp: string; element?: HTMLElement }) => void // Function to call when a Post enters a user's viewport onRead: (args: { post: object viewport: ViewPort summary: object[] }) => void // Function to call when a Post has been in the user's viewport for the specified report time onError: (event: Error) => void // Function to call when there's an error } type ViewPort = { height: number width: number } /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ type VisibilityData = { elementHeight: number // the actual height (in pixels) of the element being observed elementWidth: number // the actual width (in pixels) of the element being observed start: Date // the Date time inwhich this specific threshold came into view threshold: number // the percentage of the element currently visible on the users viewport. 0 -> 1 time: number // the time in milliseconds this intersection was recorded relative to the IntersectionObserver's origin time type: AllowedVisibilityTypes // the type of data recorded [INTERSECTION_DATA || TAB_VISIBILIYTY_DATA] viewPort: ViewPort // the current height and width of the viewport visibleHeight: number // the currently visibleHeight of the element on the user's viewport visibleWidth: number // the currently visible width of the element on the user's viewport xBound: number // The x cartesian coordinate of the left side of the element. Coordinates start from the left side of the screen. 0 = absolute left yBound: number // The y cartesian coordinate of the top side of the element. Coordinates start from the top of the screen. 0 = absolute top duration?: number // A computed total number of time this intersection was visible before the next intersection was recorded } /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ type TabVisibilityData = { hidden: boolean // if the tab is current hidden from the user i.e browser minimised or user on a different tab timestamp: Date // Datetime in which the visibility data was recorded time: number // time in milliseconds inwhich the tab visibility data was recorded type: AllowedVisibilityTypes // the type of data recorded [INTERSECTION_DATA || TAB_VISIBILITY_DATA] } type AllowedVisibilityTypes = 'INTERSECTION_DATA' | 'TAB_VISIBILITY_DATA' const DATA_TYPES = { intersectionData: 'INTERSECTION_DATA', tabVisibilityDATA: 'TAB_VISIBILITY_DATA', } /** * A class representing an intersection observer tracker. * * Used to track how a user reads through articles on FT pages. * * If no elements are found IntersectionObserverTracker the onError callback is called. */ export class PostTracker { config: PostTrackerConfig currentlyObservedElements: Set visibleElements: Set wrapperElement: HTMLElement | null defaultOptions: { root: null; rootMargin: string; threshold: number[] } onEntersViewport: PostTrackerConfig['onEntersViewport'] onRead: PostTrackerConfig['onRead'] onError: PostTrackerConfig['onError'] observer!: IntersectionObserver constructor(config: PostTrackerConfig) { /** * Create a new Intersection Observer tracker * @type {PostTrackerConfig} config */ this.config = config this.currentlyObservedElements = new Set() this.visibleElements = new Set() /** * live blog wrapper element * @type {HTMLElement} */ this.wrapperElement = config.liveBlogWrapper ? config.liveBlogWrapper : config.liveBlogWrapperQuery ? document.querySelector(config.liveBlogWrapperQuery) : null this.onEntersViewport = config?.onEntersViewport this.onRead = config?.onRead this.onError = config?.onError this.config.minMillisecondsToReport = config?.minMillisecondsToReport || 5000 this.defaultOptions = { root: null, rootMargin: '0px', threshold: [0.1, 0.2, 0.5, 0.75, 0.99], } this.setUp() } setUp() { if (IntersectionObserver) { if (!this.isValidConfig()) { return } // check if the parent wrapper element for posts exist if (!this.wrapperElement) { return } this.observer = new IntersectionObserver( (entries) => this.manageIntersection(entries), { ...this.defaultOptions, ...this.config.options, } ) if (this.observer) { this.observeElements(this.config.query) } document.addEventListener('visibilitychange', (event) => this.handleVisibilityChange(event) ) window.addEventListener('beforeunload', () => this.handleWindowClose()) // updates the list of elements currently being observed if (this.config.observerUpdateEventString && this.wrapperElement) { this.wrapperElement.addEventListener( this.config.observerUpdateEventString, () => this.observeElements(this.config.query) ) } } else { this.triggerError(new Error('Unsupported browser.')) } } /** * checks if the PostTrackerConfig passed in is valid * * @returns {Boolean} */ isValidConfig() { if (!this.config) { // eslint wasn't happy with the spaces in the template literal. Therefore the long line error message const errorMessage = 'PostTrackerConfig is missing. \nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {\nroot: null,\nrootMargin: "0px",\nthreshold: [0.1, 0.2, 0.5, 0.75, 1]\n},\nquery: \'live-blog-post\'})' this.triggerError(new Error(errorMessage)) return false } if (!this.config.query) { const errorMessage = "PostTrackerConfig.query is missing.\nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {...},\nquery: 'live-blog-post'})" this.triggerError(new Error(errorMessage)) return false } return true } /** * Handles reporting when a DOM element gets into the view * Also reports when a post has been on the viewport for more than 5s */ manageIntersection(entries: IntersectionObserverEntry[]) { entries.forEach((entry) => { if (typeof this.onEntersViewport === 'function') { this.reportOnEntersViewport(entry) } if (typeof this.onRead === 'function') { this.summariseAndReport(entry) } }) } /** * Adds window visibility data to dataset.visibilityData for all DOM elements if this.visibleElements */ handleVisibilityChange(event: Event) { const timestamp = new Date() if (this.visibleElements.size > 0) { this.visibleElements.forEach((element) => { if (element.dataset.visibilityData) { const visibilityData = JSON.parse(element.dataset.visibilityData) if (Array.isArray(visibilityData)) { const data = { type: DATA_TYPES.tabVisibilityDATA, hidden: document.hidden, timestamp, time: event.timeStamp, } visibilityData.push(data) element.dataset.visibilityData = JSON.stringify(visibilityData) } } }) } } /** * Starts observing a list of elements that match query. * Calls the on error callback if no element is found. */ observeElements(query: string) { const elements = this.wrapperElement?.querySelectorAll(query) if (query && elements && elements.length) { elements.forEach((element) => { if (!this.isElementBeingObserved(element)) { this.observer.observe(element) this.currentlyObservedElements.add(element) } }) } else { this.triggerError( new Error( 'No DOM elements found with the query passed in PostTrackerConfig' ) ) } } /** * Observes new post rendered in the wrapper */ observeNewElements() { this.observeElements(this.config.query) } /** * Disconnects the intersection observer and stops watching for visibility changes * @returns */ stopObservation() { if (!this.observer || !this.config.query || !this.wrapperElement) { return } this.observer.disconnect() } /** * Stops observing element visibility and cleans up resources */ destroy() { this.runFinalReadReport() this.stopObservation() this.currentlyObservedElements = new Set() this.visibleElements = new Set() } /** * Calls the onEntersViewport callback only once when the target element of an IntersectionObserverEntry enters the user's viewPort. */ reportOnEntersViewport(entry: IntersectionObserverEntry) { const element = entry.target as HTMLElement if ( entry.isIntersecting && (!element.dataset.seen || element.dataset.seen !== 'true') ) { element.dataset.seen = 'true' let eventData = { timestamp: new Date().toISOString(), } if (this.config.returnVisibleElement) { eventData = Object.assign({ element }, eventData) } this.onEntersViewport(eventData) } } summariseAndReport(entry: IntersectionObserverEntry) { // only add summary data when threshold/intersectionRatio is greater than 0 if (entry.intersectionRatio > 0) { this.summariseAndStoreViewData(entry) } if (!entry.isIntersecting) { this.reportRead(entry.target as HTMLElement) } this.updateVisibleElements(entry) } /** * Gets the summary data from the IntersectionObserverEntry and stores it * in the IntersectionObserverEntry.target.dataset.visibilityData as a * JSON.stringified object (refers to an Array). */ summariseAndStoreViewData(entry: IntersectionObserverEntry) { const element = entry.target as HTMLElement const data = { type: DATA_TYPES.intersectionData, threshold: entry.intersectionRatio, xBound: entry.intersectionRect.x, // x coordinate on the screen. left of screen = 0 yBound: entry.intersectionRect.y, // y coordinate on the screen. top of screen = 0 visibleHeight: entry.intersectionRect.height, visibleWidth: entry.intersectionRect.width, elementHeight: element.clientHeight, elementWidth: element.clientWidth, start: new Date(), time: entry.time, viewport: { height: window.innerHeight, width: window.innerWidth, }, } if (element.dataset.visibilityData) { const currentData = JSON.parse(element.dataset.visibilityData) currentData.push(data) element.dataset.visibilityData = JSON.stringify(currentData) } else { element.dataset.visibilityData = JSON.stringify([data]) } } /** * Emits the View data to the consumer */ reportRead(element: HTMLElement) { let summary = element.dataset.visibilityData if (summary) { summary = JSON.parse(summary) const viewData = { ...this.processTime(summary), element: this.config.returnVisibleElement ? element : null, post: { height: element.clientHeight, width: element.clientWidth, }, viewport: { height: window.innerHeight, width: window.innerWidth, }, } const duration = this.config?.minMillisecondsToReport || 5000 if (viewData.duration >= duration) { this.onRead(viewData) } } delete element.dataset.visibilityData } /** * Adds or removes a DOM element from the this.visibleElements depending on if * the element is intersecting */ updateVisibleElements(entry: IntersectionObserverEntry) { const element = entry.target as HTMLElement if (entry.isIntersecting && !this.visibleElements.has(element)) { this.visibleElements.add(element) } else if (this.visibleElements.has(element)) { this.visibleElements.delete(element) } } /** * Processes the times for when the DOM element was in view * * @param {(VisibilityData|TabVisibilityData)[]} summary - NB not working when try this in typescript */ processTime(summary: any) { /** * total time visible * time away from tab * time a specific intersection was visible */ const aggregatedSummary = [] let totalTime = 0 let start if ('start' in summary[0]) { start = summary[0].start } let end for (let i = 0; i < summary.length; i++) { const element = summary[i] const next = summary[i + 1] let newElement // calculate duration for total time spent for specific intersection if (element.type === DATA_TYPES.intersectionData) { newElement = { ...element, // if no next element, use window performance to calculate total time of intersection duration: (next ? next.time : performance.now()) - element.time, } totalTime += newElement.duration } // this also considers situations where the user closes the tab // while it is hidden. The last dataset should be tab_visibility_data with hidden = true if (!next) { if ('timestamp' in element) { end = element.timestamp } else { end = new Date().toISOString() } } aggregatedSummary.push(newElement || element) } return { summary: aggregatedSummary, duration: totalTime, start, end, } } /** * Emits the read event for all visible elements when the user closes the browser window * this function doesn't exist, unclear whether anything should happen or not */ handleWindowClose() { // this.runFinalReport() } /** * runs read report for all visible elements */ runFinalReadReport() { this.visibleElements.forEach((element: HTMLElement) => this.reportRead(element) ) } /** * Calls the onError callback if a function is passed to the */ triggerError(error: Error) { if (this.onError) { this.onError(error) } } /** * checks if an element is currently being observed * @returns {boolean} */ isElementBeingObserved(element: Element) { return this.currentlyObservedElements.has(element) } }