Greasy Fork is available in English.

WME COL Basemap

Adds aerials from the COL GIS as a basemap for WME

// ==UserScript==
// @name         WME COL Basemap
// @namespace    https://fxzfun.com/
// @version      3.1.2
// @description  Adds aerials from the COL GIS as a basemap for WME
// @author       FXZFun
// @match        https://*.waze.com/*/editor*
// @match        https://*.waze.com/editor*
// @exclude      https://*.waze.com/user/editor*
// @connect      query.cityoflewisville.com
// @icon         https://www.google.com/s2/favicons?sz=64&domain=waze.com
// @require      https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @grant        GM_xmlhttpRequest
// @license      GNU GPLv3
// ==/UserScript==

/* global W, OpenLayers, WazeWrap, trustedTypes */

(function() {
    'use strict';
    const STORAGE_TICKET_KEY = "wmecolbasemap-ticket";
    const STORAGE_EXPIRE_KEY = "wmecolbasemap-ticketExpiryDate";
    const STORAGE_DATES_KEY = "wmecolbasemap-aerialDates";
    const STORAGE_MOST_RECENT_DATE_KEY = "wmecolbasemap-mostRecentDate";
    const STORAGE_SHORTCUT_KEY = "wmecolbasemap-shortcut";
    const STORAGE_BUBBLE_KEY = "wmecolbasemap-settingsBubbleEnabled";

    const ELEMENT_BUBBLE_ID = "wmecolbasemap-datePickerContainer";
    const ELEMENT_DATE_PICKER_ID = "wmecolbasemap-datePicker";
    const ELEMENT_REFRESH_ID = "wmecolbasemap-refreshBtn";
    const ELEMENT_POPUP_CHECKBOX_ID = "wmecolbasemap-popupCheckbox";
    const ELEMENT_SETTINGS_BUBBLE_ID = "wmecolbasemap-settingsBubble";
    const ELEMENT_SETTINGS_COL_LINK_ID = "wmecolbasemap-settingsColLink";
    const ELEMENT_SETTINGS_TOGGLE_ID = "wmecolbasemap-settingsLayerToggle";
    const ELEMENT_SETTINGS_DATE_PICKER_ID = "wmecolbasemap-settingsDatePicker";
    const ELEMENT_SETTINGS_RELOAD_ID = "wmecolbasemap-settingsRefreshBtn";

    const DEBUG = false;
    const NAME = "WME COL Basemap";
    const pageWindow = unsafeWindow ?? window;

    const policy = trustedTypes?.createPolicy('wmecolbasemapPolicy', { createHTML: (input) => input});
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    let layer;
    let layerEnabled = false;
    let bubbleEnabled = JSON.parse(localStorage.getItem(STORAGE_BUBBLE_KEY)) ?? false;
    let currentDate;
    let errorCount = 0;

    function getJSON(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                onload: async function(response) {
                    resolve(JSON.parse(response.response));
                },
                onerror: function(error) {
                    reject(error);
                }
            });
        });
    }

    async function getTicket() {
        let ticket = localStorage.getItem(STORAGE_TICKET_KEY);
        let ticketExpired = (localStorage.getItem(STORAGE_EXPIRE_KEY) ?? 0) < (new Date().getTime() + (60 * 60 * 1000)); // get new ticket if less than an hour left
        if (!ticket || ticketExpired) ticket = await refreshTicket();
        return ticket;
    }

    async function refreshTicket() {
        const json = await getJSON("https://query.cityoflewisville.com/v2/?webservice=NearmapTicketAndDates");
        const data = json[0][0];
        const dates = JSON.parse(data.aerialdates).map(d => d.date);

        localStorage.setItem(STORAGE_TICKET_KEY, data?.ticket);
        localStorage.setItem(STORAGE_DATES_KEY, JSON.stringify(dates));
        localStorage.setItem(STORAGE_MOST_RECENT_DATE_KEY, dates[0]);
        localStorage.setItem(STORAGE_EXPIRE_KEY, new Date().getTime() + (24 * 60 * 60 * 1000)); // expires every 24 hours
        return data?.ticket;
    }

    function getMostRecentDate() {
        return localStorage.getItem(STORAGE_MOST_RECENT_DATE_KEY)?.replaceAll(".", "") ?? "20230531";
    }

    function createURL(date, ticket) {
        return "https://us0.nearmap.com/maps/?z=${z}&x=${x}&y=${y}&nml=V&version=2&nmd=" + date + "&ticket=" + ticket;
    }

    /*
        Add the layer to the map if it does not exist
    */
    async function addLayer() {
        const date = getMostRecentDate();
        const ticket = await getTicket();
        const url = createURL(date, ticket);

        layer = new OpenLayers.Layer.XYZ(
            NAME,
            url,
            {
                isBaseLayer: false,
                uniqueName: 'colgis',
                tileSize: new OpenLayers.Size(256,256),
                transitionEffect: 'resize',
                displayInLayerSwitcher: true,
                opacity: 1,
                visibility: false
            });
        layer.events.on({
            'loadend': async function (evt) {
                if (DEBUG) console.log("WME COL Basemap: Loaded tiles");

                const refreshBtn = document.getElementById(ELEMENT_REFRESH_ID);
                if (refreshBtn) {
                    refreshBtn.style.animation = "3s wmecolbasemapRefresh";
                    await sleep(3000);
                    refreshBtn.style.animation = "";
                }
            },
            'loaderror': async function(evt) {
                if (errorCount++ === 0) {
                    console.error("WME COL Basemap: Error loading tile");
                    await refreshTicket();
                    updateLayerDate(currentDate ?? getMostRecentDate());
                } else if (errorCount === 50) {
                    toggleBasemap();
                    alert("WME COL Basemap failed to reach imagery endpoint");
                }
            }
        });
        W.map.addLayer(layer);
        W.map.setLayerIndex(layer, 3);
    }

    async function updateLayerDate(date) {
        if (!layer) return;
        let ticket = await getTicket();
        layer.url = createURL(date, ticket);
        layer.redraw();
    }

    /*
        Add the date picker element to the top left of the map
    */
    function addSettingsBubble() {
        var dates = JSON.parse(localStorage.getItem(STORAGE_DATES_KEY) ?? "[]");

        const div = document.createElement("div");
        div.id = ELEMENT_BUBBLE_ID;
        div.innerHTML = policy.createHTML(
            `<style>
                 .wmecolbasemap { position: absolute; top: 40px; left: 60px; background-color: #fafafa; padding: 0.5em 0.75em; border-radius: 2em; }
                 .wmecolbasemap i { padding: 0.5em; vertical-align: middle; }
                 .wmecolbasemap #select-wrapper { display: none; }
                 @keyframes wmecolbasemapRefresh { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
             </style>
             <div class="wmecolbasemap">
                 <wz-checkbox id="${ELEMENT_POPUP_CHECKBOX_ID}" value="on" style="display: inline-block; vertical-align: middle; margin-left: 0.5em;" checked></wz-checkbox>
                 <wz-select id="${ELEMENT_DATE_PICKER_ID}" value="${currentDate ?? dates[0]}" style="display: inline-block">
                     <div class="select-wrapper" id="select-wrapper"><div tabindex="0" class="select-box"><div class="selected-value-wrapper"><span class="selected-value">${currentDate ?? dates[0]}</span></div></div></div>
                     ${dates.map(d => "<wz-option value=" + d + ">" + d + "</wz-option>").join("")}
                 </wz-select>
                 <i id="${ELEMENT_REFRESH_ID}" class="w-icon w-icon-refresh"></i>
             </div>`);
        document.querySelector(".olMap").appendChild(div);

        document.getElementById(ELEMENT_POPUP_CHECKBOX_ID).addEventListener("click", toggleBasemap);

        const select = document.getElementById(ELEMENT_DATE_PICKER_ID);
        select.addEventListener("optionClicked", (evt) => {
            currentDate = evt.detail.value ?? currentDate;
            select.value = currentDate;
            updateLayerDate(select.value.replaceAll(".", ""));
            let settingsSelect = document.getElementById(ELEMENT_SETTINGS_DATE_PICKER_ID)
            if (!!settingsSelect) settingsSelect.value = currentDate;
        });

        const refreshBtn = document.getElementById(ELEMENT_REFRESH_ID);
        refreshBtn.addEventListener("click", async () => {
            refreshBtn.style.animation = "1s wmecolbasemapRefresh";
            updateLayerDate(currentDate ?? getMostRecentDate());
            await sleep(1000);
            div.remove();
            addSettingsBubble();
        });
    }

    function toggleBubble() {
        bubbleEnabled = !bubbleEnabled;
        const bubble = document.getElementById(ELEMENT_BUBBLE_ID);
        if (!!bubble && !bubbleEnabled) bubble.remove();
        else if (layerEnabled) addSettingsBubble();
        localStorage.setItem(STORAGE_BUBBLE_KEY, bubbleEnabled);
    }

    /*
        Callback for shortcut and layer checkbox, toggles the visibility of the layer
    */
    function toggleBasemap() {
        layerEnabled = !layerEnabled;
        layer?.setVisibility(layerEnabled);
        document.querySelector("#layer-switcher-item_wme_col_basemap").checked = layerEnabled;
        document.getElementById(ELEMENT_SETTINGS_TOGGLE_ID).checked = layerEnabled;
        // toggles date picker
        if (layerEnabled && bubbleEnabled) addSettingsBubble();
        else document.getElementById(ELEMENT_BUBBLE_ID)?.remove();
    }

    /*
        Main entry point of the script
        Adds layer checkbox, shortcut, and layer
    */
    async function initialize() {
        console.log("WME COL Basemap: Start");

        if (DEBUG) pageWindow.wmeColBasemap = { getJSON, getTicket, refreshTicket, getMostRecentDate, createURL, addLayer, updateLayerDate, addSettingsBubble, toggleBasemap, layer };

        addLayer();

        console.log("WME COL Basemap: Added Layer");

        const i = setInterval(() => {
            if (WazeWrap?.Ready) {
                clearInterval(i);
                WazeWrap.Interface.AddLayerCheckbox(
                    "display",
                    NAME,
                    false,
                    toggleBasemap,
                    layer ?? W.map.getLayerByName(NAME));

                new WazeWrap.Interface.Shortcut('COLBasemapDisplay', 'Toggle COL Basemap',
                                                'layers', 'layersToggleCOLBasemapDisplay', localStorage.getItem(STORAGE_SHORTCUT_KEY) ?? "", toggleBasemap, null).add();
            }
        }, 500);

        const dates = JSON.parse(localStorage.getItem(STORAGE_DATES_KEY) ?? "[]");
        const { tabLabel, tabPane } = W.userscripts.registerSidebarTab("wmeColBasemap");

        tabLabel.innerText = 'COL';
        tabLabel.title = NAME;
        tabPane.style = "display: flex; flex-direction: column; height: 100%; gap: 5px;";
        tabPane.innerHTML = policy.createHTML(`
            <h3>WME COL Basemap <small>v3.1</small></h3>
            <p>provided by <a href="https://maps.cityoflewisville.com" target="_blank" id="${ELEMENT_SETTINGS_COL_LINK_ID}">maps.cityoflewisville.com</a></p>

            <b style="margin-top: 20px">Options</b>
            <wz-checkbox id="${ELEMENT_SETTINGS_BUBBLE_ID}" value="${bubbleEnabled ? "on" : "off"}" name="" ${bubbleEnabled ? "checked=''" : ''}>Show Settings Bubble</wz-checkbox>
            <wz-checkbox id="${ELEMENT_SETTINGS_TOGGLE_ID}" value="off" name="">Toggle Basemap Layer</wz-checkbox>

            <span style="margin-top:20px">Aerial Date</span>
            <wz-select id="${ELEMENT_SETTINGS_DATE_PICKER_ID}" value="${currentDate ?? dates[0]}" style="display: inline-block">
                <div class="select-wrapper" id="select-wrapper" style="display: none"><div tabindex="0" class="select-box"><div class="selected-value-wrapper"><span class="selected-value">${currentDate ?? dates[0]}</span></div></div></div>
                ${dates.map(d => "<wz-option value=" + d + ">" + d + "</wz-option>").join("")}
            </wz-select>

            <wz-button id="${ELEMENT_SETTINGS_RELOAD_ID}" color="text" size="sm">Clear token and reload layer</wz-button>

            <p style="margin-top: 20px">Current Shortcut: ${localStorage.getItem(STORAGE_SHORTCUT_KEY) ?? "None"}</p>

            <i style="margin-top: 20px">Please note that the dates shown correspond to when Nearmap imagery was taken over the City of Lewisville, TX and represent the state of all Nearmap imagery as of that date. The date shown of Lewisville imagery does not indicate that areas outside it's city limits were also updated on said date.</i>

            <p style="margin-top: 20px;"><b>Note:</b> please do not use as your default basemap - only enable when needed, as we do not want to abuse the service provided by the City of Lewisville GIS.</p>
            <em>Aerial Imagery © Nearmap - <a href="https://nearmap.com" target="_blank">nearmap.com</a></em>
        `);

        await W.userscripts.waitForElementConnected(tabPane);

        document.getElementById(ELEMENT_SETTINGS_BUBBLE_ID).addEventListener("click", toggleBubble);
        document.getElementById(ELEMENT_SETTINGS_TOGGLE_ID).addEventListener("click", toggleBasemap);
        document.getElementById(ELEMENT_SETTINGS_RELOAD_ID).addEventListener("click", () => updateLayerDate(currentDate ?? getMostRecentDate()));
        const colLink = document.getElementById(ELEMENT_SETTINGS_COL_LINK_ID);
        colLink.addEventListener("mousedown", () => {
            let center = W.map.getCenter();
            var lonlat = new OpenLayers.LonLat(center.lon, center.lat);
            lonlat.transform(new OpenLayers.Projection('EPSG:900913'),new OpenLayers.Projection('EPSG:4326'));
            colLink.href = `https://maps.cityoflewisville.com/?&zoom=${W.map.getZoom()}&center=${lonlat.lat},${lonlat.lon}&basemap=nearmap_${currentDate?.replaceAll(".", "") ?? getMostRecentDate()}`;
        });

        const select = document.getElementById(ELEMENT_SETTINGS_DATE_PICKER_ID);
        select.addEventListener("optionClicked", (evt) => {
            currentDate = evt.detail.value ?? currentDate;
            select.value = currentDate;
            updateLayerDate(select.value.replaceAll(".", ""));
            let bubbleSelect = document.getElementById(ELEMENT_DATE_PICKER_ID)
            if (!!bubbleSelect) bubbleSelect.value = currentDate;
        });

        pageWindow.addEventListener("beforeunload", () => {
            const shortcut = W.accelerators.Actions.COLBasemapDisplay?.shortcut;
            if (!shortcut) return;

            const modifiers = [];
            if (shortcut.ctrlKey) modifiers.push("Ctrl");
            if (shortcut.altKey) modifiers.push("Alt");
            if (shortcut.shiftKey) modifiers.push("Shift");

            const newShortcut = `${modifiers.join("+")}+${String.fromCharCode(shortcut.keyCode)}`;
            localStorage.setItem(STORAGE_SHORTCUT_KEY, newShortcut);
            console.log("Saved WME COL Basemap settings");
        });

    }

    W?.userscripts?.state?.isReady ? initialize() : document.addEventListener("wme-ready", initialize, { once: true });
})();