import { Controller } from "@hotwired/stimulus"; import L from "leaflet"; import "leaflet.heat"; import consumer from "../channels/consumer"; import { createMarkersArray } from "../maps/markers"; 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, fetchAndDisplayPhotos } from "../maps/helpers"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; 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"; export default class extends BaseController { static targets = ["container"]; settingsButtonAdded = false; layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; currentPopup = 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; // Defensive JSON parsing with error handling try { this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : []; } catch (error) { console.error('Error parsing coordinates data:', error); console.error('Raw coordinates data:', this.element.dataset.coordinates); 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); console.error('Raw tracks data:', this.element.dataset.tracks); 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); console.error('Raw user_settings data:', this.element.dataset.user_settings); this.userSettings = {}; } 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); // 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 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`; div.style.backgroundColor = 'white'; div.style.padding = '0 5px'; div.style.marginRight = '5px'; div.style.display = 'inline-block'; return div; } }); 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 pane for areas this.map.createPane('areasPane'); this.map.getPane('areasPane').style.zIndex = 650; this.map.getPane('areasPane').style.pointerEvents = 'all'; // Create custom panes for visits // Note: We'll still create visitsPane for backward compatibility this.map.createPane('visitsPane'); this.map.getPane('visitsPane').style.zIndex = 600; this.map.getPane('visitsPane').style.pointerEvents = 'all'; // Create separate panes for confirmed and suggested visits this.map.createPane('confirmedVisitsPane'); this.map.getPane('confirmedVisitsPane').style.zIndex = 450; this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'all'; this.map.createPane('suggestedVisitsPane'); this.map.getPane('suggestedVisitsPane').style.zIndex = 460; this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'all'; // Initialize areasLayer as a feature group and add it to the map immediately this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); this.setupScratchLayer(this.countryCodesMap); if (!this.settingsButtonAdded) { this.addSettingsButton(); } // Initialize the visits manager this.visitsManager = new VisitsManager(this.map, this.apiKey); // 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.scratchLayer, Areas: this.areasLayer, Photos: this.photoMarkers, "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), "Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer() }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); // Initialize tile monitor this.tileMonitor = new TileMonitor(this.map, this.apiKey); this.addEventListeners(); this.setupSubscription(); this.setupTracksSubscription(); // Handle routes/tracks mode selection // this.addRoutesTracksSelector(); # Temporarily disabled this.switchRouteMode('routes', true); // Initialize layers based on settings this.initializeLayersFromSettings(); // Initialize tracks layer this.initializeTracksLayer(); // Setup draw control this.initializeDrawControl(); // Preload areas fetchAndDrawAreas(this.areasLayer, this.apiKey); // Add right panel toggle this.addTogglePanelButton(); // Add visits buttons after calendar button to position them below this.visitsManager.addDrawerButton(); } 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); } } } appendPoint(data) { // Parse the received point data const newPoint = data; // Add the new point to the markers array this.markers.push(newPoint); const newMarker = L.marker([newPoint[0], newPoint[1]]) this.markersArray.push(newMarker); // Update the markers layer this.markersLayer.clearLayers(); this.markersLayer.addLayer(L.layerGroup(this.markersArray)); // Update heatmap this.heatmapMarkers.push([newPoint[0], newPoint[1], 0.2]); this.heatmapLayer.setLatLngs(this.heatmapMarkers); // Update polylines this.polylinesLayer.clearLayers(); this.polylinesLayer = createPolylinesLayer( this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit ); // Pan map to new location this.map.setView([newPoint[0], newPoint[1]], 16); // Update fog of war if enabled if (this.map.hasLayer(this.fogOverlay)) { this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold); } // Update the last marker this.map.eachLayer((layer) => { if (layer instanceof L.Marker && !layer._popup) { this.map.removeLayer(layer); } }); this.addLastMarker(this.map, this.markers); } async setupScratchLayer(countryCodesMap) { this.scratchLayer = L.geoJSON(null, { style: { fillColor: '#FFD700', fillOpacity: 0.3, color: '#FFA500', weight: 1 } }) try { // Up-to-date version can be found on Github: // https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson const response = await fetch('/api/v1/countries/borders.json', { headers: { 'Accept': 'application/geo+json,application/json' } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const worldData = await response.json(); const visitedCountries = this.getVisitedCountries(countryCodesMap) const filteredFeatures = worldData.features.filter(feature => visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) ) this.scratchLayer.addData({ type: 'FeatureCollection', features: filteredFeatures }) } catch (error) { console.error('Error loading GeoJSON:', error); } } getVisitedCountries(countryCodesMap) { if (!this.markers) return []; return [...new Set( this.markers .filter(marker => marker[7]) // Ensure country exists .map(marker => { // Convert country name to ISO code, or return the original if not found return countryCodesMap[marker[7]] || marker[7]; }) )]; } // Optional: Add methods to handle user interactions toggleScratchLayer() { if (this.map.hasLayer(this.scratchLayer)) { this.map.removeLayer(this.scratchLayer) } else { this.scratchLayer.addTo(this.map) } } 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) { customLayer.addTo(this.map); // Remove any other base layers that might be active Object.values(maps).forEach(layer => { if (this.map.hasLayer(layer)) { this.map.removeLayer(layer); } }); } maps[this.userSettings.maps.name] = customLayer; } else { // If no custom map is set, ensure a default layer is added // First check if maps object has any entries if (Object.keys(maps).length === 0) { // Fallback to OSM if no maps are configured maps["OpenStreetMap"] = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }); } // Now try to get the selected layer or fall back to alternatives const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0]; if (defaultLayer) { defaultLayer.addTo(this.map); } else { console.error("Could not find any default map layer"); // Ultimate fallback - create and add OSM layer directly L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(this.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) => { 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 === '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) => { 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 === 'Fog of War') { // Fog canvas will be automatically removed by the layer's onRemove method this.fogOverlay = null; } }); } updatePreferredBaseLayer(selectedLayerName) { fetch(`/api/v1/settings?api_key=${this.apiKey}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, 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); } }); } 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.scratchLayer || 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); } }) .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; }); } } addLastMarker(map, markers) { if (markers.length > 0) { const lastMarker = markers[markers.length - 1].slice(0, 2); L.marker(lastMarker).addTo(map); } } updateFog(markers, clearFogRadius, fogLinethreshold) { 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'); button.innerHTML = '⚙️'; // Gear icon // Style the button button.style.backgroundColor = 'white'; button.style.width = '32px'; button.style.height = '32px'; button.style.border = 'none'; button.style.cursor = 'pointer'; button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; // 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; } 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 div.style.backgroundColor = 'white'; div.style.padding = '10px'; div.style.border = '1px solid #ccc'; div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; // Prevent map interactions when interacting with the form L.DomEvent.disableClickPropagation(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?api_key=${this.apiKey}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, 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(); } } 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; // 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)); } // 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.map.hasLayer(this.scratchLayer), 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); } } createPhotoMarker(photo) { if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return; const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`; const icon = L.divIcon({ className: 'photo-marker', html: ``, iconSize: [48, 48] }); const marker = L.marker( [photo.exifInfo.latitude, photo.exifInfo.longitude], { icon } ); const startOfDay = new Date(photo.localDateTime); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(photo.localDateTime); endOfDay.setHours(23, 59, 59, 999); const queryParams = { takenAfter: startOfDay.toISOString(), takenBefore: endOfDay.toISOString() }; const encodedQuery = encodeURIComponent(JSON.stringify(queryParams)); const immich_photo_link = `${this.userSettings.immich_url}/search?query=${encodedQuery}`; const popupContent = `
${photo.originalFileName}

