eBird Alerts Map

Adds a Google Map with eBird alert locations as markers.

当前为 2024-04-20 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         eBird Alerts Map
// @namespace    http://tampermonkey.net/
// @version      2024-04-19_1.11
// @description  Adds a Google 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();
})();