diff --git a/app/javascript/controllers/maps_v2/data_loader.js b/app/javascript/controllers/maps_v2/data_loader.js new file mode 100644 index 00000000..e6a23873 --- /dev/null +++ b/app/javascript/controllers/maps_v2/data_loader.js @@ -0,0 +1,188 @@ +import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers' +import { RoutesLayer } from 'maps_v2/layers/routes_layer' +import { createCircle } from 'maps_v2/utils/geometry' +import { performanceMonitor } from 'maps_v2/utils/performance_monitor' + +/** + * Handles loading and transforming data from API + */ +export class DataLoader { + constructor(api, apiKey) { + this.api = api + this.apiKey = apiKey + } + + /** + * Fetch all map data (points, visits, photos, areas, tracks) + */ + async fetchMapData(startDate, endDate, onProgress) { + const data = {} + + // Fetch points + performanceMonitor.mark('fetch-points') + data.points = await this.api.fetchAllPoints({ + start_at: startDate, + end_at: endDate, + onProgress: onProgress + }) + performanceMonitor.measure('fetch-points') + + // Transform points to GeoJSON + performanceMonitor.mark('transform-geojson') + data.pointsGeoJSON = pointsToGeoJSON(data.points) + data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points) + performanceMonitor.measure('transform-geojson') + + // Fetch visits + try { + data.visits = await this.api.fetchVisits({ + start_at: startDate, + end_at: endDate + }) + } catch (error) { + console.warn('Failed to fetch visits:', error) + data.visits = [] + } + data.visitsGeoJSON = this.visitsToGeoJSON(data.visits) + + // Fetch photos + try { + console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) + data.photos = await this.api.fetchPhotos({ + start_at: startDate, + end_at: endDate + }) + console.log('[Photos] Fetched photos:', data.photos.length, 'photos') + console.log('[Photos] Sample photo:', data.photos[0]) + } catch (error) { + console.error('[Photos] Failed to fetch photos:', error) + data.photos = [] + } + data.photosGeoJSON = this.photosToGeoJSON(data.photos) + console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features') + console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + + // Fetch areas + try { + data.areas = await this.api.fetchAreas() + } catch (error) { + console.warn('Failed to fetch areas:', error) + data.areas = [] + } + data.areasGeoJSON = this.areasToGeoJSON(data.areas) + + // Tracks - DISABLED: Backend API not yet implemented + // TODO: Re-enable when /api/v1/tracks endpoint is created + data.tracks = [] + data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks) + + return data + } + + /** + * 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.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' + } + })) + } + } +} diff --git a/app/javascript/controllers/maps_v2/date_manager.js b/app/javascript/controllers/maps_v2/date_manager.js new file mode 100644 index 00000000..2aac97b8 --- /dev/null +++ b/app/javascript/controllers/maps_v2/date_manager.js @@ -0,0 +1,35 @@ +/** + * Manages date formatting and range calculations + */ +export class DateManager { + /** + * Format date for API requests (matching V1 format) + * Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59") + */ + static 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}` + } + + /** + * Parse month selector value to date range + */ + static parseMonthSelector(value) { + const [year, month] = 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) + + return { + startDate: this.formatDateForAPI(startDate), + endDate: this.formatDateForAPI(endDate) + } + } +} diff --git a/app/javascript/controllers/maps_v2/event_handlers.js b/app/javascript/controllers/maps_v2/event_handlers.js new file mode 100644 index 00000000..e3d5db72 --- /dev/null +++ b/app/javascript/controllers/maps_v2/event_handlers.js @@ -0,0 +1,55 @@ +import maplibregl from 'maplibre-gl' +import { PopupFactory } from 'maps_v2/components/popup_factory' +import { VisitPopupFactory } from 'maps_v2/components/visit_popup' +import { PhotoPopupFactory } from 'maps_v2/components/photo_popup' + +/** + * Handles map interaction events (clicks, popups) + */ +export class EventHandlers { + constructor(map) { + this.map = map + } + + /** + * Handle point click + */ + handlePointClick(e) { + const feature = e.features[0] + const coordinates = feature.geometry.coordinates.slice() + const properties = feature.properties + + new maplibregl.Popup() + .setLngLat(coordinates) + .setHTML(PopupFactory.createPointPopup(properties)) + .addTo(this.map) + } + + /** + * 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) + } +} diff --git a/app/javascript/controllers/maps_v2/filter_manager.js b/app/javascript/controllers/maps_v2/filter_manager.js new file mode 100644 index 00000000..70e9afc3 --- /dev/null +++ b/app/javascript/controllers/maps_v2/filter_manager.js @@ -0,0 +1,53 @@ +/** + * Manages filtering and searching of map data + */ +export class FilterManager { + constructor(dataLoader) { + this.dataLoader = dataLoader + this.currentVisitFilter = 'all' + this.allVisits = [] + } + + /** + * Store all visits for filtering + */ + setAllVisits(visits) { + this.allVisits = visits + } + + /** + * Filter and update visits display + */ + filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) { + if (!this.allVisits || !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.dataLoader.visitsToGeoJSON(filtered) + visitsLayer.update(geojson) + } + + /** + * Get current visit filter + */ + getCurrentVisitFilter() { + return this.currentVisitFilter + } + + /** + * Set current visit filter + */ + setCurrentVisitFilter(filter) { + this.currentVisitFilter = filter + } +} diff --git a/app/javascript/controllers/maps_v2/layer_manager.js b/app/javascript/controllers/maps_v2/layer_manager.js new file mode 100644 index 00000000..d0dd8e0b --- /dev/null +++ b/app/javascript/controllers/maps_v2/layer_manager.js @@ -0,0 +1,227 @@ +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 { lazyLoader } from 'maps_v2/utils/lazy_loader' +import { performanceMonitor } from 'maps_v2/utils/performance_monitor' + +/** + * Manages all map layers lifecycle and visibility + */ +export class LayerManager { + constructor(map, settings, api) { + this.map = map + this.settings = settings + this.api = api + this.layers = {} + } + + /** + * Add or update all layers with provided data + */ + async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON) { + performanceMonitor.mark('add-layers') + + // 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) + + await this._addScratchLayer(pointsGeoJSON) + this._addHeatmapLayer(pointsGeoJSON) + this._addAreasLayer(areasGeoJSON) + this._addTracksLayer(tracksGeoJSON) + this._addRoutesLayer(routesGeoJSON) + this._addVisitsLayer(visitsGeoJSON) + + // Add photos layer with error handling (async, might fail loading images) + try { + await this._addPhotosLayer(photosGeoJSON) + } catch (error) { + console.warn('Failed to add photos layer:', error) + } + + this._addFamilyLayer() + this._addPointsLayer(pointsGeoJSON) + this._addFogLayer(pointsGeoJSON) + + performanceMonitor.measure('add-layers') + } + + /** + * Setup event handlers for layer interactions + */ + setupLayerEventHandlers(handlers) { + // Click handlers + this.map.on('click', 'points', handlers.handlePointClick) + this.map.on('click', 'visits', handlers.handleVisitClick) + this.map.on('click', 'photos', handlers.handlePhotoClick) + + // Cursor change on hover + this.map.on('mouseenter', 'points', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'points', () => { + this.map.getCanvas().style.cursor = '' + }) + 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 = '' + }) + } + + /** + * Toggle layer visibility + */ + toggleLayer(layerName) { + const layer = this.layers[`${layerName}Layer`] + if (!layer) return null + + layer.toggle() + return layer.visible + } + + /** + * Get layer instance + */ + getLayer(layerName) { + return this.layers[`${layerName}Layer`] + } + + /** + * Clear all layer references (for style changes) + */ + clearLayerReferences() { + this.layers = {} + } + + // Private methods for individual layer management + + async _addScratchLayer(pointsGeoJSON) { + try { + if (!this.layers.scratchLayer && this.settings.scratchEnabled) { + const ScratchLayer = await lazyLoader.loadLayer('scratch') + this.layers.scratchLayer = new ScratchLayer(this.map, { + visible: true, + apiClient: this.api + }) + await this.layers.scratchLayer.add(pointsGeoJSON) + } else if (this.layers.scratchLayer) { + await this.layers.scratchLayer.update(pointsGeoJSON) + } + } catch (error) { + console.warn('Failed to load scratch layer:', error) + } + } + + _addHeatmapLayer(pointsGeoJSON) { + if (!this.layers.heatmapLayer) { + this.layers.heatmapLayer = new HeatmapLayer(this.map, { + visible: this.settings.heatmapEnabled + }) + this.layers.heatmapLayer.add(pointsGeoJSON) + } else { + this.layers.heatmapLayer.update(pointsGeoJSON) + } + } + + _addAreasLayer(areasGeoJSON) { + if (!this.layers.areasLayer) { + this.layers.areasLayer = new AreasLayer(this.map, { + visible: this.settings.areasEnabled || false + }) + this.layers.areasLayer.add(areasGeoJSON) + } else { + this.layers.areasLayer.update(areasGeoJSON) + } + } + + _addTracksLayer(tracksGeoJSON) { + if (!this.layers.tracksLayer) { + this.layers.tracksLayer = new TracksLayer(this.map, { + visible: this.settings.tracksEnabled || false + }) + this.layers.tracksLayer.add(tracksGeoJSON) + } else { + this.layers.tracksLayer.update(tracksGeoJSON) + } + } + + _addRoutesLayer(routesGeoJSON) { + if (!this.layers.routesLayer) { + this.layers.routesLayer = new RoutesLayer(this.map) + this.layers.routesLayer.add(routesGeoJSON) + } else { + this.layers.routesLayer.update(routesGeoJSON) + } + } + + _addVisitsLayer(visitsGeoJSON) { + if (!this.layers.visitsLayer) { + this.layers.visitsLayer = new VisitsLayer(this.map, { + visible: this.settings.visitsEnabled || false + }) + this.layers.visitsLayer.add(visitsGeoJSON) + } else { + this.layers.visitsLayer.update(visitsGeoJSON) + } + } + + async _addPhotosLayer(photosGeoJSON) { + console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled) + if (!this.layers.photosLayer) { + this.layers.photosLayer = new PhotosLayer(this.map, { + visible: this.settings.photosEnabled || false + }) + console.log('[Photos] Created new PhotosLayer instance') + await this.layers.photosLayer.add(photosGeoJSON) + console.log('[Photos] Added photos to layer') + } else { + console.log('[Photos] Updating existing PhotosLayer') + await this.layers.photosLayer.update(photosGeoJSON) + console.log('[Photos] Updated photos layer') + } + } + + _addFamilyLayer() { + if (!this.layers.familyLayer) { + this.layers.familyLayer = new FamilyLayer(this.map, { + visible: false // Initially hidden, shown when family locations arrive via ActionCable + }) + this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] }) + } + } + + _addPointsLayer(pointsGeoJSON) { + if (!this.layers.pointsLayer) { + this.layers.pointsLayer = new PointsLayer(this.map) + this.layers.pointsLayer.add(pointsGeoJSON) + } else { + this.layers.pointsLayer.update(pointsGeoJSON) + } + } + + _addFogLayer(pointsGeoJSON) { + // Always create fog layer for backward compatibility + if (!this.layers.fogLayer) { + this.layers.fogLayer = new FogLayer(this.map, { + clearRadius: 1000, + visible: this.settings.fogEnabled || false + }) + this.layers.fogLayer.add(pointsGeoJSON) + } else { + this.layers.fogLayer.update(pointsGeoJSON) + } + } +} diff --git a/app/javascript/controllers/maps_v2_controller.js b/app/javascript/controllers/maps_v2_controller.js index f4fe4052..47e564d2 100644 --- a/app/javascript/controllers/maps_v2_controller.js +++ b/app/javascript/controllers/maps_v2_controller.js @@ -1,31 +1,21 @@ 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' +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' /** * Main map controller for Maps V2 - * Phase 3: With heatmap and settings panel + * Coordinates between different managers and handles UI interactions */ export default class extends Controller { static values = { @@ -47,11 +37,16 @@ export default class extends Controller { await this.initializeMap() this.initializeAPI() - this.currentVisitFilter = 'all' + + // 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) // 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)) + 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() @@ -87,17 +82,6 @@ export default class extends Controller { // 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 = '' - }) } /** @@ -108,245 +92,43 @@ export default class extends Controller { } /** - * Load points data from API + * Load map 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') + // Fetch all map data + const data = await this.dataLoader.fetchMapData( + this.startDateValue, + this.endDateValue, + this.updateLoadingProgress.bind(this) + ) - // 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: [] }) - } - } + // Store visits for filtering + this.filterManager.setAllVisits(data.visits) // 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 this.layerManager.addAllLayers( + data.pointsGeoJSON, + data.routesGeoJSON, + data.visitsGeoJSON, + data.photosGeoJSON, + data.areasGeoJSON, + data.tracksGeoJSON + ) - 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 = '' + // 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) }) } // Use 'load' event which fires when map is fully initialized - // This is more reliable than 'style.load' if (this.map.loaded()) { await addAllLayers() } else { @@ -356,12 +138,12 @@ export default class extends Controller { } // Fit map to data bounds - if (points.length > 0) { - this.fitMapToBounds(pointsGeoJSON) + if (data.points.length > 0) { + this.fitMapToBounds(data.pointsGeoJSON) } // Show success toast - Toast.success(`Loaded ${points.length} location ${points.length === 1 ? 'point' : 'points'}`) + Toast.success(`Loaded ${data.points.length} location ${data.points.length === 1 ? 'point' : 'points'}`) } catch (error) { console.error('Failed to load map data:', error) @@ -373,21 +155,6 @@ export default class extends Controller { } } - /** - * 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 */ @@ -404,33 +171,13 @@ export default class extends Controller { }) } - /** - * 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) + 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) @@ -469,15 +216,11 @@ export default class extends Controller { 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() + const visible = this.layerManager.toggleLayer(layerName) + if (visible === null) return // Update button style - if (layer.visible) { + if (visible) { button.classList.add('btn-primary') button.classList.remove('btn-outline') } else { @@ -490,13 +233,14 @@ export default class extends Controller { * Toggle point clustering */ toggleClustering(event) { - if (!this.pointsLayer) return + const pointsLayer = this.layerManager.getLayer('points') + if (!pointsLayer) return const button = event.currentTarget // Toggle clustering state - const newClusteringState = !this.pointsLayer.clusteringEnabled - this.pointsLayer.toggleClustering(newClusteringState) + const newClusteringState = !pointsLayer.clusteringEnabled + pointsLayer.toggleClustering(newClusteringState) // Update button style to reflect state if (newClusteringState) { @@ -529,15 +273,8 @@ export default class extends Controller { 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.layerManager.clearLayerReferences() this.map.setStyle(style) @@ -555,11 +292,12 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('heatmapEnabled', enabled) - if (this.heatmapLayer) { + const heatmapLayer = this.layerManager.getLayer('heatmap') + if (heatmapLayer) { if (enabled) { - this.heatmapLayer.show() + heatmapLayer.show() } else { - this.heatmapLayer.hide() + heatmapLayer.hide() } } } @@ -574,141 +312,6 @@ export default class extends Controller { } } - /** - * 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 */ @@ -716,15 +319,16 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('visitsEnabled', enabled) - if (this.visitsLayer) { + const visitsLayer = this.layerManager.getLayer('visits') + if (visitsLayer) { if (enabled) { - this.visitsLayer.show() + visitsLayer.show() // Show visits search if (this.hasVisitsSearchTarget) { this.visitsSearchTarget.style.display = 'block' } } else { - this.visitsLayer.hide() + visitsLayer.hide() // Hide visits search if (this.hasVisitsSearchTarget) { this.visitsSearchTarget.style.display = 'none' @@ -740,11 +344,12 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('photosEnabled', enabled) - if (this.photosLayer) { + const photosLayer = this.layerManager.getLayer('photos') + if (photosLayer) { if (enabled) { - this.photosLayer.show() + photosLayer.show() } else { - this.photosLayer.hide() + photosLayer.hide() } } } @@ -754,7 +359,12 @@ export default class extends Controller { */ searchVisits(event) { const searchTerm = event.target.value.toLowerCase() - this.filterAndUpdateVisits(searchTerm, this.currentVisitFilter) + const visitsLayer = this.layerManager.getLayer('visits') + this.filterManager.filterAndUpdateVisits( + searchTerm, + this.filterManager.getCurrentVisitFilter(), + visitsLayer + ) } /** @@ -762,31 +372,10 @@ export default class extends Controller { */ filterVisits(event) { const filter = event.target.value - this.currentVisitFilter = filter + this.filterManager.setCurrentVisitFilter(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) + const visitsLayer = this.layerManager.getLayer('visits') + this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer) } /** @@ -796,11 +385,12 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('areasEnabled', enabled) - if (this.areasLayer) { + const areasLayer = this.layerManager.getLayer('areas') + if (areasLayer) { if (enabled) { - this.areasLayer.show() + areasLayer.show() } else { - this.areasLayer.hide() + areasLayer.hide() } } } @@ -812,11 +402,12 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('tracksEnabled', enabled) - if (this.tracksLayer) { + const tracksLayer = this.layerManager.getLayer('tracks') + if (tracksLayer) { if (enabled) { - this.tracksLayer.show() + tracksLayer.show() } else { - this.tracksLayer.hide() + tracksLayer.hide() } } } @@ -828,8 +419,9 @@ export default class extends Controller { const enabled = event.target.checked SettingsManager.updateSetting('fogEnabled', enabled) - if (this.fogLayer) { - this.fogLayer.toggle(enabled) + const fogLayer = this.layerManager.getLayer('fog') + if (fogLayer) { + fogLayer.toggle(enabled) } else { console.warn('Fog layer not yet initialized') } @@ -843,20 +435,23 @@ export default class extends Controller { SettingsManager.updateSetting('scratchEnabled', enabled) try { - if (!this.scratchLayer && enabled) { + const scratchLayer = this.layerManager.getLayer('scratch') + if (!scratchLayer && enabled) { // Lazy load scratch layer const ScratchLayer = await lazyLoader.loadLayer('scratch') - this.scratchLayer = new ScratchLayer(this.map, { + const newScratchLayer = 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) { + 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) { - this.scratchLayer.show() + scratchLayer.show() } else { - this.scratchLayer.hide() + scratchLayer.hide() } } } catch (error) { diff --git a/app/javascript/controllers/maps_v2_controller.js.backup b/app/javascript/controllers/maps_v2_controller.js.backup new file mode 100644 index 00000000..f4fe4052 --- /dev/null +++ b/app/javascript/controllers/maps_v2_controller.js.backup @@ -0,0 +1,867 @@ +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/e2e/v2/phase-3-heatmap.spec.js b/e2e/v2/phase-3-heatmap.spec.js index 9a5c4b07..254ed6ef 100644 --- a/e2e/v2/phase-3-heatmap.spec.js +++ b/e2e/v2/phase-3-heatmap.spec.js @@ -120,7 +120,7 @@ test.describe('Phase 3: Heatmap + Settings', () => { await page.waitForTimeout(300) const styleSelect = page.locator('#map-style') - await styleSelect.selectOption('dark-matter') + await styleSelect.selectOption('dark') // Wait for style to load await page.waitForTimeout(1000) @@ -130,7 +130,7 @@ test.describe('Phase 3: Heatmap + Settings', () => { return settings.mapStyle }) - expect(savedStyle).toBe('dark-matter') + expect(savedStyle).toBe('dark') }) test('settings persist across page loads', async ({ page }) => { @@ -161,7 +161,7 @@ test.describe('Phase 3: Heatmap + Settings', () => { await page.click('button[title="Settings"]') await page.waitForTimeout(300) - await page.locator('#map-style').selectOption('dark-matter') + await page.locator('#map-style').selectOption('dark') await page.waitForTimeout(300) const heatmapCheckbox = page.locator('label.setting-checkbox:has-text("Show Heatmap")').locator('input[type="checkbox"]')