Adds some extra WME functionality related to Google place links.
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta
// @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;
}