eBird Alerts Map

Adds a map with eBird alert locations as markers.

// ==UserScript==
// @name         eBird Alerts Map
// @namespace    http://tampermonkey.net/
// @version      2024-04-19_1.12
// @description  Adds a map with eBird alert locations as markers.
// @author       Ruslan Balagansky
// @license      MIT
// @match        https://ebird.org/alert/needs/*
// @match        https://ebird.org/alert/rba/*
// @match        https://ebird.org/alert/summary?sid=*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const mapDivId = 'userscript-map';

    // load api key from storage or use default
    const defaultApiKey = "AIzaSyCNhkdcs7rdwXoaSpqDzNLBnA-4Tu_7v-4"  // restricted to ebird.org
    var apiKey = GM_getValue("apiKey", defaultApiKey);

    // handle case when stored value is an empty string
    if (!apiKey) {
        apiKey = defaultApiKey;
    }

    // allow user to set a custom key via script's menu (via Tampermonkey extension icon)
    function promptForApiKey() {
        apiKey = prompt("Enter a Google Maps API key or accept the author-provided one: ", defaultApiKey);
        GM_setValue("apiKey", apiKey);
    }

    GM_registerMenuCommand("Change Google Maps API Key", promptForApiKey);

    // initializes the map (called by legacy API callback)
    function initMap() {
        if (typeof google.maps.Map == 'undefined'
           || typeof google.maps.Marker == 'undefined')
        {
            setTimeout(function() { initMap(); }, 100);
            return;
        }
        // Collect locations data
        var locations = {};
        const mapRegex = /Map: (.+), (.+)/;
        const observations = document.getElementsByClassName("Observation");
        for (const obs of observations) {
            const species = obs.getElementsByClassName("Observation-species")[0];
            const specRef = species.getElementsByTagName("a")[0];
            const speciesCode = specRef.getAttribute("data-species-code");
            const fourLetterSpecies = speciesCode.slice(0, 2) + speciesCode.slice(3, 5)

            const meta = obs.getElementsByClassName("Observation-meta")[0];
            let coords;
            let key;
            let age = 7;
            let dateAnchor;
            for (const a of meta.getElementsByTagName("a")) {
                const title = a.getAttribute("title");
                const mapMatch = title.match(mapRegex);
                if (mapMatch) {
                    key = title;
                    coords = { lat: Number(mapMatch[1]), lng: Number(mapMatch[2]) };
                } else {
                    const parsedDate = Date.parse(a.innerText);
                    if (parsedDate) {
                        dateAnchor = a;
                        function dateOnly(inDate) {
                            var date = new Date(inDate);
                            date.setHours(0);
                            date.setMinutes(0);
                            date.setSeconds(0);
                            date.setMilliseconds(0);
                            return date;
                        }
                        const obsDate = dateOnly(parsedDate);
                        const nowDate = dateOnly(new Date());
                        const oneDay = 24 * 60 * 60 * 1000;
                        age = (nowDate - obsDate) / oneDay;
                    }
                }
            }

            if (!(key in locations)) {
                locations[key] = {
                    labels:new Set(),
                    obsElements:[],
                    speciesElements:{},
                    speciesAge:{},
                    age:8
                };
            }
            var loc = locations[key];
            loc.coords = coords;
            loc.labels.add(fourLetterSpecies);
            loc.obsElements.push(obs);
            if (!(speciesCode in loc.speciesElements)) {
                loc.speciesElements[speciesCode] = species.cloneNode(true);
            }
            if (!(speciesCode in loc.speciesAge) || age < loc.speciesAge[speciesCode]) {
                loc.speciesAge[speciesCode] = age;
                var headings = loc.speciesElements[speciesCode].getElementsByTagName("h3");
                if (headings.length && dateAnchor) {
                    var span = document.createElement("span");
                    span.appendChild(dateAnchor.cloneNode(true));
                    headings[0].appendChild(span);

                    var subHeading = loc.speciesElements[speciesCode].getElementsByClassName("Heading-sub");
                    if (subHeading.length) {
                        subHeading[0].innerText = " - ";
                        subHeading[0].style.marginRight = "8px";
                    }
                }
            }
            loc.age = Math.min(loc.age, age);
        }

        // compute the center based on alert locations
        var mapCenter = { lat: 32.92, lng: -116.85 }; // default to San Diego

        let minLat = Infinity, minLng = Infinity, maxLat = -Infinity, maxLng = -Infinity;
        for (const loc of Object.values(locations)) {
            const lat = loc.coords.lat;
            const lng = loc.coords.lng;
            minLat = Math.min(minLat, lat);
            maxLat = Math.max(maxLat, lat);
            minLng = Math.min(minLng, lng);
            maxLng = Math.max(maxLng, lng);
        }

        mapCenter = { lat: (maxLat + minLat) / 2, lng: (maxLng + minLng) / 2 };

        // Create the map object
        var mapOptions = {
            center: mapCenter,
            zoom: 9 // Set the initial zoom level
        };

        var map = new google.maps.Map(document.getElementById(mapDivId), mapOptions);

        // Create an InfoWindow for markers
        const infoWindow = new google.maps.InfoWindow();

        // limit the height of the InfoWindow
        function addStyle(css) {
            var head = document.getElementsByTagName('head')[0];
            var style = document.createElement('style');
            style.type = 'text/css';
            style.innerHTML = css;
            head.appendChild(style);
        }

        addStyle(".gm-style-iw-d { max-height: 300px !important; overflow-y: auto !important; }");

        // Create location markers
        for (const location of Object.values(locations)) {
            var label = location.labels.values().next().value;
            if (location.labels.size > 1) {
                label = (location.labels.size).toString();
            }
            const symbol = {
                path: "M 133.532 -210.127 c -78.532 23.127 -67.754 73.858 -126.405 98.816 c -25.971 11.023 -85.809 17.688 -92.323 14.603 c -39.733 -18.68 -98.169 -65.403 -98.169 -65.403 s 5.22 38.051 25.583 70.903 C -205.388 -102.467 -230 -126.821 -230 -126.821 s -57 267.821 246.791 261.503 C 262 135 170.828 -20.369 197.662 -62.562 c 26.791 -42.128 75.002 -33.392 75.002 -33.392 S 243.177 -231.871 133.532 -210.127 z M 171.864 -127.468 c -10.009 0 -18.098 -8.111 -18.098 -18.098 s 8.089 -18.098 18.098 -18.098 c 9.966 0 18.076 8.132 18.076 18.098 S 181.851 -127.468 171.864 -127.468 z",
                scale: 0.07,
                anchor: {x:0, y:0},
                fillColor: (() => {
                    switch (location.age) {
                        case 0: return 'red';
                        case 1: return '#ff1818';
                        case 2: return '#ff3333';
                        case 3: return '#ff4848';
                        case 4: return '#9999ff';
                        case 5: return '#7878ff';
                        default:return '#6666ff';
                    }
                })(),
                fillOpacity: 1
            }
            const marker = new google.maps.Marker({
                map: map,
                position: location.coords,
                zIndex: 999 - location.age,
                icon: symbol,
                label: {
                    text: label,
                    fontFamily: 'Arial Narrow',
                    color: 'white',
                    fontSize: '12px'
                }
            });

            marker.addListener("click", () => {
                infoWindow.close();
                var infoDiv = document.createElement("div");
                for (const speciesElement of Object.values(location.speciesElements)) {
                    infoDiv.appendChild(speciesElement.cloneNode(true));
                }
                for (const obsElement of location.obsElements) {
                    infoDiv.appendChild(document.createElement("hr"));
                    // TODO: ideally this should delegate the details click and clone the response when it arrives
                    infoDiv.appendChild(obsElement.cloneNode(true));
                }
                infoWindow.setContent(infoDiv);
                infoWindow.open(marker.getMap(), marker);
            });
        }
    }

    // adds map div and sets up the callback to initialize the map
    function embedGoogleMap() {
        // Create a div element to hold the map
        var mapDiv = document.createElement('div');
        mapDiv.id = mapDivId; // Set the ID for the div

        // get the width of the observation list
        const observations = document.getElementsByClassName("Observation");
        // if there are no observations, don't add a map!
        if (observations.length == 0) {
            return;
        }
        const obsWidth = observations[0].getBoundingClientRect().width;

        // Set the size and position of the map div
        mapDiv.style.width = '' + obsWidth + 'px';
        mapDiv.style.height = '400px';
        mapDiv.style.margin = 'auto';

        // add map div above the list section
        var firstSection = document.getElementsByTagName("section")[0];
        firstSection.after(mapDiv);

        // Load the Google Maps JavaScript API
        var script = document.createElement('script');
        script.src = ['https://maps.googleapis.com/maps/api/js?key=' + apiKey + '&loading=async&libraries=maps'];
        script.async = true;
        script.defer = true;
        document.head.appendChild(script);

        // Call the initMap function once the API script is loaded
        script.onload = function() {
            initMap();
        };
    }

    // do it!
    embedGoogleMap();
})();