Map-Making Biodiversity

visualize INaturalist observations and osm poi data on map making app

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Map-Making Biodiversity
// @namespace    https://greasyfork.org/users/1179204
// @description  visualize INaturalist observations and osm poi data on map making app
// @version      1.1.2
// @license      BSD 3-Clause
// @author       KaKa
// @match        *://map-making.app/maps/*
// @grant        GM_addStyle
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @icon         https://www.svgrepo.com/show/499260/leaf.svg
// @require      https://unpkg.com/[email protected]/dist.min.js
// @require      https://unpkg.com/[email protected]/dist/h3-js.umd.js
// @require      https://update.greasyfork.org/scripts/572089/1788027/OSM%20Map%20Features%20Data.js
// ==/UserScript==

(function() {
    'use strict';
    const { GoogleMapsOverlay, ScatterplotLayer, HeatmapLayer } = deck;

    let overlay = null;
    let currentTaxonId = null;
    let currentOSMTag = null;
    let observations = [];
    let layerVisible = true;
    let currentZoom;
    let mapBoundsListener = null;
    let lastRequestedBounds = null;
    let currentLoadRequest = null;
    let pendingRequest = null;

    const imageCache = new Map();

    const MIN_TILE_Z = 1;
    const MAX_TILE_Z = 10;
    const TILE_ZOOM_OFFSET = 2;
    const TILE_TTL_MS = 3 * 60 * 1000;
    const MAX_TILES = 300;
    const MAX_CONCURRENCY = 6;
    const RENDER_BUFFER_RATIO = 0.25;
    const MAX_RENDER_POINTS = 50000;

    const tileCache = new Map();
    const inflightControllers = new Map();
    let viewportRequestId = 0;

    const observationsById = new Map();
    let observationsArray = [];

    const osmFeaturesById = new Map();
    let osmFeaturesArray = [];

    const INAT_LAYER_ID = "inat-layer";
    const OSM_LAYER_ID = "osm-layer";
    let currentDataSource = localStorage.getItem('dataSource'); // 'inaturalist' | 'osm'
    if(!currentDataSource) currentDataSource = 'inaturalist'

    // Track the query origin and center for distance calculation
    let queryOrigin = null; // Can be 'location', 'polygon', or 'bbox'
    let queryCenter = null; // { lat, lng } for distance calculations

    const eyeOpenSVG = `
<svg height="24" width="24" viewBox="0 0 24 24">
  <path d="M22 12s-3.636 7-10 7-10-7-10-7 3.636-7 10-7c2.878 0 5.198 1.432 6.876 3M9 12a3 3 0 1 0 3-3"/>
</svg>`;

    const eyeClosedSVG = `
<svg height="24" width="24" viewBox="0 0 24 24">
  <path d="M22 12s-.692 1.332-2 2.834M10 5.236A8.7 8.7 0 0 1 12 5c2.878 0 5.198 1.432 6.876 3M12 9a2.995 2.995 0 0 1 3 3M3 3l18 18m-9-6a3 3 0 0 1-2.959-2.5M4.147 9c-.308.345-.585.682-.828 1C2.453 11.128 2 12 2 12s3.636 7 10 7q.512 0 1-.058"
        stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;

    const importSVG = `
<svg width="20" height="20" viewBox="0 0 24 24">
<path d="M21,14a1,1,0,0,0-1,1v4a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V15a1,1,0,0,0-2,0v4a3,3,0,0,0,3,3H19a3,3,0,0,0,3-3V15A1,1,0,0,0,21,14Zm-9.71,1.71a1,1,0,0,0,.33.21.94.94,0,0,0,.76,0,1,1,0,0,0,.33-.21l4-4a1,1,0,0,0-1.42-1.42L13,12.59V3a1,1,0,0,0-2,0v9.59l-2.29-2.3a1,1,0,1,0-1.42,1.42Z"></path>
</svg>
`

    function throttleAsync(asyncFn, delay) {
        let lastCall = 0;
        let timer = null;
        let isRunning = false;

        return async function (...args) {
            const now = Date.now();

            if (isRunning) return;

            if (now - lastCall >= delay) {
                lastCall = now;
                isRunning = true;
                try {
                    await asyncFn.apply(this, args);
                } finally {
                    isRunning = false;
                }
            } else {
                clearTimeout(timer);
                timer = setTimeout(async () => {
                    lastCall = Date.now();
                    isRunning = true;
                    try {
                        await asyncFn.apply(this, args);
                    } finally {
                        isRunning = false;
                    }
                }, delay - (now - lastCall));
            }
        };
    }

    function getCacheKey(bounds, taxonId, zoom) {
        return `${taxonId}_${bounds.north.toFixed(2)}_${bounds.west.toFixed(2)}_z${zoom}`;
    }

    async function runWithConcurrency(tasks, limit = 5) {
        const results = [];
        let index = 0;

        async function worker() {
            while (index < tasks.length) {
                const currentIndex = index++;
                try {
                    results[currentIndex] = await tasks[currentIndex]();
                } catch (error) {
                    results[currentIndex] = null;
                }
            }
        }

        const workers = Array.from({ length: limit }, worker);
        await Promise.all(workers);

        return results.filter(r => r !== null);
    }

    function initControls() {
        if (!map) return;

        const mapContainer = document.querySelector("[role='region']");
        if (!mapContainer) return;

        const search = createControl({
            iconSVG: `<svg height="20" width="20" viewBox="0 0 300 300"><path d="M273.587,214.965c49.11-49.111,49.109-129.021,0-178.132c-49.111-49.111-129.02-49.111-178.13,0 C53.793,78.497,47.483,140.462,76.51,188.85c0,0,2.085,3.498-0.731,6.312c-16.065,16.064-64.263,64.263-64.263,64.263 c-12.791,12.79-15.836,30.675-4.493,42.02l1.953,1.951c11.343,11.345,29.229,8.301,42.019-4.49c0,0,48.096-48.097,64.128-64.128 c2.951-2.951,6.448-0.866,6.448-0.866C169.958,262.938,231.923,256.629,273.587,214.965z M118.711,191.71 c-36.288-36.288-36.287-95.332,0.001-131.62c36.288-36.287,95.332-36.288,131.619,0c36.288,36.287,36.288,95.332,0,131.62 C214.043,227.996,155,227.996,118.711,191.71z"></path> <g> <path d="M126.75,118.424c-1.689,0-3.406-0.332-5.061-1.031c-6.611-2.798-9.704-10.426-6.906-17.038 c17.586-41.559,65.703-61.062,107.261-43.476c6.611,2.798,9.704,10.426,6.906,17.038c-2.799,6.612-10.425,9.703-17.039,6.906 c-28.354-11.998-61.186,1.309-73.183,29.663C136.629,115.445,131.815,118.424,126.75,118.424z"></path></svg>`,
            label: "Search species & OSM features"
        });

        search.button.onclick = openSearchModal;

        const toggle = createControl({
            iconSVG: eyeOpenSVG,
            label: "Toggle layer visibility"
        });

        const importControl = createControl({
            iconSVG: importSVG,
            label: "Import locations to map making app",
        });

        importControl.button.onclick = importPointsToMapMaking;

        function toggleLayer() {
            layerVisible = !layerVisible;

            if (layerVisible) {
                toggle.button.innerHTML = eyeOpenSVG;
                currentDataSource === 'inaturalist' ? loadDataForViewport() : renderDeckLayer(osmFeaturesArray, 'osm');
            } else {
                toggle.button.innerHTML = eyeClosedSVG;
                overlay.setProps({ layers: [] });
            }
        }

        const container = document.querySelector(".embed-controls");

        appendControl(container, search.wrapper);
        appendControl(container, toggle.wrapper);
        appendControl(container, importControl.wrapper);

        toggle.button.onclick = () => toggleLayer();

        setupMapListeners();
    }

    function setupMapListeners() {
        const debouncedLoad = throttleAsync(loadDataForViewport, 300);

        map.addListener('zoom_changed', debouncedLoad);
        map.addListener('bounds_changed', debouncedLoad);
        map.addListener('dragend', debouncedLoad);
    }

    function createControl({ iconSVG, label }) {
        const wrapper = document.createElement("div");
        wrapper.className = "embed-controls__control";
        wrapper.setAttribute("data-position", "bottom-left");

        wrapper.style.position = "absolute";
        wrapper.style.inset = "auto auto 20px 20px";
        wrapper.style.zIndex = "9999";

        const inner = document.createElement("div");
        inner.className = "map-control white map-control--button";

        const button = document.createElement("button");
        button.setAttribute("role", "tooltip");
        button.setAttribute("aria-label", label);
        button.setAttribute("data-microtip-position", "right");
        button.innerHTML = iconSVG;

        inner.appendChild(button);
        wrapper.appendChild(inner);
        return { wrapper, button };
    }

    function appendControl(container, wrapper) {
        const controls = container.querySelectorAll(".embed-controls__control");

        const index = controls.length;
        const gap = 10;
        const height = 40;

        const zoom = window.devicePixelRatio;
        const bottom = (zoom <1.5 ?-90 :-180) + index * (height + gap);

        wrapper.style.position = "absolute";
        wrapper.style.left = "0px";
        wrapper.style.bottom = bottom + "px";
        wrapper.style.zIndex = "9999";

        container.appendChild(wrapper);
    }

    // Calculate distance between two points in kilometers
    function calculateDistance(lat1, lng1, lat2, lng2) {
        const R = 6371; // Earth's radius in km
        const dLat = (lat2 - lat1) * Math.PI / 180;
        const dLng = (lng2 - lng1) * Math.PI / 180;
        const a =
              Math.sin(dLat/2) * Math.sin(dLat/2) +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLng/2) * Math.sin(dLng/2);
        const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
        return R * c;
    }

    // Get distance range label
    function getDistanceLabel(distanceKm) {
        if (distanceKm <= 5) return "<5km";
        if (distanceKm <= 10) return "5-10km";
        if (distanceKm <= 50) return "10-50km";
        if (distanceKm <= 100) return "50-100km";
        return ">100km";
    }

    function importPointsToMapMaking() {
        const pointsToImport = currentDataSource === 'inaturalist' ? observationsArray : osmFeaturesArray;

        if (!pointsToImport || pointsToImport.length === 0) {
            alert("Data not found");
            return;
        }

        const mmd = pointsToImport.map(item => {
            let tags = [item.name];

            // Add distance range if available
            if (item.distance !== undefined) {
                const distanceLabel = getDistanceLabel(item.distance);
                tags.push(distanceLabel);
            }

            return {
                lat: item.lat,
                lng: item.lng,
                heading: 0,
                pitch: 0,
                zoom: 0.1,
                panoId: null,
                countryCode: null,
                stateCode: null,
                extra: {
                    tags: tags
                }
            };
        });

        try {
            editor.importFromString(JSON.stringify(mmd));
            Swal.fire({
                title: `${mmd.length} locations have been imported✔️`,
                timer: 2500,
                showConfirmButton: false,
                backdrop: null,
                customClass: {
                    popup: "swal-small-popup"
                }
            });
        } catch (err) {
            Swal.fire({
                title: 'Failed to import ❌',
                timer: 2500,
                showConfirmButton: false,
                backdrop: null,
                customClass: {
                    popup: "swal-small-popup"
                }
            });
        }
    }

    async function searchOSMTags(keyword) {
        const q = keyword.trim().toLowerCase();
        if (!q) return [];

        if (q.includes("=")) {
            const [k, v] = q.split("=").map(s => s.trim());
            return window.OSM_MAP_FEATURES
                .filter(item => item.key === k && item.value === v)
                .map(item => ({
                key: item.key,
                value: item.value,
                score: 999,
                thumbnail: item.image_url,
                description: item.description,
                element: item.element
            }));
        }

        return window.OSM_MAP_FEATURES
            .map(item => {
            const key = item.key || "";
            const value = item.value || "";
            const desc = item.description || "";

            const text = `${key} ${value} ${desc}`.toLowerCase();

            let score = 0;

            if (key === q) score += 100;
            if (value === q) score += 100;
            if (key.includes(q)) score += 50;
            if (value.includes(q)) score += 50;
            if (text.includes(q)) score += 10;

            return {
                key,
                value,
                score,
                thumbnail: item.image_url,
                description: item.description,
                element: item.element
            };
        })
            .filter(item => item.score > 0)
            .sort((a, b) => b.score - a.score)
            .slice(0, 50);
    }


    function buildQuery(tag, bbox) {
        const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;

        const elements = tag.element || ["node", "way", "relation"];

        const parts = elements.map(type => {
            return `${type}["${tag.key}"="${tag.value}"](${bboxStr});`;
        }).join("\n  ");

        return `[out:json][timeout:180];
(
  ${parts}
);
out center;`;
    }


    function buildAroundQuery(tag, center, radiusKm) {
        const radiusMeters = radiusKm * 1000;
        const elements = tag.element || ["node", "way", "relation"];

        const parts = elements.map(type => {
            return `${type}["${tag.key}"="${tag.value}"](around:${radiusMeters},${center.lat},${center.lng});`;
        }).join("\n  ");

        return `[out:json][timeout:180];
(
  ${parts}
);
out center;`;
    }


    function buildPolygonQuery(tag, polygonCoords) {
        const coordString = polygonCoords.map(coord => `${coord[1]} ${coord[0]}`).join(" ");
        const elements = tag.element || ["node", "way", "relation"];

        const parts = elements.map(type => {
            return `${type}["${tag.key}"="${tag.value}"](poly:"${coordString}");`;
        }).join("\n  ");

        return `[out:json][timeout:180];
(
  ${parts}
);
out center;`;
    }

    async function openSearchModal() {
        const { value: dataSource } = await Swal.fire({
            title: "Select data source",
            width: 620,
            input: "radio",
            inputOptions: {
                inaturalist: "🌿 iNaturalist (Species)",
                osm: "🗺️ OpenStreetMap (Features)"
            },
            inputValue: currentDataSource,
            showCancelButton: true,
            backdrop: null,
        });

        if (!dataSource) return;

        currentDataSource = dataSource;
        localStorage.setItem('dataSource', currentDataSource)

        if (dataSource === 'inaturalist') {
            await openSpeciesSelector();
        } else {
            await openOSMFeatureSelector();
        }
    }

    async function openSpeciesSelector() {
        const { value: keyword } = await Swal.fire({
            title: "Enter species name",
            input: "text",
            inputPlaceholder: "Example: Panthera tigris",
            backdrop: null,
            showCloseButton: true,
        });

        if (!keyword) return;

        const input = keyword.trim();
        Swal.showLoading()
        try {
            const res = await fetch(
                `https://api.inaturalist.org/v1/taxa?q=${encodeURIComponent(input)}&per_page=100`
            );

            const data = await res.json();

            if (!data.results || data.results.length === 0) {
                Swal.fire("No results found", "Try another name", "warning");
                return;
            }

            const listHTML = data.results.map(item => {
                const sci = item.name || "";
                const rank = item.rank || "—";
                const img = item.default_photo?.square_url || "";
                return `
            <div class="inat-item" data-id="${item.id}">
                <img class="inat-thumb" src="${img}" />
                <div class="inat-sci">${sci}</div>
                <div class="inat-rank">[${rank}]</div>
                <div class="inat-count">${item.observations_count}</div>
            </div>
        `;
            }).join("");

            await Swal.fire({
                width: 600,
                title: "Select a species",
                html: `
                <div class="inat-list">
                    ${listHTML}
                </div>
            `,
                showCloseButton: true,
                showConfirmButton: false,
                showCancelButton: true,
                backdrop: null,

                didOpen: () => {
                    const items = document.querySelectorAll(".inat-item");

                    items.forEach(el => {
                        el.addEventListener("click", () => {
                            currentTaxonId = el.dataset.id;
                            observations = [];
                            loadDataForViewport();
                            Swal.close();
                        });
                    });
                }

            });
        } catch (error) {
            console.error('Error searching species:', error);
            Swal.fire("Error", "Failed to search species", "error");
        }
    }

    async function openOSMFeatureSelector() {

        await Swal.fire({
            title: "Search OSM features",
            width: 520,
            html: `
            <input id="osm-search-input" class="swal2-input" style="width:300px;font-size:15px"placeholder="Type keyword (e.g. school, tree, bench)">
            <div id="osm-results" style="max-height:300px;overflow:auto;margin-top:10px;"></div>
        `,
            showCloseButton: true,
            showConfirmButton: false,
            backdrop: null,

            didOpen: () => {
                const input = document.getElementById("osm-search-input");
                const resultsContainer = document.getElementById("osm-results");

                const renderResults = (results) => {
                    if (!results || results.length === 0) {
                        resultsContainer.innerHTML = `<div style="padding:10px;color:#888;">No results</div>`;
                        return;
                    }

                    resultsContainer.innerHTML = results.map(item => `
<div class="osm-item"
     data-key="${item.key}"
     data-value="${item.value}"
     title="${item.description || ''}">

    <img class="osm-thumb" src="${item.thumbnail || ''}">

    <div class="osm-kv">
        <span class="osm-key">${item.key}</span>
        <span class="osm-eq">=</span>
        <span class="osm-value">${item.value}</span>
    </div>

</div>
`).join("");

                    const items = resultsContainer.querySelectorAll(".osm-item");

                    items.forEach(el => {
                        el.addEventListener("click", () => {
                            const key = el.dataset.key;
                            const value = el.dataset.value;

                            currentOSMTag = { key, value };
                            osmFeaturesById.clear();
                            osmFeaturesArray = [];

                            Swal.close();
                            loadPOIForViewport();
                        });
                    });
                };

                const throttledSearch = throttleAsync(async () => {
                    const keyword = input.value.trim();
                    if (!keyword) {
                        renderResults([]);
                        return;
                    }

                    const results = await searchOSMTags(keyword);
                    renderResults(results);
                }, 200);

                input.addEventListener("input", throttledSearch);
            }
        });
    }

    // ===== INATURALIST FETCH & CACHE =====

    async function fetchINatTile({ taxonId, bbox, signal, per_page = 200, page = 1 }) {
        const url =
              `https://api.inaturalist.org/v1/observations?` +
              `taxon_id=${taxonId}` +
              `&nelat=${bbox.north}` +
              `&nelng=${bbox.east}` +
              `&swlat=${bbox.south}` +
              `&swlng=${bbox.west}` +
              `&per_page=${per_page}&page=${page}`;

        const res = await fetch(url, { signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        return data.results.map(d => ({
            id: d.id,
            lat: d.geojson?.coordinates?.[1],
            lng: d.geojson?.coordinates?.[0],
            name: d.species_guess || "Unknown",
            photo: d.observation_photos?.[0]?.photo?.url?.replace("square", "medium"),
            observed_at: d.time_observed_at || d.observed_on || null
        })).filter(d => d.lat && d.lng);
    }

    // ===== OSM/OVERPASS FETCH & CACHE =====

    async function fetchOSMPOI({ bbox, query = null }) {
        // Use provided query or build from bbox
        const queryStr = query || buildQuery(currentOSMTag, bbox);

        const url = 'https://overpass-api.de/api/interpreter';

        const res = await fetch(url, {
            method: 'POST',
            body: queryStr
        });

        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();

        const features = [];

        if (data.elements) {
            for (const element of data.elements) {
                let lat, lng, name;

                if (element.type === 'node') {
                    lat = element.lat;
                    lng = element.lon;
                } else if (element.center) {
                    lat = element.center.lat;
                    lng = element.center.lon;
                } else if (element.bounds) {
                    lat = (element.bounds.minlat + element.bounds.maxlat) / 2;
                    lng = (element.bounds.minlon + element.bounds.maxlon) / 2;
                }

                if (!lat || !lng) continue;

                name = element.tags?.highway || element.tags?.['name:en'] || element.tags?.name;

                features.push({
                    id: `osm_${element.type}_${element.id}`,
                    lat,
                    lng,
                    name,
                    tags: element.tags || {}
                });
            }
        }

        return features;
    }

    async function getINatImage(name) {
        if (!name) return null;

        if (imageCache.has(name)) return imageCache.get(name);

        try {
            const res = await fetch(
                `https://api.inaturalist.org/v1/taxa?q=${encodeURIComponent(name)}&per_page=1`
            );
            const data = await res.json();
            const url = data.results?.[0]?.default_photo?.medium_url || null;

            imageCache.set(name, url);
            return url;
        } catch {
            return null;
        }
    }

    function lngToTileX(lng, zoom) {
        return Math.floor((lng + 180) / 360 * Math.pow(2, zoom));
    }

    function latToTileY(lat, zoom) {
        const rad = lat * Math.PI / 180;
        return Math.floor((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, zoom));
    }

    function tileToBBox(x, y, zoom) {
        const n = Math.pow(2, zoom);
        const west = x / n * 360 - 180;
        const east = (x + 1) / n * 360 - 180;
        const north = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
        const south = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n))) * 180 / Math.PI;
        return { north, south, east, west };
    }

    function getTileKey(z, x, y, source = 'inat') {
        return `${source}_${z}/${x}/${y}`;
    }

    function touchTile(key) {
        const entry = tileCache.get(key);
        if (!entry) return;
        entry.lastUsed = Date.now();
        tileCache.delete(key);
        tileCache.set(key, entry);
        if (tileCache.size > MAX_TILES) {
            const oldestKey = tileCache.keys().next().value;
            tileCache.delete(oldestKey);
        }
    }

    function computeTileZoom(mapZoom) {
        const z = Math.max(MIN_TILE_Z, Math.min(MAX_TILE_Z, Math.floor(mapZoom) - TILE_ZOOM_OFFSET));
        return z;
    }

    function getTilesForViewport(bounds, tileZoom) {
        const ne = bounds.getNorthEast();
        const sw = bounds.getSouthWest();

        const xMin = lngToTileX(sw.lng(), tileZoom);
        const xMax = lngToTileX(ne.lng(), tileZoom);
        const yMin = latToTileY(ne.lat(), tileZoom);
        const yMax = latToTileY(sw.lat(), tileZoom);

        const tiles = [];
        for (let x = xMin; x <= xMax; x++) {
            for (let y = yMin; y <= yMax; y++) {
                tiles.push({ x, y, z: tileZoom });
            }
        }
        return tiles;
    }

    function mergeINatTileData(newPoints) {
        let added = 0;
        for (const p of newPoints) {
            if (!observationsById.has(p.id)) {
                observationsById.set(p.id, p);
                added++;
            } else {
                observationsById.set(p.id, Object.assign(observationsById.get(p.id), p));
            }
        }
        if (added > 0) {
            observationsArray = Array.from(observationsById.values());
        }
        return added;
    }

    function mergeOSMTileData(newFeatures) {
        let added = 0;
        for (const p of newFeatures) {
            if (!osmFeaturesById.has(p.id)) {
                osmFeaturesById.set(p.id, p);
                added++;
            } else {
                osmFeaturesById.set(p.id, Object.assign(osmFeaturesById.get(p.id), p));
            }
        }
        if (added > 0) {
            osmFeaturesArray = Array.from(osmFeaturesById.values());
        }
        return added;
    }

    function ensureOverlay() {
        if (!overlay) {
            overlay = new deck.GoogleMapsOverlay({ layers: [] });
            overlay.setMap(map);
        }
    }

    function renderDeckLayer(points, source) {
        if (!layerVisible) return;
        ensureOverlay();

        if (!points || points.length === 0) {
            console.warn(`No points to render for ${source}`);
            return;
        }

        const fillColor = source === 'inaturalist'
        ? [255, 120, 0, 160]   // iNat
        : [112, 72, 235, 160]; // OSM #7048eb


        const layerId = source === 'inaturalist' ? INAT_LAYER_ID : OSM_LAYER_ID;

        let newLayer;

        newLayer = new deck.ScatterplotLayer({
            id: layerId,
            data: points,
            getPosition: d => [d.lng, d.lat],
            getRadius: source === 'inaturalist' ? 5 : 4,
            radiusUnits: 'pixels',
            getFillColor: fillColor,
            pickable: true,
            onHover: ({ object, x, y }) => {
                if (object) showTooltip(object, x, y);
                else hideTooltip();
            }
        });

        const otherLayers = (overlay.props.layers || []).filter(l => l.id !== layerId);

        overlay.setProps({
            layers: [...otherLayers, newLayer]
        });
    }

    function getPointsForRender(bounds) {

        const ne = bounds.getNorthEast();
        const sw = bounds.getSouthWest();

        const latMin = sw.lat();
        const latMax = ne.lat();
        const lngMin = sw.lng();
        const lngMax = ne.lng();

        const latPad = (latMax - latMin) * RENDER_BUFFER_RATIO;
        const lngPad = (lngMax - lngMin) * RENDER_BUFFER_RATIO;

        const minLat = latMin - latPad;
        const maxLat = latMax + latPad;
        const minLng = lngMin - lngPad;
        const maxLng = lngMax + lngPad;

        const filtered = observationsArray.filter(d =>
                                                  d.lat >= minLat && d.lat <= maxLat && d.lng >= minLng && d.lng <= maxLng
                                                 );

        if (filtered.length > MAX_RENDER_POINTS) {
            const step = Math.ceil(filtered.length / MAX_RENDER_POINTS);
            const sampled = [];
            for (let i = 0; i < filtered.length; i += step) sampled.push(filtered[i]);
            return sampled;
        }

        return filtered;
    }

    async function loadDataForViewport() {
        if (!currentTaxonId) return;
        if (!layerVisible) return;

        const bounds = map.getBounds();
        if (!bounds) return;
        if (currentLoadRequest) {
            currentLoadRequest.abort();
        }

        const controller = new AbortController();
        currentLoadRequest = controller;
        const requestId = ++viewportRequestId;

        const mapZoom = map.getZoom();
        const tileZoom = computeTileZoom(mapZoom);

        const tiles = getTilesForViewport(bounds, tileZoom);

        const now = Date.now();
        const tasks = [];

        for (const { x, y, z } of tiles) {
            const key = getTileKey(z, x, y);

            const cached = tileCache.get(key);
            if (cached && cached.expiresAt > now) {
                touchTile(key);
                mergeINatTileData(cached.data);
                continue;
            }

            if (inflightControllers.has(key)) continue;

            tasks.push(async () => {
                if (requestId !== viewportRequestId) return [];

                const controller = new AbortController();
                inflightControllers.set(key, controller);

                try {
                    const bbox = tileToBBox(x, y, z);
                    const data = await fetchINatTile({
                        taxonId: currentTaxonId,
                        bbox,
                        signal: controller.signal,
                        per_page: 200,
                        page: 1
                    });

                    if (requestId !== viewportRequestId) return [];

                    tileCache.set(key, {
                        data,
                        expiresAt: Date.now() + TILE_TTL_MS,
                        lastUsed: Date.now()
                    });
                    touchTile(key);

                    mergeINatTileData(data);

                    return data;
                } catch (e) {
                    if (e.name === 'AbortError') {
                        return [];
                    }
                    console.error('Tile fetch error', key, e);
                    return [];
                } finally {
                    inflightControllers.delete(key);
                }
            });
        }

        if (tasks.length > 0) {
            try {
                await runWithConcurrency(tasks, MAX_CONCURRENCY);
            } catch (e) {
                console.error('Concurrency runner error', e);
            }
        }

        if (requestId !== viewportRequestId) return;

        const pointsToRender = getPointsForRender(bounds);

        renderDeckLayer(pointsToRender, 'inaturalist');
    }

    // Check if current location exists
    function getCurrentLocation() {
        try {
            return editor?.currentLocation?.updatedProps?.location;
        } catch (e) {
            return null;
        }
    }

    // Check if polygon exists
    function getSelectedPolygon() {
        try {
            return editor?.selections?.[0]?.props?.polygon?.geometry?.coordinates?.[0]?.[0];
        } catch (e) {
            return null;
        }
    }

    async function loadPOIForViewport() {
        if (!currentOSMTag) return;

        const currentLocation = getCurrentLocation();
        const selectedPolygon = getSelectedPolygon();
        const bounds = map.getBounds();

        let query = null;
        let features = [];

        // Priority 1: Check for current location
        if (currentLocation && currentLocation.lat !== undefined && currentLocation.lng !== undefined) {
            const { value: radiusInput } = await Swal.fire({
                title: "Enter search radius",
                input: "number",
                inputValue: "50",
                inputAttributes: {
                    min: "1",
                    max: "200",
                    step: "1"
                },
                inputPlaceholder: "Radius in kilometers",
                showCancelButton: true,
                backdrop: null,
                didOpen: () => {
                    const input = Swal.getInput();
                    input.focus();
                }
            });

            if (radiusInput === undefined) return; // User cancelled

            const radius = parseFloat(radiusInput);

            if (radius > 100) {
                Swal.fire({
                    title: "⚠️ Large search area",
                    text: "Query range exceeds 100km. This may fail or return incomplete results.",
                    icon: "warning",
                    timer: 3000,
                    backdrop: null,
                    customClass: {
                        popup: "swal-small-popup"
                    }
                });
            }

            try {
                queryOrigin = 'location';
                queryCenter = currentLocation;
                query = buildAroundQuery(currentOSMTag, currentLocation, radius);

                Swal.fire({
                    title: "Searching…",
                    text: `Querying Overpass API around location…`,
                    allowOutsideClick: false,
                    didOpen: () => Swal.showLoading()
                });

                features = await fetchOSMPOI({ bbox: null, query });

                // Calculate distances
                for (const feature of features) {
                    feature.distance = calculateDistance(
                        currentLocation.lat,
                        currentLocation.lng,
                        feature.lat,
                        feature.lng
                    );
                }

                mergeOSMTileData(features);
                renderDeckLayer(osmFeaturesArray, 'osm');

                Swal.fire({
                    title: features.length > 0
                    ? `${features.length} POIs loaded ✔️`
                    : "No POIs found!",
                    timer: 2500,
                    showConfirmButton: false,
                    backdrop: false,
                    customClass: {
                        popup: "swal-small-popup"
                    }
                });

                return;
            } catch (err) {
                console.error("OSM POI load failed:", err);
                Swal.fire({
                    title: "Failed to load POIs",
                    text: "Overpass API request failed ❌",
                    timer: 2500,
                    showConfirmButton: false,
                    backdrop: false,
                    customClass: {
                        popup: "swal-small-popup"
                    }
                });
                return;
            }
        }

        // Priority 2: Check for polygon
        if (selectedPolygon && selectedPolygon.length > 0) {
            try {
                queryOrigin = 'polygon';
                query = buildPolygonQuery(currentOSMTag, selectedPolygon);

                Swal.fire({
                    title: "Searching…",
                    text: `Querying Overpass API within polygon…`,
                    allowOutsideClick: false,
                    didOpen: () => Swal.showLoading()
                });

                features = await fetchOSMPOI({ bbox: null, query });

                mergeOSMTileData(features);
                renderDeckLayer(osmFeaturesArray, 'osm');

                Swal.fire({
                    title: features.length > 0
                    ? `${features.length} POIs loaded ✔️`
                    : "No POIs found!",
                    timer: 2500,
                    showConfirmButton: false,
                    backdrop: false,
                    customClass: {
                        popup: "swal-small-popup"
                    }
                });

                return;
            } catch (err) {
                console.error("OSM POI load failed:", err);
                Swal.fire({
                    title: "Failed to load POIs",
                    text: "Overpass API request failed ❌",
                    timer: 2500,
                    showConfirmButton: false,
                    backdrop: false,
                    customClass: {
                        popup: "swal-small-popup"
                    }
                });
                return;
            }
        }

        // Priority 3: Use map bounds
        const bbox = {
            south: bounds.getSouthWest().lat(),
            west: bounds.getSouthWest().lng(),
            north: bounds.getNorthEast().lat(),
            east: bounds.getNorthEast().lng()
        };

        // Check if bounds are too large
        const latDiff = bbox.north - bbox.south;
        const lngDiff = bbox.east - bbox.west;
        const latDistKm = latDiff * 111; // Approximate km per degree
        const lngDistKm = lngDiff * 111 * Math.cos((bbox.north + bbox.south) / 2 * Math.PI / 180);

        if (latDistKm > 100 || lngDistKm > 100) {
            Swal.fire({
                title: "⚠️ Map bounds too large",
                text: `Current viewport (${Math.round(latDistKm)}km × ${Math.round(lngDistKm)}km) may exceed Overpass API limits. Consider zooming in or selecting a specific location.`,
                icon: "warning",
                timer: 3000,
                backdrop: null,
                customClass: {
                    popup: "swal-small-popup"
                }
            });
        }

        try {
            queryOrigin = 'bbox';
            queryCenter = {
                lat: (bbox.north + bbox.south) / 2,
                lng: (bbox.east + bbox.west) / 2
            };

            Swal.fire({
                title: "Searching…",
                text: `Querying Overpass API for current viewport…`,
                allowOutsideClick: false,
                didOpen: () => Swal.showLoading()
            });

            features = await fetchOSMPOI({ bbox });

            // Calculate distances from map center
            for (const feature of features) {
                feature.distance = calculateDistance(
                    queryCenter.lat,
                    queryCenter.lng,
                    feature.lat,
                    feature.lng
                );
            }

            mergeOSMTileData(features);
            renderDeckLayer(osmFeaturesArray, 'osm');

            Swal.fire({
                title: features.length > 0
                ? `${features.length} POIs loaded ✔️`
                : "No POIs found!",
                timer: 2500,
                showConfirmButton: false,
                backdrop: false,
                customClass: {
                    popup: "swal-small-popup"
                }
            });

        } catch (err) {
            console.error("OSM POI load failed:", err);
            Swal.fire({
                title: "Failed to load POIs",
                text: "Overpass API request failed ❌",
                timer: 2500,
                showConfirmButton: false,
                backdrop: false,
                customClass: {
                    popup: "swal-small-popup"
                }
            });
        }
    }

    let tooltip = null;

    function createTooltip() {
        tooltip = document.createElement("div");
        tooltip.style.position = "fixed";
        tooltip.style.pointerEvents = "none";
        tooltip.style.background = "white";
        tooltip.style.padding = "8px 12px";
        tooltip.style.borderRadius = "6px";
        tooltip.style.boxShadow = "0 2px 12px rgba(0,0,0,0.15)";
        tooltip.style.fontSize = "12px";
        tooltip.style.zIndex = 9999;
        tooltip.style.maxWidth = "200px";
        tooltip.style.wordWrap = "break-word";
        document.body.appendChild(tooltip);
    }

    function showTooltip(obj, x, y) {
        if (!tooltip) createTooltip();

        tooltip.style.left = x + 15 + "px";
        tooltip.style.top = y + 10 + "px";

        let html = `<div style="font-weight: 600; color:#74AC00">${obj.name}</div>`;

        if (obj.observed_at) {
            const time = new Date(obj.observed_at).toLocaleString();
            html += `<div style="font-weight: 600; color:#74AC00">${time}</div>` ;
        }

        if (obj.distance !== undefined) {
            html += `<div style="font-weight: 600; color:#7048eb">${obj.distance.toFixed(1)} km</div>`;
        }

        if (obj.photo) {
            html += `<img src="${obj.photo}" width="100%" height="100%" style="margin-top: 4px; border-radius: 4px;"/>`;
        }

        if (obj.tags && Object.keys(obj.tags).length > 0) {
            html += `<div style="font-size: 11px; color: #666; margin-top: 4px;">`;
            for (const [k, v] of Object.entries(obj.tags).slice(0, 3)) {
                html += `<div>${k}: ${v}</div>`;
            }
            html += `</div>`;
        }

        tooltip.innerHTML = html;
        tooltip.style.display = "block";
    }

    function hideTooltip() {
        if (tooltip) tooltip.style.display = "none";
    }

    const observer = new MutationObserver(() => {
        const mapContainer = document.querySelector("[role='region']");
        if (mapContainer) {
            initControls();
            observer.disconnect();
        }
    });

    observer.observe(document, {
        childList: true,
        subtree: true
    });

    GM_addStyle(`
    .inat-list {
        max-height: 420px;
        overflow-y: auto;
        padding-right: 4px;
    }

    .inat-item {
        display: grid;
        grid-template-columns: 40px 1fr 90px 80px;
        align-items: center;
        padding: 8px 10px;
        cursor: pointer;
        border-radius: 6px;
        transition: all 0.15s ease;
        gap: 10px;
        border-left: 3px solid transparent;
    }

    .inat-item:hover {
        background: #f3f6fa;
        border-left-color: #74AC00;
    }

    .inat-thumb {
        width: 40px;
        height: 40px;
        border-radius: 4px;
        object-fit: cover;
        background: #eee;
    }

    .inat-sci {
        font-size: 14px;
        font-weight: 600;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    .inat-rank {
        font-size: 12px;
        color: #666;
        white-space: nowrap;
    }

    .inat-count {
        text-align: right;
        font-size: 13px;
        font-weight: 600;
        color: #74AC00;
        white-space: nowrap;
    }

    .osm-list {
        max-height: 420px;
        overflow-y: auto;
        padding-right: 4px;
    }


.osm-item {
    padding: 8px 10px;
    border-bottom: 1px solid #e5e5e5;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 10px;
    transition: background 0.15s ease;
}

.osm-item:hover {
    background: #f5f7fa;
}

.osm-thumb {
    width: 50px;
    height: 50px;
    object-fit: cover;
    border-radius: 5px;
    background: #ddd;
}

.osm-kv {
    display: flex;
    gap: 6px;
    font-size: 14px;
    font-family: monospace;
}

.osm-key {
    color: #333;
    font-weight: 600;
}

.osm-eq {
    color: #888;
}

.osm-value {
    color: #0078ff;
    font-weight: 600;
}

    .swal-small-popup {
        position: absolute;
        width: auto !important;
        height: auto !important;
        top: -250px !important;
        font-weight: bold !important;
        font-size: 9px !important;
        text-align: center !important;
        display: flex !important;
        justify-content: center !important;
        align-items: center !important;
    }
    `);
})();