Link Enhancer

Adds some extra WME functionality related to Google place links.

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/523706/1753168/Link%20Enhancer.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Link Enhancer
// @namespace    WazeDev
// @version      2026.02.12.001
// @description  Adds some extra WME functionality related to Google place links.
// @author       MapOMatic, WazeDev group
// @include      /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
// @license      GNU GPLv3
// ==/UserScript==
 
/* global OpenLayers */
/* global W */
/* global google */

/* eslint-disable no-unused-vars */
/**
 * Google Link Enhancer - Validates and enhances Google Place links in Waze Map Editor
 * @class GoogleLinkEnhancer
 */
class GoogleLinkEnhancer {
    // Configuration Constants
    static CONFIG = {
        CACHE: {
            NAME: 'gle_link_cache',
            CLEAN_INTERVAL_MIN: 1,
            LIFESPAN_HR: 6,
            MAX_SIZE: 1000
        },
        COLORS: {
            PERM_CLOSED: '#F00',
            TEMP_CLOSED: '#FD3',
            TOO_FAR: '#0FF',
            NOT_FOUND: '#F0F',
            DUPLICATE: '#FFA500',
            HIGHLIGHT: '#FF0',
            HIGHLIGHT_BG_ORANGE: '#FFA500',
            HIGHLIGHT_BG_RED: '#FAA',
            HIGHLIGHT_BG_YELLOW: '#FFA',
            HIGHLIGHT_BG_CYAN: '#0FF'
        },
        DEFAULTS: {
            DISTANCE_LIMIT: 400,
            POINT_TIMEOUT: 4000,
            API_DISABLE_DURATION: 10000,
            MAX_RETRY_CALLS: 30,
            RETRY_INTERVAL: 100
        },
        SELECTORS: {
            EXT_PROV_ELEM: 'wz-list-item.external-provider',
            EXT_PROV_ELEM_EDIT: 'wz-list-item.external-provider-edit',
            EXT_PROV_ELEM_CONTENT: 'div.external-provider-content'
        },
        STROKE: {
            POINT_WIDTH: '4',
            AREA_WIDTH: '12',
            LINE_WIDTH: 4,
            DASH_STYLE: '12 12',
            CLOSED_DASH_SHORT: '2 6',
            CLOSED_DASH_LONG: '2 16'
        }
    };

    #DISABLE_CLOSED_PLACES = false;
    #linkCache;
    #enabled = false;
    #disableApiUntil = null;
    #mapLayer = null;
    #distanceLimit = GoogleLinkEnhancer.CONFIG.DEFAULTS.DISTANCE_LIMIT;
    #showTempClosedPOIs = true;
    #placesService;
    #linkObserver;
    #modeObserver;
    #searchResultsObserver;
    #lzString;
    #cacheCleanIntervalID;
    #originalHeadAppendChildMethod;
    #ptFeature;
    #lineFeature;
    #timeoutID;
    #processPlacesDebounced;
    strings = {
        permClosedPlace: 'Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.',
        tempClosedPlace: 'Google indicates this place is temporarily closed.',
        multiLinked: 'Linked more than once already. Please find and remove multiple links.',
        linkedToThisPlace: 'Already linked to this place',
        linkedNearby: 'Already linked to a nearby place',
        linkedToXPlaces: 'This is linked to {0} places',
        badLink: 'Invalid Google link. Please remove it.',
        tooFar: 'The Google linked place is more than {0} meters from the Waze place.  Please verify the link is correct.'
    };
 
