1import mapSettings, { syncMapStyleWithTheme } from "../scripts/config-map.js"; 2import mapStyles from "../scripts/config-map-styles.js"; 3import { loadGoogleMapsApi } from "../scripts/map-loader.js"; 4import { createHtmlMarker, createTraditionalMarker } from "../scripts/map-marker-factory.js"; 5import { createInfoWindowContent, setupInfoWindowInteractions } from "../scripts/map-info-window-factory.js"; 6import { addLocationSelectorControl, addStyleSwitcherControl, focusLocation } from "../scripts/map-controls.js"; 7 8export default function mapHandler() { 9 return { 10 mapsInitialized: false, 11 mapElements: {}, 12 isDarkMode: false, // Track dark mode state 13 advancedMarkersSupported: false, 14 15 async init() { 16 // Find all map containers on the page 17 const mapContainers = document.querySelectorAll(".google-map-container"); 18 if (mapContainers.length === 0) return; 19 20 // Check if page is in dark mode 21 this.isDarkMode = document.documentElement.classList.contains("dark"); 22 23 // Sync map style with current theme 24 syncMapStyleWithTheme(this.isDarkMode); 25 26 // Show loading indicators 27 mapContainers.forEach((container) => { 28 const loadingEl = container.querySelector(".map-loading"); 29 if (loadingEl) loadingEl.classList.remove("hidden"); 30 }); 31 32 try { 33 // Load Google Maps script and wait for it to be ready 34 await loadGoogleMapsApi(); 35 this.mapsInitialized = true; 36 37 // Check if Advanced Markers are supported 38 this.advancedMarkersSupported = 39 window.google && 40 window.google.maps && 41 window.google.maps.marker && 42 window.google.maps.marker.AdvancedMarkerElement; 43 44 45 this.initializeAllMaps(); 46 } catch (error) { 47 console.error("Failed to load Google Maps API:", error); 48 } 49 50 // Listen for theme changes 51 this.setupThemeChangeListener(); 52 }, 53 54 // Listen for theme changes (dark/light mode toggle) 55 setupThemeChangeListener() { 56 window.addEventListener("themechange", (e) => { 57 this.isDarkMode = e.detail.theme === "dark"; 58 syncMapStyleWithTheme(this.isDarkMode); 59 this.updateAllMapStyles(); 60 }); 61 }, 62 63 // Update styles on all existing maps 64 updateAllMapStyles() { 65 for (const mapId in this.mapElements) { 66 const { map } = this.mapElements[mapId]; 67 if (map) { 68 map.setOptions({ styles: mapSettings.mapOptions.styles }); 69 } 70 } 71 }, 72 73 setMapStyle(styleId) { 74 // Update the map settings with the new style 75 if (mapSettings.setMapStyle(styleId)) { 76 // Update all existing maps with the new style 77 this.updateAllMapStyles(); 78 return true; 79 } 80 return false; 81 }, 82 83 initializeAllMaps() { 84 try { 85 86 87 if (!this.mapsInitialized) { 88 console.error("Attempted to initialize maps before API was loaded."); 89 return; 90 } 91 92 const mapContainers = document.querySelectorAll( 93 ".google-map-container", 94 ); 95 if (!mapContainers.length) { 96 console.log("No map containers found"); 97 return; 98 } 99 100 mapContainers.forEach((container, index) => { 101 try { 102 this.initializeSingleMap(container, index); 103 } catch (error) { 104 console.error(`Error initializing map ${index}:`, error); 105 } 106 }); 107 108 // Handle window resize 109 window.addEventListener("resize", this.handleResize.bind(this)); 110 } catch (error) { 111 console.error("Critical error in map initialization:", error); 112 } 113 }, 114 115 async initializeSingleMap(container, index) { 116 117 const specifiedStyle = container.dataset.mapStyle; 118 119 const showMultiple = container.dataset.multiple === "true"; 120 121 const disableControls = container.dataset.disableControls === "true"; 122 const noInfoWindow = container.dataset.noInfoWindow === "true"; 123 124 let locationKeys = []; 125 126 if (showMultiple) { 127 locationKeys = Object.keys(Alpine.store('locationsData') || {}).filter( 128 (key) => 129 key.startsWith("location") && 130 Alpine.store('locationsData')[key]?.coordinates, 131 ); 132 } else { 133 // Get single location 134 locationKeys = [container.dataset.location || "location1"]; 135 } 136 137 if (!locationKeys.length) {
138 console.error("No valid locations found"); 139 return; 140 } 141 142 // Create map 143 const mapElement = container.querySelector(".map-canvas"); 144 if (!mapElement) { 145 console.error("Map canvas element not found in container"); 146 return; 147 } 148 149 const mapId = `map-${index}`; 150 mapElement.id = mapId; 151 152 // First valid location data for initial center 153 let firstValidLocation = null; 154 155 // Process all locations to get valid coordinates 156 const validLocations = locationKeys 157 .map((key) => { 158 const locationData = Alpine.store('locationsData')?.[key]; 159 if (!locationData) { 160 console.warn(`Location data not found for key: ${key}`); 161 return null; 162 } 163 164 const coordinates = this.parseCoordinates(locationData.coordinates); 165 if (!coordinates) { 166 console.warn(`Invalid coordinates for location: ${key}`); 167 return null; 168 } 169 170 if (!firstValidLocation) 171 firstValidLocation = { locationData, coordinates }; 172 return { locationData, coordinates, key }; 173 }) 174 .filter(Boolean); 175 176 if (validLocations.length === 0 || !firstValidLocation) { 177 console.error("No valid coordinates found in locations"); 178 return; 179 } 180 181 try { 182 // Create map options from scratch instead of spreading 183 const mapOptions = { 184 zoom: mapSettings.mapOptions.zoom, 185 center: firstValidLocation.coordinates, 186 mapTypeControl: disableControls ? false : mapSettings.mapOptions.mapTypeControl, 187 streetViewControl: disableControls ? false : mapSettings.mapOptions.streetViewControl, 188 fullscreenControl: disableControls ? false : mapSettings.mapOptions.fullscreenControl, 189 clickableIcons: false, 190 }; 191 if (disableControls) { 192 // disableDefaultUI removes zoom / fullscreen / street view / map type; cameraControl 193 // is the vector-map tilt/rotate control, disabled explicitly for good measure. 194 mapOptions.disableDefaultUI = true; 195 mapOptions.zoomControl = false; 196 mapOptions.cameraControl = false; 197 mapOptions.keyboardShortcuts = false; 198 } 199 200 // Determine styling mode and marker type 201 const mapId = mapSettings.getMapId(); 202 const useAdvancedMarkers = 203 mapSettings.shouldUseAdvancedMarkers() && 204 this.advancedMarkersSupported; 205 206 if (mapId) { 207 // Cloud-based styling mode 208 mapOptions.mapId = mapId; 209 console.log("Using cloud-based styling with map ID:", mapId); 210 } else { 211 // Local styling mode 212 const styleId = specifiedStyle || mapSettings.currentStyle; 213 mapOptions.styles = mapStyles[styleId]; 214 //console.log("Using local style:", styleId); 215 } 216 217 // Create the map 218 const map = new window.google.maps.Map(mapElement, mapOptions); 219 220 // Create bounds object AFTER map has been initialized 221 const bounds = new window.google.maps.LatLngBounds(); 222 223 // Add style switcher control if enabled 224 if (container.dataset.showStyleSwitcher === "true") { 225 addStyleSwitcherControl(map, (styleId) => this.setMapStyle(styleId)); 226 } 227 228 // Add markers for each location 229 const markers = []; 230 const infoWindows = []; 231 232 // Use for...of to support async/await 233 for (const { locationData, coordinates, key } of validLocations) { 234 // Add to bounds 235 bounds.extend(coordinates); 236 237 // Create marker based on settings 238 let marker; 239 240 // Use HTML marker if enabled and supported 241 if (useAdvancedMarkers) { 242 marker = createHtmlMarker(map, coordinates, locationData); 243 } else { 244 // Fallback to traditional marker (now async) 245 marker = await createTraditionalMarker( 246 map, 247 coordinates, 248 locationData, 249 ); 250 } 251 252 // Create the info window â skipped entirely for no-info-window maps (e.g. the 253 // contact mini map), where selecting a marker just recenters on it with no box. 254 let infoWindow = null; 255 256 if (noInfoWindow) { 257 const focusKey = key; 258 if (useAdvancedMarkers) { 259 const markerElement = marker.content; 260 if (markerElement) { 261 markerElement.addEventListener("click", (e) => { 262 e.stopPropagation(); 263 window.royaMapFocus?.[focusKey]?.(); 264 }); 265 } 266 marker.addListener("gmp-click", () => window.royaMapFocus?.[focusKey]?.()); 267 } else { 268 marker.addListener("click", () => window.royaMapFocus?.[focusKey]?.()); 269 } 270 } else { 271 infoWindow = new window.google.maps.InfoWindow({ 272 content: createInfoWindowContent(locationData), 273 maxWidth: mapSettings.infoWindowOptions.maxWidth, 274 }); 275 276 const openInfoWindow = () => { 277 infoWindows.forEach((iw) => iw && iw.close()); 278 setTimeout(() => { 279 if (useAdvancedMarkers) { 280 infoWindow.open({ anchor: marker, map: map }); 281 } else { 282 infoWindow.open(map, marker); 283 } 284 window.google.maps.event.addListener(infoWindow, "domready", () => { 285 setupInfoWindowInteractions(locationData); 286 }); 287 }, 50); 288 }; 289 290 if (useAdvancedMarkers) { 291 const markerElement = marker.content; 292 if (markerElement) { 293 markerElement.addEventListener("click", (e) => { 294 e.stopPropagation(); 295 openInfoWindow(); 296 }); 297 } 298 marker.addListener("gmp-click", openInfoWindow); 299 } else { 300 marker.addListener("click", openInfoWindow); 301 } 302 } 303 304 markers.push(marker); 305 infoWindows.push(infoWindow); 306 } 307 308 // If multiple locations, fit the map to show all markers 309 if (validLocations.length > 1) { 310 console.log("Fitting map to show all markers"); 311 312 // Ensure bounds is not empty 313 if (!bounds.isEmpty()) { 314 map.fitBounds(bounds); 315 316 // Add a small padding to the bounds and limit max zoom 317 window.google.maps.event.addListenerOnce(map, "idle", () => { 318 if (map.getZoom() > 15) map.setZoom(15); 319 }); 320 321 // Add location selector if there are multiple locations â unless this map
322 // opted out of map controls (data-disable-controls), in which case the 323 // built-in selector box is skipped entirely (e.g. the Vision Source V2 map 324 // and contact mini map provide their own location UI). 325 if (!disableControls) { 326 addLocationSelectorControl( 327 map, 328 validLocations, 329 markers, 330 infoWindows, 331 ); 332 } 333 } else { 334 console.warn("Bounds is empty - cannot fit to bounds"); 335 } 336 } 337 338 // Open first info window automatically if configured to do so 339 if (mapSettings.infoWindowOptions.autoOpen && markers.length === 1 && !noInfoWindow) { 340 setTimeout(() => { 341 if (useAdvancedMarkers) { 342 // For advanced markers 343 infoWindows[0].open({ 344 anchor: markers[0], 345 map: map, 346 }); 347 } else { 348 // For traditional markers 349 infoWindows[0].open(map, markers[0]); 350 } 351 352 // Add click listeners to buttons after info window is open 353 window.google.maps.event.addListener( 354 infoWindows[0], 355 "domready", 356 () => { 357 setupInfoWindowInteractions( 358 validLocations[0].locationData, 359 ); 360 }, 361 ); 362 }, mapSettings.infoWindowOptions.autoOpenDelay); 363 } 364 365 // Store map for later reference 366 this.mapElements[mapId] = { 367 map, 368 markers, 369 infoWindows, 370 bounds, 371 isMultiple: showMultiple, 372 validLocations, 373 }; 374 375 // Expose a key-based focus hook so a custom location UI (e.g. the Vision 376 // Source accordion) can trigger the exact same pan/zoom/info-window as the 377 // built-in selector. Keyed by location key, so it is index-mismatch safe. 378 window.royaMapFocus = window.royaMapFocus || {}; 379 validLocations.forEach((loc, i) => { 380 window.royaMapFocus[loc.key] = () => focusLocation(map, markers, infoWindows, i); 381 }); 382 383 // The map's container can reflow shortly after init (images above the locations 384 // section finish loading, the accordion renders), which leaves Google Maps' cached 385 // size/projection stale until the first window resize. That staleness is what made 386 // the FIRST location click render the overview with the info window half off-screen. 387 // Re-sync the size (view unchanged) a few times so the map is correct before the 388 // user interacts, and again once everything has loaded. 389 [200, 700, 1500, 3000].forEach((delay) => 390 setTimeout(() => window.google.maps.event.trigger(map, "resize"), delay), 391 ); 392 window.addEventListener("load", () => 393 window.google.maps.event.trigger(map, "resize"), 394 ); 395 396 // Hide loading indicator once map is ready 397 const loadingEl = container.querySelector(".map-loading"); 398 if (loadingEl) loadingEl.style.display = "none"; 399 400 // Reset to original global style after map initialization 401 mapSettings.mapOptions.styles = mapStyles[mapSettings.currentStyle]; 402 } catch (error) { 403 console.error("Error creating map:", error); 404 console.error(error.stack); 405 406 // Try to display a more useful error message on the map 407 const loadingEl = container.querySelector(".map-loading"); 408 if (loadingEl) { 409 loadingEl.innerHTML = ` 410 <div class="text-center"> 411 <div class="text-red-500 font-semibold">Error loading map</div> 412 <p class="mt-2 text-sm text-primary-foreground">Please refresh the page or try again later.</p> 413 </div> 414 `; 415 } 416 } 417 }, 418 419 parseCoordinates(coordinatesString) { 420 if (!coordinatesString) return null; 421 422 try { 423 const [lat, lng] = coordinatesString 424 .split(",") 425 .map((coord) => Number.parseFloat(coord.trim())); 426 if (isNaN(lat) || isNaN(lng)) throw new Error("Invalid coordinates"); 427 return { lat, lng }; 428 } catch (error) { 429 console.error("Error parsing coordinates:", error); 430 return null; 431 } 432 }, 433 434 handleResize() { 435 for (const mapId in this.mapElements) { 436 const { map, markers, bounds, isMultiple } = this.mapElements[mapId]; 437 438 if (!map) continue; 439 440 // Trigger resize event 441 window.google.maps.event.trigger(map, "resize"); 442
443 // If multiple markers and we have bounds, fit to bounds 444 if ( 445 isMultiple && 446 markers && 447 markers.length > 1 && 448 bounds && 449 !bounds.isEmpty() 450 ) { 451 map.fitBounds(bounds); 452 453 // Limit max zoom 454 const zoom = map.getZoom(); 455 if (zoom > 15) { 456 map.setZoom(15); 457 } 458 } 459 // Otherwise center on single marker 460 else if (markers && markers.length === 1) { 461 // Handle both traditional and advanced markers 462 const position = markers[0].getPosition 463 ? markers[0].getPosition() 464 : markers[0].position; 465 map.setCenter(position); 466 } 467 } 468 }, 469 }; 470}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.