Source: components/ads-defer.js

if (typeof window.db == 'undefined') window.db = { libs: {} };

(function() {
    'use strict';

    /**
     * Component responsible for requesting ads from DFP, both
     * lazyloaded and normal.
     * @namespace
     */
    db.libs.adsDefer = (function(){

        /**
         * The name of the component.
         * @private
         * @memberof db.libs.adsDefer
         * @type {string}
         */
        var name = "adsDefer";

        /**
         * The threshold of how many pixels before an adunit comes into
         * view the request to DFP should be made.
         * @private
         * @memberof db.libs.adsDefer
         * @type {number}
         */
        var threshold = 400;

        /**
         * The array for all the ads to be requested on load.
         * @private
         * @memberof db.libs.adsDefer
         * @type {array}
         */
        var ads = [];

        /**
         * The array for all the ads to be lazyloaded.
         * @private
         * @memberof db.libs.adsDefer
         * @type {array}
         */
        var lazyAds = [];

        /**
         * Boolean to check if listeners for ads have been added.
         * @private
         * @memberof db.libs.adsDefer
         * @type {boolean}
         */
        var listenersAdded = false;

        /**
         * Is set to the sum of scrollTop + window.innerHeight + threshold.
         * @private
         * @memberof db.libs.adsDefer
         * @type {number}
         */
        var offset = 0;

        /**
         * The amount of scroll in the browser's window.
         * @private
         * @memberof db.libs.adsDefer
         * @type {number}
         */
        var scrollTop = 0;

        /**
         * Boolean to check if a scroll listener has been added to the window.
         * @private
         * @memberof db.libs.adsDefer
         * @type {boolean}
         */
        var scrollIsBound = false;

        /**
         * Array of ad objects that have been rendered, meaning
         * they have received response from DFP.
         * @private
         * @memberof db.libs.adsDefer
         * @type {array}
         */
        var renderedAds = [];

        /**
         * Adds a listener that's called each time an ad on the page has finished rendering.
         * @private
         * @memberof db.libs.adsDefer
         * @docs https://developers.google.com/doubleclick-gpt/reference#googletag.events.SlotRenderEndedEvent
         */
        function addSlotRenderEndedListener() {
            googletag.pubads().addEventListener('slotRenderEnded', function(event) {
                var renderedAtTime = Date.now();

                var id = event.slot.getSlotElementId();
                var adunit = document.getElementById(id);
                var requestedOn = adunit.getAttribute('data-load-on');
                var isEmpty = event.isEmpty;

                if(!isEmpty) {
                    renderedAds.push({
                        id: id,
                        renderedAtTime: renderedAtTime
                    });
                }

                if(typeof dataLayer !== 'undefined' && dataLayer) {
                    dataLayer.push({
                        'eventCategory': 'ads',
                        'eventAction': 'loadedAds',
                        'eventLabel': id,
                        'event': 'adInteraction',
                        'eventValue': 1,
                        'isEmpty': isEmpty,
                        'requestedOn': requestedOn
                    });
                }
            });
        }

        /**
         * Adds a listener that's called each time an ad has been considered viewed
         * based on some criterias (see docs).
         * Here we also calculate how long it took for an ad to be viewable from the
         * time it was rendered.
         * @private
         * @memberof db.libs.adsDefer
         * @docs https://support.google.com/dfp_premium/answer/4574077?hl=en
         */
        function addImpressionViewableListener() {
            googletag.pubads().addEventListener('impressionViewable', function(event) {
                var viewedAtTime = Date.now();
                var id = event.slot.getSlotElementId();
                var adunit = document.getElementById(id);
                var requestedOn = adunit.getAttribute('data-load-on');

                var renderedAtTime = 0;
                for(var i = 0; i < renderedAds.length; i++) {
                    if(renderedAds[i] !== null) {
                        if(renderedAds[i].id === id) {
                            renderedAtTime = renderedAds[i].renderedAtTime;
                            renderedAds[i] = null;
                        }
                    }
                }
                
                var timeFromRender = 0;
                if(renderedAtTime > 0) {
                    timeFromRender = (viewedAtTime - renderedAtTime) / 1000;
                }

                if(typeof dataLayer !== 'undefined' && dataLayer) {
                    dataLayer.push({
                        'eventCategory': 'ads',
                        'eventAction': 'viewableAds',
                        'eventLabel': id,
                        'event': 'adInteraction',
                        'eventValue': 1,
                        'isEmpty': false,
                        'requestedOn': requestedOn,
                        'timeFromRender': timeFromRender
                    });
                }
            });
        }

        /**
         * Enables the googletag services if it hasn't been enabled already.
         * Also starts listeners for slot events.
         * @private
         * @memberof db.libs.adsDefer
         */
        function enableAndListen() {
            if(!googletag.pubadsReady) {
                // Set Publisher ID for app
                if(typeof window.dbApp !== 'undefined' && window.dbApp && dbApp.getAdid() !== ""){
                    googletag.pubads().setPublisherProvidedId(dbApp.getAdid());
                } else {
                    if(typeof window.localStorage !== 'undefined' && window.localStorage.getItem("dbApp") !== null && window.localStorage.getItem("dbApp") !== "") {
                        googletag.pubads().setPublisherProvidedId(window.localStorage.getItem("dbApp"));
                    }
                }

                // Center ads
                googletag.pubads().setCentering(true);

                googletag.enableServices();
            }

            if(!listenersAdded) {
                addSlotRenderEndedListener();
                addImpressionViewableListener();
                listenersAdded = true;
            }
        }

        /**
         * Checks if an adunit has the right attributes to be considered valid.
         * These attributes are required to make a correct request to DFP.
         * @param {HTMLElement} HTMLElement The HTMLElement to check
         * @return {object} An object representing the adunit
         * @private
         * @memberof db.libs.adsDefer
         */
        function assertValidAdUnit(adunit) {
            var isValid = true;

            if(!adunit) {
                console.warn('DB ads-defer: Not an adunit');
                return;
            }

            // Check the ID of the adunit
            var id = adunit.id;
            if(!id) {
                console.warn('DB ads-defer: An ID is not defined for an adunit');
                return;
            }

            // Get the slot
            var slot = adunit.getAttribute('data-slot');

            // Check the sizes
            var rawSizes = adunit.getAttribute('data-sizes');
            var sizes = null;
            if(rawSizes) {
                try {
                    JSON.parse(rawSizes);
                } catch (e) {
                    console.warn('DB ads-defer: Ad request error: Not valid sizes for ' + id);
                    return;
                }

                sizes = JSON.parse(rawSizes);
            }

            // Check the JSON
            var rawJSON = adunit.getAttribute('json');
            var targeting = {};
            if(rawJSON) {
                try {
                    JSON.parse(rawJSON);
                } catch (e) {
                    console.warn('DB ads-defer: Ad request error: Not valid JSON for ' + id);
                    return;
                }

                var json = JSON.parse(rawJSON);
                targeting = json.targeting;
            }

            // Check if adunit is set to collapse
            var collapse = adunit.getAttribute('data-collapse');
            var setToCollapse = false;
            if(typeof collapse !== 'undefined') {
                if(collapse === '1') {
                    setToCollapse = true;
                }
            }

            var outOfPage = adunit.getAttribute('data-out-of-page');
            var setOutOfPage = false;
            if(typeof outOfPage !== 'undefined') {
                if(outOfPage === '1') {
                    setOutOfPage = true;
                }
            }

            if(!slot) {
                console.warn('DB ads-defer: Slot is not defined for adunit ' + id);
                isValid = false;
            }

            if(!sizes) {
                console.warn('DB ads-defer: Sizes is not defined for adunit ' + id);
                isValid = false;
            }


            if(!isValid) {
                return null;
            } else {
                return {
                    id: id,
                    slot: slot,
                    sizes: sizes,
                    targeting: targeting,
                    setToCollapse: setToCollapse,
                    setOutOfPage: setOutOfPage
                };
            }
        }

        /**
         * Defines a single slot which is pushed to the googletag.cmd object.
         * @param {object} ad An object representing the adunit
         * @private
         * @memberof db.libs.adsDefer
         */
        function defineSlot(adunit) {
            // Define a new slot
            var slot;

            if(adunit.setOutOfPage) {
                slot = googletag.defineOutOfPageSlot(adunit.slot, adunit.id);
            } else {
                slot = googletag.defineSlot(adunit.slot, adunit.sizes, adunit.id);
            }

            slot.addService(googletag.pubads());

            // Set targeting
            for(var key in adunit.targeting) {
                slot.setTargeting(key, adunit.targeting[key]);
            }

            // Collapse empty divs
            if(adunit.setToCollapse) {
                slot.setCollapseEmptyDiv(true);
            }
        }

        /**
         * Creates slots for each adunit that has <code>data-load-on="load"</code> attribute.
         * The request for these slots are then made to DFP in a single request.
         * This enables roadblocking.
         * @private
         * @memberof db.libs.adsDefer
         */
        function createOnLoadSlots() {
            googletag.cmd.push(function(){
                var adsToDisplay = [];
                for(var i = 0; i < ads.length; i++) {
                    var adunit = assertValidAdUnit(ads[i]);
                    if(adunit) {
                        // Define a new slot
                        defineSlot(adunit);

                        // Add the adunit's ID to the array
                        adsToDisplay.push(adunit.id);
                    }
                }

                // Make a single request to DFP for these ads
                googletag.pubads().enableSingleRequest();

                // Enable the services and listen for slot events
                enableAndListen();

                // We need to display each ad _after_ we've enabled the service
                for(var j = 0; j < adsToDisplay.length; j++) {
                    googletag.display(adsToDisplay[j]);
                }
            });
        }

        /**
         * Create a slot for an adunit that has the <code>data-load-on="view"</code> attribute.
         * @private
         * @memberof db.libs.adsDefer
         * @param {object} ad The DOM element representing the adunit.
         */
        function createLazySlot(adunit) {
            var ad = assertValidAdUnit(adunit);
            if(ad) {
                googletag.cmd.push(function() {
                    // Define a new slot
                    defineSlot(ad);

                    // Enable the services and listen for slot events
                    enableAndListen();

                    // Display the ad
                    googletag.display(ad.id);
                });
            }
        }

        /**
         * Searches for adunits with <code>data-load-on="view"</code> attribute that are within the viewport threshold.
         * If found, a slot for this adunit is defined.
         * @private
         * @memberof db.libs.adsDefer
         */
        function getAdunitsWithinViewThreshold() {
            scrollTop = getScrollTop();
            offset = scrollTop + window.innerHeight + threshold;
            for(var i = 0; i < lazyAds.length; i++) {
                var adunit = lazyAds[i];
                if(adunit !== null) {
                    var adTopOffset;
                    if(adunit.className.indexOf('sticky') > -1) { // For sticky, we need to calculate the top offset on each scroll
                        adTopOffset = getTopOffset(adunit);
                    } else {
                        adTopOffset = parseInt(adunit.getAttribute('data-top-offset'), 10);
                    }
                    var offsetHeight = parseInt(adunit.getAttribute('data-offsetheight'), 10);
                    var adBottomOffset = adTopOffset + offsetHeight;
                    if(offset > adTopOffset) { // First, check if the adunit is located within the bottom threshold
                        if((adBottomOffset + threshold) > scrollTop) { // Then, check if the scroll does not exceed the adunit's offset + height + treshold
                            createLazySlot(adunit);
                            lazyAds[i] = null;
                        }
                    }
                }
            }
        }

        /**
         * Bind the scroll event that handles when to do requests for lazyloaded ads.
         * @private
         * @memberof db.libs.adsDefer
         */
        function bindScroll() {
            scrollIsBound = true;
            window.addEventListener('scroll', function(){
                window.requestAnimationFrame(getAdunitsWithinViewThreshold);
            });
        }

        /**
         * Get the scroll from the top.
         * @private
         * @memberof db.libs.adsDefer
         * @return {number} The scroll amount
         */
        function getScrollTop() {
            var doc = document.documentElement;
            var top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
            return top;
        }

        /**
         * Get the top offset of an element.
         * @private
         * @memberof db.libs.adsDefer
         * @param {HTMLElement} element The element of which to get the offset
         * @return {number} The top offset
         */
        function getTopOffset(element) {
            var topOffset = 0;
          
            while(element) {
                topOffset += (element.offsetTop + element.clientTop);
                element = element.offsetParent;
            }

            return topOffset;
        }

        /**
         * Get the offsetHeight of an element.
         * The <code>offsetHeight</code> is the height of an element including
         * its padding, borders and horizontal scrollbar (if present).
         * @private
         * @memberof db.libs.adsDefer
         * @param {HTMLElement} element The element of which to get the offsetHeight
         * @return {number} The element's offsetHeight
         */
        function getOffsetHeight(element) {
            return element.offsetHeight;
        }

        /**
         * Initialize ads loading and bind scroll-listener.
         * @private
         * @memberof db.libs.adsDefer
         */
        function initialize() {
            createOnLoadSlots();
            if(lazyAds.length) {
                getAdunitsWithinViewThreshold();
                if(scrollIsBound === false) {
                    bindScroll();
                }
            }
        }

        /**
         * Creates an array of <code>HTMLElements</code> from a <code>NodeList</code> that have
         * not already been configured.
         * It also sets the adunit's topoffset and offsetheight data attributes on the element.
         * @private
         * @memberof db.libs.adsDefer
         * @param {NodeList} nodeList The NodeList
         * @return {array} An array with HTMLElements
         */
        function createArray(nodeList) {
            var arr = [];
            for(var i = 0; i < nodeList.length; i++) {
                var adunit = nodeList[i];
                var isConfigured = adunit.hasAttribute('data-is-configured');

                if(isConfigured === false) {
                    adunit.setAttribute('data-top-offset', getTopOffset(adunit));
                    adunit.setAttribute('data-offsetheight', getOffsetHeight(adunit));
                    adunit.setAttribute('data-is-configured', 'true');
                    arr.push(adunit);
                }
            }

            return arr;
        }

        /**
         * Find adunits with either the <code>data-load-on="load"</code> or <code>data-load-on="view"</code> attribute.
         * Populate the two arrays <code>ads</code> and <code>lazyAds</code> with these adunits respectively.
         * @private
         * @memberof db.libs.adsDefer
         */
        function findAdunits() {
            ads = createArray(document.querySelectorAll("[data-load-on=load]"));
            lazyAds = createArray(document.querySelectorAll("[data-load-on=view]"));
        }

        /**
         * Reflow the component. Is for example called when lazyloading the rest of the Dagbladet frontpage on mobile.
         * @private
         * @memberof db.libs.adsDefer
         */
        function reflow() {
            // Find new adunits
            findAdunits();

            // No need to initialize if there are no ads on the page
            if(ads.length === 0 && lazyAds.length === 0) {
                return;
            }

            // Initialize!
            initialize();
        }

        function load() {

        }

        /**
         * Initialize the component.
         * @private
         * @memberof db.libs.adsDefer
         */
        function init() {

            var initiated = document.querySelector('html').hasAttribute('data-ads-defer');

            if(!initiated){

                // Find all the adunits on the page
                findAdunits();

                // No need to initialize if there are no ads on the page
                if(ads.length === 0 && lazyAds.length === 0) {
                    document.querySelector('html').setAttribute('data-ads-defer', 'true');
                    return;
                }

                // Initialize before the document is completely ready
                if (document.readyState === 'interactive') {
                    initialize();
                } else {
                    document.addEventListener('DOMContentLoaded', function(){
                        setTimeout(function(){
                            initialize();
                        }, 0);
                    });
                }

                document.querySelector('html').setAttribute('data-ads-defer', 'true');
            }
        }

        return {
            init: init,
            reflow: reflow,
            load: load
        };

    })();
})();