Adds some extra WME functionality related to Google place links.
Ce script ne doit pas être installé directement. C'est une librairie destinée à être incluse dans d'autres scripts avec la méta-directive // @require https://update.greasyfork.org/scripts/523706/1802734/Link%20Enhancer.js
// ==UserScript==
// @name Link Enhancer
// @namespace WazeDev
// @version 2026.04.19.005
// @description Adds some extra WME functionality related to Google place links.
// @author MapOMatic, WazeDev group, kid4rm90s
// @include /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
// @license GNU GPLv3
// ==/UserScript==
/*
*
* Usage:
* const linkEnhancer = new GoogleLinkEnhancer(wmeSDK, turf);
* linkEnhancer.enable();
*
*/
/* global google */
/* eslint-disable no-unused-vars */
/**
* Google Link Enhancer - Validates and enhances Google Place links in Waze Map Editor
* @class GoogleLinkEnhancer
*/
class GoogleLinkEnhancer {
// Configuration Constants
static CONFIG = {
CACHE: {
NAME: 'gle_link_cache',
CLEAN_INTERVAL_MIN: 1,
LIFESPAN_HR: 6,
MAX_SIZE: 1000
},
COLORS: {
PERM_CLOSED: '#F00',
TEMP_CLOSED: '#FD3',
TOO_FAR: '#0FF',
NOT_FOUND: '#F0F',
DUPLICATE: '#FFA500',
HIGHLIGHT: '#FF0',
HIGHLIGHT_BG_ORANGE: '#FFA500',
HIGHLIGHT_BG_RED: '#FAA',
HIGHLIGHT_BG_YELLOW: '#FFA',
HIGHLIGHT_BG_CYAN: '#0FF'
},
DEFAULTS: {
DISTANCE_LIMIT: 400,
POINT_TIMEOUT: 4000,
API_DISABLE_DURATION: 10000,
MAX_RETRY_CALLS: 30,
RETRY_INTERVAL: 100
},
SELECTORS: {
EXT_PROV_ELEM: 'wz-list-item.external-provider',
EXT_PROV_ELEM_EDIT: 'wz-list-item.external-provider-edit',
EXT_PROV_ELEM_CONTENT: 'div.external-provider-content'
},
STROKE: {
POINT_WIDTH: '4',
AREA_WIDTH: '12',
LINE_WIDTH: 4,
DASH_STYLE: '12 12',
CLOSED_DASH_SHORT: '2 6',
CLOSED_DASH_LONG: '2 16'
}
};
#DISABLE_CLOSED_PLACES = false;
#linkCache;
#enabled = false;
#disableApiUntil = null;
#mapLayer = null;
#distanceLimit = GoogleLinkEnhancer.CONFIG.DEFAULTS.DISTANCE_LIMIT;
#showTempClosedPOIs = true;
#placesService;
#linkObserver;
#modeObserver;
#searchResultsObserver;
#lzString;
#cacheCleanIntervalID;
#originalHeadAppendChildMethod;
#ptFeature;
#lineFeature;
#timeoutID;
#processPlacesDebounced;
strings = {
permClosedPlace: 'Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.',
tempClosedPlace: 'Google indicates this place is temporarily closed.',
multiLinked: 'Linked more than once already. Please find and remove multiple links.',
linkedToThisPlace: 'Already linked to this place',
linkedNearby: 'Already linked to a nearby place',
linkedToXPlaces: 'This is linked to {0} places',
badLink: 'Invalid Google link. Please remove it.',
tooFar: 'The Google linked place is more than {0} meters from the Waze place. Please verify the link is correct.'
};
/* eslint-enable no-unused-vars */
/**
* Initialize Google Link Enhancer
* @constructor
* @param {Object} sdk - The WME SDK instance
* @param {Object} trf - Turf.js library instance
*/
constructor(sdk, trf) {
this.sdk = sdk;
this.trf = trf;
const attributionElem = document.createElement('div');
this.#placesService = new google.maps.places.PlacesService(attributionElem);
this.#initLZString();
this.#initCache();
this.#processPlacesDebounced = this.#debounce(() => this.#processPlaces(), 300);
this.#initLayer();
// Register event handlers with debounced processing - using SDK events
this.sdk.Events.trackDataModelEvents({ dataModelName: 'venues' });
this.sdk.Events.on({
eventName: 'wme-map-data-loaded',
eventHandler: () => this.#processPlacesDebounced()
});
this.sdk.Events.on({
eventName: 'wme-data-model-objects-changed',
eventHandler: (evt) => {
if (evt.dataModelName === 'venues') {
this.#processPlacesDebounced();
}
}
});
this.sdk.Events.on({
eventName: 'wme-data-model-objects-added',
eventHandler: (evt) => {
if (evt.dataModelName === 'venues') {
this.#processPlacesDebounced();
}
}
});
this.sdk.Events.on({
eventName: 'wme-data-model-objects-removed',
eventHandler: (evt) => {
if (evt.dataModelName === 'venues') {
this.#processPlacesDebounced();
}
}
});
// Watch for ext provider elements being added to the DOM, and add hover events.
this.#linkObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
const nd = mutation.addedNodes[idx];
if (nd.nodeType === Node.ELEMENT_NODE) {
const $el = $(nd);
const $subel = $el.find(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM);
if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM)) {
this.#addHoverEvent($el);
this.#formatLinkElements();
} else if ($subel.length) {
for (let i = 0; i < $subel.length; i++) {
this.#addHoverEvent($($subel[i]));
}
this.#formatLinkElements();
}
if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_EDIT)) {
// Support both production and beta: find any wz-autocomplete
const wzAuto = $el.find('wz-autocomplete')[0];
if (wzAuto) {
// Try to observe shadowRoot if available
if (wzAuto.shadowRoot) {
this.#searchResultsObserver.observe(wzAuto.shadowRoot, { childList: true, subtree: true });
} else {
// Fallback: try to observe the element itself (may not work if results are in shadow DOM)
this.#searchResultsObserver.observe(wzAuto, { childList: true, subtree: true });
console.warn('GLE: wz-autocomplete has no shadowRoot, observing element directly (beta fallback)');
}
} else {
console.warn('GLE: wz-autocomplete not found in external-provider-edit');
}
}
}
}
for (let idx = 0; idx < mutation.removedNodes.length; idx++) {
const nd = mutation.removedNodes[idx];
if (nd.nodeType === Node.ELEMENT_NODE) {
const $el = $(nd);
if ($el.is(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_EDIT)) {
this.#searchResultsObserver.disconnect();
}
}
}
});
});
// Watch for Google place search result list items being added to the DOM
const that = this;
this.#searchResultsObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
const nd = mutation.addedNodes[idx];
if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('wz-menu-item.simple-item')) {
$(nd).mouseenter(() => {
// When mousing over a list item, find the Google place ID from the list that was stored previously.
// Then add the point/line to the map.
that.#addPoint($(nd).attr('item-id'));
}).mouseleave(() => {
// When leaving the list item, remove the point.
that.#destroyPoint();
});
}
}
});
});
// Watch the side panel for addition of the sidebar-layout div, which indicates a mode change.
this.#modeObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
const nd = mutation.addedNodes[idx];
if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('.sidebar-layout')) {
this.#observeLinks();
break;
}
}
});
});
// This is a special event that will be triggered when DOM elements are destroyed.
/* eslint-disable wrap-iife, func-names, object-shorthand */
(function($) {
$.event.special.destroyed = {
remove: function(o) {
if (o.handler && o.type !== 'destroyed') {
o.handler();
}
}
};
})(jQuery);
/* eslint-enable wrap-iife, func-names, object-shorthand */
// In case a place is already selected on load.
let currentSelection;
try { currentSelection = this.sdk.Editing.getSelection(); } catch (e) { currentSelection = null; }
if (currentSelection?.objectType === 'venue' && currentSelection?.ids?.length) {
this.#formatLinkElements();
}
}
/**
* Initialize cache from localStorage
* @private
*/
#initCache() {
const STORED_CACHE = localStorage.getItem(GoogleLinkEnhancer.CONFIG.CACHE.NAME);
try {
this.#linkCache = STORED_CACHE ? new Map(Object.entries(JSON.parse(this.#lzString.decompressFromUTF16(STORED_CACHE)))) : new Map();
} catch (ex) {
this.#handleError(ex, 'initCache', 'An error occurred while loading the stored cache. A new cache was created.');
this.#linkCache = new Map();
}
}
/**
* Error handler for consistent error logging
* @private
* @param {Error} error - The error object
* @param {string} context - Where the error occurred
* @param {string} [message] - Custom message
*/
#handleError(error, context, message) {
const logMessage = message || error.message;
console.error(`[GoogleLinkEnhancer] ${context}:`, logMessage, error);
}
/**
* Debounce function to limit function call frequency
* @private
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
* @returns {Function} Debounced function
*/
#debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
/**
* Initialize map layer for visual indicators
* @private
*/
#initLayer() {
const layerName = 'Google Link Enhancements';
const styleContext = {
strokeColor: (context) => {
return context?.feature?.properties?.style?.strokeColor;
},
strokeWidth: (context) => {
return context?.feature?.properties?.style?.strokeWidth;
},
strokeDashstyle: (context) => {
return context?.feature?.properties?.style?.strokeDashstyle;
},
pointRadius: (context) => {
return context?.feature?.properties?.style?.pointRadius;
},
fillColor: (context) => {
return context?.feature?.properties?.style?.fillColor;
},
strokeOpacity: (context) => {
return context?.feature?.properties?.style?.strokeOpacity;
},
label: (context) => {
return context?.feature?.properties?.style?.label || '';
},
labelYOffset: (context) => {
return context?.feature?.properties?.style?.labelYOffset || 0;
},
fontColor: (context) => {
return context?.feature?.properties?.style?.fontColor || '#000';
},
fontWeight: (context) => {
return context?.feature?.properties?.style?.fontWeight || 'normal';
},
labelOutlineColor: (context) => {
return context?.feature?.properties?.style?.labelOutlineColor || '';
},
labelOutlineWidth: (context) => {
return context?.feature?.properties?.style?.labelOutlineWidth || 0;
},
fontSize: (context) => {
return context?.feature?.properties?.style?.fontSize || '12';
}
};
const styleRules = [
{
style: {
strokeColor: '${strokeColor}',
strokeWidth: '${strokeWidth}',
strokeDashstyle: '${strokeDashstyle}',
strokeOpacity: '${strokeOpacity}',
pointRadius: '${pointRadius}',
fillColor: '${fillColor}',
fillOpacity: '0',
label: '${label}',
labelYOffset: '${labelYOffset}',
fontColor: '${fontColor}',
fontWeight: '${fontWeight}',
labelOutlineColor: '${labelOutlineColor}',
labelOutlineWidth: '${labelOutlineWidth}',
fontSize: '${fontSize}'
}
}
];
this.#mapLayer = layerName;
this.sdk.Map.addLayer({
layerName: layerName,
styleContext: styleContext,
styleRules: styleRules
});
this.sdk.Map.setLayerOpacity({ layerName: layerName, opacity: 0.8 });
this.sdk.LayerSwitcher.addLayerCheckbox({ name: layerName, isChecked: true });
}
/**
* Enable the link enhancer
* @public
*/
enable() {
if (!this.#enabled) {
this.#modeObserver.observe($('.edit-area #sidebarContent')[0], { childList: true, subtree: false });
this.#observeLinks();
// Watch for JSONP callbacks. JSONP is used for the autocomplete results when searching for Google links.
this.#addJsonpInterceptor();
// Note: Using on() allows passing "this" as a variable, so it can be used in the handler function.
$('#map').on('mouseenter', null, this, GoogleLinkEnhancer.#onMapMouseenter);
$(window).on('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
// Event listeners are now set up in constructor via SDK Events
this.#processPlaces();
this.#cleanAndSaveLinkCache();
this.#cacheCleanIntervalID = setInterval(
() => this.#cleanAndSaveLinkCache(),
1000 * 60 * GoogleLinkEnhancer.CONFIG.CACHE.CLEAN_INTERVAL_MIN
);
this.#enabled = true;
}
}
/**
* Disable the link enhancer
* @public
*/
disable() {
if (this.#enabled) {
this.#modeObserver.disconnect();
this.#linkObserver.disconnect();
this.#searchResultsObserver.disconnect();
this.#removeJsonpInterceptor();
$('#map').off('mouseenter', GoogleLinkEnhancer.#onMapMouseenter);
$(window).off('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
// Event listeners were set up in constructor via SDK Events - no longer need manual cleanup
if (this.#cacheCleanIntervalID) clearInterval(this.#cacheCleanIntervalID);
this.#cleanAndSaveLinkCache();
this.#enabled = false;
}
}
/**
* Get the distance limit in meters
* @public
* @returns {number} Distance limit
*/
get distanceLimit() {
return this.#distanceLimit;
}
/**
* Set the distance limit in meters
* @public
* @param {number} value - Distance limit
*/
set distanceLimit(value) {
this.#distanceLimit = value;
this.#processPlacesDebounced();
}
/**
* Get whether to show temporarily closed POIs
* @public
* @returns {boolean}
*/
get showTempClosedPOIs() {
return this.#showTempClosedPOIs;
}
/**
* Set whether to show temporarily closed POIs
* @public
* @param {boolean} value
*/
set showTempClosedPOIs(value) {
this.#showTempClosedPOIs = value;
this.#processPlacesDebounced();
}
static #onWindowUnload(evt) {
evt.data.#cleanAndSaveLinkCache();
}
/**
* Clean expired entries and save cache to localStorage
* @private
*/
#cleanAndSaveLinkCache() {
if (!this.#linkCache) return;
const now = Date.now();
const maxAge = GoogleLinkEnhancer.CONFIG.CACHE.LIFESPAN_HR * 3600 * 1000;
for (const [id, link] of this.#linkCache.entries()) {
// Bug fix: normalize location property
if (link.location) {
link.loc = link.location;
delete link.location;
}
// Delete link if older than max age
if (!link.ts || (now - link.ts) > maxAge) {
this.#linkCache.delete(id);
}
}
// Convert Map to object for storage
const cacheObj = Object.fromEntries(this.#linkCache.entries());
try {
localStorage.setItem(
GoogleLinkEnhancer.CONFIG.CACHE.NAME,
this.#lzString.compressToUTF16(JSON.stringify(cacheObj))
);
} catch (ex) {
this.#handleError(ex, 'cleanAndSaveLinkCache', 'Failed to save cache to localStorage');
}
}
/**
* Calculate distance between two points in kilometers
* @private
* @param {Array} point1 - [lng, lat] coordinates
* @param {Array} point2 - [lng, lat] coordinates
* @returns {number} Distance in kilometers
*/
#distanceBetweenPoints(point1, point2) {
const ls = this.trf.lineString([point1, point2]);
const length = this.trf.length(ls);
return length * 1000; // convert to meters
}
/**
* Check if linked Google place is too far from Waze venue
* @private
* @param {Object} link - Link info with location
* @param {Object} venue - Waze venue object
* @returns {boolean} True if link is too far
*/
#isLinkTooFar(link, venue) {
// Validate inputs
if (!link?.loc || !venue || !venue.geometry || !this.trf) {
return false;
}
try {
const linkPt = [link.loc.lng, link.loc.lat];
let venuePt;
let distanceLim = this.distanceLimit;
if (venue.geometry.type === 'Point') {
venuePt = venue.geometry.coordinates;
} else if (venue.geometry.type === 'Polygon' || venue.geometry.type === 'MultiPolygon') {
// Use centroid for polygon venues
const center = this.trf.centroid(venue.geometry);
if (!center?.geometry?.coordinates) {
return false;
}
venuePt = center.geometry.coordinates;
// Add half the bounding box size to account for area coverage
try {
const bbox = this.trf.bbox(venue.geometry);
if (bbox && bbox.length >= 4) {
// bbox is [minLng, minLat, maxLng, maxLat]
const farCorner = [bbox[2], bbox[3]]; // top-right corner
distanceLim += this.#distanceBetweenPoints(venuePt, farCorner) / 2;
}
} catch (e) {
// If bbox fails, just use centroid distance
console.warn('[GoogleLinkEnhancer] Error calculating bbox:', e);
}
} else {
return false;
}
if (!venuePt || venuePt.length !== 2) {
return false;
}
const distance = this.#distanceBetweenPoints(linkPt, venuePt);
return distance > distanceLim;
} catch (ex) {
this.#handleError(ex, 'isLinkTooFar', 'Error checking distance');
return false;
}
}
/**
* Process all places and add visual indicators for issues
* @private
*/
#processPlaces() {
try {
if (!this.#enabled) return;
const layerVisible = this.sdk.LayerSwitcher.isLayerCheckboxChecked({ name: this.#mapLayer });
const existingLinks = GoogleLinkEnhancer.#getExistingLinks(this.sdk);
this.sdk.Map.removeAllFeaturesFromLayer({ layerName: this.#mapLayer });
const drawnLinks = [];
this.sdk.DataModel.Venues.getAll().forEach(venue => {
const venueId = venue.id;
const promises = [];
for (const provID of (venue.externalProviderIds || [])) {
const id = provID;
// Check for duplicate links
const linkInfo = existingLinks[id];
if (linkInfo?.count > 1) {
const geometry = venue.geometry;
if (!geometry) return;
const width = geometry.type === 'Point' ? 4 : 12;
const color = '#fb8d00';
const features = [];
try {
const venueFeature = geometry.type === 'Point'
? this.trf.point(geometry.coordinates, {
styleName: 'venueStyle',
style: { strokeWidth: width, strokeColor: color, pointRadius: 15 }
}, { id: `venue_${venue.id}` })
: this.trf.polygon(geometry.coordinates, {
styleName: 'venueStyle',
style: { strokeColor: color, strokeWidth: width }
}, { id: `polyvenue_${venue.id}` });
features.push(venueFeature);
const lineStart = this.trf.centroid(geometry);
if (lineStart?.geometry?.coordinates) {
for (const linkVenue of linkInfo.venues) {
if (linkVenue?.geometry && linkVenue !== venue &&
!drawnLinks.some(dl => (dl[0] === venue && dl[1] === linkVenue) || (dl[0] === linkVenue && dl[1] === venue))) {
const endPoint = this.trf.centroid(linkVenue.geometry);
if (endPoint?.geometry?.coordinates) {
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 && features.length > 0) {
this.sdk.Map.addFeaturesToLayer({ features: features, layerName: this.#mapLayer });
}
} catch (ex) {
this.#handleError(ex, 'processPlaces.duplicateLinks', 'Error processing duplicate links');
}
}
promises.push(this.#getLinkInfoAsync(id));
}
// Process all results of link lookups and add a highlight feature if needed
Promise.all(promises).then(results => {
// Re-fetch the venue to ensure we have the current state
const currentVenue = this.sdk.DataModel.Venues.getById({ venueId });
if (!currentVenue || !currentVenue.geometry) {
return; // Venue was deleted
}
let strokeColor = null;
let strokeDashStyle = 'solid';
const closedNamePattern = /^(\[|\()?(permanently |temporarily )?closed(\]|\)| -)|( \(|- |\[)(permanently |temporarily )?closed(\)|\])?$/i;
if (!this.#DISABLE_CLOSED_PLACES && results.some(res => res.permclosed)) {
if (closedNamePattern.test(currentVenue.name)) {
strokeDashStyle = currentVenue.geometry.type === 'Point' ? '2 6' : '2 16';
}
strokeColor = '#F00';
} else if (results.some(res => this.#isLinkTooFar(res, currentVenue))) {
strokeColor = '#0FF';
} else if (!this.#DISABLE_CLOSED_PLACES && this.#showTempClosedPOIs && results.some(res => res.tempclosed)) {
if (closedNamePattern.test(currentVenue.name)) {
strokeDashStyle = currentVenue.geometry.type === 'Point' ? '2 6' : '2 16';
}
strokeColor = '#FD3';
} else if (results.some(res => res.notFound)) {
strokeColor = '#F0F';
}
if (strokeColor && layerVisible) {
const style = {
strokeWidth: currentVenue.geometry.type === 'Point' ? 4 : 12,
strokeColor,
strokeDashStyle,
pointRadius: 15
};
const feature = currentVenue.geometry.type === 'Point'
? this.trf.point(currentVenue.geometry.coordinates, { styleName: 'placeStyle', style: style }, { id: `place_${currentVenue.id}` })
: this.trf.polygon(currentVenue.geometry.coordinates, { styleName: 'placeStyle', style: style }, { id: `place_${currentVenue.id}` });
this.sdk.Map.addFeaturesToLayer({ features: [feature], layerName: this.#mapLayer });
}
}).catch(ex => {
this.#handleError(ex, 'processPlaces.Promise', 'Error processing venue links');
});
});
} catch (ex) {
this.#handleError(ex, 'processPlaces', 'Error in processPlaces');
}
}
/**
* Cache a link with timestamp
* @private
* @param {string} id - Place ID
* @param {Object} link - Link information
*/
#cacheLink(id, link) {
link.ts = Date.now();
this.#linkCache.set(id, link);
// Enforce max cache size
if (this.#linkCache.size > GoogleLinkEnhancer.CONFIG.CACHE.MAX_SIZE) {
const firstKey = this.#linkCache.keys().next().value;
this.#linkCache.delete(firstKey);
}
}
/**
* Get link information from cache or Google Places API
* @private
* @param {string} placeId - Google Place ID
* @returns {Promise<Object>} Link information
*/
#getLinkInfoAsync(placeId) {
return new Promise(resolve => {
let link = this.#linkCache.get(placeId);
if (!link) {
const request = {
placeId,
fields: ['geometry', 'business_status']
};
this.#placesService.getDetails(request, (place, requestStatus) => {
link = {};
if (requestStatus === google.maps.places.PlacesServiceStatus.OK) {
const loc = place.geometry.location;
link.loc = { lng: loc.lng(), lat: loc.lat() };
if (place.business_status === 'CLOSED_PERMANENTLY') {
link.permclosed = true;
} else if (place.business_status === 'CLOSED_TEMPORARILY') {
link.tempclosed = true;
}
this.#cacheLink(placeId, link);
} else if (requestStatus === google.maps.places.PlacesServiceStatus.NOT_FOUND) {
link.notfound = true;
this.#cacheLink(placeId, link);
} else if (this.#disableApiUntil) {
link.apiDisabled = true;
} else {
link.error = requestStatus;
this.#disableApiUntil = Date.now() + GoogleLinkEnhancer.CONFIG.DEFAULTS.API_DISABLE_DURATION;
this.#handleError(
new Error(requestStatus),
'getLinkInfoAsync',
`API temporarily disabled due to error: ${requestStatus}`
);
}
resolve(link);
});
} else {
resolve(link);
}
});
}
static #onMapMouseenter(event) {
// If the point isn't destroyed yet, destroy it when mousing over the map.
event.data.#destroyPoint();
}
/**
* Format and colorize link elements in edit panel
* @private
* @param {number} [callCount=0] - Retry counter
*/
async #formatLinkElements(callCount = 0) {
try {
const editPanel = document.getElementById('edit-panel');
if (!editPanel) return;
const links = editPanel.querySelectorAll(GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM);
let selection;
try { selection = this.sdk.Editing.getSelection(); } catch (e) { selection = null; }
const selObjects = selection?.objectType === 'venue' && selection?.ids?.length
? [this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] })]
: [];
if (!links.length) {
// Retry if links aren't available yet
if (callCount < GoogleLinkEnhancer.CONFIG.DEFAULTS.MAX_RETRY_CALLS
&& selObjects.length
&& selObjects[0].type === 'venue') {
setTimeout(
() => this.#formatLinkElements(++callCount),
GoogleLinkEnhancer.CONFIG.DEFAULTS.RETRY_INTERVAL
);
}
} else {
const existingLinks = GoogleLinkEnhancer.#getExistingLinks(this.sdk);
// Fetch all links first
const promises = [];
const extProvElements = [];
links.forEach(linkEl => {
const $linkEl = $(linkEl);
extProvElements.push($linkEl);
const id = this.#getIdFromElement($linkEl);
if (!id) return;
promises.push(this.#getLinkInfoAsync(id));
});
await Promise.all(promises);
extProvElements.forEach($extProvElem => {
const id = this.#getIdFromElement($extProvElem);
if (!id) return;
const contentQuery = GoogleLinkEnhancer.CONFIG.SELECTORS.EXT_PROV_ELEM_CONTENT;
const link = this.#linkCache.get(id);
const $content = $extProvElem.find(contentQuery);
if (existingLinks[id]?.count > 1 && existingLinks[id].isThisVenue) {
setTimeout(() => {
$content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_ORANGE })
.attr('title', this.strings.linkedToXPlaces.replace('{0}', existingLinks[id].count));
}, 50);
}
this.#addHoverEvent($extProvElem);
if (link) {
if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) {
$content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_RED })
.attr('title', this.strings.permClosedPlace);
} else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) {
$content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_YELLOW })
.attr('title', this.strings.tempClosedPlace);
} else if (link.notFound) {
$content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.NOT_FOUND })
.attr('title', this.strings.badLink);
} else {
const selection = this.sdk.Editing.getSelection();
const venue = selection ? this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] }) : null;
if (venue && this.#isLinkTooFar(link, venue)) {
$content.css({ backgroundColor: GoogleLinkEnhancer.CONFIG.COLORS.HIGHLIGHT_BG_CYAN })
.attr('title', this.strings.tooFar.replace('{0}', this.distanceLimit));
} else {
// Reset in case we just deleted another provider
$content.css({ backgroundColor: '' }).attr('title', '');
}
}
}
});
}
} catch (ex) {
this.#handleError(ex, 'formatLinkElements', 'Error formatting link elements');
}
}
/**
* Get all existing Google links across all venues
* @private
* @static
* @returns {Object} Map of place IDs to link info
*/
static #getExistingLinks(sdk) {
const existingLinks = {};
let thisVenue;
try { thisVenue = sdk.Editing.getSelection(); } catch (e) { return {}; }
if (thisVenue?.objectType !== 'venue' || !thisVenue?.ids?.length) return {};
const thisVenueId = thisVenue.ids[0];
for (const venue of sdk.DataModel.Venues.getAll()) {
const isThisVenue = venue.id === thisVenueId;
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
* @private
*/
#destroyPoint() {
if (this.#ptFeature) {
this.sdk.Map.removeFeaturesFromLayer({
featureIds: [this.#ptFeature.id, this.#lineFeature.id],
layerName: this.#mapLayer
});
this.#ptFeature = null;
this.#lineFeature = null;
}
}
/**
* Get the current map extent
* @private
* @returns {Array} BBox [minX, minY, maxX, maxY]
*/
#getOLMapExtent() {
return this.sdk.Map.getMapExtent();
}
/**
* Add a point and line to the map showing Google place location and distance
* @private
* @param {string} id - Google Place ID
*/
async #addPoint(id) {
if (!id) return;
const link = await this.#getLinkInfoAsync(id);
if (link && !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; }
if (!selection || selection.objectType !== 'venue' || !selection.ids?.length) return;
const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] });
if (!venue) return;
const placeGeom = this.trf.centroid(venue.geometry).geometry;
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]);
// Calculate distance before line split
let distance = this.#distanceBetweenPoints(poiPt.geometry.coordinates, placePt.geometry.coordinates);
let label = '';
// Format distance label based on settings
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;
}
// If the line extends outside the bounds, split it so we don't draw a line across the world.
try {
const splits = this.trf.lineSplit(lsLine, lsBounds);
if (splits && splits.features.length > 0) {
for (const split of splits.features) {
if (split.geometry && split.geometry.coordinates && split.geometry.coordinates.length >= 2) {
// Use the first split feature that connects venue to POI
lsLine = split;
break;
}
}
}
} catch (ex) {
console.warn('[GoogleLinkEnhancer] Error splitting line:', ex);
// Continue with unsplit line if split fails
}
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 timeout
* @private
*/
#timeoutDestroyPoint() {
if (this.#timeoutID) clearTimeout(this.#timeoutID);
this.#timeoutID = setTimeout(
() => this.#destroyPoint(),
GoogleLinkEnhancer.CONFIG.DEFAULTS.POINT_TIMEOUT
);
}
/**
* Get Place ID from DOM element
* @private
* @param {jQuery} $el - jQuery element
* @returns {string|null} Place ID or null
*/
#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' || !selection.ids?.length) return null;
const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] });
return venue?.externalProviderIds?.[providerIndex] ?? null;
}
/**
* Add hover event to show point on map
* @private
* @param {jQuery} $el - jQuery element
*/
#addHoverEvent($el) {
$el.hover(() => this.#addPoint(this.#getIdFromElement($el)), () => this.#destroyPoint());
}
/**
* Start observing edit panel for link elements
* @private
*/
#observeLinks() {
const editPanel = document.querySelector('#edit-panel');
if (!editPanel) {
setTimeout(() => this.#observeLinks(), 250);
return;
}
this.editPanelElem = editPanel;
this.#linkObserver.observe(editPanel, { childList: true, subtree: true });
}
/**
* Intercept JSONP callbacks to process Google autocomplete results
* Watches for JSONP script tags added to the DOM and overrides their callbacks
* to extract place IDs from search results.
* @private
*/
#addJsonpInterceptor() {
// The idea for this function was hatched here:
// https://stackoverflow.com/questions/6803521/can-google-maps-places-autocomplete-api-be-used-via-ajax/9856786
// The head element, where the Google Autocomplete code will insert a tag
// for a javascript file.
const head = $('head')[0];
// The name of the method the Autocomplete code uses to insert the tag.
const method = 'appendChild';
// The method we will be overriding.
const originalMethod = head[method];
this.#originalHeadAppendChildMethod = originalMethod;
const that = this;
/* eslint-disable func-names, prefer-rest-params */ // Doesn't work as an arrow function (at least not without some modifications)
head[method] = function() {
// Check that the element is a javascript tag being inserted by Google.
if (arguments[0] && arguments[0].src && arguments[0].src.match(/GetPredictions/)) {
// Regex to extract the name of the callback method that the JSONP will call.
const callbackMatchObject = (/callback=([^&]+)&|$/).exec(arguments[0].src);
// Regex to extract the search term that was entered by the user.
const searchTermMatchObject = (/\?1s([^&]+)&/).exec(arguments[0].src);
// const searchTerm = unescape(searchTermMatchObject[1]);
if (callbackMatchObject && searchTermMatchObject) {
// The JSONP callback method is in the form "abc.def" and each time has a different random name.
const names = callbackMatchObject[1].split('.');
// Store the original callback method.
const originalCallback = names[0] && names[1] && window[names[0]] && window[names[0]][names[1]];
if (originalCallback) {
const newCallback = function() { // Define your own JSONP callback
if (arguments[0] && arguments[0].predictions) {
// SUCCESS!
// The autocomplete results
const data = arguments[0];
// console.log('GLE: ' + JSON.stringify(data));
that._lastSearchResultPlaceIds = data.predictions.map(pred => pred.place_id);
// Call the original callback so the WME dropdown can do its thing.
originalCallback(data);
}
};
// Add copy of all the attributes of the old callback function to the new callback function.
// This prevents the autocomplete functionality from throwing an error.
Object.keys(originalCallback).forEach(key => {
newCallback[key] = originalCallback[key];
});
window[names[0]][names[1]] = newCallback; // Override the JSONP callback
}
}
}
// Insert the element into the dom, regardless of whether it was being inserted by Google.
return originalMethod.apply(this, arguments);
};
/* eslint-enable func-names, prefer-rest-params */
}
/**
* Remove JSONP interceptor
* @private
*/
#removeJsonpInterceptor() {
$('head')[0].appendChild = this.#originalHeadAppendChildMethod;
}
/**
* Initialize LZ-String compression library
* Used for compressing cache data stored in localStorage
* @private
*/
#initLZString() {
/* eslint-disable */ // Disabling eslint since this is copied third-party code
// LZ Compressor
// Copyright (c) 2013 Pieroxy <[email protected]>
// This work is free. You can redistribute it and/or modify it
// under the terms of the WTFPL, Version 2
// LZ-based compression algorithm, version 1.4.4
this.#lzString = (function () {
// private property
const f = String.fromCharCode;
const keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
const baseReverseDic = {};
function getBaseValue(alphabet, character) {
if (!baseReverseDic[alphabet]) {
baseReverseDic[alphabet] = {};
for (let i = 0; i < alphabet.length; i++) {
baseReverseDic[alphabet][alphabet.charAt(i)] = i;
}
}
return baseReverseDic[alphabet][character];
}
var LZString = {
compressToBase64: function (input) {
if (input === null) return "";
const res = LZString._compress(input, 6, function (a) {
return keyStrBase64.charAt(a);
});
switch (res.length % 4) { // To produce valid Base64
default: // When could this happen ?
case 0:
return res;
case 1:
return res + "===";
case 2:
return res + "==";
case 3:
return res + "=";
}
},
decompressFromBase64: function (input) {
if (input === null) return "";
if (input === "") return null;
return LZString._decompress(input.length, 32, function (index) {
return getBaseValue(keyStrBase64, input.charAt(index));
});
},
compressToUTF16: function (input) {
if (input === null) return "";
return LZString._compress(input, 15, function (a) {
return f(a + 32);
}) + " ";
},
decompressFromUTF16: function (compressed) {
if (compressed === null) return "";
if (compressed === "") return null;
return LZString._decompress(compressed.length, 16384, function (index) {
return compressed.charCodeAt(index) - 32;
});
},
compress: function (uncompressed) {
return LZString._compress(uncompressed, 16, function (a) {
return f(a);
});
},
_compress: function (uncompressed, bitsPerChar, getCharFromInt) {
if (uncompressed === null) return "";
let i, value,
context_dictionary = {},
context_dictionaryToCreate = {},
context_c = "",
context_wc = "",
context_w = "",
context_enlargeIn = 2, // Compensate for the first entry which should not count
context_dictSize = 3,
context_numBits = 2,
context_data = [],
context_data_val = 0,
context_data_position = 0,
ii;
for (ii = 0; ii < uncompressed.length; ii += 1) {
context_c = uncompressed.charAt(ii);
if (!Object.prototype.hasOwnProperty.call(context_dictionary, context_c)) {
context_dictionary[context_c] = context_dictSize++;
context_dictionaryToCreate[context_c] = true;
}
context_wc = context_w + context_c;
if (Object.prototype.hasOwnProperty.call(context_dictionary, context_wc)) {
context_w = context_wc;
} else {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn === 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn === 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
// Add wc to the dictionary.
context_dictionary[context_wc] = context_dictSize++;
context_w = String(context_c);
}
}
// Output the code for w.
if (context_w !== "") {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn === 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn === 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
}
// Mark the end of the stream
value = 2;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position === bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
// Flush the last char
while (true) {
context_data_val = (context_data_val << 1);
if (context_data_position === bitsPerChar - 1) {
context_data.push(getCharFromInt(context_data_val));
break;
} else context_data_position++;
}
return context_data.join('');
},
decompress: function (compressed) {
if (compressed === null) return "";
if (compressed === "") return null;
return LZString._decompress(compressed.length, 32768, function (index) {
return compressed.charCodeAt(index);
});
},
_decompress: function (length, resetValue, getNextValue) {
let dictionary = [],
next,
enlargeIn = 4,
dictSize = 4,
numBits = 3,
entry = "",
result = [],
i,
w,
bits, resb, maxpower, power,
c,
data = {
val: getNextValue(0),
position: resetValue,
index: 1
};
for (i = 0; i < 3; i += 1) {
dictionary[i] = i;
}
bits = 0;
maxpower = Math.pow(2, 2);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
switch (next = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 2:
return "";
}
dictionary[3] = c;
w = c;
result.push(c);
while (true) {
if (data.index > length) {
return "";
}
bits = 0;
maxpower = Math.pow(2, numBits);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
switch (c = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power !== maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position === 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 2:
return result.join('');
}
if (enlargeIn === 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
if (dictionary[c]) {
entry = dictionary[c];
} else {
if (c === dictSize) {
entry = w + w.charAt(0);
} else {
return null;
}
}
result.push(entry);
// Add w+entry[0] to the dictionary.
dictionary[dictSize++] = w + entry.charAt(0);
enlargeIn--;
w = entry;
if (enlargeIn === 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
}
}
};
return LZString;
})();
}
/* eslint-enable */
}
/* eslint-enable no-unused-vars */
// Export for use as a library
if (typeof module !== 'undefined' && module.exports) {
module.exports = GoogleLinkEnhancer;
} else if (typeof window !== 'undefined') {
window.GoogleLinkEnhancer = GoogleLinkEnhancer;
}