import { Controller } from "@hotwired/stimulus"; import L from "leaflet"; import "leaflet.heat"; import consumer from "../channels/consumer"; import { createMarkersArray } from "../maps/markers"; import { LiveMapHandler } from "../maps/live_map_handler"; import { createPolylinesLayer, updatePolylinesOpacity, updatePolylinesColors, colorFormatEncode, colorFormatDecode, colorStopsFallback, reestablishPolylineEventHandlers, managePaneVisibility } from "../maps/polylines"; import { createTracksLayer, updateTracksOpacity, toggleTracksVisibility, filterTracks, trackColorPalette, handleIncrementalTrackUpdate, addOrUpdateTrack, removeTrackById, isTrackInTimeRange } from "../maps/tracks"; import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; import { showFlashMessage } from "../maps/helpers"; import { fetchAndDisplayPhotos } from "../maps/photos"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; import { ScratchLayer } from "../maps/scratch_layer"; import { LocationSearch } from "../maps/location_search"; import { PlacesManager } from "../maps/places"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; import { TileMonitor } from "../maps/tile_monitor"; import BaseController from "./base_controller"; import { createAllMapLayers } from "../maps/layers"; import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils"; import { addTopRightButtons } from "../maps/map_controls"; export default class extends BaseController { static targets = ["container"]; settingsButtonAdded = false; layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; tracksLayer = null; tracksVisible = false; tracksSubscription = null; connect() { super.connect(); console.log("Map controller connected"); this.apiKey = this.element.dataset.api_key; this.selfHosted = this.element.dataset.self_hosted; this.userTheme = this.element.dataset.user_theme || 'dark'; try { this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : []; } catch (error) { console.error('Error parsing coordinates data:', error); this.markers = []; } try { this.tracksData = this.element.dataset.tracks ? JSON.parse(this.element.dataset.tracks) : null; } catch (error) { console.error('Error parsing tracks data:', error); this.tracksData = null; } this.timezone = this.element.dataset.timezone; try { this.userSettings = this.element.dataset.user_settings ? JSON.parse(this.element.dataset.user_settings) : {}; } catch (error) { console.error('Error parsing user_settings data:', error); this.userSettings = {}; } try { this.features = this.element.dataset.features ? JSON.parse(this.element.dataset.features) : {}; } catch (error) { console.error('Error parsing features data:', error); this.features = {}; } this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50; this.fogLineThreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90; // Store route opacity as decimal (0-1) internally this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6; this.distanceUnit = this.userSettings.maps?.distance_unit || "km"; this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw"; this.liveMapEnabled = this.userSettings.live_map_enabled || false; this.countryCodesMap = countryCodesMap(); this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback); // Flag to prevent saving layers during initialization/restoration this.isRestoringLayers = false; // Ensure we have valid markers array if (!Array.isArray(this.markers)) { console.warn('Markers is not an array, setting to empty array'); this.markers = []; } // Set default center (Berlin) if no markers available this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : [52.514568, 13.350111]; this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14); // Add scale control this.scaleControl = L.control.scale({ position: 'bottomright', imperial: this.distanceUnit === 'mi', metric: this.distanceUnit === 'km', maxWidth: 120 }).addTo(this.map); // Add stats control const StatsControl = L.Control.extend({ options: { position: 'bottomright' }, onAdd: (map) => { const div = L.DomUtil.create('div', 'leaflet-control-stats'); let distance = parseInt(this.element.dataset.distance) || 0; const pointsNumber = this.element.dataset.points_number || '0'; // Convert distance to miles if user prefers miles (assuming backend sends km) if (this.distanceUnit === 'mi') { distance = distance * 0.621371; // km to miles conversion } const unit = this.distanceUnit === 'km' ? 'km' : 'mi'; div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; applyThemeToControl(div, this.userTheme, { padding: '0 5px', marginRight: '5px', display: 'inline-block' }); return div; } }); this.statsControl = new StatsControl().addTo(this.map); // Set the maximum bounds to prevent infinite scroll var southWest = L.latLng(-120, -210); var northEast = L.latLng(120, 210); var bounds = L.latLngBounds(southWest, northEast); this.map.setMaxBounds(bounds); this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey); this.markersLayer = L.layerGroup(this.markersArray); this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]); this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); // Initialize empty tracks layer for layer control (will be populated later) this.tracksLayer = L.layerGroup(); // Create a proper Leaflet layer for fog this.fogOverlay = new (createFogOverlay())(); // Create custom panes with proper z-index ordering // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 // Areas pane - below visits so they don't block interaction this.map.createPane('areasPane'); this.map.getPane('areasPane').style.zIndex = 605; // Above markerPane but below visits this.map.getPane('areasPane').style.pointerEvents = 'none'; // Don't block clicks, let them pass through // Legacy visits pane for backward compatibility this.map.createPane('visitsPane'); this.map.getPane('visitsPane').style.zIndex = 615; this.map.getPane('visitsPane').style.pointerEvents = 'auto'; // Suggested visits pane - interactive layer this.map.createPane('suggestedVisitsPane'); this.map.getPane('suggestedVisitsPane').style.zIndex = 610; this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'auto'; // Confirmed visits pane - on top of suggested, interactive this.map.createPane('confirmedVisitsPane'); this.map.getPane('confirmedVisitsPane').style.zIndex = 620; this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'auto'; // Initialize areasLayer as a feature group and add it to the map immediately this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); this.initializeScratchLayer(); if (!this.settingsButtonAdded) { this.addSettingsButton(); } // Add info toggle button this.addInfoToggleButton(); // Initialize the visits manager this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this); // Expose visits manager globally for location search integration window.visitsManager = this.visitsManager; // Initialize the places manager this.placesManager = new PlacesManager(this.map, this.apiKey); this.placesManager.initialize(); // Expose maps controller globally for family integration window.mapsController = this; // Initialize tile monitor this.tileMonitor = new TileMonitor(this.map, this.apiKey); this.addEventListeners(); this.setupSubscription(); this.setupTracksSubscription(); // Handle routes/tracks mode selection if (this.shouldShowTracksSelector()) { this.addRoutesTracksSelector(); } this.switchRouteMode('routes', true); // Initialize layers based on settings this.initializeLayersFromSettings(); // Listen for Family Members layer becoming ready this.setupFamilyLayerListener(); // Initialize tracks layer this.initializeTracksLayer(); // Setup draw control this.initializeDrawControl(); // Preload areas fetchAndDrawAreas(this.areasLayer, this.apiKey); // Add all top-right buttons in the correct order this.initializeTopRightButtons(); // Initialize layers for the layer control const controlsLayer = { Points: this.markersLayer, Routes: this.polylinesLayer, Tracks: this.tracksLayer, Heatmap: this.heatmapLayer, "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer, Photos: this.photoMarkers, "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), "Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer(), "Places": this.placesManager.placesLayer }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); // Initialize Live Map Handler this.initializeLiveMapHandler(); // Initialize Location Search this.initializeLocationSearch(); } disconnect() { super.disconnect(); this.removeEventListeners(); if (this.tracksSubscription) { this.tracksSubscription.unsubscribe(); } if (this.tileMonitor) { this.tileMonitor.destroy(); } if (this.visitsManager) { this.visitsManager.destroy(); } if (this.layerControl) { this.map.removeControl(this.layerControl); } if (this.map) { this.map.remove(); } console.log("Map controller disconnected"); } setupSubscription() { consumer.subscriptions.create("PointsChannel", { received: (data) => { // TODO: // Only append the point if its timestamp is within current // timespan if (this.map && this.map._loaded) { this.appendPoint(data); } } }); } setupTracksSubscription() { this.tracksSubscription = consumer.subscriptions.create("TracksChannel", { received: (data) => { console.log("Received track update:", data); if (this.map && this.map._loaded && this.tracksLayer) { this.handleTrackUpdate(data); } } }); } handleTrackUpdate(data) { // Get current time range for filtering const urlParams = new URLSearchParams(window.location.search); const currentStartAt = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const currentEndAt = urlParams.get('end_at') || new Date().toISOString(); // Handle the track update handleIncrementalTrackUpdate( this.tracksLayer, data, this.map, this.userSettings, this.distanceUnit, currentStartAt, currentEndAt ); // If tracks are visible, make sure the layer is properly displayed if (this.tracksVisible && this.tracksLayer) { if (!this.map.hasLayer(this.tracksLayer)) { this.map.addLayer(this.tracksLayer); } } } /** * Initialize the Live Map Handler */ initializeLiveMapHandler() { const layers = { markersLayer: this.markersLayer, polylinesLayer: this.polylinesLayer, heatmapLayer: this.heatmapLayer, fogOverlay: this.fogOverlay }; const options = { maxPoints: 1000, routeOpacity: this.routeOpacity, timezone: this.timezone, distanceUnit: this.distanceUnit, userSettings: this.userSettings, clearFogRadius: this.clearFogRadius, fogLineThreshold: this.fogLineThreshold, // Pass existing data to LiveMapHandler existingMarkers: this.markers || [], existingMarkersArray: this.markersArray || [], existingHeatmapMarkers: this.heatmapMarkers || [] }; this.liveMapHandler = new LiveMapHandler(this.map, layers, options); // Enable live map handler if live mode is already enabled if (this.liveMapEnabled) { this.liveMapHandler.enable(); } } /** * Delegate to LiveMapHandler for memory-efficient point appending */ appendPoint(data) { if (this.liveMapHandler && this.liveMapEnabled) { this.liveMapHandler.appendPoint(data); // Update scratch layer manager with new markers if (this.scratchLayerManager) { this.scratchLayerManager.updateMarkers(this.markers); } } else { console.warn('LiveMapHandler not initialized or live mode not enabled'); } } async initializeScratchLayer() { this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey); this.scratchLayer = await this.scratchLayerManager.setup(); } toggleScratchLayer() { if (this.scratchLayerManager) { this.scratchLayerManager.toggle(); } } baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; let maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted); // Add custom map if it exists in settings if (this.userSettings.maps && this.userSettings.maps.url) { const customLayer = L.tileLayer(this.userSettings.maps.url, { maxZoom: 19, attribution: "© OpenStreetMap contributors" }); // If this is the preferred layer, add it to the map immediately if (selectedLayerName === this.userSettings.maps.name) { // Remove any existing base layers first Object.values(maps).forEach(layer => { if (this.map.hasLayer(layer)) { this.map.removeLayer(layer); } }); customLayer.addTo(this.map); } maps[this.userSettings.maps.name] = customLayer; } else { // If no maps were created (fallback case), add OSM if (Object.keys(maps).length === 0) { console.warn('No map layers available, adding OSM fallback'); const osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }); osmLayer.addTo(this.map); maps["OpenStreetMap"] = osmLayer; } // Note: createAllMapLayers already added the user's preferred layer to the map } return maps; } removeEventListeners() { document.removeEventListener('click', this.handleDeleteClick); } addEventListeners() { // Create the handler only once and store it as an instance property if (!this.handleDeleteClick) { this.handleDeleteClick = (event) => { if (event.target && event.target.classList.contains('delete-point')) { event.preventDefault(); const pointId = event.target.getAttribute('data-id'); if (confirm('Are you sure you want to delete this point?')) { this.deletePoint(pointId, this.apiKey); } } }; // Add the listener only if it hasn't been added before document.addEventListener('click', this.handleDeleteClick); } // Add an event listener for base layer change in Leaflet this.map.on('baselayerchange', (event) => { const selectedLayerName = event.name; this.updatePreferredBaseLayer(selectedLayerName); }); // Add event listeners for overlay layer changes to keep routes/tracks selector in sync this.map.on('overlayadd', (event) => { // Save enabled layers whenever a layer is added (unless we're restoring from settings) if (!this.isRestoringLayers) { this.saveEnabledLayers(); } if (event.name === 'Routes') { this.handleRouteLayerToggle('routes'); // Re-establish event handlers when routes are manually added if (event.layer === this.polylinesLayer) { reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); } } else if (event.name === 'Tracks') { this.handleRouteLayerToggle('tracks'); } else if (event.name === 'Areas') { // Show draw control when Areas layer is enabled if (this.drawControl && !this.map.hasControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { this.map.addControl(this.drawControl); } } else if (event.name === 'Photos') { // Load photos when Photos layer is enabled console.log('Photos layer enabled via layer control'); const urlParams = new URLSearchParams(window.location.search); const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const endDate = urlParams.get('end_at') || new Date().toISOString(); console.log('Fetching photos for date range:', { startDate, endDate }); fetchAndDisplayPhotos({ map: this.map, photoMarkers: this.photoMarkers, apiKey: this.apiKey, startDate: startDate, endDate: endDate, userSettings: this.userSettings }); } else if (event.name === 'Suggested Visits' || event.name === 'Confirmed Visits') { // Load visits when layer is enabled console.log(`${event.name} layer enabled via layer control`); if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { // Fetch and populate the visits - this will create circles and update drawer if open this.visitsManager.fetchAndDisplayVisits(); } } else if (event.name === 'Scratch map') { // Add scratch map layer console.log('Scratch map layer enabled via layer control'); if (this.scratchLayerManager) { this.scratchLayerManager.addToMap(); } } else if (event.name === 'Fog of War') { // Enable fog of war when layer is added this.fogOverlay = event.layer; if (this.markers && this.markers.length > 0) { this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); } } // Manage pane visibility when layers are manually toggled this.updatePaneVisibilityAfterLayerChange(); }); this.map.on('overlayremove', (event) => { // Save enabled layers whenever a layer is removed (unless we're restoring from settings) if (!this.isRestoringLayers) { this.saveEnabledLayers(); } if (event.name === 'Routes' || event.name === 'Tracks') { // Don't auto-switch when layers are manually turned off // Just update the radio button state to reflect current visibility this.updateRadioButtonState(); // Manage pane visibility when layers are manually toggled this.updatePaneVisibilityAfterLayerChange(); } else if (event.name === 'Areas') { // Hide draw control when Areas layer is disabled if (this.drawControl && this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { this.map.removeControl(this.drawControl); } } else if (event.name === 'Suggested Visits') { // Clear suggested visits when layer is disabled console.log('Suggested Visits layer disabled via layer control'); if (this.visitsManager) { // Clear the visit circles when layer is disabled this.visitsManager.visitCircles.clearLayers(); } } else if (event.name === 'Scratch map') { // Handle scratch map layer removal console.log('Scratch map layer disabled via layer control'); if (this.scratchLayerManager) { this.scratchLayerManager.remove(); } } else if (event.name === 'Fog of War') { // Fog canvas will be automatically removed by the layer's onRemove method this.fogOverlay = null; } }); } updatePreferredBaseLayer(selectedLayerName) { fetch('/api/v1/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ settings: { preferred_map_layer: selectedLayerName }, }), }) .then((response) => response.json()) .then((data) => { if (data.status === 'success') { showFlashMessage('notice', `Preferred map layer updated to: ${selectedLayerName}`); } else { showFlashMessage('error', data.message); } }); } saveEnabledLayers() { const enabledLayers = []; const layerNames = [ 'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War', 'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits', 'Family Members' ]; const controlsLayer = { 'Points': this.markersLayer, 'Routes': this.polylinesLayer, 'Tracks': this.tracksLayer, 'Heatmap': this.heatmapLayer, 'Fog of War': this.fogOverlay, 'Scratch map': this.scratchLayerManager?.getLayer(), 'Areas': this.areasLayer, 'Photos': this.photoMarkers, 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(), 'Family Members': window.familyMembersController?.familyMarkersLayer }; layerNames.forEach(name => { const layer = controlsLayer[name]; if (layer && this.map.hasLayer(layer)) { enabledLayers.push(name); } }); fetch('/api/v1/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ settings: { enabled_map_layers: enabledLayers }, }), }) .then((response) => response.json()) .then((data) => { if (data.status === 'success') { console.log('Enabled layers saved:', enabledLayers); showFlashMessage('notice', 'Map layer preferences saved'); } else { console.error('Failed to save enabled layers:', data.message); showFlashMessage('error', `Failed to save layer preferences: ${data.message}`); } }) .catch(error => { console.error('Error saving enabled layers:', error); showFlashMessage('error', 'Error saving layer preferences'); }); } deletePoint(id, apiKey) { fetch(`/api/v1/points/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` } }) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { // Remove the marker and update all layers this.removeMarker(id); let wasPolyLayerVisible = false; // Explicitly remove old polylines layer from map if (this.polylinesLayer) { if (this.map.hasLayer(this.polylinesLayer)) { wasPolyLayerVisible = true; } this.map.removeLayer(this.polylinesLayer); } // Create new polylines layer this.polylinesLayer = createPolylinesLayer( this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit ); if (wasPolyLayerVisible) { // Add new polylines layer to map and to layer control this.polylinesLayer.addTo(this.map); } else { this.map.removeLayer(this.polylinesLayer); } // Update the layer control if (this.layerControl) { this.map.removeControl(this.layerControl); const controlsLayer = { Points: this.markersLayer || L.layerGroup(), Routes: this.polylinesLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.layerGroup(), "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); } // Update heatmap this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2])); // Update fog if enabled if (this.map.hasLayer(this.fogOverlay)) { this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); } // Show success message showFlashMessage('notice', 'Point deleted successfully'); }) .catch(error => { console.error('There was a problem with the delete request:', error); showFlashMessage('error', 'Failed to delete point'); }); } removeMarker(id) { const numericId = parseInt(id); const markerIndex = this.markersArray.findIndex(marker => marker.getPopup().getContent().includes(`data-id="${id}"`) ); if (markerIndex !== -1) { this.markersArray[markerIndex].remove(); this.markersArray.splice(markerIndex, 1); this.markersLayer.clearLayers(); this.markersLayer.addLayer(L.layerGroup(this.markersArray)); this.markers = this.markers.filter(marker => { const markerId = parseInt(marker[6]); return markerId !== numericId; }); // Update scratch layer manager with updated markers if (this.scratchLayerManager) { this.scratchLayerManager.updateMarkers(this.markers); } } } updateFog(markers, clearFogRadius, fogLineThreshold) { // Call the fog overlay's updateFog method if it exists if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') { this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold); } else { // Fallback for when fog overlay isn't available const fog = document.getElementById('fog'); if (!fog) { initializeFogCanvas(this.map); } requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); } } initializeDrawControl() { // Initialize the FeatureGroup to store editable layers this.drawnItems = new L.FeatureGroup(); this.map.addLayer(this.drawnItems); // Initialize the draw control and pass it the FeatureGroup of editable layers this.drawControl = new L.Control.Draw({ draw: { polyline: false, polygon: false, rectangle: false, marker: false, circlemarker: false, circle: { shapeOptions: { color: 'red', fillColor: '#f03', fillOpacity: 0.5, }, }, } }); // Handle circle creation this.map.on('draw:created', (event) => { const layer = event.layer; if (event.layerType === 'circle') { try { // Add the layer to the map first layer.addTo(this.map); handleAreaCreated(this.areasLayer, layer, this.apiKey); } catch (error) { console.error("Error in handleAreaCreated:", error); console.error(error.stack); // Add stack trace } } }); } addSettingsButton() { if (this.settingsButtonAdded) return; // Define the custom control const SettingsControl = L.Control.extend({ onAdd: (map) => { const button = L.DomUtil.create('button', 'map-settings-button tooltip tooltip-right'); button.innerHTML = ''; // Gear icon button.setAttribute('data-tip', 'Settings'); // Style the button with theme-aware styling applyThemeToButton(button, this.userTheme); button.style.width = '30px'; button.style.height = '30px'; button.style.display = 'flex'; button.style.alignItems = 'center'; button.style.justifyContent = 'center'; button.style.padding = '0'; button.style.borderRadius = '4px'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); // Toggle settings menu on button click L.DomEvent.on(button, 'click', () => { this.toggleSettingsMenu(); }); return button; } }); // Add the control to the map this.map.addControl(new SettingsControl({ position: 'topleft' })); this.settingsButtonAdded = true; } addInfoToggleButton() { // Store reference to the controller instance for use in the control const controller = this; const InfoToggleControl = L.Control.extend({ options: { position: 'bottomleft' }, onAdd: function(map) { const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); const button = L.DomUtil.create('button', 'map-info-toggle-button tooltip tooltip-right', container); button.setAttribute('data-tip', 'Toggle footer visibility'); // Lucide info icon button.innerHTML = ` `; // Style the button with theme-aware styling applyThemeToButton(button, controller.userTheme); button.style.width = '34px'; button.style.height = '34px'; button.style.display = 'flex'; button.style.alignItems = 'center'; button.style.justifyContent = 'center'; button.style.cursor = 'pointer'; button.style.border = 'none'; button.style.borderRadius = '4px'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(container); // Toggle footer visibility on button click L.DomEvent.on(button, 'click', () => { controller.toggleFooterVisibility(); }); return container; } }); // Add the control to the map this.map.addControl(new InfoToggleControl()); } toggleFooterVisibility() { // Toggle the page footer const footer = document.getElementById('map-footer'); if (!footer) return; const isCurrentlyHidden = footer.classList.contains('hidden'); // Toggle Tailwind's hidden class footer.classList.toggle('hidden'); // Adjust bottom controls position based on footer visibility if (isCurrentlyHidden) { // Footer is being shown - move controls up setTimeout(() => { const footerHeight = footer.offsetHeight; // Add extra 20px margin above footer this.adjustBottomControls(footerHeight + 20); }, 10); // Small delay to ensure footer is rendered } else { // Footer is being hidden - reset controls position this.adjustBottomControls(10); // Back to default padding } // Add click event to close footer when clicking on it (only add once) if (!footer.dataset.clickHandlerAdded) { footer.addEventListener('click', (e) => { // Only close if clicking the footer itself, not its contents if (e.target === footer) { footer.classList.add('hidden'); this.adjustBottomControls(10); // Reset controls position } }); footer.dataset.clickHandlerAdded = 'true'; } } adjustBottomControls(paddingBottom) { // Adjust all bottom Leaflet controls const bottomLeftControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-left'); const bottomRightControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-right'); if (bottomLeftControls) { bottomLeftControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); } if (bottomRightControls) { bottomRightControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); } } toggleSettingsMenu() { // If the settings panel already exists, just show/hide it if (this.settingsPanel) { if (this.settingsPanel._map) { this.map.removeControl(this.settingsPanel); } else { this.map.addControl(this.settingsPanel); } return; } // Create the settings panel for the first time this.settingsPanel = L.control({ position: 'topleft' }); this.settingsPanel.onAdd = () => { const div = L.DomUtil.create('div', 'leaflet-settings-panel'); // Form HTML div.innerHTML = `
`; // Style the panel with theme-aware styling applyThemeToPanel(div, this.userTheme); div.style.padding = '10px'; div.style.width = '220px'; div.style.maxHeight = 'calc(60vh - 20px)'; div.style.overflowY = 'auto'; // Prevent map interactions when interacting with the form L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); // Attach event listener to the "Edit Gradient" button: const editBtn = div.querySelector("#edit-gradient-btn"); if (editBtn) { editBtn.addEventListener("click", this.showGradientEditor.bind(this)); } // Add event listener to the form submission div.querySelector('#settings-form').addEventListener( 'submit', this.updateSettings.bind(this) ); return div; }; this.map.addControl(this.settingsPanel); } pointsRenderingModeChecked(value) { if (value === this.pointsRenderingMode) { return 'checked'; } else { return ''; } } liveMapEnabledChecked(value) { if (value === this.liveMapEnabled) { return 'checked'; } else { return ''; } } speedColoredRoutesChecked() { return this.userSettings.speed_colored_routes ? 'checked' : ''; } updateSettings(event) { event.preventDefault(); console.log('Form submitted'); // Convert percentage to decimal for route_opacity const opacityValue = event.target.route_opacity.value.replace('%', ''); const decimalOpacity = parseFloat(opacityValue) / 100; fetch('/api/v1/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ settings: { route_opacity: decimalOpacity.toString(), fog_of_war_meters: event.target.fog_of_war_meters.value, fog_of_war_threshold: event.target.fog_of_war_threshold.value, meters_between_routes: event.target.meters_between_routes.value, minutes_between_routes: event.target.minutes_between_routes.value, time_threshold_minutes: event.target.time_threshold_minutes.value, merge_threshold_minutes: event.target.merge_threshold_minutes.value, points_rendering_mode: event.target.points_rendering_mode.value, live_map_enabled: event.target.live_map_enabled.checked, speed_colored_routes: event.target.speed_colored_routes.checked, speed_color_scale: event.target.speed_color_scale.value }, }), }) .then((response) => response.json()) .then((data) => { console.log('Settings update response:', data); if (data.status === 'success') { showFlashMessage('notice', data.message); this.updateMapWithNewSettings(data.settings); if (data.settings.live_map_enabled) { this.setupSubscription(); if (this.liveMapHandler) { this.liveMapHandler.enable(); } } else { if (this.liveMapHandler) { this.liveMapHandler.disable(); } } } else { showFlashMessage('error', data.message); } }) .catch(error => { console.error('Settings update error:', error); showFlashMessage('error', 'Failed to update settings'); }); } updateMapWithNewSettings(newSettings) { // Show loading indicator const loadingDiv = document.createElement('div'); loadingDiv.className = 'map-loading-overlay'; loadingDiv.innerHTML = 'Loading visited places...
Error loading visited places
'; } } } displayVisitedCities(citiesData) { const container = document.getElementById('visited-cities-list'); if (!container) return; if (!citiesData || citiesData.length === 0) { container.innerHTML = 'No places visited during this period
'; return; } const html = citiesData.map(country => `