Map-Making Biodiversity

visualize INaturalist observations and osm poi data on map making app

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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;
    }
    `);
})();