${photo.originalFileName}

Taken: ${new Date(photo.localDateTime).toLocaleString()}

Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}

${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
`; marker.bindPopup(popupContent, { autoClose: false }); this.photoMarkers.addLayer(marker); } addTogglePanelButton() { const TogglePanelControl = L.Control.extend({ onAdd: (map) => { const button = L.DomUtil.create('button', 'toggle-panel-button'); button.innerHTML = '📅'; button.style.width = '48px'; button.style.height = '48px'; button.style.border = 'none'; button.style.cursor = 'pointer'; button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; button.style.backgroundColor = 'white'; button.style.borderRadius = '4px'; button.style.padding = '0'; button.style.lineHeight = '48px'; button.style.fontSize = '18px'; button.style.textAlign = 'center'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); // Toggle panel on button click L.DomEvent.on(button, 'click', () => { this.toggleRightPanel(); }); return button; } }); // Add the control to the map this.map.addControl(new TogglePanelControl({ position: 'topright' })); } 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'); container.style.backgroundColor = 'white'; container.style.padding = '8px'; container.style.borderRadius = '4px'; container.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; container.style.fontSize = '12px'; container.style.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 // Note: Don't automatically add layers to map here - let the layer control and user preferences handle it // The layer control will manage which layers are visible based on user interaction // Initialize photos layer if user wants it visible if (this.userSettings.photos_enabled) { fetchAndDisplayPhotos(this.photoMarkers, this.apiKey, this.userSettings); } // Initialize fog of war if enabled in settings if (this.userSettings.fog_of_war_enabled) { this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold); } // Initialize visits manager functionality if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { this.visitsManager.fetchAndDisplayVisits(); } } 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); div.style.backgroundColor = 'white'; div.style.padding = '10px'; div.style.border = '1px solid #ccc'; div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; 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(); // Set initial display style based on localStorage const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; div.style.display = isPanelOpen ? 'block' : 'none'; 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); } } chunk(array, size) { const chunked = []; for (let i = 0; i < array.length; i += size) { chunked.push(array.slice(i, i + size)); } return chunked; } 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); }); } updateLayerControl() { if (!this.layerControl) return; // Remove existing layer control 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(), "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() }; // Re-add the layer control this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); } toggleTracksVisibility(event) { this.tracksVisible = event.target.checked; if (this.tracksLayer) { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } }