visualize INaturalist observations and osm poi data on map making app
// ==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; } `); })();