mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
725 lines
19 KiB
JavaScript
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()
|
|
}
|
|
}
|
|
}
|
|
}
|