WME Utils - Google Link Enhancer

Adds some extra WME functionality related to Google place links.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/39208/1569400/WME%20Utils%20-%20Google%20Link%20Enhancer.js

  1. // ==UserScript==
  2. // @name WME Utils - Google Link Enhancer
  3. // @namespace WazeDev
  4. // @version 2025.04.11.002
  5. // @description Adds some extra WME functionality related to Google place links.
  6. // @author MapOMatic, WazeDev group
  7. // @include /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
  8. // @license GNU GPLv3
  9. // ==/UserScript==
  10.  
  11. /* global OpenLayers */
  12. /* global W */
  13. /* global google */
  14.  
  15. /* eslint-disable max-classes-per-file */
  16.  
  17. // eslint-disable-next-line func-names
  18. const GoogleLinkEnhancer = (function() {
  19. 'use strict';
  20.  
  21. class GooglePlaceCache {
  22. constructor() {
  23. this.cache = new Map();
  24. this.pendingPromises = new Map();
  25. }
  26.  
  27. async getPlace(placeId) {
  28. if (this.cache.has(placeId)) {
  29. return this.cache.get(placeId);
  30. }
  31.  
  32. if (!this.pendingPromises.has(placeId)) {
  33. let resolveFn;
  34. let rejectFn;
  35. const promise = new Promise((resolve, reject) => {
  36. resolveFn = resolve;
  37. rejectFn = reject;
  38.  
  39. // Set a timeout to reject the promise if not resolved in 3 seconds
  40. setTimeout(() => {
  41. if (this.pendingPromises.has(placeId)) {
  42. this.pendingPromises.delete(placeId);
  43. rejectFn(new Error(`Timeout: Place ${placeId} not found within 3 seconds`));
  44. }
  45. }, 3000);
  46. });
  47.  
  48. this.pendingPromises.set(placeId, { promise, resolve: resolveFn, reject: rejectFn });
  49. }
  50.  
  51. return this.pendingPromises.get(placeId).promise;
  52. }
  53.  
  54. addPlace(placeId, properties) {
  55. this.cache.set(placeId, properties);
  56.  
  57. if (this.pendingPromises.has(placeId)) {
  58. this.pendingPromises.get(placeId).resolve(properties);
  59. this.pendingPromises.delete(placeId);
  60. }
  61. }
  62. }
  63. class GLE {
  64. #DISABLE_CLOSED_PLACES = false; // Set to TRUE if the feature needs to be temporarily disabled, e.g. during the COVID-19 pandemic.
  65. #EXT_PROV_ELEM_QUERY = 'wz-list-item.external-provider';
  66. #EXT_PROV_ELEM_EDIT_QUERY = 'wz-list-item.external-provider-edit';
  67. #EXT_PROV_ELEM_CONTENT_QUERY = 'div.external-provider-content';
  68.  
  69. linkCache;
  70. #enabled = false;
  71. #mapLayer = null;
  72. #distanceLimit = 400; // Default distance (meters) when Waze place is flagged for being too far from Google place.
  73. // Area place is calculated as #distanceLimit + <distance between centroid and furthest node>
  74. #showTempClosedPOIs = true;
  75. #originalHeadAppendChildMethod;
  76. #ptFeature;
  77. #lineFeature;
  78. #timeoutID;
  79. strings = {
  80. permClosedPlace: 'Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.',
  81. tempClosedPlace: 'Google indicates this place is temporarily closed.',
  82. multiLinked: 'Linked more than once already. Please find and remove multiple links.',
  83. linkedToThisPlace: 'Already linked to this place',
  84. linkedNearby: 'Already linked to a nearby place',
  85. linkedToXPlaces: 'This is linked to {0} places',
  86. badLink: 'Invalid Google link. Please remove it.',
  87. tooFar: 'The Google linked place is more than {0} meters from the Waze place. Please verify the link is correct.'
  88. };
  89.  
  90. /* eslint-enable no-unused-vars */
  91. constructor() {
  92. this.linkCache = new GooglePlaceCache();
  93. this.#initLayer();
  94.  
  95. // NOTE: Arrow functions are necessary for calling methods on object instances.
  96. // This could be made more efficient by only processing the relevant places.
  97. W.model.events.register('mergeend', null, () => { this.#processPlaces(); });
  98. W.model.venues.on('objectschanged', () => { this.#processPlaces(); });
  99. W.model.venues.on('objectsremoved', () => { this.#processPlaces(); });
  100. W.model.venues.on('objectsadded', () => { this.#processPlaces(); });
  101.  
  102. // This is a special event that will be triggered when DOM elements are destroyed.
  103. /* eslint-disable wrap-iife, func-names, object-shorthand */
  104. (function($) {
  105. $.event.special.destroyed = {
  106. remove: function(o) {
  107. if (o.handler && o.type !== 'destroyed') {
  108. o.handler();
  109. }
  110. }
  111. };
  112. })(jQuery);
  113. /* eslint-enable wrap-iife, func-names, object-shorthand */
  114.  
  115. // In case a place is already selected on load.
  116. const selObjects = W.selectionManager.getSelectedDataModelObjects();
  117. if (selObjects.length && selObjects[0].type === 'venue') {
  118. this.#formatLinkElements();
  119. }
  120.  
  121. W.selectionManager.events.register('selectionchanged', null, this.#onWmeSelectionChanged.bind(this));
  122. }
  123.  
  124. #initLayer() {
  125. this.#mapLayer = new OpenLayers.Layer.Vector('Google Link Enhancements.', {
  126. uniqueName: '___GoogleLinkEnhancements',
  127. displayInLayerSwitcher: true,
  128. styleMap: new OpenLayers.StyleMap({
  129. default: {
  130. strokeColor: '${strokeColor}',
  131. strokeWidth: '${strokeWidth}',
  132. strokeDashstyle: '${strokeDashstyle}',
  133. pointRadius: '15',
  134. fillOpacity: '0'
  135. }
  136. })
  137. });
  138.  
  139. this.#mapLayer.setOpacity(0.8);
  140. W.map.addLayer(this.#mapLayer);
  141. }
  142.  
  143. #onWmeSelectionChanged() {
  144. if (this.#enabled) {
  145. this.#destroyPoint();
  146. const selected = W.selectionManager.getSelectedDataModelObjects();
  147. if (selected[0]?.type === 'venue') {
  148. // The setTimeout is necessary (in beta WME currently, at least) to allow the
  149. // panel UI DOM to update after a place is selected.
  150. setTimeout(() => this.#formatLinkElements(), 0);
  151. }
  152. }
  153. }
  154.  
  155. enable() {
  156. if (!this.#enabled) {
  157. this.#interceptPlacesService();
  158. // Note: Using on() allows passing "this" as a variable, so it can be used in the handler function.
  159. $('#map').on('mouseenter', null, this, GLE.#onMapMouseenter);
  160. W.model.venues.on('objectschanged', this.#formatLinkElements, this);
  161. this.#processPlaces();
  162. this.#enabled = true;
  163. }
  164. }
  165.  
  166. disable() {
  167. if (this.#enabled) {
  168. $('#map').off('mouseenter', GLE.#onMapMouseenter);
  169. W.model.venues.off('objectschanged', this.#formatLinkElements, this);
  170. this.#enabled = false;
  171. }
  172. }
  173.  
  174. // The distance (in meters) before flagging a Waze place that is too far from the linked Google place.
  175. // Area places use distanceLimit, plus the distance from the centroid of the AP to its furthest node.
  176. get distanceLimit() {
  177. return this.#distanceLimit;
  178. }
  179.  
  180. set distanceLimit(value) {
  181. this.#distanceLimit = value;
  182. }
  183.  
  184. get showTempClosedPOIs() {
  185. return this.#showTempClosedPOIs;
  186. }
  187.  
  188. set showTempClosedPOIs(value) {
  189. this.#showTempClosedPOIs = value;
  190. }
  191.  
  192. // Borrowed from WazeWrap
  193. static #distanceBetweenPoints(point1, point2) {
  194. const line = new OpenLayers.Geometry.LineString([point1, point2]);
  195. const length = line.getGeodesicLength(W.map.getProjectionObject());
  196. return length; // multiply by 3.28084 to convert to feet
  197. }
  198.  
  199. #isLinkTooFar(link, venue) {
  200. if (link.loc) {
  201. const linkPt = new OpenLayers.Geometry.Point(link.loc.lng, link.loc.lat);
  202. linkPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject());
  203. let venuePt;
  204. let distanceLim = this.distanceLimit;
  205. if (venue.isPoint()) {
  206. venuePt = venue.geometry.getCentroid();
  207. } else {
  208. const bounds = venue.geometry.getBounds();
  209. const center = bounds.getCenterLonLat();
  210. venuePt = new OpenLayers.Geometry.Point(center.lon, center.lat);
  211. const topRightPt = new OpenLayers.Geometry.Point(bounds.right, bounds.top);
  212. distanceLim += GLE.#distanceBetweenPoints(venuePt, topRightPt);
  213. }
  214. const distance = GLE.#distanceBetweenPoints(linkPt, venuePt);
  215. return distance > distanceLim;
  216. }
  217. return false;
  218. }
  219.  
  220. #processPlaces() {
  221. if (this.#enabled) {
  222. try {
  223. const that = this;
  224. // Get a list of already-linked id's
  225. const existingLinks = GoogleLinkEnhancer.#getExistingLinks();
  226. this.#mapLayer.removeAllFeatures();
  227. const drawnLinks = [];
  228. W.model.venues.getObjectArray().forEach(venue => {
  229. const promises = [];
  230. venue.attributes.externalProviderIDs.forEach(provID => {
  231. const id = provID.attributes.uuid;
  232.  
  233. // Check for duplicate links
  234. const linkInfo = existingLinks[id];
  235. if (linkInfo.count > 1) {
  236. const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
  237. const width = venue.isPoint() ? '4' : '12';
  238. const color = '#fb8d00';
  239. const features = [new OpenLayers.Feature.Vector(geometry, {
  240. strokeWidth: width, strokeColor: color
  241. })];
  242. const lineStart = geometry.getCentroid();
  243. linkInfo.venues.forEach(linkVenue => {
  244. if (linkVenue !== venue
  245. && !drawnLinks.some(dl => (dl[0] === venue && dl[1] === linkVenue) || (dl[0] === linkVenue && dl[1] === venue))) {
  246. features.push(
  247. new OpenLayers.Feature.Vector(
  248. new OpenLayers.Geometry.LineString([lineStart, linkVenue.geometry.getCentroid()]),
  249. {
  250. strokeWidth: 4,
  251. strokeColor: color,
  252. strokeDashstyle: '12 12'
  253. }
  254. )
  255. );
  256. drawnLinks.push([venue, linkVenue]);
  257. }
  258. });
  259. that.#mapLayer.addFeatures(features);
  260. }
  261. });
  262.  
  263. // Process all results of link lookups and add a highlight feature if needed.
  264. Promise.all(promises).then(results => {
  265. let strokeColor = null;
  266. let strokeDashStyle = 'solid';
  267. if (!that.#DISABLE_CLOSED_PLACES && results.some(res => res.permclosed)) {
  268. if (/^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.attributes.name)
  269. || /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.attributes.name)) {
  270. strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
  271. }
  272. strokeColor = '#F00';
  273. } else if (results.some(res => that.#isLinkTooFar(res, venue))) {
  274. strokeColor = '#0FF';
  275. } else if (!that.#DISABLE_CLOSED_PLACES && that.#showTempClosedPOIs && results.some(res => res.tempclosed)) {
  276. if (/^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.attributes.name)
  277. || /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.attributes.name)) {
  278. strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
  279. }
  280. strokeColor = '#FD3';
  281. } else if (results.some(res => res.notFound)) {
  282. strokeColor = '#F0F';
  283. }
  284. if (strokeColor) {
  285. const style = {
  286. strokeWidth: venue.isPoint() ? '4' : '12',
  287. strokeColor,
  288. strokeDashStyle
  289. };
  290. const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
  291. that.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]);
  292. }
  293. });
  294. });
  295. } catch (ex) {
  296. console.error('PIE (Google Link Enhancer) error:', ex);
  297. }
  298. }
  299. }
  300.  
  301. static #onMapMouseenter(event) {
  302. // If the point isn't destroyed yet, destroy it when mousing over the map.
  303. event.data.#destroyPoint();
  304. }
  305.  
  306. async #formatLinkElements() {
  307. const $links = $('#edit-panel').find(this.#EXT_PROV_ELEM_QUERY);
  308. if ($links.length) {
  309. const existingLinks = GLE.#getExistingLinks();
  310.  
  311. // fetch all links first
  312. const promises = [];
  313. const extProvElements = [];
  314. $links.each((ix, linkEl) => {
  315. const $linkEl = $(linkEl);
  316. extProvElements.push($linkEl);
  317.  
  318. const id = GLE.#getIdFromElement($linkEl);
  319. promises.push(this.linkCache.getPlace(id));
  320. });
  321. const links = await Promise.all(promises);
  322.  
  323. extProvElements.forEach(($extProvElem, i) => {
  324. const id = GLE.#getIdFromElement($extProvElem);
  325.  
  326. if (!id) return;
  327.  
  328. const link = links[i];
  329. if (existingLinks[id] && existingLinks[id].count > 1 && existingLinks[id].isThisVenue) {
  330. setTimeout(() => {
  331. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA500' }).attr({
  332. title: this.strings.linkedToXPlaces.replace('{0}', existingLinks[id].count)
  333. });
  334. }, 50);
  335. }
  336. this.#addHoverEvent($extProvElem);
  337. if (link) {
  338. if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) {
  339. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FAA' }).attr('title', this.strings.permClosedPlace);
  340. } else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) {
  341. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA' }).attr('title', this.strings.tempClosedPlace);
  342. } else if (link.notFound) {
  343. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#F0F' }).attr('title', this.strings.badLink);
  344. } else {
  345. const venue = W.selectionManager.getSelectedDataModelObjects()[0];
  346. if (this.#isLinkTooFar(link, venue)) {
  347. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#0FF' }).attr('title', this.strings.tooFar.replace('{0}', this.distanceLimit));
  348. } else { // reset in case we just deleted another provider
  349. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '' }).attr('title', '');
  350. }
  351. }
  352. }
  353. });
  354. }
  355. }
  356.  
  357. static #getExistingLinks() {
  358. const existingLinks = {};
  359. const thisVenue = W.selectionManager.getSelectedDataModelObjects()[0];
  360. W.model.venues.getObjectArray().forEach(venue => {
  361. const isThisVenue = venue === thisVenue;
  362. const thisPlaceIDs = [];
  363. venue.attributes.externalProviderIDs.forEach(provID => {
  364. const id = provID.attributes.uuid;
  365. if (!thisPlaceIDs.includes(id)) {
  366. thisPlaceIDs.push(id);
  367. let link = existingLinks[id];
  368. if (link) {
  369. link.count++;
  370. link.venues.push(venue);
  371. } else {
  372. link = { count: 1, venues: [venue] };
  373. existingLinks[id] = link;
  374. if (provID.attributes.url != null) {
  375. const u = provID.attributes.url.replace('https://maps.google.com/?', '');
  376. link.url = u;
  377. }
  378. }
  379. link.isThisVenue = link.isThisVenue || isThisVenue;
  380. }
  381. });
  382. });
  383. return existingLinks;
  384. }
  385.  
  386. // Remove the POI point from the map.
  387. #destroyPoint() {
  388. if (this.#ptFeature) {
  389. this.#ptFeature.destroy();
  390. this.#ptFeature = null;
  391. this.#lineFeature.destroy();
  392. this.#lineFeature = null;
  393. }
  394. }
  395.  
  396. static #getOLMapExtent() {
  397. let extent = W.map.getExtent();
  398. if (Array.isArray(extent)) {
  399. extent = new OpenLayers.Bounds(extent);
  400. extent.transform('EPSG:4326', 'EPSG:3857');
  401. }
  402. return extent;
  403. }
  404.  
  405. // Add the POI point to the map.
  406. async #addPoint(id) {
  407. if (!id) return;
  408. const link = await this.linkCache.getPlace(id);
  409. if (link) {
  410. if (!link.notFound) {
  411. const coord = link.loc;
  412. const poiPt = new OpenLayers.Geometry.Point(coord.lng, coord.lat);
  413. poiPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject().projCode);
  414. const placeGeom = W.selectionManager.getSelectedDataModelObjects()[0].geometry.getCentroid();
  415. const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y);
  416. const ext = GLE.#getOLMapExtent();
  417. const lsBounds = new OpenLayers.Geometry.LineString([
  418. new OpenLayers.Geometry.Point(ext.left, ext.bottom),
  419. new OpenLayers.Geometry.Point(ext.left, ext.top),
  420. new OpenLayers.Geometry.Point(ext.right, ext.top),
  421. new OpenLayers.Geometry.Point(ext.right, ext.bottom),
  422. new OpenLayers.Geometry.Point(ext.left, ext.bottom)]);
  423. let lsLine = new OpenLayers.Geometry.LineString([placePt, poiPt]);
  424.  
  425. // If the line extends outside the bounds, split it so we don't draw a line across the world.
  426. const splits = lsLine.splitWith(lsBounds);
  427. let label = '';
  428. if (splits) {
  429. let splitPoints;
  430. splits.forEach(split => {
  431. split.components.forEach(component => {
  432. if (component.x === placePt.x && component.y === placePt.y) splitPoints = split;
  433. });
  434. });
  435. lsLine = new OpenLayers.Geometry.LineString([splitPoints.components[0], splitPoints.components[1]]);
  436. let distance = GLE.#distanceBetweenPoints(poiPt, placePt);
  437. let unitConversion;
  438. let unit1;
  439. let unit2;
  440. if (W.model.isImperial) {
  441. distance *= 3.28084;
  442. unitConversion = 5280;
  443. unit1 = ' ft';
  444. unit2 = ' mi';
  445. } else {
  446. unitConversion = 1000;
  447. unit1 = ' m';
  448. unit2 = ' km';
  449. }
  450. if (distance > unitConversion * 10) {
  451. label = Math.round(distance / unitConversion) + unit2;
  452. } else if (distance > 1000) {
  453. label = (Math.round(distance / (unitConversion / 10)) / 10) + unit2;
  454. } else {
  455. label = Math.round(distance) + unit1;
  456. }
  457. }
  458.  
  459. this.#destroyPoint(); // Just in case it still exists.
  460. this.#ptFeature = new OpenLayers.Feature.Vector(poiPt, { poiCoord: true }, {
  461. pointRadius: 6,
  462. strokeWidth: 30,
  463. strokeColor: '#FF0',
  464. fillColor: '#FF0',
  465. strokeOpacity: 0.5
  466. });
  467. this.#lineFeature = new OpenLayers.Feature.Vector(lsLine, {}, {
  468. strokeWidth: 3,
  469. strokeDashstyle: '12 8',
  470. strokeColor: '#FF0',
  471. label,
  472. labelYOffset: 45,
  473. fontColor: '#FF0',
  474. fontWeight: 'bold',
  475. labelOutlineColor: '#000',
  476. labelOutlineWidth: 4,
  477. fontSize: '18'
  478. });
  479. W.map.getLayerByUniqueName('venues').addFeatures([this.#ptFeature, this.#lineFeature]);
  480. this.#timeoutDestroyPoint();
  481. }
  482. } else {
  483. // this.#getLinkInfoAsync(id).then(res => {
  484. // if (res.error || res.apiDisabled) {
  485. // // API was temporarily disabled. Ignore for now.
  486. // } else {
  487. // this.#addPoint(id);
  488. // }
  489. // });
  490. }
  491. }
  492.  
  493. // Destroy the point after some time, if it hasn't been destroyed already.
  494. #timeoutDestroyPoint() {
  495. if (this.#timeoutID) clearTimeout(this.#timeoutID);
  496. this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000);
  497. }
  498.  
  499. static #getIdFromElement($el) {
  500. const providerIndex = $el.parent().children().toArray().indexOf($el[0]);
  501. return W.selectionManager.getSelectedDataModelObjects()[0].getExternalProviderIDs()[providerIndex]?.attributes.uuid;
  502. }
  503.  
  504. #addHoverEvent($el) {
  505. $el.hover(() => this.#addPoint(GLE.#getIdFromElement($el)), () => this.#destroyPoint());
  506. }
  507.  
  508. #interceptPlacesService() {
  509. if (typeof google === 'undefined' || !google.maps || !google.maps.places || !google.maps.places.PlacesService) {
  510. console.debug('Google Maps PlacesService not loaded yet.');
  511. setTimeout(this.#interceptPlacesService.bind(this), 500); // Retry until it loads
  512. return;
  513. }
  514.  
  515. const originalGetDetails = google.maps.places.PlacesService.prototype.getDetails;
  516. const that = this;
  517. google.maps.places.PlacesService.prototype.getDetails = function interceptedGetDetails(request, callback) {
  518. console.debug('Intercepted getDetails call:', request);
  519.  
  520. const customCallback = function(result, status) {
  521. console.debug('Intercepted getDetails response:', result, status);
  522. const link = {};
  523. switch (status) {
  524. case google.maps.places.PlacesServiceStatus.OK: {
  525. const loc = result.geometry.location;
  526. link.loc = { lng: loc.lng(), lat: loc.lat() };
  527. if (result.business_status === google.maps.places.BusinessStatus.CLOSED_PERMANENTLY) {
  528. link.permclosed = true;
  529. } else if (result.business_status === google.maps.places.BusinessStatus.CLOSED_TEMPORARILY) {
  530. link.tempclosed = true;
  531. }
  532. that.linkCache.addPlace(request.placeId, link);
  533. break;
  534. }
  535. case google.maps.places.PlacesServiceStatus.NOT_FOUND:
  536. link.notfound = true;
  537. that.linkCache.addPlace(request.placeId, link);
  538. break;
  539. default:
  540. link.error = status;
  541. }
  542. callback(result, status); // Pass the result to the original callback
  543. };
  544.  
  545. return originalGetDetails.call(this, request, customCallback);
  546. };
  547.  
  548. console.debug('Google Maps PlacesService.getDetails intercepted successfully.');
  549. }
  550. }
  551.  
  552. return GLE;
  553. }());