dawarich/app/javascript/controllers/maps_v2_controller.js
Eugene Burmakin b2802c9d6a Phase 6
2025-11-20 23:46:06 +01:00

725 lines
19 KiB
JavaScript

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 { 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'
/**
* 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']
connect() {
this.loadSettings()
this.initializeMap()
this.initializeAPI()
this.currentVisitFilter = 'all'
this.loadMapData()
}
disconnect() {
this.map?.remove()
}
/**
* Load settings from localStorage
*/
loadSettings() {
this.settings = SettingsManager.getSettings()
}
/**
* Initialize MapLibre map
*/
initializeMap() {
// Get map style URL from settings
const styleUrl = this.getMapStyleUrl(this.settings.mapStyle)
this.map = new maplibregl.Map({
container: this.containerTarget,
style: styleUrl,
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() {
this.showLoading()
try {
// Fetch all points for selected month
const points = await this.api.fetchAllPoints({
start_at: this.startDateValue,
end_at: this.endDateValue,
onProgress: this.updateLoadingProgress.bind(this)
})
// Transform to GeoJSON for points
const pointsGeoJSON = pointsToGeoJSON(points)
// 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 {
photos = await this.api.fetchPhotos({
start_at: this.startDateValue,
end_at: this.endDateValue
})
} catch (error) {
console.warn('Failed to fetch photos:', error)
// Continue with empty photos array
}
const photosGeoJSON = this.photosToGeoJSON(photos)
const addPhotosLayer = async () => {
if (!this.photosLayer) {
this.photosLayer = new PhotosLayer(this.map, {
visible: this.settings.photosEnabled || false
})
await this.photosLayer.add(photosGeoJSON)
} else {
await this.photosLayer.update(photosGeoJSON)
}
}
// 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
let tracks = []
try {
tracks = await this.api.fetchTracks()
} catch (error) {
console.warn('Failed to fetch tracks:', error)
// Continue with empty tracks array
}
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 all layers when style is ready
// Note: Layer order matters - layers added first render below layers added later
// Order: heatmap (bottom) -> areas -> tracks -> routes -> visits -> photos -> points (top)
const addAllLayers = async () => {
addHeatmapLayer() // Add heatmap first (renders at bottom)
addAreasLayer() // Add areas second
addTracksLayer() // Add tracks third
addRoutesLayer() // Add routes fourth
addVisitsLayer() // Add visits fifth
// Add photos layer with error handling (async, might fail loading images)
try {
await addPhotosLayer() // Add photos sixth (async for image loading)
} catch (error) {
console.warn('Failed to add photos layer:', error)
}
addPointsLayer() // Add points last (renders on top)
// 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)
}
} catch (error) {
console.error('Failed to load map data:', error)
alert('Failed to load location data. Please try again.')
} finally {
this.hideLoading()
}
}
/**
* 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
})
}
/**
* Month selector changed
*/
monthChanged(event) {
const [year, month] = event.target.value.split('-')
// Update date values
this.startDateValue = `${year}-${month}-01T00:00:00Z`
const lastDay = new Date(year, month, 0).getDate()
this.endDateValue = `${year}-${month}-${lastDay}T23:59:59Z`
// 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')
}
}
/**
* Get map style URL
*/
getMapStyleUrl(styleName) {
const styleUrls = {
positron: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
'dark-matter': 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
voyager: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
}
return styleUrls[styleName] || styleUrls.positron
}
/**
* Update map style from settings
*/
updateMapStyle(event) {
const style = event.target.value
SettingsManager.updateSetting('mapStyle', style)
const styleUrl = this.getMapStyleUrl(style)
// 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(styleUrl)
// 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 => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [photo.longitude, photo.latitude]
},
properties: {
id: photo.id,
thumbnail_url: photo.thumbnail_url,
url: photo.url,
taken_at: photo.taken_at,
camera: photo.camera,
location_name: photo.location_name
}
}))
}
}
/**
* 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()
}
}
}
}