WME Utils - SDK Google Link Enhancer

Adds some extra WME functionality related to Google place links.

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greatest.deepsurf.us/scripts/569692/1789860/WME%20Utils%20-%20SDK%20Google%20Link%20Enhancer.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

"use strict";
// ==UserScript==
// @name         WME Utils - SDK Google Link Enhancer
// @namespace    WazeDev
// @version      2026.03.26.1
// @description  Adds some extra WME functionality related to Google place links.
// @author       WazeDev group
// @include      /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
// @license      GNU GPLv3
// ==/UserScript==

/* global google */
/* eslint-disable max-classes-per-file */
const SDKGoogleLinkEnhancer = (() => {
    "use strict";
    var _a;
    class GooglePlaceCache {
        cache;
        pendingPromises;
        constructor() {
            this.cache = new Map();
            this.pendingPromises = new Map();
        }
        async getPlace(placeId) {
            if (this.cache.has(placeId)) {
                return this.cache.get(placeId);
            }
            if (!this.pendingPromises.has(placeId)) {
                let resolveFn;
                let rejectFn;
                const promise = new Promise((resolve, reject) => {
                    resolveFn = resolve;
                    rejectFn = reject;
                    // Set a timeout to reject the promise if not resolved in 3 seconds
                    setTimeout(() => {
                        if (this.pendingPromises.has(placeId)) {
                            this.pendingPromises.delete(placeId);
                            rejectFn(new Error(`Timeout: Place ${placeId} not found within 3 seconds`));
                        }
                    }, 3000);
                });
                this.pendingPromises.set(placeId, { promise, resolve: resolveFn, reject: rejectFn });
            }
            return this.pendingPromises.get(placeId).promise;
        }
        addPlace(placeId, properties) {
            this.cache.set(placeId, properties);
            if (this.pendingPromises.has(placeId)) {
                this.pendingPromises.get(placeId).resolve(properties);
                this.pendingPromises.delete(placeId);
            }
        }
    }
    class GLE {
        #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_CONTENT_QUERY = "div.external-provider-content";
        linkCache;
        #enabled = false;
        #venueChangeHandler = null;
        #mapLayer; // Set in constructor via options.layerName; defaults to "Google Link Enhancements"
        static #debug = false; // Set to true to enable verbose console logging
        #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;
        #ptFeature;
        #lineFeature;
        #timeoutID = -1;
        #processDebounceID = -1;          // debounce timer handle for #processPlaces
        #prefetchQueue = [];               // IDs waiting to be dispatched to Google API
        #prefetchInflight = 0;             // count of in-flight getDetails requests
        static #PREFETCH_CONCURRENCY = 50; // max simultaneous getDetails calls
        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.",
        };
        #styleConfig = {
            styleContext: {
                highNodeColor: (context) => {
                    return context?.feature?.properties?.style?.strokeColor;
                },
                strokeColor: (context) => {
                    return context?.feature?.properties?.style?.strokeColor;
                },
                strokeWidth: (context) => {
                    return context?.feature?.properties?.style?.strokeWidth;
                },
                strokeDashStyle: (context) => {
                    return context?.feature?.properties?.style?.strokeDashStyle;
                },
                label: (context) => {
                    return context?.feature?.properties?.style?.label;
                },
                labelYOffset: (context) => {
                    return context?.feature?.properties?.style?.labelYOffset;
                },
                fontColor: (context) => {
                    return context?.feature?.properties?.style?.fontColor;
                },
                fontWeight: (context) => {
                    return context?.feature?.properties?.style?.fontWeight;
                },
                labelOutlineColor: (context) => {
                    return context?.feature?.properties?.style?.labelOutlineColor;
                },
                labelOutlineWidth: (context) => {
                    return context?.feature?.properties?.style?.labelOutlineWidth;
                },
                fontSize: (context) => {
                    return context?.feature?.properties?.style?.fontSize;
                },
                pointRadius: (context) => {
                    return context?.feature?.properties?.style?.pointRadius;
                },
            },
            styleRules: [
                {
                    predicate: (properties) => {
                        return properties.styleName === "lineStyle";
                    },
                    style: {
                        strokeColor: "${strokeColor}",
                    },
                },
                {
                    predicate: (properties) => {
                        return properties.styleName === "default";
                    },
                    style: {
                        strokeColor: "${strokeColor}",
                        strokeWidth: "${strokeWidth}",
                        strokeDashstyle: "${strokeDashstyle}",
                        pointRadius: 15,
                        fillOpacity: 0,
                    },
                },
                {
                    predicate: (properties) => {
                        return properties.styleName === "venueStyle";
                    },
                    style: {
                        strokeColor: "${strokeColor}",
                        strokeWidth: "${strokeWidth}",
                        pointRadius: "${pointRadius}",
                        fillOpacity: 0,
                    },
                },
                {
                    predicate: (properties) => {
                        return properties.styleName === "placeStyle";
                    },
                    style: {
                        strokeColor: "${strokeColor}",
                        strokeWidth: "${strokeWidth}",
                        strokeDashStyle: "${strokeDashStyle}",
                        pointRadius: "${pointRadius}",
                        fillOpacity: 0,
                    },
                },
                {
                    predicate: (properties) => {
                        return properties.styleName === "googlePlacePointStyle";
                    },
                    style: {
                        pointRadius: "${pointRadius}",
                        strokeWidth: "${strokeWidth}",
                        strokeColor: "${strokeColor}",
                        fillColor: "${fillColor}",
                        strokeOpacity: "${strokeOpacity}",
                    },
                },
                {
                    predicate: (properties) => {
                        return properties.styleName === "googlePlaceLineStyle";
                    },
                    style: {
                        strokeWidth: "${strokeWidth}",
                        strokeDashstyle: "${strokeDashStyle}",
                        strokeColor: "${strokeColor}",
                        label: "${label}",
                        labelYOffset: "${labelYOffset}",
                        fontColor: "${fontColor}",
                        fontWeight: "${fontWeight}",
                        labelOutlineColor: "${labelOutlineColor}",
                        labelOutlineWidth: "${labelOutlineWidth}",
                        fontSize: "${fontSize}",
                    }
                }
            ],
        };
        sdk;
        trf;
        constructor(sdk, trf, { layerName = "Google Link Enhancements" } = {}) {
            let msg = "";
            if (!sdk) {
                msg += "SDK Must be defined to use GLE";
            }
            if (!trf) {
                msg += "\n";
                msg += "Turf Library Must be made available to GLE to Implement Some of the Functionality";
            }
            this.sdk = sdk;
            this.trf = trf;
            this.#mapLayer = layerName;
            this.linkCache = new GooglePlaceCache();
            this.#initLayer();
            this.sdk.Events.on({
                eventName: "wme-map-data-loaded",
                eventHandler: () => {
                    this.#processPlaces();
                }
            });
            this.sdk.Events.on({
                eventName: "wme-data-model-objects-added",
                eventHandler: (payload) => {
                    if (payload.dataModelName === "venues") {
                        this.#processPlaces();
                    }
                }
            });
            this.sdk.Events.on({
                eventName: "wme-data-model-objects-removed",
                eventHandler: (payload) => {
                    if (payload.dataModelName === "venues") {
                        this.#processPlaces();
                    }
                },
            });
            this.sdk.Events.on({
                eventName: "wme-data-model-objects-changed",
                eventHandler: (payload) => {
                    if (payload.dataModelName === "venues") {
                        this.#processPlaces();
                    }
                }
            });
            // This is a special event that will be triggered when DOM elements are destroyed.
            /* eslint-disable wrap-iife, func-names, object-shorthand */
            (($) => {
                $.event.special.destroyed = {
                    remove: (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.
            let currentSelection;
            try { currentSelection = this.sdk.Editing.getSelection(); } catch (e) { currentSelection = null; }
            if (currentSelection?.ids?.length && currentSelection.objectType === "venue") {
                this.#formatLinkElements();
            }
            this.sdk.Events.on({
                eventName: "wme-selection-changed",
                eventHandler: this.#onWmeSelectionChanged.bind(this),
            });
            this.sdk.Events.on({
                eventName: "wme-layer-checkbox-toggled",
                eventHandler: (payload) => {
                    if (payload.name === this.#mapLayer) {
                        if (payload.checked) {
                            // Layer re-shown: redraw rings from cache (no new API calls needed).
                            this.#processPlaces();
                        } else {
                            // Layer hidden: clear features but keep GLE running so sidebar
                            // colouring, hover line, and API interception continue to work.
                            this.sdk.Map.removeAllFeaturesFromLayer({ layerName: this.#mapLayer });
                        }
                    }
                },
            });
        }
        #initLayer() {
            this.sdk.Map.addLayer({
                layerName: this.#mapLayer,
                styleContext: this.#styleConfig.styleContext,
                styleRules: this.#styleConfig.styleRules,
            });
            this.sdk.Map.setLayerOpacity({ layerName: this.#mapLayer, opacity: 0.8 });
            this.sdk.LayerSwitcher.addLayerCheckbox({ name: this.#mapLayer, isChecked: true });
        }
        #onWmeSelectionChanged() {
            if (this.#enabled) {
                this.#destroyPoint();
                let selected;
                try { selected = this.sdk.Editing.getSelection(); } catch (e) { return; }
                if (selected?.objectType === "venue") {
                    // The setTimeout is necessary (in beta WME currently, at least) to allow the
                    // panel UI DOM to update after a place is selected.
                    setTimeout(() => this.#formatLinkElements(), 0);
                }
            }
        }
        enable() {
            if (!this.#enabled) {
                this.#interceptGooglePlacesAPIs();
                $("#map").on("mouseenter", null, this, _a.#onMapMouseenter);
                this.#venueChangeHandler = (payload) => {
                    if (payload.dataModelName === "venues") {
                        this.#formatLinkElements();
                    }
                };
                this.sdk.Events.on({
                    eventName: "wme-data-model-objects-changed",
                    eventHandler: this.#venueChangeHandler,
                });
                this.#enabled = true;
                this.#processPlaces();
            }
        }
        disable() {
            if (this.#enabled) {
                $("#map").off("mouseenter", _a.#onMapMouseenter);
                if (this.#venueChangeHandler) {
                    this.sdk.Events.off({
                        eventName: "wme-data-model-objects-changed",
                        eventHandler: this.#venueChangeHandler,
                    });
                    this.#venueChangeHandler = null;
                }
                if (this.#processDebounceID !== -1) {
                    clearTimeout(this.#processDebounceID);
                    this.#processDebounceID = -1;
                }
                this.#enabled = false;
                this.sdk.Map.removeAllFeaturesFromLayer({ layerName: this.#mapLayer });
            }
        }
        // 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;
        }
        get showTempClosedPOIs() {
            return this.#showTempClosedPOIs;
        }
        set showTempClosedPOIs(value) {
            this.#showTempClosedPOIs = value;
            this.#processPlaces();
        }
        #distanceBetweenPoints(point1, point2) {
            const ls = this.trf.lineString([point1, point2]);
            const length = this.trf.length(ls);
            return length * 1000; // multiply by 3.28084 to convert to feet
        }
        static isPointVenue(venue) {
            return venue.geometry.type === "Point";
        }
        #isLinkTooFar(link, venue) {
            if (link.loc) {
                const linkPt = this.trf.point([link.loc.lng, link.loc.lat]);
                let venuePt;
                let distanceLim = this.distanceLimit;
                if (venue.geometry.type === "Point") {
                    venuePt = venue.geometry;
                }
                else {
                    const center = this.trf.centroid(venue.geometry);
                    venuePt = center.geometry;
                    let bbox = venue.geometry.bbox;
                    if (!bbox) {
                        bbox = this.trf.bbox(venue.geometry);
                    }
                    const topRightPt = this.trf.point([bbox[0], bbox[1]]);
                    distanceLim += this.#distanceBetweenPoints(venuePt.coordinates, topRightPt.geometry.coordinates);
                }
                const distance = this.#distanceBetweenPoints(linkPt.geometry.coordinates, venuePt.coordinates);
                return distance > distanceLim;
            }
            return false;
        }
        #processPlaces() {
            if (!this.#enabled) return;
            if (this.#processDebounceID !== -1) clearTimeout(this.#processDebounceID);
            this.#processDebounceID = setTimeout(() => {
                this.#processDebounceID = -1;
                this.#doProcessPlaces();
            }, 150);
        }
        #doProcessPlaces() {
            try {
                    // Only draw map rings when the layer is visible in the Map Layers panel.
                    // Always clear first (even when hidden) so in-flight API responses can't
                    // ghost features back onto a layer the user has just hidden.
                    // Prefetch always runs so the cache stays warm and rings reappear instantly
                    // when the layer is re-shown, without needing new API calls.
                    const layerVisible = this.sdk.LayerSwitcher.isLayerCheckboxChecked({ name: this.#mapLayer });
                    // Get a list of already-linked id's
                    const existingLinks = SDKGoogleLinkEnhancer.#getExistingLinks(this.sdk);
                    this.sdk.Map.removeAllFeaturesFromLayer({ layerName: this.#mapLayer });
                    const drawnLinks = [];
                    // Clear stale queue entries from previous viewport before building uncachedIds.
                    this.#prefetchQueue = [];
                    const objectIds = [];
                    for (const venue of this.sdk.DataModel.Venues.getAll()) {
                        objectIds.push(venue.id);
                    }
                    const uncachedIds = new Set();
                    for (const objId of objectIds) {
                        const promises = [];
                        const venue = this.sdk.DataModel.Venues.getById({ venueId: objId.toString() });
                        if (venue === null)
                            continue;
                        for (const provID of venue.externalProviderIds) {
                            const id = provID;
                            if (!this.linkCache.cache.has(id) && !this.linkCache.pendingPromises.has(id)) {
                                uncachedIds.add(id);
                            }
                            // Check for duplicate links
                            const linkInfo = existingLinks[id];
                            if (linkInfo?.count > 1) {
                                const geometry = venue.geometry;
                                const width = _a.isPointVenue(venue) ? 4 : 12;
                                const color = "#fb8d00";
                                const features = [
                                    geometry.type === "Point"
                                        ? this.trf.point(geometry.coordinates, {
                                            styleName: "venueStyle",
                                            style: {
                                                strokeWidth: width,
                                                strokeColor: color,
                                                pointRadius: 15,
                                            },
                                        }, { id: `venue_${geometry.toString()}` })
                                        : this.trf.polygon(geometry.coordinates, {
                                            styleName: "venueStyle",
                                            style: {
                                                strokeColor: color,
                                                strokeWidth: width,
                                            },
                                        }, { id: `polyvenue_${geometry.toString()}` }),
                                ];
                                const lineStart = this.trf.centroid(geometry);
                                for (const linkVenue of linkInfo.venues) {
                                    if (linkVenue !== venue &&
                                        !drawnLinks.some((dl) => (dl[0] === venue && dl[1] === linkVenue) ||
                                            (dl[0] === linkVenue && dl[1] === venue))) {
                                        const endPoint = this.trf.centroid(linkVenue.geometry);
                                        features.push(this.trf.lineString([lineStart.geometry.coordinates, endPoint.geometry.coordinates], {
                                            styleName: "lineStyle",
                                            style: {
                                                strokeWidth: 4,
                                                strokeColor: color,
                                                strokeDashstyle: "12 12",
                                            },
                                        }, { id: `ls_${lineStart.geometry.toString()}_${endPoint.geometry.toString()}` }));
                                        drawnLinks.push([venue, linkVenue]);
                                    }
                                }
                                if (layerVisible) {
                                    this.sdk.Map.addFeaturesToLayer({ features: features, layerName: this.#mapLayer });
                                }
                            }
                            promises.push(this.linkCache.getPlace(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 (!this.#DISABLE_CLOSED_PLACES && results.some((res) => res.permclosed)) {
                                if (/^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.name) ||
                                    /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.name)) {
                                    strokeDashStyle = _a.isPointVenue(venue) ? "2 6" : "2 16";
                                }
                                strokeColor = "#F00";
                            }
                            else if (results.some((res) => this.#isLinkTooFar(res, venue))) {
                                strokeColor = "#0FF";
                            }
                            else if (!this.#DISABLE_CLOSED_PLACES &&
                                this.#showTempClosedPOIs &&
                                results.some((res) => res.tempclosed)) {
                                if (/^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.name) ||
                                    /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.name)) {
                                    strokeDashStyle = _a.isPointVenue(venue) ? "2 6" : "2 16";
                                }
                                strokeColor = "#FD3";
                            }
                            else if (results.some((res) => res.notFound)) {
                                strokeColor = "#F0F";
                            }
                            if (strokeColor && layerVisible) {
                                const style = {
                                    strokeWidth: _a.isPointVenue(venue) ? 4 : 12,
                                    strokeColor,
                                    strokeDashStyle,
                                    pointRadius: 15,
                                };
                                const feature = _a.isPointVenue(venue)
                                    ? this.trf.point(venue.geometry.coordinates, {
                                        styleName: "placeStyle",
                                        style: style,
                                    }, { id: `place_${venue.id}` })
                                    : this.trf.polygon(venue.geometry.coordinates, {
                                        styleName: "placeStyle",
                                        style: style,
                                    }, { id: `place_${venue.id}` });
                                this.sdk.Map.addFeaturesToLayer({ features: [feature], layerName: this.#mapLayer });
                            }
                        }).catch(() => {
                            // Timeout: Google hasn't called getDetails for this venue yet — suppress silently.
                        });
                    }
                    // Proactively fetch Google data for venues not yet in cache.
                    // When responses arrive the interceptor populates the cache and
                    // triggers another #processPlaces pass to render the highlights.
                    // Skip prefetch when the layer is hidden — sidebar coloring works
                    // reactively via the interceptor, so there's no benefit fetching
                    // data for rings that aren't being displayed.
                    if (layerVisible) {
                        this.#prefetchPlaceData([...uncachedIds]);
                    }
                }
                catch (ex) {
                    console.error("PIE (Google Link Enhancer) error:", ex);
                }
        }
        // Proactively fetches Google place data for each uncached place ID so that
        // GLE can highlight closed/far venues in the viewport without requiring user selection.
        // Always uses the legacy PlacesService.getDetails — the new Place.fetchFields API calls
        // places.googleapis.com which is not in WME's Content Security Policy connect-src list
        // and will be blocked by the browser. The Place.fetchFields interceptor in
        // #interceptGooglePlacesAPIs remains active in case WME itself ever migrates to the new API.
        // Requests are capped at #PREFETCH_CONCURRENCY simultaneous in-flight calls; the rest
        // are queued and drained automatically as each response arrives.
        #prefetchPlaceData(placeIds) {
            if (!placeIds.length) return;
            if (typeof google === "undefined" || !google.maps?.places?.PlacesService) return;
            for (const id of placeIds) {
                if (!this.#prefetchQueue.includes(id)) {
                    this.#prefetchQueue.push(id);
                }
            }
            this.#drainPrefetchQueue();
        }
        #drainPrefetchQueue() {
            if (this.#prefetchInflight >= SDKGoogleLinkEnhancer.#PREFETCH_CONCURRENCY
                || this.#prefetchQueue.length === 0) return;
            if (typeof google === "undefined" || !google.maps?.places?.PlacesService) return;
            const service = new google.maps.places.PlacesService(document.createElement("div"));
            while (this.#prefetchInflight < SDKGoogleLinkEnhancer.#PREFETCH_CONCURRENCY
                   && this.#prefetchQueue.length > 0) {
                const placeId = this.#prefetchQueue.shift();
                this.#prefetchInflight++;
                service.getDetails(
                    { placeId, fields: ["place_id", "geometry", "business_status"] },
                    () => {
                        // Interceptor in #interceptGooglePlacesAPIs handles cache population
                        // and triggers #processPlaces(). This callback only manages queue state.
                        this.#prefetchInflight--;
                        this.#drainPrefetchQueue();
                    }
                );
            }
        }
        static #onMapMouseenter(event) {
            // If the point isn't destroyed yet, destroy it when mousing over the map.
            event.data.#destroyPoint();
        }
        async #formatLinkElements() {
            const $links = $("#edit-panel").find(this.#EXT_PROV_ELEM_QUERY);
            if ($links.length) {
                const existingLinks = _a.#getExistingLinks(this.sdk);
                // fetch all links first
                const promises = [];
                const extProvElements = [];
                $links.each((ix, linkEl) => {
                    const $linkEl = $(linkEl);
                    extProvElements.push($linkEl);
                    const id = this.#getIdFromElement($linkEl);
                    promises.push(this.linkCache.getPlace(id));
                });
                const links = await Promise.all(promises);
                extProvElements.forEach(($extProvElem, i) => {
                    const id = this.#getIdFromElement($extProvElem);
                    if (!id)
                        return;
                    const link = links[i];
                    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 {
                            let selection;
                            try { selection = this.sdk.Editing.getSelection(); } catch (e) { selection = null; }
                            if (selection?.objectType === "venue") {
                                const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] });
                                if (venue && this.#isLinkTooFar(link, venue)) {
                                    $extProvElem
                                        .find(this.#EXT_PROV_ELEM_CONTENT_QUERY)
                                        .css({ backgroundColor: "#0FF" })
                                        .attr("title", this.strings.tooFar.replace("{0}", this.distanceLimit.toString()));
                                }
                                else {
                                    // reset in case we just deleted another provider
                                    $extProvElem
                                        .find(this.#EXT_PROV_ELEM_CONTENT_QUERY)
                                        .css({ backgroundColor: "" })
                                        .attr("title", "");
                                }
                            }
                        }
                    }
                });
            }
        }
        static #getExistingLinks(sdk = undefined) {
            if (!sdk) {
                const msg = "SDK Is Not Available";
                console.error(msg);
                throw new Error(msg);
            }
            const existingLinks = {};
            let thisVenue;
            try { thisVenue = sdk.Editing.getSelection(); } catch (e) { return {}; }
            if (thisVenue?.objectType !== "venue")
                return {};
            for (const venue of sdk.DataModel.Venues.getAll()) {
                const isThisVenue = venue.id === thisVenue.ids[0];
                const thisPlaceIDs = [];
                for (const provID of venue.externalProviderIds) {
                    const id = provID;
                    if (!thisPlaceIDs.includes(id)) {
                        thisPlaceIDs.push(id);
                        let link = existingLinks[id];
                        if (link) {
                            link.count++;
                            link.venues.push(venue);
                        }
                        else {
                            link = { count: 1, venues: [venue] };
                            existingLinks[id] = link;
                        }
                        link.isThisVenue = link.isThisVenue || isThisVenue;
                    }
                }
            }
            return existingLinks;
        }
        // Remove the POI point from the map.
        #destroyPoint() {
            if (this.#ptFeature) {
                this.sdk.Map.removeFeaturesFromLayer({ featureIds: [this.#ptFeature.id, this.#lineFeature.id], layerName: this.#mapLayer });
                this.#ptFeature = null;
                this.#lineFeature = null;
            }
        }
        #getOLMapExtent() {
            return this.sdk.Map.getMapExtent();
        }
        // Add the POI point to the map.
        async #addPoint(id) {
            if (!id)
                return;
            const link = await this.linkCache.getPlace(id);
            if (link) {
                if (!link.notFound) {
                    const coord = link.loc;
                    const poiPt = this.trf.point([coord.lng, coord.lat]);
                    let selection;
                    try { selection = this.sdk.Editing.getSelection(); } catch (e) { selection = null; }
                    let placeGeom;
                    if (selection?.objectType === "venue") {
                        const v = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] });
                        placeGeom = v?.geometry && this.trf.centroid(v?.geometry)?.geometry;
                    }
                    else {
                        return;
                    }
                    const placePt = this.trf.point(placeGeom.coordinates);
                    const ext = this.#getOLMapExtent();
                    const lsBounds = this.trf.lineString([
                        [ext[0], ext[3]],
                        [ext[0], ext[1]],
                        [ext[2], ext[1]],
                        [ext[2], ext[3]],
                        [ext[0], ext[3]],
                    ]);
                    let lsLine = this.trf.lineString([placePt.geometry.coordinates, poiPt.geometry.coordinates]);
                    // If the line extends outside the bounds, split it so we don't draw a line across the world.
                    const splits = this.trf.lineSplit(lsLine, lsBounds);
                    let label = "";
                    if (splits) {
                        for (const split of splits.features) {
                            for (const component of split.geometry.coordinates) {
                                if (component[0] === placePt.geometry.coordinates[0] &&
                                    component[1] === placePt.geometry.coordinates[1])
                                    lsLine = split;
                            }
                        }
                        let distance = this.#distanceBetweenPoints(poiPt.geometry.coordinates, placePt.geometry.coordinates);
                        let unitConversion;
                        let unit1;
                        let unit2;
                        if (this.sdk.Settings.getUserSettings().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 = this.trf.point(poiPt.geometry.coordinates, {
                        styleName: "googlePlacePointStyle",
                        style: {
                            pointRadius: 6,
                            strokeWidth: 30,
                            strokeColor: "#FF0",
                            fillColor: "#FF0",
                            strokeOpacity: 0.5,
                        },
                    }, { id: `PoiPT_${poiPt.toString()}` });
                    this.#lineFeature = this.trf.lineString(lsLine.geometry.coordinates, {
                        styleName: "googlePlaceLineStyle",
                        style: {
                            strokeWidth: 3,
                            strokeDashstyle: "12 8",
                            strokeColor: "#FF0",
                            label,
                            labelYOffset: 45,
                            fontColor: "#FF0",
                            fontWeight: "bold",
                            labelOutlineColor: "#000",
                            labelOutlineWidth: 4,
                            fontSize: "18",
                        },
                    }, { id: `LsLine_${lsLine.toString()}` });
                    this.sdk.Map.addFeaturesToLayer({ features: [this.#ptFeature, this.#lineFeature], layerName: this.#mapLayer });
                    this.#timeoutDestroyPoint();
                }
            }
        }
        // Destroy the point after some time, if it hasn't been destroyed already.
        #timeoutDestroyPoint() {
            if (this.#timeoutID > 0)
                clearTimeout(this.#timeoutID);
            this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000);
        }
        #getIdFromElement($el) {
            const providerIndex = $el.parent().children().toArray().indexOf($el[0]);
            let selection;
            try { selection = this.sdk.Editing.getSelection(); } catch (e) { return null; }
            if (!selection || selection.objectType !== "venue") return null;
            const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] });
            return venue?.externalProviderIds?.[providerIndex] ?? null;
        }
        #addHoverEvent($el) {
            $el.hover(() => this.#addPoint(this.#getIdFromElement($el)), () => this.#destroyPoint());
        }
        #interceptGooglePlacesAPIs() {
            // --- Legacy PlacesService.getDetails interceptor ---
            // Kept for backward compat: WME may still call the legacy API internally.
            if (typeof google === "undefined" ||
                !google.maps ||
                !google.maps.places ||
                !google.maps.places.PlacesService) {
                if (_a.#debug) console.debug("Google Maps PlacesService not loaded yet.");
                setTimeout(this.#interceptGooglePlacesAPIs.bind(this), 500); // Retry until it loads
                return;
            }
            const originalGetDetails = google.maps.places.PlacesService.prototype.getDetails;
            const that = this;
            google.maps.places.PlacesService.prototype.getDetails = function interceptedGetDetails(request, callback) {
                const customCallback = (result, status) => {
                    const link = {};
                    let cacheUpdated = false;
                    switch (status) {
                        case google.maps.places.PlacesServiceStatus.OK: {
                            const loc = result.geometry.location;
                            link.loc = { lng: loc.lng(), lat: loc.lat() };
                            if (result.business_status === google.maps.places.BusinessStatus.CLOSED_PERMANENTLY) {
                                link.permclosed = true;
                                if (_a.#debug) console.debug("GLE: permanently closed place detected:", request.placeId);
                            }
                            else if (result.business_status === google.maps.places.BusinessStatus.CLOSED_TEMPORARILY) {
                                link.tempclosed = true;
                                if (_a.#debug) console.debug("GLE: temporarily closed place detected:", request.placeId);
                            }
                            that.linkCache.addPlace(request.placeId, link);
                            cacheUpdated = true;
                            break;
                        }
                        case google.maps.places.PlacesServiceStatus.NOT_FOUND:
                            link.notFound = true;
                            that.linkCache.addPlace(request.placeId, link);
                            cacheUpdated = true;
                            if (_a.#debug) console.debug("GLE: invalid/not-found Google link:", request.placeId);
                            break;
                        default:
                            link.error = status;
                            if (_a.#debug) console.debug("GLE: unexpected getDetails status:", status, request.placeId);
                    }
                    // Re-render map highlights now that Google data has arrived for this place.
                    // #processPlaces ran earlier (on map load) before the cache was populated,
                    // so a new pass is needed each time a getDetails response comes back.
                    // The debounce inside #processPlaces coalesces rapid back-to-back calls.
                    if (cacheUpdated) {
                        that.#processPlaces();
                    }
                    callback(result, status); // Pass the result to the original callback
                };
                return originalGetDetails.call(this, request, customCallback);
            };
            if (_a.#debug) console.debug("Google Maps PlacesService.getDetails intercepted successfully.");

            // --- New Place.fetchFields interceptor ---
            // Intercepts calls from WME itself if/when it migrates to the new API.
            // Note: #prefetchPlaceData always uses the legacy PlacesService.getDetails because
            // Place.fetchFields calls places.googleapis.com which is blocked by WME's CSP.
            if (!google.maps.places.Place) {
                if (_a.#debug) console.debug("New Place class not available — skipping Place.fetchFields intercept.");
                return;
            }
            const originalFetchFields = google.maps.places.Place.prototype.fetchFields;
            google.maps.places.Place.prototype.fetchFields = async function interceptedFetchFields(options) {
                try {
                    const result = await originalFetchFields.call(this, options);
                    const place = result.place;
                    const link = {};
                    if (place.location) {
                        link.loc = { lng: place.location.lng(), lat: place.location.lat() };
                    }
                    if (place.businessStatus === google.maps.places.BusinessStatus.CLOSED_PERMANENTLY) {
                        link.permclosed = true;
                    } else if (place.businessStatus === google.maps.places.BusinessStatus.CLOSED_TEMPORARILY) {
                        link.tempclosed = true;
                    }
                    that.linkCache.addPlace(this.id, link);
                    that.#processPlaces();
                    return result;
                } catch (err) {
                    // Only cache as notFound for a definitive NOT_FOUND response.
                    // Other errors (CSP block, quota, network) are transient — leave uncached for retry.
                    const status = String(err?.status ?? err?.code ?? err?.message ?? "").toUpperCase();
                    if (status.includes("NOT_FOUND")) {
                        that.linkCache.addPlace(this.id, { notFound: true });
                        that.#processPlaces();
                    }
                    throw err; // Re-throw so WME and other callers still receive the error.
                }
            };
            if (_a.#debug) console.debug("Google Maps Place.fetchFields intercepted successfully.");
        }
    }
    _a = GLE;
    return GLE;
})();