WME Utils - Google Link Enhancer

Adds some extra WME functionality related to Google place links.

Verzia zo dňa 11.04.2023. Pozri najnovšiu verziu.

Tento skript by nemal byť nainštalovaný priamo. Je to knižnica pre ďalšie skripty, ktorú by mali používať cez meta príkaz // @require https://update.greasyfork.org/scripts/39208/1174434/WME%20Utils%20-%20Google%20Link%20Enhancer.js

// ==UserScript==
// @name         WME Utils - Google Link Enhancer
// @namespace    WazeDev
// @version      2023.04.11.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 */
/* eslint-disable no-unused-vars */
class GoogleLinkEnhancer {
    #DISABLE_CLOSED_PLACES = false; // Set to TRUE if the feature needs to be temporarily disabled, e.g. during the COVID-19 pandemic.
    #EXT_PROV_ELEM_QUERY = 'wz-list-item.external-provider';
    #EXT_PROV_ELEM_EDIT_QUERY = 'wz-list-item.external-provider-edit';
    #EXT_PROV_ELEM_CONTENT_QUERY = 'div.external-provider-content';
    #LINK_CACHE_NAME = 'gle_link_cache';
    #LINK_CACHE_CLEAN_INTERVAL_MIN = 1; // Interval to remove old links and save new ones.
    #LINK_CACHE_LIFESPAN_HR = 6; // Remove old links when they exceed this time limit.

