iNaturalist Street View Extension

Adds the buttons and radius input in Google Maps Street View to find nearby observations and species on iNaturalist.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         iNaturalist Street View Extension
// @namespace    https://greasyfork.org/users/1514977
// @version      2.12
// @description  Adds the buttons and radius input in Google Maps Street View to find nearby observations and species on iNaturalist.
// @author       ChatGPT, Alok, KaKa
// @include      *://maps.google.com/*
// @include      *://*.google.*/maps/*
// @icon         https://www.svgrepo.com/show/407400/seedling.svg
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    function waitForStreetViewContainer(callback) {
        const checkExist = setInterval(function () {
            const svContainer = document.querySelector('canvas');
            if (svContainer && window.location.href.includes('@')) {
                clearInterval(checkExist);
                callback();
            }
        }, 1000);
    }

    function createInterface() {
        if (document.getElementById('inat-container')) return;

        // === TOGGLE BUTTON ===
        const toggleBtn = document.createElement('img');
        toggleBtn.src = 'https://static.inaturalist.org/wiki_page_attachments/3154-medium.png';
        toggleBtn.id = 'inat-toggle-btn';
        toggleBtn.title = 'Toggle iNaturalist Panel';
        toggleBtn.style.cssText = `
            position: fixed;
            top: 118px;
            right: 16px;
            width: 48px;
            height: 48px;
            z-index: 10000;
            cursor: pointer;
            border-radius: 50%;
            box-shadow: 0 2px 6px rgba(0,0,0,0.3);
            background-color: white;
            padding: 4px;
        `;

        document.body.appendChild(toggleBtn);

        // === MAIN PANEL ===
        const container = document.createElement('div');
        container.id = 'inat-container';
        container.style.position = 'fixed';
        container.style.top = '178px';
        container.style.right = '16px';
        container.style.zIndex = 9999;
        container.style.padding = '10px';
        container.style.backgroundColor = 'rgba(240, 255, 240, 0.95)';
        container.style.border = '1px solid #ccc';
        container.style.borderRadius = '8px';
        container.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
        container.style.fontFamily = 'Arial, sans-serif';
        container.style.width = '260px';
        container.style.display = 'none'; // hidden by default

        // Toggle logic
        toggleBtn.addEventListener('click', () => {
            if (container.style.display === 'none') {
                container.style.display = 'block';
            } else {
                container.style.display = 'none';
                popoutBtn.style.display = 'none'; // 👈 hide when panel closes
            }
        });

        // === Rest of UI ===
        const button = document.createElement('button');
        button.id = 'inat-button';
        button.innerText = 'Find Nearby Observations';
        button.style.cssText = 'display:block;width:100%;padding:10px;margin-bottom:8px;background-color:#4CAF50;color:white;border:none;border-radius:5px;cursor:pointer;font-size:14px;text-shadow:2px 2px 5px rgba(0,0,0,0.5);';

        const speciesButton = document.createElement('button');
        speciesButton.id = 'inat-species-button';
        speciesButton.innerText = 'Show Nearby Species';
        speciesButton.style.cssText = 'display:block;width:100%;padding:10px;margin-bottom:8px;background-color:#2196F3;color:white;border:none;border-radius:5px;cursor:pointer;font-size:14px;text-shadow:2px 2px 5px rgba(0,0,0,0.5);';

        const radiusLabel = document.createElement('label');
        radiusLabel.innerText = 'Search Radius (km):';
        radiusLabel.style.fontSize = '12px';
        radiusLabel.style.display = 'block';
        radiusLabel.style.marginBottom = '4px';

        const radiusInput = document.createElement('input');
        radiusInput.type = 'number';
        radiusInput.id = 'inat-radius';
        radiusInput.placeholder = '1';
        radiusInput.min = '0.1';
        radiusInput.step = '0.1';
        radiusInput.style.cssText = 'width:50%;padding:6px;font-size:14px;border:1px solid #ccc;border-radius:4px;margin-bottom:10px;';

        // === Load saved radius if available ===
        const savedRadius = localStorage.getItem('inat-radius');
        if (savedRadius) {
            radiusInput.value = savedRadius;
        }

        // === Save radius whenever it changes ===
        radiusInput.addEventListener('input', () => {
            if (radiusInput.value && parseFloat(radiusInput.value) > 0) {
                localStorage.setItem('inat-radius', radiusInput.value);
            }
        });

        const taxonLabel = document.createElement('label');
        taxonLabel.innerText = 'Taxon Group:';
        taxonLabel.style.fontSize = '12px';
        taxonLabel.style.display = 'block';
        taxonLabel.style.marginBottom = '4px';

        const taxonSelect = document.createElement('select');
        taxonSelect.id = 'inat-taxon-select';
        taxonSelect.style.cssText = 'width:100%;padding:6px;font-size:14px;border:1px solid #ccc;border-radius:4px;margin-bottom:10px;';

        const taxonOptions = [
            { label: 'Plants', value: '47126' },
            { label: 'Animals', value: '1' },
            { label: 'Fungi', value: '47170' },
            { label: 'Chromista', value: '48222' },
            { label: 'Enter taxon ID', value: 'custom' }
        ];

        taxonOptions.forEach(opt => {
            const option = document.createElement('option');
            option.value = opt.value;
            option.textContent = opt.label;
            taxonSelect.appendChild(option);
        });

        const customTaxonInput = document.createElement('input');
        customTaxonInput.type = 'number';
        customTaxonInput.id = 'inat-custom-taxon';
        customTaxonInput.placeholder = 'Enter taxon ID';
        customTaxonInput.style.cssText = 'width:50%;padding:6px;font-size:14px;border:1px solid #ccc;border-radius:4px;margin-bottom:10px;display:none;';

        // === INFO BUTTON ===
        const infoBtn = document.createElement('span');
        infoBtn.innerText = 'ℹ️';
        infoBtn.title = 'About Taxon IDs';
        infoBtn.style.cssText = `
            cursor: pointer;
            font-size: 18px;
            margin-bottom: 10px;
            margin-left: 1px;
            display: none; /* hidden by default */
        `;
        // === INPUT + INFO WRAPPER ===
        const taxonInputWrapper = document.createElement('div');
        taxonInputWrapper.style.cssText = `
            display: flex;
            align-items: center;
        `;
        taxonInputWrapper.appendChild(customTaxonInput);
        taxonInputWrapper.appendChild(infoBtn);

        // === Load saved taxon selection ===
        const savedTaxon = localStorage.getItem('inat-taxon-select');
        const savedCustomTaxon = localStorage.getItem('inat-custom-taxon');
        if (savedTaxon) {
            taxonSelect.value = savedTaxon;
            if (savedTaxon === 'custom' && savedCustomTaxon) {
                customTaxonInput.value = savedCustomTaxon;
                customTaxonInput.style.display = 'block';
                infoBtn.style.display = 'inline';
            }
        }

        // === Save taxon selection whenever it changes ===
        taxonSelect.addEventListener('change', () => {
            localStorage.setItem('inat-taxon-select', taxonSelect.value);
            customTaxonInput.style.display = taxonSelect.value === 'custom' ? 'block' : 'none';
            infoBtn.style.display = taxonSelect.value === 'custom' ? 'inline' : 'none';
            repositionPopoutBtn();
        });

        customTaxonInput.addEventListener('input', () => {
            if (customTaxonInput.value) {
                localStorage.setItem('inat-custom-taxon', customTaxonInput.value);
            }
        });

        const resultsContainer = document.createElement('div');
        resultsContainer.id = 'inat-species-results';
        resultsContainer.style.cssText = `
            max-height:300px;
            overflow-y:auto;
            font-size:13px;
            background-color:white;
            padding:8px;
            border:1px solid #ccc;
            border-radius:6px;
            box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);
            display:none;
            position: relative; /* needed for absolute button */
        `;

        // === POP OUT BUTTON (hidden by default) ===
        const popoutBtn = document.createElement('div');
        popoutBtn.innerHTML = '⛶'; // ⛶ expand icon
        popoutBtn.title = 'Expand Species List';
        popoutBtn.style.cssText = `
            position: absolute;
            font-size: 16px;
            color: #333;
            background: rgba(255,255,255,0.85);
            border: 1px solid #aaa;
            border-radius: 4px;
            padding: 2px 6px;
            cursor: pointer;
            z-index: 10;
            display: none; /* start hidden */
        `;
        container.appendChild(popoutBtn);

        // === Function to reposition the button relative to resultsContainer ===
        function repositionPopoutBtn() {
            popoutBtn.style.top = (resultsContainer.offsetTop + 4) + 'px';
            popoutBtn.style.left =
                (resultsContainer.offsetLeft + 4) + 'px';
        }

        // === FULLSCREEN SPECIES PANEL ===
        const fullscreenPanel = document.createElement('div');
        fullscreenPanel.id = 'inat-fullscreen-panel';
        fullscreenPanel.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.85);
            display: none;
            align-items: center;
            justify-content: center;
            z-index: 30000;
        `;

        const fullscreenContent = document.createElement('div');
        fullscreenContent.className = 'species-grid';
        fullscreenContent.style.cssText = `
            background: white;
            padding: 20px;
            border-radius: 10px;
            max-width: 90%;
            max-height: 85%;
            overflow-y: auto;
        `;

        // Add responsive CSS rules
        const style = document.createElement('style');
        style.textContent = `
          #inat-fullscreen-panel .species-grid {
            display: grid;
            gap: 16px;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
          }

          @media (min-width: 1200px) {
            #inat-fullscreen-panel .species-grid {
              grid-template-columns: repeat(5, 1fr);
            }
          }
        `;
        document.head.appendChild(style);

        const fullscreenClose = document.createElement('div');
        fullscreenClose.innerHTML = '×';
        fullscreenClose.style.cssText = `
            position: absolute;
            top: 20px;
            right: 30px;
            font-size: 32px;
            color: white;
            cursor: pointer;
            font-weight: bold;
            z-index: 30001;
        `;

        fullscreenPanel.appendChild(fullscreenContent);
        fullscreenPanel.appendChild(fullscreenClose);
        document.body.appendChild(fullscreenPanel);

        // === OPEN FULLSCREEN (rebuild species cards in grid) ===
        popoutBtn.addEventListener('click', () => {
            fullscreenContent.innerHTML = '';

            // only iterate the actual species item containers
            const items = resultsContainer.querySelectorAll('.inat-species-item');
            items.forEach(parent => {
                const imgEl = parent.querySelector('img');
                const linkEl = parent.querySelector('a');
                const countEl = parent.querySelector('.inat-species-count');

                const card = document.createElement('div');
                card.style.cssText = `
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    justify-content: flex-start;
                    background: #fff;
                    border: 1px solid #ccc;
                    border-radius: 8px;
                    padding: 10px;
                    text-align: center;
                `;

                // Only add image if it exists and is visible
                if (imgEl && imgEl.src && imgEl.style.display !== 'none') {
                    const img = document.createElement('img');
                    img.src = imgEl.src;
                    img.style.cssText = `
                        width: 180px;
                        height: 180px;
                        object-fit: cover;
                        border-radius: 8px;
                        border: 1px solid #ccc;
                        margin-bottom: 6px;
                        cursor: pointer;
                    `;
                    img.addEventListener('click', () => {
                        // use the parent's img src and try to show original
                        const src = (parent.querySelector('img')?.src || '').replace('medium', 'original');
                        lightboxImg.src = src || (parent.querySelector('img')?.src || '');
                        lightbox.style.display = 'flex';
                    });
                    card.appendChild(img);
                } else {
                    // nicer look for cards without images
                    card.style.justifyContent = 'center';
                    card.style.paddingTop = '18px';
                    card.style.paddingBottom = '18px';
                }

                if (linkEl) {
                    const link = linkEl.cloneNode(true);
                    link.style.marginBottom = '4px';
                    card.appendChild(link);
                }

                if (countEl) {
                    const count = countEl.cloneNode(true);
                    card.appendChild(count);
                }

                fullscreenContent.appendChild(card);
            });

            fullscreenPanel.style.display = 'flex';
        });

        // === CLOSE FULLSCREEN ===
        fullscreenClose.addEventListener('click', () => {
            fullscreenPanel.style.display = 'none';
        });
        fullscreenPanel.addEventListener('click', (e) => {
            if (e.target === fullscreenPanel) {
                fullscreenPanel.style.display = 'none';
            }
        });

        // === LIGHTBOX OVERLAY ===
        const lightbox = document.createElement('div');
        lightbox.id = 'inat-lightbox';
        lightbox.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.8);
            display: none;
            align-items: center;
            justify-content: center;
            z-index: 40000;
        `;

        const lightboxImg = document.createElement('img');
        lightboxImg.style.cssText = `
            max-width: 90%;
            max-height: 90%;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            z-index: 40001;
        `;

        const closeBtn = document.createElement('div');
        closeBtn.innerHTML = '×';
        closeBtn.style.cssText = `
            position: absolute;
            top: 20px;
            right: 30px;
            font-size: 32px;
            color: white;
            cursor: pointer;
            font-weight: bold;
            z-index: 40002;
        `;

        function closeLightbox() {
            lightbox.style.display = 'none';
            lightboxImg.src = '';
        }

        // Close when clicking X
        closeBtn.addEventListener('click', closeLightbox);

        // Close when clicking outside the image
        lightbox.addEventListener('click', (e) => {
            if (e.target === lightbox) {
                closeLightbox();
            }
        });

        lightbox.appendChild(lightboxImg);
        lightbox.appendChild(closeBtn);
        document.body.appendChild(lightbox);

        // === INFO LIGHTBOX ===
        const infoLightbox = document.createElement('div');
        infoLightbox.style.cssText = `
            position: fixed;
            top: 0; left: 0;
            width: 100%; height: 100%;
            background: rgba(0,0,0,0.8);
            display: none;
            align-items: center;
            justify-content: center;
            z-index: 45000;
        `;

        const infoContent = document.createElement('div');
        infoContent.style.cssText = `
            background: white;
            padding: 20px;
            border-radius: 10px;
            max-width: 400px;
            font-family: Arial, sans-serif;
            text-align: left;
        `;

        infoContent.innerHTML = `
          <b>Quick links to the taxonomic divisions:</b><br><br>
          <div style="margin-bottom:8px;"><a href="https://www.inaturalist.org/taxa/47126#taxonomy-tab" target="_blank">🌱 Plants</a></div>
          <div style="margin-bottom:8px;"><a href="https://www.inaturalist.org/taxa/1#taxonomy-tab" target="_blank">🐾 Animals</a></div>
          <div style="margin-bottom:8px;"><a href="https://www.inaturalist.org/taxa/47170#taxonomy-tab" target="_blank">🍄 Fungi</a></div>
          <div style="margin-bottom:12px;"><a href="https://www.inaturalist.org/taxa/48222#taxonomy-tab" target="_blank">🌊 Chromista</a></div>
          Find the taxon IDs in the web page links of the taxonomic divisions on iNaturalist.
        `;

        const infoClose = document.createElement('div');
        infoClose.innerHTML = '&times;';
        infoClose.style.cssText = `
            position: absolute;
            top: 20px; right: 30px;
            font-size: 28px;
            color: white;
            cursor: pointer;
        `;

        infoBtn.addEventListener('click', () => {
            infoLightbox.style.display = 'flex';
        });
        infoClose.addEventListener('click', () => {
            infoLightbox.style.display = 'none';
        });
        infoLightbox.addEventListener('click', e => {
            if (e.target === infoLightbox) infoLightbox.style.display = 'none';
        });

        infoLightbox.appendChild(infoContent);
        infoLightbox.appendChild(infoClose);
        document.body.appendChild(infoLightbox);

        function getTaxonId() {
            if (taxonSelect.value === 'custom') {
                const id = parseInt(customTaxonInput.value);
                return isNaN(id) ? null : id;
            }
            return taxonSelect.value;
        }

        function extractCoordinatesFromUrl() {
            const matches = window.location.href.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
            if (matches && matches.length === 3) {
                return {
                    lat: matches[1],
                    lng: matches[2]
                };
            }
            return null;
        }

        button.onclick = function () {
            const coords = extractCoordinatesFromUrl();
            if (!coords) return alert('Could not extract coordinates from URL.');

            const { lat, lng } = coords;
            let radius = parseFloat(radiusInput.value);
            if (isNaN(radius) || radius <= 0) radius = 1;

            const taxonId = getTaxonId();
            if (!taxonId) return alert('Please enter a valid Taxon ID.');

            const inatUrl = `https://www.inaturalist.org/observations?taxon_id=${taxonId}&lat=${lat}&lng=${lng}&radius=${radius}&subview=map`;
            window.open(inatUrl, '_blank');
        };

        speciesButton.onclick = async function () {
            const coords = extractCoordinatesFromUrl();
            if (!coords) return alert('Could not extract coordinates from URL.');

            const { lat, lng } = coords;
            let radius = parseFloat(radiusInput.value);
            if (isNaN(radius) || radius <= 0) radius = 1;

            const taxonId = getTaxonId();
            if (!taxonId) return alert('Please enter a valid Taxon ID.');

            const url = `https://api.inaturalist.org/v1/observations/species_counts?lat=${lat}&lng=${lng}&radius=${radius}&taxon_id=${taxonId}&verifiable=true&locale=en`;

            resultsContainer.innerHTML = '<em>Loading nearby species...</em>';
            resultsContainer.style.display = 'block';
            popoutBtn.style.display = 'none'; // 👈 hide while loading

            try {
                const response = await fetch(url);
                const data = await response.json();

                if (!data.results || data.results.length === 0) {
                    resultsContainer.innerHTML = '<strong>No species found.</strong>';
                    popoutBtn.style.display = 'none'; // hide button
                    return;
                }

                const sorted = data.results.sort((a, b) => b.count - a.count);
                resultsContainer.innerHTML = '';
                popoutBtn.style.display = 'block'; // show button once results load
                repositionPopoutBtn();

                sorted.forEach(species => {
                    const taxon = species.taxon;
                    if (!taxon) return;

                    const div = document.createElement('div');
                    div.className = 'inat-species-item';
                    div.style.textAlign = 'center';
                    div.style.marginBottom = '14px';

                    const img = document.createElement('img');
                    img.src = taxon.default_photo ? taxon.default_photo.medium_url : '';
                    img.alt = taxon.name;
                    img.style.width = '120px';
                    img.style.height = '120px';
                    img.style.objectFit = 'cover';
                    img.style.borderRadius = '8px';
                    img.style.border = '1px solid #ccc';
                    img.style.marginBottom = '6px';
                    img.style.cursor = 'pointer'; // 👈 makes it show hand cursor
                    if (!taxon.default_photo) img.style.display = 'none';

                    // === CLICK TO ENLARGE ===
                    if (taxon.default_photo) {
                        img.addEventListener('click', () => {
                            lightboxImg.src = taxon.default_photo.original_url || taxon.default_photo.medium_url;
                            lightbox.style.display = 'flex';
                        });
                    }

                    const link = document.createElement('a');
                    link.href = `https://www.inaturalist.org/taxa/${taxon.id}`;
                    link.target = '_blank';
                    link.style.color = '#2c3e50';
                    link.style.textDecoration = 'none';
                    link.style.fontWeight = 'bold';
                    link.style.display = 'block';
                    link.textContent = `${taxon.preferred_common_name || taxon.name} (${taxon.name})`;

                    const count = document.createElement('div');
                    count.className = 'inat-species-count';
                    count.style.fontSize = '12px';
                    count.style.color = '#555';
                    count.textContent = `Observations: ${species.count}`;

                    div.appendChild(img);
                    div.appendChild(link);
                    div.appendChild(count);
                    resultsContainer.appendChild(div);
                });
            } catch (err) {
                resultsContainer.innerHTML = '<strong>Error loading species data.</strong>';
                popoutBtn.style.display = 'none';
                console.error(err);
            }
        };

        // Add everything to panel
        container.appendChild(button);
        container.appendChild(speciesButton);
        container.appendChild(radiusLabel);
        container.appendChild(radiusInput);
        container.appendChild(taxonLabel);
        container.appendChild(taxonSelect);
        container.appendChild(taxonInputWrapper);
        container.appendChild(resultsContainer);

        document.body.appendChild(container);
    }

    let lastUrl = location.href;
    new MutationObserver(() => {
        const currentUrl = location.href;
        if (currentUrl !== lastUrl) {
            lastUrl = currentUrl;
            setTimeout(() => {
                if (currentUrl.includes('@') && currentUrl.includes('!1s')) {
                    createInterface();
                }
            }, 1000);
        }
    }).observe(document, { subtree: true, childList: true });

    waitForStreetViewContainer(() => {
        if (window.location.href.includes('@') && window.location.href.includes('!1s')) {
            createInterface();
        }
    });
})();