    /* eslint-enable no-unused-vars */
    /**
     * Initialize Google Link Enhancer
     * @constructor
     */
    constructor() {
        const attributionElem = document.createElement('div');
        this.#placesService = new google.maps.places.PlacesService(attributionElem);
        this.#initLZString();
        this.#initCache();
        this.#processPlacesDebounced = this.#debounce(() => this.#processPlaces(), 300);
 
        this.#initLayer();

        // Register event handlers with debounced processing
        W.model.events.register('mergeend', null, () => { this.#processPlacesDebounced(); });
        W.model.venues.on('objectschanged', () => { this.#processPlacesDebounced(); });
        W.model.venues.on('objectsremoved', () => { this.#processPlacesDebounced(); });
        W.model.venues.on('objectsadded', () => { this.#processPlacesDebounced(); });
 
        // Watch for ext provider elements being added to the DOM, and add hover events.
        this.#linkObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
                    const nd = mutation.addedNodes[idx];
                    if (nd.nodeType === Node.ELEMENT_NODE) {
                        const $el = $(nd);
                        const $subel = $el.find(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM);
                        if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM)) {
                            this.#addHoverEvent($el);
                            this.#formatLinkElements();
                        } else if ($subel.length) {
                            for (let i = 0; i < $subel.length; i++) {
                                this.#addHoverEvent($($subel[i]));
                            }
                            this.#formatLinkElements();
                        }
                        if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_EDIT)) {
                            // Support both production and beta: find any wz-autocomplete
                            const wzAuto = $el.find('wz-autocomplete')[0];
                            if (wzAuto) {
                                // Try to observe shadowRoot if available
                                if (wzAuto.shadowRoot) {
                                    this.#searchResultsObserver.observe(wzAuto.shadowRoot, { childList: true, subtree: true });
                                } else {
                                    // Fallback: try to observe the element itself (may not work if results are in shadow DOM)
                                    this.#searchResultsObserver.observe(wzAuto, { childList: true, subtree: true });
                                    console.warn('GLE: wz-autocomplete has no shadowRoot, observing element directly (beta fallback)');
                                }
                            } else {
                                console.warn('GLE: wz-autocomplete not found in external-provider-edit');
                            }
                        }
                    }
                }
                for (let idx = 0; idx < mutation.removedNodes.length; idx++) {
                    const nd = mutation.removedNodes[idx];
                    if (nd.nodeType === Node.ELEMENT_NODE) {
                        const $el = $(nd);
                        if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_EDIT)) {
                            this.#searchResultsObserver.disconnect();
                        }
                    }
                }
            });
        });
 
        // Watch for Google place search result list items being added to the DOM
        const that = this;
        this.#searchResultsObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
                    const nd = mutation.addedNodes[idx];
                    if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('wz-menu-item.simple-item')) {
                        $(nd).mouseenter(() => {
                            // When mousing over a list item, find the Google place ID from the list that was stored previously.
                            // Then add the point/line to the map.
                            that.#addPoint($(nd).attr('item-id'));
                        }).mouseleave(() => {
                            // When leaving the list item, remove the point.
                            that.#destroyPoint();
                        });
                    }
                }
            });
        });
 
        // Watch the side panel for addition of the sidebar-layout div, which indicates a mode change.
        this.#modeObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
                    const nd = mutation.addedNodes[idx];
                    if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('.sidebar-layout')) {
                        this.#observeLinks();
                        break;
                    }
                }
            });
        });
 
        // This is a special event that will be triggered when DOM elements are destroyed.
        /* eslint-disable wrap-iife, func-names, object-shorthand */
        (function($) {
            $.event.special.destroyed = {
                remove: function(o) {
                    if (o.handler && o.type !== 'destroyed') {
                        o.handler();
                    }
                }
            };
        })(jQuery);
        /* eslint-enable wrap-iife, func-names, object-shorthand */
 
        // In case a place is already selected on load.
        const selObjects = W.selectionManager.getSelectedDataModelObjects();
        if (selObjects.length && selObjects[0].type === 'venue') {
            this.#formatLinkElements();
        }
    }

    /**
     * Initialize cache from localStorage
     * @private
     */
    #initCache() {
        const STORED_CACHE = localStorage.getItem(GoogleLinkEnhancer.CONFIG.CACHE.NAME);
        try {
            this.#linkCache = STORED_CACHE ? new Map(Object.entries(JSON.parse(this.#lzString.decompressFromUTF16(STORED_CACHE)))) : new Map();
        } catch (ex) {
            this.#handleError(ex, 'initCache', 'An error occurred while loading the stored cache. A new cache was created.');
            this.#linkCache = new Map();
        }
    }

    /**
     * Error handler for consistent error logging
     * @private
     * @param {Error} error - The error object
     * @param {string} context - Where the error occurred
     * @param {string} [message] - Custom message
     */
    #handleError(error, context, message) {
        const logMessage = message || error.message;
        console.error(`[GoogleLinkEnhancer] ${context}:`, logMessage, error);
    }

    /**
     * Debounce function to limit function call frequency
     * @private
     * @param {Function} func - Function to debounce
     * @param {number} wait - Wait time in milliseconds
     * @returns {Function} Debounced function
     */
    #debounce(func, wait) {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }
 
    /**
     * Initialize map layer for visual indicators
     * @private
     */
    #initLayer() {
        const uniqueName = '___GoogleGLELinkEnhancements';
        const existingLayer = W.map.getLayerByUniqueName(uniqueName);
        if (existingLayer) {
            this.#mapLayer = existingLayer;
        } else {
            this.#mapLayer = new OpenLayers.Layer.Vector('Google GLE Link Enhancements.', {
                uniqueName,
                displayInLayerSwitcher: true,
                styleMap: new OpenLayers.StyleMap({
                    default: {
                        strokeColor: '${strokeColor}',
                        strokeWidth: '${strokeWidth}',
                        strokeDashstyle: '${strokeDashstyle}',
                        pointRadius: '15',
                        fillOpacity: '0'
                    }
                })
            });
            this.#mapLayer.setOpacity(0.8);
            W.map.addLayer(this.#mapLayer);
        }
    }

    /**
     * Enable the link enhancer
     * @public
     */
 
    enable() {
        if (!this.#enabled) {
            this.#modeObserver.observe($('.edit-area #sidebarContent')[0], { childList: true, subtree: false });
            this.#observeLinks();
            // Watch for JSONP callbacks. JSONP is used for the autocomplete results when searching for Google links.
            this.#addJsonpInterceptor();
            // Note: Using on() allows passing "this" as a variable, so it can be used in the handler function.
            $('#map').on('mouseenter', null, this, GoogleLinkEnhancer.#onMapMouseenter);
            $(window).on('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
            W.model.venues.on('objectschanged', this.#formatLinkElements, this);
            this.#processPlaces();
            this.#cleanAndSaveLinkCache();
            this.#cacheCleanIntervalID = setInterval(
                () => this.#cleanAndSaveLinkCache(),
                1000 * 60 * GoogleLinkEnhancer.CONFIG.CACHE.CLEAN_INTERVAL_MIN
            );
            this.#enabled = true;
        }
    }

    /**
     * Disable the link enhancer
     * @public
     */
 
    disable() {
        if (this.#enabled) {
            this.#modeObserver.disconnect();
            this.#linkObserver.disconnect();
            this.#searchResultsObserver.disconnect();
            this.#removeJsonpInterceptor();
            $('#map').off('mouseenter', GoogleLinkEnhancer.#onMapMouseenter);
            $(window).off('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
            W.model.venues.off('objectschanged', this.#formatLinkElements, this);
            if (this.#cacheCleanIntervalID) clearInterval(this.#cacheCleanIntervalID);
            this.#cleanAndSaveLinkCache();
            this.#enabled = false;
        }
    }
 
    /**
     * Get the distance limit in meters
     * @public
     * @returns {number} Distance limit
     */
    get distanceLimit() {
        return this.#distanceLimit;
    }

    /**
     * Set the distance limit in meters
     * @public
     * @param {number} value - Distance limit
     */
 
    set distanceLimit(value) {
        this.#distanceLimit = value;
        this.#processPlacesDebounced();
    }

    /**
     * Get whether to show temporarily closed POIs
     * @public
     * @returns {boolean}
     */
    get showTempClosedPOIs() {
        return this.#showTempClosedPOIs;
    }

    /**
     * Set whether to show temporarily closed POIs
     * @public
     * @param {boolean} value
     */
 
    set showTempClosedPOIs(value) {
        this.#showTempClosedPOIs = value;
        this.#processPlacesDebounced();
    }
 
    static #onWindowUnload(evt) {
        evt.data.#cleanAndSaveLinkCache();
    }
 
    /**
     * Clean expired entries and save cache to localStorage
     * @private
     */
    #cleanAndSaveLinkCache() {
        if (!this.#linkCache) return;
        const now = Date.now();
        const maxAge = GoogleLinkEnhancer.CONFIG.CACHE.LIFESPAN_HR * 3600 * 1000;
        
        for (const [id, link] of this.#linkCache.entries()) {
            // Bug fix: normalize location property
            if (link.location) {
                link.loc = link.location;
                delete link.location;
            }
            // Delete link if older than max age
            if (!link.ts || (now - link.ts) > maxAge) {
                this.#linkCache.delete(id);
            }
        }
        
        // Convert Map to object for storage
        const cacheObj = Object.fromEntries(this.#linkCache.entries());
        try {
            localStorage.setItem(
                GoogleLinkEnhancer.CONFIG.CACHE.NAME,
                this.#lzString.compressToUTF16(JSON.stringify(cacheObj))
            );
        } catch (ex) {
            this.#handleError(ex, 'cleanAndSaveLinkCache', 'Failed to save cache to localStorage');
        }
    }
 
    /**
     * Calculate distance between two points in meters
     * @private
     * @static
     * @param {OpenLayers.Geometry.Point} point1 - First point
     * @param {OpenLayers.Geometry.Point} point2 - Second point
     * @returns {number} Distance in meters
     */
    static #distanceBetweenPoints(point1, point2) {
        const line = new OpenLayers.Geometry.LineString([point1, point2]);
        return line.getGeodesicLength(W.map.getProjectionObject());
    }
 
    /**
     * Check if linked Google place is too far from Waze venue
     * @private
     * @param {Object} link - Link info with location
     * @param {Object} venue - Waze venue object
     * @returns {boolean} True if link is too far
     */
    #isLinkTooFar(link, venue) {
        if (!link?.loc) return false;
        
        const linkPt = new OpenLayers.Geometry.Point(link.loc.lng, link.loc.lat);
        linkPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject());
        
        let venuePt;
        let distanceLim = this.distanceLimit;
        
        if (venue.isPoint()) {
            venuePt = venue.geometry.getCentroid();
        } else {
            const bounds = venue.geometry.getBounds();
            const center = bounds.getCenterLonLat();
            venuePt = new OpenLayers.Geometry.Point(center.lon, center.lat);
            const topRightPt = new OpenLayers.Geometry.Point(bounds.right, bounds.top);
            distanceLim += GoogleLinkEnhancer.#distanceBetweenPoints(venuePt, topRightPt);
        }
        
        const distance = GoogleLinkEnhancer.#distanceBetweenPoints(linkPt, venuePt);
        return distance > distanceLim;
    }
 
    /**
     * Process all places and add visual indicators for issues
     * @private
     */
    #processPlaces() {
        try {
            if (!this.#enabled) return;
                const that = this;
                // Get a list of already-linked id's
                const existingLinks = GoogleLinkEnhancer.#getExistingLinks();
                this.#mapLayer.removeAllFeatures();
                const drawnLinks = [];
                W.model.venues.getObjectArray().forEach(venue => {
                    const promises = [];
                    venue.attributes.externalProviderIDs.forEach(provID => {
                        const id = provID.attributes.uuid;

                        // Check for duplicate links
                        const linkInfo = existingLinks[id];
                        if (linkInfo.count > 1) {
                            const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
                            const width = venue.isPoint() ? GoogleLinkEnhancer.CONFIG.STROKE.POINT_WIDTH : GoogleLinkEnhancer.CONFIG.STROKE.AREA_WIDTH;
                            const color = GoogleLinkEnhancer.CONFIG.COLORS.DUPLICATE;
                            const features = [new OpenLayers.Feature.Vector(geometry, {
                                strokeWidth: width, strokeColor: color
                            })];
                            const lineStart = geometry.getCentroid();
                            linkInfo.venues.forEach(linkVenue => {
                                if (linkVenue !== venue
                                    && !drawnLinks.some(dl => (dl[0] === venue && dl[1] === linkVenue) || (dl[0] === linkVenue && dl[1] === venue))) {
                                    features.push(
                                        new OpenLayers.Feature.Vector(
                                            new OpenLayers.Geometry.LineString([lineStart, linkVenue.geometry.getCentroid()]),
                                            {
                                                strokeWidth: GoogleLinkEnhancer.CONFIG.STROKE.LINE_WIDTH,
                                                strokeColor: color,
                                                strokeDashstyle: GoogleLinkEnhancer.CONFIG.STROKE.DASH_STYLE
                                            }
                                        )
                                    );
                                    drawnLinks.push([venue, linkVenue]);
                                }
                            });
                            that.#mapLayer.addFeatures(features);
                        }

                        // Get Google link info, and store results for processing.
                        promises.push(that.#getLinkInfoAsync(id));
                    });

                    // Process all results of link lookups and add a highlight feature if needed
                    Promise.all(promises).then(results => {
                        let strokeColor = null;
                        let strokeDashStyle = 'solid';
                        const closedNamePattern = /^(\[|\()?(permanently |temporarily )?closed(\]|\)| -)|( \(|- |\[)(permanently |temporarily )?closed(\)|\])?$/i;
                        
                        if (!that.#DISABLE_CLOSED_PLACES && results.some(res => res.permclosed)) {
                            if (closedNamePattern.test(venue.attributes.name)) {
                                strokeDashStyle = venue.isPoint() 
                                    ? GoogleLinkEnhancer.CONFIG.STROKE.CLOSED_DASH_SHORT 
                                    : GoogleLinkEnhancer.CONFIG.STROKE.CLOSED_DASH_LONG;
                            }
                            strokeColor = GoogleLinkEnhancer.CONFIG.COLORS.PERM_CLOSED;
                        } else if (results.some(res => that.#isLinkTooFar(res, venue))) {
                            strokeColor = GoogleLinkEnhancer.CONFIG.COLORS.TOO_FAR;
                        } else if (!that.#DISABLE_CLOSED_PLACES && that.#showTempClosedPOIs && results.some(res => res.tempclosed)) {
                            if (closedNamePattern.test(venue.attributes.name)) {
                                strokeDashStyle = venue.isPoint() 
                                    ? GoogleLinkEnhancer.CONFIG.STROKE.CLOSED_DASH_SHORT 
                                    : GoogleLinkEnhancer.CONFIG.STROKE.CLOSED_DASH_LONG;
                            }
                            strokeColor = GoogleLinkEnhancer.CONFIG.COLORS.TEMP_CLOSED;
                        } else if (results.some(res => res.notFound)) {
                            strokeColor = GoogleLinkEnhancer.CONFIG.COLORS.NOT_FOUND;
                        }
                        
                        if (strokeColor) {
                            const style = {
                                strokeWidth: venue.isPoint() 
                                    ? GoogleLinkEnhancer.CONFIG.STROKE.POINT_WIDTH 
                                    : GoogleLinkEnhancer.CONFIG.STROKE.AREA_WIDTH,
                                strokeColor,
                                strokeDashStyle
                            };
                            const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
                            that.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]);
                        }
                    }).catch(ex => {
                        that.#handleError(ex, 'processPlaces.Promise', 'Error processing venue links');
                    });
                });
        } catch (ex) {
            this.#handleError(ex, 'processPlaces', 'Error in processPlaces');
        }
    }
 
    /**
     * Cache a link with timestamp
     * @private
     * @param {string} id - Place ID
     * @param {Object} link - Link information
     */
    #cacheLink(id, link) {
        link.ts = Date.now();
        this.#linkCache.set(id, link);
        
        // Enforce max cache size
        if (this.#linkCache.size > GoogleLinkEnhancer.CONFIG.CACHE.MAX_SIZE) {
            const firstKey = this.#linkCache.keys().next().value;
            this.#linkCache.delete(firstKey);
        }
    }
 
    /**
     * Get link information from cache or Google Places API
     * @private
     * @param {string} placeId - Google Place ID
     * @returns {Promise<Object>} Link information
     */
    #getLinkInfoAsync(placeId) {
        return new Promise(resolve => {
            let link = this.#linkCache.get(placeId);
            if (!link) {
                const request = {
                    placeId,
                    fields: ['geometry', 'business_status']
                };
                this.#placesService.getDetails(request, (place, requestStatus) => {
                    link = {};
                    if (requestStatus === google.maps.places.PlacesServiceStatus.OK) {
                        const loc = place.geometry.location;
                        link.loc = { lng: loc.lng(), lat: loc.lat() };
                        if (place.business_status === 'CLOSED_PERMANENTLY') {
                            link.permclosed = true;
                        } else if (place.business_status === 'CLOSED_TEMPORARILY') {
                            link.tempclosed = true;
                        }
                        this.#cacheLink(placeId, link);
                    } else if (requestStatus === google.maps.places.PlacesServiceStatus.NOT_FOUND) {
                        link.notfound = true;
                        this.#cacheLink(placeId, link);
                    } else if (this.#disableApiUntil) {
                        link.apiDisabled = true;
                    } else {
                        link.error = requestStatus;
                        this.#disableApiUntil = Date.now() + GoogleLinkEnhancer.CONFIG.DEFAULTS.API_DISABLE_DURATION;
                        this.#handleError(
                            new Error(requestStatus),
                            'getLinkInfoAsync',
                            `API temporarily disabled due to error: ${requestStatus}`
                        );
                    }
                    resolve(link);
                });
            } else {
                resolve(link);
            }
        });
    }
 
    static #onMapMouseenter(event) {
        // If the point isn't destroyed yet, destroy it when mousing over the map.
        event.data.#destroyPoint();
    }
 
    /**
     * Format and colorize link elements in edit panel
     * @private
     * @param {number} [callCount=0] - Retry counter
     */
    async #formatLinkElements(callCount = 0) {
        const editPanel = document.getElementById('edit-panel');
        if (!editPanel) return;
        
        const links = editPanel.querySelectorAll(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM);
        const selObjects = W.selectionManager.getSelectedDataModelObjects();
        
        if (!links.length) {
            // Retry if links aren't available yet
            if (callCount < GoogleLinkEnhancer.CONFIG.DEFAULTS.MAX_RETRY_CALLS 
                && selObjects.length 
                && selObjects[0].type === 'venue') {
                setTimeout(
                    () => this.#formatLinkElements(++callCount),
                    GoogleLinkEnhancer.CONFIG.DEFAULTS.RETRY_INTERVAL
                );
            }
        } else {
            const existingLinks = GoogleLinkEnhancer.#getExistingLinks();

            // Fetch all links first
            const promises = [];
            const extProvElements = [];
            
            links.forEach(linkEl => {
                const $linkEl = $(linkEl);
                extProvElements.push($linkEl);

                const id = GoogleLinkEnhancer.#getIdFromElement($linkEl);
                if (!id) return;

                promises.push(this.#getLinkInfoAsync(id));
            });
            
            await Promise.all(promises);

            extProvElements.forEach($extProvElem => {
                const id = GoogleLinkEnhancer.#getIdFromElement($extProvElem);
                if (!id) return;

                const contentQuery = GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_CONTENT;
                const link = this.#linkCache.get(id);
                const $content = $extProvElem.find(contentQuery);
                
                if (existingLinks[id]?.count > 1 && existingLinks[id].isThisVenue) {
                    setTimeout(() => {
                        $content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_ORANGE })
                            .attr('title', this.strings.linkedToXPlaces.replace('{0}', existingLinks[id].count));
                    }, 50);
                }
                
                this.#addHoverEvent($extProvElem);
                
                if (link) {
                    if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) {
                        $content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_RED })
                            .attr('title', this.strings.permClosedPlace);
                    } else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) {
                        $content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_YELLOW })
                            .attr('title', this.strings.tempClosedPlace);
                    } else if (link.notFound) {
                        $content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.NOT_FOUND })
                            .attr('title', this.strings.badLink);
                    } else {
                        const venue = W.selectionManager.getSelectedDataModelObjects()[0];
                        if (this.#isLinkTooFar(link, venue)) {
                            $content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_CYAN })
                                .attr('title', this.strings.tooFar.replace('{0}', this.distanceLimit));
                        } else {
                            // Reset in case we just deleted another provider
                            $content.css({ backgroundColor: '' }).attr('title', '');
                        }
                    }
                }
            });
        }
    }
 
    /**
     * Get all existing Google links across all venues
     * @private
     * @static
     * @returns {Object} Map of place IDs to link info
     */
    static #getExistingLinks() {
        const existingLinks = {};
        const thisVenue = W.selectionManager.getSelectedDataModelObjects()[0];
        W.model.venues.getObjectArray().forEach(venue => {
            const isThisVenue = venue === thisVenue;
            const thisPlaceIDs = [];
            venue.attributes.externalProviderIDs.forEach(provID => {
                const id = provID.attributes.uuid;
                if (thisPlaceIDs.indexOf(id) === -1) {
                    thisPlaceIDs.push(id);
                    let link = existingLinks[id];
                    if (link) {
                        link.count++;
                        link.venues.push(venue);
                    } else {
                        link = { count: 1, venues: [venue] };
                        existingLinks[id] = link;
                        if (provID.attributes.url != null) {
                            const u = provID.attributes.url.replace('https://maps.google.com/?', '');
                            link.url = u;
                        }
                    }
                    link.isThisVenue = link.isThisVenue || isThisVenue;
                }
            });
        });
        return existingLinks;
    }
 
    /**
     * Remove the POI point from the map
     * @private
     */
    #destroyPoint() {
        if (this.#ptFeature) {
            this.#ptFeature.destroy();
            this.#ptFeature = null;
            this.#lineFeature.destroy();
            this.#lineFeature = null;
        }
    }
 
    /**
     * Get the current map extent in OpenLayers format
     * @private
     * @static
     * @returns {OpenLayers.Bounds} Map extent
     */
    static #getOLMapExtent() {
        let extent = W.map.getExtent();
        if (Array.isArray(extent)) {
            extent = new OpenLayers.Bounds(extent);
            extent.transform('EPSG:4326', 'EPSG:3857');
        }
        return extent;
    }
 
    /**
     * Add a point and line to the map showing Google place location and distance
     * @private
     * @param {string} id - Google Place ID
     */
    #addPoint(id) {
        if (!id) return;
        const link = this.#linkCache.get(id);
        if (link) {
            if (!link.notFound) {
                const coord = link.loc;
                const poiPt = new OpenLayers.Geometry.Point(coord.lng, coord.lat);
                poiPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject().projCode);
                const placeGeom = W.selectionManager.getSelectedDataModelObjects()[0].geometry.getCentroid();
                const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y);
                const ext = GoogleLinkEnhancer.#getOLMapExtent();
                const lsBounds = new OpenLayers.Geometry.LineString([
                    new OpenLayers.Geometry.Point(ext.left, ext.bottom),
                    new OpenLayers.Geometry.Point(ext.left, ext.top),
                    new OpenLayers.Geometry.Point(ext.right, ext.top),
                    new OpenLayers.Geometry.Point(ext.right, ext.bottom),
                    new OpenLayers.Geometry.Point(ext.left, ext.bottom)]);
                let lsLine = new OpenLayers.Geometry.LineString([placePt, poiPt]);
 
                // If the line extends outside the bounds, split it so we don't draw a line across the world.
                const splits = lsLine.splitWith(lsBounds);
                let label = '';
                if (splits) {
                    let splitPoints;
                    splits.forEach(split => {
                        split.components.forEach(component => {
                            if (component.x === placePt.x && component.y === placePt.y) splitPoints = split;
                        });
                    });
                    lsLine = new OpenLayers.Geometry.LineString([splitPoints.components[0], splitPoints.components[1]]);
                    let distance = GoogleLinkEnhancer.#distanceBetweenPoints(poiPt, placePt);
                    let unitConversion;
                    let unit1;
                    let unit2;
                    if (W.model.isImperial) {
                        distance *= 3.28084;
                        unitConversion = 5280;
                        unit1 = ' ft';
                        unit2 = ' mi';
                    } else {
                        unitConversion = 1000;
                        unit1 = ' m';
                        unit2 = ' km';
                    }
                    if (distance > unitConversion * 10) {
                        label = Math.round(distance / unitConversion) + unit2;
                    } else if (distance > 1000) {
                        label = (Math.round(distance / (unitConversion / 10)) / 10) + unit2;
                    } else {
                        label = Math.round(distance) + unit1;
                    }
                }
 
                this.#destroyPoint(); // Just in case it still exists.
                this.#ptFeature = new OpenLayers.Feature.Vector(poiPt, { poiCoord: true }, {
                    pointRadius: 6,
                    strokeWidth: 30,
                    strokeColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT,
                    fillColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT,
                    strokeOpacity: 0.5
                });
                this.#lineFeature = new OpenLayers.Feature.Vector(lsLine, {}, {
                    strokeWidth: 3,
                    strokeDashstyle: '12 8',
                    strokeColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT,
                    label,
                    labelYOffset: 45,
                    fontColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT,
                    fontWeight: 'bold',
                    labelOutlineColor: '#000',
                    labelOutlineWidth: 4,
                    fontSize: '18'
                });
                W.map.getLayerByUniqueName('venues').addFeatures([this.#ptFeature, this.#lineFeature]);
                this.#timeoutDestroyPoint();
            }
        } else {
            this.#getLinkInfoAsync(id).then(res => {
                if (!res.error && !res.apiDisabled) {
                    this.#addPoint(id);
                }
            }).catch(ex => {
                this.#handleError(ex, 'addPoint.getLinkInfo', 'Error getting link info for point');
            });
        }
    }
 
    /**
     * Destroy the point after timeout
     * @private
     */
    #timeoutDestroyPoint() {
        if (this.#timeoutID) clearTimeout(this.#timeoutID);
        this.#timeoutID = setTimeout(
            () => this.#destroyPoint(),
            GoogleLinkEnhancer.CONFIG.DEFAULTS.POINT_TIMEOUT
        );
    }
 
    /**
     * Get Place ID from DOM element
     * @private
     * @static
     * @param {jQuery} $el - jQuery element
     * @returns {string|null} Place ID or null
     */
    static #getIdFromElement($el) {
        const providerIndex = $el.parent().children().toArray().indexOf($el[0]);
        return W.selectionManager.getSelectedDataModelObjects()[0].getExternalProviderIDs()[providerIndex]?.attributes.uuid;
    }
 
    /**
     * Add hover event to show point on map
     * @private
     * @param {jQuery} $el - jQuery element
     */
    #addHoverEvent($el) {
        $el.hover(() => this.#addPoint(GoogleLinkEnhancer.#getIdFromElement($el)), () => this.#destroyPoint());
    }
 
    /**
     * Start observing edit panel for link elements
     * @private
     */
    #observeLinks() {
        const editPanel = document.querySelector('#edit-panel');
        if (!editPanel) {
            setTimeout(() => this.#observeLinks(), 250);
            return;
        }
        this.editPanelElem = editPanel;
        this.#linkObserver.observe(editPanel, { childList: true, subtree: true });
    }
 
    /**
     * Intercept JSONP callbacks to process Google autocomplete results
     * Watches for JSONP script tags added to the DOM and overrides their callbacks
     * to extract place IDs from search results.
     * @private
     */
    #addJsonpInterceptor() {
        // The idea for this function was hatched here:
        // https://stackoverflow.com/questions/6803521/can-google-maps-places-autocomplete-api-be-used-via-ajax/9856786
 
        // The head element, where the Google Autocomplete code will insert a tag
        // for a javascript file.
        const head = $('head')[0];
        // The name of the method the Autocomplete code uses to insert the tag.
        const method = 'appendChild';
        // The method we will be overriding.
        const originalMethod = head[method];
        this.#originalHeadAppendChildMethod = originalMethod;
        const that = this;
        /* eslint-disable func-names, prefer-rest-params */ // Doesn't work as an arrow function (at least not without some modifications)
        head[method] = function() {
            // Check that the element is a javascript tag being inserted by Google.
            if (arguments[0] && arguments[0].src && arguments[0].src.match(/GetPredictions/)) {
                // Regex to extract the name of the callback method that the JSONP will call.
                const callbackMatchObject = (/callback=([^&]+)&|$/).exec(arguments[0].src);
 
                // Regex to extract the search term that was entered by the user.
                const searchTermMatchObject = (/\?1s([^&]+)&/).exec(arguments[0].src);
 
                // const searchTerm = unescape(searchTermMatchObject[1]);
                if (callbackMatchObject && searchTermMatchObject) {
                    // The JSONP callback method is in the form "abc.def" and each time has a different random name.
                    const names = callbackMatchObject[1].split('.');
                    // Store the original callback method.
                    const originalCallback = names[0] && names[1] && window[names[0]] && window[names[0]][names[1]];
 
                    if (originalCallback) {
                        const newCallback = function() { // Define your own JSONP callback
                            if (arguments[0] && arguments[0].predictions) {
                                // SUCCESS!
 
                                // The autocomplete results
                                const data = arguments[0];
 
                                // console.log('GLE: ' + JSON.stringify(data));
                                that._lastSearchResultPlaceIds = data.predictions.map(pred => pred.place_id);
 
                                // Call the original callback so the WME dropdown can do its thing.
                                originalCallback(data);
                            }
                        };
 
                        // Add copy of all the attributes of the old callback function to the new callback function.
                        // This prevents the autocomplete functionality from throwing an error.
                        Object.keys(originalCallback).forEach(key => {
                            newCallback[key] = originalCallback[key];
                        });
                        window[names[0]][names[1]] = newCallback; // Override the JSONP callback
                    }
                }
            }
            // Insert the element into the dom, regardless of whether it was being inserted by Google.
            return originalMethod.apply(this, arguments);
        };
        /* eslint-enable func-names, prefer-rest-params */
    }
 
    /**
     * Remove JSONP interceptor
     * @private
     */
    #removeJsonpInterceptor() {
        $('head')[0].appendChild = this.#originalHeadAppendChildMethod;
    }
 
    /**
     * Initialize LZ-String compression library
     * Used for compressing cache data stored in localStorage
     * @private
     */
    #initLZString() {
        /* eslint-disable */ // Disabling eslint since this is copied third-party code
        // LZ Compressor
        // Copyright (c) 2013 Pieroxy <[email protected]>
        // This work is free. You can redistribute it and/or modify it
        // under the terms of the WTFPL, Version 2
        // LZ-based compression algorithm, version 1.4.4
        this.#lzString = (function () {
            // private property
            const f = String.fromCharCode;
            const keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
            const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
            const baseReverseDic = {};
 
            function getBaseValue(alphabet, character) {
                if (!baseReverseDic[alphabet]) {
                    baseReverseDic[alphabet] = {};
                    for (let i = 0; i < alphabet.length; i++) {
                        baseReverseDic[alphabet][alphabet.charAt(i)] = i;
                    }
                }
                return baseReverseDic[alphabet][character];
            }
            var LZString = {
                compressToBase64: function (input) {
                    if (input === null) return "";
                    const res = LZString._compress(input, 6, function (a) {
                        return keyStrBase64.charAt(a);
                    });
                    switch (res.length % 4) { // To produce valid Base64
                        default: // When could this happen ?
                        case 0:
                            return res;
                        case 1:
                            return res + "===";
                        case 2:
                            return res + "==";
                        case 3:
                            return res + "=";
                    }
                },
                decompressFromBase64: function (input) {
                    if (input === null) return "";
                    if (input === "") return null;
                    return LZString._decompress(input.length, 32, function (index) {
                        return getBaseValue(keyStrBase64, input.charAt(index));
                    });
                },
                compressToUTF16: function (input) {
                    if (input === null) return "";
                    return LZString._compress(input, 15, function (a) {
                        return f(a + 32);
                    }) + " ";
                },
                decompressFromUTF16: function (compressed) {
                    if (compressed === null) return "";
                    if (compressed === "") return null;
                    return LZString._decompress(compressed.length, 16384, function (index) {
                        return compressed.charCodeAt(index) - 32;
                    });
                },
 
                compress: function (uncompressed) {
                    return LZString._compress(uncompressed, 16, function (a) {
                        return f(a);
                    });
                },
                _compress: function (uncompressed, bitsPerChar, getCharFromInt) {
                    if (uncompressed === null) return "";
                    let i, value,
                        context_dictionary = {},
                        context_dictionaryToCreate = {},
                        context_c = "",
                        context_wc = "",
                        context_w = "",
                        context_enlargeIn = 2, // Compensate for the first entry which should not count
                        context_dictSize = 3,
                        context_numBits = 2,
                        context_data = [],
                        context_data_val = 0,
                        context_data_position = 0,
                        ii;
                    for (ii = 0; ii < uncompressed.length; ii += 1) {
                        context_c = uncompressed.charAt(ii);
                        if (!Object.prototype.hasOwnProperty.call(context_dictionary, context_c)) {
                            context_dictionary[context_c] = context_dictSize++;
                            context_dictionaryToCreate[context_c] = true;
                        }
                        context_wc = context_w + context_c;
                        if (Object.prototype.hasOwnProperty.call(context_dictionary, context_wc)) {
                            context_w = context_wc;
                        } else {
                            if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
                                if (context_w.charCodeAt(0) < 256) {
                                    for (i = 0; i < context_numBits; i++) {
                                        context_data_val = (context_data_val << 1);
                                        if (context_data_position === bitsPerChar - 1) {
                                            context_data_position = 0;
                                            context_data.push(getCharFromInt(context_data_val));
                                            context_data_val = 0;
                                        } else {
                                            context_data_position++;
                                        }
                                    }
                                    value = context_w.charCodeAt(0);
                                    for (i = 0; i < 8; i++) {
                                        context_data_val = (context_data_val << 1) | (value & 1);
                                        if (context_data_position === bitsPerChar - 1) {
                                            context_data_position = 0;
                                            context_data.push(getCharFromInt(context_data_val));
                                            context_data_val = 0;
                                        } else {
                                            context_data_position++;
                                        }
                                        value = value >> 1;
                                    }
                                } else {
                                    value = 1;
                                    for (i = 0; i < context_numBits; i++) {
                                        context_data_val = (context_data_val << 1) | value;
                                        if (context_data_position === bitsPerChar - 1) {
                                            context_data_position = 0;
                                            context_data.push(getCharFromInt(context_data_val));
                                            context_data_val = 0;
                                        } else {
                                            context_data_position++;
                                        }
                                        value = 0;
                                    }
                                    value = context_w.charCodeAt(0);
                                    for (i = 0; i < 16; i++) {
                                        context_data_val = (context_data_val << 1) | (value & 1);
                                        if (context_data_position === bitsPerChar - 1) {
                                            context_data_position = 0;
                                            context_data.push(getCharFromInt(context_data_val));
                                            context_data_val = 0;
                                        } else {
                                            context_data_position++;
                                        }
                                        value = value >> 1;
                                    }
                                }
                                context_enlargeIn--;
                                if (context_enlargeIn === 0) {
                                    context_enlargeIn = Math.pow(2, context_numBits);
                                    context_numBits++;
                                }
                                delete context_dictionaryToCreate[context_w];
                            } else {
                                value = context_dictionary[context_w];
                                for (i = 0; i < context_numBits; i++) {
                                    context_data_val = (context_data_val << 1) | (value & 1);
                                    if (context_data_position === bitsPerChar - 1) {
                                        context_data_position = 0;
                                        context_data.push(getCharFromInt(context_data_val));
                                        context_data_val = 0;
                                    } else {
                                        context_data_position++;
                                    }
                                    value = value >> 1;
                                }
                            }
                            context_enlargeIn--;
                            if (context_enlargeIn === 0) {
                                context_enlargeIn = Math.pow(2, context_numBits);
                                context_numBits++;
                            }
                            // Add wc to the dictionary.
                            context_dictionary[context_wc] = context_dictSize++;
                            context_w = String(context_c);
                        }
                    }
                    // Output the code for w.
                    if (context_w !== "") {
                        if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
                            if (context_w.charCodeAt(0) < 256) {
                                for (i = 0; i < context_numBits; i++) {
                                    context_data_val = (context_data_val << 1);
                                    if (context_data_position === bitsPerChar - 1) {
                                        context_data_position = 0;
                                        context_data.push(getCharFromInt(context_data_val));
                                        context_data_val = 0;
                                    } else {
                                        context_data_position++;
                                    }
                                }
                                value = context_w.charCodeAt(0);
                                for (i = 0; i < 8; i++) {
                                    context_data_val = (context_data_val << 1) | (value & 1);
                                    if (context_data_position === bitsPerChar - 1) {
                                        context_data_position = 0;
                                        context_data.push(getCharFromInt(context_data_val));
                                        context_data_val = 0;
                                    } else {
                                        context_data_position++;
                                    }
                                    value = value >> 1;
                                }
                            } else {
                                value = 1;
                                for (i = 0; i < context_numBits; i++) {
                                    context_data_val = (context_data_val << 1) | value;
                                    if (context_data_position === bitsPerChar - 1) {
                                        context_data_position = 0;
                                        context_data.push(getCharFromInt(context_data_val));
                                        context_data_val = 0;
                                    } else {
                                        context_data_position++;
                                    }
                                    value = 0;
                                }
                                value = context_w.charCodeAt(0);
                                for (i = 0; i < 16; i++) {
                                    context_data_val = (context_data_val << 1) | (value & 1);
                                    if (context_data_position === bitsPerChar - 1) {
                                        context_data_position = 0;
                                        context_data.push(getCharFromInt(context_data_val));
                                        context_data_val = 0;
                                    } else {
                                        context_data_position++;
                                    }
                                    value = value >> 1;
                                }
                            }
                            context_enlargeIn--;
                            if (context_enlargeIn === 0) {
                                context_enlargeIn = Math.pow(2, context_numBits);
                                context_numBits++;
                            }
                            delete context_dictionaryToCreate[context_w];
                        } else {
                            value = context_dictionary[context_w];
                            for (i = 0; i < context_numBits; i++) {
                                context_data_val = (context_data_val << 1) | (value & 1);
                                if (context_data_position === bitsPerChar - 1) {
                                    context_data_position = 0;
                                    context_data.push(getCharFromInt(context_data_val));
                                    context_data_val = 0;
                                } else {
                                    context_data_position++;
                                }
                                value = value >> 1;
                            }
                        }
                        context_enlargeIn--;
                        if (context_enlargeIn === 0) {
                            context_enlargeIn = Math.pow(2, context_numBits);
                            context_numBits++;
                        }
                    }
                    // Mark the end of the stream
                    value = 2;
                    for (i = 0; i < context_numBits; i++) {
                        context_data_val = (context_data_val << 1) | (value & 1);
                        if (context_data_position === bitsPerChar - 1) {
                            context_data_position = 0;
                            context_data.push(getCharFromInt(context_data_val));
                            context_data_val = 0;
                        } else {
                            context_data_position++;
                        }
                        value = value >> 1;
                    }
                    // Flush the last char
                    while (true) {
                        context_data_val = (context_data_val << 1);
                        if (context_data_position === bitsPerChar - 1) {
                            context_data.push(getCharFromInt(context_data_val));
                            break;
                        } else context_data_position++;
                    }
                    return context_data.join('');
                },
                decompress: function (compressed) {
                    if (compressed === null) return "";
                    if (compressed === "") return null;
                    return LZString._decompress(compressed.length, 32768, function (index) {
                        return compressed.charCodeAt(index);
                    });
                },
                _decompress: function (length, resetValue, getNextValue) {
                    let dictionary = [],
                        next,
                        enlargeIn = 4,
                        dictSize = 4,
                        numBits = 3,
                        entry = "",
                        result = [],
                        i,
                        w,
                        bits, resb, maxpower, power,
                        c,
                        data = {
                            val: getNextValue(0),
                            position: resetValue,
                            index: 1
                        };
                    for (i = 0; i < 3; i += 1) {
                        dictionary[i] = i;
                    }
                    bits = 0;
                    maxpower = Math.pow(2, 2);
                    power = 1;
                    while (power !== maxpower) {
                        resb = data.val & data.position;
                        data.position >>= 1;
                        if (data.position === 0) {
                            data.position = resetValue;
                            data.val = getNextValue(data.index++);
                        }
                        bits |= (resb > 0 ? 1 : 0) * power;
                        power <<= 1;
                    }
                    switch (next = bits) {
                        case 0:
                            bits = 0;
                            maxpower = Math.pow(2, 8);
                            power = 1;
                            while (power !== maxpower) {
                                resb = data.val & data.position;
                                data.position >>= 1;
                                if (data.position === 0) {
                                    data.position = resetValue;
                                    data.val = getNextValue(data.index++);
                                }
                                bits |= (resb > 0 ? 1 : 0) * power;
                                power <<= 1;
                            }
                            c = f(bits);
                            break;
                        case 1:
                            bits = 0;
                            maxpower = Math.pow(2, 16);
                            power = 1;
                            while (power !== maxpower) {
                                resb = data.val & data.position;
                                data.position >>= 1;
                                if (data.position === 0) {
                                    data.position = resetValue;
                                    data.val = getNextValue(data.index++);
                                }
                                bits |= (resb > 0 ? 1 : 0) * power;
                                power <<= 1;
                            }
                            c = f(bits);
                            break;
                        case 2:
                            return "";
                    }
                    dictionary[3] = c;
                    w = c;
                    result.push(c);
                    while (true) {
                        if (data.index > length) {
                            return "";
                        }
                        bits = 0;
                        maxpower = Math.pow(2, numBits);
                        power = 1;
                        while (power !== maxpower) {
                            resb = data.val & data.position;
                            data.position >>= 1;
                            if (data.position === 0) {
                                data.position = resetValue;
                                data.val = getNextValue(data.index++);
                            }
                            bits |= (resb > 0 ? 1 : 0) * power;
                            power <<= 1;
                        }
                        switch (c = bits) {
                            case 0:
                                bits = 0;
                                maxpower = Math.pow(2, 8);
                                power = 1;
                                while (power !== maxpower) {
                                    resb = data.val & data.position;
                                    data.position >>= 1;
                                    if (data.position === 0) {
                                        data.position = resetValue;
                                        data.val = getNextValue(data.index++);
                                    }
                                    bits |= (resb > 0 ? 1 : 0) * power;
                                    power <<= 1;
                                }
                                dictionary[dictSize++] = f(bits);
                                c = dictSize - 1;
                                enlargeIn--;
                                break;
                            case 1:
                                bits = 0;
                                maxpower = Math.pow(2, 16);
                                power = 1;
                                while (power !== maxpower) {
                                    resb = data.val & data.position;
                                    data.position >>= 1;
                                    if (data.position === 0) {
                                        data.position = resetValue;
                                        data.val = getNextValue(data.index++);
                                    }
                                    bits |= (resb > 0 ? 1 : 0) * power;
                                    power <<= 1;
                                }
                                dictionary[dictSize++] = f(bits);
                                c = dictSize - 1;
                                enlargeIn--;
                                break;
                            case 2:
                                return result.join('');
                        }
                        if (enlargeIn === 0) {
                            enlargeIn = Math.pow(2, numBits);
                            numBits++;
                        }
                        if (dictionary[c]) {
                            entry = dictionary[c];
                        } else {
                            if (c === dictSize) {
                                entry = w + w.charAt(0);
                            } else {
                                return null;
                            }
                        }
                        result.push(entry);
                        // Add w+entry[0] to the dictionary.
                        dictionary[dictSize++] = w + entry.charAt(0);
                        enlargeIn--;
                        w = entry;
                        if (enlargeIn === 0) {
                            enlargeIn = Math.pow(2, numBits);
                            numBits++;
                        }
                    }
                }
            };
            return LZString;
        })();
    }
    /* eslint-enable */
}