WME Utils - Google Link Enhancer

Adds some extra WME functionality related to Google place links.

当前为 2022-11-02 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/39208/1112338/WME%20Utils%20-%20Google%20Link%20Enhancer.js

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