PageSourceSearch

https://virtualcaravans.web.app/app.js

js virtualcaravans.web.app collected 2026-10-03 12:39:19 UTC 79,751 bytes, 1,940 lines download raw bytes

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 '&amp;';
50        case '<': return '&lt;';
51        case '>': return '&gt;';
52        case '"': return '&quot;';
53        case "'": return '&#039;';
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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 '&lt;';
1511        case '>': return '&gt;';
1512        case '&': return '&amp;';
1513        case '\'': return '&apos;';
1514        case '"': return '&quot;';
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.