1/** 2 * DispersedCampFinder - Core Application JS 3 */ 4 5document.addEventListener('DOMContentLoaded', () => { 6 // Application State 7 const state = { 8 map: null, 9 blmBoundaryLayer: null, 10 mvumLayer: null, // Motor Vehicle Use Map layer 11 destinationMarker: null, 12 campsiteMarkers: [], 13 activeCampsites: [], // Store all successfully processed campsites 14 selectedCamp: null, 15 routeLine: null, 16 currentDestination: null, // { lat, lon, name } 17 isBlmVisible: true, 18 isMvumVisible: false, 19 sortField: 'time', // default sort field 20 sortAsc: true, 21 boundaryRequestCounter: 0, // Keep track of requests to prevent race conditions 22 boundaryAbortController: null, // Controller to abort previous boundary requests 23 isSearchActive: false, // Flag to prevent concurrent Overpass requests during campsite search 24 boundaryCache: {}, // Cache of local boundary results by grid key 25 }; 26 27 // --- Global Utility Helpers --- 28 async function fetchWithTimeout(resource, options = {}) { 29 const { timeout = 8000 } = options; 30 const controller = new AbortController(); 31 const id = setTimeout(() => controller.abort(), timeout); 32 try { 33 const response = await fetch(resource, { 34 ...options, 35 signal: controller.signal 36 }); 37 clearTimeout(id); 38 return response; 39 } catch (error) { 40 clearTimeout(id); 41 throw error; 42 } 43 } 44 45 function escapeHtml(str) { 46 if (!str) return ''; 47 return str.replace(/[&<>"']/g, function (m) { 48 switch (m) { 49 case '&': return '&'; 50 case '<': return '<'; 51 case '>': return '>'; 52 case '"': return '"'; 53 case "'": return '''; 54 default: return m; 55 } 56 }); 57 } 58 59 // --- Discord Authentication Configuration --- 60 const DISCORD_CLIENT_ID = "1508085547090968624"; 61 const ALLOWED_GUILD_IDS = [ 62 "750838335987122226", 63 "1383201963851780167" 64 ]; 65 66 function loadWeatherIfActive() { 67 const weatherContent = document.getElementById('weather-content'); 68 if (weatherContent && !weatherContent.classList.contains('hidden')) { 69 const iframe = document.getElementById('weather-iframe'); 70 if (iframe && (!iframe.src || !iframe.src.includes('weather/index.html'))) { 71 iframe.setAttribute('src', iframe.getAttribute('data-src') || './weather/index.html'); 72 } 73 } 74 } 75 76 async function checkDiscordAuth() { 77 const loginOverlay = document.getElementById("login-overlay"); 78 const loginBtn = document.getElementById("login-btn"); 79 const errorEl = document.getElementById("login-error-msg"); 80 81 // Bypass authentication overlay for local development (PC/Mac/Mobile on local network) 82 if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname === "::1" || window.location.hostname === "[::1]" || window.location.hostname.startsWith("192.168.")) { 83 loginOverlay.classList.add("hidden"); 84 loadWeatherIfActive(); 85 return; 86 } 87 88 // 1. Check if we already have a verified session in local storage (persistent SSO) 89 if (localStorage.getItem("discord_verified") === "true") { 90 loginOverlay.classList.add("hidden"); 91 loadWeatherIfActive(); 92 return; 93 } 94 95 // 2. Process callback from Discord if redirected back 96 const hashParams = new URLSearchParams(window.location.hash.substring(1)); 97 const accessToken = hashParams.get("access_token"); 98 99 if (accessToken) { 100 window.location.hash = ""; 101 102 loginBtn.disabled = true; 103 loginBtn.innerHTML = '<i class="fa-solid fa-spinner animate-spin"></i> Verifying Server Membership...'; 104 errorEl.classList.add("hidden"); 105 106 try { 107 // Fetch user's guilds directly from Discord in the browser 108 const res = await fetch("https://discord.com/api/users/@me/guilds", { 109 headers: { 110 Authorization: `Bearer ${accessToken}` 111 } 112 }); 113 114 if (!res.ok) { 115 throw new Error("Failed to verify membership with Discord."); 116 } 117 118 const userGuilds = await res.json(); 119 120 // Validate membership in allowed servers 121 const isMember = userGuilds.some(guild => ALLOWED_GUILD_IDS.includes(guild.id)); 122 123 if (isMember) { 124 // Save verified status in local storage for cross-directory SSO 125 localStorage.setItem("discord_verified", "true"); 126 localStorage.setItem("discord_token", accessToken); 127 loginOverlay.classList.add("hidden"); 128 loadWeatherIfActive(); 129 } else { 130 throw new Error("Access Denied: You are not a member of the required Discord server(s)."); 131 } 132 } catch (err) { 133 console.error("Discord verification failed:", err); 134 errorEl.textContent = err.message || "Authentication failed. Please try again."; 135 errorEl.classList.remove("hidden"); 136 137 loginBtn.disabled = false;
138 loginBtn.innerHTML = '<i class="fa-brands fa-discord"></i> Login with Discord'; 139 loginOverlay.classList.remove("hidden"); 140 } 141 return; 142 } 143 144 // 3. Not verified and no callback, show login screen 145 loginOverlay.classList.remove("hidden"); 146 } 147 148 // DOM Elements 149 const destInput = document.getElementById('destination-input'); 150 const clearSearchBtn = document.getElementById('clear-search'); 151 const suggestionsBox = document.getElementById('suggestions-box'); 152 const suggestionsList = document.getElementById('suggestions-list'); 153 const distanceLimit = document.getElementById('distance-limit'); 154 const toggleMvumBtn = document.getElementById('toggle-mvum'); 155 const searchBtn = document.getElementById('search-btn'); 156 const mapLoader = document.getElementById('map-loader'); 157 const loaderText = document.getElementById('loader-text'); 158 159 const tableBody = document.getElementById('campsite-table-body'); 160 const exportGpxBtn = document.getElementById('export-gpx'); 161 const exportCsvBtn = document.getElementById('export-csv'); 162 const showLegendBtn = document.getElementById('show-legend'); 163 const closeLegendBtn = document.getElementById('close-legend'); 164 const legendModal = document.getElementById('legend-modal'); 165 166 // Tab switcher elements 167 const tabCampsites = document.getElementById('tab-campsites'); 168 const tabWeather = document.getElementById('tab-weather'); 169 const mainContent = document.getElementById('main-content'); 170 const weatherContent = document.getElementById('weather-content'); 171 172 // Tab listeners 173 tabCampsites.addEventListener('click', () => { 174 mainContent.classList.remove('hidden'); 175 weatherContent.classList.add('hidden'); 176 177 // Toggle active styles (using weather app colors) 178 tabCampsites.classList.add('bg-cyan-500', 'text-slate-950', 'shadow-sm'); 179 tabCampsites.classList.remove('text-slate-400', 'hover:text-cyan-400'); 180 181 tabWeather.classList.remove('bg-cyan-500', 'text-slate-950', 'shadow-sm'); 182 tabWeather.classList.add('text-slate-400', 'hover:text-cyan-400'); 183 184 // Restore Campsite Finder Header details (using weather app cyan styling for badge) 185 document.getElementById('main-title').innerHTML = 'DispersedCampFinder <span id="title-badge" class="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-400 font-medium border border-cyan-500/20">Leads to possible free sites</span>'; 186 document.getElementById('main-subtitle').textContent = 'Discover off-grid camping within 2 hours of your destination'; 187 188 // Show parent footer 189 const footer = document.getElementById('main-footer'); 190 if (footer) footer.classList.remove('hidden'); 191 192 if (state.map) { 193 setTimeout(() => { state.map.invalidateSize(); }, 100); 194 } 195 }); 196 197 tabWeather.addEventListener('click', () => { 198 mainContent.classList.add('hidden'); 199 weatherContent.classList.remove('hidden'); 200 201 // Toggle active styles (using weather app colors) 202 tabWeather.classList.add('bg-cyan-500', 'text-slate-950', 'shadow-sm'); 203 tabWeather.classList.remove('text-slate-400', 'hover:text-cyan-400'); 204 205 tabCampsites.classList.remove('bg-cyan-500', 'text-slate-950', 'shadow-sm'); 206 tabCampsites.classList.add('text-slate-400', 'hover:text-cyan-400'); 207 208 // Update Header details for Weather & Climate (using weather app cyan styling for badge) 209 document.getElementById('main-title').innerHTML = 'Nomad High Temps <span id="title-badge" class="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-400 font-medium border border-cyan-500/20">Feels-like matching</span>'; 210 document.getElementById('main-subtitle').textContent = 'Find your perfect temperature within driving range'; 211 212 // Hide parent footer (the weather app has its own nested footer) 213 const footer = document.getElementById('main-footer'); 214 if (footer) footer.classList.add('hidden'); 215 216 // Lazy load the weather iframe if not already loaded 217 const iframe = document.getElementById('weather-iframe'); 218 if (iframe && (!iframe.src || !iframe.src.includes('weather/index.html'))) { 219 iframe.setAttribute('src', iframe.getAttribute('data-src') || './weather/index.html'); 220 } 221 }); 222 223 // Bind Login Button click 224 const loginBtn = document.getElementById("login-btn"); 225 if (loginBtn) { 226 loginBtn.addEventListener("click", () => { 227 const redirectUri = encodeURIComponent(window.location.origin + "/"); 228 window.location.href = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=token&scope=identify%20guilds`; 229 }); 230 } 231 232 // Run Discord check on init 233 checkDiscordAuth(); 234 235 // Initialize Map 236 function initMap() { 237 // Center of US initially 238 state.map = L.map('map', { 239 zoomControl: true, 240 minZoom: 3, 241 maxZoom: 18 242 }
242).setView([39.8283, -98.5795], 4); 243 244 // Premium Light Map tiles (CartoDB Positron) 245 L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { 246 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', 247 subdomains: 'abcd', 248 maxZoom: 20 249 }).addTo(state.map); 250 251 // Initialize Boundary Layers using standard Leaflet WMS (bypasses plugin dependencies and CORS) 252 // BLM SMA (Surface Management Agency) 253 state.blmBoundaryLayer = L.tileLayer.wms('https://gis.blm.gov/arcgis/services/lands/BLM_Natl_SMA_Cached_without_PriUnk/MapServer/WMSServer?', { 254 layers: '0', 255 format: 'image/png', 256 transparent: true, 257 opacity: 0.5, 258 attribution: 'BLM SMA' 259 }); 260 261 // USFS Motor Vehicle Use Map (MVUM) Roads and Trails Layer using Esri Leaflet (REST export) 262 state.mvumLayer = L.esri.dynamicMapLayer({ 263 url: 'https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_MVUM_02/MapServer', 264 layers: [1, 2, 4, 5], // Load both standard and visitor map road/trail layers at all scales 265 opacity: 0.8 266 }); 267 268 // Debug listeners to check for MVUM loading issues in the browser 269 state.mvumLayer.on('requeststart', () => { 270 console.log('MVUM loading started...'); 271 }); 272 state.mvumLayer.on('requestend', () => { 273 console.log('MVUM loading finished successfully.'); 274 }); 275 state.mvumLayer.on('requesterror', (e) => { 276 console.error('MVUM layer load error:', e); 277 const errMsg = e.error ? (e.error.message || JSON.stringify(e.error)) : 'Network or scale constraint'; 278 alert('MVUM Layer Error: ' + errMsg + ' (Make sure you are zoomed in close to the forest to see MVUM roads!)'); 279 }); 280 281 // Set toggle event handlers 282 setupLayerToggles(); 283 284 // Add boundaries to map by default on startup 285 state.blmBoundaryLayer.addTo(state.map); 286 287 // Helper function to trigger a boundary search for current map view 288 window.triggerBoundarySearch = function() { 289 if (!state.map) return; 290 291 // Force Leaflet to sync container size (without triggering moveend) 292 state.map.invalidateSize({ pan: false, zoom: false }); 293 294 const zoom = state.map.getZoom(); 295 const selectEl = document.getElementById('local-boundaries-select'); 296 if (!selectEl) return; 297 298 if (zoom < 7) { 299 selectEl.innerHTML = '<option value="" disabled selected>Find Official Websites</option><option value="" disabled>Zoom in closer to list specific districts...</option>'; 300 return; 301 } 302 303 const bounds = state.map.getBounds(); 304 const center = bounds.getCenter(); 305 306 // Calculate 0.2-degree grid key (approx 12-14 miles grid) to cache queries efficiently 307 const latGrid = Math.round(center.lat * 5) / 5; 308 const lonGrid = Math.round(center.lng * 5) / 5; 309 const cacheKey = `${latGrid.toFixed(1)}_${lonGrid.toFixed(1)}`; 310 311 if (state.boundaryCache[cacheKey]) { 312 console.log("Boundary cache HIT for key:", cacheKey); 313 renderLocalBoundaries(state.boundaryCache[cacheKey]); 314 return; 315 } 316 317 let west = bounds.getWest(); 318 let east = bounds.getEast(); 319 west = ((west + 180) % 360 + 360) % 360 - 180; 320 east = ((east + 180) % 360 + 360) % 360 - 180; 321 322 const mapBbox = { 323 south: bounds.getSouth(), 324 west: west, 325 north: bounds.getNorth(), 326 east: east 327 }; 328 329 if (isNaN(mapBbox.south) || isNaN(mapBbox.west) || isNaN(mapBbox.north) || isNaN(mapBbox.east)) { 330 console.warn("Invalid map bounds for boundary search"); 331 return; 332 } 333 334 selectEl.innerHTML = `<option value="" disabled selected>Loading (${mapBbox.south.toFixed(2)}, ${mapBbox.west.toFixed(2)})...</option>`; 335 336 // Abort the previous active request 337 if (state.boundaryAbortController) { 338 state.boundaryAbortController.abort(); 339 } 340 state.boundaryAbortController = new AbortController(); 341 342 const currentRequestId = ++state.boundaryRequestCounter; 343 fetchLocalBoundaries(mapBbox, state.boundaryAbortController.signal) 344 .then(areas => { 345 if (currentRequestId === state.boundaryRequestCounter) { 346 state.boundaryCache[cacheKey] = areas; 347 renderLocalBoundaries(areas); 348 } else { 349 console.log("Ignoring outdated boundary response"); 350 } 351 }) 352 .catch(err => { 353 if (err.name === 'AbortError') { 354 console.log("Boundary request aborted silently"); 355 return; 356 } 357 if (currentRequestId === state.boundaryRequestCounter) { 358 console.warn("Boundary fetch failed or rate-limited. Rendering fallback search options.", err); 359 renderBoundaryFallback(); 360 } else { 361 console.log("Ignoring outdated boundary error"); 362 } 363 }); 364 }; 365 366 // Dynamic listener to list specific federal districts shown in the map viewport 367 let moveTimeout = null; 368 state.map.on('moveend', () => { 369 if (!state.map || state.isSearchActive) return; 370 clearTimeout(moveTimeout); 371 moveTimeout = setTimeout(() => { 372 window.triggerBoundarySearch(); 373 }, 1500); // 1.5s debounce to prevent spamming queries during map panning/zooming 374 }); 375 } 376 377 // Toggle boundary layers 378 function setupLayerToggles() { 379 380 toggleMvumBtn.addEventListener('click', () => { 381 state.isMvumVisible = !state.isMvumVisible; 382 if (state.isMvumVisible) { 383 state.mvumLayer.addTo(state.map); 384 toggleMvumBtn.classList.remove('bg-primary-800/40', 'border-emerald-500/30', 'text-emerald-400'); 385 toggleMvumBtn.classList.add('bg-emerald-500/20', 'border-emerald-500', 'text-emerald-300'); 386 } else { 387 state.map.removeLayer(state.mvumLayer); 388 toggleMvumBtn.classList.add('bg-primary-800/40', 'border-emerald-500/30', 'text-emerald-400'); 389 toggleMvumBtn.classList.remove('bg-emerald-500/20', 'border-emerald-500', 'text-emerald-300'); 390 } 391 }); 392 393 // Legend Modal Interactions 394 showLegendBtn.addEventListener('click', () => { 395 legendModal.classList.remove('opacity-0', 'pointer-events-none'); 396 }); 397 398 closeLegendBtn.addEventListener('click', () => { 399 legendModal.classList.add('opacity-0', 'pointer-events-none'); 400 }); 401 402 legendModal.addEventListener('click', (e) => { 403 if (e.target === legendModal) { 404 legendModal.classList.add('opacity-0', 'pointer-events-none'); 405 } 406 }); 407 } 408 409 // Trigger search immediately when max distance changes 410 distanceLimit.addEventListener('change', () => { 411 if (state.currentDestination) { 412 triggerCampSearch(); 413 } 414 }); 415 416 // Suggestions search logic (Nominatim geocoder) 417 let searchTimeout = null; 418 destInput.addEventListener('input', (e) => { 419 state.currentDestination = null; // Reset active destination on text edit 420 const query = e.target.value.trim(); 421 if (query.length > 0) { 422 clearSearchBtn.classList.remove('hidden'); 423 } else { 424 clearSearchBtn.classList.add('hidden'); 425 } 426 427 clearTimeout(searchTimeout); 428 if (query.length < 3) { 429 suggestionsBox.classList.add('hidden'); 430 return; 431 } 432 433 searchTimeout = setTimeout(() => { 434 fetchSuggestions(query); 435 }, 300); 436 }); 437
438 // Support Enter key press to search 439 destInput.addEventListener('keydown', (e) => { 440 if (e.key === 'Enter') { 441 e.preventDefault(); 442 suggestionsBox.classList.add('hidden'); 443 searchBtn.click(); 444 } 445 }); 446 447 clearSearchBtn.addEventListener('click', () => { 448 destInput.value = ''; 449 clearSearchBtn.classList.add('hidden'); 450 suggestionsBox.classList.add('hidden'); 451 state.currentDestination = null; 452 }); 453 454 function sortGeocodingResults(results) { 455 if (!results || results.length <= 1) return results; 456 return results.sort((a, b) => { 457 const classPriority = { 'place': 1, 'boundary': 2 }; 458 const typePriority = { 'city': 1, 'town': 2, 'village': 3, 'hamlet': 4, 'census': 5 }; 459 460 const aClass = classPriority[a.class] || 99; 461 const bClass = classPriority[b.class] || 99; 462 if (aClass !== bClass) return aClass - bClass; 463 464 const aType = typePriority[a.type] || 99; 465 const bType = typePriority[b.type] || 99; 466 if (aType !== bType) return aType - bType; 467 468 return 0; 469 }); 470 } 471 472 async function fetchSuggestions(query) { 473 try { 474 const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&countrycodes=us&limit=5`; 475 const res = await fetchWithTimeout(url, { 476 headers: { 'User-Agent': 'DispersedCampFinderAgent/1.0' }, 477 timeout: 4000 478 }); 479 if (!res.ok) return; 480 const data = await res.json(); 481 const sortedData = sortGeocodingResults(data); 482 renderSuggestions(sortedData); 483 } catch (err) { 484 console.error('Geocoding error:', err); 485 } 486 } 487
488 function renderSuggestions(list) { 489 suggestionsList.innerHTML = ''; 490 if (list.length === 0) { 491 suggestionsBox.classList.add('hidden'); 492 return; 493 } 494 495 list.forEach(item => { 496 const li = document.createElement('li'); 497 li.className = 'px-4 py-3 hover:bg-primary-900/60 cursor-pointer transition-colors text-xs text-slate-200 flex items-center gap-2.5'; 498 li.innerHTML = `<i class="fa-solid fa-map-pin text-accent-500"></i> <span>${item.display_name}</span>`; 499 li.addEventListener('click', () => { 500 destInput.value = item.display_name; 501 state.currentDestination = { 502 lat: parseFloat(item.lat), 503 lon: parseFloat(item.lon), 504 name: item.display_name 505 }; 506 suggestionsBox.classList.add('hidden'); 507 clearSearchBtn.classList.remove('hidden'); 508 triggerCampSearch(); 509 }); 510 suggestionsList.appendChild(li); 511 }); 512 suggestionsBox.classList.remove('hidden'); 513 } 514 515 // Close suggestions box on outside click 516 document.addEventListener('click', (e) => { 517 if (!destInput.contains(e.target) && !suggestionsBox.contains(e.target)) { 518 suggestionsBox.classList.add('hidden'); 519 } 520 }); 521 522 // Calculate coordinates bounds box given a radius (approx miles to lat/lon degrees) 523 function getBbox(lat, lon, miles) { 524 const latDelta = miles / 69.0; 525 const lonDelta = miles / (69.0 * Math.cos(lat * Math.PI / 180.0)); 526 return { 527 south: lat - latDelta, 528 west: lon - lonDelta, 529 north: lat + latDelta, 530 east: lon + lonDelta 531 }; 532 } 533 534 // Haversine formula for sorting candidate sites by straight-line distance 535 function getDistanceMiles(lat1, lon1, lat2, lon2) { 536 const R = 3958.8; // Radius of Earth in miles 537 const dLat = (lat2 - lat1) * Math.PI / 180; 538 const dLon = (lon2 - lon1) * Math.PI / 180; 539 const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + 540 Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * 541 Math.sin(dLon / 2) * Math.sin(dLon / 2); 542 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 543 return R * c; 544 } 545 546 // Set loaders 547 function showLoader(text) { 548 loaderText.innerText = text; 549 mapLoader.classList.remove('opacity-0', 'pointer-events-none'); 550 } 551 552 function hideLoader() { 553 mapLoader.classList.add('opacity-0', 'pointer-events-none'); 554 } 555 556 searchBtn.addEventListener('click', async () => { 557 const query = destInput.value.trim(); 558 if (query.length < 3) return; 559 560 if (!state.currentDestination) { 561 showLoader('Searching location...'); 562 try { 563 const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&countrycodes=us&limit=5`; 564 const res = await fetchWithTimeout(url, { 565 headers: { 'User-Agent': 'DispersedCampFinderAgent/1.0' }, 566 timeout: 4000 567 }); 568 if (res.ok) { 569 const data = await res.json(); 570 const sortedData = sortGeocodingResults(data); 571 if (sortedData && sortedData.length > 0) { 572 const first = sortedData[0]; 573 state.currentDestination = { 574 lat: parseFloat(first.lat), 575 lon: parseFloat(first.lon), 576 name: first.display_name 577 }; 578 destInput.value = first.display_name; 579 suggestionsBox.classList.add('hidden'); 580 clearSearchBtn.classList.remove('hidden'); 581 await triggerCampSearch(); 582 } else { 583 hideLoader(); 584 alert('Location not found. Please try another destination in the US.'); 585 } 586 } else { 587 hideLoader(); 588 alert('Search service returned an error. Please try again.'); 589 } 590 } catch (err) { 591 console.error('Direct geocoding error:', err); 592 hideLoader(); 593 alert('An error occurred during location search.'); 594 } 595 } else { 596 triggerCampSearch(); 597 } 598 }); 599 600 async function triggerCampSearch() { 601 if (!state.currentDestination) return; 602 603 state.isSearchActive = true; 604 605 // Force Leaflet to sync container size (without triggering moveend) 606 if (state.map) { 607 state.map.invalidateSize({ pan: false, zoom: false }); 608 } 609 610 showLoader('Querying dispersed campsites...'); 611 612 // Clear old elements 613 if (state.destinationMarker) state.map.removeLayer(state.destinationMarker);
614 state.campsiteMarkers.forEach(m => state.map.removeLayer(m)); 615 state.campsiteMarkers = []; 616 if (state.routeLine) state.map.removeLayer(state.routeLine); 617 state.routeLine = null; 618 deselectCamp(); 619 620 const { lat, lon, name } = state.currentDestination; 621 622 // Create invisible destination marker (popup only) 623 const destIcon = L.divIcon({ 624 html: '', 625 className: 'invisible-destination-marker', 626 iconSize: [0, 0], 627 iconAnchor: [0, 0] 628 }); 629 state.destinationMarker = L.marker([lat, lon], { icon: destIcon }) 630 .addTo(state.map) 631 .bindPopup(`<strong class="text-accent-400">Destination:</strong><br>${name}<br><span class="font-mono text-[10px] text-slate-400">Coordinates: ${lat.toFixed(5)}, ${lon.toFixed(5)}</span>`) 632 .openPopup(); 633 634 // Fetch destination elevation early in parallel to ensure it displays even if campsites query takes time or returns 0 results 635 fetchElevations(state.currentDestination, []).then(() => { 636 if (state.destinationMarker && state.currentDestination.elevationFeet !== undefined) { 637 state.destinationMarker.setPopupContent( 638 `<strong class="text-accent-400">Destination:</strong><br>${name}<br>` + 639 `<span class="font-mono text-[10px] text-slate-400">Coordinates: ${lat.toFixed(5)}, ${lon.toFixed(5)}</span><br>` + 640 `<span class="text-xs">â°ï¸ Elevation: <strong>${state.currentDestination.elevationFeet.toLocaleString()} ft</strong> (${Math.round(state.currentDestination.elevationMeters)} m)</span>` 641 ); 642 } 643 }).catch(err => console.error("Destination elevation fetch failed:", err)); 644 645 state.map.setView([lat, lon], 9); 646 647 const limitMiles = parseInt(distanceLimit.value); 648 649 // Bbox based on distance limit plus a small buffer for road winding path leeway 650 const bbox = getBbox(lat, lon, limitMiles + 5); 651 652 // Fetch in sequence with loader updates 653 try { 654 showLoader('Querying OpenStreetMap backcountry sites...'); 655 const osmSites = await fetchOSMCampsites(bbox); 656 657 showLoader(`OSM found ${osmSites.length} sites. Querying Forest Service database...`); 658 let usfsSites = [];
659 try { 660 usfsSites = await fetchUSFSCampsites(bbox); 661 } catch (e) { 662 console.error('USFS Fetch error:', e); 663 // Show error briefly in subtext 664 document.getElementById('loader-subtext').innerText = 'USFS Query error: ' + e.message; 665 } 666 667 showLoader(`Found OSM: ${osmSites.length}, USFS: ${usfsSites.length}. Calculating routes...`); 668 669 // Combine and filter duplicates 670 const allCandidates = combineAndDeduplicate(osmSites, usfsSites); 671 672 // Calculate straight line distance and pre-filter 673 const preFilteredCandidates = allCandidates 674 .map(c => ({ ...c, straightDist: getDistanceMiles(lat, lon, c.lat, c.lon) })) 675 .filter(c => c.straightDist <= limitMiles); 676 677 if (preFilteredCandidates.length === 0) { 678 tableBody.innerHTML = ` 679 <tr> 680 <td colspan="8" class="py-12 text-center text-primary-400"> 681 <i class="fa-solid fa-circle-exclamation text-3xl mb-3 block text-primary-600"></i> 682 No potential campsites found within ${limitMiles} miles. Try another area. 683 </td> 684 </tr> 685 `; 686 exportCsvBtn.disabled = true; 687 exportGpxBtn.disabled = true; 688 state.isSearchActive = false; 689 hideLoader(); 690 window.triggerBoundarySearch(); 691 return; 692 } 693 694 showLoader(`Calculating driving routes for nearest ${preFilteredCandidates.length} potential locations...`); 695 696 // Sort and slice candidates to top 40 closest straight-line distance points to speed up OSRM dramatically 697 const closestCandidates = preFilteredCandidates 698 .sort((a, b) => a.straightDist - b.straightDist) 699 .slice(0, 40); 700 701 // Perform matrix routing duration lookup 702 const validatedCampsites = await calculateDrivingDurations(lat, lon, closestCandidates); 703 704 // Post-filter strictly by actual road driving distance (miles) 705 const finalCampsites = validatedCampsites.filter(c => c.distanceMiles <= limitMiles); 706 707 if (finalCampsites.length === 0) { 708 tableBody.innerHTML = ` 709 <tr> 710 <td colspan="8" class="py-12 text-center text-primary-400"> 711 <i class="fa-solid fa-circle-exclamation text-3xl mb-3 block text-primary-600"></i> 712 No campsites found within a ${limitMiles} mile driving route. Try another area. 713 </td> 714 </tr> 715 `; 716 exportCsvBtn.disabled = true; 717 exportGpxBtn.disabled = true; 718 state.isSearchActive = false; 719 hideLoader(); 720 window.triggerBoundarySearch(); 721 return; 722 } 723 724 // Fetch elevation data for destination and final campsites 725 await fetchElevations(state.currentDestination, finalCampsites); 726 727 // Update destination marker popup with elevation if loaded 728 if (state.destinationMarker && state.currentDestination.elevationFeet !== undefined) { 729 state.destinationMarker.setPopupContent( 730 `<strong class="text-accent-400">Destination:</strong><br>${name}<br>` + 731 `<span class="font-mono text-[10px] text-slate-400">Coordinates: ${lat.toFixed(5)}, ${lon.toFixed(5)}</span><br>` + 732 `<span class="text-xs">â°ï¸ Elevation: <strong>${state.currentDestination.elevationFeet.toLocaleString()} ft</strong> (${Math.round(state.currentDestination.elevationMeters)} m)</span>` 733 ); 734 } 735 736 state.activeCampsites = finalCampsites; 737 738 // Plot on map and fill coordinates table 739 plotCampsites(finalCampsites); 740 populateTable(finalCampsites); 741 742 state.isSearchActive = false; 743 hideLoader(); 744 window.triggerBoundarySearch(); 745 } catch (err) { 746 console.error(err); 747 state.isSearchActive = false; 748 hideLoader(); 749 window.triggerBoundarySearch(); 750 alert('An error occurred while fetching campsite data. Please try again.'); 751 } 752 } 753 754 // Helper to query Overpass with automatic fallback to multiple public mirrors in case of overload/timeouts 755 async function fetchFromOverpass(query, timeoutMs = 8000, parentSignal = null) { 756 if (parentSignal && parentSignal.aborted) { 757 throw new DOMException('The user aborted a request.', 'AbortError'); 758 } 759 760 const mirrors = [ 761 'https://overpass-api.de/api/interpreter', 762 'https://lz4.overpass-api.de/api/interpreter' 763 ]; 764 765 for (const baseUrl of mirrors) { 766 if (parentSignal && parentSignal.aborted) { 767 throw new DOMException('The user aborted a request.', 'AbortError'); 768 } 769 770 const controller = new AbortController(); 771 const mirrorTimeout = Math.min(timeoutMs, 4000); 772 const timeoutId = setTimeout(() => controller.abort(), mirrorTimeout); // Custom timeout per mirror 773 774 // Abort mirror fetch if the parent request is aborted 775 const onParentAbort = () => controller.abort(); 776 if (parentSignal) { 777 parentSignal.addEventListener('abort', onParentAbort); 778 } 779
780 try { 781 const url = `${baseUrl}?data=${encodeURIComponent(query)}&_=${Date.now()}`; 782 const res = await fetch(url, { 783 method: 'GET', 784 signal: controller.signal 785 }); 786 clearTimeout(timeoutId); 787 if (parentSignal) { 788 parentSignal.removeEventListener('abort', onParentAbort); 789 } 790 if (parentSignal && parentSignal.aborted) { 791 throw new DOMException('The user aborted a request.', 'AbortError'); 792 } 793 if (res.ok) { 794 const data = await res.json(); 795 if (data && data.elements) { 796 console.log(`Successfully fetched from Overpass mirror: ${baseUrl}`); 797 return data; 798 } 799 } else { 800 console.warn(`Overpass mirror ${baseUrl} returned status: ${res.status}`); 801 } 802 } catch (e) { 803 clearTimeout(timeoutId); 804 if (parentSignal) { 805 parentSignal.removeEventListener('abort', onParentAbort); 806 } 807 if (parentSignal && parentSignal.aborted) { 808 throw new DOMException('The user aborted a request.', 'AbortError'); 809 } 810 console.warn(`Overpass mirror ${baseUrl} failed:`, e.name === 'AbortError' ? 'Timeout' : e.message); 811 } 812 } 813 814 if (parentSignal && parentSignal.aborted) { 815 throw new DOMException('The user aborted a request.', 'AbortError'); 816 } 817 throw new Error('All Overpass API mirrors failed to respond.'); 818 } 819 820 // Fetch from OpenStreetMap Overpass API (optimized filters & GET query to prevent timeouts/errors) 821 async function fetchOSMCampsites(bbox) { 822 const query = `[out:json][timeout:20]; 823 ( 824 node["tourism"="camp_site"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); 825 )->.camps; 826 ( 827 relation["boundary"="protected_area"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); 828 relation["boundary"="national_park"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); 829 relation["boundary"="administrative"]["name"~"National Forest|National Grassland|National Wildlife Refuge|Bureau of Land Management"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); 830 )->.bounds; 831 .camps out; 832 .bounds out tags;`; 833 834 try { 835 const data = await fetchFromOverpass(query, 8000); // 8s timeout for combined query 836 837 // Parse and cache boundaries 838 const seen = new Set(); 839 const areas = []; 840 (data.elements || []).forEach(el => { 841 if (el.type !== 'relation' || !el.tags) return; 842 const name = el.tags.name; 843 if (!name || seen.has(name)) return; 844 seen.add(name); 845 846 const operator = el.tags.operator || ''; 847 let agencyBadge = 'Federal'; 848 let iconClass = 'fa-solid fa-tree'; 849 850 const lowerName = name.toLowerCase(); 851 const lowerOp = operator.toLowerCase(); 852 853 if (lowerName.includes('national forest') || lowerOp.includes('usfs') || lowerOp.includes('forest service')) { 854 agencyBadge = 'USFS'; 855 iconClass = 'fa-solid fa-mountain'; 856 } else if (lowerName.includes('national park') || lowerName.includes('national monument') || lowerOp.includes('nps') || lowerOp.includes('national park service')) { 857 agencyBadge = 'NPS'; 858 iconClass = 'fa-solid fa-monument'; 859 } else if (lowerName.includes('national wildlife refuge') || lowerName.includes('wildlife refuge') || lowerOp.includes('fws') || lowerOp.includes('fish and wildlife')) { 860 agencyBadge = 'USFWS'; 861 iconClass = 'fa-solid fa-shield-cat'; 862 } else if (lowerName.includes('bureau of land management') || lowerOp.includes('blm') || lowerOp.includes('bureau of land management') || lowerName.includes('blm')) { 863 agencyBadge = 'BLM'; 864 iconClass = 'fa-solid fa-tree'; 865 } else if (lowerName.includes('wilderness')) { 866 agencyBadge = 'Wilderness'; 867 iconClass = 'fa-solid fa-compass'; 868 } else { 869 return; 870 } 871 areas.push({ name, agencyBadge, iconClass }); 872 }); 873 874 const filteredAreas = areas.slice(0, 8); 875 const latCenter = (bbox.south + bbox.north) / 2; 876 const lonCenter = (bbox.west + bbox.east) / 2; 877 const latGrid = Math.round(latCenter * 5) / 5;
878 const lonGrid = Math.round(lonCenter * 5) / 5; 879 const cacheKey = `${latGrid.toFixed(1)}_${lonGrid.toFixed(1)}`; 880 state.boundaryCache[cacheKey] = filteredAreas; 881 console.log("Boundary cache pre-populated from combined campsites query:", cacheKey, filteredAreas); 882 883 // Parse and return campsites 884 return (data.elements || []) 885 .filter(el => el.type === 'node' && el.tags && el.tags.tourism === 'camp_site') 886 .map(el => { 887 const lat = el.lat || (el.center ? el.center.lat : null); 888 const lon = el.lon || (el.center ? el.center.lon : null); 889 if (!lat || !lon) return null; 890 891 // Apply strict filtering to avoid paid campgrounds, state parks, RV resorts, and non-primitive campsites 892 if (el.tags) { 893 const tags = el.tags; 894 895 // 1. Fee checks (any fee indication, not just fee=yes) 896 if (tags.fee && tags.fee.toLowerCase() !== 'no') return null; 897 if (tags.charge) return null; 898 899 // 2. Serviced campsite indicators (utilities suggest managed, paid sites) 900 if (tags.power === 'yes' || tags.electricity === 'yes') return null; 901 if (tags.sewer === 'yes') return null; 902 if (tags.sanitation === 'dump_station') return null; 903 904 // 3. camp_site classifications (OSM standard/serviced/group/organized camps are typically paid/managed) 905 if (tags.camp_site && ['standard', 'serviced', 'organized', 'group'].includes(tags.camp_site.toLowerCase())) { 906 return null; 907 } 908 if (tags.backcountry === 'no') return null; 909 910 // 4. Name, Description, and Operator checks 911 const nameLower = (tags.name || '').toLowerCase(); 912 const descLower = (tags.description || '').toLowerCase(); 913 const opLower = (tags.operator || '').toLowerCase(); 914 915 const blacklistKeywords = [ 916 'state park', 'state beach', 'rv park', 'rv resort', 'resort', 'koa', 'yurt', 917 'cabin', 'glamping', 'private', 'scout', 'ymca', 'campground', 'church', 918 'bible', 'religious', 'retreat', 'youth camp', 'mission', 'diocesan', 'parish', 919 'christian', 'zion', 'campust' 920 ]; 921 922 // Specially handle "campground" keyword (often denotes paid/organized sites unless they specify primitive/dispersed/backcountry) 923 const isCampgroundWord = nameLower.includes('campground') || descLower.includes('campground'); 924 const isPrimitiveWord = nameLower.includes('dispersed') || nameLower.includes('backcountry') || 925 nameLower.includes('primitive') || nameLower.includes('basic') || 926 descLower.includes('dispersed') || descLower.includes('backcountry') || 927 descLower.includes('primitive') || descLower.includes('basic'); 928 929 if (isCampgroundWord && !isPrimitiveWord) { 930 return null; 931 } 932 933 // Specially handle names starting with "Camp " (e.g., "Camp Zion", "Camp Lawton", "Camp Geronimo") 934 // to filter out organized youth/organization camps while keeping geographic descriptors or numbered sites 935 if (nameLower.startsWith('camp ')) { 936 const allowedGeographicWords = [ 937 'dispersed', 'primitive', 'backcountry', 'basic', 'road', 'forest', 'blm', 938 'creek', 'lake', 'trail', 'canyon', 'river', 'mountain', 'spring', 'wash', 939 'valley', 'site', 'campsite', 'wood', 'campground', 'clearing', 'flat', 940 'ridge', 'meadow' 941 ]; 942 const hasAllowedWord = allowedGeographicWords.some(w => nameLower.includes(w)) || /^\s*camp\s+\d+/i.test(nameLower); 943 if (!hasAllowedWord) { 944 return null; 945 } 946 } 947 948 // Check rest of keywords 949 for (const word of blacklistKeywords) { 950 if (word === 'campground') continue; 951 if (nameLower.includes(word) || descLower.includes(word) || opLower.includes(word)) { 952 return null; 953 } 954 } 955 } 956 957 return { 958 id: `osm-${el.id}`, 959 name: el.tags.name || el.tags.description || 'Dispersed Campsite (OSM)', 960 lat: lat, 961 lon: lon, 962 type: el.tags.backcountry === 'yes' || el.tags.camp_site === 'basic' ? 'OSM Backcountry' : 'OSM Campsite', 963 source: 'OSM', 964 operator: el.tags.operator || '' 965 }; 966 }).filter(el => el !== null); 967 } catch (err) { 968 console.error('Overpass API error:', err); 969 return []; 970 } 971 } 972 973 // Fetch from USFS Recreation Sites and Opportunities REST APIs using native JSONP 974 async function fetchUSFSCampsites(bbox) { 975 // Helper function for JSONP requests with timeout 976 function jsonp(url, timeoutMs = 8000) { 977 return new Promise((resolve, reject) => { 978 const callbackId = 'jsonp_' + Date.now() + '_' + Math.floor(Math.random() * 100000); 979 980 const timeoutId = setTimeout(() => { 981 cleanup(); 982 reject(new Error('USFS query timed out')); 983 }, timeoutMs); 984 985 window[callbackId] = (data) => { 986 clearTimeout(timeoutId); 987 resolve(data); 988 cleanup(); 989 }; 990 991 function cleanup() { 992 delete window[callbackId]; 993 const script = document.getElementById(callbackId); 994 if (script) document.body.removeChild(script); 995 } 996 997 const script = document.createElement('script'); 998 script.id = callbackId; 999 script.src = `${url}&callback=${callbackId}`; 1000 script.onerror = (err) => { 1001 clearTimeout(timeoutId); 1002 reject(new Error('JSONP script load failed')); 1003 cleanup(); 1004 }; 1005 document.body.appendChild(script); 1006 }); 1007 } 1008 1009 const infraUrl = `https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_InfraRecreationSites_01/MapServer/0/query?where=(site_subtype%20LIKE%20%27%25CAMP%25%27%20OR%20site_name%20LIKE%20%27%25DISPERSED%25%27)%20AND%20(fee_charged%20%3D%20%27N%27%20OR%20fee_charged%20IS%20NULL)&geometry=${bbox.west},${bbox.south},${bbox.east},${bbox.north}&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&inSR=4326&outFields=site_name,latitude,longitude,site_subtype,fee_charged&f=json&outSR=4326`; 1010 const opportunitiesUrl = `https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_RecreationOpportunities_01/MapServer/0/query?where=markeractivity%20%3D%20%27Dispersed%20Camping%27%20AND%20(feedescription%20IS%20NULL%20OR%20feedescription%20LIKE%20%27%25No%25%27%20OR%20feedescription%20%3D%20%27%240%27%20OR%20feedescription%20NOT%20LIKE%20%27%25%24%25%27)&geometry=${bbox.west},${bbox.south},${bbox.east},${bbox.north}&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&inSR=4326&outFields=recareaname,latitude,longitude,markeractivity,feedescription&f
1010=json&outSR=4326`; 1011 1012 try { 1013 const [infraData, recData] = await Promise.all([ 1014 jsonp(infraUrl).catch(e => { console.error('USFS Infra fetch error:', e); return null; }), 1015 jsonp(opportunitiesUrl).catch(e => { console.error('USFS Opportunities fetch error:', e); return null; }) 1016 ]); 1017 1018 const sites = []; 1019 1020 // Process Infra Sites 1021 if (infraData && infraData.features) { 1022 infraData.features.forEach((f, idx) => { 1023 const attr = f.attributes; 1024 const lat = attr.latitude || (f.geometry ? f.geometry.y : null); 1025 const lon = attr.longitude || (f.geometry ? f.geometry.x : null); 1026 if (lat && lon) { 1027 sites.push({ 1028 id: `usfs-infra-${idx}-${lat}`, 1029 name: attr.site_name || 'Forest Service Campsite', 1030 lat: parseFloat(lat), 1031 lon: parseFloat(lon), 1032 type: attr.site_subtype || 'USFS Camp Opportunity', 1033 source: 'USFS' 1034 }); 1035 } 1036 }); 1037 } 1038 1039 // Process Recreation Opportunities 1040 if (recData && recData.features) { 1041 recData.features.forEach((f, idx) => { 1042 const attr = f.attributes; 1043 const lat = attr.latitude || (f.geometry ? f.geometry.y : null); 1044 const lon = attr.longitude || (f.geometry ? f.geometry.x : null); 1045 if (lat && lon) { 1046 sites.push({ 1047 id: `usfs-rec-${idx}-${lat}`, 1048 name: attr.recareaname || 'Forest Service Dispersed Area', 1049 lat: parseFloat(lat), 1050 lon: parseFloat(lon), 1051 type: attr.markeractivity || 'USFS Dispersed Camping Area', 1052 source: 'USFS' 1053 }); 1054 } 1055 }); 1056 } 1057 1058 return sites; 1059 } catch (err) { 1060 console.error('USFS JSONP fetch exception:', err); 1061 document.getElementById('loader-subtext').innerText = 'USFS exception: ' + err.message; 1062 return []; 1063 } 1064 } 1065 1066 1067 // Combine lists and deduplicate overlapping coordinates (within ~200 meters) 1068 function combineAndDeduplicate(osm, usfs) { 1069 const combined = [...osm]; 1070 1071 usfs.forEach(uSite => { 1072 // Check if coordinates overlap with any OSM campsite 1073 const isDuplicate = combined.some(oSite => { 1074 const dist = getDistanceMiles(uSite.lat, uSite.lon, oSite.lat, oSite.lon); 1075 return dist < 0.15; // Within ~240 meters 1076 }); 1077 1078 if (!isDuplicate) { 1079 combined.push(uSite); 1080 } 1081 }); 1082 1083 return combined; 1084 } 1085 1086 // Query OSRM Table to get actual driving duration & distance from destination to campsites 1087 async function calculateDrivingDurations(destLat, destLon, campsites) { 1088 // Structure: originLon,originLat;camp1Lon,camp1Lat;camp2Lon,camp2Lat... 1089 const coordsStr = `${destLon},${destLat};` + campsites.map(c => `${c.lon},${c.lat}`).join(';'); 1090 const url = `https://router.project-osrm.org/table/v1/driving/${coordsStr}?sources=0&annotations=duration,distance`; 1091 1092 try { 1093 const res = await fetchWithTimeout(url, { timeout: 4000 }); 1094 if (!res.ok) throw new Error('OSRM Route calculation failed'); 1095 const data = await res.json(); 1096 1097 const durations = data.durations[0]; // Times from source 0 (origin) to targets 1098 const distances = data.distances[0]; // Distances from source 0 (origin) to targets 1099 1100 // Map back to the campsite objects 1101 const validated = [];
1102 campsites.forEach((site, index) => { 1103 const durationSec = durations[index + 1]; // Offset index by 1 (since index 0 is origin-to-origin) 1104 const distanceMeters = distances[index + 1]; 1105 1106 if (durationSec !== null && durationSec !== undefined) { 1107 const durationMins = Math.round(durationSec / 60); 1108 const distanceMiles = Math.round((distanceMeters / 1609.34) * 10) / 10; 1109 1110 validated.push({ 1111 ...site, 1112 durationMinutes: durationMins, 1113 distanceMiles: distanceMiles 1114 }); 1115 } 1116 }); 1117 1118 return validated.sort((a, b) => a.durationMinutes - b.durationMinutes); 1119 } catch (err) { 1120 console.error('OSRM Driving lookup failure, falling back to geodesic estimation:', err); 1121 // Fallback: estimate at 45 mph average speed geodesic 1122 return campsites.map(site => { 1123 const distMiles = getDistanceMiles(destLat, destLon, site.lat, site.lon); 1124 const durationMins = Math.round((distMiles / 45) * 60); 1125 return { 1126 ...site, 1127 durationMinutes: durationMins, 1128 distanceMiles: Math.round(distMiles * 1.3 * 10) / 10 // Factor in winding road index 1129 }; 1130 }); 1131 } 1132 } 1133 1134 // Plot markers on the map 1135 function plotCampsites(campsites) {
1136 campsites.forEach(site => { 1137 const pinColorClass = site.source === 'OSM' ? 'camping-pin-osm' : 'camping-pin-usfs'; 1138 const iconHtml = `<div class="marker-pin ${pinColorClass}" id="marker-${site.id}"><i class="fa-solid fa-tent"></i></div>`; 1139 1140 const customIcon = L.divIcon({ 1141 html: iconHtml, 1142 className: 'custom-div-icon', 1143 iconSize: [26, 38], 1144 iconAnchor: [13, 38] 1145 }); 1146 1147 const marker = L.marker([site.lat, site.lon], { icon: customIcon }).addTo(state.map); 1148 1149 // Agency Search Link & labels for the popup 1150 let agencySearchQuery = ''; 1151 let agencyLabel = ''; 1152 let isFederal = true; 1153 const nameLower = site.name.toLowerCase(); 1154 const opLower = (site.operator || '').toLowerCase(); 1155 1156 if (site.source === 'USFS' || nameLower.includes('national forest') || opLower.includes('forest service') || opLower.includes('usfs')) { 1157 agencySearchQuery = `site:.gov ${site.name} USFS Ranger District`; 1158 agencyLabel = 'USFS Site'; 1159 } else if (nameLower.includes('national monument') || nameLower.includes('national park')) { 1160 agencySearchQuery = `site:.gov ${site.name} NPS Park Site`; 1161 agencyLabel = 'NPS Site'; 1162 } else if (nameLower.includes('state park') || nameLower.includes('state land') || nameLower.includes('state trust') || nameLower.includes('state forest') || opLower.includes('state')) { 1163 agencyLabel = 'State Land'; 1164 isFederal = false; 1165 } else { 1166 agencySearchQuery = `site:.gov ${site.name} BLM District dispersed camping`; 1167 agencyLabel = 'BLM Site'; 1168 } 1169 1170 const sourceBadgeColor = isFederal 1171 ? (site.source === 'OSM' ? 'bg-primary-950/80 text-primary-300 border border-primary-800' : 'bg-blue-950/80 text-blue-300 border border-blue-900') 1172 : 'bg-red-950/80 text-red-400 border border-red-900/60'; 1173 1174 const typeLabel = isFederal 1175 ? (site.source === 'OSM' ? 'OSM Backcountry' : 'USFS Campsite') 1176 : 'State Land'; 1177 1178 const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&origin=${state.currentDestination.lat},${state.currentDestination.lon}&destination=${site.lat},${site.lon}&travelmode=driving`; 1179 1180 // Build coordinates details string 1181 const coordsStr = `${site.lat.toFixed(5)}, ${site.lon.toFixed(5)}`; 1182 1183 marker.bindPopup(` 1184 <div class="p-1 min-w-[210px] text-slate-100"> 1185 <div class="flex items-center justify-between gap-1 mb-2 border-b border-primary-900/60 pb-1.5"> 1186 <span class="inline-block text-[9px] px-1.5 py-0.5 rounded-full font-bold uppercase tracking-wider ${sourceBadgeColor}"> 1187 ${typeLabel} 1188 </span> 1189 <span class="text-[9px] text-primary-400 font-mono">${coordsStr}</span> 1190 </div> 1191 1192 <strong class="text-accent-400 font-bold block mb-2 text-sm leading-tight">${escapeHtml(site.name)}</strong> 1193 1194 <div class="text-[10px] text-slate-300 space-y-1 mb-2"> 1195 <div class="flex justify-between"> 1196 <span class="text-primary-400">ð Drive Time:</span> 1197 <strong class="text-slate-100">${formatTime(site.durationMinutes)}</strong> 1198 </div> 1199 <div class="flex justify-between"> 1200 <span class="text-primary-400">ð Distance:</span> 1201 <strong class="text-slate-100">${site.distanceMiles} mi</strong> 1202 </div> 1203 <div class="flex justify-between"> 1204 <span class="text-primary-400">â°ï¸ Elevation:</span> 1205 <strong class="text-slate-100" id="popup-elevation-${site.id}"> 1206 ${site.elevationFeet !== undefined ? `${site.elevationFeet.toLocaleString()} ft` : '--'} 1207 </strong> 1208 </div> 1209 <div class="flex justify-between"> 1210 <span class="text-primary-400">ð¡ï¸ Jurisdiction:</span> 1211 <strong class="text-slate-100" id="popup-jurisdiction-${site.id}">Verifying...</strong> 1212 </div> 1213 </div> 1214 1215 <div class="grid grid-cols-3 gap-1 mt-2.5 border-t border-primary-900/60 pt-2 flex-wrap"> 1216 <a href="${googleMapsUrl}" 1217 target="_blank" 1218 class="py-1 px-1.5 text-[9px] font-bold text-center bg-emerald-600 hover:bg-emerald-700 text-white rounded transition-colors flex items-center justify-center gap-1 shadow-sm" 1219 style="text-decoration:none !important;" 1220 >
1221 <i class="fa-solid fa-arrow-turn-up text-[8px]"></i> Maps 1222 </a> 1223 <button onclick="window.appHighlightRoute('${site.id}')" 1224 class="py-1 px-1.5 text-[9px] font-bold text-center bg-primary-800 hover:bg-primary-750 border border-primary-700 text-slate-200 rounded transition-all active:scale-95" 1225 > 1226 <i class="fa-solid fa-route text-[8px]"></i> Route 1227 </button> 1228 <span id="popup-agency-container-${site.id}"> 1229 <button disabled class="py-1 px-1.5 text-[9px] font-bold text-center bg-primary-950/60 border border-primary-900/60 text-slate-500 rounded flex items-center justify-center gap-1 opacity-50"> 1230 <i class="fa-solid fa-spinner fa-spin text-[8px]"></i> Agency 1231 </button> 1232 </span> 1233 </div> 1234 1235 <div class="flex justify-between items-center text-[9px] mt-2 pt-1.5 border-t border-primary-900/60"> 1236 <span class="font-mono text-slate-400 font-semibold">${coordsStr}</span> 1237 <button onclick="window.appCopyCoords('${coordsStr}', this)" 1238 class="px-1.5 py-0.5 bg-primary-950/80 hover:bg-primary-900 text-slate-300 border border-primary-800 rounded text-[8px] flex items-center gap-1 transition-colors" 1239 > 1240 <i class="fa-solid fa-copy"></i> Copy 1241 </button> 1242 </div> 1243 </div> 1244 `, { maxWidth: 280 }); 1245 1246 marker.on('click', () => { 1247 selectCampsite(site); 1248 }); 1249 1250 marker.on('popupopen', async () => { 1251 // Trigger lazy loading of elevation 1252 lazyLoadElevation(site); 1253 1254 try { 1255 const url = `https://gis.blm.gov/arcgis/rest/services/lands/BLM_Natl_SMA_Cached_without_PriUnk/MapServer/identify?geometry=${site.lon},${site.lat}&geometryType=esriGeometryPoint&sr=4326&layers=all&tolerance=5&mapExtent=${site.lon-0.01},${site.lat-0.01},${site.lon+0.01},${site.lat+0.01}&imageDisplay=800,600,96&f=json`; 1256 const res = await fetchWithTimeout(url, { timeout: 4000 }); 1257 if (res.ok) { 1258 const data = await res.json(); 1259 let isFed = false; 1260 let agency = 'Non-Federal'; 1261 let dept = ''; 1262 1263 if (data.results && data.results.length > 0) { 1264 const smaResult = data.results.find(r => r.layerId === 1) || data.results[0]; 1265 if (smaResult && smaResult.attributes) { 1266 const attrs = smaResult.attributes; 1267 dept = attrs.ADMIN_DEPT_CODE || ''; 1268 agency = attrs.ADMIN_AGENCY_CODE || ''; 1269 1270 if (['DOI', 'USDA'].includes(dept) && ['BLM', 'FS', 'USFS', 'NPS', 'FWS', 'USFWS'].includes(agency)) { 1271 isFed = true; 1272 } 1273 } 1274 } 1275 1276 let userLabel = 'State/Other Land'; 1277 if (dept === 'PVT') userLabel = 'Private Land'; 1278 if (isFed) { 1279 if (agency === 'BLM') userLabel = 'BLM land'; 1280 if (agency === 'FS' || agency === 'USFS') userLabel = 'USFS land'; 1281 if (agency === 'NPS') userLabel = 'NPS land'; 1282 if (agency === 'FWS' || agency === 'USFWS') userLabel = 'USFWS land'; 1283 } 1284 1285 const jurEl = document.getElementById(`popup-jurisdiction-${site.id}`); 1286 if (jurEl) { 1287 jurEl.innerText = userLabel; 1288 } 1289 1290 const btnContainer = document.getElementById(`popup-agency-container-${site.id}`); 1291 if (btnContainer) { 1292 if (isFed) { 1293 let queryTag = 'BLM District dispersed camping'; 1294 if (agency === 'FS' || agency === 'USFS') { 1295 queryTag = 'USFS Ranger District'; 1296 } else if (agency === 'NPS') { 1297 queryTag = 'NPS Park Site'; 1298 } else if (agency === 'FWS' || agency === 'USFWS') { 1299 queryTag = 'USFWS Refuge'; 1300 } 1301 const agencySearchQuery = `site:.gov ${site.name} ${queryTag}`; 1302 btnContainer.innerHTML = ` 1303 <a href="https://www.google.com/search?q=${encodeURIComponent(agencySearchQuery)}" 1304 target="_blank" 1305 class="py-1 px-1.5 text-[9px] font-bold text-center bg-primary-800 hover:bg-primary-750 border border-primary-700 text-slate-200 rounded transition-all flex items-center justify-center gap-1" 1306 style="text-decoration:none !important;" 1307 > 1308 <i class="fa-solid fa-arrow-up-right-from-square text-[8px]"></i> Agency 1309 </a> 1310 `; 1311 } else { 1312 btnContainer.innerHTML = ` 1313 <button disabled 1314 class="py-1 px-1.5 text-[9px] font-bold text-center bg-primary-950/60 border border-primary-900/60 text-slate-500 rounded cursor-not-allowed flex items-center justify-center gap-1 opacity-50" 1315 title="Website searches are restricted to Federal lands" 1316 > 1317 <i class="fa-solid fa-ban text-[8px]"></i> Agency 1318 </button> 1319 `; 1320 } 1321 } 1322 } 1323 } catch (err) { 1324 console.error('Error identifying SMA ownership:', err); 1325 } 1326 }); 1327 1328 state.campsiteMarkers.push(marker); 1329 site.markerRef = marker; 1330 }); 1331 1332 // Zoom/pan map to contain all markers + destination 1333 if (state.campsiteMarkers.length > 0) { 1334 const group = new L.featureGroup([state.destinationMarker, ...state.campsiteMarkers]); 1335 state.map.fitBounds(group.getBounds().pad(0.1)); 1336 } 1337 } 1338 1339 // Format minutes into a nice readable format 1340 function formatTime(minutes) { 1341 const hrs = Math.floor(minutes / 60); 1342 const mins = minutes % 60; 1343 return hrs > 0 ? `${hrs}h ${mins}m` : `${mins} mins`; 1344 } 1345 1346 // Populate coordinate list table 1347 function populateTable(campsites) { 1348 tableBody.innerHTML = ''; 1349
1350 campsites.forEach(site => { 1351 const tr = document.createElement('tr'); 1352 tr.id = `row-${site.id}`; 1353 tr.className = 'border-b border-primary-900/40 cursor-pointer'; 1354 1355 const sourceBadgeColor = site.source === 'OSM' ? 'bg-primary-900 text-primary-300 border-primary-800' : 'bg-blue-950 text-blue-300 border-blue-900'; 1356 1357 tr.innerHTML = ` 1358 <td class="py-3 px-4 font-medium text-slate-200 truncate max-w-[200px]">${escapeHtml(site.name)}</td> 1359 <td class="py-3 px-4"> 1360 <span class="text-[10px] px-2 py-0.5 rounded-full border ${sourceBadgeColor} font-semibold uppercase tracking-wider">${site.source}</span> 1361 </td> 1362 <td class="py-3 px-4 font-mono text-xs text-primary-400">${site.lat.toFixed(5)}</td> 1363 <td class="py-3 px-4 font-mono text-xs text-primary-400">${site.lon.toFixed(5)}</td> 1364 <td class="py-3 px-4 text-right text-slate-300 font-medium font-mono">${site.elevationFeet !== undefined ? `${site.elevationFeet.toLocaleString()} ft` : '--'}</td> 1365 <td class="py-3 px-4 text-right text-slate-300 font-medium">${site.distanceMiles} mi</td> 1366 <td class="py-3 px-4 text-right text-accent-400 font-bold">${formatTime(site.durationMinutes)}</td> 1367 <td class="py-3 px-4 text-center"> 1368 <button class="text-slate-400 hover:text-accent-400 transition-colors p-1" title="Select Site"> 1369 <i class="fa-solid fa-chevron-right"></i> 1370 </button> 1371 </td> 1372 `; 1373 1374 tr.addEventListener('click', () => { 1375 selectCampsite(site); 1376 // Pan and open popup on map 1377 site.markerRef.openPopup(); 1378 state.map.setView([site.lat, site.lon], 12); 1379 1380 // Scroll to map 1381 const mapEl = document.getElementById('map'); 1382 if (mapEl) { 1383 mapEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 1384 } 1385 }); 1386 1387 tableBody.appendChild(tr); 1388 }); 1389 1390 // Enable exports 1391 exportGpxBtn.disabled = false; 1392 exportCsvBtn.disabled = false; 1393 } 1394 1395 // Select campsite 1396 function selectCampsite(site) { 1397 // Remove previous selection formatting 1398 document.querySelectorAll('.marker-pin').forEach(el => el.classList.remove('marker-selected')); 1399 document.querySelectorAll('tbody tr').forEach(el => el.classList.remove('active-row')); 1400 1401 state.selectedCamp = site; 1402 1403 // Highlight marker 1404 const markerEl = document.getElementById(`marker-${site.id}`); 1405 if (markerEl) markerEl.classList.add('marker-selected'); 1406 1407 // Highlight row 1408 const rowEl = document.getElementById(`row-${site.id}`); 1409 if (rowEl) { 1410 rowEl.classList.add('active-row'); 1411 } 1412 } 1413 1414 // Highlight route globally 1415 window.appHighlightRoute = async function(siteId) { 1416 const site = state.activeCampsites.find(c => c.id === siteId || c.id === `osm-${siteId}` || `osm-${c.id}` === siteId || c.id.toString() === siteId.toString()); 1417 if (!site || !state.currentDestination) return; 1418 1419 if (state.routeLine) { 1420 state.map.removeLayer(state.routeLine); 1421 } 1422 1423 const start = state.currentDestination; 1424 const end = site; 1425 1426 try { 1427 const url = `https://router.project-osrm.org/route/v1/driving/${start.lon},${start.lat};${end.lon},${end.lat}?overview=full&geometries=geojson`; 1428 const res = await fetchWithTimeout(url, { timeout: 4000 }); 1429 if (!res.ok) throw new Error('Route drawing failed'); 1430 const data = await res.json(); 1431 1432 const routeCoords = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]); 1433 1434 state.routeLine = L.polyline(routeCoords, { 1435 color: '#f9991e', 1436 weight: 5, 1437 opacity: 0.85 1438 }).addTo(state.map); 1439 1440 // Fit map to route 1441 state.map.fitBounds(state.routeLine.getBounds().pad(0.2)); 1442 } catch (err) { 1443 console.error('Routing overlay error:', err); 1444 // Fallback simple line 1445 state.routeLine = L.polyline([[start.lat, start.lon], [end.lat, end.lon]], { 1446 color: '#f9991e', 1447 weight: 3, 1448 opacity: 0.7 1449 }).addTo(state.map); 1450 } 1451 }; 1452 1453 // Copy coordinates globally 1454 window.appCopyCoords = function(coordsStr, btn) { 1455 navigator.clipboard.writeText(coordsStr).then(() => { 1456 const origHtml = btn.innerHTML; 1457 btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; 1458 setTimeout(() => { 1459 btn.innerHTML = origHtml; 1460 }, 2000); 1461 }); 1462 }; 1463 1464 function deselectCamp() { 1465 state.selectedCamp = null; 1466 } 1467 1468 // Export GPX Coordinator File 1469 exportGpxBtn.addEventListener('click', () => { 1470 if (state.activeCampsites.length === 0) return; 1471 1472 let gpx = `<?xml version="1.0" encoding="UTF-8"?> 1473<gpx version="1.1" creator="DispersedCampFinder" xmlns="http://www.topografix.com/GPX/1/1"> 1474 <metadata> 1475 <name>Dispersed Campsites near ${state.currentDestination.name}</name> 1476 </metadata>\n`; 1477
1478 state.activeCampsites.forEach(c => { 1479 const eleTag = c.elevationMeters !== undefined ? `\n <ele>${Math.round(c.elevationMeters)}</ele>` : ''; 1480 gpx += ` <wpt lat="${c.lat}" lon="${c.lon}">${eleTag} 1481 <name>${escapeXml(c.name)}</name> 1482 <desc>Source: ${c.source} | Drive time: ${formatTime(c.durationMinutes)} | Distance: ${c.distanceMiles} mi${c.elevationFeet !== undefined ? ` | Elevation: ${c.elevationFeet} ft` : ''}</desc> 1483 <sym>Campground</sym> 1484 </wpt>\n`; 1485 }); 1486 1487 gpx += '</gpx>'; 1488 1489 downloadBlob(gpx, 'camp_coordinates.gpx', 'application/gpx+xml'); 1490 }); 1491 1492 // Export CSV Coordinator File 1493 exportCsvBtn.addEventListener('click', () => { 1494 if (state.activeCampsites.length === 0) return; 1495 1496 let csv = 'Name,Source,Latitude,Longitude,Elevation (ft),Elevation (m),Driving Distance (mi),Driving Time (mins)\n'; 1497 state.activeCampsites.forEach(c => { 1498 const eleFt = c.elevationFeet !== undefined ? c.elevationFeet : ''; 1499 const eleM = c.elevationMeters !== undefined ? Math.round(c.elevationMeters) : ''; 1500 csv += `"${escapeCsv(c.name)}",${c.source},${c.lat},${c.lon},${eleFt},${eleM},${c.distanceMiles},${c.durationMinutes}\n`; 1501 }); 1502 1503 downloadBlob(csv, 'camp_coordinates.csv', 'text/csv'); 1504 }); 1505 1506 // Helper function to escape special chars for XML 1507 function escapeXml(unsafe) { 1508 return unsafe.replace(/[<>&'"]/g, function (c) { 1509 switch (c) { 1510 case '<': return '<'; 1511 case '>': return '>'; 1512 case '&': return '&'; 1513 case '\'': return '''; 1514 case '"': return '"'; 1515 } 1516 }); 1517 } 1518 1519 // Helper to escape values for CSV 1520 function escapeCsv(val) { 1521 return val.replace(/"/g, '""'); 1522 } 1523 1524 // Trigger browser file download 1525 function downloadBlob(content, filename, contentType) { 1526 const blob = new Blob([content], { type: contentType }); 1527 const url = URL.createObjectURL(blob); 1528 const a = document.createElement('a'); 1529 a.href = url; 1530 a.download = filename; 1531 document.body.appendChild(a); 1532 a.click(); 1533 setTimeout(() => { 1534 document.body.removeChild(a); 1535 window.URL.revokeObjectURL(url); 1536 }, 0); 1537 } 1538 1539 // Fetch local boundaries (parks, monuments, refuges, forests) near the searched coordinates 1540 async function fetchLocalBoundaries(bbox, signal = null) { 1541 let south = bbox.south; 1542 let west = bbox.west; 1543 let north = bbox.north; 1544 let east = bbox.east; 1545 1546 // Bound the query box: at least minSize (1.0 deg) and at most maxSize (2.0 deg) to capture regional federal lands 1547 const minSize = 1.0; 1548 const maxSize = 2.0; 1549 const latCenter = (south + north) / 2; 1550 const lonCenter = (west + east) / 2; 1551 1552 if (north - south < minSize) { 1553 south = latCenter - minSize / 2; 1554 north = latCenter + minSize / 2; 1555 } else if (north - south > maxSize) { 1556 south = latCenter - maxSize / 2; 1557 north = latCenter + maxSize / 2; 1558 } 1559 1560 if (east - west < minSize) { 1561 west = lonCenter - minSize / 2; 1562 east = lonCenter + minSize / 2; 1563 } else if (east - west > maxSize) { 1564 west = lonCenter - maxSize / 2; 1565 east = lonCenter + maxSize / 2; 1566 } 1567 1568 const query = `[out:json][timeout:10]; 1569 ( 1570 relation["boundary"="protected_area"](${south},${west},${north},${east}); 1571 relation["boundary"="national_park"](${south},${west},${north},${east}); 1572 relation["boundary"="administrative"]["name"~"National Forest|National Grassland|National Wildlife Refuge|Bureau of Land Management"](${south},${west},${north},${east}); 1573 ); 1574 out tags;`; 1575 1576 console.log("Fetching local boundaries with bbox:", { south, west, north, east }); 1577 try { 1578 const data = await fetchFromOverpass(query, 10000, signal); 1579 console.log("Raw boundary elements fetched:", data.elements ? data.elements.length : 0); 1580 1581 const seen = new Set(); 1582 const areas = []; 1583 1584 (data.elements || []).forEach(el => { 1585 const name = el.tags.name; 1586 if (!name || seen.has(name)) return; 1587 seen.add(name); 1588 1589 const operator = el.tags.operator || ''; 1590 let agencyBadge = 'Federal'; 1591 let iconClass = 'fa-solid fa-tree'; 1592 1593 const lowerName = name.toLowerCase(); 1594 const lowerOp = operator.toLowerCase(); 1595 1596 if (lowerName.includes('national forest') || lowerOp.includes('usfs') || lowerOp.includes('forest service')) { 1597 agencyBadge = 'USFS'; 1598 iconClass = 'fa-solid fa-mountain'; 1599 } else if (lowerName.includes('national park') || lowerName.includes('national monument') || lowerOp.includes('nps') || lowerOp.includes('national park service')) { 1600 agencyBadge = 'NPS'; 1601 iconClass = 'fa-solid fa-monument'; 1602 } else if (lowerName.includes('national wildlife refuge') || lowerName.includes('wildlife refuge') || lowerOp.includes('fws') || lowerOp.includes('fish and wildlife')) { 1603 agencyBadge = 'USFWS'; 1604 iconClass = 'fa-solid fa-shield-cat'; 1605 } else if (lowerName.includes('bureau of land management') || lowerOp.includes('blm') || lowerOp.includes('bureau of land management') || lowerName.includes('blm')) { 1606 agencyBadge = 'BLM'; 1607 iconClass = 'fa-solid fa-tree'; 1608 } else if (lowerName.includes('wilderness')) { 1609 agencyBadge = 'Wilderness'; 1610 iconClass = 'fa-solid fa-compass'; 1611 } else { 1612 return; // Skip state, regional, county, municipal, and private parks/lands 1613 } 1614 1615 areas.push({ name, agencyBadge, iconClass }); 1616 }); 1617 1618 1619 console.log("Filtered boundary areas list:", areas); 1620 return areas.slice(0, 8); // Show top 8 major federal/state areas 1621 } catch (e) { 1622 console.error('Boundaries fetch error:', e); 1623 throw e; 1624 } 1625 } 1626 1627 function renderLocalBoundaries(areas) { 1628 const selectEl = document.getElementById('local-boundaries-select'); 1629 selectEl.innerHTML = `<option value="" disabled selected>Find Official Websites (${areas.length} found)</option>`; 1630 1631 // Extract State from destination name to build a regional BLM District link 1632 let blmStateQuery = 'site:.gov BLM District Offices'; 1633 let blmStateName = 'State'; 1634 1635 if (state.currentDestination && state.currentDestination.name) { 1636 const destName = state.currentDestination.name.toLowerCase(); 1637 const states = [ 1638 { name: 'Arizona', code: 'AZ' }, 1639 { name: 'Utah', code: 'UT' }, 1640 { name: 'Colorado', code: 'CO' }, 1641 { name: 'California', code: 'CA' }, 1642 { name: 'Nevada', code: 'NV' }, 1643 { name: 'Oregon', code: 'OR' }, 1644 { name: 'Idaho', code: 'ID' }, 1645 { name: 'Wyoming', code: 'WY' }, 1646 { name: 'Montana', code: 'MT' }, 1647 { name: 'New Mexico', code: 'NM' } 1648 ]; 1649 1650 const foundState = states.find(s => 1651 destName.includes(s.name.toLowerCase()) || 1652 new RegExp(`\\b${s.code.toLowerCase()}\\b`).test(destName) 1653 ); 1654 if (foundState) { 1655 blmStateName = foundState.name; 1656 blmStateQuery = `site:.gov BLM ${foundState.name} District Offices`; 1657 } 1658 } 1659 1660 // Always inject the regional BLM District Finder option at the top of the dropdown 1661 const blmOpt = document.createElement('option'); 1662 blmOpt.value = `https://www.google.com/search?q=${encodeURIComponent(blmStateQuery)}`;
1663 blmOpt.innerText = `BLM ${blmStateName} District Offices (BLM)`; 1664 selectEl.appendChild(blmOpt); 1665 1666 if (areas.length === 0) { 1667 const opt = document.createElement('option'); 1668 opt.value = ""; 1669 opt.disabled = true; 1670 opt.innerText = "No other districts found in this view"; 1671 selectEl.appendChild(opt); 1672 return; 1673 } 1674 1675 areas.forEach(area => { 1676 // Avoid duplicating BLM state if it matches 1677 if (area.name.toLowerCase().includes('bureau of land management') || area.name.toLowerCase() === `blm ${blmStateName.toLowerCase()}`) return; 1678 1679 const opt = document.createElement('option'); 1680 opt.value = `https://www.google.com/search?q=${encodeURIComponent('site:.gov ' + area.name)}`; 1681 opt.innerText = `${area.name} (${area.agencyBadge})`; 1682 selectEl.appendChild(opt); 1683 }); 1684 } 1685 1686 function renderBoundaryFallback() { 1687 const selectEl = document.getElementById('local-boundaries-select'); 1688 selectEl.innerHTML = `<option value="" disabled selected>Find Official Websites (Search Links)</option>`; 1689 1690 let destName = ''; 1691 let blmStateQuery = 'site:.gov BLM District Offices'; 1692 let blmStateName = 'State'; 1693 1694 if (state.currentDestination && state.currentDestination.name) { 1695 destName = state.currentDestination.name; 1696 const destLower = destName.toLowerCase(); 1697 const states = [ 1698 { name: 'Arizona', code: 'AZ' }, 1699 { name: 'Utah', code: 'UT' }, 1700 { name: 'Colorado', code: 'CO' }, 1701 { name: 'California', code: 'CA' }, 1702 { name: 'Nevada', code: 'NV' }, 1703 { name: 'Oregon', code: 'OR' }, 1704 { name: 'Idaho', code: 'ID' }, 1705 { name: 'Wyoming', code: 'WY' }, 1706 { name: 'Montana', code: 'MT' }, 1707 { name: 'New Mexico', code: 'NM' } 1708 ]; 1709 1710 const foundState = states.find(s => 1711 destLower.includes(s.name.toLowerCase()) || 1712 new RegExp(`\\b${s.code.toLowerCase()}\\b`).test(destLower) 1713 ); 1714 if (foundState) { 1715 blmStateName = foundState.name; 1716 blmStateQuery = `site:.gov BLM ${foundState.name} District Offices`; 1717 } 1718 } 1719 1720 // BLM District Offices Option 1721 const blmOpt = document.createElement('option'); 1722 blmOpt.value = `https://www.google.com/search?q=${encodeURIComponent(blmStateQuery)}`; 1723 blmOpt.innerText = `BLM ${blmStateName} District Offices (BLM)`; 1724 selectEl.appendChild(blmOpt); 1725 1726 if (destName) { 1727 // USFS Search Option 1728 const fsOpt = document.createElement('option'); 1729 fsOpt.value = `https://www.google.com/search?q=${encodeURIComponent('site:.fed.us OR site:.gov dispersed camping ' + destName)}`; 1730 fsOpt.innerText = `Search USFS camping near ${destName.split(',')[0]}`; 1731 selectEl.appendChild(fsOpt); 1732 1733 // BLM Search Option 1734 const blmSearchOpt = document.createElement('option'); 1735 blmSearchOpt.value = `https://www.google.com/search?q=${encodeURIComponent('site:.gov BLM dispersed camping ' + destName)}`; 1736 blmSearchOpt.innerText = `Search BLM camping near ${destName.split(',')[0]}`; 1737 selectEl.appendChild(blmSearchOpt); 1738 } 1739 1740 const infoOpt = document.createElement('option'); 1741 infoOpt.value = ""; 1742 infoOpt.disabled = true; 1743 infoOpt.innerText = "(Live lookup rate-limited; showing search fallbacks)"; 1744 selectEl.appendChild(infoOpt); 1745 } 1746 1747 // Handle local boundaries select redirection 1748 const selectEl = document.getElementById('local-boundaries-select'); 1749 selectEl.addEventListener('change', (e) => { 1750 const url = e.target.value; 1751 if (url) { 1752 window.open(url, '_blank'); 1753 // Reset select back to placeholder 1754 selectEl.value = ""; 1755 } 1756 }); 1757 1758 // Fetch elevation data for a single lat/lon using USGS 1759 async function fetchUsgsElevation(lat, lon) { 1760 const url = `https://epqs.nationalmap.gov/v1/json?x=${lon}&y=${lat}&wkid=4326&units=Feet`; 1761 const res = await fetchWithTimeout(url, { timeout: 3000 }); 1762 if (!res.ok) throw new Error(`USGS HTTP error ${res.status}`); 1763 const data = await res.json(); 1764 if (data && data.value !== undefined && data.value > -1000) { 1765 return { 1766 elevationFeet: Math.round(data.value), 1767 elevationMeters: Math.round(data.value / 3.28084) 1768 }; 1769 }
1770 throw new Error('USGS returned invalid data'); 1771 } 1772 1773 // Fetch elevation data for latitude and longitude arrays from Open-Meteo with USGS fallback (lazy campsites) 1774 async function fetchElevations(dest, campsites) { 1775 if (!dest) return; 1776 1777 // Attempt Open-Meteo first (highly efficient single batch query) 1778 try { 1779 const lats = [dest.lat, ...campsites.map(c => c.lat)].join(','); 1780 const lons = [dest.lon, ...campsites.map(c => c.lon)].join(','); 1781 const url = `https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`; 1782 1783 const res = await fetchWithTimeout(url, { timeout: 5000 }); 1784 if (res.ok) { 1785 const data = await res.json(); 1786 if (data && data.elevation) { 1787 dest.elevationMeters = data.elevation[0]; 1788 dest.elevationFeet = Math.round(data.elevation[0] * 3.28084); 1789 1790 campsites.forEach((site, index) => { 1791 const m = data.elevation[index + 1]; 1792 if (m !== null && m !== undefined) { 1793 site.elevationMeters = m; 1794 site.elevationFeet = Math.round(m * 3.28084); 1795 } 1796 }); 1797 return; // Success! 1798 } 1799 } 1800 } catch (err) { 1801 console.warn('Open-Meteo elevation lookup failed, falling back to USGS:', err); 1802 } 1803 1804 // Fallback: Only fetch destination elevation immediately 1805 try { 1806 const res = await fetchUsgsElevation(dest.lat, dest.lon); 1807 dest.elevationFeet = res.elevationFeet; 1808 dest.elevationMeters = res.elevationMeters; 1809 } catch (err) { 1810 console.error('Failed to get destination elevation:', err); 1811 } 1812 } 1813 1814 // Lazy fetch elevation for a single site if not already loaded 1815 async function lazyLoadElevation(site) { 1816 if (site.elevationFeet !== undefined) return; // Already loaded 1817 1818 try { 1819 const res = await fetchUsgsElevation(site.lat, site.lon); 1820 site.elevationFeet = res.elevationFeet; 1821 site.elevationMeters = res.elevationMeters; 1822 1823 // Update UI elements dynamically: 1824 1825 // 1. Update Popup content if currently open 1826 const popupEl = document.getElementById(`popup-elevation-${site.id}`); 1827 if (popupEl) { 1828 popupEl.innerText = `${site.elevationFeet.toLocaleString()} ft`; 1829 } 1830 1831 // 2. Update Table Row 1832 const rowEle = document.getElementById(`row-${site.id}`); 1833 if (rowEle) { 1834 const cells = rowEle.getElementsByTagName('td'); 1835 if (cells && cells[4]) { 1836 cells[4].innerText = `${site.elevationFeet.toLocaleString()} ft`; 1837 } 1838 } 1839 } catch (err) { 1840 console.warn(`Failed to lazy load elevation for campsite ${site.name}:`, err); 1841 } 1842 } 1843 1844 // Sort campsites table 1845 function sortCampsites(field) { 1846 if (state.sortField === field) { 1847 state.sortAsc = !state.sortAsc; 1848 } else { 1849 state.sortField = field; 1850 state.sortAsc = true; 1851 } 1852 1853 // Sort array 1854 state.activeCampsites.sort((a, b) => { 1855 let valA, valB; 1856 1857 switch (field) { 1858 case 'name': 1859 valA = (a.name || '').toLowerCase(); 1860 valB = (b.name || '').toLowerCase(); 1861 return state.sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA); 1862 case 'source': 1863 valA = (a.source || '').toLowerCase(); 1864 valB = (b.source || '').toLowerCase(); 1865 return state.sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA); 1866 case 'lat': 1867 valA = a.lat; 1868 valB = b.lat; 1869 break; 1870 case 'lon': 1871 valA = a.lon; 1872 valB = b.lon; 1873 break; 1874 case 'elevation': 1875 valA = a.elevationFeet !== undefined ? a.elevationFeet : -99999; 1876 valB = b.elevationFeet !== undefined ? b.elevationFeet : -99999; 1877 break; 1878 case 'distance': 1879 valA = a.distanceMiles; 1880 valB = b.distanceMiles; 1881 break; 1882 case 'time': 1883 valA = a.durationMinutes; 1884 valB = b.durationMinutes; 1885 break; 1886 default: 1887 return 0; 1888 } 1889 1890 if (valA < valB) return state.sortAsc ? -1 : 1; 1891 if (valA > valB) return state.sortAsc ? 1 : -1; 1892 return 0; 1893 }); 1894 1895 // Re-populate the table 1896 populateTable(state.activeCampsites); 1897 1898 // Update sort icons in HTML headers 1899 updateSortIcons(field, state.sortAsc); 1900 } 1901 1902 function updateSortIcons(activeField, isAsc) { 1903 const headers = { 1904 name: 'th-name', 1905 source: 'th-source', 1906 lat: 'th-lat', 1907 lon: 'th-lon', 1908 elevation: 'th-elevation', 1909 distance: 'th-distance', 1910 time: 'th-time' 1911 }; 1912
1913 Object.keys(headers).forEach(key => { 1914 const el = document.getElementById(headers[key]); 1915 if (!el) return; 1916 1917 const icon = el.querySelector('i'); 1918 if (!icon) return; 1919 1920 if (key === activeField) { 1921 icon.className = isAsc ? 'fa-solid fa-sort-up text-[10px] text-accent-500 ml-1' : 'fa-solid fa-sort-down text-[10px] text-accent-500 ml-1'; 1922 icon.classList.remove('opacity-40'); 1923 } else { 1924 icon.className = 'fa-solid fa-sort text-[10px] opacity-40 ml-1'; 1925 } 1926 }); 1927 } 1928 1929 // Set up sorting listeners 1930 document.getElementById('th-name').addEventListener('click', () => sortCampsites('name')); 1931 document.getElementById('th-source').addEventListener('click', () => sortCampsites('source')); 1932 document.getElementById('th-lat').addEventListener('click', () => sortCampsites('lat')); 1933 document.getElementById('th-lon').addEventListener('click', () => sortCampsites('lon')); 1934 document.getElementById('th-elevation').addEventListener('click', () => sortCampsites('elevation')); 1935 document.getElementById('th-distance').addEventListener('click', () => sortCampsites('distance')); 1936 document.getElementById('th-time').addEventListener('click', () => sortCampsites('time')); 1937 1938 // Fire up initialization 1939 initMap(); 1940});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.