Geocaching Trackable Map Visualizer

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

  1. // ==UserScript==
  2. // @name Geocaching Trackable Map Visualizer
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.0
  5. // @description View your trackables on an interactive map. See where all your Geocaching trackables have been at a glance!
  6. // @author ViezeVingertjes
  7. // @match *://*.geocaching.com/track/search.aspx*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=geocaching.com
  9. // @grant none
  10. // @require https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
  11. // @resource LEAFLET_CSS https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Inject Leaflet CSS
  18. const linkElement = document.createElement('link');
  19. linkElement.rel = 'stylesheet';
  20. linkElement.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
  21. document.head.appendChild(linkElement);
  22.  
  23. console.log("Geocaching Trackable Page Enhancer script loaded!");
  24.  
  25. /**
  26. * Helper function to check if an element is visible
  27. * @param {Element} element - The DOM element to check
  28. * @returns {boolean} - Whether the element is visible
  29. */
  30. function isElementVisible(element) {
  31. if (!element) return false;
  32.  
  33. const style = window.getComputedStyle(element);
  34. return style.display !== 'none' &&
  35. style.visibility !== 'hidden' &&
  36. style.opacity !== '0' &&
  37. element.offsetWidth > 0 &&
  38. element.offsetHeight > 0;
  39. }
  40.  
  41. /**
  42. * Extracts trackable information from anchor elements on the page
  43. * @returns {Map} Map of trackable objects with id as key
  44. */
  45. function extractTrackablesFromPage() {
  46. const anchorElements = document.querySelectorAll("a");
  47. const trackablesMap = new Map();
  48. const trackableUrlPrefix = "https://www.geocaching.com/track/details.aspx?id=";
  49.  
  50. anchorElements.forEach(anchor => {
  51. const href = anchor.getAttribute("href");
  52. if (href && href.startsWith(trackableUrlPrefix)) {
  53. try {
  54. const url = new URL(href, document.baseURI);
  55. const id = url.searchParams.get("id");
  56. const name = anchor.innerText.trim();
  57.  
  58. if (id && name && !trackablesMap.has(id)) {
  59. trackablesMap.set(id, { id, name });
  60. }
  61. } catch (e) {
  62. console.error("Error parsing URL or extracting trackable info:", e, href);
  63. }
  64. }
  65. });
  66.  
  67. return trackablesMap;
  68. }
  69.  
  70. /**
  71. * Parse trackable stops from the map page HTML content
  72. * @param {string} htmlContent - HTML content from the trackable map page
  73. * @param {string} trackableId - ID of the trackable for error reporting
  74. * @returns {Array} Array of stop objects with coordinates and cache names
  75. */
  76. function parseTrackableStops(htmlContent, trackableId) {
  77. const stops = [];
  78. const tbStopsRegex = /var tbStops\s*=\s*(\[[\s\S]*?\])\s*;/;
  79. const match = htmlContent.match(tbStopsRegex);
  80.  
  81. if (!match || !match[1]) {
  82. console.warn(`tbStops not found for trackable ${trackableId}`);
  83. return stops;
  84. }
  85.  
  86. try {
  87. const arrayContentString = match[1].slice(1, -1); // Remove outer brackets
  88. const objectRegex = /{[\s\S]*?}/g;
  89. const coordRegex = /ll\s*:\s*\[\s*([\d\.-]+)\s*,\s*([\d\.-]+)\s*\]/;
  90. const nameRegex = /n\s*:\s*"([^"]*)"/;
  91. let objectMatch;
  92.  
  93. while ((objectMatch = objectRegex.exec(arrayContentString)) !== null) {
  94. const objectString = objectMatch[0];
  95. const coordMatch = objectString.match(coordRegex);
  96. const nameMatch = objectString.match(nameRegex);
  97.  
  98. if (coordMatch && coordMatch[1] && coordMatch[2] && nameMatch && nameMatch[1]) {
  99. try {
  100. const lat = parseFloat(coordMatch[1]);
  101. const lon = parseFloat(coordMatch[2]);
  102. const name = nameMatch[1];
  103. stops.push({
  104. coordinates: [lat, lon],
  105. cacheName: name
  106. });
  107. } catch (e) {
  108. console.error(`Error parsing coordinates for trackable ${trackableId}:`, e);
  109. }
  110. } else {
  111. console.error(`Failed to extract data from object string for trackable ${trackableId}:`, objectString);
  112. }
  113. }
  114. } catch (e) {
  115. console.error(`Error processing tbStops for trackable ${trackableId}:`, e);
  116. }
  117.  
  118. return stops;
  119. }
  120.  
  121. /**
  122. * Fetches and processes trackable stops data
  123. * @param {Object} trackable - The trackable object to enrich with stops
  124. * @returns {Object} The enriched trackable object
  125. */
  126. async function fetchTrackableStops(trackable) {
  127. const mapUrl = `https://www.geocaching.com/track/map_gm.aspx?ID=${trackable.id}`;
  128.  
  129. try {
  130. const response = await fetch(mapUrl);
  131. if (!response.ok) {
  132. console.error(`Failed to fetch ${mapUrl}: ${response.status} ${response.statusText}`);
  133. trackable.stops = [];
  134. return trackable;
  135. }
  136.  
  137. const htmlContent = await response.text();
  138. trackable.stops = parseTrackableStops(htmlContent, trackable.id);
  139.  
  140. } catch (error) {
  141. console.error(`Error fetching stops for trackable ${trackable.id}:`, error);
  142. trackable.stops = [];
  143. }
  144.  
  145. return trackable;
  146. }
  147.  
  148. /**
  149. * Enriches trackables with their stop information
  150. * @param {Array} trackables - Array of trackable objects
  151. * @returns {Array} Array of enriched trackable objects
  152. */
  153. async function enrichTrackablesWithStops(trackables) {
  154. if (trackables.length === 0) {
  155. console.log("No trackables found to enrich.");
  156. return [];
  157. }
  158.  
  159. const enrichedTrackables = await Promise.all(
  160. trackables.map(trackable => fetchTrackableStops(trackable))
  161. );
  162.  
  163. console.log("Enriched Trackables (with stops):", enrichedTrackables);
  164. return enrichedTrackables;
  165. }
  166.  
  167. // Add a global variable to track if processing is currently in progress
  168. let isProcessingTrackables = false;
  169. // Add a global variable to store the map instance
  170. let trackableMap = null;
  171.  
  172. /**
  173. * Displays the trackable data on a map
  174. * @param {Array} trackables - Array of trackable objects with stops
  175. * @param {HTMLElement} [existingContainer] - Optional existing map container
  176. */
  177. function displayTrackablesMap(trackables, existingContainer) {
  178. // Filter trackables with stops
  179. const trackablesWithStops = trackables.filter(t => t.stops && t.stops.length > 0);
  180.  
  181. if (trackablesWithStops.length === 0) {
  182. console.log('No trackables with stops to display on map');
  183. return;
  184. }
  185.  
  186. // Sort trackables by number of stops (descending)
  187. trackablesWithStops.sort((a, b) => b.stops.length - a.stops.length);
  188.  
  189. // Extract the last stop from each trackable
  190. const mapPoints = trackablesWithStops.map(trackable => {
  191. const lastStop = trackable.stops[trackable.stops.length - 1];
  192. return {
  193. trackableId: trackable.id,
  194. trackableName: trackable.name,
  195. cacheName: lastStop.cacheName,
  196. coordinates: lastStop.coordinates,
  197. totalStops: trackable.stops.length
  198. };
  199. });
  200.  
  201. console.log('Map points for display:', mapPoints);
  202.  
  203. // Group points by coordinates to combine markers at the same location
  204. const groupedPoints = {};
  205. mapPoints.forEach(point => {
  206. const coordKey = point.coordinates.join(',');
  207. if (!groupedPoints[coordKey]) {
  208. groupedPoints[coordKey] = {
  209. coordinates: point.coordinates,
  210. cacheName: point.cacheName, // Use the cache name from the first trackable at this location
  211. trackables: []
  212. };
  213. }
  214. groupedPoints[coordKey].trackables.push(point);
  215. });
  216.  
  217. console.log('Grouped map points:', groupedPoints);
  218.  
  219. // Convert back to array for display and sort by total number of trackables (descending)
  220. const combinedMapPoints = Object.values(groupedPoints);
  221. combinedMapPoints.sort((a, b) => b.trackables.length - a.trackables.length);
  222.  
  223. // If we don't have a map instance, we can't proceed
  224. if (!trackableMap) {
  225. console.error('No map instance available');
  226. return;
  227. }
  228.  
  229. // Remove any loading message
  230. const loadingControl = document.querySelector('.loading-message');
  231. if (loadingControl && loadingControl.parentNode) {
  232. loadingControl.parentNode.removeChild(loadingControl);
  233. }
  234.  
  235. // Clear any existing markers
  236. trackableMap.eachLayer(layer => {
  237. if (layer instanceof L.Marker || layer instanceof L.Tooltip) {
  238. trackableMap.removeLayer(layer);
  239. }
  240. });
  241.  
  242. // Calculate bounding box for all points
  243. let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180;
  244.  
  245. combinedMapPoints.forEach(point => {
  246. const [lat, lon] = point.coordinates;
  247. minLat = Math.min(minLat, lat);
  248. maxLat = Math.max(maxLat, lat);
  249. minLon = Math.min(minLon, lon);
  250. maxLon = Math.max(maxLon, lon);
  251. });
  252.  
  253. // Add padding
  254. const latPadding = Math.max(0.05, (maxLat - minLat) * 0.1);
  255. const lonPadding = Math.max(0.05, (maxLon - minLon) * 0.1);
  256.  
  257. minLat = Math.max(-85, minLat - latPadding);
  258. maxLat = Math.min(85, maxLat + latPadding);
  259. minLon = Math.max(-180, minLon - lonPadding);
  260. maxLon = Math.min(180, maxLon + lonPadding);
  261.  
  262. // Fit the map to the bounds of all markers
  263. try {
  264. trackableMap.fitBounds([
  265. [minLat, minLon],
  266. [maxLat, maxLon]
  267. ]);
  268. } catch (e) {
  269. console.error('Error fitting map bounds:', e);
  270. }
  271.  
  272. // Store markers for reference
  273. const markers = [];
  274.  
  275. // Define a good palette of distinct colors
  276. const colorPalette = [
  277. '#e6194B', // Red
  278. '#3cb44b', // Green
  279. '#ffe119', // Yellow
  280. '#4363d8', // Blue
  281. '#f58231', // Orange
  282. '#911eb4', // Purple
  283. '#42d4f4', // Cyan
  284. '#f032e6', // Magenta
  285. '#bfef45', // Lime
  286. '#fabed4', // Pink
  287. '#469990', // Teal
  288. '#dcbeff', // Lavender
  289. '#9A6324', // Brown
  290. '#fffac8', // Beige
  291. '#800000', // Maroon
  292. '#aaffc3', // Mint
  293. '#808000', // Olive
  294. '#ffd8b1', // Apricot
  295. '#000075', // Navy
  296. '#a9a9a9', // Grey
  297. '#ffffff', // White
  298. '#000000' // Black
  299. ];
  300.  
  301. // Create a map to track used colors for cache names
  302. const cacheColorMap = new Map();
  303. // Track last used color index for round-robin assignment
  304. let lastColorIndex = -1;
  305.  
  306. // Get a color ensuring no consecutive identical colors
  307. function getColorForCache(cacheName) {
  308. // If we already assigned a color to this cache, use it
  309. if (cacheColorMap.has(cacheName)) {
  310. return cacheColorMap.get(cacheName);
  311. }
  312.  
  313. // Get the next color in round-robin fashion
  314. lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
  315.  
  316. // Find a different color if this would create consecutive same colors
  317. if (markers.length > 0) {
  318. const prevMarker = markers[markers.length - 1];
  319. const prevColor = getColorForCache(prevMarker.point.cacheName);
  320.  
  321. // If colors would match, skip to next color
  322. if (colorPalette[lastColorIndex] === prevColor) {
  323. lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
  324. }
  325. }
  326.  
  327. const color = colorPalette[lastColorIndex];
  328. cacheColorMap.set(cacheName, color);
  329. return color;
  330. }
  331.  
  332. // Add markers for each point
  333. combinedMapPoints.forEach((point, index) => {
  334. const [lat, lon] = point.coordinates;
  335. const trackables = point.trackables;
  336. const trackableCount = trackables.length;
  337.  
  338. // Sort trackables at this location by number of stops (descending)
  339. trackables.sort((a, b) => b.totalStops - a.totalStops);
  340.  
  341. // Get a color based on cache name
  342. const markerColor = getColorForCache(point.cacheName);
  343.  
  344. // Create popup content with basic information
  345. let popupContent = `
  346. <div>
  347. <div style="font-weight: bold; margin-bottom: 5px;">${point.cacheName}</div>
  348. <div style="font-size: 12px; color: #666; margin-bottom: 8px;">Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}</div>
  349. `;
  350.  
  351. if (trackables.length > 1) {
  352. popupContent += `<div style="font-weight: bold; margin-bottom: 8px; color: ${markerColor};">${trackables.length} Trackables at this Location</div>`;
  353. }
  354.  
  355. // Add each trackable with simple formatting
  356. trackables.forEach((tb, i) => {
  357. popupContent += `
  358. <div style="margin-top: 8px; ${i > 0 ? 'border-top: 1px solid #eee; padding-top: 8px;' : ''}">
  359. <div style="font-weight: bold;">${i+1}. ${tb.trackableName}</div>
  360. ${tb.totalStops ? `<div style="font-size: 12px; color: #666;">Total stops: ${tb.totalStops}</div>` : ''}
  361. <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>
  362. </div>
  363. `;
  364. });
  365.  
  366. popupContent += '</div>';
  367.  
  368. // Create a colored marker for this point
  369. const markerIcon = L.divIcon({
  370. className: '',
  371. 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>`,
  372. iconSize: [28, 28],
  373. iconAnchor: [14, 14]
  374. });
  375. const marker = L.marker([lat, lon], { icon: markerIcon }).addTo(trackableMap);
  376.  
  377. // Add a label for the marker
  378. const labelText = point.cacheName + (trackables.length > 1 ? ` (${trackables.length})` : '');
  379.  
  380. const label = L.tooltip({
  381. permanent: true,
  382. direction: 'top',
  383. className: 'trackable-marker-label',
  384. offset: [0, -12]
  385. })
  386. .setContent(labelText)
  387. .setLatLng([lat, lon]);
  388.  
  389. label.addTo(trackableMap);
  390.  
  391. // Bind popup to marker
  392. marker.bindPopup(popupContent);
  393.  
  394. // Hide label when popup is open
  395. marker.on('popupopen', function() {
  396. trackableMap.removeLayer(label);
  397. });
  398.  
  399. // Show label when popup is closed
  400. marker.on('popupclose', function() {
  401. label.addTo(trackableMap);
  402. });
  403.  
  404. markers.push({
  405. marker,
  406. label,
  407. point
  408. });
  409. });
  410.  
  411. // Update the legend
  412. const mapSection = existingContainer.closest('#gc-trackables-map-section');
  413. const legendContainer = mapSection ? mapSection.querySelector('#trackables-map-legend') : null;
  414.  
  415. if (legendContainer) {
  416. // Get the content container
  417. const legendContent = document.getElementById('trackables-map-legend-content');
  418. if (!legendContent) return;
  419.  
  420. // Clear any existing content
  421. legendContent.innerHTML = '';
  422.  
  423. // Add entries for each marker/location
  424. markers.forEach((markerData, index) => {
  425. const { marker, point, label } = markerData;
  426. const trackables = point.trackables;
  427.  
  428. // For each location, create a section
  429. const sectionContainer = document.createElement('div');
  430. sectionContainer.style.marginBottom = index < markers.length - 1 ? '10px' : '0';
  431. sectionContainer.style.paddingBottom = index < markers.length - 1 ? '10px' : '0';
  432. sectionContainer.style.borderBottom = index < markers.length - 1 ? '1px solid #eee' : 'none';
  433.  
  434. // Location header
  435. const locationHeader = document.createElement('div');
  436. locationHeader.style.display = 'flex';
  437. locationHeader.style.alignItems = 'center';
  438. locationHeader.style.marginBottom = '5px';
  439. locationHeader.style.cursor = 'pointer';
  440.  
  441. // Create color dot to match marker color
  442. const colorDot = document.createElement('span');
  443. colorDot.style.width = '16px';
  444. colorDot.style.height = '16px';
  445. colorDot.style.borderRadius = '50%';
  446. colorDot.style.backgroundColor = getColorForCache(point.cacheName);
  447. colorDot.style.display = 'inline-block';
  448. colorDot.style.marginRight = '8px';
  449. colorDot.style.border = '1px solid rgba(0,0,0,0.2)';
  450.  
  451. locationHeader.appendChild(colorDot);
  452.  
  453. // Location text
  454. let locationText;
  455. locationText = document.createElement('div');
  456.  
  457. if (trackables.length === 1) {
  458. locationText.textContent = trackables[0].cacheName;
  459. } else {
  460. locationText.textContent = `${point.cacheName} (${trackables.length} trackables)`;
  461. }
  462.  
  463. locationText.style.fontWeight = 'bold';
  464. locationHeader.appendChild(locationText);
  465.  
  466. // Add click event to zoom to marker
  467. locationHeader.addEventListener('click', () => {
  468. trackableMap.setView(marker.getLatLng(), 15);
  469.  
  470. // Slight delay to ensure map has completed moving before opening popup
  471. setTimeout(() => {
  472. marker.openPopup();
  473. }, 300);
  474. });
  475.  
  476. // Add hover effect
  477. locationHeader.addEventListener('mouseenter', () => {
  478. locationHeader.style.backgroundColor = '#f0f0f0';
  479. });
  480.  
  481. locationHeader.addEventListener('mouseleave', () => {
  482. locationHeader.style.backgroundColor = '';
  483. });
  484.  
  485. sectionContainer.appendChild(locationHeader);
  486.  
  487. // Add individual trackable items if there are multiple at this location
  488. if (trackables.length > 1) {
  489. const trackablesList = document.createElement('div');
  490. trackablesList.style.marginLeft = '24px';
  491.  
  492. trackables.forEach((tb, i) => {
  493. const trackableItem = document.createElement('div');
  494. trackableItem.style.padding = '3px 0';
  495. trackableItem.style.fontSize = '12px';
  496. trackableItem.style.display = 'flex';
  497. trackableItem.style.alignItems = 'center';
  498. trackableItem.style.cursor = 'pointer';
  499.  
  500. const bulletPoint = document.createElement('span');
  501. bulletPoint.textContent = '•';
  502. bulletPoint.style.marginRight = '5px';
  503. trackableItem.appendChild(bulletPoint);
  504.  
  505. const tbName = document.createElement('span');
  506. tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
  507. trackableItem.appendChild(tbName);
  508.  
  509. // Add click handler to open trackable page
  510. trackableItem.addEventListener('click', () => {
  511. window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
  512. });
  513.  
  514. // Add hover effect
  515. trackableItem.addEventListener('mouseenter', () => {
  516. trackableItem.style.backgroundColor = '#f0f0f0';
  517. trackableItem.style.color = '#0066cc';
  518. });
  519.  
  520. trackableItem.addEventListener('mouseleave', () => {
  521. trackableItem.style.backgroundColor = '';
  522. trackableItem.style.color = '';
  523. });
  524.  
  525. trackablesList.appendChild(trackableItem);
  526. });
  527.  
  528. sectionContainer.appendChild(trackablesList);
  529. } else if (trackables.length === 1) {
  530. // Make single trackable clickable too
  531. const tb = trackables[0];
  532. const trackableItem = document.createElement('div');
  533. trackableItem.style.marginLeft = '28px';
  534. trackableItem.style.fontSize = '12px';
  535. trackableItem.style.cursor = 'pointer';
  536. trackableItem.style.display = 'flex';
  537. trackableItem.style.alignItems = 'center';
  538.  
  539. const bulletPoint = document.createElement('span');
  540. bulletPoint.textContent = '•';
  541. bulletPoint.style.marginRight = '5px';
  542. trackableItem.appendChild(bulletPoint);
  543.  
  544. const tbName = document.createElement('span');
  545. tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
  546. trackableItem.appendChild(tbName);
  547.  
  548. // Add click handler to open trackable page
  549. trackableItem.addEventListener('click', () => {
  550. window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
  551. });
  552.  
  553. // Add hover effect
  554. trackableItem.addEventListener('mouseenter', () => {
  555. trackableItem.style.backgroundColor = '#f0f0f0';
  556. trackableItem.style.color = '#0066cc';
  557. });
  558.  
  559. trackableItem.addEventListener('mouseleave', () => {
  560. trackableItem.style.backgroundColor = '';
  561. trackableItem.style.color = '';
  562. });
  563.  
  564. sectionContainer.appendChild(trackableItem);
  565. }
  566.  
  567. legendContent.appendChild(sectionContainer);
  568. });
  569. }
  570. }
  571.  
  572. /**
  573. * Creates a map using Leaflet
  574. * @param {HTMLElement} container - The container to add the map to
  575. * @param {Array} points - The points to display on the map
  576. */
  577. function createSimpleMapWithMarkers(container, points) {
  578. if (!container || !points || points.length === 0) return;
  579.  
  580. // Calculate bounding box for all points
  581. let minLat = 90;
  582. let maxLat = -90;
  583. let minLon = 180;
  584. let maxLon = -180;
  585.  
  586. points.forEach(point => {
  587. const [lat, lon] = point.coordinates;
  588. minLat = Math.min(minLat, lat);
  589. maxLat = Math.max(maxLat, lat);
  590. minLon = Math.min(minLon, lon);
  591. maxLon = Math.max(maxLon, lon);
  592. });
  593.  
  594. // Add padding
  595. const latPadding = Math.max(0.05, (maxLat - minLat) * 0.1);
  596. const lonPadding = Math.max(0.05, (maxLon - minLon) * 0.1);
  597.  
  598. minLat = Math.max(-85, minLat - latPadding);
  599. maxLat = Math.min(85, maxLat + latPadding);
  600. minLon = Math.max(-180, minLon - lonPadding);
  601. maxLon = Math.min(180, maxLon + lonPadding);
  602.  
  603. // Clear the container
  604. container.innerHTML = '';
  605.  
  606. // Create map container for Leaflet
  607. const mapViewContainer = document.createElement('div');
  608. mapViewContainer.id = 'leaflet-map';
  609. mapViewContainer.style.width = '100%';
  610. mapViewContainer.style.height = '500px';
  611. mapViewContainer.style.border = '1px solid #ddd';
  612. mapViewContainer.style.borderRadius = '4px';
  613. container.appendChild(mapViewContainer);
  614.  
  615. // Initialize the map
  616. const map = L.map('leaflet-map').fitBounds([
  617. [minLat, minLon],
  618. [maxLat, maxLon]
  619. ]);
  620.  
  621. // Add OpenStreetMap tile layer
  622. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  623. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
  624. maxZoom: 18
  625. }).addTo(map);
  626.  
  627. // Add custom CSS for marker labels
  628. const style = document.createElement('style');
  629. style.textContent = `
  630. .trackable-marker-label {
  631. background: white;
  632. border: 1px solid #333;
  633. border-radius: 4px;
  634. padding: 2px 6px;
  635. font-weight: bold;
  636. white-space: nowrap;
  637. text-align: center;
  638. box-shadow: 0 1px 3px rgba(0,0,0,0.2);
  639. pointer-events: none;
  640. }
  641. `;
  642. document.head.appendChild(style);
  643.  
  644. // Store markers for reference
  645. const markers = [];
  646.  
  647. // Define a good palette of distinct colors
  648. const colorPalette = [
  649. '#e6194B', // Red
  650. '#3cb44b', // Green
  651. '#ffe119', // Yellow
  652. '#4363d8', // Blue
  653. '#f58231', // Orange
  654. '#911eb4', // Purple
  655. '#42d4f4', // Cyan
  656. '#f032e6', // Magenta
  657. '#bfef45', // Lime
  658. '#fabed4', // Pink
  659. '#469990', // Teal
  660. '#dcbeff', // Lavender
  661. '#9A6324', // Brown
  662. '#fffac8', // Beige
  663. '#800000', // Maroon
  664. '#aaffc3', // Mint
  665. '#808000', // Olive
  666. '#ffd8b1', // Apricot
  667. '#000075', // Navy
  668. '#a9a9a9', // Grey
  669. '#ffffff', // White
  670. '#000000' // Black
  671. ];
  672.  
  673. // Create a map to track used colors for cache names
  674. const cacheColorMap = new Map();
  675. // Track last used color index for round-robin assignment
  676. let lastColorIndex = -1;
  677.  
  678. // Get a color ensuring no consecutive identical colors
  679. function getColorForCache(cacheName) {
  680. // If we already assigned a color to this cache, use it
  681. if (cacheColorMap.has(cacheName)) {
  682. return cacheColorMap.get(cacheName);
  683. }
  684.  
  685. // Get the next color in round-robin fashion
  686. lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
  687.  
  688. // Find a different color if this would create consecutive same colors
  689. if (markers.length > 0) {
  690. const prevMarker = markers[markers.length - 1];
  691. const prevColor = getColorForCache(prevMarker.point.cacheName);
  692.  
  693. // If colors would match, skip to next color
  694. if (colorPalette[lastColorIndex] === prevColor) {
  695. lastColorIndex = (lastColorIndex + 1) % colorPalette.length;
  696. }
  697. }
  698.  
  699. const color = colorPalette[lastColorIndex];
  700. cacheColorMap.set(cacheName, color);
  701. return color;
  702. }
  703.  
  704. // Add markers for each point
  705. points.forEach((point, index) => {
  706. const [lat, lon] = point.coordinates;
  707. const trackables = point.trackables;
  708. const trackableCount = trackables.length;
  709.  
  710. // Sort trackables at this location by number of stops (descending)
  711. trackables.sort((a, b) => b.totalStops - a.totalStops);
  712.  
  713. // Get a color based on cache name hash for better distribution
  714. const markerColor = getColorForCache(point.cacheName);
  715.  
  716. // Create popup content with basic information
  717. let popupContent = `
  718. <div>
  719. <div style="font-weight: bold; margin-bottom: 5px;">${point.cacheName}</div>
  720. <div style="font-size: 12px; color: #666; margin-bottom: 8px;">Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}</div>
  721. `;
  722.  
  723. if (trackables.length > 1) {
  724. popupContent += `<div style="font-weight: bold; margin-bottom: 8px; color: ${markerColor};">${trackables.length} Trackables at this Location</div>`;
  725. }
  726.  
  727. // Add each trackable with simple formatting
  728. trackables.forEach((tb, i) => {
  729. popupContent += `
  730. <div style="margin-top: 8px; ${i > 0 ? 'border-top: 1px solid #eee; padding-top: 8px;' : ''}">
  731. <div style="font-weight: bold;">${i+1}. ${tb.trackableName}</div>
  732. ${tb.totalStops ? `<div style="font-size: 12px; color: #666;">Total stops: ${tb.totalStops}</div>` : ''}
  733. <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>
  734. </div>
  735. `;
  736. });
  737.  
  738. popupContent += '</div>';
  739.  
  740. // Create a colored marker for this point
  741. const markerIcon = L.divIcon({
  742. className: '',
  743. 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>`,
  744. iconSize: [28, 28],
  745. iconAnchor: [14, 14]
  746. });
  747. const marker = L.marker([lat, lon], { icon: markerIcon }).addTo(map);
  748.  
  749. // Add a label for the marker
  750. const labelText = trackables.length === 1 ?
  751. point.cacheName + (trackables.length > 1 ? ` (${trackables.length})` : '') :
  752. `${point.cacheName} (${trackables.length})`;
  753.  
  754. const label = L.tooltip({
  755. permanent: true,
  756. direction: 'top',
  757. className: 'trackable-marker-label',
  758. offset: [0, -12]
  759. })
  760. .setContent(labelText)
  761. .setLatLng([lat, lon]);
  762.  
  763. label.addTo(map);
  764.  
  765. // Bind popup to marker
  766. marker.bindPopup(popupContent);
  767.  
  768. // Hide label when popup is open
  769. marker.on('popupopen', function() {
  770. map.removeLayer(label);
  771. });
  772.  
  773. // Show label when popup is closed
  774. marker.on('popupclose', function() {
  775. label.addTo(map);
  776. });
  777.  
  778. markers.push({
  779. marker,
  780. label,
  781. point
  782. });
  783. });
  784.  
  785. // Find the legend container - should be a sibling of our map container
  786. const mapSection = container.closest('#gc-trackables-map-section');
  787. const legendContainer = mapSection ? mapSection.querySelector('#trackables-map-legend') : null;
  788.  
  789. if (legendContainer) {
  790. // Get the content container
  791. const legendContent = document.getElementById('trackables-map-legend-content');
  792. if (!legendContent) return;
  793.  
  794. // Clear any existing content
  795. legendContent.innerHTML = '';
  796.  
  797. // Add entries for each marker/location
  798. markers.forEach((markerData, index) => {
  799. const { marker, point, label } = markerData;
  800. const trackables = point.trackables;
  801. const trackableCount = trackables.length;
  802.  
  803. // For each location, create a section
  804. const sectionContainer = document.createElement('div');
  805. sectionContainer.style.marginBottom = index < markers.length - 1 ? '10px' : '0';
  806. sectionContainer.style.paddingBottom = index < markers.length - 1 ? '10px' : '0';
  807. sectionContainer.style.borderBottom = index < markers.length - 1 ? '1px solid #eee' : 'none';
  808.  
  809. // Location header
  810. const locationHeader = document.createElement('div');
  811. locationHeader.style.display = 'flex';
  812. locationHeader.style.alignItems = 'center';
  813. locationHeader.style.marginBottom = '5px';
  814. locationHeader.style.cursor = 'pointer';
  815.  
  816. // Create color dot to match marker color
  817. const colorDot = document.createElement('span');
  818. colorDot.style.width = '16px';
  819. colorDot.style.height = '16px';
  820. colorDot.style.borderRadius = '50%';
  821. colorDot.style.backgroundColor = getColorForCache(point.cacheName);
  822. colorDot.style.display = 'inline-block';
  823. colorDot.style.marginRight = '8px';
  824. colorDot.style.border = '1px solid rgba(0,0,0,0.2)';
  825.  
  826. locationHeader.appendChild(colorDot);
  827.  
  828. // Location text
  829. let locationText;
  830. locationText = document.createElement('div');
  831.  
  832. if (trackables.length === 1) {
  833. locationText.textContent = trackables[0].cacheName;
  834. } else {
  835. locationText.textContent = `${point.cacheName} (${trackables.length} trackables)`;
  836. }
  837.  
  838. locationText.style.fontWeight = 'bold';
  839. locationHeader.appendChild(locationText);
  840.  
  841. // Add click event to zoom to marker
  842. locationHeader.addEventListener('click', () => {
  843. map.setView(marker.getLatLng(), 15);
  844.  
  845. // Slight delay to ensure map has completed moving before opening popup
  846. setTimeout(() => {
  847. marker.openPopup();
  848. }, 300);
  849. });
  850.  
  851. // Add hover effect
  852. locationHeader.addEventListener('mouseenter', () => {
  853. locationHeader.style.backgroundColor = '#f0f0f0';
  854. });
  855.  
  856. locationHeader.addEventListener('mouseleave', () => {
  857. locationHeader.style.backgroundColor = '';
  858. });
  859.  
  860. sectionContainer.appendChild(locationHeader);
  861.  
  862. // Add individual trackable items if there are multiple at this location
  863. if (trackables.length > 1) {
  864. const trackablesList = document.createElement('div');
  865. trackablesList.style.marginLeft = '24px';
  866.  
  867. trackables.forEach((tb, i) => {
  868. const trackableItem = document.createElement('div');
  869. trackableItem.style.padding = '3px 0';
  870. trackableItem.style.fontSize = '12px';
  871. trackableItem.style.display = 'flex';
  872. trackableItem.style.alignItems = 'center';
  873. trackableItem.style.cursor = 'pointer';
  874.  
  875. const bulletPoint = document.createElement('span');
  876. bulletPoint.textContent = '•';
  877. bulletPoint.style.marginRight = '5px';
  878. trackableItem.appendChild(bulletPoint);
  879.  
  880. const tbName = document.createElement('span');
  881. tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
  882. trackableItem.appendChild(tbName);
  883.  
  884. // Add click handler to open trackable page
  885. trackableItem.addEventListener('click', () => {
  886. window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
  887. });
  888.  
  889. // Add hover effect
  890. trackableItem.addEventListener('mouseenter', () => {
  891. trackableItem.style.backgroundColor = '#f0f0f0';
  892. trackableItem.style.color = '#0066cc';
  893. });
  894.  
  895. trackableItem.addEventListener('mouseleave', () => {
  896. trackableItem.style.backgroundColor = '';
  897. trackableItem.style.color = '';
  898. });
  899.  
  900. trackablesList.appendChild(trackableItem);
  901. });
  902.  
  903. sectionContainer.appendChild(trackablesList);
  904. } else if (trackables.length === 1) {
  905. // Make single trackable clickable too
  906. const tb = trackables[0];
  907. const trackableItem = document.createElement('div');
  908. trackableItem.style.marginLeft = '28px';
  909. trackableItem.style.fontSize = '12px';
  910. trackableItem.style.cursor = 'pointer';
  911. trackableItem.style.display = 'flex';
  912. trackableItem.style.alignItems = 'center';
  913.  
  914. const bulletPoint = document.createElement('span');
  915. bulletPoint.textContent = '•';
  916. bulletPoint.style.marginRight = '5px';
  917. trackableItem.appendChild(bulletPoint);
  918.  
  919. const tbName = document.createElement('span');
  920. tbName.innerHTML = `<span style="font-weight: bold;">${tb.trackableName}</span>${tb.totalStops ? ` (${tb.totalStops} stops)` : ''}`;
  921. trackableItem.appendChild(tbName);
  922.  
  923. // Add click handler to open trackable page
  924. trackableItem.addEventListener('click', () => {
  925. window.open(`https://www.geocaching.com/track/details.aspx?id=${tb.trackableId}`, '_blank');
  926. });
  927.  
  928. // Add hover effect
  929. trackableItem.addEventListener('mouseenter', () => {
  930. trackableItem.style.backgroundColor = '#f0f0f0';
  931. trackableItem.style.color = '#0066cc';
  932. });
  933.  
  934. trackableItem.addEventListener('mouseleave', () => {
  935. trackableItem.style.backgroundColor = '';
  936. trackableItem.style.color = '';
  937. });
  938.  
  939. sectionContainer.appendChild(trackableItem);
  940. }
  941.  
  942. legendContent.appendChild(sectionContainer);
  943. });
  944. }
  945. }
  946.  
  947. // Function to safely inject the map container into the page
  948. function safelyInjectMap() {
  949. // First, identify the main container and the search panel
  950. const pageWrapper = document.querySelector('#Content, #content, .Content');
  951.  
  952. if (!pageWrapper) {
  953. console.error('Could not find main content wrapper');
  954. return null;
  955. }
  956.  
  957. // Clear any existing map we might have added before
  958. const existingMap = document.getElementById('gc-trackables-map-section');
  959. if (existingMap) {
  960. existingMap.remove();
  961. }
  962.  
  963. // Create our map container with a distinctive ID
  964. const mapSection = document.createElement('div');
  965. mapSection.id = 'gc-trackables-map-section';
  966. mapSection.style.width = '100%';
  967. mapSection.style.clear = 'both';
  968. mapSection.style.position = 'relative';
  969. mapSection.style.margin = '20px 0';
  970. mapSection.style.padding = '0';
  971. mapSection.style.backgroundColor = '#fff';
  972. mapSection.style.boxSizing = 'border-box';
  973.  
  974. // Add title
  975. const mapTitle = document.createElement('h3');
  976. mapTitle.textContent = 'Trackable Locations Map';
  977. mapTitle.style.margin = '0 0 10px 0';
  978. mapTitle.style.padding = '0';
  979. mapTitle.style.fontSize = '16px';
  980. mapTitle.style.fontWeight = 'bold';
  981. mapSection.appendChild(mapTitle);
  982.  
  983. // Create map container
  984. const mapContainer = document.createElement('div');
  985. mapContainer.id = 'trackables-map-container';
  986. mapContainer.style.width = '100%';
  987. mapContainer.style.height = '500px';
  988. mapContainer.style.border = '1px solid #ddd';
  989. mapContainer.style.borderRadius = '4px';
  990. mapContainer.style.marginBottom = '10px';
  991. mapContainer.style.boxSizing = 'border-box';
  992. mapSection.appendChild(mapContainer);
  993.  
  994. // Create legend container that will be filled by the map creation function
  995. const legendContainer = document.createElement('div');
  996. legendContainer.id = 'trackables-map-legend';
  997. legendContainer.style.marginTop = '10px';
  998. legendContainer.style.width = '100%';
  999. legendContainer.style.boxSizing = 'border-box';
  1000. legendContainer.style.border = '1px solid #eee';
  1001. legendContainer.style.borderRadius = '4px';
  1002. legendContainer.style.backgroundColor = '#fff';
  1003.  
  1004. // Create collapsible header for legend
  1005. const legendHeader = document.createElement('div');
  1006. legendHeader.style.padding = '10px';
  1007. legendHeader.style.borderBottom = '1px solid #eee';
  1008. legendHeader.style.display = 'flex';
  1009. legendHeader.style.alignItems = 'center';
  1010. legendHeader.style.justifyContent = 'space-between';
  1011. legendHeader.style.cursor = 'pointer';
  1012.  
  1013. // Create title text
  1014. const headerText = document.createElement('div');
  1015. headerText.textContent = 'Trackables';
  1016. headerText.style.fontWeight = 'bold';
  1017. headerText.style.fontSize = '14px';
  1018.  
  1019. // Create arrow indicator
  1020. const arrowIndicator = document.createElement('div');
  1021. arrowIndicator.innerHTML = '&#9650;'; // Up arrow (collapsed)
  1022. arrowIndicator.style.transition = 'transform 0.3s';
  1023. arrowIndicator.style.fontSize = '12px';
  1024.  
  1025. // Append elements to header
  1026. legendHeader.appendChild(headerText);
  1027. legendHeader.appendChild(arrowIndicator);
  1028. legendContainer.appendChild(legendHeader);
  1029.  
  1030. // Create content container for the legend
  1031. const legendContent = document.createElement('div');
  1032. legendContent.id = 'trackables-map-legend-content';
  1033. legendContent.style.padding = '10px';
  1034. legendContent.style.display = 'none'; // Hidden by default
  1035. legendContent.style.maxHeight = 'none';
  1036. legendContent.style.overflowY = 'visible';
  1037. legendContainer.appendChild(legendContent);
  1038.  
  1039. // Add click event to toggle legend visibility
  1040. legendHeader.addEventListener('click', function(e) {
  1041. e.preventDefault();
  1042. e.stopPropagation();
  1043.  
  1044. const isVisible = legendContent.style.display !== 'none';
  1045. legendContent.style.display = isVisible ? 'none' : 'block';
  1046. arrowIndicator.innerHTML = isVisible ? '&#9650;' : '&#9660;'; // Up arrow when closed, down arrow when open
  1047.  
  1048. return false;
  1049. });
  1050.  
  1051. mapSection.appendChild(legendContainer);
  1052.  
  1053. // Look for the best insertion point
  1054. let inserted = false;
  1055.  
  1056. // Method 1: Try to find common table containers
  1057. const tableContainers = Array.from(document.querySelectorAll('.Table, table, .table-container'));
  1058. for (const table of tableContainers) {
  1059. // Only consider visible tables
  1060. if (isElementVisible(table)) {
  1061. const tableParent = table.parentNode;
  1062.  
  1063. // Insert before the table
  1064. tableParent.insertBefore(mapSection, table);
  1065. inserted = true;
  1066. break;
  1067. }
  1068. }
  1069.  
  1070. // Method 2: If we couldn't find a table, try to find section headings
  1071. if (!inserted) {
  1072. const sectionHeadings = Array.from(document.querySelectorAll('h1, h2, h3'));
  1073. for (const heading of sectionHeadings) {
  1074. // Look for headings related to trackables or search
  1075. const headingText = heading.textContent.toLowerCase();
  1076. if ((headingText.includes('trackable') || headingText.includes('search')) && isElementVisible(heading)) {
  1077. // Insert after the heading
  1078. if (heading.nextSibling) {
  1079. heading.parentNode.insertBefore(mapSection, heading.nextSibling);
  1080. } else {
  1081. heading.parentNode.appendChild(mapSection);
  1082. }
  1083. inserted = true;
  1084. break;
  1085. }
  1086. }
  1087. }
  1088.  
  1089. // Method 3: Last resort - insert at top of content area
  1090. if (!inserted) {
  1091. // Insert at the beginning of the content area
  1092. if (pageWrapper.firstChild) {
  1093. pageWrapper.insertBefore(mapSection, pageWrapper.firstChild);
  1094. } else {
  1095. pageWrapper.appendChild(mapSection);
  1096. }
  1097. }
  1098.  
  1099. // Initialize the empty map
  1100. const mapViewContainer = document.createElement('div');
  1101. mapViewContainer.id = 'leaflet-map';
  1102. mapViewContainer.style.width = '100%';
  1103. mapViewContainer.style.height = '500px';
  1104. mapViewContainer.style.border = '1px solid #ddd';
  1105. mapViewContainer.style.borderRadius = '4px';
  1106. mapContainer.appendChild(mapViewContainer);
  1107.  
  1108. // Initialize the map with a default view (world map)
  1109. try {
  1110. // Create a new map instance
  1111. trackableMap = L.map('leaflet-map').setView([20, 0], 2);
  1112.  
  1113. // Add OpenStreetMap tile layer
  1114. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  1115. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
  1116. maxZoom: 18
  1117. }).addTo(trackableMap);
  1118.  
  1119. // Add "Loading trackables..." message
  1120. const loadingMessage = L.control({position: 'bottomleft'});
  1121. loadingMessage.onAdd = function(map) {
  1122. const div = L.DomUtil.create('div', 'loading-message');
  1123. div.innerHTML = '<div style="background-color: white; padding: 5px 10px; border-radius: 4px; border: 1px solid #ccc; font-weight: bold;">Loading trackable data...</div>';
  1124. return div;
  1125. };
  1126. loadingMessage.addTo(trackableMap);
  1127. } catch (e) {
  1128. console.error('Error initializing map:', e);
  1129. }
  1130.  
  1131. // Add custom CSS for marker labels
  1132. const style = document.createElement('style');
  1133. style.textContent = `
  1134. .trackable-marker-label {
  1135. background: white;
  1136. border: 1px solid #333;
  1137. border-radius: 4px;
  1138. padding: 2px 6px;
  1139. font-weight: bold;
  1140. white-space: nowrap;
  1141. text-align: center;
  1142. box-shadow: 0 1px 3px rgba(0,0,0,0.2);
  1143. pointer-events: none;
  1144. }
  1145. `;
  1146. document.head.appendChild(style);
  1147.  
  1148. return mapContainer;
  1149. }
  1150.  
  1151. // Find trackables on the page and process them
  1152. async function processTrackables() {
  1153. // Prevent concurrent processing
  1154. if (isProcessingTrackables) {
  1155. console.log('Already processing trackables, skipping duplicate call');
  1156. return;
  1157. }
  1158.  
  1159. isProcessingTrackables = true;
  1160.  
  1161. try {
  1162. // Create and inject map container first before processing data
  1163. const mapContainer = safelyInjectMap();
  1164.  
  1165. // Extract trackables
  1166. const trackablesMap = extractTrackablesFromPage();
  1167. console.log(`Found ${trackablesMap.size} trackables on page`);
  1168.  
  1169. if (trackablesMap.size === 0) {
  1170. console.log('No trackables found on page');
  1171. // Update the map with a "No trackables" message
  1172. if (trackableMap) {
  1173. // Remove any loading message
  1174. const loadingControl = document.querySelector('.loading-message');
  1175. if (loadingControl && loadingControl.parentNode) {
  1176. loadingControl.parentNode.removeChild(loadingControl);
  1177. }
  1178.  
  1179. const noDataMessage = L.control({position: 'bottomleft'});
  1180. noDataMessage.onAdd = function(map) {
  1181. const div = L.DomUtil.create('div', 'no-data-message');
  1182. div.innerHTML = '<div style="background-color: white; padding: 5px 10px; border-radius: 4px; border: 1px solid #ccc;">No trackable location data available</div>';
  1183. return div;
  1184. };
  1185. noDataMessage.addTo(trackableMap);
  1186. }
  1187. isProcessingTrackables = false;
  1188. return;
  1189. }
  1190.  
  1191. const trackables = Array.from(trackablesMap.values());
  1192. console.log('Trackables found:', trackables);
  1193.  
  1194. // Enrich trackables with stop data
  1195. const enrichedTrackables = await enrichTrackablesWithStops(trackables);
  1196.  
  1197. // Display on map
  1198. displayTrackablesMap(enrichedTrackables, mapContainer);
  1199. } catch (error) {
  1200. console.error('Error in processTrackables:', error);
  1201. } finally {
  1202. // Always reset the processing flag
  1203. isProcessingTrackables = false;
  1204. }
  1205. }
  1206.  
  1207. // Run on page load and after AJAX content updates
  1208. setTimeout(processTrackables, 1000);
  1209.  
  1210. // Track if the map has been added to the page
  1211. let mapAdded = false;
  1212.  
  1213. // Create a MutationObserver to watch for content changes
  1214. const observer = new MutationObserver(function(mutations) {
  1215. // Don't trigger if we're already processing or if we created the map element
  1216. if (isProcessingTrackables || mapAdded) return;
  1217.  
  1218. let shouldReprocess = false;
  1219.  
  1220. // Check if any mutations affect our elements of interest (trackable links)
  1221. for (const mutation of mutations) {
  1222. // Skip mutations caused by our own map
  1223. if (mutation.target.id === 'gc-trackables-map-section' ||
  1224. mutation.target.closest('#gc-trackables-map-section')) {
  1225. continue;
  1226. }
  1227.  
  1228. // Skip mutations that don't add nodes - we only care about content being added
  1229. if (mutation.type !== 'childList' || mutation.addedNodes.length === 0) {
  1230. continue;
  1231. }
  1232.  
  1233. // Look for relevant data tables or trackable links
  1234. if (mutation.target.classList.contains('Table') ||
  1235. mutation.target.querySelector('.Table') ||
  1236. mutation.target.querySelector('a[href*="track/details.aspx"]')) {
  1237. shouldReprocess = true;
  1238. break;
  1239. }
  1240. }
  1241.  
  1242. if (shouldReprocess) {
  1243. console.log('Content changed, reprocessing trackables');
  1244. processTrackables().finally(() => {
  1245. mapAdded = true;
  1246.  
  1247. // Disconnect observer after first successful map creation to prevent further updates
  1248. // This prevents repeated refreshing while still allowing the initial map to be created
  1249. observer.disconnect();
  1250. });
  1251. }
  1252. });
  1253.  
  1254. // Start observing with configuration
  1255. observer.observe(document.body, {
  1256. childList: true,
  1257. subtree: true
  1258. });
  1259. })();