WME-PermanentlyClosed

A Waze Map Editor Script to find permanently closed places

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         WME-PermanentlyClosed
// @namespace    http://tampermonkey.net/
// @version      1.3.1
// @description  A Waze Map Editor Script to find permanently closed places
// @author       deqline
// @source       https://github.com/deqline/WME-PermanentlyClosed
// @match        https://www.waze.com/*/editor
// @match        https://www.waze.com/editor
// @icon         https://www.google.com/s2/favicons?sz=64&domain=waze.com
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    let api = undefined;
    let scannedPlaces = [];
    let options = {
        'enabled': false,
        'bbox': false,
        'residential': false,
        'category': "",
        'radius': 50,
        'closed': false,
        'overlays': false,
        'debug': false
    };
    let categories = new Set();
    const debug = false;
    let parentLayerElement = null

    //Waze object
    if (W?.userscripts?.state.isReady) { //optional chaining operator ?.
        console.log("user:", W.loginManager.user);
        console.log("segments:", W.model.segments.getObjectArray());

        InitializeScriptTab();
    } else {
        document.addEventListener("wme-ready", InitializeScriptTab, {
            once: true,
        });
    }

    function refreshOptions() {
        options.enabled     = document.getElementById("enable").checked;
        options.bbox        = document.getElementById("bbox_enable").checked;
        options.residential = document.getElementById("residential_enable").checked;
        options.category    = document.getElementById("category_filter").value;
        options.radius      = document.getElementById("radius_number").value;
        options.closed      = document.getElementById("closed_enable").checked;
        options.overlays   = document.getElementById("overlays_enable").checked;
        options.debug       = document.getElementById("debug_check").checked;
    }

    function refreshUI() {
        for (let p in scannedPlaces) {
            if(p.circleOverlay){
                document.getElementById(p.parentFeatureID).removeChild(p.circleOverlay);
            }

        }

        scannedPlaces.length = 0;
        parentLayerElement = null;

        options = {
            'enabled': false,
            'bbox': false,
            'residential': false,
            'category': "",
            'radius': 50,
            'closed': false
        };
        categories = new Set();
        refreshOptions();
        document.getElementById("scanned-places").innerHTML = "";
        document.getElementById("category_filter").innerHTML = "";

        document.getElementById("bbox_enable").checked = false;
        document.getElementById("residential_enable").checked = false;
        document.getElementById("category_filter").value = "";
        document.getElementById("radius_number").value = 50;
        document.getElementById("closed_enable").checked = false;
        document.getElementById("overlays_enable").checked = false;
        document.getElementById("progress").innerText = " (Progress 0%)";

    }

    function InitializeScriptTab()
    {
        api = window.prompt("Enter your google maps api key (optional)");

        const { tabLabel, tabPane } = W.userscripts.registerSidebarTab("PermanentlyClosed");
        tabLabel.innerHTML = "PermanentlyClosed";

        tabPane.innerHTML = `
        <script async src="https://maps.googleapis.com/maps/api/js?key=${api}&libraries=places"></script>
         <style>
         table {
            border-collapse: collapse;
             width: 100%;
         }

        td,th {
            max-width: 10px;
            word-wrap: break-word; /* Enable word wrap */
            border: 1px solid black;
            text-align: left;
            padding: 8px;
            margin: 2px;
        }
  </style>
         <div id="map" style="display:none;"></div>
         <label for="enable" >Enable</label>
         <input name="enable" type="checkbox" id="enable"><br>

         <label for="radius">Nearby Search radius (in m)</label>
         <input name="radius" id="radius_number" type="number" value="50"><br>

         <label for="residential"> Show residential places </label>
         <input name="residential" type="checkbox" id="residential_enable"><br>

         <label for="show_bounding_box">Show areas</label>
         <input name="show_bounding_box" type="checkbox" id="bbox_enable" checked><br>

         <label for="closed"> Show closed places</label>
         <input name="closed" type="checkbox" id="closed_enable"/><small id="progress"> (Progress 0%)</small><br>

         <label for="closed_overlays"> Show overlays on map </label>
         <input name="closed_overlays" type="checkbox" id="overlays_enable"/><br>

         <label for="debug"> Show console debug </label>
         <input name="debug" type="checkbox" id="debug_check"/><br>

         <label for="category_choice">Filter by category</label>
         <select name="category_choice" id="category_filter"></select><br>


         <span>Scanned places (<span id="place_count"></span>)</span>
         <table style="border: 1px solid black">
            <thead>
                <th>Name</th>
                <th>Coords</th>
                <th>Other</th>
            </thead>
            <tbody id="scanned-places"></tbody>
         </table>
        `;

        W.userscripts.waitForElementConnected(tabPane).then(() => {
            InitializeScript();
        });

    }

    function InitializeScript()
    {
        if(debug) {
            getRenderedMarkers();
        }

        document.getElementById("enable").addEventListener("change", function() {
            refreshOptions();


            if(options.enabled) {
                W.map.registerPriorityMapEvent("moveend", MoveZoomHandler, W.issueTrackerController);
                W.map.registerPriorityMapEvent("zoomend", MoveZoomHandler, W.issueTrackerController);
                getRenderedMarkers();
            } else {
                refreshUI();
                W.map.unregisterMapEvent("moveend", MoveZoomHandler, W.issueTrackerController);
                W.map.unregisterMapEvent("zoomend", MoveZoomHandler, W.issueTrackerController);
                return;
            }
        });


        document.addEventListener("click", function(event) {handleRowClick(event)});

        document.addEventListener("change", function(e) {
            refreshOptions();
            filterByCategory();
            displayPlaces();

            if(e.target.id == "closed_enable")
            {
                if(options.closed){
                    showClosed();
                } else {
                    document.getElementById("progress").innerText = ` (Progress 0%)`;
                }
            }

        });

    }

    function handleRowClick(event)
    {
        if (event.target.tagName == "TD" && event.target.closest("tbody").id == "scanned-places") {
            for(let p of scannedPlaces)
            {
                if(p.name == event.target.textContent) {
                    if(debug) console.log("click!", event.target.textContent, p.featureID);
                    if(p.isBbox) {
                        document.getElementById(p.featureID).setAttribute("stroke", "yellow");
                    } else {
                        document.getElementById(p.featureID).setAttribute("r", "10");
                    }


                    return;
                }
            }
        }
    }

    function updatePlaceCount()
    {
        let i = 0, j = 0;
        for(let p of scannedPlaces) {
            if(p.display) {
                i++;
            }
            if(p.closed) {
                j++;
            }
        }
        document.getElementById("place_count").innerHTML = `${scannedPlaces.length} total, ${i} shown, ${j} closed`;
    }

    //implemented for belgium since sometimes places are in dutch in waze
    function checkSimilarity(obj1, obj2)
    {
        const regex = /[\.-;,\/]+/gm;

        let a = obj1.name.toLowerCase().replaceAll(regex, " ").replaceAll("é", "e").replaceAll("à", "a").replaceAll("è", "e").replaceAll("â", "a");
        let b = obj2.name.toLowerCase().replaceAll(regex, " ").replaceAll("é", "e").replaceAll("à", "a").replaceAll("è", "e").replaceAll("â", "a");

        if(a == b || a.includes(b) || b.includes(a)) return true;


        let substr = "";
        let nb_substr = 0;
        for(let ltr of a) {
            if(ltr == " ") {
                if(b.includes(substr)) return true;
                substr = "";
            }
            else substr += ltr;
        }

        for(let ltr of b) {
            if(ltr == " ") {
                if(a.includes(substr)) return true;
                substr = "";
            }
            else substr += ltr;
        }

        for(let t of obj1.types) {
            for(let t2 of obj2.categories) {
                let a = t.toLowerCase()
                let b = t2.toLowerCase();

                if(a == b || a.includes(b) || b.includes(a)) {
                    return true;
                }

            }
        }

        return false;
    }


    function showClosed()
    {
        if(!scannedPlaces.length) return;

        let mapCenter = new google.maps.LatLng(scannedPlaces[0].coords[0], scannedPlaces[0].coords[1]);
        let map = new google.maps.Map(document.getElementById('map'), {center: mapCenter});
        var service = new google.maps.places.PlacesService(map);

        let progress = 0;

        for (let p of scannedPlaces)
        {
            if(p.display) {
                progress++;

                let circleElem = document.getElementById(p.featureID);
                if(!circleElem) continue;

                //Red/orange overlays over map circles

                circleElem.setAttribute("z-index", "2");
                //create new element with a custom qualifier from an URI
                let newCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
                //set the new circle coordinates to the parent circle coordinates
                newCircle.setAttribute("cx", circleElem.getAttribute("cx"));
                newCircle.setAttribute("cy", circleElem.getAttribute("cy"));
                //set some styling
                newCircle.setAttribute("r", "12");
                newCircle.setAttribute("fill-opacity", "0.7");
                newCircle.setAttribute("z-index", "1");
                let newID = "_" + circleElem.getAttribute("id");
                newCircle.setAttribute("id", newID);

                let match = false;

                //actual closed places logic

                //First try searching the place on google maps by name and address
                let request = {
                    query: p.name + " " + p.fullAddress, //because google sometimes includes street name inside the results name
                    fields: ['name', 'business_status','formatted_address', 'types']
                }
                service.findPlaceFromQuery(request, function(results, status) {
                    if (status === google.maps.places.PlacesServiceStatus.OK) {

                        if(debug) console.log(results);
                        for (var i = 0; i < results.length; i++) {
                            if(options.debug) console.log(`[google_places_api] Fetched results for findPlace query '${p.name + p.fullAddress}' === ${results[i].name} ( ${results[i].formatted_address} )`);
                            if(checkSimilarity(results[i], p)) {
                                match = true;
                                if(options.debug) console.log("Found similarity");

                                if(results[i].business_status == "CLOSED_PERMANENTLY") { //can also check for "CLOSED_TEMPORARILY" if really needed
                                    p.closed = true;
                                    console.log(p.name, "=>Closed:", results[i]);

                                }
                            }
                        }

                    }

                    if(!match && !p.closed) {
                        //search twice in case we haven't found the place with the first search
                        request = {
                            location: new google.maps.LatLng(p.coords[0], p.coords[1]),
                            radius: options.radius,
                            keywords: p.name
                        };
                        service.nearbySearch(request, function(results, status) {
                            if (status === google.maps.places.PlacesServiceStatus.OK) {

                                if(debug) console.log(results);
                                for (var i = 0; i < results.length; i++) {
                                    if(options.debug) console.log(`[google_places_api] Second try: Fetched results for nearby search query '${p.coords.join(",")}' => matched ${results[i].name}`);
                                    if(checkSimilarity(results[i], p)) {
                                        match = true;
                                        if(options.debug) console.log("Found similarity");

                                        if("business_status" in results[i] && results[i].business_status == "CLOSED_PERMANENTLY") {
                                            p.closed = true;
                                            console.log(p.name, "=>Closed:", results[i]);

                                        }
                                    }
                                }
                            }
                        });
                    }

                    //still no match, means missing info or data mismatch between google maps and waze
                    if(!match) {
                        if(p.display) {
                            //p.node.children[0].style.color = "orange";
                            newCircle.setAttribute("fill", "orange");
                        }
                    }

                    if(p.closed) {
                        updatePlaceCount();
                        if(p.display) {
                            p.node.children[0].style.color = "red";
                            newCircle.setAttribute("fill", "red");
                        }
                        document.getElementById(p.parentFeatureID).appendChild(newCircle);
                        p.circleOverlay = newCircle;
                        if(!options.overlays) {
                            p.circleOverlay.style.display = "none";
                        }
                    }
                });

            }
            document.getElementById("progress").innerText = ` (Progress ${(progress/scannedPlaces.length)*100}%)`;
        }
        document.getElementById("progress").innerText = ` (Progress 100%)`;
        //updatePlaceCount(); //update closed place count
    }

    // Calculate the center coordinate of the purple areas by doing an average of all its coords
    function calculatePolygonCenter(coordinates) {
        if (coordinates.length === 0) {
            return null;
        }

        let sumLat = 0;
        let sumLon = 0;

        for (const coordinate of coordinates) {
            sumLon += coordinate[0];
            sumLat += coordinate[1];
        }

        const avgLat = sumLat / coordinates.length;
        const avgLon = sumLon / coordinates.length;

        return [avgLat, avgLon];
    }

    //define what places should appear in the table on the script tab
    function displayPlaces()
    {
        let placesTable = document.getElementById("scanned-places");

        for(let place of scannedPlaces) {
            if (place.node == null) {
                let cell = document.createElement("tr");

                if(place.coords[0].length > 1) {
                    let center = calculatePolygonCenter(place.coords[0]);
                    place.coords = center;
                }
                let coordsString = place.coords.join(",");

                cell.innerHTML = `
                <td>${place.name}</td>
                <td>${coordsString}</td>
                <td>${place.categories.join(",")}</td>
                `;
                place.node = cell;
            }

            if(place.node != null) {
                //we show the areas in the global list, but when filtering by a category, the display will be affected by the filtering function
                if(options.category == "" && place.isBbox) {
                    place.display = options.bbox;
                    place.node.style.display = (options.bbox ? "" : "none");
                }
                if ("RESIDENCE_HOME" in place.categories)
                {
                    place.display = options.residential;
                    place.node.style.display = (options.residential ? "" : "none");
                }

                if(!options.closed) place.node.children[0].style.color = "black";
                if(place.circleOverlay) place.circleOverlay.style.display = (options.overlays ? "" : "none");

            }

        }

        for(let e of scannedPlaces) {
            //avoid duplicate elements
            if(e.added == false) {
                e.node.style.display = (e.display ? "" : "none");
                placesTable.appendChild(e.node);
                e.added = true;
            }

        }
        updatePlaceCount();
    }

    function updateCategories()
    {
        document.getElementById("category_filter").innerHTML = "";
        categories.clear();
        //no filter option
        let emptyOption = document.createElement("option");
        // Set HTML content for the option
        emptyOption.innerHTML = '----';
        // Set a value for the option (optional)
        emptyOption.value = "";
        options.category = "";

        document.getElementById("category_filter").appendChild(emptyOption);
        document.getElementById("category_filter").value = ""; // set it to default to no filter

        //add closed option
        let closedOption = document.createElement("option");
        // Set HTML content for the option
        closedOption.innerHTML = 'CLOSED';
        // Set a value for the option (optional)
        closedOption.value = "CLOSED";
        categories.add("CLOSED");

        document.getElementById("category_filter").appendChild(closedOption);


        for(let place of scannedPlaces) {
            for(let c of place.categories) {
                if(!categories.has(c)) {
                    let optionCategory = document.createElement("option");
                    optionCategory.innerHTML = c;
                    optionCategory.value = c;
                    document.getElementById("category_filter").appendChild(optionCategory);

                    categories.add(c);
                }
            }
        }
    }

    function refreshPlaceList()
    {
        //refresh table first
        for(let p of scannedPlaces) {
            if(p.isBbox && !options.bbox) continue;
            if(("RESIDENCE_HOME" in p.categories) && !options.residential) continue;

            p.display = true;
            if(p.node) {
                p.node.style.display = "";
            }
            if(p.featureID.length > 0) {
                if(document.getElementById(p.featureID) == null) {
                    deletePlace(p.featureID);
                    continue;
                }
                document.getElementById(p.featureID).style.display = "";
            }
        }
    }

    function filterByCategory()
    {
        refreshPlaceList();
        if(options.category == "") return;

        for(let p of scannedPlaces) {
            if(options.category == "CLOSED"){
                if(!p.closed){
                    p.display = false;
                    p.node.style.display = "none";
                    document.getElementById(p.featureID).style.display = "none";
                }
                continue;
            }

            if(p.featureID.length > 0){
                if(document.getElementById(p.featureID) === null) {
                    deletePlace(p.featureID);
                    continue;
                }
            }


            if( !p.categories.includes(options.category)) {
                p.display = false;
                p.node.style.display = "none";
                document.getElementById(p.featureID).style.display = "none";
            }
        }
    }

    function MoveZoomHandler() {
        //remove out of view features

        for(let p of scannedPlaces){
            if(p.featureID.length > 0){
                if(document.getElementById(p.featureID) === null) {
                    deletePlace(p.featureID);
                }
            }
        }
        getRenderedMarkers();
    }

    function deletePlace(featureID)
    {
        let toRemove = [];
        for(let p of scannedPlaces) {
            if(p.featureID == featureID) {
                if(p.node != null){
                    let parent = document.getElementById("scanned-places");
                    if(parent.contains(p.node)){
                        //p.display = false;
                        parent.removeChild(p.node);
                        toRemove.push(p);
                        //scannedPlaces = scannedPlaces.filter((p) => {return p.featureID != featureID;});
                        //console.log("Removing", place.name, " ", scannedPlaces);
                    }

                }
            }
        }
        for(let place of toRemove){
                scannedPlaces = scannedPlaces.filter((p) => {return p != place;});
        }
        updatePlaceCount();
    }

    function sameCoords(coords1, coords2)
    {
        if(coords1.length != coords2.length) {
            return false;
        }

        for(let i = 0; i < coords1.length; i++){
            if(coords1[i] != coords2[i]){
                return false;
            }
        }
        return true;
    }

    function getRenderedMarkers()
    {
        console.log("Rendering");

        let renderedLayers = W.map.nodeLayer.renderer.map.layers;

        if(!parentLayerElement && renderedLayers) {
            for(let layerElement of renderedLayers) {

                if(debug) console.log(layerElement.name, " : ", layerElement);


                if(layerElement.name  == "venues") {
                    parentLayerElement = layerElement;
                }
            }
        }


        let parentFeatureID      = parentLayerElement.renderer.vectorRoot.id;
        let renderedPlaceMarkers = parentLayerElement.features;

        if(debug) console.log("rendered place markers : ", renderedPlaceMarkers);

        for(let marker of renderedPlaceMarkers) {
            let markerFeatureObject = marker.data.wazeFeature._wmeObject;
            let featureElement = W.userscripts.getFeatureElementByDataModel(markerFeatureObject);

            //if we successfully got the id of the circle marker on the map
            if(featureElement) {
                let point = document.getElementById(featureElement.id);

                let markerDetails = markerFeatureObject.attributes;
                if(debug) console.log(markerFeatureObject);

                let streetsObject = markerFeatureObject.model.streets.objects;
                let citiesObject = markerFeatureObject.model.cities.objects;

                let fullAddress = "";
                let partialAddress = "";
                let street = streetsObject[markerFeatureObject.attributes.streetID];
                if(street) {
                    let city = citiesObject[street.attributes.cityID];
                    let country = markerFeatureObject.model.topCountry.attributes.name;
                    if(city && country && markerDetails.houseNumber) {
                        fullAddress = `${markerDetails.houseNumber} ${street.attributes.name}, ${city.attributes.name}, ${country}`;
                    } else if (city && country) {
                        partialAddress = `${city.attributes.name}, ${country}`;
                    }

                }

                let placeDetails = {
                    'name': markerDetails.residential ? "maison n°" +
                    markerDetails.houseNumber : (markerDetails.name.length > 0 ? markerDetails.name : "/"),
                    'coords': markerDetails.geoJSONGeometry.coordinates.reverse(),
                    'topAddress':partialAddress,
                    'fullAddress': fullAddress,
                    'categories': markerDetails.categories,
                    'businessDetails': {
                        'tel': markerDetails.phone,
                        'website': markerDetails.url
                    },
                    'description': markerDetails.description,
                    'node': null,
                    'featureID': featureElement.id,
                    'parentFeatureID': parentFeatureID,
                    'circleOverlay': null,
                    'isBbox': markerDetails.geoJSONGeometry.type == "Polygon",//bounding boxes (polygon of multiple points)
                    'display': true,
                    'closed': false,
                    'added': false //added to table
                };

                let duplicate = false;
                for(let p of scannedPlaces) {
                    if(sameCoords(p.coords, placeDetails.coords) || p.featureID == placeDetails.featureID) {
                        duplicate = true;
                    }
                }
                if(!duplicate) {
                    scannedPlaces.push(placeDetails);
                }
            }
        }
        displayPlaces();
        updateCategories();
    }
})();