    #linkCache;
    #enabled = false;
    #disableApiUntil = null; // When a serious API error occurs (OVER_QUERY_LIMIT, REQUEST_DENIED), set this to a time in the future.
    #mapLayer = null;
    #distanceLimit = 400; // Default distance (meters) when Waze place is flagged for being too far from Google place.
    // Area place is calculated as #distanceLimit + <distance between centroid and furthest node>
    #showTempClosedPOIs = true;
    #placesService;
    #linkObserver;
    #modeObserver;
    #searchResultsObserver;
    #lzString;
    #cacheCleanIntervalID;
    #originalHeadAppendChildMethod;
    #ptFeature;
    #lineFeature;
    #timeoutID;
    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 */
    constructor() {
        const attributionElem = document.createElement('div');
        this.#placesService = new google.maps.places.PlacesService(attributionElem);
        this.#initLZString();
        const STORED_CACHE = localStorage.getItem(this.#LINK_CACHE_NAME);
        try {
            this.#linkCache = STORED_CACHE ? $.parseJSON(this.#lzString.decompressFromUTF16(STORED_CACHE)) : {};
        } catch (ex) {
            if (ex.name === 'SyntaxError') {
                // In case the cache is corrupted and can't be read.
                this.#linkCache = {};
                console.warn('GoogleLinkEnhancer:', 'An error occurred while loading the stored cache. A new cache was created.');
            } else {
                throw ex;
            }
        }
        if (this.#linkCache === null || this.#linkCache.length === 0) this.#linkCache = {};

        this.#initLayer();

        // NOTE: Arrow functions are necessary for calling methods on object instances.
        // This could be made more efficient by only processing the relevant places.
        W.model.events.register('mergeend', null, () => { this.#processPlaces(); });
        W.model.venues.on('objectschanged', () => { this.#processPlaces(); });
        W.model.venues.on('objectsremoved', () => { this.#processPlaces(); });
        W.model.venues.on('objectsadded', () => { this.#processPlaces(); });

        // 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(this.#EXT_PROV_ELEM_QUERY);
                        if ($el.is(this.#EXT_PROV_ELEM_QUERY)) {
                            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(this.#EXT_PROV_ELEM_EDIT_QUERY)) {
                            this.#searchResultsObserver.observe($el.find('wz-autocomplete[placeholder="Search for a place"]')[0].shadowRoot, { childList: true, subtree: true });
                        }
                    }
                }
                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(this.#EXT_PROV_ELEM_EDIT_QUERY)) {
                            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 selFeatures = W.selectionManager.getSelectedFeatures();
        if (selFeatures.length && selFeatures[0].model.type === 'venue') {
            this.#formatLinkElements();
        }
    }

    #initLayer() {
        this.#mapLayer = new OpenLayers.Layer.Vector('Google Link Enhancements.', {
            uniqueName: '___GoogleLinkEnhancements',
            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() {
        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 * this.#LINK_CACHE_CLEAN_INTERVAL_MIN);
            this.#enabled = true;
        }
    }

    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;
        }
    }

    // The distance (in meters) before flagging a Waze place that is too far from the linked Google place.
    // Area places use distanceLimit, plus the distance from the centroid of the AP to its furthest node.
    get distanceLimit() {
        return this.#distanceLimit;
    }

    set distanceLimit(value) {
        this.#distanceLimit = value;
        this.#processPlaces();
    }

    get showTempClosedPOIs() {
        return this.#showTempClosedPOIs;
    }

    set showTempClosedPOIs(value) {
        this.#showTempClosedPOIs = value;
        this.#processPlaces();
    }

    static #onWindowUnload(evt) {
        evt.data.#cleanAndSaveLinkCache();
    }

    #cleanAndSaveLinkCache() {
        if (!this.#linkCache) return;
        const now = new Date();
        Object.keys(this.#linkCache).forEach(id => {
            const link = this.#linkCache[id];
            // Bug fix:
            if (link.location) {
                link.loc = link.location;
                delete link.location;
            }
            // Delete link if older than X hours.
            if (!link.ts || (now - new Date(link.ts)) > this.#LINK_CACHE_LIFESPAN_HR * 3600 * 1000) {
                delete this.#linkCache[id];
            }
        });
        localStorage.setItem(this.#LINK_CACHE_NAME, this.#lzString.compressToUTF16(JSON.stringify(this.#linkCache)));
        // console.log('link cache count: ' + Object.keys(this.#linkCache).length, this.#linkCache);
    }

    static #distanceBetweenPoints(x1, y1, x2, y2) {
        return Math.sqrt(((x1 - x2) ** 2) + ((y1 - y2) ** 2));
    }

    #isLinkTooFar(link, venue) {
        if (link.loc) {
            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;
            if (venue.isPoint()) {
                venuePt = venue.geometry.getCentroid();
                distanceLim = this.distanceLimit;
            } else {
                const bounds = venue.geometry.getBounds();
                const center = bounds.getCenterLonLat();
                venuePt = { x: center.lon, y: center.lat };
                distanceLim = GoogleLinkEnhancer.#distanceBetweenPoints(center.lon, center.lat, bounds.right, bounds.top) + this.distanceLimit;
            }
            const distance = GoogleLinkEnhancer.#distanceBetweenPoints(linkPt.x, linkPt.y, venuePt.x, venuePt.y);
            return distance > distanceLim;
        }
        return false;
    }

    #processPlaces() {
        return; // Disabled until we can find a fix.
        try {
            if (this.#enabled) {
                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() ? '4' : '12';
                            const color = '#fb8d00';
                            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: 4,
                                                strokeColor: color,
                                                strokeDashstyle: '12 12'
                                            }
                                        )
                                    );
                                    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';
                        if (!that.#DISABLE_CLOSED_PLACES && results.some(res => res.permclosed)) {
                            if (/^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.attributes.name)
                                || /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.attributes.name)) {
                                strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
                            }
                            strokeColor = '#F00';
                        } else if (results.some(res => that.#isLinkTooFar(res, venue))) {
                            strokeColor = '#0FF';
                        } else if (!that.#DISABLE_CLOSED_PLACES && that.#showTempClosedPOIs && results.some(res => res.tempclosed)) {
                            if (/^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.attributes.name)
                                || /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.attributes.name)) {
                                strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
                            }
                            strokeColor = '#FD3';
                        } else if (results.some(res => res.notFound)) {
                            strokeColor = '#F0F';
                        }
                        if (strokeColor) {
                            const style = {
                                strokeWidth: venue.isPoint() ? '4' : '12',
                                strokeColor,
                                strokeDashStyle
                            };
                            const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
                            that.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]);
                        }
                    });
                });
            }
        } catch (ex) {
            console.error('PIE (Google Link Enhancer) error:', ex);
        }
    }

    #cacheLink(id, link) {
        link.ts = new Date();
        this.#linkCache[id] = link;
        // console.log('link cache count: ' + Object.keys(this.#linkCache).length, this.#linkCache);
    }

    #getLinkInfoAsync(placeId) {
        return new Promise(resolve => {
            let link = this.#linkCache[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;
                        // res.errorMessage = json.error_message;
                        this.#disableApiUntil = Date.now() + 10 * 1000; // Disable api calls for 10 seconds.
                        console.error(`${GM_info.script.name}, Google Link Enhancer disabled for 10 seconds due to API error.`, link);
                    }
                    resolve(link);
                });
            } else {
                resolve(link);
            }
        });
    }

    // _getLinkInfoAsync(id) {
    //     const link = this.#linkCache[id];
    //     if (link) {
    //         return Promise.resolve(link);
    //     }
    //     if (this.#disableApiUntil) {
    //         if (Date.now() < this.#disableApiUntil) {
    //             return Promise.resolve({ apiDisabled: true });
    //         }
    //         this.#disableApiUntil = null;
    //     }
    //     return new Promise(resolve => {

    //         let fullUrl = this._urlBase;
    //         if (id.startsWith('q=') || id.startsWith('cid=')) {
    //             fullUrl += id;
    //         } else {
    //             fullUrl += `place_id=${id}`;
    //         }
    //         $.getJSON(fullUrl).then(json => {
    //             const res = {};
    //             if (json.status === 'OK') {
    //                 res.loc = json.result.geometry.location;
    //                 if (json.result.business_status === 'CLOSED_PERMANENTLY') {
    //                     res.permclosed = true;
    //                 } else if (json.result.business_status === 'CLOSED_TEMPORARILY') {
    //                     res.tempclosed = true;
    //                 }
    //                 this._cacheLink(id, res);
    //             } else if (json.status === 'NOT_FOUND') {
    //                 res.notfound = true;
    //                 this._cacheLink(id, res);
    //             } else if (this.#disableApiUntil) {
    //                 res.apiDisabled = true;
    //             } else {
    //                 res.error = json.status;
    //                 res.errorMessage = json.error_message;
    //                 this.#disableApiUntil = Date.now() + 10 * 1000; // Disable api calls for 10 seconds.
    //                 console.error(`${GM_info.script.name}, Google Link Enhancer disabled for 10 seconds due to API error.`, res);
    //             }
    //             resolve(res);
    //         });
    //     });
    // }

    static #onMapMouseenter(event) {
        // If the point isn't destroyed yet, destroy it when mousing over the map.
        event.data.#destroyPoint();
    }

    static #getSelectedFeatures() {
        if (!W.selectionManager.getSelectedFeatures) {
            return W.selectionManager.selectedItems;
        }
        return W.selectionManager.getSelectedFeatures();
    }

    async #formatLinkElements(callCount = 0) {
        const $links = $('#edit-panel').find(this.#EXT_PROV_ELEM_QUERY);
        const selFeatures = W.selectionManager.getSelectedFeatures();
        if (!$links.length) {
            // If links aren't available, continue calling this function for up to 3 seconds unless place has been deselected.
            if (callCount < 30 && selFeatures.length && selFeatures[0].model.type === 'venue') {
                setTimeout(() => this.#formatLinkElements(++callCount), 100);
            }
        } else {
            const existingLinks = GoogleLinkEnhancer.#getExistingLinks();

            // fetch all links first
            const promises = [];
            const extProvElements = [];
            $links.each((ix, 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 link = this.#linkCache[id];
                if (existingLinks[id] && existingLinks[id].count > 1 && existingLinks[id].isThisVenue) {
                    setTimeout(() => {
                        $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA500' }).attr({
                            title: this.strings.linkedToXPlaces.replace('{0}', existingLinks[id].count)
                        });
                    }, 50);
                }
                this.#addHoverEvent($extProvElem);
                if (link) {
                    if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) {
                        $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FAA' }).attr('title', this.strings.permClosedPlace);
                    } else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) {
                        $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA' }).attr('title', this.strings.tempClosedPlace);
                    } else if (link.notFound) {
                        $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#F0F' }).attr('title', this.strings.badLink);
                    } else {
                        const venue = GoogleLinkEnhancer.#getSelectedFeatures()[0].model;
                        if (this.#isLinkTooFar(link, venue)) {
                            $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#0FF' }).attr('title', this.strings.tooFar.replace('{0}', this.distanceLimit));
                        } else { // reset in case we just deleted another provider
                            $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '' }).attr('title', '');
                        }
                    }
                }
            });
        }
    }

    static #getExistingLinks() {
        const existingLinks = {};
        let thisVenue;
        if (GoogleLinkEnhancer.#getSelectedFeatures().length) {
            thisVenue = GoogleLinkEnhancer.#getSelectedFeatures()[0].model;
        }
        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.
    #destroyPoint() {
        if (this.#ptFeature) {
            this.#ptFeature.destroy();
            this.#ptFeature = null;
            this.#lineFeature.destroy();
            this.#lineFeature = null;
        }
    }

    // Borrowed from WazeWrap
    static #calculateDistance(point1, point2) {
        const line = new OpenLayers.Geometry.LineString([point1, point2]);
        const length = line.getGeodesicLength(W.map.getProjectionObject());
        return length; // multiply by 3.28084 to convert to feet
    }

    // Add the POI point to the map.
    #addPoint(id) {
        if (!id) return;
        const link = this.#linkCache[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 = GoogleLinkEnhancer.#getSelectedFeatures()[0].geometry.getCentroid();
                const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y);
                const ext = W.map.getExtent();
                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.#calculateDistance(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: '#FF0',
                    fillColor: '#FF0',
                    strokeOpacity: 0.5
                });
                this.#lineFeature = new OpenLayers.Feature.Vector(lsLine, {}, {
                    strokeWidth: 3,
                    strokeDashstyle: '12 8',
                    strokeColor: '#FF0',
                    label,
                    labelYOffset: 45,
                    fontColor: '#FF0',
                    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) {
                    // API was temporarily disabled.  Ignore for now.
                } else {
                    this.#addPoint(id);
                }
            });
        }
    }

    // Destroy the point after some time, if it hasn't been destroyed already.
    #timeoutDestroyPoint() {
        if (this.#timeoutID) clearTimeout(this.#timeoutID);
        this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000);
    }

    static #getIdFromElement($el) {
        const providerIndex = $el.parent().children().toArray().indexOf($el[0]);
        return W.selectionManager.getSelectedFeatures()[0].model.getExternalProviderIDs()[providerIndex]?.attributes.uuid;
    }

    #addHoverEvent($el) {
        $el.hover(() => this.#addPoint(GoogleLinkEnhancer.#getIdFromElement($el)), () => this.#destroyPoint());
    }

    #observeLinks() {
        this.elem = document.querySelector('#edit-panel');
        this.#linkObserver.observe(document.querySelector('#edit-panel'), { childList: true, subtree: true });
    }

    // The JSONP interceptor is used to watch the head element for the addition of JSONP functions
    // that process Google link search results. Those functions are overridden by our own so we can
    // process the results before sending them on to the original function.
    #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 */
    }

    #removeJsonpInterceptor() {
        $('head')[0].appendChild = this.#originalHeadAppendChildMethod;
    }

    /* eslint-disable */ // Disabling eslint since this is copied code.
    #initLZString() {
        // LZ Compressor
        // Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
        // 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 */
}