// ==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: '© <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 = '▲'; // 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 ? '▲' : '▼'; // 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: '© <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
});
})();