diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccb2c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/SDKGoogleLinkEnhancer.js b/SDKGoogleLinkEnhancer.js new file mode 100644 index 0000000..85fdcc5 --- /dev/null +++ b/SDKGoogleLinkEnhancer.js @@ -0,0 +1,868 @@ +"use strict"; +// ==UserScript== +// @name WME Utils - Google Link Enhancer +// @namespace WazeDev +// @version 2025.06.13.001 +// @description Adds some extra WME functionality related to Google place links. +// @author MapOMatic, WazeDev group +// @include /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/ +// @license GNU GPLv3 +// ==/UserScript== +/* global OpenLayers */ +/* global W */ +/* global google */ +/* eslint-disable max-classes-per-file */ +// eslint-disable-next-line func-names +// import * as turf from "@turf/turf"; +// import type { Venue, WmeSDK, DataModelName} from "wme-sdk-typings"; +// import $ from "jquery"; +// import * as googlemaps from "@googlemaps/google-maps-services-js" +const SDKGoogleLinkEnhancer = (() => { + "use strict"; + var _a; + class GooglePlaceCache { + cache; + pendingPromises; + constructor() { + this.cache = new Map(); + this.pendingPromises = new Map(); + } + async getPlace(placeId) { + if (this.cache.has(placeId)) { + return this.cache.get(placeId); + } + if (!this.pendingPromises.has(placeId)) { + let resolveFn; + let rejectFn; + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + // Set a timeout to reject the promise if not resolved in 3 seconds + setTimeout(() => { + if (this.pendingPromises.has(placeId)) { + this.pendingPromises.delete(placeId); + rejectFn(new Error(`Timeout: Place ${placeId} not found within 3 seconds`)); + } + }, 3000); + }); + this.pendingPromises.set(placeId, { promise, resolve: resolveFn, reject: rejectFn }); + } + return this.pendingPromises.get(placeId).promise; + } + addPlace(placeId, properties) { + this.cache.set(placeId, properties); + if (this.pendingPromises.has(placeId)) { + this.pendingPromises.get(placeId).resolve(properties); + this.pendingPromises.delete(placeId); + } + } + } + class GLE { + #DISABLE_CLOSED_PLACES = false; // Set to TRUE if the feature needs to be temporarily disabled, e.g. during the COVID-19 pandemic. + #EXT_PROV_ELEM_QUERY = "wz-list-item.external-provider"; + #EXT_PROV_ELEM_EDIT_QUERY = "wz-list-item.external-provider-edit"; + #EXT_PROV_ELEM_CONTENT_QUERY = "div.external-provider-content"; + linkCache; + #enabled = false; + static #mapLayer = "Google Link Enhancements."; + #distanceLimit = 400; // Default distance (meters) when Waze place is flagged for being too far from Google place. + // Area place is calculated as #distanceLimit + + #showTempClosedPOIs = true; + // #originalHeadAppendChildMethod; + #ptFeature; + #lineFeature; + #timeoutID = -1; + strings = { + permClosedPlace: "Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.", + tempClosedPlace: "Google indicates this place is temporarily closed.", + multiLinked: "Linked more than once already. Please find and remove multiple links.", + linkedToThisPlace: "Already linked to this place", + linkedNearby: "Already linked to a nearby place", + linkedToXPlaces: "This is linked to {0} places", + badLink: "Invalid Google link. Please remove it.", + tooFar: "The Google linked place is more than {0} meters from the Waze place. Please verify the link is correct.", + }; + #styleConfig = { + styleContext: { + highNodeColor: (context) => { + return context?.feature?.properties?.style?.strokeColor; + }, + strokeColor: (context) => { + return context?.feature?.properties?.style?.strokeColor; + }, + strokeWidth: (context) => { + return context?.feature?.properties?.style?.strokeWidth; + }, + strokeDashStyle: (context) => { + return context?.feature?.properties?.style?.strokeDashstyle; + }, + label: (context) => { + return context?.feature?.properties?.style?.label; + }, + labelYOffset: (context) => { + return context?.feature?.properties?.style?.labelYOffset; + }, + fontColor: (context) => { + return context?.feature?.properties?.style?.fontColor; + }, + fontWeight: (context) => { + return context?.feature?.properties?.style?.fontWeight; + }, + labelOutlineColor: (context) => { + return context?.feature?.properties?.style?.labelOutlineColor; + }, + labelOutlineWidth: (context) => { + return context?.feature?.properties?.style?.labelOutlineWidth; + }, + fontSize: (context) => { + return context?.feature?.properties?.style?.fontSize; + }, + pointRadius: (context) => { + return context?.feature?.properties?.style?.pointRadius; + }, + }, + styleRules: [ + { + predicate: (properties) => { + return properties.styleName === "lineStyle"; + }, + style: { + strokeColor: "${strokeColor}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "default"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + strokeDashstyle: "${strokeDashstyle}", + pointRadius: 15, + fillOpacity: 0, + }, + }, + { + predicate: (properties) => { + return properties.styleName === "venueStyle"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "placeStyle"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + strokeDashStyle: "${strokeDashStyle}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "googlePlacePointStyle"; + }, + style: { + pointRadius: "${pointRadius}", + strokeWidth: "${strokeWidth}", + strokeColor: "${strokeColor}", + fillColor: "${fillColor}", + strokeOpacity: "${strokeOpacity}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "googlePlaceLineStyle"; + }, + style: { + strokeWidth: "${strokeWidth}", + strokeDashstyle: "${strokeDashStyle}", + strokeColor: "${strokeColor}", + label: "${label}", + labelYOffset: "${labelYOffset}", + fontColor: "${fontColor}", + fontWeight: "${fontWeight}", + labelOutlineColor: "${labelOutlineColor}", + labelOutlineWidth: "${labelOutlineWidth}", + fontSize: "${fontSize}", + } + } + ], + }; + sdk; + trf; + /* eslint-enable no-unused-vars */ + constructor(sdk, trf) { + let msg = ""; + if (!sdk) { + msg += "SDK Must be defined to use GLE"; + } + if (!trf) { + msg += "\n"; + msg += "Turf Library Must be made available to GLE to Implement Some of the Functionality"; + } + this.sdk = sdk; + this.trf = trf; + this.linkCache = new GooglePlaceCache(); + this.#initLayer(); + // NOTE: Arrow functions are necessary for calling methods on object instances. + // This could be made more efficient by only processing the relevant places. + // W.model.events.register("mergeend", null, () => { + // this.#processPlaces(); + // }); + this.sdk.Events.on({ + eventName: "wme-map-data-loaded", + eventHandler: () => { + this.#processPlaces(); + } + }); + // W.model.venues.on('objectschanged', () => { this.#processPlaces(); }); + // W.model.venues.on('objectsremoved', () => { this.#processPlaces(); }); + // W.model.venues.on('objectsadded', () => { this.#processPlaces(); }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-added", + eventHandler: (payload) => { + if (payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + } + }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-removed", + eventHandler: (payload) => { + if (payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + }, + }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: (payload) => { + if (payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + } + }); + // This is a special event that will be triggered when DOM elements are destroyed. + /* eslint-disable wrap-iife, func-names, object-shorthand */ + (($) => { + $.event.special.destroyed = { + remove: (o) => { + if (o.handler && o.type !== "destroyed") { + o.handler(); + } + }, + }; + })(jQuery); + /* eslint-enable wrap-iife, func-names, object-shorthand */ + // In case a place is already selected on load. + /** + * @type Selection + */ + const currentSelection = this.sdk.Editing.getSelection(); + // const selObjects = W.selectionManager.getSelectedDataModelObjects(); + if (currentSelection?.ids?.length && currentSelection.objectType === "venue") { + this.#formatLinkElements(); + } + this.sdk.Events.on({ + eventName: "wme-selection-changed", + eventHandler: this.#onWmeSelectionChanged.bind(this), + }); + // W.selectionManager.events.register('selectionchanged', null, this.#onWmeSelectionChanged.bind(this)); + } + #initLayer() { + // this.#mapLayer = new OpenLayers.Layer.Vector('Google Link Enhancements.', { + // uniqueName: '___GoogleLinkEnhancements', + // displayInLayerSwitcher: true, + // styleMap: new OpenLayers.StyleMap({ + // default: { + // strokeColor: '${strokeColor}', + // strokeWidth: '${strokeWidth}', + // strokeDashstyle: '${strokeDashstyle}', + // pointRadius: '15', + // fillOpacity: '0' + // } + // }) + // }); + // this.#mapLayer.setOpacity(0.8); + // W.map.addLayer(this.#mapLayer); + this.sdk.Map.addLayer({ + layerName: _a.#mapLayer, + styleContext: this.#styleConfig.styleContext, + styleRules: this.#styleConfig.styleRules, + }); + this.sdk.Map.setLayerOpacity({ layerName: _a.#mapLayer, opacity: 0.8 }); + } + #onWmeSelectionChanged() { + if (this.#enabled) { + this.#destroyPoint(); + // const selected = W.selectionManager.getSelectedDataModelObjects(); + const selected = this.sdk.Editing.getSelection(); + if (selected?.objectType === "venue") { + // The setTimeout is necessary (in beta WME currently, at least) to allow the + // panel UI DOM to update after a place is selected. + setTimeout(() => this.#formatLinkElements(), 0); + } + } + } + enable() { + if (!this.#enabled) { + this.#interceptPlacesService(); + // Note: Using on() allows passing "this" as a variable, so it can be used in the handler function. + $("#map").on("mouseenter", null, this, _a.#onMapMouseenter); + // W.model.venues.on('objectschanged', this.#formatLinkElements, this); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: (payload) => { + if (payload.dataModelName === "venues") { + this.#formatLinkElements.bind(this); + } + }, + }); + this.#processPlaces(); + this.#enabled = true; + } + } + disable() { + if (this.#enabled) { + $("#map").off("mouseenter", _a.#onMapMouseenter); + // W.model.venues.off('objectschanged', this.#formatLinkElements, this); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: ({}) => { + this.#formatLinkElements.bind(this); + }, + }); + this.#enabled = false; + } + } + // The distance (in meters) before flagging a Waze place that is too far from the linked Google place. + // Area places use distanceLimit, plus the distance from the centroid of the AP to its furthest node. + get distanceLimit() { + return this.#distanceLimit; + } + set distanceLimit(value) { + this.#distanceLimit = value; + } + get showTempClosedPOIs() { + return this.#showTempClosedPOIs; + } + set showTempClosedPOIs(value) { + this.#showTempClosedPOIs = value; + } + // Borrowed from WazeWrap + #distanceBetweenPoints(point1, point2) { + // const line = new OpenLayers.Geometry.LineString([point1, point2]); + // const length = line.getGeodesicLength(W.map.getProjectionObject()); + const ls = this.trf.lineString([point1, point2]); + const length = this.trf.length(ls); + return length * 1000; // multiply by 3.28084 to convert to feet + } + static isPointVenue(venue) { + return venue.geometry.type === "Point"; + } + #isLinkTooFar(link, venue) { + if (link.loc) { + // const linkPt = new OpenLayers.Geometry.Point(link.loc.lng, link.loc.lat); + const linkPt = this.trf.point([link.loc.lng, link.loc.lat]); + // linkPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject()); + let venuePt; + let distanceLim = this.distanceLimit; + // if (venue.isPoint()) { + if (venue.geometry.type === "Point") { + venuePt = venue.geometry; + } + else { + // const bounds = venue.geometry.getBounds(); + // const center = bounds.getCenterLonLat(); + const center = this.trf.centroid(venue.geometry); + // venuePt = new OpenLayers.Geometry.Point(center.lon, center.lat); + // const topRightPt = new OpenLayers.Geometry.Point(bounds.right, bounds.top); + venuePt = center.geometry; + let bbox = venue.geometry.bbox; + if (!bbox) { + bbox = this.trf.bbox(venue.geometry); + } + const topRightPt = this.trf.point([bbox[0], bbox[1]]); + distanceLim += this.#distanceBetweenPoints(venuePt.coordinates, topRightPt.geometry.coordinates); + } + const distance = this.#distanceBetweenPoints(linkPt.geometry.coordinates, venuePt.coordinates); + return distance > distanceLim; + } + return false; + } + #processPlaces(objectIds = undefined) { + if (this.#enabled) { + try { + // Get a list of already-linked id's + const existingLinks = SDKGoogleLinkEnhancer.#getExistingLinks(this.sdk); + this.sdk.Map.removeAllFeaturesFromLayer({ layerName: _a.#mapLayer }); + const drawnLinks = []; + if (!objectIds) { + objectIds = []; + for (const venue of this.sdk.DataModel.Venues.getAll()) { + objectIds.push(venue.id); + } + } + for (const objId of objectIds) { + // W.model.venues.getObjectArray().forEach(venue => { + const promises = []; + const venue = this.sdk.DataModel.Venues.getById({ venueId: objId.toString() }); + if (venue === null) + continue; + // venue.attributes.externalProviderIDs.forEach(provID => { + for (const provID of venue.externalProviderIds) { + const id = provID; + // Check for duplicate links + const linkInfo = existingLinks[id]; + if (linkInfo?.count > 1) { + // const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone(); + const geometry = venue.geometry; + // const width = venue.isPoint() ? '4' : '12'; + const width = _a.isPointVenue(venue) ? 4 : 12; + const color = "#fb8d00"; + // const features = [new OpenLayers.Feature.Vector(geometry, { + // strokeWidth: width, strokeColor: color + // })]; + const features = [ + geometry.type === "Point" + ? this.trf.point(geometry.coordinates, { + styleName: "venueStyle", + style: { + strokeWidth: width, + strokeColor: color, + }, + }, { id: `venue_${geometry.toString()}` }) + : this.trf.polygon(geometry.coordinates, { + styleName: "venueStyle", + style: { + strokeColor: color, + strokeWidth: width, + }, + }, { id: `polyvenue_${geometry.toString()}` }), + ]; + const lineStart = this.trf.centroid(geometry); + // linkInfo.venues.forEach(linkVenue => { + for (const linkVenue of linkInfo.venues) { + if (linkVenue !== venue && + !drawnLinks.some((dl) => (dl[0] === venue && dl[1] === linkVenue) || + (dl[0] === linkVenue && dl[1] === venue))) { + const endPoint = this.trf.centroid(linkVenue.geometry); + features.push( + // new OpenLayers.Feature.Vector( + // new OpenLayers.Geometry.LineString([lineStart, linkVenue.geometry.getCentroid()]), + // { + // strokeWidth: 4, + // strokeColor: color, + // strokeDashstyle: '12 12' + // } + // ) + this.trf.lineString([lineStart.geometry.coordinates, endPoint.geometry.coordinates], { + styleName: "lineStyle", + style: { + strokeWidth: 4, + strokeColor: color, + strokeDashstyle: "12 12", + }, + }, { id: `ls_${lineStart.geometry.toString()}_${endPoint.geometry.toString()}` })); + drawnLinks.push([venue, linkVenue]); + } + } + this.sdk.Map.addFeaturesToLayer({ features: features, layerName: _a.#mapLayer }); + // this.#mapLayer.addFeatures(features); + } + } + // Process all results of link lookups and add a highlight feature if needed. + Promise.all(promises).then((results) => { + let strokeColor = null; + let strokeDashStyle = "solid"; + if (!this.#DISABLE_CLOSED_PLACES && results.some((res) => res.permclosed)) { + if (/^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.name) || + /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.name)) { + strokeDashStyle = _a.isPointVenue(venue) ? "2 6" : "2 16"; + } + strokeColor = "#F00"; + } + else if (results.some((res) => this.#isLinkTooFar(res, venue))) { + strokeColor = "#0FF"; + } + else if (!this.#DISABLE_CLOSED_PLACES && + this.#showTempClosedPOIs && + results.some((res) => res.tempclosed)) { + if (/^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.name) || + /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.name)) { + strokeDashStyle = _a.isPointVenue(venue) ? "2 6" : "2 16"; + } + strokeColor = "#FD3"; + } + else if (results.some((res) => res.notFound)) { + strokeColor = "#F0F"; + } + if (strokeColor) { + const style = { + strokeWidth: _a.isPointVenue(venue) ? 4 : 12, + strokeColor, + strokeDashStyle, + }; + // const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone(); + const feature = _a.isPointVenue(venue) + ? this.trf.point(venue.geometry.coordinates, { + styleName: "placeStyle", + style: style, + }) + : this.trf.polygon(venue.geometry.coordinates, { + styleName: "placeStyle", + style: style, + }); + // this.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]); + } + }); + } + } + catch (ex) { + console.error("PIE (Google Link Enhancer) error:", ex); + } + } + } + static #onMapMouseenter(event) { + // If the point isn't destroyed yet, destroy it when mousing over the map. + event.data.#destroyPoint(); + } + async #formatLinkElements() { + const $links = $("#edit-panel").find(this.#EXT_PROV_ELEM_QUERY); + if ($links.length) { + const existingLinks = _a.#getExistingLinks(this.sdk); + // fetch all links first + const promises = []; + const extProvElements = []; + $links.each((ix, linkEl) => { + const $linkEl = $(linkEl); + extProvElements.push($linkEl); + const id = _a.#getIdFromElement($linkEl); + promises.push(this.linkCache.getPlace(id)); + }); + const links = await Promise.all(promises); + extProvElements.forEach(($extProvElem, i) => { + const id = _a.#getIdFromElement($extProvElem); + if (!id) + return; + const link = links[i]; + if (existingLinks[id] && existingLinks[id].count > 1 && existingLinks[id].isThisVenue) { + setTimeout(() => { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FFA500" }) + .attr({ + title: this.strings.linkedToXPlaces.replace("{0}", existingLinks[id].count), + }); + }, 50); + } + this.#addHoverEvent($extProvElem); + if (link) { + if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FAA" }) + .attr("title", this.strings.permClosedPlace); + } + else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FFA" }) + .attr("title", this.strings.tempClosedPlace); + } + else if (link.notFound) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#F0F" }) + .attr("title", this.strings.badLink); + } + else { + // const venue = W.selectionManager.getSelectedDataModelObjects()[0]; + const selection = this.sdk.Editing.getSelection(); + if (selection?.objectType === "venue") { + const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] }); + if (venue && this.#isLinkTooFar(link, venue)) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#0FF" }) + .attr("title", this.strings.tooFar.replace("{0}", this.distanceLimit.toString())); + } + else { + // reset in case we just deleted another provider + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "" }) + .attr("title", ""); + } + } + } + } + }); + } + } + static #getExistingLinks(sdk = undefined) { + if (!sdk) { + const msg = "SDK Is Not Available"; + console.error(msg); + throw new Error(msg); + } + const existingLinks = {}; + // const thisVenue = W.selectionManager.getSelectedDataModelObjects()[0]; + const thisVenue = sdk.Editing.getSelection(); + if (thisVenue?.objectType !== "venue") + return {}; + // W.model.venues.getObjectArray().forEach(venue => { + for (const venue of sdk.DataModel.Venues.getAll()) { + const isThisVenue = venue.id === thisVenue.ids[0]; + const thisPlaceIDs = []; + // venue.attributes.externalProviderIDs.forEach(provID => { + for (const provID of venue.externalProviderIds) { + // const id = provID.attributes.uuid; + const id = provID; + if (!thisPlaceIDs.includes(id)) { + thisPlaceIDs.push(id); + let link = existingLinks[id]; + if (link) { + link.count++; + link.venues.push(venue); + } + else { + link = { count: 1, venues: [venue] }; + existingLinks[id] = link; + // if (provID.attributes.url != null) { + // const u = provID.attributes.url.replace('https://maps.google.com/?', ''); + // link.url = u; + // } + } + link.isThisVenue = link.isThisVenue || isThisVenue; + } + } + } + return existingLinks; + } + // Remove the POI point from the map. + #destroyPoint() { + if (this.#ptFeature) { + this.sdk.Map.removeFeaturesFromLayer({ featureIds: [this.#ptFeature.id, this.#lineFeature.id], layerName: _a.#mapLayer }); + // this.#ptFeature.destroy(); + this.#ptFeature = null; + // this.#lineFeature.destroy(); + this.#lineFeature = null; + } + } + #getOLMapExtent() { + // let extent = W.map.getExtent(); + // if (Array.isArray(extent)) { + // extent = new OpenLayers.Bounds(extent); + // extent.transform('EPSG:4326', 'EPSG:3857'); + // } + return this.sdk.Map.getMapExtent(); + } + // Add the POI point to the map. + async #addPoint(id) { + if (!id) + return; + const link = await this.linkCache.getPlace(id); + if (link) { + if (!link.notFound) { + const coord = link.loc; + // const poiPt = new OpenLayers.Geometry.Point(coord.lng, coord.lat); + const poiPt = this.trf.point([coord.lng, coord.lat]); + // poiPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject().projCode); + // const placeGeom = W.selectionManager.getSelectedDataModelObjects()[0].geometry.getCentroid(); + const selection = this.sdk.Editing.getSelection(); + let placeGeom; + if (selection?.objectType === "venue") { + const v = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] }); + placeGeom = v?.geometry && this.trf.centroid(v?.geometry)?.geometry; + } + else { + return; + } + // const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y); + const placePt = this.trf.point(placeGeom.coordinates); + const ext = this.#getOLMapExtent(); + // const lsBounds = new OpenLayers.Geometry.LineString([ + // new OpenLayers.Geometry.Point(ext.left, ext.bottom), + // new OpenLayers.Geometry.Point(ext.left, ext.top), + // new OpenLayers.Geometry.Point(ext.right, ext.top), + // new OpenLayers.Geometry.Point(ext.right, ext.bottom), + // new OpenLayers.Geometry.Point(ext.left, ext.bottom)]); + const lsBounds = this.trf.lineString([ + [ext[0], ext[3]], + [ext[0], ext[1]], + [ext[2], ext[1]], + [ext[2], ext[3]], + [ext[0], ext[3]], + ]); + // let lsLine = new OpenLayers.Geometry.LineString([placePt, poiPt]); + let lsLine = this.trf.lineString([placePt.geometry.coordinates, poiPt.geometry.coordinates]); + // If the line extends outside the bounds, split it so we don't draw a line across the world. + const splits = this.trf.lineSplit(lsLine, lsBounds); + let label = ""; + if (splits) { + for (const split of splits.features) { + for (const component of split.geometry.coordinates) { + if (component[0] === placePt.geometry.coordinates[0] && + component[1] === placePt.geometry.coordinates[1]) + lsLine = split; + } + } + let distance = this.#distanceBetweenPoints(poiPt.geometry.coordinates, placePt.geometry.coordinates); + let unitConversion; + let unit1; + let unit2; + // if (W.model.isImperial) { + if (this.sdk.Settings.getUserSettings().isImperial) { + distance *= 3.28084; + unitConversion = 5280; + unit1 = " ft"; + unit2 = " mi"; + } + else { + unitConversion = 1000; + unit1 = " m"; + unit2 = " km"; + } + if (distance > unitConversion * 10) { + label = Math.round(distance / unitConversion) + unit2; + } + else if (distance > 1000) { + label = Math.round(distance / (unitConversion / 10)) / 10 + unit2; + } + else { + label = Math.round(distance) + unit1; + } + } + this.#destroyPoint(); // Just in case it still exists. + // this.#ptFeature = new OpenLayers.Feature.Vector( + // poiPt, + // { poiCoord: true }, + // { + // pointRadius: 6, + // strokeWidth: 30, + // strokeColor: "#FF0", + // fillColor: "#FF0", + // strokeOpacity: 0.5, + // } + // ); + this.#ptFeature = this.trf.point(poiPt.geometry.coordinates, { + styleName: "googlePlacePointStyle", + style: { + pointRadius: 6, + strokeWidth: 30, + strokeColor: "#FF0", + fillColor: "#FF0", + strokeOpacity: 0.5, + }, + }, { id: `PoiPT_${poiPt.toString()}` }); + // this.#lineFeature = new OpenLayers.Feature.Vector( + // lsLine, + // {}, + // { + // strokeWidth: 3, + // strokeDashstyle: "12 8", + // strokeColor: "#FF0", + // label, + // labelYOffset: 45, + // fontColor: "#FF0", + // fontWeight: "bold", + // labelOutlineColor: "#000", + // labelOutlineWidth: 4, + // fontSize: "18", + // } + // ); + this.#lineFeature = this.trf.lineString(lsLine.geometry.coordinates, { + styleName: "googlePlaceLineStyle", + style: { + strokeWidth: 3, + strokeDashstyle: "12 8", + strokeColor: "#FF0", + label, + labelYOffset: 45, + fontColor: "#FF0", + fontWeight: "bold", + labelOutlineColor: "#000", + labelOutlineWidth: 4, + fontSize: "18", + }, + }, { id: `LsLine_${lsLine.toString()}` }); + // W.map.getLayerByUniqueName("venues").addFeatures([this.#ptFeature, this.#lineFeature]); + this.sdk.Map.addFeaturesToLayer({ features: [this.#ptFeature, this.#lineFeature], layerName: _a.#mapLayer }); + this.#timeoutDestroyPoint(); + } + } + else { + // this.#getLinkInfoAsync(id).then(res => { + // if (res.error || res.apiDisabled) { + // // API was temporarily disabled. Ignore for now. + // } else { + // this.#addPoint(id); + // } + // }); + } + } + // Destroy the point after some time, if it hasn't been destroyed already. + #timeoutDestroyPoint() { + if (this.#timeoutID > 0) + clearTimeout(this.#timeoutID); + this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000); + } + static #getIdFromElement($el) { + const providerIndex = $el.parent().children().toArray().indexOf($el[0]); + return W.selectionManager.getSelectedDataModelObjects()[0].getExternalProviderIDs()[providerIndex] + ?.attributes.uuid; + } + #addHoverEvent($el) { + $el.hover(() => this.#addPoint(_a.#getIdFromElement($el)), () => this.#destroyPoint()); + } + #interceptPlacesService() { + if (typeof google === "undefined" || + !google.maps || + !google.maps.places || + !google.maps.places.PlacesService) { + console.debug("Google Maps PlacesService not loaded yet."); + setTimeout(this.#interceptPlacesService.bind(this), 500); // Retry until it loads + return; + } + const originalGetDetails = google.maps.places.PlacesService.prototype.getDetails; + const that = this; + google.maps.places.PlacesService.prototype.getDetails = function interceptedGetDetails(request, callback) { + console.debug("Intercepted getDetails call:", request); + const customCallback = (result, status) => { + console.debug("Intercepted getDetails response:", result, status); + const link = {}; + switch (status) { + case google.maps.places.PlacesServiceStatus.OK: { + const loc = result.geometry.location; + link.loc = { lng: loc.lng(), lat: loc.lat() }; + if (result.business_status === google.maps.places.BusinessStatus.CLOSED_PERMANENTLY) { + link.permclosed = true; + } + else if (result.business_status === google.maps.places.BusinessStatus.CLOSED_TEMPORARILY) { + link.tempclosed = true; + } + that.linkCache.addPlace(request.placeId, link); + break; + } + case google.maps.places.PlacesServiceStatus.NOT_FOUND: + link.notfound = true; + that.linkCache.addPlace(request.placeId, link); + break; + default: + link.error = status; + } + callback(result, status); // Pass the result to the original callback + }; + return originalGetDetails.call(this, request, customCallback); + }; + console.debug("Google Maps PlacesService.getDetails intercepted successfully."); + } + } + _a = GLE; + return GLE; +})(); diff --git a/SDKGoogleLinkEnhancer.ts b/SDKGoogleLinkEnhancer.ts new file mode 100644 index 0000000..311e66e --- /dev/null +++ b/SDKGoogleLinkEnhancer.ts @@ -0,0 +1,943 @@ +// ==UserScript== +// @name WME Utils - Google Link Enhancer +// @namespace WazeDev +// @version 2025.06.13.001 +// @description Adds some extra WME functionality related to Google place links. +// @author MapOMatic, WazeDev group +// @include /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/ +// @license GNU GPLv3 +// ==/UserScript== + +/* global OpenLayers */ +/* global W */ +/* global google */ + +/* eslint-disable max-classes-per-file */ + +// eslint-disable-next-line func-names +// import * as turf from "@turf/turf"; +// import type { Venue, WmeSDK, DataModelName} from "wme-sdk-typings"; +// import $ from "jquery"; +// import * as googlemaps from "@googlemaps/google-maps-services-js" + +const SDKGoogleLinkEnhancer = (() => { + "use strict"; + + class GooglePlaceCache { + cache: Map; + pendingPromises: Map; + constructor() { + this.cache = new Map(); + this.pendingPromises = new Map(); + } + + async getPlace(placeId) { + if (this.cache.has(placeId)) { + return this.cache.get(placeId); + } + + if (!this.pendingPromises.has(placeId)) { + let resolveFn; + let rejectFn; + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + + // Set a timeout to reject the promise if not resolved in 3 seconds + setTimeout(() => { + if (this.pendingPromises.has(placeId)) { + this.pendingPromises.delete(placeId); + rejectFn(new Error(`Timeout: Place ${placeId} not found within 3 seconds`)); + } + }, 3000); + }); + + this.pendingPromises.set(placeId, { promise, resolve: resolveFn, reject: rejectFn }); + } + + return this.pendingPromises.get(placeId).promise; + } + + addPlace(placeId, properties) { + this.cache.set(placeId, properties); + + if (this.pendingPromises.has(placeId)) { + this.pendingPromises.get(placeId).resolve(properties); + this.pendingPromises.delete(placeId); + } + } + } + + interface LinkInfo { + isThisVenue: boolean; + count: number; + venues: Venue[]; + } + + class GLE { + #DISABLE_CLOSED_PLACES = false; // Set to TRUE if the feature needs to be temporarily disabled, e.g. during the COVID-19 pandemic. + #EXT_PROV_ELEM_QUERY = "wz-list-item.external-provider"; + #EXT_PROV_ELEM_EDIT_QUERY = "wz-list-item.external-provider-edit"; + #EXT_PROV_ELEM_CONTENT_QUERY = "div.external-provider-content"; + + linkCache; + #enabled = false; + static #mapLayer = "Google Link Enhancements."; + #distanceLimit = 400; // Default distance (meters) when Waze place is flagged for being too far from Google place. + // Area place is calculated as #distanceLimit + + #showTempClosedPOIs = true; + // #originalHeadAppendChildMethod; + #ptFeature: GeoJSON.Feature | undefined; + #lineFeature: GeoJSON.Feature | undefined; + #timeoutID: number = -1; + strings = { + permClosedPlace: + "Google indicates this place is permanently closed.\nVerify with other sources or your editor community before deleting.", + tempClosedPlace: "Google indicates this place is temporarily closed.", + multiLinked: "Linked more than once already. Please find and remove multiple links.", + linkedToThisPlace: "Already linked to this place", + linkedNearby: "Already linked to a nearby place", + linkedToXPlaces: "This is linked to {0} places", + badLink: "Invalid Google link. Please remove it.", + tooFar: "The Google linked place is more than {0} meters from the Waze place. Please verify the link is correct.", + }; + + #styleConfig = { + styleContext: { + highNodeColor: (context) => { + return context?.feature?.properties?.style?.strokeColor; + }, + strokeColor: (context) => { + return context?.feature?.properties?.style?.strokeColor; + }, + strokeWidth: (context) => { + return context?.feature?.properties?.style?.strokeWidth; + }, + strokeDashStyle: (context) => { + return context?.feature?.properties?.style?.strokeDashstyle; + }, + label: (context) => { + return context?.feature?.properties?.style?.label; + }, + labelYOffset: (context) => { + return context?.feature?.properties?.style?.labelYOffset; + }, + fontColor: (context) => { + return context?.feature?.properties?.style?.fontColor; + }, + fontWeight: (context) => { + return context?.feature?.properties?.style?.fontWeight; + }, + labelOutlineColor: (context) => { + return context?.feature?.properties?.style?.labelOutlineColor; + }, + labelOutlineWidth: (context) => { + return context?.feature?.properties?.style?.labelOutlineWidth; + }, + fontSize: (context) => { + return context?.feature?.properties?.style?.fontSize; + }, + pointRadius: (context) => { + return context?.feature?.properties?.style?.pointRadius; + }, + }, + styleRules: [ + { + predicate: (properties) => { + return properties.styleName === "lineStyle"; + }, + style: { + strokeColor: "${strokeColor}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "default"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + strokeDashstyle: "${strokeDashstyle}", + pointRadius: 15, + fillOpacity: 0, + }, + }, + { + predicate: (properties) => { + return properties.styleName === "venueStyle"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "placeStyle"; + }, + style: { + strokeColor: "${strokeColor}", + strokeWidth: "${strokeWidth}", + strokeDashStyle: "${strokeDashStyle}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "googlePlacePointStyle"; + }, + style: { + pointRadius: "${pointRadius}", + strokeWidth: "${strokeWidth}", + strokeColor: "${strokeColor}", + fillColor: "${fillColor}", + strokeOpacity: "${strokeOpacity}", + }, + }, + { + predicate: (properties) => { + return properties.styleName === "googlePlaceLineStyle"; + }, + style: { + strokeWidth: "${strokeWidth}", + strokeDashstyle: "${strokeDashStyle}", + strokeColor: "${strokeColor}", + label: "${label}", + labelYOffset: "${labelYOffset}", + fontColor: "${fontColor}", + fontWeight: "${fontWeight}", + labelOutlineColor: "${labelOutlineColor}", + labelOutlineWidth: "${labelOutlineWidth}", + fontSize: "${fontSize}", + } + } + ], + }; + sdk: WmeSDK; + trf: typeof turf; + + /* eslint-enable no-unused-vars */ + constructor(sdk: WmeSDK, trf: typeof turf) { + let msg = ""; + if (!sdk) { + msg += "SDK Must be defined to use GLE"; + } + if (!trf) { + msg += "\n"; + msg += "Turf Library Must be made available to GLE to Implement Some of the Functionality"; + } + this.sdk = sdk; + this.trf = trf; + this.linkCache = new GooglePlaceCache(); + this.#initLayer(); + + // NOTE: Arrow functions are necessary for calling methods on object instances. + // This could be made more efficient by only processing the relevant places. + // W.model.events.register("mergeend", null, () => { + // this.#processPlaces(); + // }); + this.sdk.Events.on({ + eventName: "wme-map-data-loaded", + eventHandler: () => { + this.#processPlaces(); + } + }); + // W.model.venues.on('objectschanged', () => { this.#processPlaces(); }); + // W.model.venues.on('objectsremoved', () => { this.#processPlaces(); }); + // W.model.venues.on('objectsadded', () => { this.#processPlaces(); }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-added", + eventHandler: (payload: {dataModelName: DataModelName, objectIds: Array}) => { + if(payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + } + }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-removed", + eventHandler: (payload: {dataModelName: DataModelName, objectIds: Array}) => { + if(payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + }, + }); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: (payload: {dataModelName: DataModelName, objectIds: Array}) => { + if(payload.dataModelName === "venues") { + this.#processPlaces(payload.objectIds); + } + } + }); + + // This is a special event that will be triggered when DOM elements are destroyed. + /* eslint-disable wrap-iife, func-names, object-shorthand */ + (($: JQueryStatic) => { + $.event.special.destroyed = { + remove: (o ) => { + if (o.handler && o.type !== "destroyed") { + o.handler(); + } + }, + }; + })(jQuery); + /* eslint-enable wrap-iife, func-names, object-shorthand */ + + // In case a place is already selected on load. + /** + * @type Selection + */ + const currentSelection = this.sdk.Editing.getSelection(); + // const selObjects = W.selectionManager.getSelectedDataModelObjects(); + if (currentSelection?.ids?.length && currentSelection.objectType === "venue") { + this.#formatLinkElements(); + } + + this.sdk.Events.on({ + eventName: "wme-selection-changed", + eventHandler: this.#onWmeSelectionChanged.bind(this), + }); + // W.selectionManager.events.register('selectionchanged', null, this.#onWmeSelectionChanged.bind(this)); + } + + #initLayer() { + // this.#mapLayer = new OpenLayers.Layer.Vector('Google Link Enhancements.', { + // uniqueName: '___GoogleLinkEnhancements', + // displayInLayerSwitcher: true, + // styleMap: new OpenLayers.StyleMap({ + // default: { + // strokeColor: '${strokeColor}', + // strokeWidth: '${strokeWidth}', + // strokeDashstyle: '${strokeDashstyle}', + // pointRadius: '15', + // fillOpacity: '0' + // } + // }) + // }); + + // this.#mapLayer.setOpacity(0.8); + // W.map.addLayer(this.#mapLayer); + this.sdk.Map.addLayer({ + layerName: GLE.#mapLayer, + styleContext: this.#styleConfig.styleContext, + styleRules: this.#styleConfig.styleRules, + }); + this.sdk.Map.setLayerOpacity({ layerName: GLE.#mapLayer, opacity: 0.8 }); + } + + #onWmeSelectionChanged() { + if (this.#enabled) { + this.#destroyPoint(); + // const selected = W.selectionManager.getSelectedDataModelObjects(); + const selected = this.sdk.Editing.getSelection(); + if (selected?.objectType === "venue") { + // The setTimeout is necessary (in beta WME currently, at least) to allow the + // panel UI DOM to update after a place is selected. + setTimeout(() => this.#formatLinkElements(), 0); + } + } + } + + enable() { + if (!this.#enabled) { + this.#interceptPlacesService(); + // Note: Using on() allows passing "this" as a variable, so it can be used in the handler function. + $("#map").on("mouseenter", null, this, GLE.#onMapMouseenter); + // W.model.venues.on('objectschanged', this.#formatLinkElements, this); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: (payload: {dataModelName: DataModelName, objectIds: Array}) => { + if(payload.dataModelName === "venues") { + this.#formatLinkElements.bind(this); + } + }, + }); + this.#processPlaces(); + this.#enabled = true; + } + } + + disable() { + if (this.#enabled) { + $("#map").off("mouseenter", GLE.#onMapMouseenter); + // W.model.venues.off('objectschanged', this.#formatLinkElements, this); + this.sdk.Events.on({ + eventName: "wme-data-model-objects-changed", + eventHandler: ({}) => { + this.#formatLinkElements.bind(this); + }, + }); + + this.#enabled = false; + } + } + + // The distance (in meters) before flagging a Waze place that is too far from the linked Google place. + // Area places use distanceLimit, plus the distance from the centroid of the AP to its furthest node. + get distanceLimit(): number { + return this.#distanceLimit; + } + + set distanceLimit(value) { + this.#distanceLimit = value; + } + + get showTempClosedPOIs() { + return this.#showTempClosedPOIs; + } + + set showTempClosedPOIs(value) { + this.#showTempClosedPOIs = value; + } + + // Borrowed from WazeWrap + #distanceBetweenPoints(point1: GeoJSON.Position, point2: GeoJSON.Position) { + // const line = new OpenLayers.Geometry.LineString([point1, point2]); + // const length = line.getGeodesicLength(W.map.getProjectionObject()); + const ls = this.trf.lineString([point1, point2]); + const length = this.trf.length(ls); + return length * 1000; // multiply by 3.28084 to convert to feet + } + static isPointVenue(venue: Venue) { + return venue.geometry.type === "Point"; + } + #isLinkTooFar(link, venue: Venue) { + if (link.loc) { + // const linkPt = new OpenLayers.Geometry.Point(link.loc.lng, link.loc.lat); + const linkPt = this.trf.point([link.loc.lng, link.loc.lat]); + // linkPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject()); + let venuePt: GeoJSON.Point; + let distanceLim = this.distanceLimit; + // if (venue.isPoint()) { + if (venue.geometry.type === "Point") { + venuePt = venue.geometry; + } else { + // const bounds = venue.geometry.getBounds(); + // const center = bounds.getCenterLonLat(); + const center = this.trf.centroid(venue.geometry); + // venuePt = new OpenLayers.Geometry.Point(center.lon, center.lat); + // const topRightPt = new OpenLayers.Geometry.Point(bounds.right, bounds.top); + venuePt = center.geometry; + let bbox = venue.geometry.bbox; + if (!bbox) { + bbox = this.trf.bbox(venue.geometry); + } + const topRightPt = this.trf.point([bbox[0], bbox[1]]); + distanceLim += this.#distanceBetweenPoints(venuePt.coordinates, topRightPt.geometry.coordinates); + } + const distance = this.#distanceBetweenPoints(linkPt.geometry.coordinates, venuePt.coordinates); + return distance > distanceLim; + } + return false; + } + + #processPlaces(objectIds: Array | undefined = undefined) { + if (this.#enabled) { + try { + // Get a list of already-linked id's + const existingLinks = SDKGoogleLinkEnhancer.#getExistingLinks(this.sdk); + this.sdk.Map.removeAllFeaturesFromLayer({ layerName: GLE.#mapLayer }); + const drawnLinks: Venue[][] = []; + if(!objectIds) { + objectIds = []; + for(const venue of this.sdk.DataModel.Venues.getAll()) { + objectIds.push(venue.id); + } + } + for (const objId of objectIds) { + // W.model.venues.getObjectArray().forEach(venue => { + const promises = []; + const venue = this.sdk.DataModel.Venues.getById({venueId: objId.toString()}); + if(venue === null) continue; + // venue.attributes.externalProviderIDs.forEach(provID => { + for (const provID of venue.externalProviderIds) { + const id = provID; + + // Check for duplicate links + const linkInfo = existingLinks[id]; + if (linkInfo?.count > 1) { + // const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone(); + const geometry: GeoJSON.Point | GeoJSON.Polygon = venue.geometry; + // const width = venue.isPoint() ? '4' : '12'; + const width = GLE.isPointVenue(venue) ? 4 : 12; + const color = "#fb8d00"; + // const features = [new OpenLayers.Feature.Vector(geometry, { + // strokeWidth: width, strokeColor: color + // })]; + const features: GeoJSON.Feature[] = [ + geometry.type === "Point" + ? this.trf.point(geometry.coordinates, { + styleName: "venueStyle", + style: { + strokeWidth: width, + strokeColor: color, + }, + }, {id: `venue_${geometry.toString()}`}) + : this.trf.polygon(geometry.coordinates, { + styleName: "venueStyle", + style: { + strokeColor: color, + strokeWidth: width, + }, + }, {id: `polyvenue_${geometry.toString()}`}), + ]; + const lineStart = this.trf.centroid(geometry); + // linkInfo.venues.forEach(linkVenue => { + for (const linkVenue of linkInfo.venues) { + if ( + linkVenue !== venue && + !drawnLinks.some( + (dl) => + (dl[0] === venue && dl[1] === linkVenue) || + (dl[0] === linkVenue && dl[1] === venue) + ) + ) { + const endPoint = this.trf.centroid(linkVenue.geometry); + features.push( + // new OpenLayers.Feature.Vector( + // new OpenLayers.Geometry.LineString([lineStart, linkVenue.geometry.getCentroid()]), + // { + // strokeWidth: 4, + // strokeColor: color, + // strokeDashstyle: '12 12' + // } + // ) + this.trf.lineString( + [lineStart.geometry.coordinates, endPoint.geometry.coordinates], + { + styleName: "lineStyle", + style: { + strokeWidth: 4, + strokeColor: color, + strokeDashstyle: "12 12", + }, + }, + { id: `ls_${lineStart.geometry.toString()}_${endPoint.geometry.toString()}`} + ) + ); + drawnLinks.push([venue, linkVenue]); + } + } + this.sdk.Map.addFeaturesToLayer({ features: features, layerName: GLE.#mapLayer }); + // this.#mapLayer.addFeatures(features); + } + } + + // Process all results of link lookups and add a highlight feature if needed. + Promise.all(promises).then((results) => { + let strokeColor = null; + let strokeDashStyle = "solid"; + if (!this.#DISABLE_CLOSED_PLACES && results.some((res) => res.permclosed)) { + if ( + /^(\[|\()?(permanently )?closed(\]|\)| -)/i.test(venue.name) || + /(\(|- |\[)(permanently )?closed(\)|\])?$/i.test(venue.name) + ) { + strokeDashStyle = GLE.isPointVenue(venue) ? "2 6" : "2 16"; + } + strokeColor = "#F00"; + } else if (results.some((res) => this.#isLinkTooFar(res, venue))) { + strokeColor = "#0FF"; + } else if ( + !this.#DISABLE_CLOSED_PLACES && + this.#showTempClosedPOIs && + results.some((res) => res.tempclosed) + ) { + if ( + /^(\[|\()?(temporarily )?closed(\]|\)| -)/i.test(venue.name) || + /(\(|- |\[)(temporarily )?closed(\)|\])?$/i.test(venue.name) + ) { + strokeDashStyle = GLE.isPointVenue(venue) ? "2 6" : "2 16"; + } + strokeColor = "#FD3"; + } else if (results.some((res) => res.notFound)) { + strokeColor = "#F0F"; + } + if (strokeColor) { + const style = { + strokeWidth: GLE.isPointVenue(venue) ? 4 : 12, + strokeColor, + strokeDashStyle, + }; + // const geometry = venue.isPoint() ? venue.geometry.getCentroid() : venue.geometry.clone(); + const feature: Feature = GLE.isPointVenue(venue) + ? this.trf.point(venue.geometry.coordinates, { + styleName: "placeStyle", + style: style, + }) + : this.trf.polygon(venue.geometry.coordinates, { + styleName: "placeStyle", + style: style, + }); + // this.#mapLayer.addFeatures([new OpenLayers.Feature.Vector(geometry, style)]); + } + }); + } + } catch (ex) { + console.error("PIE (Google Link Enhancer) error:", ex); + } + } + } + + static #onMapMouseenter(event) { + // If the point isn't destroyed yet, destroy it when mousing over the map. + event.data.#destroyPoint(); + } + + async #formatLinkElements() { + const $links = $("#edit-panel").find(this.#EXT_PROV_ELEM_QUERY); + if ($links.length) { + const existingLinks = GLE.#getExistingLinks(this.sdk); + + // fetch all links first + const promises = []; + const extProvElements = []; + $links.each((ix, linkEl) => { + const $linkEl = $(linkEl); + extProvElements.push($linkEl); + + const id = GLE.#getIdFromElement($linkEl); + promises.push(this.linkCache.getPlace(id)); + }); + const links = await Promise.all(promises); + + extProvElements.forEach(($extProvElem, i) => { + const id = GLE.#getIdFromElement($extProvElem); + + if (!id) return; + + const link = links[i]; + if (existingLinks[id] && existingLinks[id].count > 1 && existingLinks[id].isThisVenue) { + setTimeout(() => { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FFA500" }) + .attr({ + title: this.strings.linkedToXPlaces.replace("{0}", existingLinks[id].count), + }); + }, 50); + } + this.#addHoverEvent($extProvElem); + if (link) { + if (link.permclosed && !this.#DISABLE_CLOSED_PLACES) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FAA" }) + .attr("title", this.strings.permClosedPlace); + } else if (link.tempclosed && !this.#DISABLE_CLOSED_PLACES) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#FFA" }) + .attr("title", this.strings.tempClosedPlace); + } else if (link.notFound) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#F0F" }) + .attr("title", this.strings.badLink); + } else { + // const venue = W.selectionManager.getSelectedDataModelObjects()[0]; + const selection = this.sdk.Editing.getSelection(); + if (selection?.objectType === "venue") { + const venue = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] }); + if (venue && this.#isLinkTooFar(link, venue)) { + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "#0FF" }) + .attr( + "title", + this.strings.tooFar.replace("{0}", this.distanceLimit.toString()) + ); + } else { + // reset in case we just deleted another provider + $extProvElem + .find(this.#EXT_PROV_ELEM_CONTENT_QUERY) + .css({ backgroundColor: "" }) + .attr("title", ""); + } + } + } + } + }); + } + } + + static #getExistingLinks(sdk: WmeSDK | undefined = undefined) { + if (!sdk) { + const msg = "SDK Is Not Available"; + console.error(msg); + throw new Error(msg); + } + const existingLinks: Record = {}; + // const thisVenue = W.selectionManager.getSelectedDataModelObjects()[0]; + const thisVenue = sdk.Editing.getSelection(); + if (thisVenue?.objectType !== "venue") return {}; + // W.model.venues.getObjectArray().forEach(venue => { + for (const venue of sdk.DataModel.Venues.getAll()) { + const isThisVenue = venue.id === thisVenue.ids[0]; + const thisPlaceIDs: string[] = []; + // venue.attributes.externalProviderIDs.forEach(provID => { + for (const provID of venue.externalProviderIds) { + // const id = provID.attributes.uuid; + const id = provID; + if (!thisPlaceIDs.includes(id)) { + thisPlaceIDs.push(id); + let link: LinkInfo = existingLinks[id]; + if (link) { + link.count++; + link.venues.push(venue); + } else { + link = { count: 1, venues: [venue] }; + existingLinks[id] = link; + // if (provID.attributes.url != null) { + // const u = provID.attributes.url.replace('https://maps.google.com/?', ''); + // link.url = u; + // } + } + link.isThisVenue = link.isThisVenue || isThisVenue; + } + } + } + return existingLinks; + } + + // Remove the POI point from the map. + #destroyPoint() { + if (this.#ptFeature) { + this.sdk.Map.removeFeaturesFromLayer({featureIds: [this.#ptFeature.id, this.#lineFeature.id], layerName: GLE.#mapLayer}) + // this.#ptFeature.destroy(); + this.#ptFeature = null; + // this.#lineFeature.destroy(); + this.#lineFeature = null; + } + } + + #getOLMapExtent() { + // let extent = W.map.getExtent(); + // if (Array.isArray(extent)) { + // extent = new OpenLayers.Bounds(extent); + // extent.transform('EPSG:4326', 'EPSG:3857'); + // } + return this.sdk.Map.getMapExtent(); + } + + // Add the POI point to the map. + async #addPoint(id: string | null | undefined) { + if (!id) return; + const link = await this.linkCache.getPlace(id); + if (link) { + if (!link.notFound) { + const coord = link.loc; + // const poiPt = new OpenLayers.Geometry.Point(coord.lng, coord.lat); + const poiPt = this.trf.point([coord.lng, coord.lat]); + // poiPt.transform(W.Config.map.projection.remote, W.map.getProjectionObject().projCode); + // const placeGeom = W.selectionManager.getSelectedDataModelObjects()[0].geometry.getCentroid(); + const selection = this.sdk.Editing.getSelection(); + let placeGeom: GeoJSON.Point; + if (selection?.objectType === "venue") { + const v = this.sdk.DataModel.Venues.getById({ venueId: selection.ids[0] }); + placeGeom = v?.geometry && this.trf.centroid(v?.geometry)?.geometry; + } else { + return; + } + // const placePt = new OpenLayers.Geometry.Point(placeGeom.x, placeGeom.y); + const placePt = this.trf.point(placeGeom.coordinates); + const ext = this.#getOLMapExtent(); + // const lsBounds = new OpenLayers.Geometry.LineString([ + // new OpenLayers.Geometry.Point(ext.left, ext.bottom), + // new OpenLayers.Geometry.Point(ext.left, ext.top), + // new OpenLayers.Geometry.Point(ext.right, ext.top), + // new OpenLayers.Geometry.Point(ext.right, ext.bottom), + // new OpenLayers.Geometry.Point(ext.left, ext.bottom)]); + const lsBounds = this.trf.lineString([ + [ext[0], ext[3]], + [ext[0], ext[1]], + [ext[2], ext[1]], + [ext[2], ext[3]], + [ext[0], ext[3]], + ]); + // let lsLine = new OpenLayers.Geometry.LineString([placePt, poiPt]); + let lsLine = this.trf.lineString([placePt.geometry.coordinates, poiPt.geometry.coordinates]); + + // If the line extends outside the bounds, split it so we don't draw a line across the world. + const splits = this.trf.lineSplit(lsLine, lsBounds); + let label = ""; + if (splits) { + for (const split of splits.features) { + for (const component of split.geometry.coordinates) { + if ( + component[0] === placePt.geometry.coordinates[0] && + component[1] === placePt.geometry.coordinates[1] + ) + lsLine = split; + } + } + let distance = this.#distanceBetweenPoints(poiPt.geometry.coordinates, placePt.geometry.coordinates); + let unitConversion: number; + let unit1: string; + let unit2: string; + // if (W.model.isImperial) { + if (this.sdk.Settings.getUserSettings().isImperial) { + distance *= 3.28084; + unitConversion = 5280; + unit1 = " ft"; + unit2 = " mi"; + } else { + unitConversion = 1000; + unit1 = " m"; + unit2 = " km"; + } + if (distance > unitConversion * 10) { + label = Math.round(distance / unitConversion) + unit2; + } else if (distance > 1000) { + label = Math.round(distance / (unitConversion / 10)) / 10 + unit2; + } else { + label = Math.round(distance) + unit1; + } + } + + this.#destroyPoint(); // Just in case it still exists. + // this.#ptFeature = new OpenLayers.Feature.Vector( + // poiPt, + // { poiCoord: true }, + // { + // pointRadius: 6, + // strokeWidth: 30, + // strokeColor: "#FF0", + // fillColor: "#FF0", + // strokeOpacity: 0.5, + // } + // ); + this.#ptFeature = this.trf.point( + poiPt.geometry.coordinates, + { + styleName: "googlePlacePointStyle", + style: { + pointRadius: 6, + strokeWidth: 30, + strokeColor: "#FF0", + fillColor: "#FF0", + strokeOpacity: 0.5, + }, + }, + { id: `PoiPT_${poiPt.toString()}` } + ); + // this.#lineFeature = new OpenLayers.Feature.Vector( + // lsLine, + // {}, + // { + // strokeWidth: 3, + // strokeDashstyle: "12 8", + // strokeColor: "#FF0", + // label, + // labelYOffset: 45, + // fontColor: "#FF0", + // fontWeight: "bold", + // labelOutlineColor: "#000", + // labelOutlineWidth: 4, + // fontSize: "18", + // } + // ); + this.#lineFeature = this.trf.lineString( + lsLine.geometry.coordinates, + { + styleName: "googlePlaceLineStyle", + style: { + strokeWidth: 3, + strokeDashstyle: "12 8", + strokeColor: "#FF0", + label, + labelYOffset: 45, + fontColor: "#FF0", + fontWeight: "bold", + labelOutlineColor: "#000", + labelOutlineWidth: 4, + fontSize: "18", + }, + }, + { id: `LsLine_${lsLine.toString()}` } + ); + // W.map.getLayerByUniqueName("venues").addFeatures([this.#ptFeature, this.#lineFeature]); + this.sdk.Map.addFeaturesToLayer({features: [this.#ptFeature, this.#lineFeature], layerName: GLE.#mapLayer}); + this.#timeoutDestroyPoint(); + } + } else { + // this.#getLinkInfoAsync(id).then(res => { + // if (res.error || res.apiDisabled) { + // // API was temporarily disabled. Ignore for now. + // } else { + // this.#addPoint(id); + // } + // }); + } + } + + // Destroy the point after some time, if it hasn't been destroyed already. + #timeoutDestroyPoint() { + if (this.#timeoutID > 0) clearTimeout(this.#timeoutID); + this.#timeoutID = setTimeout(() => this.#destroyPoint(), 4000); + } + + static #getIdFromElement($el) { + const providerIndex = $el.parent().children().toArray().indexOf($el[0]); + return W.selectionManager.getSelectedDataModelObjects()[0].getExternalProviderIDs()[providerIndex] + ?.attributes.uuid; + } + + #addHoverEvent($el) { + $el.hover( + () => this.#addPoint(GLE.#getIdFromElement($el)), + () => this.#destroyPoint() + ); + } + + #interceptPlacesService() { + if ( + typeof google === "undefined" || + !google.maps || + !google.maps.places || + !google.maps.places.PlacesService + ) { + console.debug("Google Maps PlacesService not loaded yet."); + setTimeout(this.#interceptPlacesService.bind(this), 500); // Retry until it loads + return; + } + + const originalGetDetails = google.maps.places.PlacesService.prototype.getDetails; + const that = this; + google.maps.places.PlacesService.prototype.getDetails = function interceptedGetDetails(request, callback) { + console.debug("Intercepted getDetails call:", request); + + const customCallback = (result, status) => { + console.debug("Intercepted getDetails response:", result, status); + const link = {}; + switch (status) { + case google.maps.places.PlacesServiceStatus.OK: { + const loc = result.geometry.location; + link.loc = { lng: loc.lng(), lat: loc.lat() }; + if (result.business_status === google.maps.places.BusinessStatus.CLOSED_PERMANENTLY) { + link.permclosed = true; + } else if ( + result.business_status === google.maps.places.BusinessStatus.CLOSED_TEMPORARILY + ) { + link.tempclosed = true; + } + that.linkCache.addPlace(request.placeId, link); + break; + } + case google.maps.places.PlacesServiceStatus.NOT_FOUND: + link.notfound = true; + that.linkCache.addPlace(request.placeId, link); + break; + default: + link.error = status; + } + callback(result, status); // Pass the result to the original callback + }; + + return originalGetDetails.call(this, request, customCallback); + }; + + console.debug("Google Maps PlacesService.getDetails intercepted successfully."); + } + } + + return GLE; +})(); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..19eac3a --- /dev/null +++ b/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + }, + "suspicious": { + "noRedundantUseStrict": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0dc1293 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "devDependencies": { + "@biomejs/biome": "^2.0.6", + "@googlemaps/google-maps-services-js": "^3.4.1", + "@turf/turf": "^7.2.0", + "@types/jquery": "^3.5.32", + "typescript": "^5.8.3" + }, + "dependencies": { + "jquery": "^3.7.1", + "wme-sdk-typings": "https://web-assets.waze.com/wme_sdk_docs/beta/latest/wme-sdk-typings.tgz" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f912511 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "target": "es2022", + "module": "ES2022", + "moduleResolution": "node10", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "outDir": "./", + "allowSyntheticDefaultImports": true, + "rootDirs": ["./"], + "types": [ + "wme-sdk-typings", + "geojson" + ] + }, + "exclude": ["node_modules", "releases"], + "include": ["./*.ts", "src/**/*"] +}