mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Implement area creation
This commit is contained in:
parent
dad5fa9c4f
commit
8d0fb2867e
10 changed files with 358 additions and 3032 deletions
|
|
@ -1,9 +1,8 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
|
||||
/**
|
||||
* Area creation controller for Maps V2
|
||||
* Handles area creation workflow with area drawer
|
||||
* Area creation controller
|
||||
* Handles the area creation modal and form submission
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
|
|
@ -24,154 +23,87 @@ export default class extends Controller {
|
|||
apiKey: String
|
||||
}
|
||||
|
||||
static outlets = ['area-drawer']
|
||||
|
||||
connect() {
|
||||
console.log('[Area Creation V2] Connected')
|
||||
this.latitude = null
|
||||
this.longitude = null
|
||||
this.radius = null
|
||||
this.mapsController = null
|
||||
this.area = null
|
||||
this.setupEventListeners()
|
||||
console.log('[Area Creation V2] Controller connected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal and start drawing mode
|
||||
* @param {number} lat - Initial latitude (optional)
|
||||
* @param {number} lng - Initial longitude (optional)
|
||||
* @param {object} mapsController - Maps V2 controller reference
|
||||
* Setup event listeners for area drawing
|
||||
*/
|
||||
open(lat = null, lng = null, mapsController = null) {
|
||||
console.log('[Area Creation V2] Opening modal', { lat, lng })
|
||||
setupEventListeners() {
|
||||
document.addEventListener('area:drawn', (e) => {
|
||||
console.log('[Area Creation V2] area:drawn event received:', e.detail)
|
||||
this.open(e.detail.center, e.detail.radius)
|
||||
})
|
||||
}
|
||||
|
||||
this.mapsController = mapsController
|
||||
this.latitude = lat
|
||||
this.longitude = lng
|
||||
this.radius = 100 // Default radius in meters
|
||||
/**
|
||||
* Open the modal with area data
|
||||
*/
|
||||
open(center, radius) {
|
||||
console.log('[Area Creation V2] open() called with center:', center, 'radius:', radius)
|
||||
|
||||
// Update hidden inputs if coordinates provided
|
||||
if (lat && lng) {
|
||||
this.latitudeInputTarget.value = lat
|
||||
this.longitudeInputTarget.value = lng
|
||||
this.radiusInputTarget.value = this.radius
|
||||
this.updateLocationDisplay(lat, lng)
|
||||
this.updateRadiusDisplay(this.radius)
|
||||
}
|
||||
// Store area data
|
||||
this.area = { center, radius }
|
||||
|
||||
// Clear form
|
||||
this.nameInputTarget.value = ''
|
||||
// Update form fields
|
||||
this.latitudeInputTarget.value = center[1]
|
||||
this.longitudeInputTarget.value = center[0]
|
||||
this.radiusInputTarget.value = Math.round(radius)
|
||||
this.radiusDisplayTarget.value = Math.round(radius)
|
||||
this.locationDisplayTarget.value = `${center[1].toFixed(6)}, ${center[0].toFixed(6)}`
|
||||
|
||||
// Show modal
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
|
||||
// Start drawing mode if area-drawer outlet is available
|
||||
if (this.hasAreaDrawerOutlet) {
|
||||
console.log('[Area Creation V2] Starting drawing mode')
|
||||
this.areaDrawerOutlet.startDrawing()
|
||||
} else {
|
||||
console.warn('[Area Creation V2] Area drawer outlet not found')
|
||||
}
|
||||
this.nameInputTarget.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and cancel drawing
|
||||
* Close the modal
|
||||
*/
|
||||
close() {
|
||||
console.log('[Area Creation V2] Closing modal')
|
||||
|
||||
this.modalTarget.classList.remove('modal-open')
|
||||
|
||||
// Cancel drawing mode
|
||||
if (this.hasAreaDrawerOutlet) {
|
||||
this.areaDrawerOutlet.cancelDrawing()
|
||||
}
|
||||
|
||||
// Reset form
|
||||
this.formTarget.reset()
|
||||
this.latitude = null
|
||||
this.longitude = null
|
||||
this.radius = null
|
||||
this.resetForm()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle area drawn event from area-drawer
|
||||
*/
|
||||
handleAreaDrawn(event) {
|
||||
console.log('[Area Creation V2] Area drawn', event.detail)
|
||||
|
||||
const { area } = event.detail
|
||||
const [lng, lat] = area.center
|
||||
const radius = Math.round(area.radius)
|
||||
|
||||
this.latitude = lat
|
||||
this.longitude = lng
|
||||
this.radius = radius
|
||||
|
||||
// Update form fields
|
||||
this.latitudeInputTarget.value = lat
|
||||
this.longitudeInputTarget.value = lng
|
||||
this.radiusInputTarget.value = radius
|
||||
|
||||
// Update displays
|
||||
this.updateLocationDisplay(lat, lng)
|
||||
this.updateRadiusDisplay(radius)
|
||||
|
||||
console.log('[Area Creation V2] Form updated with drawn area')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location display
|
||||
*/
|
||||
updateLocationDisplay(lat, lng) {
|
||||
this.locationDisplayTarget.value = `${lat.toFixed(6)}, ${lng.toFixed(6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Update radius display
|
||||
*/
|
||||
updateRadiusDisplay(radius) {
|
||||
this.radiusDisplayTarget.value = `${radius.toLocaleString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
* Submit the form
|
||||
*/
|
||||
async submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
console.log('[Area Creation V2] Submitting form')
|
||||
|
||||
// Validate
|
||||
if (!this.latitude || !this.longitude || !this.radius) {
|
||||
Toast.error('Please draw an area on the map first')
|
||||
if (!this.area) {
|
||||
console.error('No area data available')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
const name = formData.get('name')
|
||||
const latitude = parseFloat(formData.get('latitude'))
|
||||
const longitude = parseFloat(formData.get('longitude'))
|
||||
const radius = parseFloat(formData.get('radius'))
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
Toast.error('Please enter an area name')
|
||||
this.nameInputTarget.focus()
|
||||
if (!name || !latitude || !longitude || !radius) {
|
||||
alert('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.submitButtonTarget.disabled = true
|
||||
this.submitSpinnerTarget.classList.remove('hidden')
|
||||
this.submitTextTarget.textContent = 'Creating...'
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/areas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
latitude: this.latitude,
|
||||
longitude: this.longitude,
|
||||
radius: this.radius
|
||||
name,
|
||||
latitude,
|
||||
longitude,
|
||||
radius
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -180,25 +112,59 @@ export default class extends Controller {
|
|||
throw new Error(error.message || 'Failed to create area')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('[Area Creation V2] Area created:', data)
|
||||
const area = await response.json()
|
||||
|
||||
Toast.success(`Area "${name}" created successfully`)
|
||||
// Close modal
|
||||
this.close()
|
||||
|
||||
// Dispatch event to notify maps controller
|
||||
// Dispatch document event for area created
|
||||
document.dispatchEvent(new CustomEvent('area:created', {
|
||||
detail: { area: data }
|
||||
detail: { area }
|
||||
}))
|
||||
|
||||
this.close()
|
||||
} catch (error) {
|
||||
console.error('[Area Creation V2] Failed to create area:', error)
|
||||
Toast.error(error.message || 'Failed to create area')
|
||||
console.error('Error creating area:', error)
|
||||
alert(`Error creating area: ${error.message}`)
|
||||
} finally {
|
||||
// Reset button state
|
||||
this.submitButtonTarget.disabled = false
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
setLoading(loading) {
|
||||
this.submitButtonTarget.disabled = loading
|
||||
|
||||
if (loading) {
|
||||
this.submitSpinnerTarget.classList.remove('hidden')
|
||||
this.submitTextTarget.textContent = 'Creating...'
|
||||
} else {
|
||||
this.submitSpinnerTarget.classList.add('hidden')
|
||||
this.submitTextTarget.textContent = 'Create Area'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form
|
||||
*/
|
||||
resetForm() {
|
||||
this.formTarget.reset()
|
||||
this.area = null
|
||||
this.radiusDisplayTarget.value = ''
|
||||
this.locationDisplayTarget.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(message) {
|
||||
// You can replace this with a toast notification if available
|
||||
console.log(message)
|
||||
|
||||
// Try to use the Toast component if available
|
||||
if (window.Toast) {
|
||||
window.Toast.show(message, 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,31 @@ import { createCircle, calculateDistance } from 'maps_v2/utils/geometry'
|
|||
* Draw circular areas on map
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static outlets = ['mapsV2']
|
||||
|
||||
connect() {
|
||||
this.isDrawing = false
|
||||
this.center = null
|
||||
this.radius = 0
|
||||
this.map = null
|
||||
|
||||
// Bind event handlers to maintain context
|
||||
this.onClick = this.onClick.bind(this)
|
||||
this.onMouseMove = this.onMouseMove.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start drawing mode
|
||||
* @param {maplibregl.Map} map - The MapLibre map instance
|
||||
*/
|
||||
startDrawing() {
|
||||
if (!this.hasMapsV2Outlet) {
|
||||
console.error('Maps V2 outlet not found')
|
||||
startDrawing(map) {
|
||||
console.log('[Area Drawer] startDrawing called with map:', map)
|
||||
if (!map) {
|
||||
console.error('[Area Drawer] Map instance not provided')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Area Drawer] Starting drawing mode')
|
||||
this.isDrawing = true
|
||||
const map = this.mapsV2Outlet.map
|
||||
this.map = map
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
|
||||
// Add temporary layer
|
||||
|
|
@ -64,43 +70,47 @@ export default class extends Controller {
|
|||
* Cancel drawing mode
|
||||
*/
|
||||
cancelDrawing() {
|
||||
if (!this.hasMapsV2Outlet) return
|
||||
if (!this.map) return
|
||||
|
||||
this.isDrawing = false
|
||||
this.center = null
|
||||
this.radius = 0
|
||||
|
||||
const map = this.mapsV2Outlet.map
|
||||
map.getCanvas().style.cursor = ''
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
|
||||
// Clear drawing
|
||||
const source = map.getSource('draw-source')
|
||||
const source = this.map.getSource('draw-source')
|
||||
if (source) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
map.off('click', this.onClick)
|
||||
map.off('mousemove', this.onMouseMove)
|
||||
this.map.off('click', this.onClick)
|
||||
this.map.off('mousemove', this.onMouseMove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler
|
||||
*/
|
||||
onClick = (e) => {
|
||||
if (!this.isDrawing || !this.hasMapsV2Outlet) return
|
||||
onClick(e) {
|
||||
if (!this.isDrawing || !this.map) return
|
||||
|
||||
if (!this.center) {
|
||||
// First click - set center
|
||||
console.log('[Area Drawer] First click - setting center:', e.lngLat)
|
||||
this.center = [e.lngLat.lng, e.lngLat.lat]
|
||||
} else {
|
||||
// Second click - finish drawing
|
||||
const area = {
|
||||
center: this.center,
|
||||
radius: this.radius
|
||||
}
|
||||
console.log('[Area Drawer] Second click - finishing drawing')
|
||||
|
||||
console.log('[Area Drawer] Dispatching area:drawn event')
|
||||
document.dispatchEvent(new CustomEvent('area:drawn', {
|
||||
detail: {
|
||||
center: this.center,
|
||||
radius: this.radius
|
||||
}
|
||||
}))
|
||||
|
||||
this.dispatch('drawn', { detail: { area } })
|
||||
this.cancelDrawing()
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +118,8 @@ export default class extends Controller {
|
|||
/**
|
||||
* Mouse move handler
|
||||
*/
|
||||
onMouseMove = (e) => {
|
||||
if (!this.isDrawing || !this.center || !this.hasMapsV2Outlet) return
|
||||
onMouseMove(e) {
|
||||
if (!this.isDrawing || !this.center || !this.map) return
|
||||
|
||||
const currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||
this.radius = calculateDistance(this.center, currentPoint)
|
||||
|
|
@ -121,11 +131,11 @@ export default class extends Controller {
|
|||
* Update drawing visualization
|
||||
*/
|
||||
updateDrawing() {
|
||||
if (!this.center || this.radius === 0 || !this.hasMapsV2Outlet) return
|
||||
if (!this.center || this.radius === 0 || !this.map) return
|
||||
|
||||
const coordinates = createCircle(this.center, this.radius)
|
||||
|
||||
const source = this.mapsV2Outlet.map.getSource('draw-source')
|
||||
const source = this.map.getSource('draw-source')
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ export class DataLoader {
|
|||
type: 'FeatureCollection',
|
||||
features: areas.map(area => {
|
||||
// Create circle polygon from center and radius
|
||||
const center = [area.longitude, area.latitude]
|
||||
// Parse as floats since API returns strings
|
||||
const center = [parseFloat(area.longitude), parseFloat(area.latitude)]
|
||||
const coordinates = createCircle(center, area.radius)
|
||||
|
||||
return {
|
||||
|
|
@ -193,7 +194,7 @@ export class DataLoader {
|
|||
properties: {
|
||||
id: area.id,
|
||||
name: area.name,
|
||||
color: area.color || '#3b82f6',
|
||||
color: area.color || '#ef4444',
|
||||
radius: area.radius
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ export default class extends Controller {
|
|||
this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager)
|
||||
this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated)
|
||||
|
||||
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
|
||||
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
|
||||
|
||||
// Format initial dates
|
||||
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
|
||||
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
|
||||
|
|
@ -330,23 +333,73 @@ export default class extends Controller {
|
|||
this.toggleSettings()
|
||||
}
|
||||
|
||||
const modalElement = document.querySelector('[data-controller="area-creation-v2"]')
|
||||
if (!modalElement) {
|
||||
console.error('[Maps V2] Area creation modal not found')
|
||||
Toast.error('Area creation modal not available')
|
||||
return
|
||||
}
|
||||
|
||||
const controller = this.application.getControllerForElementAndIdentifier(
|
||||
modalElement,
|
||||
'area-creation-v2'
|
||||
// Find area drawer controller on the same element
|
||||
const drawerController = this.application.getControllerForElementAndIdentifier(
|
||||
this.element,
|
||||
'area-drawer'
|
||||
)
|
||||
|
||||
if (controller) {
|
||||
controller.open(null, null, this)
|
||||
if (drawerController) {
|
||||
console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map)
|
||||
drawerController.startDrawing(this.map)
|
||||
} else {
|
||||
console.error('[Maps V2] Area creation controller not found')
|
||||
Toast.error('Area creation controller not available')
|
||||
console.error('[Maps V2] Area drawer controller not found')
|
||||
Toast.error('Area drawer controller not available')
|
||||
}
|
||||
}
|
||||
|
||||
async handleAreaCreated(event) {
|
||||
console.log('[Maps V2] Area created:', event.detail.area)
|
||||
|
||||
try {
|
||||
// Fetch all areas from API
|
||||
const areas = await this.api.fetchAreas()
|
||||
console.log('[Maps V2] Fetched areas:', areas.length)
|
||||
|
||||
// Convert to GeoJSON
|
||||
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||
console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features')
|
||||
if (areasGeoJSON.features.length > 0) {
|
||||
console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2))
|
||||
}
|
||||
|
||||
// Get or create the areas layer
|
||||
let areasLayer = this.layerManager.getLayer('areas')
|
||||
console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible)
|
||||
|
||||
if (areasLayer) {
|
||||
// Update existing layer
|
||||
areasLayer.update(areasGeoJSON)
|
||||
console.log('[Maps V2] Areas layer updated')
|
||||
} else {
|
||||
// Create the layer if it doesn't exist yet
|
||||
console.log('[Maps V2] Creating areas layer')
|
||||
this.layerManager._addAreasLayer(areasGeoJSON)
|
||||
areasLayer = this.layerManager.getLayer('areas')
|
||||
console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible)
|
||||
}
|
||||
|
||||
// Enable the layer if it wasn't already
|
||||
if (areasLayer) {
|
||||
if (!areasLayer.visible) {
|
||||
console.log('[Maps V2] Showing areas layer')
|
||||
areasLayer.show()
|
||||
this.settings.layers.areas = true
|
||||
this.settingsController.saveSetting('layers.areas', true)
|
||||
|
||||
// Update toggle state
|
||||
if (this.hasAreasToggleTarget) {
|
||||
this.areasToggleTarget.checked = true
|
||||
}
|
||||
} else {
|
||||
console.log('[Maps V2] Areas layer already visible')
|
||||
}
|
||||
}
|
||||
|
||||
Toast.success('Area created successfully!')
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload areas:', error)
|
||||
Toast.error('Failed to reload areas')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,867 +0,0 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { ApiClient } from 'maps_v2/services/api_client'
|
||||
import { PointsLayer } from 'maps_v2/layers/points_layer'
|
||||
import { RoutesLayer } from 'maps_v2/layers/routes_layer'
|
||||
import { HeatmapLayer } from 'maps_v2/layers/heatmap_layer'
|
||||
import { VisitsLayer } from 'maps_v2/layers/visits_layer'
|
||||
import { PhotosLayer } from 'maps_v2/layers/photos_layer'
|
||||
import { AreasLayer } from 'maps_v2/layers/areas_layer'
|
||||
import { TracksLayer } from 'maps_v2/layers/tracks_layer'
|
||||
import { FogLayer } from 'maps_v2/layers/fog_layer'
|
||||
import { FamilyLayer } from 'maps_v2/layers/family_layer'
|
||||
import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers'
|
||||
import { PopupFactory } from 'maps_v2/components/popup_factory'
|
||||
import { VisitPopupFactory } from 'maps_v2/components/visit_popup'
|
||||
import { PhotoPopupFactory } from 'maps_v2/components/photo_popup'
|
||||
import { SettingsManager } from 'maps_v2/utils/settings_manager'
|
||||
import { createCircle } from 'maps_v2/utils/geometry'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
import { lazyLoader } from 'maps_v2/utils/lazy_loader'
|
||||
import { ProgressiveLoader } from 'maps_v2/utils/progressive_loader'
|
||||
import { performanceMonitor } from 'maps_v2/utils/performance_monitor'
|
||||
import { CleanupHelper } from 'maps_v2/utils/cleanup_helper'
|
||||
import { getMapStyle } from 'maps_v2/utils/style_manager'
|
||||
|
||||
/**
|
||||
* Main map controller for Maps V2
|
||||
* Phase 3: With heatmap and settings panel
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
apiKey: String,
|
||||
startDate: String,
|
||||
endDate: String
|
||||
}
|
||||
|
||||
static targets = ['container', 'loading', 'loadingText', 'monthSelect', 'clusterToggle', 'settingsPanel', 'visitsSearch']
|
||||
|
||||
async connect() {
|
||||
this.cleanup = new CleanupHelper()
|
||||
|
||||
// Initialize settings manager with API key for backend sync
|
||||
SettingsManager.initialize(this.apiKeyValue)
|
||||
|
||||
// Sync settings from backend (will fall back to localStorage if needed)
|
||||
await this.loadSettings()
|
||||
|
||||
await this.initializeMap()
|
||||
this.initializeAPI()
|
||||
this.currentVisitFilter = 'all'
|
||||
|
||||
// Format initial dates from backend to match V1 API format
|
||||
this.startDateValue = this.formatDateForAPI(new Date(this.startDateValue))
|
||||
this.endDateValue = this.formatDateForAPI(new Date(this.endDateValue))
|
||||
console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue)
|
||||
|
||||
this.loadMapData()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup.cleanup()
|
||||
this.map?.remove()
|
||||
performanceMonitor.logReport()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings (sync from backend and localStorage)
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MapLibre map
|
||||
*/
|
||||
async initializeMap() {
|
||||
// Get map style from local files (async)
|
||||
const style = await getMapStyle(this.settings.mapStyle)
|
||||
|
||||
this.map = new maplibregl.Map({
|
||||
container: this.containerTarget,
|
||||
style: style,
|
||||
center: [0, 0],
|
||||
zoom: 2
|
||||
})
|
||||
|
||||
// Add navigation controls
|
||||
this.map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
|
||||
// Setup click handler for points
|
||||
this.map.on('click', 'points', this.handlePointClick.bind(this))
|
||||
|
||||
// Change cursor on hover
|
||||
this.map.on('mouseenter', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize API client
|
||||
*/
|
||||
initializeAPI() {
|
||||
this.api = new ApiClient(this.apiKeyValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load points data from API
|
||||
*/
|
||||
async loadMapData() {
|
||||
performanceMonitor.mark('load-map-data')
|
||||
this.showLoading()
|
||||
|
||||
try {
|
||||
// Fetch all points for selected month
|
||||
performanceMonitor.mark('fetch-points')
|
||||
const points = await this.api.fetchAllPoints({
|
||||
start_at: this.startDateValue,
|
||||
end_at: this.endDateValue,
|
||||
onProgress: this.updateLoadingProgress.bind(this)
|
||||
})
|
||||
performanceMonitor.measure('fetch-points')
|
||||
|
||||
// Transform to GeoJSON for points
|
||||
performanceMonitor.mark('transform-geojson')
|
||||
const pointsGeoJSON = pointsToGeoJSON(points)
|
||||
performanceMonitor.measure('transform-geojson')
|
||||
|
||||
// Create routes from points
|
||||
const routesGeoJSON = RoutesLayer.pointsToRoutes(points)
|
||||
|
||||
// Define all layer add functions
|
||||
const addRoutesLayer = () => {
|
||||
if (!this.routesLayer) {
|
||||
this.routesLayer = new RoutesLayer(this.map)
|
||||
this.routesLayer.add(routesGeoJSON)
|
||||
} else {
|
||||
this.routesLayer.update(routesGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
const addPointsLayer = () => {
|
||||
if (!this.pointsLayer) {
|
||||
this.pointsLayer = new PointsLayer(this.map)
|
||||
this.pointsLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.pointsLayer.update(pointsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
const addHeatmapLayer = () => {
|
||||
if (!this.heatmapLayer) {
|
||||
this.heatmapLayer = new HeatmapLayer(this.map, {
|
||||
visible: this.settings.heatmapEnabled
|
||||
})
|
||||
this.heatmapLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.heatmapLayer.update(pointsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Load visits
|
||||
let visits = []
|
||||
try {
|
||||
visits = await this.api.fetchVisits({
|
||||
start_at: this.startDateValue,
|
||||
end_at: this.endDateValue
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch visits:', error)
|
||||
// Continue with empty visits array
|
||||
}
|
||||
|
||||
const visitsGeoJSON = this.visitsToGeoJSON(visits)
|
||||
this.allVisits = visits // Store for filtering
|
||||
|
||||
const addVisitsLayer = () => {
|
||||
if (!this.visitsLayer) {
|
||||
this.visitsLayer = new VisitsLayer(this.map, {
|
||||
visible: this.settings.visitsEnabled || false
|
||||
})
|
||||
this.visitsLayer.add(visitsGeoJSON)
|
||||
} else {
|
||||
this.visitsLayer.update(visitsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Load photos
|
||||
let photos = []
|
||||
try {
|
||||
console.log('[Photos] Fetching photos from:', this.startDateValue, 'to', this.endDateValue)
|
||||
photos = await this.api.fetchPhotos({
|
||||
start_at: this.startDateValue,
|
||||
end_at: this.endDateValue
|
||||
})
|
||||
console.log('[Photos] Fetched photos:', photos.length, 'photos')
|
||||
console.log('[Photos] Sample photo:', photos[0])
|
||||
} catch (error) {
|
||||
console.error('[Photos] Failed to fetch photos:', error)
|
||||
// Continue with empty photos array
|
||||
}
|
||||
|
||||
const photosGeoJSON = this.photosToGeoJSON(photos)
|
||||
console.log('[Photos] Converted to GeoJSON:', photosGeoJSON.features.length, 'features')
|
||||
console.log('[Photos] Sample feature:', photosGeoJSON.features[0])
|
||||
|
||||
const addPhotosLayer = async () => {
|
||||
console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled)
|
||||
if (!this.photosLayer) {
|
||||
this.photosLayer = new PhotosLayer(this.map, {
|
||||
visible: this.settings.photosEnabled || false
|
||||
})
|
||||
console.log('[Photos] Created new PhotosLayer instance')
|
||||
await this.photosLayer.add(photosGeoJSON)
|
||||
console.log('[Photos] Added photos to layer')
|
||||
} else {
|
||||
console.log('[Photos] Updating existing PhotosLayer')
|
||||
await this.photosLayer.update(photosGeoJSON)
|
||||
console.log('[Photos] Updated photos layer')
|
||||
}
|
||||
}
|
||||
|
||||
// Load areas
|
||||
let areas = []
|
||||
try {
|
||||
areas = await this.api.fetchAreas()
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch areas:', error)
|
||||
// Continue with empty areas array
|
||||
}
|
||||
|
||||
const areasGeoJSON = this.areasToGeoJSON(areas)
|
||||
|
||||
const addAreasLayer = () => {
|
||||
if (!this.areasLayer) {
|
||||
this.areasLayer = new AreasLayer(this.map, {
|
||||
visible: this.settings.areasEnabled || false
|
||||
})
|
||||
this.areasLayer.add(areasGeoJSON)
|
||||
} else {
|
||||
this.areasLayer.update(areasGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Load tracks - DISABLED: Backend API not yet implemented
|
||||
// TODO: Re-enable when /api/v1/tracks endpoint is created
|
||||
const tracks = []
|
||||
const tracksGeoJSON = this.tracksToGeoJSON(tracks)
|
||||
|
||||
const addTracksLayer = () => {
|
||||
if (!this.tracksLayer) {
|
||||
this.tracksLayer = new TracksLayer(this.map, {
|
||||
visible: this.settings.tracksEnabled || false
|
||||
})
|
||||
this.tracksLayer.add(tracksGeoJSON)
|
||||
} else {
|
||||
this.tracksLayer.update(tracksGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Add scratch layer (lazy loaded)
|
||||
const addScratchLayer = async () => {
|
||||
try {
|
||||
if (!this.scratchLayer && this.settings.scratchEnabled) {
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
this.scratchLayer = new ScratchLayer(this.map, {
|
||||
visible: true,
|
||||
apiClient: this.api // Pass API client for authenticated requests
|
||||
})
|
||||
await this.scratchLayer.add(pointsGeoJSON)
|
||||
} else if (this.scratchLayer) {
|
||||
await this.scratchLayer.update(pointsGeoJSON)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load scratch layer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add family layer (for real-time family locations)
|
||||
const addFamilyLayer = () => {
|
||||
if (!this.familyLayer) {
|
||||
this.familyLayer = new FamilyLayer(this.map, {
|
||||
visible: false // Initially hidden, shown when family locations arrive via ActionCable
|
||||
})
|
||||
this.familyLayer.add({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
// Add all layers when style is ready
|
||||
// Note: Layer order matters - layers added first render below layers added later
|
||||
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> photos -> family -> points (top) -> fog (canvas overlay)
|
||||
const addAllLayers = async () => {
|
||||
performanceMonitor.mark('add-layers')
|
||||
|
||||
await addScratchLayer() // Add scratch first (renders at bottom) - lazy loaded
|
||||
addHeatmapLayer() // Add heatmap second
|
||||
addAreasLayer() // Add areas third
|
||||
addTracksLayer() // Add tracks fourth
|
||||
addRoutesLayer() // Add routes fifth
|
||||
addVisitsLayer() // Add visits sixth
|
||||
|
||||
// Add photos layer with error handling (async, might fail loading images)
|
||||
try {
|
||||
await addPhotosLayer() // Add photos seventh (async for image loading)
|
||||
} catch (error) {
|
||||
console.warn('Failed to add photos layer:', error)
|
||||
}
|
||||
|
||||
addFamilyLayer() // Add family layer (real-time family locations)
|
||||
addPointsLayer() // Add points last (renders on top)
|
||||
|
||||
// Add fog layer (canvas overlay, separate from MapLibre layers)
|
||||
// Always create fog layer for backward compatibility
|
||||
if (!this.fogLayer) {
|
||||
this.fogLayer = new FogLayer(this.map, {
|
||||
clearRadius: 1000,
|
||||
visible: this.settings.fogEnabled || false
|
||||
})
|
||||
this.fogLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.fogLayer.update(pointsGeoJSON)
|
||||
}
|
||||
|
||||
performanceMonitor.measure('add-layers')
|
||||
|
||||
// Add click handlers for visits and photos
|
||||
this.map.on('click', 'visits', this.handleVisitClick.bind(this))
|
||||
this.map.on('click', 'photos', this.handlePhotoClick.bind(this))
|
||||
|
||||
// Change cursor on hover
|
||||
this.map.on('mouseenter', 'visits', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'visits', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
this.map.on('mouseenter', 'photos', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'photos', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
}
|
||||
|
||||
// Use 'load' event which fires when map is fully initialized
|
||||
// This is more reliable than 'style.load'
|
||||
if (this.map.loaded()) {
|
||||
await addAllLayers()
|
||||
} else {
|
||||
this.map.once('load', async () => {
|
||||
await addAllLayers()
|
||||
})
|
||||
}
|
||||
|
||||
// Fit map to data bounds
|
||||
if (points.length > 0) {
|
||||
this.fitMapToBounds(pointsGeoJSON)
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
Toast.success(`Loaded ${points.length} location ${points.length === 1 ? 'point' : 'points'}`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error)
|
||||
Toast.error('Failed to load location data. Please try again.')
|
||||
} finally {
|
||||
this.hideLoading()
|
||||
const duration = performanceMonitor.measure('load-map-data')
|
||||
console.log(`[Performance] Map data loaded in ${duration}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle point click
|
||||
*/
|
||||
handlePointClick(e) {
|
||||
const feature = e.features[0]
|
||||
const coordinates = feature.geometry.coordinates.slice()
|
||||
const properties = feature.properties
|
||||
|
||||
// Create popup
|
||||
new maplibregl.Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(PopupFactory.createPointPopup(properties))
|
||||
.addTo(this.map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to data bounds
|
||||
*/
|
||||
fitMapToBounds(geojson) {
|
||||
const coordinates = geojson.features.map(f => f.geometry.coordinates)
|
||||
|
||||
const bounds = coordinates.reduce((bounds, coord) => {
|
||||
return bounds.extend(coord)
|
||||
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
|
||||
|
||||
this.map.fitBounds(bounds, {
|
||||
padding: 50,
|
||||
maxZoom: 15
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for API requests (matching V1 format)
|
||||
* Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59")
|
||||
*/
|
||||
formatDateForAPI(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hours = pad(date.getHours())
|
||||
const minutes = pad(date.getMinutes())
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Month selector changed
|
||||
*/
|
||||
monthChanged(event) {
|
||||
const [year, month] = event.target.value.split('-')
|
||||
|
||||
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
|
||||
const lastDay = new Date(year, month, 0).getDate()
|
||||
const endDate = new Date(year, month - 1, lastDay, 23, 59, 0)
|
||||
|
||||
this.startDateValue = this.formatDateForAPI(startDate)
|
||||
this.endDateValue = this.formatDateForAPI(endDate)
|
||||
|
||||
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
|
||||
|
||||
// Reload data
|
||||
this.loadMapData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading indicator
|
||||
*/
|
||||
showLoading() {
|
||||
this.loadingTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading indicator
|
||||
*/
|
||||
hideLoading() {
|
||||
this.loadingTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update loading progress
|
||||
*/
|
||||
updateLoadingProgress({ loaded, totalPages, progress }) {
|
||||
if (this.hasLoadingTextTarget) {
|
||||
const percentage = Math.round(progress * 100)
|
||||
this.loadingTextTarget.textContent = `Loading... ${percentage}%`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle layer visibility
|
||||
*/
|
||||
toggleLayer(event) {
|
||||
const button = event.currentTarget
|
||||
const layerName = button.dataset.layer
|
||||
|
||||
// Get the layer instance
|
||||
const layer = this[`${layerName}Layer`]
|
||||
if (!layer) return
|
||||
|
||||
// Toggle visibility
|
||||
layer.toggle()
|
||||
|
||||
// Update button style
|
||||
if (layer.visible) {
|
||||
button.classList.add('btn-primary')
|
||||
button.classList.remove('btn-outline')
|
||||
} else {
|
||||
button.classList.remove('btn-primary')
|
||||
button.classList.add('btn-outline')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle point clustering
|
||||
*/
|
||||
toggleClustering(event) {
|
||||
if (!this.pointsLayer) return
|
||||
|
||||
const button = event.currentTarget
|
||||
|
||||
// Toggle clustering state
|
||||
const newClusteringState = !this.pointsLayer.clusteringEnabled
|
||||
this.pointsLayer.toggleClustering(newClusteringState)
|
||||
|
||||
// Update button style to reflect state
|
||||
if (newClusteringState) {
|
||||
button.classList.add('btn-primary')
|
||||
button.classList.remove('btn-outline')
|
||||
} else {
|
||||
button.classList.remove('btn-primary')
|
||||
button.classList.add('btn-outline')
|
||||
}
|
||||
|
||||
// Save setting
|
||||
SettingsManager.updateSetting('clustering', newClusteringState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle settings panel
|
||||
*/
|
||||
toggleSettings() {
|
||||
if (this.hasSettingsPanelTarget) {
|
||||
this.settingsPanelTarget.classList.toggle('open')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map style from settings
|
||||
*/
|
||||
async updateMapStyle(event) {
|
||||
const styleName = event.target.value
|
||||
SettingsManager.updateSetting('mapStyle', styleName)
|
||||
|
||||
const style = await getMapStyle(styleName)
|
||||
|
||||
// Store current data
|
||||
const pointsData = this.pointsLayer?.data
|
||||
const routesData = this.routesLayer?.data
|
||||
const heatmapData = this.heatmapLayer?.data
|
||||
|
||||
// Clear layer references
|
||||
this.pointsLayer = null
|
||||
this.routesLayer = null
|
||||
this.heatmapLayer = null
|
||||
|
||||
this.map.setStyle(style)
|
||||
|
||||
// Reload layers after style change
|
||||
this.map.once('style.load', () => {
|
||||
console.log('Style loaded, reloading map data')
|
||||
this.loadMapData()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heatmap visibility
|
||||
*/
|
||||
toggleHeatmap(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('heatmapEnabled', enabled)
|
||||
|
||||
if (this.heatmapLayer) {
|
||||
if (enabled) {
|
||||
this.heatmapLayer.show()
|
||||
} else {
|
||||
this.heatmapLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
resetSettings() {
|
||||
if (confirm('Reset all settings to defaults? This will reload the page.')) {
|
||||
SettingsManager.resetToDefaults()
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert visits to GeoJSON
|
||||
*/
|
||||
visitsToGeoJSON(visits) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: visits.map(visit => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [visit.place.longitude, visit.place.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: visit.id,
|
||||
name: visit.name,
|
||||
place_name: visit.place?.name,
|
||||
status: visit.status,
|
||||
started_at: visit.started_at,
|
||||
ended_at: visit.ended_at,
|
||||
duration: visit.duration
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert photos to GeoJSON
|
||||
*/
|
||||
photosToGeoJSON(photos) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: photos.map(photo => {
|
||||
// Construct thumbnail URL
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.api.apiKey}&source=${photo.source}`
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [photo.longitude, photo.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: photo.id,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
taken_at: photo.localDateTime,
|
||||
filename: photo.originalFileName,
|
||||
city: photo.city,
|
||||
state: photo.state,
|
||||
country: photo.country,
|
||||
type: photo.type,
|
||||
source: photo.source
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert areas to GeoJSON
|
||||
* Backend returns circular areas with latitude, longitude, radius
|
||||
*/
|
||||
areasToGeoJSON(areas) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: areas.map(area => {
|
||||
// Create circle polygon from center and radius
|
||||
const center = [area.longitude, area.latitude]
|
||||
const coordinates = createCircle(center, area.radius)
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates]
|
||||
},
|
||||
properties: {
|
||||
id: area.id,
|
||||
name: area.name,
|
||||
color: area.color || '#3b82f6',
|
||||
radius: area.radius
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tracks to GeoJSON
|
||||
*/
|
||||
tracksToGeoJSON(tracks) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: tracks.map(track => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: track.coordinates
|
||||
},
|
||||
properties: {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
color: track.color || '#8b5cf6'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit click
|
||||
*/
|
||||
handleVisitClick(e) {
|
||||
const feature = e.features[0]
|
||||
const coordinates = feature.geometry.coordinates.slice()
|
||||
const properties = feature.properties
|
||||
|
||||
new maplibregl.Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(VisitPopupFactory.createVisitPopup(properties))
|
||||
.addTo(this.map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle photo click
|
||||
*/
|
||||
handlePhotoClick(e) {
|
||||
const feature = e.features[0]
|
||||
const coordinates = feature.geometry.coordinates.slice()
|
||||
const properties = feature.properties
|
||||
|
||||
new maplibregl.Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(PhotoPopupFactory.createPhotoPopup(properties))
|
||||
.addTo(this.map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visits layer
|
||||
*/
|
||||
toggleVisits(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('visitsEnabled', enabled)
|
||||
|
||||
if (this.visitsLayer) {
|
||||
if (enabled) {
|
||||
this.visitsLayer.show()
|
||||
// Show visits search
|
||||
if (this.hasVisitsSearchTarget) {
|
||||
this.visitsSearchTarget.style.display = 'block'
|
||||
}
|
||||
} else {
|
||||
this.visitsLayer.hide()
|
||||
// Hide visits search
|
||||
if (this.hasVisitsSearchTarget) {
|
||||
this.visitsSearchTarget.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle photos layer
|
||||
*/
|
||||
togglePhotos(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('photosEnabled', enabled)
|
||||
|
||||
if (this.photosLayer) {
|
||||
if (enabled) {
|
||||
this.photosLayer.show()
|
||||
} else {
|
||||
this.photosLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visits
|
||||
*/
|
||||
searchVisits(event) {
|
||||
const searchTerm = event.target.value.toLowerCase()
|
||||
this.filterAndUpdateVisits(searchTerm, this.currentVisitFilter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter visits by status
|
||||
*/
|
||||
filterVisits(event) {
|
||||
const filter = event.target.value
|
||||
this.currentVisitFilter = filter
|
||||
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
|
||||
this.filterAndUpdateVisits(searchTerm, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and update visits display
|
||||
*/
|
||||
filterAndUpdateVisits(searchTerm, statusFilter) {
|
||||
if (!this.allVisits || !this.visitsLayer) return
|
||||
|
||||
const filtered = this.allVisits.filter(visit => {
|
||||
// Apply search
|
||||
const matchesSearch = !searchTerm ||
|
||||
visit.name?.toLowerCase().includes(searchTerm) ||
|
||||
visit.place?.name?.toLowerCase().includes(searchTerm)
|
||||
|
||||
// Apply status filter
|
||||
const matchesStatus = statusFilter === 'all' || visit.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const geojson = this.visitsToGeoJSON(filtered)
|
||||
this.visitsLayer.update(geojson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle areas layer
|
||||
*/
|
||||
toggleAreas(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('areasEnabled', enabled)
|
||||
|
||||
if (this.areasLayer) {
|
||||
if (enabled) {
|
||||
this.areasLayer.show()
|
||||
} else {
|
||||
this.areasLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle tracks layer
|
||||
*/
|
||||
toggleTracks(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('tracksEnabled', enabled)
|
||||
|
||||
if (this.tracksLayer) {
|
||||
if (enabled) {
|
||||
this.tracksLayer.show()
|
||||
} else {
|
||||
this.tracksLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fog of war layer
|
||||
*/
|
||||
toggleFog(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('fogEnabled', enabled)
|
||||
|
||||
if (this.fogLayer) {
|
||||
this.fogLayer.toggle(enabled)
|
||||
} else {
|
||||
console.warn('Fog layer not yet initialized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle scratch map layer
|
||||
*/
|
||||
async toggleScratch(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('scratchEnabled', enabled)
|
||||
|
||||
try {
|
||||
if (!this.scratchLayer && enabled) {
|
||||
// Lazy load scratch layer
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
this.scratchLayer = new ScratchLayer(this.map, {
|
||||
visible: true,
|
||||
apiClient: this.api
|
||||
})
|
||||
const pointsData = this.pointsLayer?.data || { type: 'FeatureCollection', features: [] }
|
||||
await this.scratchLayer.add(pointsData)
|
||||
} else if (this.scratchLayer) {
|
||||
if (enabled) {
|
||||
this.scratchLayer.show()
|
||||
} else {
|
||||
this.scratchLayer.hide()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle scratch layer:', error)
|
||||
Toast.error('Failed to load scratch layer')
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,8 +26,8 @@ export class AreasLayer extends BaseLayer {
|
|||
type: 'fill',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'fill-color': ['get', 'color'],
|
||||
'fill-opacity': 0.2
|
||||
'fill-color': '#ff0000',
|
||||
'fill-opacity': 0.4
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -37,8 +37,8 @@ export class AreasLayer extends BaseLayer {
|
|||
type: 'line',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 2
|
||||
'line-color': '#ff0000',
|
||||
'line-width': 3
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<div data-controller="area-creation-v2"
|
||||
data-area-creation-v2-api-key-value="<%= current_user.api_key %>"
|
||||
data-area-drawer-area-creation-v2-outlet=".area-creation-v2"
|
||||
data-action="area-drawer:drawn->area-creation-v2#handleAreaDrawn">
|
||||
data-area-creation-v2-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal" data-area-creation-v2-target="modal">
|
||||
<div class="modal-box max-w-xl">
|
||||
<h3 class="font-bold text-lg mb-4">Create New Area</h3>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
<%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %>
|
||||
|
||||
<div data-controller="maps-v2 area-drawer"
|
||||
<div id="maps-v2-container"
|
||||
data-controller="maps-v2 area-drawer"
|
||||
data-maps-v2-api-key-value="<%= current_user.api_key %>"
|
||||
data-maps-v2-start-date-value="<%= @start_at.to_s %>"
|
||||
data-maps-v2-end-date-value="<%= @end_at.to_s %>"
|
||||
data-area-drawer-maps-v2-outlet="#maps-v2-container"
|
||||
style="width: 100%; height: 100%; position: relative;">
|
||||
<!--
|
||||
Phase 7 Realtime Controller: Currently disabled pending initialization fix
|
||||
|
|
|
|||
|
|
@ -13,20 +13,26 @@ test.describe('Areas Layer', () => {
|
|||
|
||||
test.describe('Toggle', () => {
|
||||
test('areas layer toggle exists', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
// Open settings panel
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Click Layers tab
|
||||
await page.locator('button[data-tab="layers"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const areasToggle = page.locator('label:has-text("Areas")').first().locator('input.toggle')
|
||||
await expect(areasToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle areas layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
// Open settings panel
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Click Layers tab
|
||||
await page.locator('button[data-tab="layers"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const areasToggle = page.locator('label:has-text("Areas")').first().locator('input.toggle')
|
||||
await areasToggle.check()
|
||||
|
|
@ -36,4 +42,151 @@ test.describe('Areas Layer', () => {
|
|||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Area Creation', () => {
|
||||
test('should have Create an Area button in Tools tab', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Click Tools tab
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Verify Create an Area button exists
|
||||
const createAreaButton = page.locator('button:has-text("Create an Area")')
|
||||
await expect(createAreaButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should change cursor to crosshair when Create an Area is clicked', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Click Create an Area
|
||||
await page.locator('button:has-text("Create an Area")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify cursor changed to crosshair
|
||||
const cursorStyle = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.maplibregl-canvas')
|
||||
return canvas ? window.getComputedStyle(canvas).cursor : null
|
||||
})
|
||||
expect(cursorStyle).toBe('crosshair')
|
||||
})
|
||||
|
||||
test('should show area preview while drawing', async ({ page }) => {
|
||||
// Enable creation mode
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button:has-text("Create an Area")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// First click to set center
|
||||
const mapCanvas = page.locator('.maplibregl-canvas')
|
||||
await mapCanvas.click({ position: { x: 400, y: 300 } })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Move mouse to create radius preview
|
||||
await mapCanvas.hover({ position: { x: 450, y: 350 } })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify draw layers exist
|
||||
const hasDrawLayers = await page.evaluate(() => {
|
||||
const map = window.maplibreMap
|
||||
return map && map.getSource('draw-source') !== undefined
|
||||
})
|
||||
expect(hasDrawLayers).toBe(true)
|
||||
})
|
||||
|
||||
test('should open modal when area is drawn', async ({ page }) => {
|
||||
// Enable creation mode
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button:has-text("Create an Area")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Draw area: first click for center, second click to finish
|
||||
const mapCanvas = page.locator('.maplibregl-canvas')
|
||||
await mapCanvas.click({ position: { x: 400, y: 300 } })
|
||||
await page.waitForTimeout(300)
|
||||
await mapCanvas.click({ position: { x: 450, y: 350 } })
|
||||
|
||||
// Wait for area creation modal to appear
|
||||
const areaModalBox = page.locator('[data-controller="area-creation-v2"] .modal-box')
|
||||
await areaModalBox.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
// Verify form fields exist
|
||||
await expect(page.locator('[data-area-creation-v2-target="nameInput"]')).toBeVisible()
|
||||
await expect(page.locator('[data-area-creation-v2-target="radiusDisplay"]')).toBeVisible()
|
||||
await expect(page.locator('[data-area-creation-v2-target="locationDisplay"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display radius and location in modal', async ({ page }) => {
|
||||
// Enable creation mode and draw area
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button:has-text("Create an Area")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const mapCanvas = page.locator('.maplibregl-canvas')
|
||||
await mapCanvas.click({ position: { x: 400, y: 300 } })
|
||||
await page.waitForTimeout(300)
|
||||
await mapCanvas.click({ position: { x: 450, y: 350 } })
|
||||
|
||||
// Wait for modal
|
||||
const areaModalBox = page.locator('[data-controller="area-creation-v2"] .modal-box')
|
||||
await areaModalBox.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
// Verify radius has a value
|
||||
const radiusValue = await page.locator('[data-area-creation-v2-target="radiusDisplay"]').inputValue()
|
||||
expect(parseInt(radiusValue)).toBeGreaterThan(0)
|
||||
|
||||
// Verify location has a value (should be coordinates)
|
||||
const locationValue = await page.locator('[data-area-creation-v2-target="locationDisplay"]').inputValue()
|
||||
expect(locationValue).toMatch(/\d+\.\d+,\s*\d+\.\d+/)
|
||||
})
|
||||
|
||||
test('should create area and enable layer when submitted', async ({ page }) => {
|
||||
// Draw area
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="tools"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button:has-text("Create an Area")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const mapCanvas = page.locator('.maplibregl-canvas')
|
||||
await mapCanvas.click({ position: { x: 400, y: 300 } })
|
||||
await page.waitForTimeout(300)
|
||||
await mapCanvas.click({ position: { x: 450, y: 350 } })
|
||||
|
||||
// Wait for modal and fill form
|
||||
const areaModalBox = page.locator('[data-controller="area-creation-v2"] .modal-box')
|
||||
await areaModalBox.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
await page.locator('[data-area-creation-v2-target="nameInput"]').fill('Test Area E2E')
|
||||
await page.locator('button[type="submit"]:has-text("Create Area")').click()
|
||||
|
||||
// Wait for modal to close
|
||||
await areaModalBox.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
|
||||
// Verify areas layer is now enabled
|
||||
await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('button[data-tab="layers"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const areasToggle = page.locator('label:has-text("Areas")').first().locator('input.toggle')
|
||||
await expect(areasToggle).toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue