import { Controller } from '@hotwired/stimulus' import { ApiClient } from 'maps_maplibre/services/api_client' import { SettingsManager } from 'maps_maplibre/utils/settings_manager' import { SearchManager } from 'maps_maplibre/utils/search_manager' import { Toast } from 'maps_maplibre/components/toast' import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper' import { MapInitializer } from './maplibre/map_initializer' import { MapDataManager } from './maplibre/map_data_manager' import { LayerManager } from './maplibre/layer_manager' import { DataLoader } from './maplibre/data_loader' import { EventHandlers } from './maplibre/event_handlers' import { FilterManager } from './maplibre/filter_manager' import { DateManager } from './maplibre/date_manager' import { SettingsController } from './maplibre/settings_manager' import { AreaSelectionManager } from './maplibre/area_selection_manager' import { VisitsManager } from './maplibre/visits_manager' import { PlacesManager } from './maplibre/places_manager' import { RoutesManager } from './maplibre/routes_manager' /** * Main map controller for Maps V2 * Coordinates between different managers and handles UI interactions */ export default class extends Controller { static values = { apiKey: String, startDate: String, endDate: String, timezone: String } static targets = [ 'container', 'loading', 'loadingText', 'monthSelect', 'clusterToggle', 'settingsPanel', 'visitsSearch', 'routeOpacityRange', 'placesFilters', 'enableAllPlaceTagsToggle', 'fogRadiusValue', 'fogThresholdValue', 'metersBetweenValue', 'minutesBetweenValue', // Search 'searchInput', 'searchResults', // Layer toggles 'pointsToggle', 'routesToggle', 'heatmapToggle', 'visitsToggle', 'photosToggle', 'areasToggle', 'placesToggle', 'fogToggle', 'scratchToggle', 'familyToggle', // Speed-colored routes 'routesOptions', 'speedColoredToggle', 'speedColorScaleContainer', 'speedColorScaleInput', // Globe projection 'globeToggle', // Family members 'familyMembersList', 'familyMembersContainer', // Area selection 'selectAreaButton', 'selectionActions', 'deleteButtonText', 'selectedVisitsContainer', 'selectedVisitsBulkActions', // Info display 'infoDisplay', 'infoTitle', 'infoContent', 'infoActions', // Route info template 'routeInfoTemplate', 'routeStartTime', 'routeEndTime', 'routeDuration', 'routeDistance', 'routeSpeed', 'routeSpeedContainer', 'routePoints' ] async connect() { this.cleanup = new CleanupHelper() // Initialize API and settings SettingsManager.initialize(this.apiKeyValue) this.settingsController = new SettingsController(this) await this.settingsController.loadSettings() this.settings = this.settingsController.settings // Sync toggle states with loaded settings this.settingsController.syncToggleStates() await this.initializeMap() this.initializeAPI() // Initialize managers this.layerManager = new LayerManager(this.map, this.settings, this.api) this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings) this.eventHandlers = new EventHandlers(this.map, this) this.filterManager = new FilterManager(this.dataLoader) this.mapDataManager = new MapDataManager(this) // Initialize feature managers this.areaSelectionManager = new AreaSelectionManager(this) this.visitsManager = new VisitsManager(this) this.placesManager = new PlacesManager(this) this.routesManager = new RoutesManager(this) // Initialize search manager this.initializeSearch() // Listen for visit and place creation/update events this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager) this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated) this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager) this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated) this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager) this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated) this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager) this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated) this.boundHandleAreaCreated = this.handleAreaCreated.bind(this) this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated) // Format initial dates this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue)) this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue)) this.loadMapData() } disconnect() { this.searchManager?.destroy() this.cleanup.cleanup() this.map?.remove() performanceMonitor.logReport() } /** * Initialize MapLibre map */ async initializeMap() { this.map = await MapInitializer.initialize(this.containerTarget, { mapStyle: this.settings.mapStyle, globeProjection: this.settings.globeProjection }) } /** * Initialize API client */ initializeAPI() { this.api = new ApiClient(this.apiKeyValue) } /** * Initialize location search */ initializeSearch() { if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) { console.warn('[Maps V2] Search targets not found, search functionality disabled') return } this.searchManager = new SearchManager(this.map, this.apiKeyValue) this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget) } /** * Load map data from API */ async loadMapData(options = {}) { return this.mapDataManager.loadMapData( this.startDateValue, this.endDateValue, { ...options, onProgress: this.updateLoadingProgress.bind(this) } ) } /** * Month selector changed */ monthChanged(event) { const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value) this.startDateValue = startDate this.endDateValue = endDate this.loadMapData() } /** * Show loading indicator */ showLoading() { this.loadingTarget.classList.remove('hidden') } /** * Hide loading indicator */ hideLoading() { this.loadingTarget.classList.add('hidden') } /** * Update loading progress */ updateLoadingProgress({ loaded, totalPages, progress }) { if (this.hasLoadingTextTarget) { const percentage = Math.round(progress * 100) this.loadingTextTarget.textContent = `Loading... ${percentage}%` } } /** * Toggle settings panel */ toggleSettings() { if (this.hasSettingsPanelTarget) { this.settingsPanelTarget.classList.toggle('open') } } // ===== Delegated Methods to Managers ===== // Settings Controller methods updateMapStyle(event) { return this.settingsController.updateMapStyle(event) } resetSettings() { return this.settingsController.resetSettings() } updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) } updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) } updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) } updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) } updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) } updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) } toggleGlobe(event) { return this.settingsController.toggleGlobe(event) } // Area Selection Manager methods startSelectArea() { return this.areaSelectionManager.startSelectArea() } cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() } deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() } // Visits Manager methods toggleVisits(event) { return this.visitsManager.toggleVisits(event) } searchVisits(event) { return this.visitsManager.searchVisits(event) } filterVisits(event) { return this.visitsManager.filterVisits(event) } startCreateVisit() { return this.visitsManager.startCreateVisit() } // Places Manager methods togglePlaces(event) { return this.placesManager.togglePlaces(event) } filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) } toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) } startCreatePlace() { return this.placesManager.startCreatePlace() } // Area creation startCreateArea() { if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) { this.toggleSettings() } // Find area drawer controller on the same element const drawerController = this.application.getControllerForElementAndIdentifier( this.element, 'area-drawer' ) if (drawerController) { drawerController.startDrawing(this.map) } else { Toast.error('Area drawer controller not available') } } async handleAreaCreated(event) { try { // Fetch all areas from API const areas = await this.api.fetchAreas() // Convert to GeoJSON const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas) // Get or create the areas layer let areasLayer = this.layerManager.getLayer('areas') if (areasLayer) { // Update existing layer areasLayer.update(areasGeoJSON) } else { // Create the layer if it doesn't exist yet console.log('[Maps V2] Creating areas layer') this.layerManager._addAreasLayer(areasGeoJSON) areasLayer = this.layerManager.getLayer('areas') console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible) } // Enable the layer if it wasn't already if (areasLayer) { if (!areasLayer.visible) { areasLayer.show() this.settings.layers.areas = true this.settingsController.saveSetting('layers.areas', true) // Update toggle state if (this.hasAreasToggleTarget) { this.areasToggleTarget.checked = true } } else { console.log('[Maps V2] Areas layer already visible') } } Toast.success('Area created successfully!') } catch (error) { Toast.error('Failed to reload areas') } } // Routes Manager methods togglePoints(event) { return this.routesManager.togglePoints(event) } toggleRoutes(event) { return this.routesManager.toggleRoutes(event) } toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) } toggleFog(event) { return this.routesManager.toggleFog(event) } toggleScratch(event) { return this.routesManager.toggleScratch(event) } togglePhotos(event) { return this.routesManager.togglePhotos(event) } toggleAreas(event) { return this.routesManager.toggleAreas(event) } toggleTracks(event) { return this.routesManager.toggleTracks(event) } toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) } openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() } handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) } toggleFamily(event) { return this.routesManager.toggleFamily(event) } // Family Members methods async loadFamilyMembers() { try { const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { Toast.info('Family feature not available') return } throw new Error(`HTTP error! status: ${response.status}`) } const data = await response.json() const locations = data.locations || [] // Update family layer with locations const familyLayer = this.layerManager.getLayer('family') if (familyLayer) { familyLayer.loadMembers(locations) } // Render family members list this.renderFamilyMembersList(locations) Toast.success(`Loaded ${locations.length} family member(s)`) } catch (error) { console.error('[Maps V2] Failed to load family members:', error) Toast.error('Failed to load family members') } } renderFamilyMembersList(locations) { if (!this.hasFamilyMembersContainerTarget) return const container = this.familyMembersContainerTarget if (locations.length === 0) { container.innerHTML = '
No family members sharing location
' return } container.innerHTML = locations.map(location => { const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?' const color = this.getFamilyMemberColor(location.user_id) const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: this.timezoneValue || 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) return `