dawarich/app/javascript/maps_maplibre/utils/search_manager.js
Evgenii Burmakin 8934c29fce
0.36.2 (#2007)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-06 20:54:49 +01:00

729 lines
22 KiB
JavaScript

/**
* Search Manager
* Manages location search functionality for Maps V2
*/
import { LocationSearchService } from '../services/location_search_service.js'
export class SearchManager {
constructor(map, apiKey) {
this.map = map
this.service = new LocationSearchService(apiKey)
this.searchInput = null
this.resultsContainer = null
this.debounceTimer = null
this.debounceDelay = 300 // ms
this.currentMarker = null
this.currentVisitsData = null // Store visits data for click handling
}
/**
* Initialize search manager with DOM elements
* @param {HTMLInputElement} searchInput - Search input element
* @param {HTMLElement} resultsContainer - Container for search results
*/
initialize(searchInput, resultsContainer) {
this.searchInput = searchInput
this.resultsContainer = resultsContainer
if (!this.searchInput || !this.resultsContainer) {
console.warn('SearchManager: Missing required DOM elements')
return
}
this.attachEventListeners()
}
/**
* Attach event listeners to search input
*/
attachEventListeners() {
// Input event with debouncing
this.searchInput.addEventListener('input', (e) => {
this.handleSearchInput(e.target.value)
})
// Prevent results from hiding when clicking inside results container
this.resultsContainer.addEventListener('mousedown', (e) => {
e.preventDefault() // Prevent blur event on search input
})
// Clear results when clicking outside
document.addEventListener('click', (e) => {
if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) {
// Delay to allow animations to complete
setTimeout(() => {
this.clearResults()
}, 100)
}
})
// Handle Enter key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
const firstResult = this.resultsContainer.querySelector('.search-result-item')
if (firstResult) {
firstResult.click()
}
}
})
}
/**
* Handle search input with debouncing
* @param {string} query - Search query
*/
handleSearchInput(query) {
clearTimeout(this.debounceTimer)
if (!query || query.length < 2) {
this.clearResults()
return
}
this.debounceTimer = setTimeout(async () => {
try {
this.showLoading()
const suggestions = await this.service.fetchSuggestions(query)
this.displayResults(suggestions)
} catch (error) {
this.showError('Failed to fetch suggestions')
console.error('SearchManager: Search error:', error)
}
}, this.debounceDelay)
}
/**
* Display search results
* @param {Array} suggestions - Array of location suggestions
*/
displayResults(suggestions) {
this.clearResults()
if (!suggestions || suggestions.length === 0) {
this.showNoResults()
return
}
suggestions.forEach(suggestion => {
const resultItem = this.createResultItem(suggestion)
this.resultsContainer.appendChild(resultItem)
})
this.resultsContainer.classList.remove('hidden')
}
/**
* Create a result item element
* @param {Object} suggestion - Location suggestion
* @returns {HTMLElement} Result item element
*/
createResultItem(suggestion) {
const item = document.createElement('div')
item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors'
item.setAttribute('data-lat', suggestion.lat)
item.setAttribute('data-lon', suggestion.lon)
const name = document.createElement('div')
name.className = 'font-medium text-sm'
name.textContent = suggestion.name || 'Unknown location'
if (suggestion.address) {
const address = document.createElement('div')
address.className = 'text-xs text-base-content/60 mt-1'
address.textContent = suggestion.address
item.appendChild(name)
item.appendChild(address)
} else {
item.appendChild(name)
}
item.addEventListener('click', () => {
this.handleResultClick(suggestion)
})
return item
}
/**
* Handle click on search result
* @param {Object} location - Selected location
*/
async handleResultClick(location) {
// Fly to location on map
this.map.flyTo({
center: [location.lon, location.lat],
zoom: 15,
duration: 1000
})
// Add temporary marker
this.addSearchMarker(location.lon, location.lat)
// Update search input
if (this.searchInput) {
this.searchInput.value = location.name || ''
}
// Show loading state in results
this.showVisitsLoading(location.name)
// Search for visits at this location
try {
const visitsData = await this.service.searchVisits({
lat: location.lat,
lon: location.lon,
name: location.name,
address: location.address || ''
})
// Display visits results
this.displayVisitsResults(visitsData, location)
} catch (error) {
console.error('SearchManager: Failed to fetch visits:', error)
this.showError('Failed to load visits for this location')
}
// Dispatch custom event for other components
this.dispatchSearchEvent(location)
}
/**
* Add a temporary marker at search location
* @param {number} lon - Longitude
* @param {number} lat - Latitude
*/
addSearchMarker(lon, lat) {
// Remove existing marker
if (this.currentMarker) {
this.currentMarker.remove()
}
// Create marker element
const el = document.createElement('div')
el.className = 'search-marker'
el.style.cssText = `
width: 30px;
height: 30px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
cursor: pointer;
`
// Add marker to map (MapLibre GL style)
if (this.map.getSource) {
// Use MapLibre marker
const maplibregl = window.maplibregl
if (maplibregl) {
this.currentMarker = new maplibregl.Marker({ element: el })
.setLngLat([lon, lat])
.addTo(this.map)
}
}
}
/**
* Dispatch custom search event
* @param {Object} location - Selected location
*/
dispatchSearchEvent(location) {
const event = new CustomEvent('location-search:selected', {
detail: { location },
bubbles: true
})
document.dispatchEvent(event)
}
/**
* Show loading indicator
*/
showLoading() {
this.clearResults()
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-base-content/60 flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Searching...
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show no results message
*/
showNoResults() {
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-base-content/60">
No locations found
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-error">
${message}
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show loading state while fetching visits
* @param {string} locationName - Name of the location being searched
*/
showVisitsLoading(locationName) {
this.resultsContainer.innerHTML = `
<div class="p-4 text-sm text-base-content/60">
<div class="flex items-center gap-2 mb-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="font-medium">Searching for visits...</span>
</div>
<div class="text-xs">${this.escapeHtml(locationName)}</div>
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Display visits results
* @param {Object} visitsData - Visits data from API
* @param {Object} location - Selected location
*/
displayVisitsResults(visitsData, location) {
// Store visits data for click handling
this.currentVisitsData = visitsData
if (!visitsData.locations || visitsData.locations.length === 0) {
this.resultsContainer.innerHTML = `
<div class="p-6 text-center text-base-content/60">
<div class="text-3xl mb-3">📍</div>
<div class="text-sm font-medium">No visits found</div>
<div class="text-xs mt-1">No visits found for "${this.escapeHtml(location.name)}"</div>
</div>
`
this.resultsContainer.classList.remove('hidden')
return
}
// Display visits grouped by location
let html = `
<div class="p-4 border-b bg-base-200">
<div class="text-sm font-medium">Found ${visitsData.total_locations} location(s)</div>
<div class="text-xs text-base-content/60 mt-1">for "${this.escapeHtml(location.name)}"</div>
</div>
`
visitsData.locations.forEach((loc, index) => {
html += this.buildLocationVisitsHtml(loc, index)
})
this.resultsContainer.innerHTML = html
this.resultsContainer.classList.remove('hidden')
// Attach event listeners to year toggles and visit items
this.attachYearToggleListeners()
}
/**
* Build HTML for a location with its visits
* @param {Object} location - Location with visits
* @param {number} index - Location index
* @returns {string} HTML string
*/
buildLocationVisitsHtml(location, index) {
const visits = location.visits || []
if (visits.length === 0) return ''
// Handle case where visits are sorted newest first
const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date))
const firstVisit = sortedVisits[0]
const lastVisit = sortedVisits[sortedVisits.length - 1]
const visitsByYear = this.groupVisitsByYear(visits)
// Use place_name, address, or coordinates as fallback
const displayName = location.place_name || location.address ||
`Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})`
return `
<div class="location-result border-b" data-location-index="${index}">
<div class="p-4">
<div class="font-medium text-sm">${this.escapeHtml(displayName)}</div>
${location.address && location.place_name !== location.address ?
`<div class="text-xs text-base-content/60 mt-1">${this.escapeHtml(location.address)}</div>` : ''}
<div class="flex justify-between items-center mt-3">
<div class="text-xs text-primary">${location.total_visits} visit(s)</div>
<div class="text-xs text-base-content/60">
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
</div>
</div>
</div>
<!-- Years Section -->
<div class="border-t bg-base-200">
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
<div class="year-section">
<div class="year-toggle p-3 hover:bg-base-300 cursor-pointer border-b flex justify-between items-center"
data-location-index="${index}" data-year="${year}">
<span class="text-sm font-medium">${year}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-primary">${yearVisits.length} visits</span>
<span class="year-arrow text-base-content/40 transition-transform">▶</span>
</div>
</div>
<div class="year-visits hidden" id="year-${index}-${year}">
${yearVisits.map((visit) => `
<div class="visit-item text-xs py-2 px-4 border-b hover:bg-base-300 cursor-pointer"
data-location-index="${index}" data-visit-index="${visits.indexOf(visit)}">
<div class="flex justify-between items-start">
<div>📍 ${this.formatDateTime(visit.date)}</div>
<div class="text-xs text-base-content/60">${visit.duration_estimate || 'N/A'}</div>
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`
}
/**
* Group visits by year
* @param {Array} visits - Array of visits
* @returns {Object} Visits grouped by year
*/
groupVisitsByYear(visits) {
const groups = {}
visits.forEach(visit => {
const year = new Date(visit.date).getFullYear().toString()
if (!groups[year]) {
groups[year] = []
}
groups[year].push(visit)
})
return groups
}
/**
* Attach event listeners to year toggle elements
*/
attachYearToggleListeners() {
const toggles = this.resultsContainer.querySelectorAll('.year-toggle')
toggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
const locationIndex = e.currentTarget.dataset.locationIndex
const year = e.currentTarget.dataset.year
const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`)
const arrow = e.currentTarget.querySelector('.year-arrow')
if (visitsContainer) {
visitsContainer.classList.toggle('hidden')
arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'
}
})
})
// Attach event listeners to individual visit items
const visitItems = this.resultsContainer.querySelectorAll('.visit-item')
visitItems.forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation()
const locationIndex = parseInt(item.dataset.locationIndex)
const visitIndex = parseInt(item.dataset.visitIndex)
this.handleVisitClick(locationIndex, visitIndex)
})
})
}
/**
* Handle click on individual visit item
* @param {number} locationIndex - Index of location in results
* @param {number} visitIndex - Index of visit within location
*/
handleVisitClick(locationIndex, visitIndex) {
if (!this.currentVisitsData || !this.currentVisitsData.locations) return
const location = this.currentVisitsData.locations[locationIndex]
if (!location || !location.visits) return
const visit = location.visits[visitIndex]
if (!visit) return
// Fly to visit coordinates (more precise than location coordinates)
const [lat, lon] = visit.coordinates || location.coordinates
this.map.flyTo({
center: [lon, lat],
zoom: 18,
duration: 1000
})
// Extract visit details
const visitDetails = visit.visit_details || {}
const startTime = visitDetails.start_time || visit.date
const endTime = visitDetails.end_time || visit.date
const placeName = location.place_name || location.address || 'Unnamed Location'
// Open create visit modal
this.openCreateVisitModal({
name: placeName,
latitude: lat,
longitude: lon,
started_at: startTime,
ended_at: endTime
})
}
/**
* Open modal to create a visit with prefilled data
* @param {Object} visitData - Visit data to prefill
*/
openCreateVisitModal(visitData) {
// Create modal HTML
const modalId = 'create-visit-modal'
// Remove existing modal if present
const existingModal = document.getElementById(modalId)
if (existingModal) {
existingModal.remove()
}
const modal = document.createElement('div')
modal.id = modalId
modal.innerHTML = `
<input type="checkbox" id="${modalId}-toggle" class="modal-toggle" checked />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">Create Visit</h3>
<form id="${modalId}-form">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" name="name" class="input input-bordered w-full"
value="${this.escapeHtml(visitData.name)}" required />
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Start Time</span>
</label>
<input type="datetime-local" name="started_at" class="input input-bordered w-full"
value="${this.formatDateTimeForInput(visitData.started_at)}" required />
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">End Time</span>
</label>
<input type="datetime-local" name="ended_at" class="input input-bordered w-full"
value="${this.formatDateTimeForInput(visitData.ended_at)}" required />
</div>
<input type="hidden" name="latitude" value="${visitData.latitude}" />
<input type="hidden" name="longitude" value="${visitData.longitude}" />
<div class="modal-action">
<button type="button" class="btn" data-action="close">Cancel</button>
<button type="submit" class="btn btn-primary">
<span class="submit-text">Create Visit</span>
<span class="loading loading-spinner loading-sm hidden"></span>
</button>
</div>
</form>
</div>
<label class="modal-backdrop" for="${modalId}-toggle"></label>
</div>
`
document.body.appendChild(modal)
// Attach event listeners
const form = modal.querySelector('form')
const closeBtn = modal.querySelector('[data-action="close"]')
const modalToggle = modal.querySelector(`#${modalId}-toggle`)
const backdrop = modal.querySelector('.modal-backdrop')
form.addEventListener('submit', (e) => {
e.preventDefault()
this.submitCreateVisit(form, modal)
})
closeBtn.addEventListener('click', () => {
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
})
backdrop.addEventListener('click', () => {
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
})
}
/**
* Submit create visit form
* @param {HTMLFormElement} form - Form element
* @param {HTMLElement} modal - Modal element
*/
async submitCreateVisit(form, modal) {
const submitBtn = form.querySelector('button[type="submit"]')
const submitText = submitBtn.querySelector('.submit-text')
const spinner = submitBtn.querySelector('.loading')
// Disable submit button and show loading
submitBtn.disabled = true
submitText.classList.add('hidden')
spinner.classList.remove('hidden')
try {
const formData = new FormData(form)
const visitData = {
name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
status: 'confirmed'
}
const response = await this.service.createVisit(visitData)
if (response.error) {
throw new Error(response.error)
}
// Success - close modal and show success message
const modalToggle = modal.querySelector('.modal-toggle')
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
// Show success notification
this.showSuccessNotification('Visit created successfully!')
// Dispatch custom event for other components to react
document.dispatchEvent(new CustomEvent('visit:created', {
detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] }
}))
} catch (error) {
console.error('Failed to create visit:', error)
alert(`Failed to create visit: ${error.message}`)
// Re-enable submit button
submitBtn.disabled = false
submitText.classList.remove('hidden')
spinner.classList.add('hidden')
}
}
/**
* Show success notification
* @param {string} message - Success message
*/
showSuccessNotification(message) {
const notification = document.createElement('div')
notification.className = 'toast toast-top toast-end z-[9999]'
notification.innerHTML = `
<div class="alert alert-success">
<span>✓ ${this.escapeHtml(message)}</span>
</div>
`
document.body.appendChild(notification)
setTimeout(() => {
notification.remove()
}, 3000)
}
/**
* Format datetime for input field (YYYY-MM-DDTHH:MM)
* @param {string} dateString - Date string
* @returns {string} Formatted datetime
*/
formatDateTimeForInput(dateString) {
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* Format date in short format
* @param {string} dateString - Date string
* @returns {string} Formatted date
*/
formatDateShort(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
/**
* Format date and time
* @param {string} dateString - Date string
* @returns {string} Formatted date and time
*/
formatDateTime(dateString) {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
/**
* Escape HTML to prevent XSS
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
escapeHtml(str) {
if (!str) return ''
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
/**
* Clear search results
*/
clearResults() {
if (this.resultsContainer) {
this.resultsContainer.innerHTML = ''
this.resultsContainer.classList.add('hidden')
}
}
/**
* Clear search marker
*/
clearMarker() {
if (this.currentMarker) {
this.currentMarker.remove()
this.currentMarker = null
}
}
/**
* Cleanup
*/
destroy() {
clearTimeout(this.debounceTimer)
this.clearMarker()
this.clearResults()
}
}