WME-PermanentlyClosed

A Waze Map Editor Script to find permanently closed places

目前為 2023-12-11 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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.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 featureIDS = [];
    let parentLayerElement = null
    let emptyOption = 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;
        featureIDS.length = 0;
        parentLayerElement = null;
        let emptyOption = 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"><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" && options.closed)
            {
                showClosed();
            }

        });

    }

    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;
        if(scannedPlaces[0].coords.length == 0 || scannedPlaces[0].coords[0] == NaN) 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;

                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;

                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;
                                    if(p.display) {
                                        p.node.children[0].style.color = "red";
                                        newCircle.setAttribute("fill", "red");
                                    }
                                    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;
                                            if(p.display) {
                                                p.node.children[0].style.color = "red";
                                                newCircle.setAttribute("fill", "red");
                                            }
                                            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(!match || p.closed ) {
                        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%)`;

        //add closed filter
        if(!categories.has("CLOSED")) {

            let optionClosed = document.createElement("option");
            optionClosed.style.color = "red";
            optionClosed.innerText = "CLOSED";
            optionClosed.value = "CLOSED";
            document.getElementById("category_filter").appendChild(optionClosed);

            categories.add("CLOSED");
        }
        updatePlaceCount(); //update closed place count
    }

    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];
    }

    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) {
                if(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) {
            if(e.added == false) {
               e.node.style.display = (e.display ? "" : "none");
               placesTable.appendChild(e.node);
               e.added = true;
            }

        }
        updatePlaceCount();
    }

    function updateCategories()
    {
        if(!emptyOption){
            //no filter option
            emptyOption = document.createElement("option");
            // Set HTML content for the option
            emptyOption.innerHTML = '----';
            // Set a value for the option (optional)
            emptyOption.value = "";
            document.getElementById("category_filter").appendChild(emptyOption);
        }

        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);
                }
            }
        }
        options.category = "";
        document.getElementById("category_filter").value = ""; // set it to default to no filter
    }

    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.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 id of featureIDS){
            if(document.getElementById(id) === null) {
                deletePlace(id);
            }
        }


        getRenderedMarkers();
    }

    function deletePlace(featureID)
    {
        let place = null;
        for(let p of scannedPlaces) {

            if(p.featureID == featureID) {
                place = p;
            }
        }
        if(place != null)
        {
            if(place.node != null){
                let parent = document.getElementById("scanned-places");
                if(parent.contains(place.node)){
                   parent.removeChild(place.node);
                   scannedPlaces = scannedPlaces.filter((p) => {return p.featureID != featureID;});
                   //console.log("Removing", place.name, " ", scannedPlaces);
                 }

            }

            for(let c of place.categories) {
                categories.delete(c);
            }
            //get removed categories
            let existingCategories = document.getElementById("category_filter");

            for(let c of existingCategories.children) {
                if(categories.has(c.innerText)){
                    c.remove();
                }
            }
        }
        updatePlaceCount();

    }

    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) {
                if(featureIDS.includes(featureElement.id)) {
                    continue;
                }

                let point = document.getElementById(featureElement.id);

                featureIDS.push(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}`;
                    }

                }

                // console.log(`Adding ${markerDetails.name}`);
                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
                };

                scannedPlaces.push(placeDetails);
            }
        }
        displayPlaces();
        updateCategories();
    }
})();