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 = '
Updating map...
'; document.body.appendChild(loadingDiv); try { // Update settings first if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { if (this.polylinesLayer) { updatePolylinesColors( this.polylinesLayer, newSettings.speed_colored_routes, newSettings.speed_color_scale ); } } if (newSettings.speed_color_scale !== this.userSettings.speed_color_scale) { if (this.polylinesLayer) { updatePolylinesColors( this.polylinesLayer, newSettings.speed_colored_routes, newSettings.speed_color_scale ); } } if (newSettings.route_opacity !== this.userSettings.route_opacity) { const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; if (this.polylinesLayer) { updatePolylinesOpacity(this.polylinesLayer, newOpacity); } } // Update the local settings this.userSettings = { ...this.userSettings, ...newSettings }; // Store the value as decimal internally, but display as percentage in UI this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; this.liveMapEnabled = newSettings.live_map_enabled || false; // Update the DOM data attribute to keep it in sync const mapElement = document.getElementById('map'); if (mapElement) { mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings)); // Update theme if it changed if (newSettings.theme && newSettings.theme !== this.userTheme) { this.userTheme = newSettings.theme; mapElement.setAttribute('data-user_theme', this.userTheme); // Dispatch theme change event for other controllers document.dispatchEvent(new CustomEvent('theme:changed', { detail: { theme: this.userTheme } })); } } // Store current layer states const layerStates = { Points: this.map.hasLayer(this.markersLayer), Routes: this.map.hasLayer(this.polylinesLayer), Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false, Heatmap: this.map.hasLayer(this.heatmapLayer), "Fog of War": this.map.hasLayer(this.fogOverlay), "Scratch map": this.scratchLayerManager?.isVisible() || false, Areas: this.map.hasLayer(this.areasLayer), Photos: this.map.hasLayer(this.photoMarkers) }; // Remove only the layer control if (this.layerControl) { this.map.removeControl(this.layerControl); } // Create new controls layer object const controlsLayer = { Points: this.markersLayer || L.layerGroup(), Routes: this.polylinesLayer || L.layerGroup(), Tracks: this.tracksLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.heatLayer([]), "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() }; // Re-add the layer control in the same position this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); // Restore layer visibility states Object.entries(layerStates).forEach(([name, wasVisible]) => { const layer = controlsLayer[name]; if (wasVisible && layer) { layer.addTo(this.map); // Re-establish event handlers for polylines layer when it's re-added if (name === 'Routes' && layer === this.polylinesLayer) { reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); } } else if (layer && this.map.hasLayer(layer)) { this.map.removeLayer(layer); } }); // Manage pane visibility based on which layers are visible const routesVisible = this.map.hasLayer(this.polylinesLayer); const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); if (routesVisible && !tracksVisible) { managePaneVisibility(this.map, 'routes'); } else if (tracksVisible && !routesVisible) { managePaneVisibility(this.map, 'tracks'); } else { managePaneVisibility(this.map, 'both'); } } catch (error) { console.error('Error updating map settings:', error); console.error(error.stack); } finally { // Remove loading indicator setTimeout(() => { document.body.removeChild(loadingDiv); }, 500); } } initializeTopRightButtons() { // Add all top-right buttons in the correct order: // 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer // Note: Layer control is added separately and appears at the top this.topRightControls = addTopRightButtons( this.map, { onSelectArea: () => this.visitsManager.toggleSelectionMode(), // onAddVisit is intentionally null - the add_visit_controller will attach its handler onAddVisit: null, onToggleCalendar: () => this.toggleRightPanel(), onToggleDrawer: () => this.visitsManager.toggleDrawer() }, this.userTheme ); // Add CSS for selection button active state (needed by visits manager) if (!document.getElementById('selection-tool-active-style')) { const style = document.createElement('style'); style.id = 'selection-tool-active-style'; style.textContent = ` #selection-tool-button.active { border: 2px dashed #3388ff !important; box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important; } `; document.head.appendChild(style); } } shouldShowTracksSelector() { const urlParams = new URLSearchParams(window.location.search); return urlParams.get('tracks_debug') === 'true'; } addRoutesTracksSelector() { // Store reference to the controller instance for use in the control const controller = this; const RouteTracksControl = L.Control.extend({ onAdd: function(map) { const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar'); applyThemeToControl(container, controller.userTheme, { padding: '8px', borderRadius: '4px', fontSize: '12px', lineHeight: '1.2' }); // Get saved preference or default to 'routes' const savedPreference = localStorage.getItem('mapRouteMode') || 'routes'; container.innerHTML = `
Display
`; // Disable map interactions when clicking the control L.DomEvent.disableClickPropagation(container); // Add change event listeners const radioButtons = container.querySelectorAll('input[name="route-mode"]'); radioButtons.forEach(radio => { L.DomEvent.on(radio, 'change', () => { if (radio.checked) { controller.switchRouteMode(radio.value); } }); }); return container; } }); // Add the control to the map this.map.addControl(new RouteTracksControl({ position: 'topleft' })); // Apply initial state based on saved preference const savedPreference = localStorage.getItem('mapRouteMode') || 'routes'; this.switchRouteMode(savedPreference, true); // Set initial pane visibility this.updatePaneVisibilityAfterLayerChange(); } switchRouteMode(mode, isInitial = false) { // Save preference to localStorage localStorage.setItem('mapRouteMode', mode); if (mode === 'routes') { // Hide tracks layer if it exists and is visible if (this.tracksLayer && this.map.hasLayer(this.tracksLayer)) { this.map.removeLayer(this.tracksLayer); } // Show routes layer if it exists and is not visible if (this.polylinesLayer && !this.map.hasLayer(this.polylinesLayer)) { this.map.addLayer(this.polylinesLayer); // Re-establish event handlers after adding the layer back reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); } else if (this.polylinesLayer) { reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); } // Manage pane visibility to fix z-index blocking managePaneVisibility(this.map, 'routes'); // Update layer control checkboxes this.updateLayerControlCheckboxes('Routes', true); this.updateLayerControlCheckboxes('Tracks', false); } else if (mode === 'tracks') { // Hide routes layer if it exists and is visible if (this.polylinesLayer && this.map.hasLayer(this.polylinesLayer)) { this.map.removeLayer(this.polylinesLayer); } // Show tracks layer if it exists and is not visible if (this.tracksLayer && !this.map.hasLayer(this.tracksLayer)) { this.map.addLayer(this.tracksLayer); } // Manage pane visibility to fix z-index blocking managePaneVisibility(this.map, 'tracks'); // Update layer control checkboxes this.updateLayerControlCheckboxes('Routes', false); this.updateLayerControlCheckboxes('Tracks', true); } } updateLayerControlCheckboxes(layerName, isVisible) { // Find the layer control input for the specified layer const layerControlContainer = document.querySelector('.leaflet-control-layers'); if (!layerControlContainer) return; const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]'); inputs.forEach(input => { const label = input.nextElementSibling; if (label && label.textContent.trim() === layerName) { input.checked = isVisible; } }); } handleRouteLayerToggle(mode) { // Update the radio button selection const radioButtons = document.querySelectorAll('input[name="route-mode"]'); radioButtons.forEach(radio => { if (radio.value === mode) { radio.checked = true; } }); // Switch to the selected mode and enforce mutual exclusivity this.switchRouteMode(mode); } updateRadioButtonState() { // Update radio buttons to reflect current layer visibility const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer); const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); const radioButtons = document.querySelectorAll('input[name="route-mode"]'); radioButtons.forEach(radio => { if (radio.value === 'routes' && routesVisible && !tracksVisible) { radio.checked = true; } else if (radio.value === 'tracks' && tracksVisible && !routesVisible) { radio.checked = true; } }); } updatePaneVisibilityAfterLayerChange() { // Update pane visibility based on current layer visibility const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer); const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); if (routesVisible && !tracksVisible) { managePaneVisibility(this.map, 'routes'); } else if (tracksVisible && !routesVisible) { managePaneVisibility(this.map, 'tracks'); } else { managePaneVisibility(this.map, 'both'); } } initializeLayersFromSettings() { // Initialize layer visibility based on user settings or defaults // This method sets up the initial state of overlay layers // Get enabled layers from user settings const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap']; console.log('Initializing layers from settings:', enabledLayers); 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 }; // Apply saved layer preferences Object.entries(controlsLayer).forEach(([name, layer]) => { if (!layer) { if (enabledLayers.includes(name)) { console.log(`Layer ${name} is in enabled layers but layer object is null/undefined`); } return; } const shouldBeEnabled = enabledLayers.includes(name); const isCurrentlyEnabled = this.map.hasLayer(layer); if (name === 'Family Members') { console.log('Family Members layer check:', { shouldBeEnabled, isCurrentlyEnabled, layerExists: !!layer, controllerExists: !!window.familyMembersController }); } if (shouldBeEnabled && !isCurrentlyEnabled) { // Add layer to map layer.addTo(this.map); console.log(`Enabled layer: ${name}`); // Trigger special initialization for certain layers if (name === 'Photos') { 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(); fetchAndDisplayPhotos({ map: this.map, photoMarkers: this.photoMarkers, apiKey: this.apiKey, startDate: startDate, endDate: endDate, userSettings: this.userSettings }); } else if (name === 'Fog of War') { this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); } else if (name === 'Suggested Visits' || name === 'Confirmed Visits') { if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { this.visitsManager.fetchAndDisplayVisits(); } } else if (name === 'Scratch map') { if (this.scratchLayerManager) { this.scratchLayerManager.addToMap(); } } else if (name === 'Routes') { // Re-establish event handlers for routes layer reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); } else if (name === 'Areas') { // Show draw control when Areas layer is enabled if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { this.map.addControl(this.drawControl); } } else if (name === 'Family Members') { // Refresh family locations when layer is restored if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { window.familyMembersController.refreshFamilyLocations(); } } } else if (!shouldBeEnabled && isCurrentlyEnabled) { // Remove layer from map this.map.removeLayer(layer); console.log(`Disabled layer: ${name}`); } }); } setupFamilyLayerListener() { // Listen for when the Family Members layer becomes available document.addEventListener('family:layer:ready', (event) => { console.log('Family layer ready event received'); const enabledLayers = this.userSettings.enabled_map_layers || []; // Check if Family Members should be enabled based on saved settings if (enabledLayers.includes('Family Members')) { const layer = event.detail.layer; if (layer && !this.map.hasLayer(layer)) { // Set flag to prevent saving during restoration this.isRestoringLayers = true; layer.addTo(this.map); console.log('Enabled layer: Family Members (from ready event)'); // Refresh family locations if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { window.familyMembersController.refreshFamilyLocations(); } // Reset flag after a short delay to allow all events to complete setTimeout(() => { this.isRestoringLayers = false; }, 100); } } }, { once: true }); // Only listen once } toggleRightPanel() { if (this.rightPanel) { const panel = document.querySelector('.leaflet-right-panel'); if (panel) { if (panel.style.display === 'none') { panel.style.display = 'block'; localStorage.setItem('mapPanelOpen', 'true'); } else { panel.style.display = 'none'; localStorage.setItem('mapPanelOpen', 'false'); } return; } } this.rightPanel = L.control({ position: 'topright' }); this.rightPanel.onAdd = () => { const div = L.DomUtil.create('div', 'leaflet-right-panel'); const allMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; // Get current date from URL query parameters const urlParams = new URLSearchParams(window.location.search); const startDate = urlParams.get('start_at'); const currentYear = startDate ? new Date(startDate).getFullYear().toString() : new Date().getFullYear().toString(); const currentMonth = startDate ? allMonths[new Date(startDate).getMonth()] : allMonths[new Date().getMonth()]; // Initially create select with loading state and current year if available div.innerHTML = `
Whole year
${allMonths.map(month => ` `).join('')}
`; this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths); applyThemeToPanel(div, this.userTheme); div.style.padding = '10px'; div.style.marginRight = '10px'; div.style.marginTop = '10px'; div.style.width = '300px'; div.style.maxHeight = '80vh'; div.style.overflowY = 'auto'; L.DomEvent.disableClickPropagation(div); // Add container for visited cities div.innerHTML += `

