Geocaching Trackable Map Visualizer

View your trackables on an interactive map. See where all your Geocaching trackables have been at a glance!

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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

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

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

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         Geocaching Trackable Map Visualizer
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  View your trackables on an interactive map. See where all your Geocaching trackables have been at a glance!
// @author       ViezeVingertjes
// @match        *://*.geocaching.com/track/search.aspx*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=geocaching.com
// @grant        none
// @require      https://unpkg.com/[email protected]/dist/leaflet.js
// @resource     LEAFLET_CSS https://unpkg.com/[email protected]/dist/leaflet.css
// ==/UserScript==

(function() {
    'use strict';

    // Inject Leaflet CSS
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.href = 'https://unpkg.com/[email protected]/dist/leaflet.css';
    document.head.appendChild(linkElement);

    console.log("Geocaching Trackable Page Enhancer script loaded!");

    /**
     * Helper function to check if an element is visible
     * @param {Element} element - The DOM element to check
     * @returns {boolean} - Whether the element is visible
     */
    function isElementVisible(element) {
      if (!element) return false;

      const style = window.getComputedStyle(element);
      return style.display !== 'none' &&
             style.visibility !== 'hidden' &&
             style.opacity !== '0' &&
             element.offsetWidth > 0 &&
             element.offsetHeight > 0;
    }

    /**
     * Extracts trackable information from anchor elements on the page
     * @returns {Map} Map of trackable objects with id as key
     */
    function extractTrackablesFromPage() {
      const anchorElements = document.querySelectorAll("a");
      const trackablesMap = new Map();
      const trackableUrlPrefix = "https://www.geocaching.com/track/details.aspx?id=";

      anchorElements.forEach(anchor => {
        const href = anchor.getAttribute("href");
        if (href && href.startsWith(trackableUrlPrefix)) {
          try {
            const url = new URL(href, document.baseURI);
            const id = url.searchParams.get("id");
            const name = anchor.innerText.trim();

            if (id && name && !trackablesMap.has(id)) {
              trackablesMap.set(id, { id, name });
            }
          } catch (e) {
            console.error("Error parsing URL or extracting trackable info:", e, href);
          }
        }
      });

      return trackablesMap;
    }

    /**
     * Parse trackable stops from the map page HTML content
     * @param {string} htmlContent - HTML content from the trackable map page
     * @param {string} trackableId - ID of the trackable for error reporting
     * @returns {Array} Array of stop objects with coordinates and cache names
     */
    function parseTrackableStops(htmlContent, trackableId) {
      const stops = [];
      const tbStopsRegex = /var tbStops\s*=\s*(\[[\s\S]*?\])\s*;/;
      const match = htmlContent.match(tbStopsRegex);

      if (!match || !match[1]) {
        console.warn(`tbStops not found for trackable ${trackableId}`);
        return stops;
      }

      try {
        const arrayContentString = match[1].slice(1, -1); // Remove outer brackets
        const objectRegex = /{[\s\S]*?}/g;
        const coordRegex = /ll\s*:\s*\[\s*([\d\.-]+)\s*,\s*([\d\.-]+)\s*\]/;
        const nameRegex = /n\s*:\s*"([^"]*)"/;
        let objectMatch;

        while ((objectMatch = objectRegex.exec(arrayContentString)) !== null) {
          const objectString = objectMatch[0];
          const coordMatch = objectString.match(coordRegex);
          const nameMatch = objectString.match(nameRegex);

          if (coordMatch && coordMatch[1] && coordMatch[2] && nameMatch && nameMatch[1]) {
            try {
              const lat = parseFloat(coordMatch[1]);
              const lon = parseFloat(coordMatch[2]);
              const name = nameMatch[1];
              stops.push({
                coordinates: [lat, lon],
                cacheName: name
              });
            } catch (e) {
              console.error(`Error parsing coordinates for trackable ${trackableId}:`, e);
            }
          } else {
            console.error(`Failed to extract data from object string for trackable ${trackableId}:`, objectString);
          }
        }
      } catch (e) {
        console.error(`Error processing tbStops for trackable ${trackableId}:`, e);
      }

      return stops;
    }

    /**
     * Fetches and processes trackable stops data
     * @param {Object} trackable - The trackable object to enrich with stops
     * @returns {Object} The enriched trackable object
     */
    async function fetchTrackableStops(trackable) {
      const mapUrl = `https://www.geocaching.com/track/map_gm.aspx?ID=${trackable.id}`;

      try {
        const response = await fetch(mapUrl);
        if (!response.ok) {
          console.error(`Failed to fetch ${mapUrl}: ${response.status} ${response.statusText}`);
          trackable.stops = [];
          return trackable;
        }

        const htmlContent = await response.text();
        trackable.stops = parseTrackableStops(htmlContent, trackable.id);

      } catch (error) {
        console.error(`Error fetching stops for trackable ${trackable.id}:`, error);
        trackable.stops = [];
      }

      return trackable;
    }

    /**
     * Enriches trackables with their stop information
     * @param {Array} trackables - Array of trackable objects
     * @returns {Array} Array of enriched trackable objects
     */
    async function enrichTrackablesWithStops(trackables) {
      if (trackables.length === 0) {
        console.log("No trackables found to enrich.");
        return [];
      }

      const enrichedTrackables = await Promise.all(
        trackables.map(trackable => fetchTrackableStops(trackable))
      );

      console.log("Enriched Trackables (with stops):", enrichedTrackables);
      return enrichedTrackables;
    }

    // Add a global variable to track if processing is currently in progress
    let isProcessingTrackables = false;
    // Add a global variable to store the map instance
    let trackableMap = null;

    /**
     * Displays the trackable data on a map
     * @param {Array} trackables - Array of trackable objects with stops
     * @param {HTMLElement} [existingContainer] - Optional existing map container
     */
    function displayTrackablesMap(trackables, existingContainer) {
      // Filter trackables with stops
      const trackablesWithStops = trackables.filter(t => t.stops && t.stops.length > 0);

      if (trackablesWithStops.length === 0) {
        console.log('No trackables with stops to display on map');
        return;
      }

      // Sort trackables by number of stops (descending)
      trackablesWithStops.sort((a, b) => b.stops.length - a.stops.length);

      // Extract the last stop from each trackable
      const mapPoints = trackablesWithStops.map(trackable => {
        const lastStop = trackable.stops[trackable.stops.length - 1];
        return {
          trackableId: trackable.id,
          trackableName: trackable.name,
          cacheName: lastStop.cacheName,
          coordinates: lastStop.coordinates,
          totalStops: trackable.stops.length
        };
      });

      console.log('Map points for display:', mapPoints);

      // Group points by coordinates to combine markers at the same location
      const groupedPoints = {};
      mapPoints.forEach(point => {
        const coordKey = point.coordinates.join(',');
        if (!groupedPoints[coordKey]) {
          groupedPoints[coordKey] = {
            coordinates: point.coordinates,
            cacheName: point.cacheName, // Use the cache name from the first trackable at this location
            trackables: []
          };
        }
        groupedPoints[coordKey].trackables.push(point);
      });

      console.log('Grouped map points:', groupedPoints);

      // Convert back to array for display and sort by total number of trackables (descending)
      const combinedMapPoints = Object.values(groupedPoints);
      combinedMapPoints.sort((a, b) => b.trackables.length - a.trackables.length);

      // If we don't have a map instance, we can't proceed
      if (!trackableMap) {
        console.error('No map instance available');
        return;
      }

      // Remove any loading message
      const loadingControl = document.querySelector('.loading-message');
      if (loadingControl && loadingControl.parentNode) {
        loadingControl.parentNode.removeChild(loadingControl);
      }

      // Clear any existing markers
      trackableMap.eachLayer(layer => {
        if (layer instanceof L.Marker || layer instanceof L.Tooltip) {
          trackableMap.removeLayer(layer);
        }
      });

      // Calculate bounding box for all points
      let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180;

      combinedMapPoints.forEach(point => {
        const [lat, lon] = point.coordinates;
        minLat = Math.min(minLat, lat);
        maxLat = Math.max(maxLat, lat);
        minLon = Math.min(minLon, lon);
        maxLon = Math.max(maxLon, lon);
      });

      // Add padding
      const latPadding = Math.max(0.05, (maxLat - minLat) * 0.1);
      const lonPadding = Math.max(0.05, (maxLon - minLon) * 0.1);

      minLat = Math.max(-85, minLat - latPadding);
      maxLat = Math.min(85, maxLat + latPadding);
      minLon = Math.max(-180, minLon - lonPadding);
      maxLon = Math.min(180, maxLon + lonPadding);

      // Fit the map to the bounds of all markers
      try {
        trackableMap.fitBounds([
          [minLat, minLon],
          [maxLat, maxLon]
        ]);
      } catch (e) {
        console.error('Error fitting map bounds:', e);
      }

      // Store markers for reference
      const markers = [];

      // Define a good palette of distinct colors
      const colorPalette = [
        '#e6194B', // Red
        '#3cb44b', // Green
        '#ffe119', // Yellow
        '#4363d8', // Blue
        '#f58231', // Orange
        '#911eb4', // Purple
        '#42d4f4', // Cyan
        '#f032e6', // Magenta
        '#bfef45', // Lime
        '#fabed4', // Pink
        '#469990', // Teal
        '#dcbeff', // Lavender
        '#9A6324', // Brown
        '#fffac8', // Beige
        '#800000', // Maroon
        '#aaffc3', // Mint
        '#808000', // Olive
        '#ffd8b1', // Apricot
        '#000075', // Navy
        '#a9a9a9', // Grey
        '#ffffff', // White
        '#000000'  // Black
      ];

      // Create a map to track used colors for cache names
      const cacheColorMap = new Map();
      // Track last used color index for round-robin assignment
      let lastColorIndex = -1;

      // Get a color ensuring no consecutive identical colors
      function getColorForCache(cacheName) {
        // If we already assigned a color to this cache, use it
        if (cacheColorMap.has(cacheName)) {
          return cacheColorMap.get(cacheName);
        }

        // Get the next color in round-robin fashion
        lastColorIndex = (lastColorIndex + 1) % colorPalette.length;

        // Find a different color if this would create consecutive same colors
        if (markers.length > 0) {
          const prevMarker = markers[markers.length - 1];
          const prevColor = getColorForCache(prevMarker.point.cacheName);

          // If colors would match, skip to next color
          if (colorPalette[lastColorIndex] === prevColor) {
            lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
          }
        }

        const color = colorPalette[lastColorIndex];
        cacheColorMap.set(cacheName, color);
        return color;
      }

      // Add markers for each point
      combinedMapPoints.forEach((point, index) => {
        const [lat, lon] = point.coordinates;
        const trackables = point.trackables;
        const trackableCount = trackables.length;

        // Sort trackables at this location by number of stops (descending)
        trackables.sort((a, b) => b.totalStops - a.totalStops);

        // Get a color based on cache name
        const markerColor = getColorForCache(point.cacheName);

        // Create popup content with basic information
        let popupContent = `
          <div>
            <div style="font-weight: bold; margin-bottom: 5px;">${point.cacheName}</div>
            <div style="font-size: 12px; color: #666; margin-bottom: 8px;">Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}</div>
        `;

        if (trackables.length > 1) {
          popupContent += `<div style="font-weight: bold; margin-bottom: 8px; color: ${markerColor};">${trackables.length} Trackables at this Location</div>`;
        }

        // Add each trackable with simple formatting
        trackables.forEach((tb, i) => {
          popupContent += `
            <div style="margin-top: 8px; ${i > 0 ? 'border-top: 1px solid #eee; padding-top: 8px;' : ''}">
              <div style="font-weight: bold;">${i+1}. ${tb.trackableName}</div>
              ${tb.totalStops ? `<div style="font-size: 12px; color: #666;">Total stops: ${tb.totalStops}</div>` : ''}
              <div><a href="https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}" target="_blank" style="color: #007bff; text-decoration: none;">View trackable details</a></div>
            </div>
          `;
        });

        popupContent += '</div>';

        // Create a colored marker for this point
        const markerIcon = L.divIcon({
          className: '',
          html: `<div style="background-color: ${markerColor}; width: 24px; height: 24px; border-radius: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>`,
          iconSize: [28, 28],
          iconAnchor: [14, 14]
        });
        const marker = L.marker([lat, lon], { icon: markerIcon }).addTo(trackableMap);

        // Add a label for the marker
        const labelText = point.cacheName + (trackables.length > 1 ? ` (${trackables.length})` : '');

        const label = L.tooltip({
          permanent: true,
          direction: 'top',
          className: 'trackable-marker-label',
          offset: [0, -12]
        })
        .setContent(labelText)
        .setLatLng([lat, lon]);

        label.addTo(trackableMap);

        // Bind popup to marker
        marker.bindPopup(popupContent);

        // Hide label when popup is open
        marker.on('popupopen', function() {
          trackableMap.removeLayer(label);
        });

        // Show label when popup is closed
        marker.on('popupclose', function() {
          label.addTo(trackableMap);
        });

        markers.push({
          marker,
          label,
          point
        });
      });

      // Update the legend
      const mapSection = existingContainer.closest('#gc-trackables-map-section');
      const legendContainer = mapSection ? mapSection.querySelector('#trackables-map-legend') : null;

      if (legendContainer) {
        // Get the content container
        const legendContent = document.getElementById('trackables-map-legend-content');
        if (!legendContent) return;

        // Clear any existing content
        legendContent.innerHTML = '';

        // Add entries for each marker/location
        markers.forEach((markerData, index) => {
          const { marker, point, label } = markerData;
          const trackables = point.trackables;

          // For each location, create a section
          const sectionContainer = document.createElement('div');
          sectionContainer.style.marginBottom = index < markers.length - 1 ? '10px' : '0';
          sectionContainer.style.paddingBottom = index < markers.length - 1 ? '10px' : '0';
          sectionContainer.style.borderBottom = index < markers.length - 1 ? '1px solid #eee' : 'none';

          // Location header
          const locationHeader = document.createElement('div');
          locationHeader.style.display = 'flex';
          locationHeader.style.alignItems = 'center';
          locationHeader.style.marginBottom = '5px';
          locationHeader.style.cursor = 'pointer';

          // Create color dot to match marker color
          const colorDot = document.createElement('span');
          colorDot.style.width = '16px';
          colorDot.style.height = '16px';
          colorDot.style.borderRadius = '50%';
          colorDot.style.backgroundColor = getColorForCache(point.cacheName);
          colorDot.style.display = 'inline-block';
          colorDot.style.marginRight = '8px';
          colorDot.style.border = '1px solid rgba(0,0,0,0.2)';

          locationHeader.appendChild(colorDot);

          // Location text
          let locationText;
          locationText = document.createElement('div');

          if (trackables.length === 1) {
            locationText.textContent = trackables[0].cacheName;
          } else {
            locationText.textContent = `${point.cacheName} (${trackables.length} trackables)`;
          }

          locationText.style.fontWeight = 'bold';
          locationHeader.appendChild(locationText);

          // Add click event to zoom to marker
          locationHeader.addEventListener('click', () => {
            trackableMap.setView(marker.getLatLng(), 15);

            // Slight delay to ensure map has completed moving before opening popup
            setTimeout(() => {
              marker.openPopup();
            }, 300);
          });

          // Add hover effect
          locationHeader.addEventListener('mouseenter', () => {
            locationHeader.style.backgroundColor = '#f0f0f0';
          });

          locationHeader.addEventListener('mouseleave', () => {
            locationHeader.style.backgroundColor = '';
          });

          sectionContainer.appendChild(locationHeader);

          // Add individual trackable items if there are multiple at this location
          if (trackables.length > 1) {
            const trackablesList = document.createElement('div');
            trackablesList.style.marginLeft = '24px';

            trackables.forEach((tb, i) => {
              const trackableItem = document.createElement('div');
              trackableItem.style.padding = '3px 0';
              trackableItem.style.fontSize = '12px';
              trackableItem.style.display = 'flex';
              trackableItem.style.alignItems = 'center';
              trackableItem.style.cursor = 'pointer';

              const bulletPoint = document.createElement('span');
              bulletPoint.textContent = '•';
              bulletPoint.style.marginRight = '5px';
              trackableItem.appendChild(bulletPoint);

              const tbName = document.createElement('span');
              tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
              trackableItem.appendChild(tbName);

              // Add click handler to open trackable page
              trackableItem.addEventListener('click', () => {
                window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
              });

              // Add hover effect
              trackableItem.addEventListener('mouseenter', () => {
                trackableItem.style.backgroundColor = '#f0f0f0';
                trackableItem.style.color = '#0066cc';
              });

              trackableItem.addEventListener('mouseleave', () => {
                trackableItem.style.backgroundColor = '';
                trackableItem.style.color = '';
              });

              trackablesList.appendChild(trackableItem);
            });

            sectionContainer.appendChild(trackablesList);
          } else if (trackables.length === 1) {
            // Make single trackable clickable too
            const tb = trackables[0];
            const trackableItem = document.createElement('div');
            trackableItem.style.marginLeft = '28px';
            trackableItem.style.fontSize = '12px';
            trackableItem.style.cursor = 'pointer';
            trackableItem.style.display = 'flex';
            trackableItem.style.alignItems = 'center';

            const bulletPoint = document.createElement('span');
            bulletPoint.textContent = '•';
            bulletPoint.style.marginRight = '5px';
            trackableItem.appendChild(bulletPoint);

            const tbName = document.createElement('span');
            tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
            trackableItem.appendChild(tbName);

            // Add click handler to open trackable page
            trackableItem.addEventListener('click', () => {
              window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
            });

            // Add hover effect
            trackableItem.addEventListener('mouseenter', () => {
              trackableItem.style.backgroundColor = '#f0f0f0';
              trackableItem.style.color = '#0066cc';
            });

            trackableItem.addEventListener('mouseleave', () => {
              trackableItem.style.backgroundColor = '';
              trackableItem.style.color = '';
            });

            sectionContainer.appendChild(trackableItem);
          }

          legendContent.appendChild(sectionContainer);
        });
      }
    }

    /**
     * Creates a map using Leaflet
     * @param {HTMLElement} container - The container to add the map to
     * @param {Array} points - The points to display on the map
     */
    function createSimpleMapWithMarkers(container, points) {
      if (!container || !points || points.length === 0) return;

      // Calculate bounding box for all points
      let minLat = 90;
      let maxLat = -90;
      let minLon = 180;
      let maxLon = -180;

      points.forEach(point => {
        const [lat, lon] = point.coordinates;
        minLat = Math.min(minLat, lat);
        maxLat = Math.max(maxLat, lat);
        minLon = Math.min(minLon, lon);
        maxLon = Math.max(maxLon, lon);
      });

      // Add padding
      const latPadding = Math.max(0.05, (maxLat - minLat) * 0.1);
      const lonPadding = Math.max(0.05, (maxLon - minLon) * 0.1);

      minLat = Math.max(-85, minLat - latPadding);
      maxLat = Math.min(85, maxLat + latPadding);
      minLon = Math.max(-180, minLon - lonPadding);
      maxLon = Math.min(180, maxLon + lonPadding);

      // Clear the container
      container.innerHTML = '';

      // Create map container for Leaflet
      const mapViewContainer = document.createElement('div');
      mapViewContainer.id = 'leaflet-map';
      mapViewContainer.style.width = '100%';
      mapViewContainer.style.height = '500px';
      mapViewContainer.style.border = '1px solid #ddd';
      mapViewContainer.style.borderRadius = '4px';
      container.appendChild(mapViewContainer);

      // Initialize the map
      const map = L.map('leaflet-map').fitBounds([
        [minLat, minLon],
        [maxLat, maxLon]
      ]);

      // Add OpenStreetMap tile layer
      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
        maxZoom: 18
      }).addTo(map);

      // Add custom CSS for marker labels
      const style = document.createElement('style');
      style.textContent = `
        .trackable-marker-label {
          background: white;
          border: 1px solid #333;
          border-radius: 4px;
          padding: 2px 6px;
          font-weight: bold;
          white-space: nowrap;
          text-align: center;
          box-shadow: 0 1px 3px rgba(0,0,0,0.2);
          pointer-events: none;
        }
      `;
      document.head.appendChild(style);

      // Store markers for reference
      const markers = [];

      // Define a good palette of distinct colors
      const colorPalette = [
        '#e6194B', // Red
        '#3cb44b', // Green
        '#ffe119', // Yellow
        '#4363d8', // Blue
        '#f58231', // Orange
        '#911eb4', // Purple
        '#42d4f4', // Cyan
        '#f032e6', // Magenta
        '#bfef45', // Lime
        '#fabed4', // Pink
        '#469990', // Teal
        '#dcbeff', // Lavender
        '#9A6324', // Brown
        '#fffac8', // Beige
        '#800000', // Maroon
        '#aaffc3', // Mint
        '#808000', // Olive
        '#ffd8b1', // Apricot
        '#000075', // Navy
        '#a9a9a9', // Grey
        '#ffffff', // White
        '#000000'  // Black
      ];

      // Create a map to track used colors for cache names
      const cacheColorMap = new Map();
      // Track last used color index for round-robin assignment
      let lastColorIndex = -1;

      // Get a color ensuring no consecutive identical colors
      function getColorForCache(cacheName) {
        // If we already assigned a color to this cache, use it
        if (cacheColorMap.has(cacheName)) {
          return cacheColorMap.get(cacheName);
        }

        // Get the next color in round-robin fashion
        lastColorIndex = (lastColorIndex + 1) % colorPalette.length;

        // Find a different color if this would create consecutive same colors
        if (markers.length > 0) {
          const prevMarker = markers[markers.length - 1];
          const prevColor = getColorForCache(prevMarker.point.cacheName);

          // If colors would match, skip to next color
          if (colorPalette[lastColorIndex] === prevColor) {
            lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
          }
        }

        const color = colorPalette[lastColorIndex];
        cacheColorMap.set(cacheName, color);
        return color;
      }

      // Add markers for each point
      points.forEach((point, index) => {
        const [lat, lon] = point.coordinates;
        const trackables = point.trackables;
        const trackableCount = trackables.length;

        // Sort trackables at this location by number of stops (descending)
        trackables.sort((a, b) => b.totalStops - a.totalStops);

        // Get a color based on cache name hash for better distribution
        const markerColor = getColorForCache(point.cacheName);

        // Create popup content with basic information
        let popupContent = `
          <div>
            <div style="font-weight: bold; margin-bottom: 5px;">${point.cacheName}</div>
            <div style="font-size: 12px; color: #666; margin-bottom: 8px;">Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}</div>
        `;

        if (trackables.length > 1) {
          popupContent += `<div style="font-weight: bold; margin-bottom: 8px; color: ${markerColor};">${trackables.length} Trackables at this Location</div>`;
        }

        // Add each trackable with simple formatting
        trackables.forEach((tb, i) => {
          popupContent += `
            <div style="margin-top: 8px; ${i > 0 ? 'border-top: 1px solid #eee; padding-top: 8px;' : ''}">
              <div style="font-weight: bold;">${i+1}. ${tb.trackableName}</div>
              ${tb.totalStops ? `<div style="font-size: 12px; color: #666;">Total stops: ${tb.totalStops}</div>` : ''}
              <div><a href="https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}" target="_blank" style="color: #007bff; text-decoration: none;">View trackable details</a></div>
            </div>
          `;
        });

        popupContent += '</div>';

        // Create a colored marker for this point
        const markerIcon = L.divIcon({
          className: '',
          html: `<div style="background-color: ${markerColor}; width: 24px; height: 24px; border-radius: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>`,
          iconSize: [28, 28],
          iconAnchor: [14, 14]
        });
        const marker = L.marker([lat, lon], { icon: markerIcon }).addTo(map);

        // Add a label for the marker
        const labelText = trackables.length === 1 ?
          point.cacheName + (trackables.length > 1 ? ` (${trackables.length})` : '') :
          `${point.cacheName} (${trackables.length})`;

        const label = L.tooltip({
          permanent: true,
          direction: 'top',
          className: 'trackable-marker-label',
          offset: [0, -12]
        })
        .setContent(labelText)
        .setLatLng([lat, lon]);

        label.addTo(map);

        // Bind popup to marker
        marker.bindPopup(popupContent);

        // Hide label when popup is open
        marker.on('popupopen', function() {
          map.removeLayer(label);
        });

        // Show label when popup is closed
        marker.on('popupclose', function() {
          label.addTo(map);
        });

        markers.push({
          marker,
          label,
          point
        });
      });

      // Find the legend container - should be a sibling of our map container
      const mapSection = container.closest('#gc-trackables-map-section');
      const legendContainer = mapSection ? mapSection.querySelector('#trackables-map-legend') : null;

      if (legendContainer) {
        // Get the content container
        const legendContent = document.getElementById('trackables-map-legend-content');
        if (!legendContent) return;

        // Clear any existing content
        legendContent.innerHTML = '';

        // Add entries for each marker/location
        markers.forEach((markerData, index) => {
          const { marker, point, label } = markerData;
          const trackables = point.trackables;
          const trackableCount = trackables.length;

          // For each location, create a section
          const sectionContainer = document.createElement('div');
          sectionContainer.style.marginBottom = index < markers.length - 1 ? '10px' : '0';
          sectionContainer.style.paddingBottom = index < markers.length - 1 ? '10px' : '0';
          sectionContainer.style.borderBottom = index < markers.length - 1 ? '1px solid #eee' : 'none';

          // Location header
          const locationHeader = document.createElement('div');
          locationHeader.style.display = 'flex';
          locationHeader.style.alignItems = 'center';
          locationHeader.style.marginBottom = '5px';
          locationHeader.style.cursor = 'pointer';

          // Create color dot to match marker color
          const colorDot = document.createElement('span');
          colorDot.style.width = '16px';
          colorDot.style.height = '16px';
          colorDot.style.borderRadius = '50%';
          colorDot.style.backgroundColor = getColorForCache(point.cacheName);
          colorDot.style.display = 'inline-block';
          colorDot.style.marginRight = '8px';
          colorDot.style.border = '1px solid rgba(0,0,0,0.2)';

          locationHeader.appendChild(colorDot);

          // Location text
          let locationText;
          locationText = document.createElement('div');

          if (trackables.length === 1) {
            locationText.textContent = trackables[0].cacheName;
          } else {
            locationText.textContent = `${point.cacheName} (${trackables.length} trackables)`;
          }

          locationText.style.fontWeight = 'bold';
          locationHeader.appendChild(locationText);

          // Add click event to zoom to marker
          locationHeader.addEventListener('click', () => {
            map.setView(marker.getLatLng(), 15);

            // Slight delay to ensure map has completed moving before opening popup
            setTimeout(() => {
              marker.openPopup();
            }, 300);
          });

          // Add hover effect
          locationHeader.addEventListener('mouseenter', () => {
            locationHeader.style.backgroundColor = '#f0f0f0';
          });

          locationHeader.addEventListener('mouseleave', () => {
            locationHeader.style.backgroundColor = '';
          });

          sectionContainer.appendChild(locationHeader);

          // Add individual trackable items if there are multiple at this location
          if (trackables.length > 1) {
            const trackablesList = document.createElement('div');
            trackablesList.style.marginLeft = '24px';

            trackables.forEach((tb, i) => {
              const trackableItem = document.createElement('div');
              trackableItem.style.padding = '3px 0';
              trackableItem.style.fontSize = '12px';
              trackableItem.style.display = 'flex';
              trackableItem.style.alignItems = 'center';
              trackableItem.style.cursor = 'pointer';

              const bulletPoint = document.createElement('span');
              bulletPoint.textContent = '•';
              bulletPoint.style.marginRight = '5px';
              trackableItem.appendChild(bulletPoint);

              const tbName = document.createElement('span');
              tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
              trackableItem.appendChild(tbName);

              // Add click handler to open trackable page
              trackableItem.addEventListener('click', () => {
                window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
              });

              // Add hover effect
              trackableItem.addEventListener('mouseenter', () => {
                trackableItem.style.backgroundColor = '#f0f0f0';
                trackableItem.style.color = '#0066cc';
              });

              trackableItem.addEventListener('mouseleave', () => {
                trackableItem.style.backgroundColor = '';
                trackableItem.style.color = '';
              });

              trackablesList.appendChild(trackableItem);
            });

            sectionContainer.appendChild(trackablesList);
          } else if (trackables.length === 1) {
            // Make single trackable clickable too
            const tb = trackables[0];
            const trackableItem = document.createElement('div');
            trackableItem.style.marginLeft = '28px';
            trackableItem.style.fontSize = '12px';
            trackableItem.style.cursor = 'pointer';
            trackableItem.style.display = 'flex';
            trackableItem.style.alignItems = 'center';

            const bulletPoint = document.createElement('span');
            bulletPoint.textContent = '•';
            bulletPoint.style.marginRight = '5px';
            trackableItem.appendChild(bulletPoint);

            const tbName = document.createElement('span');
            tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
            trackableItem.appendChild(tbName);

            // Add click handler to open trackable page
            trackableItem.addEventListener('click', () => {
              window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
            });

            // Add hover effect
            trackableItem.addEventListener('mouseenter', () => {
              trackableItem.style.backgroundColor = '#f0f0f0';
              trackableItem.style.color = '#0066cc';
            });

            trackableItem.addEventListener('mouseleave', () => {
              trackableItem.style.backgroundColor = '';
              trackableItem.style.color = '';
            });

            sectionContainer.appendChild(trackableItem);
          }

          legendContent.appendChild(sectionContainer);
        });
      }
    }

    // Function to safely inject the map container into the page
    function safelyInjectMap() {
      // First, identify the main container and the search panel
      const pageWrapper = document.querySelector('#Content, #content, .Content');

      if (!pageWrapper) {
        console.error('Could not find main content wrapper');
        return null;
      }

      // Clear any existing map we might have added before
      const existingMap = document.getElementById('gc-trackables-map-section');
      if (existingMap) {
        existingMap.remove();
      }

      // Create our map container with a distinctive ID
      const mapSection = document.createElement('div');
      mapSection.id = 'gc-trackables-map-section';
      mapSection.style.width = '100%';
      mapSection.style.clear = 'both';
      mapSection.style.position = 'relative';
      mapSection.style.margin = '20px 0';
      mapSection.style.padding = '0';
      mapSection.style.backgroundColor = '#fff';
      mapSection.style.boxSizing = 'border-box';

      // Add title
      const mapTitle = document.createElement('h3');
      mapTitle.textContent = 'Trackable Locations Map';
      mapTitle.style.margin = '0 0 10px 0';
      mapTitle.style.padding = '0';
      mapTitle.style.fontSize = '16px';
      mapTitle.style.fontWeight = 'bold';
      mapSection.appendChild(mapTitle);

      // Create map container
      const mapContainer = document.createElement('div');
      mapContainer.id = 'trackables-map-container';
      mapContainer.style.width = '100%';
      mapContainer.style.height = '500px';
      mapContainer.style.border = '1px solid #ddd';
      mapContainer.style.borderRadius = '4px';
      mapContainer.style.marginBottom = '10px';
      mapContainer.style.boxSizing = 'border-box';
      mapSection.appendChild(mapContainer);

      // Create legend container that will be filled by the map creation function
      const legendContainer = document.createElement('div');
      legendContainer.id = 'trackables-map-legend';
      legendContainer.style.marginTop = '10px';
      legendContainer.style.width = '100%';
      legendContainer.style.boxSizing = 'border-box';
      legendContainer.style.border = '1px solid #eee';
      legendContainer.style.borderRadius = '4px';
      legendContainer.style.backgroundColor = '#fff';

      // Create collapsible header for legend
      const legendHeader = document.createElement('div');
      legendHeader.style.padding = '10px';
      legendHeader.style.borderBottom = '1px solid #eee';
      legendHeader.style.display = 'flex';
      legendHeader.style.alignItems = 'center';
      legendHeader.style.justifyContent = 'space-between';
      legendHeader.style.cursor = 'pointer';

      // Create title text
      const headerText = document.createElement('div');
      headerText.textContent = 'Trackables';
      headerText.style.fontWeight = 'bold';
      headerText.style.fontSize = '14px';

      // Create arrow indicator
      const arrowIndicator = document.createElement('div');
      arrowIndicator.innerHTML = '&#9650;'; // Up arrow (collapsed)
      arrowIndicator.style.transition = 'transform 0.3s';
      arrowIndicator.style.fontSize = '12px';

      // Append elements to header
      legendHeader.appendChild(headerText);
      legendHeader.appendChild(arrowIndicator);
      legendContainer.appendChild(legendHeader);

      // Create content container for the legend
      const legendContent = document.createElement('div');
      legendContent.id = 'trackables-map-legend-content';
      legendContent.style.padding = '10px';
      legendContent.style.display = 'none'; // Hidden by default
      legendContent.style.maxHeight = 'none';
      legendContent.style.overflowY = 'visible';
      legendContainer.appendChild(legendContent);

      // Add click event to toggle legend visibility
      legendHeader.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();

        const isVisible = legendContent.style.display !== 'none';
        legendContent.style.display = isVisible ? 'none' : 'block';
        arrowIndicator.innerHTML = isVisible ? '&#9650;' : '&#9660;'; // Up arrow when closed, down arrow when open

        return false;
      });

      mapSection.appendChild(legendContainer);

      // Look for the best insertion point
      let inserted = false;

      // Method 1: Try to find common table containers
      const tableContainers = Array.from(document.querySelectorAll('.Table, table, .table-container'));
      for (const table of tableContainers) {
        // Only consider visible tables
        if (isElementVisible(table)) {
          const tableParent = table.parentNode;

          // Insert before the table
          tableParent.insertBefore(mapSection, table);
          inserted = true;
          break;
        }
      }

      // Method 2: If we couldn't find a table, try to find section headings
      if (!inserted) {
        const sectionHeadings = Array.from(document.querySelectorAll('h1, h2, h3'));
        for (const heading of sectionHeadings) {
          // Look for headings related to trackables or search
          const headingText = heading.textContent.toLowerCase();
          if ((headingText.includes('trackable') || headingText.includes('search')) && isElementVisible(heading)) {
            // Insert after the heading
            if (heading.nextSibling) {
              heading.parentNode.insertBefore(mapSection, heading.nextSibling);
            } else {
              heading.parentNode.appendChild(mapSection);
            }
            inserted = true;
            break;
          }
        }
      }

      // Method 3: Last resort - insert at top of content area
      if (!inserted) {
        // Insert at the beginning of the content area
        if (pageWrapper.firstChild) {
          pageWrapper.insertBefore(mapSection, pageWrapper.firstChild);
        } else {
          pageWrapper.appendChild(mapSection);
        }
      }

      // Initialize the empty map
      const mapViewContainer = document.createElement('div');
      mapViewContainer.id = 'leaflet-map';
      mapViewContainer.style.width = '100%';
      mapViewContainer.style.height = '500px';
      mapViewContainer.style.border = '1px solid #ddd';
      mapViewContainer.style.borderRadius = '4px';
      mapContainer.appendChild(mapViewContainer);

      // Initialize the map with a default view (world map)
      try {
        // Create a new map instance
        trackableMap = L.map('leaflet-map').setView([20, 0], 2);

        // Add OpenStreetMap tile layer
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
          attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
          maxZoom: 18
        }).addTo(trackableMap);

        // Add "Loading trackables..." message
        const loadingMessage = L.control({position: 'bottomleft'});
        loadingMessage.onAdd = function(map) {
          const div = L.DomUtil.create('div', 'loading-message');
          div.innerHTML = '<div style="background-color: white; padding: 5px 10px; border-radius: 4px; border: 1px solid #ccc; font-weight: bold;">Loading trackable data...</div>';
          return div;
        };
        loadingMessage.addTo(trackableMap);
      } catch (e) {
        console.error('Error initializing map:', e);
      }

      // Add custom CSS for marker labels
      const style = document.createElement('style');
      style.textContent = `
        .trackable-marker-label {
          background: white;
          border: 1px solid #333;
          border-radius: 4px;
          padding: 2px 6px;
          font-weight: bold;
          white-space: nowrap;
          text-align: center;
          box-shadow: 0 1px 3px rgba(0,0,0,0.2);
          pointer-events: none;
        }
      `;
      document.head.appendChild(style);

      return mapContainer;
    }

    // Find trackables on the page and process them
    async function processTrackables() {
      // Prevent concurrent processing
      if (isProcessingTrackables) {
        console.log('Already processing trackables, skipping duplicate call');
        return;
      }

      isProcessingTrackables = true;

      try {
        // Create and inject map container first before processing data
        const mapContainer = safelyInjectMap();

        // Extract trackables
        const trackablesMap = extractTrackablesFromPage();
        console.log(`Found ${trackablesMap.size} trackables on page`);

        if (trackablesMap.size === 0) {
          console.log('No trackables found on page');
          // Update the map with a "No trackables" message
          if (trackableMap) {
            // Remove any loading message
            const loadingControl = document.querySelector('.loading-message');
            if (loadingControl && loadingControl.parentNode) {
              loadingControl.parentNode.removeChild(loadingControl);
            }

            const noDataMessage = L.control({position: 'bottomleft'});
            noDataMessage.onAdd = function(map) {
              const div = L.DomUtil.create('div', 'no-data-message');
              div.innerHTML = '<div style="background-color: white; padding: 5px 10px; border-radius: 4px; border: 1px solid #ccc;">No trackable location data available</div>';
              return div;
            };
            noDataMessage.addTo(trackableMap);
          }
          isProcessingTrackables = false;
          return;
        }

        const trackables = Array.from(trackablesMap.values());
        console.log('Trackables found:', trackables);

        // Enrich trackables with stop data
        const enrichedTrackables = await enrichTrackablesWithStops(trackables);

        // Display on map
        displayTrackablesMap(enrichedTrackables, mapContainer);
      } catch (error) {
        console.error('Error in processTrackables:', error);
      } finally {
        // Always reset the processing flag
        isProcessingTrackables = false;
      }
    }

    // Run on page load and after AJAX content updates
    setTimeout(processTrackables, 1000);

    // Track if the map has been added to the page
    let mapAdded = false;

    // Create a MutationObserver to watch for content changes
    const observer = new MutationObserver(function(mutations) {
      // Don't trigger if we're already processing or if we created the map element
      if (isProcessingTrackables || mapAdded) return;

      let shouldReprocess = false;

      // Check if any mutations affect our elements of interest (trackable links)
      for (const mutation of mutations) {
        // Skip mutations caused by our own map
        if (mutation.target.id === 'gc-trackables-map-section' ||
            mutation.target.closest('#gc-trackables-map-section')) {
          continue;
        }

        // Skip mutations that don't add nodes - we only care about content being added
        if (mutation.type !== 'childList' || mutation.addedNodes.length === 0) {
          continue;
        }

        // Look for relevant data tables or trackable links
        if (mutation.target.classList.contains('Table') ||
            mutation.target.querySelector('.Table') ||
            mutation.target.querySelector('a[href*="track/details.aspx"]')) {
          shouldReprocess = true;
          break;
        }
      }

      if (shouldReprocess) {
        console.log('Content changed, reprocessing trackables');
        processTrackables().finally(() => {
          mapAdded = true;

          // Disconnect observer after first successful map creation to prevent further updates
          // This prevents repeated refreshing while still allowing the initial map to be created
          observer.disconnect();
        });
      }
    });

    // Start observing with configuration
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
})();