assets_js_controllers_gathering-map-controller.js
import { Controller } from "@hotwired/stimulus"
// Module-scoped promise to memoize Google Maps loading and prevent concurrent script injections
let googleMapsLoaderPromise = null
/**
* GatheringMapController
*
* Handles interactive map display for gathering locations using Google Maps.
* Displays the location on a map and provides options to open in external mapping services
* for directions and navigation.
*
* @example
* <div data-controller="gathering-map"
* data-gathering-map-location-value="123 Main St, City, State"
* data-gathering-map-gathering-name-value="Great Western War">
* <div data-gathering-map-target="map"></div>
* </div>
*/
class GatheringMapController extends Controller {
// Define targets - elements this controller interacts with
static targets = ["map", "error"]
// Define values - properties that can be set from HTML
static values = {
location: String, // The address/location string
gatheringName: String, // Name of the gathering
apiKey: String, // Google Maps API key (optional)
latitude: Number, // Stored latitude (optional, saves API call)
longitude: Number, // Stored longitude (optional, saves API call)
zoom: { // Default zoom level
type: Number,
default: 15
}
}
/**
* Initialize the controller
*/
initialize() {
this.map = null
this.marker = null
this.geocoded = false
}
/**
* Connect function - runs when controller connects to DOM
*/
connect() {
console.log("GatheringMapController connected")
if (!this.locationValue) {
this.showError("No location provided")
return
}
// Initialize Google Maps
this.initGoogleMap()
}
/**
* Initialize Google Maps
*/
async initGoogleMap() {
try {
// Check if Google Maps API is loaded
if (typeof google === 'undefined' || !google.maps) {
// Load Google Maps API dynamically if not present
await this.loadGoogleMapsScript()
}
// Initialize the map with required configuration for AdvancedMarkerElement
const mapOptions = {
zoom: this.zoomValue,
center: { lat: 0, lng: 0 }, // Will be updated after geocoding
mapId: 'GATHERING_MAP', // Required for AdvancedMarkerElement
mapTypeId: 'roadmap'
}
this.map = new google.maps.Map(this.mapTarget, mapOptions)
// Geocode the location and add marker
await this.geocodeAndDisplayGoogle()
} catch (error) {
console.error("Error initializing Google Maps:", error)
this.showError("Failed to load map. Please try again later.")
}
}
/**
* Load Google Maps Script dynamically with marker library
* Memoized to prevent concurrent script injections and share a single Promise
*/
loadGoogleMapsScript() {
// Return existing promise if already loading
if (googleMapsLoaderPromise) {
console.log("Google Maps already loading, returning existing promise")
return googleMapsLoaderPromise
}
// Check if already loaded
if (typeof google !== 'undefined' && google.maps) {
console.log("Google Maps already loaded")
return Promise.resolve()
}
// Check if script tag already exists to prevent duplicates
const existingScript = document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]')
if (existingScript) {
console.log("Google Maps script already in DOM, waiting for load")
// Script exists but may not be loaded yet, create a promise to wait for it
googleMapsLoaderPromise = new Promise((resolve, reject) => {
// Check if it's already loaded
if (typeof google !== 'undefined' && google.maps) {
resolve()
googleMapsLoaderPromise = null
return
}
// Wait for the existing script to load
const checkInterval = setInterval(() => {
if (typeof google !== 'undefined' && google.maps) {
clearInterval(checkInterval)
resolve()
googleMapsLoaderPromise = null
}
}, 100)
// Timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval)
googleMapsLoaderPromise = null
reject(new Error('Timeout waiting for Google Maps to load'))
}, 10000)
})
return googleMapsLoaderPromise
}
// Create and assign the promise once
googleMapsLoaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
const apiKey = this.apiKeyValue || ''
const keyParam = apiKey ? `key=${apiKey}&` : ''
// Load with marker library for AdvancedMarkerElement and loading=async for performance
script.src = `https://maps.googleapis.com/maps/api/js?${keyParam}libraries=marker&loading=async&callback=initGoogleMapsCallback`
script.async = true
script.defer = true
window.initGoogleMapsCallback = () => {
console.log("Google Maps loaded successfully")
// Cleanup global callback
delete window.initGoogleMapsCallback
// Reset module-scoped promise on success
googleMapsLoaderPromise = null
resolve()
}
script.onerror = (error) => {
console.error("Failed to load Google Maps script")
// Cleanup global callback
delete window.initGoogleMapsCallback
// Reset module-scoped promise on failure to allow retries
googleMapsLoaderPromise = null
reject(new Error('Failed to load Google Maps script'))
}
document.head.appendChild(script)
console.log("Google Maps script appended to DOM")
})
return googleMapsLoaderPromise
}
/**
* Geocode location and display on Google Maps with AdvancedMarkerElement
* Uses stored lat/lng if available to avoid geocoding API call
*/
async geocodeAndDisplayGoogle() {
// Check if we have stored coordinates to avoid API call
if (this.hasLatitudeValue && this.hasLongitudeValue) {
console.log('Using stored coordinates:', this.latitudeValue, this.longitudeValue)
const location = {
lat: this.latitudeValue,
lng: this.longitudeValue
}
// Center map on stored location
this.map.setCenter(location)
// Create marker at stored location
await this.createMarker(location)
return
}
// No stored coordinates, use geocoding API
console.log('No stored coordinates, geocoding address:', this.locationValue)
const geocoder = new google.maps.Geocoder()
geocoder.geocode({ address: this.locationValue }, async (results, status) => {
if (status === 'OK' && results[0]) {
const location = results[0].geometry.location
// Center map on location
this.map.setCenter(location)
// Create marker
await this.createMarker(location)
this.geocoded = true
} else {
console.error('Geocode was not successful:', status)
this.showError(`Unable to find location: ${this.locationValue}`)
}
})
}
/**
* Create marker on the map
* @param {Object} location - Google Maps LatLng or {lat, lng} object
*/
async createMarker(location) {
try {
// Import the AdvancedMarkerElement library
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker")
// Create marker using AdvancedMarkerElement
this.marker = new AdvancedMarkerElement({
map: this.map,
position: location,
title: this.gatheringNameValue || 'Gathering Location'
})
// Add info window
const infoWindow = new google.maps.InfoWindow({
content: `
<div style="padding: 8px;">
<strong>${this.gatheringNameValue || 'Gathering Location'}</strong><br>
<span style="color: #666;">${this.locationValue}</span>
</div>
`
})
// Add click listener to marker
this.marker.addListener('click', () => {
infoWindow.open({
anchor: this.marker,
map: this.map
})
})
// Open info window by default
infoWindow.open({
anchor: this.marker,
map: this.map
})
this.geocoded = true
} catch (error) {
console.error('Error creating marker:', error)
this.showError('Failed to display marker on map')
}
}
/**
* Open location in Google Maps (new window/tab)
* Uses stored lat/lng if available for precise location, otherwise uses address string
*/
openInGoogleMaps(event) {
event.preventDefault()
let url
if (this.hasLatitudeValue && this.hasLongitudeValue) {
// Use precise coordinates
url = `https://www.google.com/maps/search/?api=1&query=${this.latitudeValue},${this.longitudeValue}`
} else {
// Fall back to address string
url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(this.locationValue)}`
}
window.open(url, '_blank')
}
/**
* Open location in Apple Maps (works on supported devices)
* Uses stored lat/lng if available for precise location, otherwise uses address string
*/
openInAppleMaps(event) {
event.preventDefault()
let url
if (this.hasLatitudeValue && this.hasLongitudeValue) {
// Use precise coordinates - Apple Maps uses ll parameter
url = `https://maps.apple.com/?ll=${this.latitudeValue},${this.longitudeValue}&q=${encodeURIComponent(this.locationValue)}`
} else {
// Fall back to address string
url = `https://maps.apple.com/?q=${encodeURIComponent(this.locationValue)}`
}
window.open(url, '_blank')
}
/**
* Get directions to location in Google Maps
* Uses stored lat/lng if available for precise destination, otherwise uses address string
*/
getDirections(event) {
event.preventDefault()
let url
if (this.hasLatitudeValue && this.hasLongitudeValue) {
// Use precise coordinates
url = `https://www.google.com/maps/dir/?api=1&destination=${this.latitudeValue},${this.longitudeValue}`
} else {
// Fall back to address string
url = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(this.locationValue)}`
}
window.open(url, '_blank')
}
/**
* Show error message
*/
showError(message) {
if (this.hasErrorTarget) {
this.errorTarget.textContent = message
this.errorTarget.style.display = 'block'
} else {
console.error(message)
// Create error display if target doesn't exist
const errorDiv = document.createElement('div')
errorDiv.className = 'alert alert-warning'
errorDiv.innerHTML = `<i class="bi bi-exclamation-triangle"></i> ${message}`
this.mapTarget.parentNode.insertBefore(errorDiv, this.mapTarget)
}
// Hide map container if there's an error
if (this.hasMapTarget) {
this.mapTarget.style.display = 'none'
}
}
/**
* Cleanup when controller disconnects
*/
disconnect() {
if (this.map) {
// Clean up map resources
this.map = null
this.marker = null
}
}
}
// Add to global controllers registry
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["gathering-map"] = GatheringMapController;