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;