Visited cities

Loading visited places...

`; // Prevent map zoom when scrolling the cities list const citiesList = div.querySelector('#visited-cities-list'); L.DomEvent.disableScrollPropagation(citiesList); // Fetch visited cities when panel is first created this.fetchAndDisplayVisitedCities(); // Since user clicked to open panel, make it visible and update localStorage div.style.display = 'block'; localStorage.setItem('mapPanelOpen', 'true'); return div; }; this.map.addControl(this.rightPanel); } async fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths) { try { let yearsData; // Check cache first if (this.trackedMonthsCache) { yearsData = this.trackedMonthsCache; } else { const response = await fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } yearsData = await response.json(); // Store in cache this.trackedMonthsCache = yearsData; } const yearSelect = document.getElementById('year-select'); if (!Array.isArray(yearsData) || yearsData.length === 0) { yearSelect.innerHTML = ''; return; } // Check if the current year exists in the API response const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear); const options = yearsData .filter(yearData => yearData && yearData.year) .map(yearData => { const months = Array.isArray(yearData.months) ? yearData.months : []; const isCurrentYear = yearData.year.toString() === currentYear; return ` `; }) .join(''); yearSelect.innerHTML = ` ${options} `; const updateMonthLinks = (selectedYear, availableMonths) => { // Get current date from URL parameters const urlParams = new URLSearchParams(window.location.search); const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date(); const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date(); allMonths.forEach((month, index) => { const monthLink = div.querySelector(`a[data-month-name="${month}"]`); if (!monthLink) return; // Update the content to show the month name instead of loading dots monthLink.innerHTML = month; // Check if this month falls within the selected date range const isSelected = startDate && endDate && selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year isMonthInRange(index, startDate, endDate, parseInt(selectedYear)); if (availableMonths.includes(month)) { monthLink.classList.remove('disabled'); monthLink.style.pointerEvents = 'auto'; monthLink.style.opacity = '1'; // Update the active state based on selection if (isSelected) { monthLink.classList.add('btn-active', 'btn-primary'); } else { monthLink.classList.remove('btn-active', 'btn-primary'); } const monthNum = (index + 1).toString().padStart(2, '0'); const startDate = `${selectedYear}-${monthNum}-01T00:00`; const lastDay = new Date(selectedYear, index + 1, 0).getDate(); const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`; const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; monthLink.setAttribute('href', href); } else { monthLink.classList.add('disabled'); monthLink.classList.remove('btn-active', 'btn-primary'); monthLink.style.pointerEvents = 'none'; monthLink.style.opacity = '0.6'; monthLink.setAttribute('href', '#'); } }); }; // Helper function to check if a month falls within a date range const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => { // Create date objects for the first and last day of the month in the selected year const monthStart = new Date(selectedYear, monthIndex, 1); const monthEnd = new Date(selectedYear, monthIndex + 1, 0); // Check if any part of the month overlaps with the selected date range return monthStart <= endDate && monthEnd >= startDate; }; yearSelect.addEventListener('change', (event) => { const selectedOption = event.target.selectedOptions[0]; const selectedYear = selectedOption.value; const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); // Update whole year link with selected year const wholeYearLink = document.getElementById('whole-year-link'); const startDate = `${selectedYear}-01-01T00:00`; const endDate = `${selectedYear}-12-31T23:59`; const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; wholeYearLink.setAttribute('href', href); updateMonthLinks(selectedYear, availableMonths); }); // If we have a current year, set it and update month links if (currentYear && currentYearData) { yearSelect.value = currentYear; updateMonthLinks(currentYear, currentYearData.months); } } catch (error) { const yearSelect = document.getElementById('year-select'); yearSelect.innerHTML = ''; console.error('Error fetching tracked months:', error); } } getWholeYearLink() { // First try to get year from URL parameters const urlParams = new URLSearchParams(window.location.search); let year; if (urlParams.has('start_at')) { year = new Date(urlParams.get('start_at')).getFullYear(); } else { // If no URL params, try to get year from start_at input const startAtInput = document.querySelector('input#start_at'); if (startAtInput && startAtInput.value) { year = new Date(startAtInput.value).getFullYear(); } else { // If no input value, use current year year = new Date().getFullYear(); } } const startDate = `${year}-01-01T00:00`; const endDate = `${year}-12-31T23:59`; return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; } async fetchAndDisplayVisitedCities() { const urlParams = new URLSearchParams(window.location.search); const startAt = urlParams.get('start_at') || new Date().toISOString(); const endAt = urlParams.get('end_at') || new Date().toISOString(); // Create a cache key from the date range const cacheKey = `${startAt}-${endAt}`; // Check if we have cached data for this date range if (this.visitedCitiesCache.has(cacheKey)) { this.displayVisitedCities(this.visitedCitiesCache.get(cacheKey)); return; } try { const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', } }); if (!response.ok) { throw new Error('Network response was not ok'); } const data = await response.json(); // Cache the results this.visitedCitiesCache.set(cacheKey, data.data); this.displayVisitedCities(data.data); } catch (error) { console.error('Error fetching visited cities:', error); const container = document.getElementById('visited-cities-list'); if (container) { container.innerHTML = '

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 => `

${country.country}

`).join(''); container.innerHTML = html; } showGradientEditor() { const modal = document.createElement("div"); modal.id = "gradient-editor-modal"; Object.assign(modal.style, { position: "fixed", top: "0", left: "0", right: "0", bottom: "0", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: "100", }); const content = document.createElement("div"); Object.assign(content.style, { backgroundColor: "#fff", padding: "20px", borderRadius: "5px", minWidth: "300px", maxHeight: "80vh", display: "flex", flexDirection: "column", }); const title = document.createElement("h2"); title.textContent = "Edit Speed Color Scale"; content.appendChild(title); const gradientContainer = document.createElement("div"); gradientContainer.id = "gradient-editor-container"; Object.assign(gradientContainer.style, { marginTop: "15px", overflowY: "auto", flex: "1", border: "1px solid #ccc", padding: "5px", }); const createRow = (stop = { speed: 0, color: "#000000" }) => { const row = document.createElement("div"); row.style.display = "flex"; row.style.alignItems = "center"; row.style.gap = "10px"; row.style.marginBottom = "8px"; const speedInput = document.createElement("input"); speedInput.type = "number"; speedInput.value = stop.speed; speedInput.style.width = "70px"; const colorInput = document.createElement("input"); colorInput.type = "color"; colorInput.value = stop.color; colorInput.style.width = "70px"; const removeBtn = document.createElement("button"); removeBtn.textContent = "x"; removeBtn.style.color = "#cc3311"; removeBtn.style.flexShrink = "0"; removeBtn.addEventListener("click", () => { if (gradientContainer.childElementCount > 1) { gradientContainer.removeChild(row); } else { showFlashMessage('error', 'At least one gradient stop is required.'); } }); row.appendChild(speedInput); row.appendChild(colorInput); row.appendChild(removeBtn); return row; }; let stops; try { stops = colorFormatDecode(this.speedColorScale); } catch (error) { stops = colorStopsFallback; } stops.forEach(stop => { const row = createRow(stop); gradientContainer.appendChild(row); }); content.appendChild(gradientContainer); const addRowBtn = document.createElement("button"); addRowBtn.textContent = "Add Row"; addRowBtn.style.marginTop = "10px"; addRowBtn.addEventListener("click", () => { const newRow = createRow({ speed: 0, color: "#000000" }); gradientContainer.appendChild(newRow); }); content.appendChild(addRowBtn); const btnContainer = document.createElement("div"); btnContainer.style.display = "flex"; btnContainer.style.justifyContent = "flex-end"; btnContainer.style.gap = "10px"; btnContainer.style.marginTop = "15px"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel"; cancelBtn.addEventListener("click", () => { document.body.removeChild(modal); }); const saveBtn = document.createElement("button"); saveBtn.textContent = "Save"; saveBtn.addEventListener("click", () => { const newStops = []; gradientContainer.querySelectorAll("div").forEach(row => { const inputs = row.querySelectorAll("input"); const speed = Number(inputs[0].value); const color = inputs[1].value; newStops.push({ speed, color }); }); const newGradient = colorFormatEncode(newStops); this.speedColorScale = newGradient; const speedColorScaleInput = document.getElementById("speed_color_scale"); if (speedColorScaleInput) { speedColorScaleInput.value = newGradient; } document.body.removeChild(modal); }); btnContainer.appendChild(cancelBtn); btnContainer.appendChild(saveBtn); content.appendChild(btnContainer); modal.appendChild(content); document.body.appendChild(modal); } // Track-related methods async initializeTracksLayer() { // Use pre-loaded tracks data if available if (this.tracksData && this.tracksData.length > 0) { this.createTracksFromData(this.tracksData); } else { // Create empty layer for layer control this.tracksLayer = L.layerGroup(); } } createTracksFromData(tracksData) { // Clear existing tracks this.tracksLayer.clearLayers(); if (!tracksData || tracksData.length === 0) { return; } // Create tracks layer with data and add to existing tracks layer const newTracksLayer = createTracksLayer( tracksData, this.map, this.userSettings, this.distanceUnit ); // Add all tracks to the existing tracks layer newTracksLayer.eachLayer((layer) => { this.tracksLayer.addLayer(layer); }); } toggleTracksVisibility(event) { this.tracksVisible = event.target.checked; if (this.tracksLayer) { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } initializeLocationSearch() { if (this.map && this.apiKey && this.features.reverse_geocoding) { this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme); } } // Helper method for family controller to update layer control updateLayerControl(additionalLayers = {}) { if (!this.layerControl) return; // Store which base and overlay layers are currently visible const overlayStates = {}; let activeBaseLayer = null; let activeBaseLayerName = null; if (this.layerControl._layers) { Object.values(this.layerControl._layers).forEach(layerObj => { if (layerObj.overlay && layerObj.layer) { // Store overlay layer states overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer); } else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) { // Store the currently active base layer activeBaseLayer = layerObj.layer; activeBaseLayerName = layerObj.name; } }); } // Remove existing layer control this.map.removeControl(this.layerControl); // Create base controls layer object const baseControlsLayer = { Points: this.markersLayer || L.layerGroup(), Routes: this.polylinesLayer || L.layerGroup(), Tracks: this.tracksLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.heatLayer([]), "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup(), "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() }; // Merge with additional layers (like family members) const controlsLayer = { ...baseControlsLayer, ...additionalLayers }; // Get base maps and re-add the layer control const baseMaps = this.baseMaps(); this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map); // Restore the active base layer if we had one if (activeBaseLayer && activeBaseLayerName) { console.log(`Restoring base layer: ${activeBaseLayerName}`); // Make sure the base layer is added to the map if (!this.map.hasLayer(activeBaseLayer)) { activeBaseLayer.addTo(this.map); } } else { // If no active base layer was found, ensure we have a default one console.log('No active base layer found, adding default'); const defaultBaseLayer = Object.values(baseMaps)[0]; if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) { defaultBaseLayer.addTo(this.map); } } // Restore overlay layer visibility states Object.entries(overlayStates).forEach(([name, wasVisible]) => { const layer = controlsLayer[name]; if (layer && wasVisible && !this.map.hasLayer(layer)) { layer.addTo(this.map); } }); } togglePlaceCreationMode() { if (!this.placesManager) { console.warn("Places manager not initialized"); return; } const button = document.getElementById('create-place-btn'); if (this.placesManager.creationMode) { // Disable creation mode this.placesManager.disableCreationMode(); if (button) { button.classList.remove('btn-error'); button.classList.add('btn-success'); button.title = 'Click to create a place on the map'; } } else { // Enable creation mode this.placesManager.enableCreationMode(); if (button) { button.classList.remove('btn-success'); button.classList.add('btn-error'); button.title = 'Click map to place marker (click again to cancel)'; } } } }