mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Extract js to modules from maps_v2_controller.js
This commit is contained in:
parent
236da955d4
commit
dad5fa9c4f
13 changed files with 3960 additions and 1707 deletions
File diff suppressed because one or more lines are too long
1
app/assets/svg/icons/lucide/outline/circle-plus.svg
Normal file
1
app/assets/svg/icons/lucide/outline/circle-plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||
|
After Width: | Height: | Size: 316 B |
204
app/javascript/controllers/area_creation_v2_controller.js
Normal file
204
app/javascript/controllers/area_creation_v2_controller.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
'modal',
|
||||
'form',
|
||||
'nameInput',
|
||||
'latitudeInput',
|
||||
'longitudeInput',
|
||||
'radiusInput',
|
||||
'radiusDisplay',
|
||||
'locationDisplay',
|
||||
'submitButton',
|
||||
'submitSpinner',
|
||||
'submitText'
|
||||
]
|
||||
|
||||
static values = {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
open(lat = null, lng = null, mapsController = null) {
|
||||
console.log('[Area Creation V2] Opening modal', { lat, lng })
|
||||
|
||||
this.mapsController = mapsController
|
||||
this.latitude = lat
|
||||
this.longitude = lng
|
||||
this.radius = 100 // Default radius in meters
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Clear form
|
||||
this.nameInputTarget.value = ''
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and cancel drawing
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
const name = formData.get('name')
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
Toast.error('Please enter an area name')
|
||||
this.nameInputTarget.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.submitButtonTarget.disabled = true
|
||||
this.submitSpinnerTarget.classList.remove('hidden')
|
||||
this.submitTextTarget.textContent = 'Creating...'
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/areas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
latitude: this.latitude,
|
||||
longitude: this.longitude,
|
||||
radius: this.radius
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to create area')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('[Area Creation V2] Area created:', data)
|
||||
|
||||
Toast.success(`Area "${name}" created successfully`)
|
||||
|
||||
// Dispatch event to notify maps controller
|
||||
document.dispatchEvent(new CustomEvent('area:created', {
|
||||
detail: { area: data }
|
||||
}))
|
||||
|
||||
this.close()
|
||||
} catch (error) {
|
||||
console.error('[Area Creation V2] Failed to create area:', error)
|
||||
Toast.error(error.message || 'Failed to create area')
|
||||
} finally {
|
||||
// Reset button state
|
||||
this.submitButtonTarget.disabled = false
|
||||
this.submitSpinnerTarget.classList.add('hidden')
|
||||
this.submitTextTarget.textContent = 'Create Area'
|
||||
}
|
||||
}
|
||||
}
|
||||
540
app/javascript/controllers/maps_v2/area_selection_manager.js
Normal file
540
app/javascript/controllers/maps_v2/area_selection_manager.js
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { SelectionLayer } from 'maps_v2/layers/selection_layer'
|
||||
import { SelectedPointsLayer } from 'maps_v2/layers/selected_points_layer'
|
||||
import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers'
|
||||
import { VisitCard } from 'maps_v2/components/visit_card'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
|
||||
/**
|
||||
* Manages area selection and bulk operations for Maps V2
|
||||
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
|
||||
*/
|
||||
export class AreaSelectionManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.map = controller.map
|
||||
this.api = controller.api
|
||||
this.selectionLayer = null
|
||||
this.selectedPointsLayer = null
|
||||
this.selectedVisits = []
|
||||
this.selectedVisitIds = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start area selection mode
|
||||
*/
|
||||
async startSelectArea() {
|
||||
console.log('[Maps V2] Starting area selection mode')
|
||||
|
||||
// Initialize selection layer if not exists
|
||||
if (!this.selectionLayer) {
|
||||
this.selectionLayer = new SelectionLayer(this.map, {
|
||||
visible: true,
|
||||
onSelectionComplete: this.handleAreaSelected.bind(this)
|
||||
})
|
||||
|
||||
this.selectionLayer.add({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selection layer initialized')
|
||||
}
|
||||
|
||||
// Initialize selected points layer if not exists
|
||||
if (!this.selectedPointsLayer) {
|
||||
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
|
||||
visible: true
|
||||
})
|
||||
|
||||
this.selectedPointsLayer.add({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selected points layer initialized')
|
||||
}
|
||||
|
||||
// Enable selection mode
|
||||
this.selectionLayer.enableSelectionMode()
|
||||
|
||||
// Update UI - replace Select Area button with Cancel Selection button
|
||||
if (this.controller.hasSelectAreaButtonTarget) {
|
||||
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
Cancel Selection
|
||||
`
|
||||
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps-v2#cancelAreaSelection'
|
||||
}
|
||||
|
||||
Toast.info('Draw a rectangle on the map to select points')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle area selection completion
|
||||
*/
|
||||
async handleAreaSelected(bounds) {
|
||||
console.log('[Maps V2] Area selected:', bounds)
|
||||
|
||||
try {
|
||||
Toast.info('Fetching data in selected area...')
|
||||
|
||||
const [points, visits] = await Promise.all([
|
||||
this.api.fetchPointsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
min_longitude: bounds.minLng,
|
||||
max_longitude: bounds.maxLng,
|
||||
min_latitude: bounds.minLat,
|
||||
max_latitude: bounds.maxLat
|
||||
}),
|
||||
this.api.fetchVisitsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
sw_lat: bounds.minLat,
|
||||
sw_lng: bounds.minLng,
|
||||
ne_lat: bounds.maxLat,
|
||||
ne_lng: bounds.maxLng
|
||||
})
|
||||
])
|
||||
|
||||
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
|
||||
|
||||
if (points.length === 0 && visits.length === 0) {
|
||||
Toast.info('No data found in selected area')
|
||||
this.cancelAreaSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// Convert points to GeoJSON and display
|
||||
if (points.length > 0) {
|
||||
const geojson = pointsToGeoJSON(points)
|
||||
this.selectedPointsLayer.updateSelectedPoints(geojson)
|
||||
this.selectedPointsLayer.show()
|
||||
}
|
||||
|
||||
// Display visits in side panel and on map
|
||||
if (visits.length > 0) {
|
||||
this.displaySelectedVisits(visits)
|
||||
}
|
||||
|
||||
// Update UI - show action buttons
|
||||
if (this.controller.hasSelectionActionsTarget) {
|
||||
this.controller.selectionActionsTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
// Update delete button text with count
|
||||
if (this.controller.hasDeleteButtonTextTarget) {
|
||||
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
// Disable selection mode
|
||||
this.selectionLayer.disableSelectionMode()
|
||||
|
||||
const messages = []
|
||||
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
|
||||
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
|
||||
|
||||
Toast.success(`Selected ${messages.join(' and ')}`)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to fetch data in area:', error)
|
||||
Toast.error('Failed to fetch data in selected area')
|
||||
this.cancelAreaSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display selected visits in side panel
|
||||
*/
|
||||
displaySelectedVisits(visits) {
|
||||
if (!this.controller.hasSelectedVisitsContainerTarget) return
|
||||
|
||||
this.selectedVisits = visits
|
||||
this.selectedVisitIds = new Set()
|
||||
|
||||
const cardsHTML = visits.map(visit =>
|
||||
VisitCard.create(visit, { isSelected: false })
|
||||
).join('')
|
||||
|
||||
this.controller.selectedVisitsContainerTarget.innerHTML = `
|
||||
<div class="selected-visits-list">
|
||||
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
|
||||
</div>
|
||||
${cardsHTML}
|
||||
</div>
|
||||
`
|
||||
|
||||
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
|
||||
this.attachVisitCardListeners()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.updateBulkActions()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to visit cards
|
||||
*/
|
||||
attachVisitCardListeners() {
|
||||
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
const visitId = parseInt(e.target.dataset.visitSelect)
|
||||
if (e.target.checked) {
|
||||
this.selectedVisitIds.add(visitId)
|
||||
} else {
|
||||
this.selectedVisitIds.delete(visitId)
|
||||
}
|
||||
this.updateBulkActions()
|
||||
})
|
||||
})
|
||||
|
||||
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
|
||||
await this.confirmVisit(visitId)
|
||||
})
|
||||
})
|
||||
|
||||
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
|
||||
await this.declineVisit(visitId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bulk action buttons visibility and attach listeners
|
||||
*/
|
||||
updateBulkActions() {
|
||||
const selectedCount = this.selectedVisitIds.size
|
||||
|
||||
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
|
||||
existingBulkActions.forEach(el => el.remove())
|
||||
|
||||
if (selectedCount >= 2) {
|
||||
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
|
||||
.filter(card => {
|
||||
const visitId = parseInt(card.dataset.visitId)
|
||||
return this.selectedVisitIds.has(visitId)
|
||||
})
|
||||
|
||||
if (selectedVisitCards.length > 0) {
|
||||
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
|
||||
|
||||
const bulkActionsDiv = document.createElement('div')
|
||||
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
|
||||
bulkActionsDiv.innerHTML = `
|
||||
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
|
||||
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
Merge
|
||||
</button>
|
||||
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
|
||||
|
||||
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
|
||||
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
|
||||
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
|
||||
|
||||
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
|
||||
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
|
||||
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a single visit
|
||||
*/
|
||||
async confirmVisit(visitId) {
|
||||
try {
|
||||
await this.api.updateVisitStatus(visitId, 'confirmed')
|
||||
Toast.success('Visit confirmed')
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to confirm visit:', error)
|
||||
Toast.error('Failed to confirm visit')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a single visit
|
||||
*/
|
||||
async declineVisit(visitId) {
|
||||
try {
|
||||
await this.api.updateVisitStatus(visitId, 'declined')
|
||||
Toast.success('Visit declined')
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to decline visit:', error)
|
||||
Toast.error('Failed to decline visit')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk merge selected visits
|
||||
*/
|
||||
async bulkMergeVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
if (visitIds.length < 2) {
|
||||
Toast.error('Select at least 2 visits to merge')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.info('Merging visits...')
|
||||
const mergedVisit = await this.api.mergeVisits(visitIds)
|
||||
Toast.success('Visits merged successfully')
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
this.replaceVisitsWithMerged(visitIds, mergedVisit)
|
||||
this.updateBulkActions()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to merge visits:', error)
|
||||
Toast.error('Failed to merge visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk confirm selected visits
|
||||
*/
|
||||
async bulkConfirmVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
try {
|
||||
Toast.info('Confirming visits...')
|
||||
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
|
||||
Toast.success(`Confirmed ${visitIds.length} visits`)
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to confirm visits:', error)
|
||||
Toast.error('Failed to confirm visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk decline selected visits
|
||||
*/
|
||||
async bulkDeclineVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
if (!confirm(`Decline ${visitIds.length} visits?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.info('Declining visits...')
|
||||
await this.api.bulkUpdateVisits(visitIds, 'declined')
|
||||
Toast.success(`Declined ${visitIds.length} visits`)
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to decline visits:', error)
|
||||
Toast.error('Failed to decline visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace merged visit cards with the new merged visit
|
||||
*/
|
||||
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
|
||||
const container = this.controller.element.querySelector('.selected-visits-list')
|
||||
if (!container) return
|
||||
|
||||
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
|
||||
const allCards = Array.from(container.querySelectorAll('.visit-card'))
|
||||
|
||||
let insertBeforeCard = null
|
||||
for (const card of allCards) {
|
||||
const cardId = parseInt(card.dataset.visitId)
|
||||
if (oldVisitIds.includes(cardId)) continue
|
||||
|
||||
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
|
||||
if (cardVisit) {
|
||||
const cardStartTime = new Date(cardVisit.started_at).getTime()
|
||||
if (cardStartTime > mergedStartTime) {
|
||||
insertBeforeCard = card
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldVisitIds.forEach(id => {
|
||||
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
|
||||
if (card) card.remove()
|
||||
})
|
||||
|
||||
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
|
||||
this.selectedVisits.push(mergedVisit)
|
||||
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
|
||||
|
||||
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
|
||||
|
||||
if (insertBeforeCard) {
|
||||
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
|
||||
} else {
|
||||
container.insertAdjacentHTML('beforeend', newCardHTML)
|
||||
}
|
||||
|
||||
const header = container.querySelector('h3')
|
||||
if (header) {
|
||||
header.textContent = `Visits in Area (${this.selectedVisits.length})`
|
||||
}
|
||||
|
||||
this.attachVisitCardListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh selected visits after changes
|
||||
*/
|
||||
async refreshSelectedVisits() {
|
||||
const bounds = this.selectionLayer.currentRect
|
||||
if (!bounds) return
|
||||
|
||||
try {
|
||||
const visits = await this.api.fetchVisitsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
|
||||
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
|
||||
})
|
||||
|
||||
this.displaySelectedVisits(visits)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to refresh visits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel area selection
|
||||
*/
|
||||
cancelAreaSelection() {
|
||||
console.log('[Maps V2] Cancelling area selection')
|
||||
|
||||
if (this.selectionLayer) {
|
||||
this.selectionLayer.disableSelectionMode()
|
||||
this.selectionLayer.clearSelection()
|
||||
}
|
||||
|
||||
if (this.selectedPointsLayer) {
|
||||
this.selectedPointsLayer.clearSelection()
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectedVisitsContainerTarget) {
|
||||
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
|
||||
this.controller.selectedVisitsContainerTarget.innerHTML = ''
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
|
||||
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
this.selectedVisits = []
|
||||
this.selectedVisitIds = new Set()
|
||||
|
||||
if (this.controller.hasSelectAreaButtonTarget) {
|
||||
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<path d="M9 3v18"></path>
|
||||
<path d="M15 3v18"></path>
|
||||
<path d="M3 9h18"></path>
|
||||
<path d="M3 15h18"></path>
|
||||
</svg>
|
||||
Select Area
|
||||
`
|
||||
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
|
||||
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
|
||||
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps-v2#startSelectArea'
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectionActionsTarget) {
|
||||
this.controller.selectionActionsTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
Toast.info('Selection cancelled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected points
|
||||
*/
|
||||
async deleteSelectedPoints() {
|
||||
const pointCount = this.selectedPointsLayer.getCount()
|
||||
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
|
||||
|
||||
if (pointIds.length === 0) {
|
||||
Toast.error('No points selected')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
console.log('[Maps V2] Deleting', pointIds.length, 'points')
|
||||
|
||||
try {
|
||||
Toast.info('Deleting points...')
|
||||
const result = await this.api.bulkDeletePoints(pointIds)
|
||||
|
||||
console.log('[Maps V2] Deleted', result.count, 'points')
|
||||
|
||||
this.cancelAreaSelection()
|
||||
|
||||
await this.controller.loadMapData({
|
||||
showLoading: false,
|
||||
fitBounds: false,
|
||||
showToast: false
|
||||
})
|
||||
|
||||
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to delete points:', error)
|
||||
Toast.error('Failed to delete points. Please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
271
app/javascript/controllers/maps_v2/places_manager.js
Normal file
271
app/javascript/controllers/maps_v2/places_manager.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { SettingsManager } from 'maps_v2/utils/settings_manager'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
|
||||
/**
|
||||
* Manages places-related operations for Maps V2
|
||||
* Including place creation, tag filtering, and layer management
|
||||
*/
|
||||
export class PlacesManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.layerManager = controller.layerManager
|
||||
this.api = controller.api
|
||||
this.dataLoader = controller.dataLoader
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle places layer
|
||||
*/
|
||||
togglePlaces(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('placesEnabled', enabled)
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
if (enabled) {
|
||||
placesLayer.show()
|
||||
if (this.controller.hasPlacesFiltersTarget) {
|
||||
this.controller.placesFiltersTarget.style.display = 'block'
|
||||
}
|
||||
this.initializePlaceTagFilters()
|
||||
} else {
|
||||
placesLayer.hide()
|
||||
if (this.controller.hasPlacesFiltersTarget) {
|
||||
this.controller.placesFiltersTarget.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize place tag filters (enable all by default or restore saved state)
|
||||
*/
|
||||
initializePlaceTagFilters() {
|
||||
const savedFilters = this.settings.placesTagFilters
|
||||
|
||||
if (savedFilters && savedFilters.length > 0) {
|
||||
this.restoreSavedTagFilters(savedFilters)
|
||||
} else {
|
||||
this.enableAllTagsInitial()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore saved tag filters
|
||||
*/
|
||||
restoreSavedTagFilters(savedFilters) {
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||
const shouldBeChecked = savedFilters.includes(value)
|
||||
|
||||
if (checkbox.checked !== shouldBeChecked) {
|
||||
checkbox.checked = shouldBeChecked
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (shouldBeChecked) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.syncEnableAllTagsToggle()
|
||||
this.loadPlacesWithTags(savedFilters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all tags initially
|
||||
*/
|
||||
enableAllTagsInitial() {
|
||||
if (this.controller.hasEnableAllPlaceTagsToggleTarget) {
|
||||
this.controller.enableAllPlaceTagsToggleTarget.checked = true
|
||||
}
|
||||
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
const allTagIds = []
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = true
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
|
||||
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||
allTagIds.push(value)
|
||||
})
|
||||
|
||||
SettingsManager.updateSetting('placesTagFilters', allTagIds)
|
||||
this.loadPlacesWithTags(allTagIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected place tag IDs
|
||||
*/
|
||||
getSelectedPlaceTags() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('input[name="place_tag_ids[]"]:checked')
|
||||
).map(cb => {
|
||||
const value = cb.value
|
||||
return value === 'untagged' ? value : parseInt(value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter places by selected tags
|
||||
*/
|
||||
filterPlacesByTags(event) {
|
||||
const badge = event.target.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (event.target.checked) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
|
||||
this.syncEnableAllTagsToggle()
|
||||
|
||||
const checkedTags = this.getSelectedPlaceTags()
|
||||
SettingsManager.updateSetting('placesTagFilters', checkedTags)
|
||||
this.loadPlacesWithTags(checkedTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync "Enable All Tags" toggle with individual tag states
|
||||
*/
|
||||
syncEnableAllTagsToggle() {
|
||||
if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return
|
||||
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked)
|
||||
|
||||
this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked
|
||||
}
|
||||
|
||||
/**
|
||||
* Load places filtered by tags
|
||||
*/
|
||||
async loadPlacesWithTags(tagIds = []) {
|
||||
try {
|
||||
let places = []
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
places = await this.api.fetchPlaces({ tag_ids: tagIds })
|
||||
}
|
||||
|
||||
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
placesLayer.update(placesGeoJSON)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to load places:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all place tags on/off
|
||||
*/
|
||||
toggleAllPlaceTags(event) {
|
||||
const enableAll = event.target.checked
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
if (checkbox.checked !== enableAll) {
|
||||
checkbox.checked = enableAll
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (enableAll) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selectedTags = this.getSelectedPlaceTags()
|
||||
SettingsManager.updateSetting('placesTagFilters', selectedTags)
|
||||
this.loadPlacesWithTags(selectedTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start create place mode
|
||||
*/
|
||||
startCreatePlace() {
|
||||
console.log('[Maps V2] Starting create place mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||
Toast.info('Click on the map to place a place')
|
||||
|
||||
this.handleCreatePlaceClick = (e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
|
||||
document.dispatchEvent(new CustomEvent('place:create', {
|
||||
detail: { latitude: lat, longitude: lng }
|
||||
}))
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
this.controller.map.once('click', this.handleCreatePlaceClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle place creation event - reload places and update layer
|
||||
*/
|
||||
async handlePlaceCreated(event) {
|
||||
console.log('[Maps V2] Place created, reloading places...', event.detail)
|
||||
|
||||
try {
|
||||
const selectedTags = this.getSelectedPlaceTags()
|
||||
|
||||
const places = await this.api.fetchPlaces({
|
||||
tag_ids: selectedTags
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Fetched places:', places.length)
|
||||
|
||||
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||
|
||||
console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features')
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
placesLayer.update(placesGeoJSON)
|
||||
console.log('[Maps V2] Places layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Places layer not found, cannot update')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload places:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
360
app/javascript/controllers/maps_v2/routes_manager.js
Normal file
360
app/javascript/controllers/maps_v2/routes_manager.js
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { SettingsManager } from 'maps_v2/utils/settings_manager'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
import { lazyLoader } from 'maps_v2/utils/lazy_loader'
|
||||
|
||||
/**
|
||||
* Manages routes-related operations for Maps V2
|
||||
* Including speed-colored routes, route generation, and layer management
|
||||
*/
|
||||
export class RoutesManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.map = controller.map
|
||||
this.layerManager = controller.layerManager
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle routes layer visibility
|
||||
*/
|
||||
toggleRoutes(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.toggle(visible)
|
||||
}
|
||||
|
||||
if (this.controller.hasRoutesOptionsTarget) {
|
||||
this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('routesVisible', visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle speed-colored routes
|
||||
*/
|
||||
async toggleSpeedColoredRoutes(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
|
||||
|
||||
if (this.controller.hasSpeedColorScaleContainerTarget) {
|
||||
this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
|
||||
}
|
||||
|
||||
await this.reloadRoutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open speed color editor modal
|
||||
*/
|
||||
openSpeedColorEditor() {
|
||||
const currentScale = this.controller.speedColorScaleInputTarget.value ||
|
||||
'0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
|
||||
let modal = document.getElementById('speed-color-editor-modal')
|
||||
if (!modal) {
|
||||
modal = this.createSpeedColorEditorModal(currentScale)
|
||||
document.body.appendChild(modal)
|
||||
} else {
|
||||
const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
|
||||
if (controller) {
|
||||
controller.colorStopsValue = currentScale
|
||||
controller.loadColorStops()
|
||||
}
|
||||
}
|
||||
|
||||
const checkbox = modal.querySelector('.modal-toggle')
|
||||
if (checkbox) {
|
||||
checkbox.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create speed color editor modal element
|
||||
*/
|
||||
createSpeedColorEditorModal(currentScale) {
|
||||
const modal = document.createElement('div')
|
||||
modal.id = 'speed-color-editor-modal'
|
||||
modal.setAttribute('data-controller', 'speed-color-editor')
|
||||
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
|
||||
modal.setAttribute('data-action', 'speed-color-editor:save->maps-v2#handleSpeedColorSave')
|
||||
|
||||
modal.innerHTML = `
|
||||
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
|
||||
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Gradient Preview -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Preview</span>
|
||||
</label>
|
||||
<div class="h-12 rounded-lg border-2 border-base-300"
|
||||
data-speed-color-editor-target="preview"></div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Color Stops List -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Color Stops</span>
|
||||
</label>
|
||||
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Stop Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline w-full"
|
||||
data-action="click->speed-color-editor#addStop">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Color Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button"
|
||||
class="btn btn-ghost"
|
||||
data-action="click->speed-color-editor#resetToDefault">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
data-action="click->speed-color-editor#close">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
data-action="click->speed-color-editor#save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
|
||||
</div>
|
||||
`
|
||||
|
||||
return modal
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speed color save event from editor
|
||||
*/
|
||||
handleSpeedColorSave(event) {
|
||||
const newScale = event.detail.colorStops
|
||||
|
||||
this.controller.speedColorScaleInputTarget.value = newScale
|
||||
SettingsManager.updateSetting('speedColorScale', newScale)
|
||||
|
||||
if (this.controller.speedColoredToggleTarget.checked) {
|
||||
this.reloadRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload routes layer
|
||||
*/
|
||||
async reloadRoutes() {
|
||||
this.controller.showLoading('Reloading routes...')
|
||||
|
||||
try {
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
const points = pointsLayer?.data?.features?.map(f => ({
|
||||
latitude: f.geometry.coordinates[1],
|
||||
longitude: f.geometry.coordinates[0],
|
||||
timestamp: f.properties.timestamp
|
||||
})) || []
|
||||
|
||||
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500
|
||||
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
||||
|
||||
const { calculateSpeed, getSpeedColor } = await import('maps_v2/utils/speed_colors')
|
||||
|
||||
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
|
||||
points,
|
||||
{ distanceThresholdMeters, timeThresholdMinutes },
|
||||
calculateSpeed,
|
||||
getSpeedColor
|
||||
)
|
||||
|
||||
this.layerManager.updateLayer('routes', routesGeoJSON)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to reload routes:', error)
|
||||
Toast.error('Failed to reload routes')
|
||||
} finally {
|
||||
this.controller.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate routes with speed coloring
|
||||
*/
|
||||
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
|
||||
const { RoutesLayer } = await import('maps_v2/layers/routes_layer')
|
||||
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
|
||||
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
|
||||
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
|
||||
|
||||
if (!useSpeedColors) {
|
||||
return routesGeoJSON
|
||||
}
|
||||
|
||||
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
|
||||
const segment = points.slice(
|
||||
points.findIndex(p => p.timestamp === feature.properties.startTime),
|
||||
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
|
||||
)
|
||||
|
||||
if (segment.length >= 2) {
|
||||
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
|
||||
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
|
||||
feature.properties.speed = speed
|
||||
feature.properties.color = color
|
||||
}
|
||||
|
||||
return feature
|
||||
})
|
||||
|
||||
return routesGeoJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heatmap visibility
|
||||
*/
|
||||
toggleHeatmap(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('heatmapEnabled', enabled)
|
||||
|
||||
const heatmapLayer = this.layerManager.getLayer('heatmap')
|
||||
if (heatmapLayer) {
|
||||
if (enabled) {
|
||||
heatmapLayer.show()
|
||||
} else {
|
||||
heatmapLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fog of war layer
|
||||
*/
|
||||
toggleFog(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('fogEnabled', enabled)
|
||||
|
||||
const fogLayer = this.layerManager.getLayer('fog')
|
||||
if (fogLayer) {
|
||||
fogLayer.toggle(enabled)
|
||||
} else {
|
||||
console.warn('Fog layer not yet initialized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle scratch map layer
|
||||
*/
|
||||
async toggleScratch(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('scratchEnabled', enabled)
|
||||
|
||||
try {
|
||||
const scratchLayer = this.layerManager.getLayer('scratch')
|
||||
if (!scratchLayer && enabled) {
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
const newScratchLayer = new ScratchLayer(this.map, {
|
||||
visible: true,
|
||||
apiClient: this.controller.api
|
||||
})
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] }
|
||||
await newScratchLayer.add(pointsData)
|
||||
this.layerManager.layers.scratchLayer = newScratchLayer
|
||||
} else if (scratchLayer) {
|
||||
if (enabled) {
|
||||
scratchLayer.show()
|
||||
} else {
|
||||
scratchLayer.hide()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle scratch layer:', error)
|
||||
Toast.error('Failed to load scratch layer')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle photos layer
|
||||
*/
|
||||
togglePhotos(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('photosEnabled', enabled)
|
||||
|
||||
const photosLayer = this.layerManager.getLayer('photos')
|
||||
if (photosLayer) {
|
||||
if (enabled) {
|
||||
photosLayer.show()
|
||||
} else {
|
||||
photosLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle areas layer
|
||||
*/
|
||||
toggleAreas(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('areasEnabled', enabled)
|
||||
|
||||
const areasLayer = this.layerManager.getLayer('areas')
|
||||
if (areasLayer) {
|
||||
if (enabled) {
|
||||
areasLayer.show()
|
||||
} else {
|
||||
areasLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle tracks layer
|
||||
*/
|
||||
toggleTracks(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('tracksEnabled', enabled)
|
||||
|
||||
const tracksLayer = this.layerManager.getLayer('tracks')
|
||||
if (tracksLayer) {
|
||||
if (enabled) {
|
||||
tracksLayer.show()
|
||||
} else {
|
||||
tracksLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle points layer visibility
|
||||
*/
|
||||
togglePoints(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
if (pointsLayer) {
|
||||
pointsLayer.toggle(visible)
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('pointsVisible', visible)
|
||||
}
|
||||
}
|
||||
271
app/javascript/controllers/maps_v2/settings_manager.js
Normal file
271
app/javascript/controllers/maps_v2/settings_manager.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { SettingsManager } from 'maps_v2/utils/settings_manager'
|
||||
import { getMapStyle } from 'maps_v2/utils/style_manager'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
|
||||
/**
|
||||
* Handles all settings-related operations for Maps V2
|
||||
* Including toggles, advanced settings, and UI synchronization
|
||||
*/
|
||||
export class SettingsController {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
// Lazy getters for properties that may not be initialized yet
|
||||
get map() {
|
||||
return this.controller.map
|
||||
}
|
||||
|
||||
get layerManager() {
|
||||
return this.controller.layerManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings (sync from backend and localStorage)
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
this.controller.settings = this.settings
|
||||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
return this.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync UI controls with loaded settings
|
||||
*/
|
||||
syncToggleStates() {
|
||||
const controller = this.controller
|
||||
|
||||
// Sync layer toggles
|
||||
const toggleMap = {
|
||||
pointsToggle: 'pointsVisible',
|
||||
routesToggle: 'routesVisible',
|
||||
heatmapToggle: 'heatmapEnabled',
|
||||
visitsToggle: 'visitsEnabled',
|
||||
photosToggle: 'photosEnabled',
|
||||
areasToggle: 'areasEnabled',
|
||||
placesToggle: 'placesEnabled',
|
||||
fogToggle: 'fogEnabled',
|
||||
scratchToggle: 'scratchEnabled',
|
||||
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||
}
|
||||
|
||||
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||
const target = `${targetName}Target`
|
||||
if (controller[target]) {
|
||||
controller[target].checked = this.settings[settingKey]
|
||||
}
|
||||
})
|
||||
|
||||
// Show/hide visits search based on initial toggle state
|
||||
if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) {
|
||||
controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Show/hide places filters based on initial toggle state
|
||||
if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) {
|
||||
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Sync route opacity slider
|
||||
if (controller.hasRouteOpacityRangeTarget) {
|
||||
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||
}
|
||||
|
||||
// Sync map style dropdown
|
||||
const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]')
|
||||
if (mapStyleSelect) {
|
||||
mapStyleSelect.value = this.settings.mapStyle || 'light'
|
||||
}
|
||||
|
||||
// Sync fog of war settings
|
||||
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
|
||||
if (fogRadiusInput) {
|
||||
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
|
||||
if (controller.hasFogRadiusValueTarget) {
|
||||
controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]')
|
||||
if (fogThresholdInput) {
|
||||
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
|
||||
if (controller.hasFogThresholdValueTarget) {
|
||||
controller.fogThresholdValueTarget.textContent = fogThresholdInput.value
|
||||
}
|
||||
}
|
||||
|
||||
// Sync route generation settings
|
||||
const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]')
|
||||
if (metersBetweenInput) {
|
||||
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
|
||||
if (controller.hasMetersBetweenValueTarget) {
|
||||
controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]')
|
||||
if (minutesBetweenInput) {
|
||||
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
|
||||
if (controller.hasMinutesBetweenValueTarget) {
|
||||
controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
|
||||
}
|
||||
}
|
||||
|
||||
// Sync speed-colored routes settings
|
||||
if (controller.hasSpeedColorScaleInputTarget) {
|
||||
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
controller.speedColorScaleInputTarget.value = colorScale
|
||||
}
|
||||
if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) {
|
||||
const isEnabled = controller.speedColoredToggleTarget.checked
|
||||
controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
|
||||
}
|
||||
|
||||
// Sync points rendering mode radio buttons
|
||||
const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]')
|
||||
pointsRenderingRadios.forEach(radio => {
|
||||
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
|
||||
})
|
||||
|
||||
// Sync speed-colored routes toggle
|
||||
const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]')
|
||||
if (speedColoredRoutesToggle) {
|
||||
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||
}
|
||||
|
||||
console.log('[Maps V2] UI controls synced with settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map style from settings
|
||||
*/
|
||||
async updateMapStyle(event) {
|
||||
const styleName = event.target.value
|
||||
SettingsManager.updateSetting('mapStyle', styleName)
|
||||
|
||||
const style = await getMapStyle(styleName)
|
||||
|
||||
// Clear layer references
|
||||
this.layerManager.clearLayerReferences()
|
||||
|
||||
this.map.setStyle(style)
|
||||
|
||||
// Reload layers after style change
|
||||
this.map.once('style.load', () => {
|
||||
console.log('Style loaded, reloading map data')
|
||||
this.controller.loadMapData()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
resetSettings() {
|
||||
if (confirm('Reset all settings to defaults? This will reload the page.')) {
|
||||
SettingsManager.resetToDefaults()
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route opacity in real-time
|
||||
*/
|
||||
updateRouteOpacity(event) {
|
||||
const opacity = parseInt(event.target.value) / 100
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', opacity)
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('routeOpacity', opacity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update advanced settings from form submission
|
||||
*/
|
||||
async updateAdvancedSettings(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(event.target)
|
||||
const settings = {
|
||||
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
|
||||
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
|
||||
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
|
||||
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
|
||||
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
|
||||
pointsRenderingMode: formData.get('pointsRenderingMode'),
|
||||
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
|
||||
}
|
||||
|
||||
// Apply settings to current map
|
||||
await this.applySettingsToMap(settings)
|
||||
|
||||
// Save to backend and localStorage
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await SettingsManager.updateSetting(key, value)
|
||||
}
|
||||
|
||||
Toast.success('Settings updated successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings to map without reload
|
||||
*/
|
||||
async applySettingsToMap(settings) {
|
||||
// Update route opacity
|
||||
if (settings.routeOpacity !== undefined) {
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Update fog of war settings
|
||||
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
|
||||
const fogLayer = this.layerManager.getLayer('fog')
|
||||
if (fogLayer) {
|
||||
if (settings.fogOfWarRadius) {
|
||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||
}
|
||||
// Redraw fog layer
|
||||
if (fogLayer.visible) {
|
||||
await fogLayer.update(fogLayer.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For settings that require data reload
|
||||
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
|
||||
Toast.info('Reloading map data with new settings...')
|
||||
await this.controller.loadMapData()
|
||||
}
|
||||
}
|
||||
|
||||
// Display value update methods
|
||||
updateFogRadiusDisplay(event) {
|
||||
if (this.controller.hasFogRadiusValueTarget) {
|
||||
this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
updateFogThresholdDisplay(event) {
|
||||
if (this.controller.hasFogThresholdValueTarget) {
|
||||
this.controller.fogThresholdValueTarget.textContent = event.target.value
|
||||
}
|
||||
}
|
||||
|
||||
updateMetersBetweenDisplay(event) {
|
||||
if (this.controller.hasMetersBetweenValueTarget) {
|
||||
this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
updateMinutesBetweenDisplay(event) {
|
||||
if (this.controller.hasMinutesBetweenValueTarget) {
|
||||
this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min`
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/javascript/controllers/maps_v2/visits_manager.js
Normal file
143
app/javascript/controllers/maps_v2/visits_manager.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { SettingsManager } from 'maps_v2/utils/settings_manager'
|
||||
import { Toast } from 'maps_v2/components/toast'
|
||||
|
||||
/**
|
||||
* Manages visits-related operations for Maps V2
|
||||
* Including visit creation, filtering, and layer management
|
||||
*/
|
||||
export class VisitsManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.layerManager = controller.layerManager
|
||||
this.filterManager = controller.filterManager
|
||||
this.api = controller.api
|
||||
this.dataLoader = controller.dataLoader
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visits layer
|
||||
*/
|
||||
toggleVisits(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('visitsEnabled', enabled)
|
||||
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
if (visitsLayer) {
|
||||
if (enabled) {
|
||||
visitsLayer.show()
|
||||
if (this.controller.hasVisitsSearchTarget) {
|
||||
this.controller.visitsSearchTarget.style.display = 'block'
|
||||
}
|
||||
} else {
|
||||
visitsLayer.hide()
|
||||
if (this.controller.hasVisitsSearchTarget) {
|
||||
this.controller.visitsSearchTarget.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visits
|
||||
*/
|
||||
searchVisits(event) {
|
||||
const searchTerm = event.target.value.toLowerCase()
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
this.filterManager.filterAndUpdateVisits(
|
||||
searchTerm,
|
||||
this.filterManager.getCurrentVisitFilter(),
|
||||
visitsLayer
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter visits by status
|
||||
*/
|
||||
filterVisits(event) {
|
||||
const filter = event.target.value
|
||||
this.filterManager.setCurrentVisitFilter(filter)
|
||||
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start create visit mode
|
||||
*/
|
||||
startCreateVisit() {
|
||||
console.log('[Maps V2] Starting create visit mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||
Toast.info('Click on the map to place a visit')
|
||||
|
||||
this.handleCreateVisitClick = (e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
this.openVisitCreationModal(lat, lng)
|
||||
this.controller.map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
this.controller.map.once('click', this.handleCreateVisitClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open visit creation modal
|
||||
*/
|
||||
openVisitCreationModal(lat, lng) {
|
||||
console.log('[Maps V2] Opening visit creation modal', { lat, lng })
|
||||
|
||||
const modalElement = document.querySelector('[data-controller="visit-creation-v2"]')
|
||||
|
||||
if (!modalElement) {
|
||||
console.error('[Maps V2] Visit creation modal not found')
|
||||
Toast.error('Visit creation modal not available')
|
||||
return
|
||||
}
|
||||
|
||||
const controller = this.controller.application.getControllerForElementAndIdentifier(
|
||||
modalElement,
|
||||
'visit-creation-v2'
|
||||
)
|
||||
|
||||
if (controller) {
|
||||
controller.open(lat, lng, this.controller)
|
||||
} else {
|
||||
console.error('[Maps V2] Visit creation controller not found')
|
||||
Toast.error('Visit creation controller not available')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit creation event - reload visits and update layer
|
||||
*/
|
||||
async handleVisitCreated(event) {
|
||||
console.log('[Maps V2] Visit created, reloading visits...', event.detail)
|
||||
|
||||
try {
|
||||
const visits = await this.api.fetchVisits({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Fetched visits:', visits.length)
|
||||
|
||||
this.filterManager.setAllVisits(visits)
|
||||
const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits)
|
||||
|
||||
console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features')
|
||||
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
if (visitsLayer) {
|
||||
visitsLayer.update(visitsGeoJSON)
|
||||
console.log('[Maps V2] Visits layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Visits layer not found, cannot update')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload visits:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1988
app/javascript/controllers/maps_v2_controller_old.js
Normal file
1988
app/javascript/controllers/maps_v2_controller_old.js
Normal file
File diff suppressed because it is too large
Load diff
86
app/views/maps_v2/_area_creation_modal.html.erb
Normal file
86
app/views/maps_v2/_area_creation_modal.html.erb
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<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">
|
||||
<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>
|
||||
|
||||
<form data-area-creation-v2-target="form" data-action="submit->area-creation-v2#submit">
|
||||
<input type="hidden" name="latitude" data-area-creation-v2-target="latitudeInput">
|
||||
<input type="hidden" name="longitude" data-area-creation-v2-target="longitudeInput">
|
||||
<input type="hidden" name="radius" data-area-creation-v2-target="radiusInput">
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Area Name -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Area Name *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="e.g. Home, Office, Gym..."
|
||||
class="input input-bordered w-full"
|
||||
data-area-creation-v2-target="nameInput"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- Radius Display -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Radius</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
data-area-creation-v2-target="radiusDisplay"
|
||||
readonly>
|
||||
<div class="badge badge-info">meters</div>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Draw on the map to set the radius</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Center Location -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Center Location</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full text-sm"
|
||||
data-area-creation-v2-target="locationDisplay"
|
||||
readonly>
|
||||
</div>
|
||||
|
||||
<!-- Drawing Instructions -->
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<strong>How to draw:</strong>
|
||||
<ol class="list-decimal list-inside mt-1">
|
||||
<li>Click once to set the center point</li>
|
||||
<li>Move mouse to adjust radius</li>
|
||||
<li>Click again to finish drawing</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" data-action="click->area-creation-v2#close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-area-creation-v2-target="submitButton">
|
||||
<span class="loading loading-spinner loading-sm hidden" data-area-creation-v2-target="submitSpinner"></span>
|
||||
<span data-area-creation-v2-target="submitText">Create Area</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-backdrop" data-action="click->area-creation-v2#close"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-2">
|
||||
Search for a location to navigate on the map
|
||||
Search for a location to find places you visited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -367,7 +367,7 @@
|
|||
max="100"
|
||||
step="10"
|
||||
value="100"
|
||||
class="range range-primary range-sm"
|
||||
class="range range-sm"
|
||||
data-maps-v2-target="routeOpacityRange"
|
||||
data-action="input->maps-v2#updateRouteOpacity" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
|
|
@ -391,7 +391,7 @@
|
|||
max="2000"
|
||||
step="5"
|
||||
value="1000"
|
||||
class="range range-primary range-sm"
|
||||
class="range range-sm"
|
||||
data-action="input->maps-v2#updateFogRadiusDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>5m</span>
|
||||
|
|
@ -412,7 +412,7 @@
|
|||
max="10"
|
||||
step="1"
|
||||
value="1"
|
||||
class="range range-primary range-sm"
|
||||
class="range range-sm"
|
||||
data-action="input->maps-v2#updateFogThresholdDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1</span>
|
||||
|
|
@ -436,7 +436,7 @@
|
|||
max="5000"
|
||||
step="100"
|
||||
value="500"
|
||||
class="range range-primary range-sm"
|
||||
class="range range-sm"
|
||||
data-action="input->maps-v2#updateMetersBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>100m</span>
|
||||
|
|
@ -457,7 +457,7 @@
|
|||
max="180"
|
||||
step="1"
|
||||
value="60"
|
||||
class="range range-primary range-sm"
|
||||
class="range range-sm"
|
||||
data-action="input->maps-v2#updateMinutesBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1min</span>
|
||||
|
|
@ -569,12 +569,20 @@
|
|||
|
||||
<!-- Select Area Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline md:col-span-2"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-maps-v2-target="selectAreaButton"
|
||||
data-action="click->maps-v2#startSelectArea">
|
||||
<%= icon 'square-dashed-mouse-pointer' %>
|
||||
Select Area
|
||||
</button>
|
||||
|
||||
<!-- Create Area Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-action="click->maps-v2#startCreateArea">
|
||||
<%= icon 'circle-plus' %>
|
||||
Create an Area
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selection Actions (shown after area is selected) -->
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
<%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %>
|
||||
|
||||
<div data-controller="maps-v2"
|
||||
<div 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
|
||||
|
|
@ -43,6 +44,9 @@
|
|||
<!-- Visit creation modal -->
|
||||
<%= render 'maps_v2/visit_creation_modal' %>
|
||||
|
||||
<!-- Area creation modal -->
|
||||
<%= render 'maps_v2/area_creation_modal' %>
|
||||
|
||||
<!-- Place creation modal (shared) -->
|
||||
<%= render 'shared/place_creation_modal' %>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue