diff --git a/app/javascript/controllers/area_creation_v2_controller.js b/app/javascript/controllers/area_creation_v2_controller.js index 998bd40c..d241499e 100644 --- a/app/javascript/controllers/area_creation_v2_controller.js +++ b/app/javascript/controllers/area_creation_v2_controller.js @@ -1,9 +1,8 @@ import { Controller } from '@hotwired/stimulus' -import { Toast } from 'maps_v2/components/toast' /** - * Area creation controller for Maps V2 - * Handles area creation workflow with area drawer + * Area creation controller + * Handles the area creation modal and form submission */ export default class extends Controller { static targets = [ @@ -24,154 +23,87 @@ export default class extends Controller { apiKey: String } - static outlets = ['area-drawer'] - connect() { - console.log('[Area Creation V2] Connected') - this.latitude = null - this.longitude = null - this.radius = null - this.mapsController = null + this.area = null + this.setupEventListeners() + console.log('[Area Creation V2] Controller connected') } /** - * Open modal and start drawing mode - * @param {number} lat - Initial latitude (optional) - * @param {number} lng - Initial longitude (optional) - * @param {object} mapsController - Maps V2 controller reference + * Setup event listeners for area drawing */ - open(lat = null, lng = null, mapsController = null) { - console.log('[Area Creation V2] Opening modal', { lat, lng }) + setupEventListeners() { + document.addEventListener('area:drawn', (e) => { + console.log('[Area Creation V2] area:drawn event received:', e.detail) + this.open(e.detail.center, e.detail.radius) + }) + } - this.mapsController = mapsController - this.latitude = lat - this.longitude = lng - this.radius = 100 // Default radius in meters + /** + * Open the modal with area data + */ + open(center, radius) { + console.log('[Area Creation V2] open() called with center:', center, 'radius:', radius) - // Update hidden inputs if coordinates provided - if (lat && lng) { - this.latitudeInputTarget.value = lat - this.longitudeInputTarget.value = lng - this.radiusInputTarget.value = this.radius - this.updateLocationDisplay(lat, lng) - this.updateRadiusDisplay(this.radius) - } + // Store area data + this.area = { center, radius } - // Clear form - this.nameInputTarget.value = '' + // Update form fields + this.latitudeInputTarget.value = center[1] + this.longitudeInputTarget.value = center[0] + this.radiusInputTarget.value = Math.round(radius) + this.radiusDisplayTarget.value = Math.round(radius) + this.locationDisplayTarget.value = `${center[1].toFixed(6)}, ${center[0].toFixed(6)}` // Show modal this.modalTarget.classList.add('modal-open') - - // Start drawing mode if area-drawer outlet is available - if (this.hasAreaDrawerOutlet) { - console.log('[Area Creation V2] Starting drawing mode') - this.areaDrawerOutlet.startDrawing() - } else { - console.warn('[Area Creation V2] Area drawer outlet not found') - } + this.nameInputTarget.focus() } /** - * Close modal and cancel drawing + * Close the modal */ close() { - console.log('[Area Creation V2] Closing modal') - this.modalTarget.classList.remove('modal-open') - - // Cancel drawing mode - if (this.hasAreaDrawerOutlet) { - this.areaDrawerOutlet.cancelDrawing() - } - - // Reset form - this.formTarget.reset() - this.latitude = null - this.longitude = null - this.radius = null + this.resetForm() } /** - * Handle area drawn event from area-drawer - */ - handleAreaDrawn(event) { - console.log('[Area Creation V2] Area drawn', event.detail) - - const { area } = event.detail - const [lng, lat] = area.center - const radius = Math.round(area.radius) - - this.latitude = lat - this.longitude = lng - this.radius = radius - - // Update form fields - this.latitudeInputTarget.value = lat - this.longitudeInputTarget.value = lng - this.radiusInputTarget.value = radius - - // Update displays - this.updateLocationDisplay(lat, lng) - this.updateRadiusDisplay(radius) - - console.log('[Area Creation V2] Form updated with drawn area') - } - - /** - * Update location display - */ - updateLocationDisplay(lat, lng) { - this.locationDisplayTarget.value = `${lat.toFixed(6)}, ${lng.toFixed(6)}` - } - - /** - * Update radius display - */ - updateRadiusDisplay(radius) { - this.radiusDisplayTarget.value = `${radius.toLocaleString()}` - } - - /** - * Handle form submission + * Submit the form */ async submit(event) { event.preventDefault() - console.log('[Area Creation V2] Submitting form') - - // Validate - if (!this.latitude || !this.longitude || !this.radius) { - Toast.error('Please draw an area on the map first') + if (!this.area) { + console.error('No area data available') return } const formData = new FormData(this.formTarget) const name = formData.get('name') + const latitude = parseFloat(formData.get('latitude')) + const longitude = parseFloat(formData.get('longitude')) + const radius = parseFloat(formData.get('radius')) - if (!name || name.trim() === '') { - Toast.error('Please enter an area name') - this.nameInputTarget.focus() + if (!name || !latitude || !longitude || !radius) { + alert('Please fill in all required fields') return } - // Show loading state - this.submitButtonTarget.disabled = true - this.submitSpinnerTarget.classList.remove('hidden') - this.submitTextTarget.textContent = 'Creating...' + this.setLoading(true) try { const response = await fetch('/api/v1/areas', { method: 'POST', headers: { - 'Authorization': `Bearer ${this.apiKeyValue}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKeyValue}` }, body: JSON.stringify({ - name: name.trim(), - latitude: this.latitude, - longitude: this.longitude, - radius: this.radius + name, + latitude, + longitude, + radius }) }) @@ -180,25 +112,59 @@ export default class extends Controller { throw new Error(error.message || 'Failed to create area') } - const data = await response.json() - console.log('[Area Creation V2] Area created:', data) + const area = await response.json() - Toast.success(`Area "${name}" created successfully`) + // Close modal + this.close() - // Dispatch event to notify maps controller + // Dispatch document event for area created document.dispatchEvent(new CustomEvent('area:created', { - detail: { area: data } + detail: { area } })) - this.close() } catch (error) { - console.error('[Area Creation V2] Failed to create area:', error) - Toast.error(error.message || 'Failed to create area') + console.error('Error creating area:', error) + alert(`Error creating area: ${error.message}`) } finally { - // Reset button state - this.submitButtonTarget.disabled = false + this.setLoading(false) + } + } + + /** + * Set loading state + */ + setLoading(loading) { + this.submitButtonTarget.disabled = loading + + if (loading) { + this.submitSpinnerTarget.classList.remove('hidden') + this.submitTextTarget.textContent = 'Creating...' + } else { this.submitSpinnerTarget.classList.add('hidden') this.submitTextTarget.textContent = 'Create Area' } } + + /** + * Reset form + */ + resetForm() { + this.formTarget.reset() + this.area = null + this.radiusDisplayTarget.value = '' + this.locationDisplayTarget.value = '' + } + + /** + * Show success message + */ + showSuccess(message) { + // You can replace this with a toast notification if available + console.log(message) + + // Try to use the Toast component if available + if (window.Toast) { + window.Toast.show(message, 'success') + } + } } diff --git a/app/javascript/controllers/area_drawer_controller.js b/app/javascript/controllers/area_drawer_controller.js index 44c1ba59..94bd1870 100644 --- a/app/javascript/controllers/area_drawer_controller.js +++ b/app/javascript/controllers/area_drawer_controller.js @@ -6,25 +6,31 @@ import { createCircle, calculateDistance } from 'maps_v2/utils/geometry' * Draw circular areas on map */ export default class extends Controller { - static outlets = ['mapsV2'] - connect() { this.isDrawing = false this.center = null this.radius = 0 + this.map = null + + // Bind event handlers to maintain context + this.onClick = this.onClick.bind(this) + this.onMouseMove = this.onMouseMove.bind(this) } /** * Start drawing mode + * @param {maplibregl.Map} map - The MapLibre map instance */ - startDrawing() { - if (!this.hasMapsV2Outlet) { - console.error('Maps V2 outlet not found') + startDrawing(map) { + console.log('[Area Drawer] startDrawing called with map:', map) + if (!map) { + console.error('[Area Drawer] Map instance not provided') return } + console.log('[Area Drawer] Starting drawing mode') this.isDrawing = true - const map = this.mapsV2Outlet.map + this.map = map map.getCanvas().style.cursor = 'crosshair' // Add temporary layer @@ -64,43 +70,47 @@ export default class extends Controller { * Cancel drawing mode */ cancelDrawing() { - if (!this.hasMapsV2Outlet) return + if (!this.map) return this.isDrawing = false this.center = null this.radius = 0 - const map = this.mapsV2Outlet.map - map.getCanvas().style.cursor = '' + this.map.getCanvas().style.cursor = '' // Clear drawing - const source = map.getSource('draw-source') + const source = this.map.getSource('draw-source') if (source) { source.setData({ type: 'FeatureCollection', features: [] }) } // Remove event listeners - map.off('click', this.onClick) - map.off('mousemove', this.onMouseMove) + this.map.off('click', this.onClick) + this.map.off('mousemove', this.onMouseMove) } /** * Click handler */ - onClick = (e) => { - if (!this.isDrawing || !this.hasMapsV2Outlet) return + onClick(e) { + if (!this.isDrawing || !this.map) return if (!this.center) { // First click - set center + console.log('[Area Drawer] First click - setting center:', e.lngLat) this.center = [e.lngLat.lng, e.lngLat.lat] } else { // Second click - finish drawing - const area = { - center: this.center, - radius: this.radius - } + console.log('[Area Drawer] Second click - finishing drawing') + + console.log('[Area Drawer] Dispatching area:drawn event') + document.dispatchEvent(new CustomEvent('area:drawn', { + detail: { + center: this.center, + radius: this.radius + } + })) - this.dispatch('drawn', { detail: { area } }) this.cancelDrawing() } } @@ -108,8 +118,8 @@ export default class extends Controller { /** * Mouse move handler */ - onMouseMove = (e) => { - if (!this.isDrawing || !this.center || !this.hasMapsV2Outlet) return + onMouseMove(e) { + if (!this.isDrawing || !this.center || !this.map) return const currentPoint = [e.lngLat.lng, e.lngLat.lat] this.radius = calculateDistance(this.center, currentPoint) @@ -121,11 +131,11 @@ export default class extends Controller { * Update drawing visualization */ updateDrawing() { - if (!this.center || this.radius === 0 || !this.hasMapsV2Outlet) return + if (!this.center || this.radius === 0 || !this.map) return const coordinates = createCircle(this.center, this.radius) - const source = this.mapsV2Outlet.map.getSource('draw-source') + const source = this.map.getSource('draw-source') if (source) { source.setData({ type: 'FeatureCollection', diff --git a/app/javascript/controllers/maps_v2/data_loader.js b/app/javascript/controllers/maps_v2/data_loader.js index 7bbdfd51..2a6b1141 100644 --- a/app/javascript/controllers/maps_v2/data_loader.js +++ b/app/javascript/controllers/maps_v2/data_loader.js @@ -181,7 +181,8 @@ export class DataLoader { type: 'FeatureCollection', features: areas.map(area => { // Create circle polygon from center and radius - const center = [area.longitude, area.latitude] + // Parse as floats since API returns strings + const center = [parseFloat(area.longitude), parseFloat(area.latitude)] const coordinates = createCircle(center, area.radius) return { @@ -193,7 +194,7 @@ export class DataLoader { properties: { id: area.id, name: area.name, - color: area.color || '#3b82f6', + color: area.color || '#ef4444', radius: area.radius } } diff --git a/app/javascript/controllers/maps_v2_controller.js b/app/javascript/controllers/maps_v2_controller.js index 95db760e..3e547044 100644 --- a/app/javascript/controllers/maps_v2_controller.js +++ b/app/javascript/controllers/maps_v2_controller.js @@ -107,6 +107,9 @@ export default class extends Controller { this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager) this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated) + this.boundHandleAreaCreated = this.handleAreaCreated.bind(this) + this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated) + // Format initial dates this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue)) this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue)) @@ -330,23 +333,73 @@ export default class extends Controller { this.toggleSettings() } - const modalElement = document.querySelector('[data-controller="area-creation-v2"]') - if (!modalElement) { - console.error('[Maps V2] Area creation modal not found') - Toast.error('Area creation modal not available') - return - } - - const controller = this.application.getControllerForElementAndIdentifier( - modalElement, - 'area-creation-v2' + // Find area drawer controller on the same element + const drawerController = this.application.getControllerForElementAndIdentifier( + this.element, + 'area-drawer' ) - if (controller) { - controller.open(null, null, this) + if (drawerController) { + console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map) + drawerController.startDrawing(this.map) } else { - console.error('[Maps V2] Area creation controller not found') - Toast.error('Area creation controller not available') + console.error('[Maps V2] Area drawer controller not found') + Toast.error('Area drawer controller not available') + } + } + + async handleAreaCreated(event) { + console.log('[Maps V2] Area created:', event.detail.area) + + try { + // Fetch all areas from API + const areas = await this.api.fetchAreas() + console.log('[Maps V2] Fetched areas:', areas.length) + + // Convert to GeoJSON + const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas) + console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features') + if (areasGeoJSON.features.length > 0) { + console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2)) + } + + // Get or create the areas layer + let areasLayer = this.layerManager.getLayer('areas') + console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible) + + if (areasLayer) { + // Update existing layer + areasLayer.update(areasGeoJSON) + console.log('[Maps V2] Areas layer updated') + } else { + // Create the layer if it doesn't exist yet + console.log('[Maps V2] Creating areas layer') + this.layerManager._addAreasLayer(areasGeoJSON) + areasLayer = this.layerManager.getLayer('areas') + console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible) + } + + // Enable the layer if it wasn't already + if (areasLayer) { + if (!areasLayer.visible) { + console.log('[Maps V2] Showing areas layer') + areasLayer.show() + this.settings.layers.areas = true + this.settingsController.saveSetting('layers.areas', true) + + // Update toggle state + if (this.hasAreasToggleTarget) { + this.areasToggleTarget.checked = true + } + } else { + console.log('[Maps V2] Areas layer already visible') + } + } + + Toast.success('Area created successfully!') + } catch (error) { + console.error('[Maps V2] Failed to reload areas:', error) + Toast.error('Failed to reload areas') } } diff --git a/app/javascript/controllers/maps_v2_controller.js.backup b/app/javascript/controllers/maps_v2_controller.js.backup deleted file mode 100644 index f4fe4052..00000000 --- a/app/javascript/controllers/maps_v2_controller.js.backup +++ /dev/null @@ -1,867 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import maplibregl from 'maplibre-gl' -import { ApiClient } from 'maps_v2/services/api_client' -import { PointsLayer } from 'maps_v2/layers/points_layer' -import { RoutesLayer } from 'maps_v2/layers/routes_layer' -import { HeatmapLayer } from 'maps_v2/layers/heatmap_layer' -import { VisitsLayer } from 'maps_v2/layers/visits_layer' -import { PhotosLayer } from 'maps_v2/layers/photos_layer' -import { AreasLayer } from 'maps_v2/layers/areas_layer' -import { TracksLayer } from 'maps_v2/layers/tracks_layer' -import { FogLayer } from 'maps_v2/layers/fog_layer' -import { FamilyLayer } from 'maps_v2/layers/family_layer' -import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers' -import { PopupFactory } from 'maps_v2/components/popup_factory' -import { VisitPopupFactory } from 'maps_v2/components/visit_popup' -import { PhotoPopupFactory } from 'maps_v2/components/photo_popup' -import { SettingsManager } from 'maps_v2/utils/settings_manager' -import { createCircle } from 'maps_v2/utils/geometry' -import { Toast } from 'maps_v2/components/toast' -import { lazyLoader } from 'maps_v2/utils/lazy_loader' -import { ProgressiveLoader } from 'maps_v2/utils/progressive_loader' -import { performanceMonitor } from 'maps_v2/utils/performance_monitor' -import { CleanupHelper } from 'maps_v2/utils/cleanup_helper' -import { getMapStyle } from 'maps_v2/utils/style_manager' - -/** - * Main map controller for Maps V2 - * Phase 3: With heatmap and settings panel - */ -export default class extends Controller { - static values = { - apiKey: String, - startDate: String, - endDate: String - } - - static targets = ['container', 'loading', 'loadingText', 'monthSelect', 'clusterToggle', 'settingsPanel', 'visitsSearch'] - - async connect() { - this.cleanup = new CleanupHelper() - - // Initialize settings manager with API key for backend sync - SettingsManager.initialize(this.apiKeyValue) - - // Sync settings from backend (will fall back to localStorage if needed) - await this.loadSettings() - - await this.initializeMap() - this.initializeAPI() - this.currentVisitFilter = 'all' - - // Format initial dates from backend to match V1 API format - this.startDateValue = this.formatDateForAPI(new Date(this.startDateValue)) - this.endDateValue = this.formatDateForAPI(new Date(this.endDateValue)) - console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue) - - this.loadMapData() - } - - disconnect() { - this.cleanup.cleanup() - this.map?.remove() - performanceMonitor.logReport() - } - - /** - * Load settings (sync from backend and localStorage) - */ - async loadSettings() { - this.settings = await SettingsManager.sync() - console.log('[Maps V2] Settings loaded:', this.settings) - } - - /** - * Initialize MapLibre map - */ - async initializeMap() { - // Get map style from local files (async) - const style = await getMapStyle(this.settings.mapStyle) - - this.map = new maplibregl.Map({ - container: this.containerTarget, - style: style, - center: [0, 0], - zoom: 2 - }) - - // Add navigation controls - this.map.addControl(new maplibregl.NavigationControl(), 'top-right') - - // Setup click handler for points - this.map.on('click', 'points', this.handlePointClick.bind(this)) - - // Change cursor on hover - this.map.on('mouseenter', 'points', () => { - this.map.getCanvas().style.cursor = 'pointer' - }) - this.map.on('mouseleave', 'points', () => { - this.map.getCanvas().style.cursor = '' - }) - } - - /** - * Initialize API client - */ - initializeAPI() { - this.api = new ApiClient(this.apiKeyValue) - } - - /** - * Load points data from API - */ - async loadMapData() { - performanceMonitor.mark('load-map-data') - this.showLoading() - - try { - // Fetch all points for selected month - performanceMonitor.mark('fetch-points') - const points = await this.api.fetchAllPoints({ - start_at: this.startDateValue, - end_at: this.endDateValue, - onProgress: this.updateLoadingProgress.bind(this) - }) - performanceMonitor.measure('fetch-points') - - // Transform to GeoJSON for points - performanceMonitor.mark('transform-geojson') - const pointsGeoJSON = pointsToGeoJSON(points) - performanceMonitor.measure('transform-geojson') - - // Create routes from points - const routesGeoJSON = RoutesLayer.pointsToRoutes(points) - - // Define all layer add functions - const addRoutesLayer = () => { - if (!this.routesLayer) { - this.routesLayer = new RoutesLayer(this.map) - this.routesLayer.add(routesGeoJSON) - } else { - this.routesLayer.update(routesGeoJSON) - } - } - - const addPointsLayer = () => { - if (!this.pointsLayer) { - this.pointsLayer = new PointsLayer(this.map) - this.pointsLayer.add(pointsGeoJSON) - } else { - this.pointsLayer.update(pointsGeoJSON) - } - } - - const addHeatmapLayer = () => { - if (!this.heatmapLayer) { - this.heatmapLayer = new HeatmapLayer(this.map, { - visible: this.settings.heatmapEnabled - }) - this.heatmapLayer.add(pointsGeoJSON) - } else { - this.heatmapLayer.update(pointsGeoJSON) - } - } - - // Load visits - let visits = [] - try { - visits = await this.api.fetchVisits({ - start_at: this.startDateValue, - end_at: this.endDateValue - }) - } catch (error) { - console.warn('Failed to fetch visits:', error) - // Continue with empty visits array - } - - const visitsGeoJSON = this.visitsToGeoJSON(visits) - this.allVisits = visits // Store for filtering - - const addVisitsLayer = () => { - if (!this.visitsLayer) { - this.visitsLayer = new VisitsLayer(this.map, { - visible: this.settings.visitsEnabled || false - }) - this.visitsLayer.add(visitsGeoJSON) - } else { - this.visitsLayer.update(visitsGeoJSON) - } - } - - // Load photos - let photos = [] - try { - console.log('[Photos] Fetching photos from:', this.startDateValue, 'to', this.endDateValue) - photos = await this.api.fetchPhotos({ - start_at: this.startDateValue, - end_at: this.endDateValue - }) - console.log('[Photos] Fetched photos:', photos.length, 'photos') - console.log('[Photos] Sample photo:', photos[0]) - } catch (error) { - console.error('[Photos] Failed to fetch photos:', error) - // Continue with empty photos array - } - - const photosGeoJSON = this.photosToGeoJSON(photos) - console.log('[Photos] Converted to GeoJSON:', photosGeoJSON.features.length, 'features') - console.log('[Photos] Sample feature:', photosGeoJSON.features[0]) - - const addPhotosLayer = async () => { - console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled) - if (!this.photosLayer) { - this.photosLayer = new PhotosLayer(this.map, { - visible: this.settings.photosEnabled || false - }) - console.log('[Photos] Created new PhotosLayer instance') - await this.photosLayer.add(photosGeoJSON) - console.log('[Photos] Added photos to layer') - } else { - console.log('[Photos] Updating existing PhotosLayer') - await this.photosLayer.update(photosGeoJSON) - console.log('[Photos] Updated photos layer') - } - } - - // Load areas - let areas = [] - try { - areas = await this.api.fetchAreas() - } catch (error) { - console.warn('Failed to fetch areas:', error) - // Continue with empty areas array - } - - const areasGeoJSON = this.areasToGeoJSON(areas) - - const addAreasLayer = () => { - if (!this.areasLayer) { - this.areasLayer = new AreasLayer(this.map, { - visible: this.settings.areasEnabled || false - }) - this.areasLayer.add(areasGeoJSON) - } else { - this.areasLayer.update(areasGeoJSON) - } - } - - // Load tracks - DISABLED: Backend API not yet implemented - // TODO: Re-enable when /api/v1/tracks endpoint is created - const tracks = [] - const tracksGeoJSON = this.tracksToGeoJSON(tracks) - - const addTracksLayer = () => { - if (!this.tracksLayer) { - this.tracksLayer = new TracksLayer(this.map, { - visible: this.settings.tracksEnabled || false - }) - this.tracksLayer.add(tracksGeoJSON) - } else { - this.tracksLayer.update(tracksGeoJSON) - } - } - - // Add scratch layer (lazy loaded) - const addScratchLayer = async () => { - try { - if (!this.scratchLayer && this.settings.scratchEnabled) { - const ScratchLayer = await lazyLoader.loadLayer('scratch') - this.scratchLayer = new ScratchLayer(this.map, { - visible: true, - apiClient: this.api // Pass API client for authenticated requests - }) - await this.scratchLayer.add(pointsGeoJSON) - } else if (this.scratchLayer) { - await this.scratchLayer.update(pointsGeoJSON) - } - } catch (error) { - console.warn('Failed to load scratch layer:', error) - } - } - - // Add family layer (for real-time family locations) - const addFamilyLayer = () => { - if (!this.familyLayer) { - this.familyLayer = new FamilyLayer(this.map, { - visible: false // Initially hidden, shown when family locations arrive via ActionCable - }) - this.familyLayer.add({ type: 'FeatureCollection', features: [] }) - } - } - - // Add all layers when style is ready - // Note: Layer order matters - layers added first render below layers added later - // Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> photos -> family -> points (top) -> fog (canvas overlay) - const addAllLayers = async () => { - performanceMonitor.mark('add-layers') - - await addScratchLayer() // Add scratch first (renders at bottom) - lazy loaded - addHeatmapLayer() // Add heatmap second - addAreasLayer() // Add areas third - addTracksLayer() // Add tracks fourth - addRoutesLayer() // Add routes fifth - addVisitsLayer() // Add visits sixth - - // Add photos layer with error handling (async, might fail loading images) - try { - await addPhotosLayer() // Add photos seventh (async for image loading) - } catch (error) { - console.warn('Failed to add photos layer:', error) - } - - addFamilyLayer() // Add family layer (real-time family locations) - addPointsLayer() // Add points last (renders on top) - - // Add fog layer (canvas overlay, separate from MapLibre layers) - // Always create fog layer for backward compatibility - if (!this.fogLayer) { - this.fogLayer = new FogLayer(this.map, { - clearRadius: 1000, - visible: this.settings.fogEnabled || false - }) - this.fogLayer.add(pointsGeoJSON) - } else { - this.fogLayer.update(pointsGeoJSON) - } - - performanceMonitor.measure('add-layers') - - // Add click handlers for visits and photos - this.map.on('click', 'visits', this.handleVisitClick.bind(this)) - this.map.on('click', 'photos', this.handlePhotoClick.bind(this)) - - // Change cursor on hover - this.map.on('mouseenter', 'visits', () => { - this.map.getCanvas().style.cursor = 'pointer' - }) - this.map.on('mouseleave', 'visits', () => { - this.map.getCanvas().style.cursor = '' - }) - this.map.on('mouseenter', 'photos', () => { - this.map.getCanvas().style.cursor = 'pointer' - }) - this.map.on('mouseleave', 'photos', () => { - this.map.getCanvas().style.cursor = '' - }) - } - - // Use 'load' event which fires when map is fully initialized - // This is more reliable than 'style.load' - if (this.map.loaded()) { - await addAllLayers() - } else { - this.map.once('load', async () => { - await addAllLayers() - }) - } - - // Fit map to data bounds - if (points.length > 0) { - this.fitMapToBounds(pointsGeoJSON) - } - - // Show success toast - Toast.success(`Loaded ${points.length} location ${points.length === 1 ? 'point' : 'points'}`) - - } catch (error) { - console.error('Failed to load map data:', error) - Toast.error('Failed to load location data. Please try again.') - } finally { - this.hideLoading() - const duration = performanceMonitor.measure('load-map-data') - console.log(`[Performance] Map data loaded in ${duration}ms`) - } - } - - /** - * Handle point click - */ - handlePointClick(e) { - const feature = e.features[0] - const coordinates = feature.geometry.coordinates.slice() - const properties = feature.properties - - // Create popup - new maplibregl.Popup() - .setLngLat(coordinates) - .setHTML(PopupFactory.createPointPopup(properties)) - .addTo(this.map) - } - - /** - * Fit map to data bounds - */ - fitMapToBounds(geojson) { - const coordinates = geojson.features.map(f => f.geometry.coordinates) - - const bounds = coordinates.reduce((bounds, coord) => { - return bounds.extend(coord) - }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) - - this.map.fitBounds(bounds, { - padding: 50, - maxZoom: 15 - }) - } - - /** - * Format date for API requests (matching V1 format) - * Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59") - */ - formatDateForAPI(date) { - const pad = (n) => String(n).padStart(2, '0') - const year = date.getFullYear() - const month = pad(date.getMonth() + 1) - const day = pad(date.getDate()) - const hours = pad(date.getHours()) - const minutes = pad(date.getMinutes()) - - return `${year}-${month}-${day}T${hours}:${minutes}` - } - - /** - * Month selector changed - */ - monthChanged(event) { - const [year, month] = event.target.value.split('-') - - const startDate = new Date(year, month - 1, 1, 0, 0, 0) - const lastDay = new Date(year, month, 0).getDate() - const endDate = new Date(year, month - 1, lastDay, 23, 59, 0) - - this.startDateValue = this.formatDateForAPI(startDate) - this.endDateValue = this.formatDateForAPI(endDate) - - console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue) - - // Reload data - this.loadMapData() - } - - /** - * Show loading indicator - */ - showLoading() { - this.loadingTarget.classList.remove('hidden') - } - - /** - * Hide loading indicator - */ - hideLoading() { - this.loadingTarget.classList.add('hidden') - } - - /** - * Update loading progress - */ - updateLoadingProgress({ loaded, totalPages, progress }) { - if (this.hasLoadingTextTarget) { - const percentage = Math.round(progress * 100) - this.loadingTextTarget.textContent = `Loading... ${percentage}%` - } - } - - /** - * Toggle layer visibility - */ - toggleLayer(event) { - const button = event.currentTarget - const layerName = button.dataset.layer - - // Get the layer instance - const layer = this[`${layerName}Layer`] - if (!layer) return - - // Toggle visibility - layer.toggle() - - // Update button style - if (layer.visible) { - button.classList.add('btn-primary') - button.classList.remove('btn-outline') - } else { - button.classList.remove('btn-primary') - button.classList.add('btn-outline') - } - } - - /** - * Toggle point clustering - */ - toggleClustering(event) { - if (!this.pointsLayer) return - - const button = event.currentTarget - - // Toggle clustering state - const newClusteringState = !this.pointsLayer.clusteringEnabled - this.pointsLayer.toggleClustering(newClusteringState) - - // Update button style to reflect state - if (newClusteringState) { - button.classList.add('btn-primary') - button.classList.remove('btn-outline') - } else { - button.classList.remove('btn-primary') - button.classList.add('btn-outline') - } - - // Save setting - SettingsManager.updateSetting('clustering', newClusteringState) - } - - /** - * Toggle settings panel - */ - toggleSettings() { - if (this.hasSettingsPanelTarget) { - this.settingsPanelTarget.classList.toggle('open') - } - } - - /** - * Update map style from settings - */ - async updateMapStyle(event) { - const styleName = event.target.value - SettingsManager.updateSetting('mapStyle', styleName) - - const style = await getMapStyle(styleName) - - // Store current data - const pointsData = this.pointsLayer?.data - const routesData = this.routesLayer?.data - const heatmapData = this.heatmapLayer?.data - - // Clear layer references - this.pointsLayer = null - this.routesLayer = null - this.heatmapLayer = null - - this.map.setStyle(style) - - // Reload layers after style change - this.map.once('style.load', () => { - console.log('Style loaded, reloading map data') - this.loadMapData() - }) - } - - /** - * Toggle heatmap visibility - */ - toggleHeatmap(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('heatmapEnabled', enabled) - - if (this.heatmapLayer) { - if (enabled) { - this.heatmapLayer.show() - } else { - this.heatmapLayer.hide() - } - } - } - - /** - * Reset settings to defaults - */ - resetSettings() { - if (confirm('Reset all settings to defaults? This will reload the page.')) { - SettingsManager.resetToDefaults() - window.location.reload() - } - } - - /** - * Convert visits to GeoJSON - */ - visitsToGeoJSON(visits) { - return { - type: 'FeatureCollection', - features: visits.map(visit => ({ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [visit.place.longitude, visit.place.latitude] - }, - properties: { - id: visit.id, - name: visit.name, - place_name: visit.place?.name, - status: visit.status, - started_at: visit.started_at, - ended_at: visit.ended_at, - duration: visit.duration - } - })) - } - } - - /** - * Convert photos to GeoJSON - */ - photosToGeoJSON(photos) { - return { - type: 'FeatureCollection', - features: photos.map(photo => { - // Construct thumbnail URL - const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.api.apiKey}&source=${photo.source}` - - return { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [photo.longitude, photo.latitude] - }, - properties: { - id: photo.id, - thumbnail_url: thumbnailUrl, - taken_at: photo.localDateTime, - filename: photo.originalFileName, - city: photo.city, - state: photo.state, - country: photo.country, - type: photo.type, - source: photo.source - } - } - }) - } - } - - /** - * Convert areas to GeoJSON - * Backend returns circular areas with latitude, longitude, radius - */ - areasToGeoJSON(areas) { - return { - type: 'FeatureCollection', - features: areas.map(area => { - // Create circle polygon from center and radius - const center = [area.longitude, area.latitude] - const coordinates = createCircle(center, area.radius) - - return { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [coordinates] - }, - properties: { - id: area.id, - name: area.name, - color: area.color || '#3b82f6', - radius: area.radius - } - } - }) - } - } - - /** - * Convert tracks to GeoJSON - */ - tracksToGeoJSON(tracks) { - return { - type: 'FeatureCollection', - features: tracks.map(track => ({ - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: track.coordinates - }, - properties: { - id: track.id, - name: track.name, - color: track.color || '#8b5cf6' - } - })) - } - } - - /** - * Handle visit click - */ - handleVisitClick(e) { - const feature = e.features[0] - const coordinates = feature.geometry.coordinates.slice() - const properties = feature.properties - - new maplibregl.Popup() - .setLngLat(coordinates) - .setHTML(VisitPopupFactory.createVisitPopup(properties)) - .addTo(this.map) - } - - /** - * Handle photo click - */ - handlePhotoClick(e) { - const feature = e.features[0] - const coordinates = feature.geometry.coordinates.slice() - const properties = feature.properties - - new maplibregl.Popup() - .setLngLat(coordinates) - .setHTML(PhotoPopupFactory.createPhotoPopup(properties)) - .addTo(this.map) - } - - /** - * Toggle visits layer - */ - toggleVisits(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('visitsEnabled', enabled) - - if (this.visitsLayer) { - if (enabled) { - this.visitsLayer.show() - // Show visits search - if (this.hasVisitsSearchTarget) { - this.visitsSearchTarget.style.display = 'block' - } - } else { - this.visitsLayer.hide() - // Hide visits search - if (this.hasVisitsSearchTarget) { - this.visitsSearchTarget.style.display = 'none' - } - } - } - } - - /** - * Toggle photos layer - */ - togglePhotos(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('photosEnabled', enabled) - - if (this.photosLayer) { - if (enabled) { - this.photosLayer.show() - } else { - this.photosLayer.hide() - } - } - } - - /** - * Search visits - */ - searchVisits(event) { - const searchTerm = event.target.value.toLowerCase() - this.filterAndUpdateVisits(searchTerm, this.currentVisitFilter) - } - - /** - * Filter visits by status - */ - filterVisits(event) { - const filter = event.target.value - this.currentVisitFilter = filter - const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || '' - this.filterAndUpdateVisits(searchTerm, filter) - } - - /** - * Filter and update visits display - */ - filterAndUpdateVisits(searchTerm, statusFilter) { - if (!this.allVisits || !this.visitsLayer) return - - const filtered = this.allVisits.filter(visit => { - // Apply search - const matchesSearch = !searchTerm || - visit.name?.toLowerCase().includes(searchTerm) || - visit.place?.name?.toLowerCase().includes(searchTerm) - - // Apply status filter - const matchesStatus = statusFilter === 'all' || visit.status === statusFilter - - return matchesSearch && matchesStatus - }) - - const geojson = this.visitsToGeoJSON(filtered) - this.visitsLayer.update(geojson) - } - - /** - * Toggle areas layer - */ - toggleAreas(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('areasEnabled', enabled) - - if (this.areasLayer) { - if (enabled) { - this.areasLayer.show() - } else { - this.areasLayer.hide() - } - } - } - - /** - * Toggle tracks layer - */ - toggleTracks(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('tracksEnabled', enabled) - - if (this.tracksLayer) { - if (enabled) { - this.tracksLayer.show() - } else { - this.tracksLayer.hide() - } - } - } - - /** - * Toggle fog of war layer - */ - toggleFog(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('fogEnabled', enabled) - - if (this.fogLayer) { - this.fogLayer.toggle(enabled) - } else { - console.warn('Fog layer not yet initialized') - } - } - - /** - * Toggle scratch map layer - */ - async toggleScratch(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('scratchEnabled', enabled) - - try { - if (!this.scratchLayer && enabled) { - // Lazy load scratch layer - const ScratchLayer = await lazyLoader.loadLayer('scratch') - this.scratchLayer = new ScratchLayer(this.map, { - visible: true, - apiClient: this.api - }) - const pointsData = this.pointsLayer?.data || { type: 'FeatureCollection', features: [] } - await this.scratchLayer.add(pointsData) - } else if (this.scratchLayer) { - if (enabled) { - this.scratchLayer.show() - } else { - this.scratchLayer.hide() - } - } - } catch (error) { - console.error('Failed to toggle scratch layer:', error) - Toast.error('Failed to load scratch layer') - } - } -} diff --git a/app/javascript/controllers/maps_v2_controller_old.js b/app/javascript/controllers/maps_v2_controller_old.js deleted file mode 100644 index c57fafc7..00000000 --- a/app/javascript/controllers/maps_v2_controller_old.js +++ /dev/null @@ -1,1988 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import maplibregl from 'maplibre-gl' -import { ApiClient } from 'maps_v2/services/api_client' -import { SettingsManager } from 'maps_v2/utils/settings_manager' -import { SearchManager } from 'maps_v2/utils/search_manager' -import { Toast } from 'maps_v2/components/toast' -import { performanceMonitor } from 'maps_v2/utils/performance_monitor' -import { CleanupHelper } from 'maps_v2/utils/cleanup_helper' -import { getMapStyle } from 'maps_v2/utils/style_manager' -import { LayerManager } from './maps_v2/layer_manager' -import { DataLoader } from './maps_v2/data_loader' -import { EventHandlers } from './maps_v2/event_handlers' -import { FilterManager } from './maps_v2/filter_manager' -import { DateManager } from './maps_v2/date_manager' -import { lazyLoader } from 'maps_v2/utils/lazy_loader' -import { SelectionLayer } from 'maps_v2/layers/selection_layer' -import { SelectedPointsLayer } from 'maps_v2/layers/selected_points_layer' -import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers' -import { VisitCard } from 'maps_v2/components/visit_card' - -/** - * Main map controller for Maps V2 - * Coordinates between different managers and handles UI interactions - */ -export default class extends Controller { - static values = { - apiKey: String, - startDate: String, - endDate: String - } - - static targets = [ - 'container', - 'loading', - 'loadingText', - 'monthSelect', - 'clusterToggle', - 'settingsPanel', - 'visitsSearch', - 'routeOpacityRange', - 'placesFilters', - 'enableAllPlaceTagsToggle', - 'fogRadiusValue', - 'fogThresholdValue', - 'metersBetweenValue', - 'minutesBetweenValue', - // Search - 'searchInput', - 'searchResults', - // Layer toggles - 'pointsToggle', - 'routesToggle', - 'heatmapToggle', - 'visitsToggle', - 'photosToggle', - 'areasToggle', - // 'tracksToggle', - 'placesToggle', - 'fogToggle', - 'scratchToggle', - // Speed-colored routes - 'routesOptions', - 'speedColoredToggle', - 'speedColorScaleContainer', - 'speedColorScaleInput', - // Area selection - 'selectAreaButton', - 'selectionActions', - 'deleteButtonText', - 'selectedVisitsContainer', - 'selectedVisitsBulkActions' - ] - - async connect() { - this.cleanup = new CleanupHelper() - - // Initialize settings manager with API key for backend sync - SettingsManager.initialize(this.apiKeyValue) - - // Sync settings from backend (will fall back to localStorage if needed) - await this.loadSettings() - - // Sync toggle states with loaded settings - this.syncToggleStates() - - await this.initializeMap() - this.initializeAPI() - - // Initialize managers - this.layerManager = new LayerManager(this.map, this.settings, this.api) - this.dataLoader = new DataLoader(this.api, this.apiKeyValue) - this.eventHandlers = new EventHandlers(this.map) - this.filterManager = new FilterManager(this.dataLoader) - - // Initialize search manager - this.initializeSearch() - - // Listen for visit creation events - this.boundHandleVisitCreated = this.handleVisitCreated.bind(this) - this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated) - - // Listen for place creation events - this.boundHandlePlaceCreated = this.handlePlaceCreated.bind(this) - this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated) - - // Format initial dates from backend to match V1 API format - this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue)) - this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue)) - console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue) - - this.loadMapData() - } - - disconnect() { - this.searchManager?.destroy() - this.cleanup.cleanup() - this.map?.remove() - performanceMonitor.logReport() - } - - /** - * Load settings (sync from backend and localStorage) - */ - async loadSettings() { - this.settings = await SettingsManager.sync() - console.log('[Maps V2] Settings loaded:', this.settings) - } - - /** - * Sync UI controls with loaded settings - */ - syncToggleStates() { - // Sync layer toggles - const toggleMap = { - pointsToggle: 'pointsVisible', - routesToggle: 'routesVisible', - heatmapToggle: 'heatmapEnabled', - visitsToggle: 'visitsEnabled', - photosToggle: 'photosEnabled', - areasToggle: 'areasEnabled', - placesToggle: 'placesEnabled', - // tracksToggle: 'tracksEnabled', - fogToggle: 'fogEnabled', - scratchToggle: 'scratchEnabled', - speedColoredToggle: 'speedColoredRoutesEnabled' - } - - Object.entries(toggleMap).forEach(([targetName, settingKey]) => { - const target = `${targetName}Target` - if (this[target]) { - this[target].checked = this.settings[settingKey] - } - }) - - // Show/hide visits search based on initial toggle state - if (this.hasVisitsToggleTarget && this.hasVisitsSearchTarget) { - if (this.visitsToggleTarget.checked) { - this.visitsSearchTarget.style.display = 'block' - } else { - this.visitsSearchTarget.style.display = 'none' - } - } - - // Show/hide places filters based on initial toggle state - if (this.hasPlacesToggleTarget && this.hasPlacesFiltersTarget) { - if (this.placesToggleTarget.checked) { - this.placesFiltersTarget.style.display = 'block' - } else { - this.placesFiltersTarget.style.display = 'none' - } - } - - // Sync route opacity slider - if (this.hasRouteOpacityRangeTarget) { - this.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100 - } - - // Sync map style dropdown - const mapStyleSelect = this.element.querySelector('select[name="mapStyle"]') - if (mapStyleSelect) { - mapStyleSelect.value = this.settings.mapStyle || 'light' - } - - // Sync fog of war settings - const fogRadiusInput = this.element.querySelector('input[name="fogOfWarRadius"]') - if (fogRadiusInput) { - fogRadiusInput.value = this.settings.fogOfWarRadius || 1000 - if (this.hasFogRadiusValueTarget) { - this.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m` - } - } - - const fogThresholdInput = this.element.querySelector('input[name="fogOfWarThreshold"]') - if (fogThresholdInput) { - fogThresholdInput.value = this.settings.fogOfWarThreshold || 1 - if (this.hasFogThresholdValueTarget) { - this.fogThresholdValueTarget.textContent = fogThresholdInput.value - } - } - - // Sync route generation settings - const metersBetweenInput = this.element.querySelector('input[name="metersBetweenRoutes"]') - if (metersBetweenInput) { - metersBetweenInput.value = this.settings.metersBetweenRoutes || 500 - if (this.hasMetersBetweenValueTarget) { - this.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m` - } - } - - const minutesBetweenInput = this.element.querySelector('input[name="minutesBetweenRoutes"]') - if (minutesBetweenInput) { - minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60 - if (this.hasMinutesBetweenValueTarget) { - this.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min` - } - } - - // Sync speed-colored routes settings - if (this.hasSpeedColorScaleInputTarget) { - const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' - this.speedColorScaleInputTarget.value = colorScale - } - if (this.hasSpeedColorScaleContainerTarget && this.hasSpeedColoredToggleTarget) { - const isEnabled = this.speedColoredToggleTarget.checked - this.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled) - } - - // Sync points rendering mode radio buttons - const pointsRenderingRadios = this.element.querySelectorAll('input[name="pointsRenderingMode"]') - pointsRenderingRadios.forEach(radio => { - radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw') - }) - - // Sync speed-colored routes toggle - const speedColoredRoutesToggle = this.element.querySelector('input[name="speedColoredRoutes"]') - if (speedColoredRoutesToggle) { - speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false - } - - console.log('[Maps V2] UI controls synced with settings') - } - - /** - * Initialize MapLibre map - */ - async initializeMap() { - // Get map style from local files (async) - const style = await getMapStyle(this.settings.mapStyle) - - this.map = new maplibregl.Map({ - container: this.containerTarget, - style: style, - center: [0, 0], - zoom: 2 - }) - - // Add navigation controls - this.map.addControl(new maplibregl.NavigationControl(), 'top-right') - } - - /** - * Initialize API client - */ - initializeAPI() { - this.api = new ApiClient(this.apiKeyValue) - } - - /** - * Initialize location search - */ - initializeSearch() { - if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) { - console.warn('[Maps V2] Search targets not found, search functionality disabled') - return - } - - this.searchManager = new SearchManager(this.map, this.apiKeyValue) - this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget) - - console.log('[Maps V2] Search manager initialized') - } - - /** - * Handle visit creation event - reload visits and update layer - */ - async handleVisitCreated(event) { - console.log('[Maps V2] Visit created, reloading visits...', event.detail) - - try { - // Fetch updated visits - const visits = await this.api.fetchVisits({ - start_at: this.startDateValue, - end_at: this.endDateValue - }) - - console.log('[Maps V2] Fetched visits:', visits.length) - - // Update FilterManager with all visits (for search functionality) - this.filterManager.setAllVisits(visits) - - // Convert to GeoJSON - const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits) - - console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features') - - // Get the visits layer and update it - const visitsLayer = this.layerManager.getLayer('visits') - if (visitsLayer) { - visitsLayer.update(visitsGeoJSON) - console.log('[Maps V2] Visits layer updated successfully') - } else { - console.warn('[Maps V2] Visits layer not found, cannot update') - } - } catch (error) { - console.error('[Maps V2] Failed to reload visits:', error) - } - } - - /** - * Handle place creation event - reload places and update layer - */ - async handlePlaceCreated(event) { - console.log('[Maps V2] Place created, reloading places...', event.detail) - - try { - // Get currently selected tag filters - const selectedTags = this.getSelectedPlaceTags() - - // Fetch updated places with filters - const places = await this.api.fetchPlaces({ - tag_ids: selectedTags - }) - - console.log('[Maps V2] Fetched places:', places.length) - - // Convert to GeoJSON - const placesGeoJSON = this.dataLoader.placesToGeoJSON(places) - - console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features') - - // Get the places layer and update it - const placesLayer = this.layerManager.getLayer('places') - if (placesLayer) { - placesLayer.update(placesGeoJSON) - console.log('[Maps V2] Places layer updated successfully') - } else { - console.warn('[Maps V2] Places layer not found, cannot update') - } - } catch (error) { - console.error('[Maps V2] Failed to reload places:', error) - } - } - - /** - * Start create visit mode - * Allows user to click on map to create a new visit - */ - startCreateVisit() { - console.log('[Maps V2] Starting create visit mode') - - // Close settings panel - if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) { - this.toggleSettings() - } - - // Change cursor to crosshair - this.map.getCanvas().style.cursor = 'crosshair' - - // Show info message - Toast.info('Click on the map to place a visit') - - // Add map click listener - this.handleCreateVisitClick = (e) => { - const { lng, lat } = e.lngLat - this.openVisitCreationModal(lat, lng) - // Reset cursor - this.map.getCanvas().style.cursor = '' - } - - this.map.once('click', this.handleCreateVisitClick) - } - - /** - * Open visit creation modal - */ - openVisitCreationModal(lat, lng) { - console.log('[Maps V2] Opening visit creation modal', { lat, lng }) - - // Find the visit creation controller - const modalElement = document.querySelector('[data-controller="visit-creation-v2"]') - - if (!modalElement) { - console.error('[Maps V2] Visit creation modal not found') - Toast.error('Visit creation modal not available') - return - } - - // Get the controller instance - const controller = this.application.getControllerForElementAndIdentifier( - modalElement, - 'visit-creation-v2' - ) - - if (controller) { - controller.open(lat, lng, this) - } else { - console.error('[Maps V2] Visit creation controller not found') - Toast.error('Visit creation controller not available') - } - } - - /** - * Start create place mode - * Allows user to click on map to create a new place - */ - startCreatePlace() { - console.log('[Maps V2] Starting create place mode') - - // Close settings panel - if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) { - this.toggleSettings() - } - - // Change cursor to crosshair - this.map.getCanvas().style.cursor = 'crosshair' - - // Show info message - Toast.info('Click on the map to place a place') - - // Add map click listener - this.handleCreatePlaceClick = (e) => { - const { lng, lat } = e.lngLat - - // Dispatch event for place creation modal (reuse existing controller) - document.dispatchEvent(new CustomEvent('place:create', { - detail: { latitude: lat, longitude: lng } - })) - - // Reset cursor - this.map.getCanvas().style.cursor = '' - } - - this.map.once('click', this.handleCreatePlaceClick) - } - - /** - * Load map data from API - * @param {Object} options - { showLoading, fitBounds, showToast } - */ - async loadMapData(options = {}) { - const { - showLoading = true, - fitBounds = true, - showToast = true - } = options - - performanceMonitor.mark('load-map-data') - - if (showLoading) { - this.showLoading() - } - - try { - // Fetch all map data - const data = await this.dataLoader.fetchMapData( - this.startDateValue, - this.endDateValue, - showLoading ? this.updateLoadingProgress.bind(this) : null - ) - - // Store visits for filtering - this.filterManager.setAllVisits(data.visits) - - // Add all layers when style is ready - const addAllLayers = async () => { - await this.layerManager.addAllLayers( - data.pointsGeoJSON, - data.routesGeoJSON, - data.visitsGeoJSON, - data.photosGeoJSON, - data.areasGeoJSON, - data.tracksGeoJSON, - data.placesGeoJSON - ) - - // Setup event handlers - this.layerManager.setupLayerEventHandlers({ - handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers), - handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers), - handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers), - handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers) - }) - } - - // Use 'load' event which fires when map is fully initialized - if (this.map.loaded()) { - await addAllLayers() - } else { - this.map.once('load', async () => { - await addAllLayers() - }) - } - - // Fit map to data bounds (optional) - if (fitBounds && data.points.length > 0) { - this.fitMapToBounds(data.pointsGeoJSON) - } - - // Show success toast (optional) - if (showToast) { - Toast.success(`Loaded ${data.points.length} location ${data.points.length === 1 ? 'point' : 'points'}`) - } - - } catch (error) { - console.error('Failed to load map data:', error) - Toast.error('Failed to load location data. Please try again.') - } finally { - if (showLoading) { - this.hideLoading() - } - const duration = performanceMonitor.measure('load-map-data') - console.log(`[Performance] Map data loaded in ${duration}ms`) - } - } - - /** - * Fit map to data bounds - */ - fitMapToBounds(geojson) { - const coordinates = geojson.features.map(f => f.geometry.coordinates) - - const bounds = coordinates.reduce((bounds, coord) => { - return bounds.extend(coord) - }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) - - this.map.fitBounds(bounds, { - padding: 50, - maxZoom: 15 - }) - } - - /** - * Month selector changed - */ - monthChanged(event) { - const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value) - this.startDateValue = startDate - this.endDateValue = endDate - - console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue) - - // Reload data - this.loadMapData() - } - - /** - * Show loading indicator - */ - showLoading() { - this.loadingTarget.classList.remove('hidden') - } - - /** - * Hide loading indicator - */ - hideLoading() { - this.loadingTarget.classList.add('hidden') - } - - /** - * Update loading progress - */ - updateLoadingProgress({ loaded, totalPages, progress }) { - if (this.hasLoadingTextTarget) { - const percentage = Math.round(progress * 100) - this.loadingTextTarget.textContent = `Loading... ${percentage}%` - } - } - - /** - * Toggle layer visibility - */ - toggleLayer(event) { - const element = event.currentTarget - const layerName = element.dataset.layer || event.params?.layer - - const visible = this.layerManager.toggleLayer(layerName) - if (visible === null) return - - // Update button style (for button-based toggles) - if (element.tagName === 'BUTTON') { - if (visible) { - element.classList.add('btn-primary') - element.classList.remove('btn-outline') - } else { - element.classList.remove('btn-primary') - element.classList.add('btn-outline') - } - } - - // Update checkbox state (for checkbox-based toggles) - if (element.tagName === 'INPUT' && element.type === 'checkbox') { - element.checked = visible - } - } - - /** - * Toggle points layer visibility - */ - togglePoints(event) { - const element = event.currentTarget - const visible = element.checked - - const pointsLayer = this.layerManager.getLayer('points') - if (pointsLayer) { - pointsLayer.toggle(visible) - } - - // Save setting - SettingsManager.updateSetting('pointsVisible', visible) - } - - /** - * Toggle routes layer visibility - */ - toggleRoutes(event) { - const element = event.currentTarget - const visible = element.checked - - const routesLayer = this.layerManager.getLayer('routes') - if (routesLayer) { - routesLayer.toggle(visible) - } - - // Show/hide routes options panel - if (this.hasRoutesOptionsTarget) { - this.routesOptionsTarget.style.display = visible ? 'block' : 'none' - } - - // Save setting - SettingsManager.updateSetting('routesVisible', visible) - } - - /** - * Toggle settings panel - */ - toggleSettings() { - if (this.hasSettingsPanelTarget) { - this.settingsPanelTarget.classList.toggle('open') - } - } - - /** - * Update map style from settings - */ - async updateMapStyle(event) { - const styleName = event.target.value - SettingsManager.updateSetting('mapStyle', styleName) - - const style = await getMapStyle(styleName) - - // Clear layer references - this.layerManager.clearLayerReferences() - - this.map.setStyle(style) - - // Reload layers after style change - this.map.once('style.load', () => { - console.log('Style loaded, reloading map data') - this.loadMapData() - }) - } - - /** - * Toggle heatmap visibility - */ - toggleHeatmap(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('heatmapEnabled', enabled) - - const heatmapLayer = this.layerManager.getLayer('heatmap') - if (heatmapLayer) { - if (enabled) { - heatmapLayer.show() - } else { - heatmapLayer.hide() - } - } - } - - /** - * Reset settings to defaults - */ - resetSettings() { - if (confirm('Reset all settings to defaults? This will reload the page.')) { - SettingsManager.resetToDefaults() - window.location.reload() - } - } - - /** - * Update route opacity in real-time - */ - updateRouteOpacity(event) { - const opacity = parseInt(event.target.value) / 100 - - const routesLayer = this.layerManager.getLayer('routes') - if (routesLayer && this.map.getLayer('routes')) { - this.map.setPaintProperty('routes', 'line-opacity', opacity) - } - - // Save setting - SettingsManager.updateSetting('routeOpacity', opacity) - } - - /** - * Update fog radius display value - */ - updateFogRadiusDisplay(event) { - if (this.hasFogRadiusValueTarget) { - this.fogRadiusValueTarget.textContent = `${event.target.value}m` - } - } - - /** - * Update fog threshold display value - */ - updateFogThresholdDisplay(event) { - if (this.hasFogThresholdValueTarget) { - this.fogThresholdValueTarget.textContent = event.target.value - } - } - - /** - * Update meters between routes display value - */ - updateMetersBetweenDisplay(event) { - if (this.hasMetersBetweenValueTarget) { - this.metersBetweenValueTarget.textContent = `${event.target.value}m` - } - } - - /** - * Update minutes between routes display value - */ - updateMinutesBetweenDisplay(event) { - if (this.hasMinutesBetweenValueTarget) { - this.minutesBetweenValueTarget.textContent = `${event.target.value}min` - } - } - - /** - * Update advanced settings from form submission - */ - async updateAdvancedSettings(event) { - event.preventDefault() - - const formData = new FormData(event.target) - const settings = { - routeOpacity: parseFloat(formData.get('routeOpacity')) / 100, - fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')), - fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')), - metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')), - minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')), - pointsRenderingMode: formData.get('pointsRenderingMode'), - speedColoredRoutes: formData.get('speedColoredRoutes') === 'on' - } - - // Apply settings to current map - await this.applySettingsToMap(settings) - - // Save to backend and localStorage - for (const [key, value] of Object.entries(settings)) { - await SettingsManager.updateSetting(key, value) - } - - Toast.success('Settings updated successfully') - } - - /** - * Apply settings to map without reload - */ - async applySettingsToMap(settings) { - // Update route opacity - if (settings.routeOpacity !== undefined) { - const routesLayer = this.layerManager.getLayer('routes') - if (routesLayer && this.map.getLayer('routes')) { - this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity) - } - } - - // Update fog of war settings - if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) { - const fogLayer = this.layerManager.getLayer('fog') - if (fogLayer) { - if (settings.fogOfWarRadius) { - fogLayer.clearRadius = settings.fogOfWarRadius - } - // Redraw fog layer - if (fogLayer.visible) { - await fogLayer.update(fogLayer.data) - } - } - } - - // For settings that require data reload (points rendering mode, speed-colored routes, etc) - // we need to reload the map data - if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) { - Toast.info('Reloading map data with new settings...') - await this.loadMapData() - } - } - - /** - * Toggle visits layer - */ - toggleVisits(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('visitsEnabled', enabled) - - const visitsLayer = this.layerManager.getLayer('visits') - if (visitsLayer) { - if (enabled) { - visitsLayer.show() - // Show visits search - if (this.hasVisitsSearchTarget) { - this.visitsSearchTarget.style.display = 'block' - } - } else { - visitsLayer.hide() - // Hide visits search - if (this.hasVisitsSearchTarget) { - this.visitsSearchTarget.style.display = 'none' - } - } - } - } - - /** - * Toggle places layer - */ - togglePlaces(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('placesEnabled', enabled) - - const placesLayer = this.layerManager.getLayer('places') - if (placesLayer) { - if (enabled) { - placesLayer.show() - // Show places filters - if (this.hasPlacesFiltersTarget) { - this.placesFiltersTarget.style.display = 'block' - } - - // Initialize tag filters: enable all tags if no saved selection exists - this.initializePlaceTagFilters() - } else { - placesLayer.hide() - // Hide places filters - if (this.hasPlacesFiltersTarget) { - this.placesFiltersTarget.style.display = 'none' - } - } - } - } - - /** - * Initialize place tag filters (enable all by default or restore saved state) - */ - initializePlaceTagFilters() { - const savedFilters = this.settings.placesTagFilters - - if (savedFilters && savedFilters.length > 0) { - // Restore saved tag selection - this.restoreSavedTagFilters(savedFilters) - } else { - // Default: enable all tags - this.enableAllTagsInitial() - } - } - - /** - * Restore saved tag filters - */ - restoreSavedTagFilters(savedFilters) { - const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') - - tagCheckboxes.forEach(checkbox => { - const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value) - const shouldBeChecked = savedFilters.includes(value) - - if (checkbox.checked !== shouldBeChecked) { - checkbox.checked = shouldBeChecked - - // Update badge styling - const badge = checkbox.nextElementSibling - const color = badge.style.borderColor - - if (shouldBeChecked) { - badge.classList.remove('badge-outline') - badge.style.backgroundColor = color - badge.style.color = 'white' - } else { - badge.classList.add('badge-outline') - badge.style.backgroundColor = 'transparent' - badge.style.color = color - } - } - }) - - // Sync "Enable All Tags" toggle - this.syncEnableAllTagsToggle() - - // Load places with restored filters - this.loadPlacesWithTags(savedFilters) - } - - /** - * Enable all tags initially - */ - enableAllTagsInitial() { - if (this.hasEnableAllPlaceTagsToggleTarget) { - this.enableAllPlaceTagsToggleTarget.checked = true - } - - const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') - const allTagIds = [] - - tagCheckboxes.forEach(checkbox => { - checkbox.checked = true - - // Update badge styling - const badge = checkbox.nextElementSibling - const color = badge.style.borderColor - badge.classList.remove('badge-outline') - badge.style.backgroundColor = color - badge.style.color = 'white' - - // Collect tag IDs - const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value) - allTagIds.push(value) - }) - - // Save to settings - SettingsManager.updateSetting('placesTagFilters', allTagIds) - - // Load places with all tags - this.loadPlacesWithTags(allTagIds) - } - - /** - * Get selected place tag IDs - */ - getSelectedPlaceTags() { - return Array.from( - document.querySelectorAll('input[name="place_tag_ids[]"]:checked') - ).map(cb => { - const value = cb.value - // Keep "untagged" as string, convert others to integers - return value === 'untagged' ? value : parseInt(value) - }) - } - - /** - * Filter places by selected tags - */ - filterPlacesByTags(event) { - // Update badge styles - const badge = event.target.nextElementSibling - const color = badge.style.borderColor - - if (event.target.checked) { - badge.classList.remove('badge-outline') - badge.style.backgroundColor = color - badge.style.color = 'white' - } else { - badge.classList.add('badge-outline') - badge.style.backgroundColor = 'transparent' - badge.style.color = color - } - - // Sync "Enable All Tags" toggle state - this.syncEnableAllTagsToggle() - - // Get all checked tag checkboxes - const checkedTags = this.getSelectedPlaceTags() - - // Save selection to settings - SettingsManager.updateSetting('placesTagFilters', checkedTags) - - // Reload places with selected tags (empty array = show NO places) - this.loadPlacesWithTags(checkedTags) - } - - /** - * Sync "Enable All Tags" toggle with individual tag states - */ - syncEnableAllTagsToggle() { - if (!this.hasEnableAllPlaceTagsToggleTarget) return - - const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') - const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked) - const noneChecked = Array.from(tagCheckboxes).every(cb => !cb.checked) - - // Update toggle state without triggering change event - this.enableAllPlaceTagsToggleTarget.checked = allChecked - } - - /** - * Load places filtered by tags - */ - async loadPlacesWithTags(tagIds = []) { - try { - let places = [] - - if (tagIds.length > 0) { - // Fetch places with selected tags - places = await this.api.fetchPlaces({ tag_ids: tagIds }) - } - // If tagIds is empty, places remains empty array = show NO places - - const placesGeoJSON = this.dataLoader.placesToGeoJSON(places) - - const placesLayer = this.layerManager.getLayer('places') - if (placesLayer) { - placesLayer.update(placesGeoJSON) - } - } catch (error) { - console.error('[Maps V2] Failed to load places:', error) - } - } - - /** - * Toggle all place tags on/off - */ - toggleAllPlaceTags(event) { - const enableAll = event.target.checked - const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') - - tagCheckboxes.forEach(checkbox => { - if (checkbox.checked !== enableAll) { - checkbox.checked = enableAll - - // Update badge styling - const badge = checkbox.nextElementSibling - const color = badge.style.borderColor - - if (enableAll) { - badge.classList.remove('badge-outline') - badge.style.backgroundColor = color - badge.style.color = 'white' - } else { - badge.classList.add('badge-outline') - badge.style.backgroundColor = 'transparent' - badge.style.color = color - } - } - }) - - // Get selected tags - const selectedTags = this.getSelectedPlaceTags() - - // Save selection to settings - SettingsManager.updateSetting('placesTagFilters', selectedTags) - - // Reload places with selected tags - this.loadPlacesWithTags(selectedTags) - } - - /** - * Toggle photos layer - */ - togglePhotos(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('photosEnabled', enabled) - - const photosLayer = this.layerManager.getLayer('photos') - if (photosLayer) { - if (enabled) { - photosLayer.show() - } else { - photosLayer.hide() - } - } - } - - /** - * Search visits - */ - searchVisits(event) { - const searchTerm = event.target.value.toLowerCase() - const visitsLayer = this.layerManager.getLayer('visits') - this.filterManager.filterAndUpdateVisits( - searchTerm, - this.filterManager.getCurrentVisitFilter(), - visitsLayer - ) - } - - /** - * Filter visits by status - */ - filterVisits(event) { - const filter = event.target.value - this.filterManager.setCurrentVisitFilter(filter) - const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || '' - const visitsLayer = this.layerManager.getLayer('visits') - this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer) - } - - /** - * Toggle areas layer - */ - toggleAreas(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('areasEnabled', enabled) - - const areasLayer = this.layerManager.getLayer('areas') - if (areasLayer) { - if (enabled) { - areasLayer.show() - } else { - areasLayer.hide() - } - } - } - - /** - * Toggle tracks layer - */ - toggleTracks(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('tracksEnabled', enabled) - - const tracksLayer = this.layerManager.getLayer('tracks') - if (tracksLayer) { - if (enabled) { - tracksLayer.show() - } else { - tracksLayer.hide() - } - } - } - - /** - * Toggle fog of war layer - */ - toggleFog(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('fogEnabled', enabled) - - const fogLayer = this.layerManager.getLayer('fog') - if (fogLayer) { - fogLayer.toggle(enabled) - } else { - console.warn('Fog layer not yet initialized') - } - } - - /** - * Toggle scratch map layer - */ - async toggleScratch(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('scratchEnabled', enabled) - - try { - const scratchLayer = this.layerManager.getLayer('scratch') - if (!scratchLayer && enabled) { - // Lazy load scratch layer - const ScratchLayer = await lazyLoader.loadLayer('scratch') - const newScratchLayer = new ScratchLayer(this.map, { - visible: true, - apiClient: this.api - }) - const pointsLayer = this.layerManager.getLayer('points') - const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] } - await newScratchLayer.add(pointsData) - this.layerManager.layers.scratchLayer = newScratchLayer - } else if (scratchLayer) { - if (enabled) { - scratchLayer.show() - } else { - scratchLayer.hide() - } - } - } catch (error) { - console.error('Failed to toggle scratch layer:', error) - Toast.error('Failed to load scratch layer') - } - } - - /** - * Toggle speed-colored routes - */ - async toggleSpeedColoredRoutes(event) { - const enabled = event.target.checked - SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled) - - // Show/hide color scale container - if (this.hasSpeedColorScaleContainerTarget) { - this.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled) - } - - // Reload routes with speed colors - await this.reloadRoutes() - } - - /** - * Open speed color editor modal - */ - openSpeedColorEditor() { - const currentScale = this.speedColorScaleInputTarget.value || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' - - // Create modal if it doesn't exist - let modal = document.getElementById('speed-color-editor-modal') - if (!modal) { - modal = this.createSpeedColorEditorModal(currentScale) - document.body.appendChild(modal) - } else { - // Update existing modal with current scale - const controller = this.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor') - if (controller) { - controller.colorStopsValue = currentScale - controller.loadColorStops() - } - } - - // Show modal - const checkbox = modal.querySelector('.modal-toggle') - if (checkbox) { - checkbox.checked = true - } - } - - /** - * Create speed color editor modal element - */ - createSpeedColorEditorModal(currentScale) { - const modal = document.createElement('div') - modal.id = 'speed-color-editor-modal' - modal.setAttribute('data-controller', 'speed-color-editor') - modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale) - modal.setAttribute('data-action', 'speed-color-editor:save->maps-v2#handleSpeedColorSave') - - modal.innerHTML = ` - -