// ==UserScript==
// @name eBird Alerts Map
// @namespace http://tampermonkey.net/
// @version 2025-04-25_1.1
// @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 specNameSpan = species.getElementsByTagName("span")[0];
const specName = specNameSpan.textContent
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;
}
}
}
console.log(coords.lat, ",", coords.lng, ",", age, ",", fourLetterSpecies, ",", specName);
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();
})();