Greasy Fork is available in English.

Link Enhancer

Adds some extra WME functionality related to Google place links.

אין להתקין סקריפט זה ישירות. זוהי ספריה עבור סקריפטים אחרים // @require https://update.greatest.deepsurf.us/scripts/523706/1569240/Link%20Enhancer.js

  1. // ==UserScript==
  2. // @name Link Enhancer
  3. // @namespace WazeDev
  4. // @version 2025.01.31.000
  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. /* global OpenLayers */
  11. /* global W */
  12. /* global google */
  13. // /* eslint-disable */
  14. /* eslint-disable no-unused-vars */
  15. class GoogleLinkEnhancer {
  16. #DISABLE_CLOSED_PLACES = false; // Set to TRUE if the feature needs to be temporarily disabled, e.g. during the COVID-19 pandemic.
  17. #EXT_PROV_ELEM_QUERY = 'wz-list-item.external-provider';
  18. #EXT_PROV_ELEM_EDIT_QUERY = 'wz-list-item.external-provider-edit';
  19. #EXT_PROV_ELEM_CONTENT_QUERY = 'div.external-provider-content';
  20. #LINK_CACHE_NAME = 'gle_link_cache';
  21. #LINK_CACHE_CLEAN_INTERVAL_MIN = 1; // Interval to remove old links and save new ones.
  22. #LINK_CACHE_LIFESPAN_HR = 6; // Remove old links when they exceed this time limit.
  23. #linkCache;
  24. #enabled = false;
  25. #disableApiUntil = null; // When a serious API error occurs (OVER_QUERY_LIMIT, REQUEST_DENIED), set this to a time in the future.
  26. #mapLayer = null;
  27. #distanceLimit = 400; // Default distance (meters) when Waze place is flagged for being too far from Google place.
  28. // Area place is calculated as #distanceLimit + <distance between centroid and furthest node>
  29. #showTempClosedPOIs = true;
  30. #placesService;
  31. #linkObserver;
  32. #modeObserver;
  33. #searchResultsObserver;
  34. #lzString;
  35. #cacheCleanIntervalID;
  36. #originalHeadAppendChildMethod;
  37. #ptFeature;
  38. #lineFeature;
  39. #timeoutID;
  40. strings = {
  41. permClosedPlace: 'Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.',
  42. tempClosedPlace: 'Google indicates this place is temporarily closed.',
  43. multiLinked: 'Linked more than once already. Please find and remove multiple links.',
  44. linkedToThisPlace: 'Already linked to this place',
  45. linkedNearby: 'Already linked to a nearby place',
  46. linkedToXPlaces: 'This is linked to {0} places',
  47. badLink: 'Invalid Google link. Please remove it.',
  48. tooFar: 'The Google linked place is more than {0} meters from the Waze place. Please verify the link is correct.'
  49. };
  50. /* eslint-enable no-unused-vars */
  51. constructor() {
  52. const attributionElem = document.createElement('div');
  53. this.#placesService = new google.maps.places.PlacesService(attributionElem);
  54. this.#initLZString();
  55. const STORED_CACHE = localStorage.getItem(this.#LINK_CACHE_NAME);
  56. try {
  57. this.#linkCache = STORED_CACHE ? $.parseJSON(this.#lzString.decompressFromUTF16(STORED_CACHE)) : {};
  58. } catch (ex) {
  59. if (ex.name === 'SyntaxError') {
  60. // In case the cache is corrupted and can't be read.
  61. this.#linkCache = {};
  62. console.warn('GoogleLinkEnhancer:', 'An error occurred while loading the stored cache. A new cache was created.');
  63. } else {
  64. throw ex;
  65. }
  66. }
  67. if (this.#linkCache === null || this.#linkCache.length === 0) this.#linkCache = {};
  68. this.#initLayer();
  69. // NOTE: Arrow functions are necessary for calling methods on object instances.
  70. // This could be made more efficient by only processing the relevant places.
  71. W.model.events.register('mergeend', null, () => { this.#processPlaces(); });
  72. W.model.venues.on('objectschanged', () => { this.#processPlaces(); });
  73. W.model.venues.on('objectsremoved', () => { this.#processPlaces(); });
  74. W.model.venues.on('objectsadded', () => { this.#processPlaces(); });
  75. // Watch for ext provider elements being added to the DOM, and add hover events.
  76. this.#linkObserver = new MutationObserver(mutations => {
  77. mutations.forEach(mutation => {
  78. for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
  79. const nd = mutation.addedNodes[idx];
  80. if (nd.nodeType === Node.ELEMENT_NODE) {
  81. const $el = $(nd);
  82. const $subel = $el.find(this.#EXT_PROV_ELEM_QUERY);
  83. if ($el.is(this.#EXT_PROV_ELEM_QUERY)) {
  84. this.#addHoverEvent($el);
  85. this.#formatLinkElements();
  86. } else if ($subel.length) {
  87. for (let i = 0; i < $subel.length; i++) {
  88. this.#addHoverEvent($($subel[i]));
  89. }
  90. this.#formatLinkElements();
  91. }
  92. if ($el.is(this.#EXT_PROV_ELEM_EDIT_QUERY)) {
  93. this.#searchResultsObserver.observe($el.find('wz-autocomplete[placeholder="Search for a place"]')[0].shadowRoot, { childList: true, subtree: true });
  94. }
  95. }
  96. }
  97. for (let idx = 0; idx < mutation.removedNodes.length; idx++) {
  98. const nd = mutation.removedNodes[idx];
  99. if (nd.nodeType === Node.ELEMENT_NODE) {
  100. const $el = $(nd);
  101. if ($el.is(this.#EXT_PROV_ELEM_EDIT_QUERY)) {
  102. this.#searchResultsObserver.disconnect();
  103. }
  104. }
  105. }
  106. });
  107. });
  108. // Watch for Google place search result list items being added to the DOM
  109. const that = this;
  110. this.#searchResultsObserver = new MutationObserver(mutations => {
  111. mutations.forEach(mutation => {
  112. for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
  113. const nd = mutation.addedNodes[idx];
  114. if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('wz-menu-item.simple-item')) {
  115. $(nd).mouseenter(() => {
  116. // When mousing over a list item, find the Google place ID from the list that was stored previously.
  117. // Then add the point/line to the map.
  118. that.#addPoint($(nd).attr('item-id'));
  119. }).mouseleave(() => {
  120. // When leaving the list item, remove the point.
  121. that.#destroyPoint();
  122. });
  123. }
  124. }
  125. });
  126. });
  127. // Watch the side panel for addition of the sidebar-layout div, which indicates a mode change.
  128. this.#modeObserver = new MutationObserver(mutations => {
  129. mutations.forEach(mutation => {
  130. for (let idx = 0; idx < mutation.addedNodes.length; idx++) {
  131. const nd = mutation.addedNodes[idx];
  132. if (nd.nodeType === Node.ELEMENT_NODE && $(nd).is('.sidebar-layout')) {
  133. this.#observeLinks();
  134. break;
  135. }
  136. }
  137. });
  138. });
  139. // This is a special event that will be triggered when DOM elements are destroyed.
  140. /* eslint-disable wrap-iife, func-names, object-shorthand */
  141. (function($) {
  142. $.event.special.destroyed = {
  143. remove: function(o) {
  144. if (o.handler && o.type !== 'destroyed') {
  145. o.handler();
  146. }
  147. }
  148. };
  149. })(jQuery);
  150. /* eslint-enable wrap-iife, func-names, object-shorthand */
  151. // In case a place is already selected on load.
  152. const selObjects = W.selectionManager.getSelectedDataModelObjects();
  153. if (selObjects.length && selObjects[0].type === 'venue') {
  154. this.#formatLinkElements();
  155. }
  156. }
  157. #initLayer() {
  158. this.#mapLayer = new OpenLayers.Layer.Vector('Google Link Enhancements.', {
  159. uniqueName: '___GoogleLinkEnhancements',
  160. displayInLayerSwitcher: true,
  161. styleMap: new OpenLayers.StyleMap({
  162. default: {
  163. strokeColor: '${strokeColor}',
  164. strokeWidth: '${strokeWidth}',
  165. strokeDashstyle: '${strokeDashstyle}',
  166. pointRadius: '15',
  167. fillOpacity: '0'
  168. }
  169. })
  170. });
  171. this.#mapLayer.setOpacity(0.8);
  172. W.map.addLayer(this.#mapLayer);
  173. }
  174. enable() {
  175. if (!this.#enabled) {
  176. this.#modeObserver.observe($('.edit-area #sidebarContent')[0], { childList: true, subtree: false });
  177. this.#observeLinks();
  178. // Watch for JSONP callbacks. JSONP is used for the autocomplete results when searching for Google links.
  179. this.#addJsonpInterceptor();
  180. // Note: Using on() allows passing "this" as a variable, so it can be used in the handler function.
  181. $('#map').on('mouseenter', null, this, GoogleLinkEnhancer.#onMapMouseenter);
  182. $(window).on('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
  183. W.model.venues.on('objectschanged', this.#formatLinkElements, this);
  184. this.#processPlaces();
  185. this.#cleanAndSaveLinkCache();
  186. this.#cacheCleanIntervalID = setInterval(() => this.#cleanAndSaveLinkCache(), 1000 * 60 * this.#LINK_CACHE_CLEAN_INTERVAL_MIN);
  187. this.#enabled = true;
  188. }
  189. }
  190. disable() {
  191. if (this.#enabled) {
  192. this.#modeObserver.disconnect();
  193. this.#linkObserver.disconnect();
  194. this.#searchResultsObserver.disconnect();
  195. this.#removeJsonpInterceptor();
  196. $('#map').off('mouseenter', GoogleLinkEnhancer.#onMapMouseenter);
  197. $(window).off('unload', null, this, GoogleLinkEnhancer.#onWindowUnload);
  198. W.model.venues.off('objectschanged', this.#formatLinkElements, this);
  199. if (this.#cacheCleanIntervalID) clearInterval(this.#cacheCleanIntervalID);
  200. this.#cleanAndSaveLinkCache();
  201. this.#enabled = false;
  202. }
  203. }
  204. // The distance (in meters) before flagging a Waze place that is too far from the linked Google place.
  205. // Area places use distanceLimit, plus the distance from the centroid of the AP to its furthest node.
  206. get distanceLimit() {
  207. return this.#distanceLimit;
  208. }
  209. set distanceLimit(value) {
  210. this.#distanceLimit = value;
  211. this.#processPlaces();
  212. }
  213. get showTempClosedPOIs() {
  214. return this.#showTempClosedPOIs;
  215. }
  216. set showTempClosedPOIs(value) {
  217. this.#showTempClosedPOIs = value;
  218. this.#processPlaces();
  219. }
  220. static #onWindowUnload(evt) {
  221. evt.data.#cleanAndSaveLinkCache();
  222. }
  223. #cleanAndSaveLinkCache() {
  224. if (!this.#linkCache) return;
  225. const now = new Date();
  226. Object.keys(this.#linkCache).forEach(id => {
  227. const link = this.#linkCache[id];
  228. // Bug fix:
  229. if (link.location) {
  230. link.loc = link.location;
  231. delete link.location;
  232. }
  233. // Delete link if older than X hours.
  234. if (!link.ts || (now - new Date(link.ts)) > this.#LINK_CACHE_LIFESPAN_HR * 3600 * 1000) {
  235. delete this.#linkCache[id];
  236. }
  237. });
  238. localStorage.setItem(this.#LINK_CACHE_NAME, this.#lzString.compressToUTF16(JSON.stringify(this.#linkCache)));
  239. // console.log('link cache count: ' + Object.keys(this.#linkCache).length, this.#linkCache);
  240. }
  241. // Borrowed from WazeWrap
  242. static #distanceBetweenPoints(point1, point2) {
  243. const line = new OpenLayers.Geometry.LineString([point1, point2]);
  244. const length = line.getGeodesicLength(W.map.getProjectionObject());
  245. return length; // multiply by 3.28084 to convert to feet
  246. }
  247. #isLinkTooFar(link, venue) {
  248. if (link.loc) {
  249. const linkPt = new OpenLayers.Geometry.Point(link.loc.lng, link.loc.lat);
  250. linkPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject());
  251. let venuePt;
  252. let distanceLim = this.distanceLimit;
  253. if (venue.isPoint()) {
  254. venuePt = venue.geometry.getCentroid();
  255. } else {
  256. const bounds = venue.geometry.getBounds();
  257. const center = bounds.getCenterLonLat();
  258. venuePt = new OpenLayers.Geometry.Point(center.lon, center.lat);
  259. const topRightPt = new OpenLayers.Geometry.Point(bounds.right, bounds.top);
  260. distanceLim += GoogleLinkEnhancer.#distanceBetweenPoints(venuePt, topRightPt);
  261. }
  262. const distance = GoogleLinkEnhancer.#distanceBetweenPoints(linkPt, venuePt);
  263. return distance > distanceLim;
  264. }
  265. return false;
  266. }
  267. // eslint-disable-next-line class-methods-use-this
  268. #processPlaces() {
  269. try {
  270. if (this.#enabled) {
  271. const that = this;
  272. // Get a list of already-linked id's
  273. const existingLinks = GoogleLinkEnhancer.#getExistingLinks();
  274. this.#mapLayer.removeAllFeatures();
  275. const drawnLinks = [];
  276. W.model.venues.getObjectArray().forEach(venue => {
  277. const promises = [];
  278. venue.attributes.externalProviderIDs.forEach(provID => {
  279. const id = provID.attributes.uuid;
  280.  
  281. // Check for duplicate links
  282. const linkInfo = existingLinks[id];
  283. if (linkInfo.count > 1) {
  284. const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
  285. const width = venue.isPoint() ? '4' : '12';
  286. const color = '#fb8d00';
  287. const features = [new OpenLayers.Feature.Vector(geometry, {
  288. strokeWidth: width, strokeColor: color
  289. })];
  290. const lineStart = geometry.getCentroid();
  291. linkInfo.venues.forEach(linkVenue => {
  292. if (linkVenue !== venue
  293. && !drawnLinks.some(dl => (dl[0] === venue && dl[1] === linkVenue) || (dl[0] === linkVenue && dl[1] === venue))) {
  294. features.push(
  295. new OpenLayers.Feature.Vector(
  296. new OpenLayers.Geometry.LineString([lineStart, linkVenue.geometry.getCentroid()]),
  297. {
  298. strokeWidth: 4,
  299. strokeColor: color,
  300. strokeDashstyle: '12 12'
  301. }
  302. )
  303. );
  304. drawnLinks.push([venue, linkVenue]);
  305. }
  306. });
  307. that.#mapLayer.addFeatures(features);
  308. }
  309.  
  310. // Get Google link info, and store results for processing.
  311. promises.push(that.#getLinkInfoAsync(id));
  312. });
  313.  
  314. // Process all results of link lookups and add a highlight feature if needed.
  315. Promise.all(promises).then(results => {
  316. let strokeColor = null;
  317. let strokeDashStyle = 'solid';
  318. if (!that.#DISABLE_CLOSED_PLACES && results.some(res => res.permclosed)) {
  319. if (/^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.attributes.name)
  320. || /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.attributes.name)) {
  321. strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
  322. }
  323. strokeColor = '#F00';
  324. } else if (results.some(res => that.#isLinkTooFar(res, venue))) {
  325. strokeColor = '#0FF';
  326. } else if (!that.#DISABLE_CLOSED_PLACES && that.#showTempClosedPOIs && results.some(res => res.tempclosed)) {
  327. if (/^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.attributes.name)
  328. || /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.attributes.name)) {
  329. strokeDashStyle = venue.isPoint() ? '2 6' : '2 16';
  330. }
  331. strokeColor = '#FD3';
  332. } else if (results.some(res => res.notFound)) {
  333. strokeColor = '#F0F';
  334. }
  335. if (strokeColor) {
  336. const style = {
  337. strokeWidth: venue.isPoint() ? '4' : '12',
  338. strokeColor,
  339. strokeDashStyle
  340. };
  341. const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone();
  342. that.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]);
  343. }
  344. });
  345. });
  346. }
  347. } catch (ex) {
  348. console.error('PIE (Google Link Enhancer) error:', ex);
  349. }
  350. }
  351. #cacheLink(id, link) {
  352. link.ts = new Date();
  353. this.#linkCache[id] = link;
  354. // console.log('link cache count: ' + Object.keys(this.#linkCache).length, this.#linkCache);
  355. }
  356. #getLinkInfoAsync(placeId) {
  357. return new Promise(resolve => {
  358. let link = this.#linkCache[placeId];
  359. if (!link) {
  360. const request = {
  361. placeId,
  362. fields: ['geometry', 'business_status']
  363. };
  364. this.#placesService.getDetails(request, (place, requestStatus) => {
  365. link = {};
  366. if (requestStatus === google.maps.places.PlacesServiceStatus.OK) {
  367. const loc = place.geometry.location;
  368. link.loc = { lng: loc.lng(), lat: loc.lat() };
  369. if (place.business_status === 'CLOSED_PERMANENTLY') {
  370. link.permclosed = true;
  371. } else if (place.business_status === 'CLOSED_TEMPORARILY') {
  372. link.tempclosed = true;
  373. }
  374. this.#cacheLink(placeId, link);
  375. } else if (requestStatus === google.maps.places.PlacesServiceStatus.NOT_FOUND) {
  376. link.notfound = true;
  377. this.#cacheLink(placeId, link);
  378. } else if (this.#disableApiUntil) {
  379. link.apiDisabled = true;
  380. } else {
  381. link.error = requestStatus;
  382. //res.errorMessage = json.error_message;
  383. this.#disableApiUntil = Date.now() + 10 * 1000; // Disable api calls for 10 seconds.
  384. console.error(`${GM_info.script.name}, Google Link Enhancer disabled for 10 seconds due to API error.`, link);
  385. }
  386. resolve(link);
  387. });
  388. } else {
  389. resolve(link);
  390. }
  391. // link = {};
  392. // this.#cacheLink(placeId, link);
  393. // }
  394. // resolve(link);
  395. });
  396. }
  397. static #onMapMouseenter(event) {
  398. // If the point isn't destroyed yet, destroy it when mousing over the map.
  399. event.data.#destroyPoint();
  400. }
  401. async #formatLinkElements(callCount = 0) {
  402. const $links = $('#edit-panel').find(this.#EXT_PROV_ELEM_QUERY);
  403. const selObjects = W.selectionManager.getSelectedDataModelObjects();
  404. if (!$links.length) {
  405. // If links aren't available, continue calling this function for up to 3 seconds unless place has been deselected.
  406. if (callCount < 30 && selObjects.length && selObjects[0].type === 'venue') {
  407. setTimeout(() => this.#formatLinkElements(++callCount), 100);
  408. }
  409. } else {
  410. const existingLinks = GoogleLinkEnhancer.#getExistingLinks();
  411. // fetch all links first
  412. const promises = [];
  413. const extProvElements = [];
  414. $links.each((ix, linkEl) => {
  415. const $linkEl = $(linkEl);
  416. extProvElements.push($linkEl);
  417. const id = GoogleLinkEnhancer.#getIdFromElement($linkEl);
  418. if (!id) return;
  419. promises.push(this.#getLinkInfoAsync(id));
  420. });
  421. await Promise.all(promises);
  422. extProvElements.forEach($extProvElem => {
  423. const id = GoogleLinkEnhancer.#getIdFromElement($extProvElem);
  424. if (!id) return;
  425. const link = this.#linkCache[id];
  426. if (existingLinks[id] && existingLinks[id].count > 1 && existingLinks[id].isThisVenue) {
  427. setTimeout(() => {
  428. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA500' }).attr({
  429. title: this.strings.linkedToXPlaces.replace('{0}', existingLinks[id].count)
  430. });
  431. }, 50);
  432. }
  433. this.#addHoverEvent($extProvElem);
  434. if (link) {
  435. if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) {
  436. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FAA' }).attr('title', this.strings.permClosedPlace);
  437. } else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) {
  438. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#FFA' }).attr('title', this.strings.tempClosedPlace);
  439. } else if (link.notFound) {
  440. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#F0F' }).attr('title', this.strings.badLink);
  441. } else {
  442. const venue = W.selectionManager.getSelectedDataModelObjects()[0];
  443. if (this.#isLinkTooFar(link, venue)) {
  444. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '#0FF' }).attr('title', this.strings.tooFar.replace('{0}', this.distanceLimit));
  445. } else { // reset in case we just deleted another provider
  446. $extProvElem.find(this.#EXT_PROV_ELEM_CONTENT_QUERY).css({ backgroundColor: '' }).attr('title', '');
  447. }
  448. }
  449. }
  450. });
  451. }
  452. }
  453. static #getExistingLinks() {
  454. const existingLinks = {};
  455. const thisVenue = W.selectionManager.getSelectedDataModelObjects()[0];
  456. W.model.venues.getObjectArray().forEach(venue => {
  457. const isThisVenue = venue === thisVenue;
  458. const thisPlaceIDs = [];
  459. venue.attributes.externalProviderIDs.forEach(provID => {
  460. const id = provID.attributes.uuid;
  461. if (thisPlaceIDs.indexOf(id) === -1) {
  462. thisPlaceIDs.push(id);
  463. let link = existingLinks[id];
  464. if (link) {
  465. link.count++;
  466. link.venues.push(venue);
  467. } else {
  468. link = { count: 1, venues: [venue] };
  469. existingLinks[id] = link;
  470. if (provID.attributes.url != null) {
  471. const u = provID.attributes.url.replace('https://maps.google.com/?', '');
  472. link.url = u;
  473. }
  474. }
  475. link.isThisVenue = link.isThisVenue || isThisVenue;
  476. }
  477. });
  478. });
  479. return existingLinks;
  480. }
  481. // Remove the POI point from the map.
  482. #destroyPoint() {
  483. if (this.#ptFeature) {
  484. this.#ptFeature.destroy();
  485. this.#ptFeature = null;
  486. this.#lineFeature.destroy();
  487. this.#lineFeature = null;
  488. }
  489. }
  490. static #getOLMapExtent() {
  491. let extent = W.map.getExtent();
  492. if (Array.isArray(extent)) {
  493. extent = new OpenLayers.Bounds(extent);
  494. extent.transform('EPSG:4326', 'EPSG:3857');
  495. }
  496. return extent;
  497. }
  498. // Add the POI point to the map.
  499. #addPoint(id) {
  500. if (!id) return;
  501. const link = this.#linkCache[id];
  502. if (link) {
  503. if (!link.notFound) {
  504. const coord = link.loc;
  505. const poiPt = new OpenLayers.Geometry.Point(coord.lng, coord.lat);
  506. poiPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject().projCode);
  507. const placeGeom = W.selectionManager.getSelectedDataModelObjects()[0].geometry.getCentroid();
  508. const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y);
  509. const ext = GoogleLinkEnhancer.#getOLMapExtent();
  510. const lsBounds = new OpenLayers.Geometry.LineString([
  511. new OpenLayers.Geometry.Point(ext.left, ext.bottom),
  512. new OpenLayers.Geometry.Point(ext.left, ext.top),
  513. new OpenLayers.Geometry.Point(ext.right, ext.top),
  514. new OpenLayers.Geometry.Point(ext.right, ext.bottom),
  515. new OpenLayers.Geometry.Point(ext.left, ext.bottom)]);
  516. let lsLine = new OpenLayers.Geometry.LineString([placePt, poiPt]);
  517. // If the line extends outside the bounds, split it so we don't draw a line across the world.
  518. const splits = lsLine.splitWith(lsBounds);
  519. let label = '';
  520. if (splits) {
  521. let splitPoints;
  522. splits.forEach(split => {
  523. split.components.forEach(component => {
  524. if (component.x === placePt.x && component.y === placePt.y) splitPoints = split;
  525. });
  526. });
  527. lsLine = new OpenLayers.Geometry.LineString([splitPoints.components[0], splitPoints.components[1]]);
  528. let distance = GoogleLinkEnhancer.#distanceBetweenPoints(poiPt, placePt);
  529. let unitConversion;
  530. let unit1;
  531. let unit2;
  532. if (W.model.isImperial) {
  533. distance *= 3.28084;
  534. unitConversion = 5280;
  535. unit1 = ' ft';
  536. unit2 = ' mi';
  537. } else {
  538. unitConversion = 1000;
  539. unit1 = ' m';
  540. unit2 = ' km';
  541. }
  542. if (distance > unitConversion * 10) {
  543. label = Math.round(distance / unitConversion) + unit2;
  544. } else if (distance > 1000) {
  545. label = (Math.round(distance / (unitConversion / 10)) / 10) + unit2;
  546. } else {
  547. label = Math.round(distance) + unit1;
  548. }
  549. }
  550. this.#destroyPoint(); // Just in case it still exists.
  551. this.#ptFeature = new OpenLayers.Feature.Vector(poiPt, { poiCoord: true }, {
  552. pointRadius: 6,
  553. strokeWidth: 30,
  554. strokeColor: '#FF0',
  555. fillColor: '#FF0',
  556. strokeOpacity: 0.5
  557. });
  558. this.#lineFeature = new OpenLayers.Feature.Vector(lsLine, {}, {
  559. strokeWidth: 3,
  560. strokeDashstyle: '12 8',
  561. strokeColor: '#FF0',
  562. label,
  563. labelYOffset: 45,
  564. fontColor: '#FF0',
  565. fontWeight: 'bold',
  566. labelOutlineColor: '#000',
  567. labelOutlineWidth: 4,
  568. fontSize: '18'
  569. });
  570. W.map.getLayerByUniqueName('venues').addFeatures([this.#ptFeature, this.#lineFeature]);
  571. this.#timeoutDestroyPoint();
  572. }
  573. } else {
  574. this.#getLinkInfoAsync(id).then(res => {
  575. if (res.error || res.apiDisabled) {
  576. // API was temporarily disabled. Ignore for now.
  577. } else {
  578. this.#addPoint(id);
  579. }
  580. });
  581. }
  582. }
  583. // Destroy the point after some time, if it hasn't been destroyed already.
  584. #timeoutDestroyPoint() {
  585. if (this.#timeoutID) clearTimeout(this.#timeoutID);
  586. this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000);
  587. }
  588. static #getIdFromElement($el) {
  589. const providerIndex = $el.parent().children().toArray().indexOf($el[0]);
  590. return W.selectionManager.getSelectedDataModelObjects()[0].getExternalProviderIDs()[providerIndex]?.attributes.uuid;
  591. }
  592. #addHoverEvent($el) {
  593. $el.hover(() => this.#addPoint(GoogleLinkEnhancer.#getIdFromElement($el)), () => this.#destroyPoint());
  594. }
  595. #observeLinks() {
  596. this.elem = document.querySelector('#edit-panel');
  597. this.#linkObserver.observe(document.querySelector('#edit-panel'), { childList: true, subtree: true });
  598. }
  599. // The JSONP interceptor is used to watch the head element for the addition of JSONP functions
  600. // that process Google link search results. Those functions are overridden by our own so we can
  601. // process the results before sending them on to the original function.
  602. #addJsonpInterceptor() {
  603. // The idea for this function was hatched here:
  604. // https://stackoverflow.com/questions/6803521/can-google-maps-places-autocomplete-api-be-used-via-ajax/9856786
  605. // The head element, where the Google Autocomplete code will insert a tag
  606. // for a javascript file.
  607. const head = $('head')[0];
  608. // The name of the method the Autocomplete code uses to insert the tag.
  609. const method = 'appendChild';
  610. // The method we will be overriding.
  611. const originalMethod = head[method];
  612. this.#originalHeadAppendChildMethod = originalMethod;
  613. const that = this;
  614. /* eslint-disable func-names, prefer-rest-params */ // Doesn't work as an arrow function (at least not without some modifications)
  615. head[method] = function() {
  616. // Check that the element is a javascript tag being inserted by Google.
  617. if (arguments[0] && arguments[0].src && arguments[0].src.match(/GetPredictions/)) {
  618. // Regex to extract the name of the callback method that the JSONP will call.
  619. const callbackMatchObject = (/callback=([^&]+)&|$/).exec(arguments[0].src);
  620. // Regex to extract the search term that was entered by the user.
  621. const searchTermMatchObject = (/\?1s([^&]+)&/).exec(arguments[0].src);
  622. // const searchTerm = unescape(searchTermMatchObject[1]);
  623. if (callbackMatchObject && searchTermMatchObject) {
  624. // The JSONP callback method is in the form "abc.def" and each time has a different random name.
  625. const names = callbackMatchObject[1].split('.');
  626. // Store the original callback method.
  627. const originalCallback = names[0] && names[1] && window[names[0]] && window[names[0]][names[1]];
  628. if (originalCallback) {
  629. const newCallback = function() { // Define your own JSONP callback
  630. if (arguments[0] && arguments[0].predictions) {
  631. // SUCCESS!
  632. // The autocomplete results
  633. const data = arguments[0];
  634. // console.log('GLE: ' + JSON.stringify(data));
  635. that._lastSearchResultPlaceIds = data.predictions.map(pred => pred.place_id);
  636. // Call the original callback so the WME dropdown can do its thing.
  637. originalCallback(data);
  638. }
  639. };
  640. // Add copy of all the attributes of the old callback function to the new callback function.
  641. // This prevents the autocomplete functionality from throwing an error.
  642. Object.keys(originalCallback).forEach(key => {
  643. newCallback[key] = originalCallback[key];
  644. });
  645. window[names[0]][names[1]] = newCallback; // Override the JSONP callback
  646. }
  647. }
  648. }
  649. // Insert the element into the dom, regardless of whether it was being inserted by Google.
  650. return originalMethod.apply(this, arguments);
  651. };
  652. /* eslint-enable func-names, prefer-rest-params */
  653. }
  654. #removeJsonpInterceptor() {
  655. $('head')[0].appendChild = this.#originalHeadAppendChildMethod;
  656. }
  657. /* eslint-disable */ // Disabling eslint since this is copied code.
  658. #initLZString() {
  659. // LZ Compressor
  660. // Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
  661. // This work is free. You can redistribute it and/or modify it
  662. // under the terms of the WTFPL, Version 2
  663. // LZ-based compression algorithm, version 1.4.4
  664. this.#lzString = (function () {
  665. // private property
  666. const f = String.fromCharCode;
  667. const keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
  668. const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
  669. const baseReverseDic = {};
  670. function getBaseValue(alphabet, character) {
  671. if (!baseReverseDic[alphabet]) {
  672. baseReverseDic[alphabet] = {};
  673. for (let i = 0; i < alphabet.length; i++) {
  674. baseReverseDic[alphabet][alphabet.charAt(i)] = i;
  675. }
  676. }
  677. return baseReverseDic[alphabet][character];
  678. }
  679. var LZString = {
  680. compressToBase64: function (input) {
  681. if (input === null) return "";
  682. const res = LZString._compress(input, 6, function (a) {
  683. return keyStrBase64.charAt(a);
  684. });
  685. switch (res.length % 4) { // To produce valid Base64
  686. default: // When could this happen ?
  687. case 0:
  688. return res;
  689. case 1:
  690. return res + "===";
  691. case 2:
  692. return res + "==";
  693. case 3:
  694. return res + "=";
  695. }
  696. },
  697. decompressFromBase64: function (input) {
  698. if (input === null) return "";
  699. if (input === "") return null;
  700. return LZString._decompress(input.length, 32, function (index) {
  701. return getBaseValue(keyStrBase64, input.charAt(index));
  702. });
  703. },
  704. compressToUTF16: function (input) {
  705. if (input === null) return "";
  706. return LZString._compress(input, 15, function (a) {
  707. return f(a + 32);
  708. }) + " ";
  709. },
  710. decompressFromUTF16: function (compressed) {
  711. if (compressed === null) return "";
  712. if (compressed === "") return null;
  713. return LZString._decompress(compressed.length, 16384, function (index) {
  714. return compressed.charCodeAt(index) - 32;
  715. });
  716. },
  717. compress: function (uncompressed) {
  718. return LZString._compress(uncompressed, 16, function (a) {
  719. return f(a);
  720. });
  721. },
  722. _compress: function (uncompressed, bitsPerChar, getCharFromInt) {
  723. if (uncompressed === null) return "";
  724. let i, value,
  725. context_dictionary = {},
  726. context_dictionaryToCreate = {},
  727. context_c = "",
  728. context_wc = "",
  729. context_w = "",
  730. context_enlargeIn = 2, // Compensate for the first entry which should not count
  731. context_dictSize = 3,
  732. context_numBits = 2,
  733. context_data = [],
  734. context_data_val = 0,
  735. context_data_position = 0,
  736. ii;
  737. for (ii = 0; ii < uncompressed.length; ii += 1) {
  738. context_c = uncompressed.charAt(ii);
  739. if (!Object.prototype.hasOwnProperty.call(context_dictionary, context_c)) {
  740. context_dictionary[context_c] = context_dictSize++;
  741. context_dictionaryToCreate[context_c] = true;
  742. }
  743. context_wc = context_w + context_c;
  744. if (Object.prototype.hasOwnProperty.call(context_dictionary, context_wc)) {
  745. context_w = context_wc;
  746. } else {
  747. if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
  748. if (context_w.charCodeAt(0) < 256) {
  749. for (i = 0; i < context_numBits; i++) {
  750. context_data_val = (context_data_val << 1);
  751. if (context_data_position === bitsPerChar - 1) {
  752. context_data_position = 0;
  753. context_data.push(getCharFromInt(context_data_val));
  754. context_data_val = 0;
  755. } else {
  756. context_data_position++;
  757. }
  758. }
  759. value = context_w.charCodeAt(0);
  760. for (i = 0; i < 8; i++) {
  761. context_data_val = (context_data_val << 1) | (value & 1);
  762. if (context_data_position === bitsPerChar - 1) {
  763. context_data_position = 0;
  764. context_data.push(getCharFromInt(context_data_val));
  765. context_data_val = 0;
  766. } else {
  767. context_data_position++;
  768. }
  769. value = value >> 1;
  770. }
  771. } else {
  772. value = 1;
  773. for (i = 0; i < context_numBits; i++) {
  774. context_data_val = (context_data_val << 1) | value;
  775. if (context_data_position === bitsPerChar - 1) {
  776. context_data_position = 0;
  777. context_data.push(getCharFromInt(context_data_val));
  778. context_data_val = 0;
  779. } else {
  780. context_data_position++;
  781. }
  782. value = 0;
  783. }
  784. value = context_w.charCodeAt(0);
  785. for (i = 0; i < 16; i++) {
  786. context_data_val = (context_data_val << 1) | (value & 1);
  787. if (context_data_position === bitsPerChar - 1) {
  788. context_data_position = 0;
  789. context_data.push(getCharFromInt(context_data_val));
  790. context_data_val = 0;
  791. } else {
  792. context_data_position++;
  793. }
  794. value = value >> 1;
  795. }
  796. }
  797. context_enlargeIn--;
  798. if (context_enlargeIn === 0) {
  799. context_enlargeIn = Math.pow(2, context_numBits);
  800. context_numBits++;
  801. }
  802. delete context_dictionaryToCreate[context_w];
  803. } else {
  804. value = context_dictionary[context_w];
  805. for (i = 0; i < context_numBits; i++) {
  806. context_data_val = (context_data_val << 1) | (value & 1);
  807. if (context_data_position === bitsPerChar - 1) {
  808. context_data_position = 0;
  809. context_data.push(getCharFromInt(context_data_val));
  810. context_data_val = 0;
  811. } else {
  812. context_data_position++;
  813. }
  814. value = value >> 1;
  815. }
  816. }
  817. context_enlargeIn--;
  818. if (context_enlargeIn === 0) {
  819. context_enlargeIn = Math.pow(2, context_numBits);
  820. context_numBits++;
  821. }
  822. // Add wc to the dictionary.
  823. context_dictionary[context_wc] = context_dictSize++;
  824. context_w = String(context_c);
  825. }
  826. }
  827. // Output the code for w.
  828. if (context_w !== "") {
  829. if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
  830. if (context_w.charCodeAt(0) < 256) {
  831. for (i = 0; i < context_numBits; i++) {
  832. context_data_val = (context_data_val << 1);
  833. if (context_data_position === bitsPerChar - 1) {
  834. context_data_position = 0;
  835. context_data.push(getCharFromInt(context_data_val));
  836. context_data_val = 0;
  837. } else {
  838. context_data_position++;
  839. }
  840. }
  841. value = context_w.charCodeAt(0);
  842. for (i = 0; i < 8; i++) {
  843. context_data_val = (context_data_val << 1) | (value & 1);
  844. if (context_data_position === bitsPerChar - 1) {
  845. context_data_position = 0;
  846. context_data.push(getCharFromInt(context_data_val));
  847. context_data_val = 0;
  848. } else {
  849. context_data_position++;
  850. }
  851. value = value >> 1;
  852. }
  853. } else {
  854. value = 1;
  855. for (i = 0; i < context_numBits; i++) {
  856. context_data_val = (context_data_val << 1) | value;
  857. if (context_data_position === bitsPerChar - 1) {
  858. context_data_position = 0;
  859. context_data.push(getCharFromInt(context_data_val));
  860. context_data_val = 0;
  861. } else {
  862. context_data_position++;
  863. }
  864. value = 0;
  865. }
  866. value = context_w.charCodeAt(0);
  867. for (i = 0; i < 16; i++) {
  868. context_data_val = (context_data_val << 1) | (value & 1);
  869. if (context_data_position === bitsPerChar - 1) {
  870. context_data_position = 0;
  871. context_data.push(getCharFromInt(context_data_val));
  872. context_data_val = 0;
  873. } else {
  874. context_data_position++;
  875. }
  876. value = value >> 1;
  877. }
  878. }
  879. context_enlargeIn--;
  880. if (context_enlargeIn === 0) {
  881. context_enlargeIn = Math.pow(2, context_numBits);
  882. context_numBits++;
  883. }
  884. delete context_dictionaryToCreate[context_w];
  885. } else {
  886. value = context_dictionary[context_w];
  887. for (i = 0; i < context_numBits; i++) {
  888. context_data_val = (context_data_val << 1) | (value & 1);
  889. if (context_data_position === bitsPerChar - 1) {
  890. context_data_position = 0;
  891. context_data.push(getCharFromInt(context_data_val));
  892. context_data_val = 0;
  893. } else {
  894. context_data_position++;
  895. }
  896. value = value >> 1;
  897. }
  898. }
  899. context_enlargeIn--;
  900. if (context_enlargeIn === 0) {
  901. context_enlargeIn = Math.pow(2, context_numBits);
  902. context_numBits++;
  903. }
  904. }
  905. // Mark the end of the stream
  906. value = 2;
  907. for (i = 0; i < context_numBits; i++) {
  908. context_data_val = (context_data_val << 1) | (value & 1);
  909. if (context_data_position === bitsPerChar - 1) {
  910. context_data_position = 0;
  911. context_data.push(getCharFromInt(context_data_val));
  912. context_data_val = 0;
  913. } else {
  914. context_data_position++;
  915. }
  916. value = value >> 1;
  917. }
  918. // Flush the last char
  919. while (true) {
  920. context_data_val = (context_data_val << 1);
  921. if (context_data_position === bitsPerChar - 1) {
  922. context_data.push(getCharFromInt(context_data_val));
  923. break;
  924. } else context_data_position++;
  925. }
  926. return context_data.join('');
  927. },
  928. decompress: function (compressed) {
  929. if (compressed === null) return "";
  930. if (compressed === "") return null;
  931. return LZString._decompress(compressed.length, 32768, function (index) {
  932. return compressed.charCodeAt(index);
  933. });
  934. },
  935. _decompress: function (length, resetValue, getNextValue) {
  936. let dictionary = [],
  937. next,
  938. enlargeIn = 4,
  939. dictSize = 4,
  940. numBits = 3,
  941. entry = "",
  942. result = [],
  943. i,
  944. w,
  945. bits, resb, maxpower, power,
  946. c,
  947. data = {
  948. val: getNextValue(0),
  949. position: resetValue,
  950. index: 1
  951. };
  952. for (i = 0; i < 3; i += 1) {
  953. dictionary[i] = i;
  954. }
  955. bits = 0;
  956. maxpower = Math.pow(2, 2);
  957. power = 1;
  958. while (power !== maxpower) {
  959. resb = data.val & data.position;
  960. data.position >>= 1;
  961. if (data.position === 0) {
  962. data.position = resetValue;
  963. data.val = getNextValue(data.index++);
  964. }
  965. bits |= (resb > 0 ? 1 : 0) * power;
  966. power <<= 1;
  967. }
  968. switch (next = bits) {
  969. case 0:
  970. bits = 0;
  971. maxpower = Math.pow(2, 8);
  972. power = 1;
  973. while (power !== maxpower) {
  974. resb = data.val & data.position;
  975. data.position >>= 1;
  976. if (data.position === 0) {
  977. data.position = resetValue;
  978. data.val = getNextValue(data.index++);
  979. }
  980. bits |= (resb > 0 ? 1 : 0) * power;
  981. power <<= 1;
  982. }
  983. c = f(bits);
  984. break;
  985. case 1:
  986. bits = 0;
  987. maxpower = Math.pow(2, 16);
  988. power = 1;
  989. while (power !== maxpower) {
  990. resb = data.val & data.position;
  991. data.position >>= 1;
  992. if (data.position === 0) {
  993. data.position = resetValue;
  994. data.val = getNextValue(data.index++);
  995. }
  996. bits |= (resb > 0 ? 1 : 0) * power;
  997. power <<= 1;
  998. }
  999. c = f(bits);
  1000. break;
  1001. case 2:
  1002. return "";
  1003. }
  1004. dictionary[3] = c;
  1005. w = c;
  1006. result.push(c);
  1007. while (true) {
  1008. if (data.index > length) {
  1009. return "";
  1010. }
  1011. bits = 0;
  1012. maxpower = Math.pow(2, numBits);
  1013. power = 1;
  1014. while (power !== maxpower) {
  1015. resb = data.val & data.position;
  1016. data.position >>= 1;
  1017. if (data.position === 0) {
  1018. data.position = resetValue;
  1019. data.val = getNextValue(data.index++);
  1020. }
  1021. bits |= (resb > 0 ? 1 : 0) * power;
  1022. power <<= 1;
  1023. }
  1024. switch (c = bits) {
  1025. case 0:
  1026. bits = 0;
  1027. maxpower = Math.pow(2, 8);
  1028. power = 1;
  1029. while (power !== maxpower) {
  1030. resb = data.val & data.position;
  1031. data.position >>= 1;
  1032. if (data.position === 0) {
  1033. data.position = resetValue;
  1034. data.val = getNextValue(data.index++);
  1035. }
  1036. bits |= (resb > 0 ? 1 : 0) * power;
  1037. power <<= 1;
  1038. }
  1039. dictionary[dictSize++] = f(bits);
  1040. c = dictSize - 1;
  1041. enlargeIn--;
  1042. break;
  1043. case 1:
  1044. bits = 0;
  1045. maxpower = Math.pow(2, 16);
  1046. power = 1;
  1047. while (power !== maxpower) {
  1048. resb = data.val & data.position;
  1049. data.position >>= 1;
  1050. if (data.position === 0) {
  1051. data.position = resetValue;
  1052. data.val = getNextValue(data.index++);
  1053. }
  1054. bits |= (resb > 0 ? 1 : 0) * power;
  1055. power <<= 1;
  1056. }
  1057. dictionary[dictSize++] = f(bits);
  1058. c = dictSize - 1;
  1059. enlargeIn--;
  1060. break;
  1061. case 2:
  1062. return result.join('');
  1063. }
  1064. if (enlargeIn === 0) {
  1065. enlargeIn = Math.pow(2, numBits);
  1066. numBits++;
  1067. }
  1068. if (dictionary[c]) {
  1069. entry = dictionary[c];
  1070. } else {
  1071. if (c === dictSize) {
  1072. entry = w + w.charAt(0);
  1073. } else {
  1074. return null;
  1075. }
  1076. }
  1077. result.push(entry);
  1078. // Add w+entry[0] to the dictionary.
  1079. dictionary[dictSize++] = w + entry.charAt(0);
  1080. enlargeIn--;
  1081. w = entry;
  1082. if (enlargeIn === 0) {
  1083. enlargeIn = Math.pow(2, numBits);
  1084. numBits++;
  1085. }
  1086. }
  1087. }
  1088. };
  1089. return LZString;
  1090. })();
  1091. }
  1092. /* eslint-enable */
  1093. }