// Location search functionality for the map
import { applyThemeToButton } from "./theme_utils";
class LocationSearch {
constructor(map, apiKey, userTheme = 'dark') {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
this.searchResults = [];
this.searchMarkersLayer = null;
this.currentSearchQuery = '';
this.searchTimeout = null;
this.suggestionsVisible = false;
this.currentSuggestionIndex = -1;
// Make instance globally accessible for popup buttons
window.locationSearchInstance = this;
this.initializeSearchBar();
}
initializeSearchBar() {
// Create search toggle button using Leaflet control (positioned below settings button)
const SearchToggleControl = L.Control.extend({
onAdd: function(map) {
const button = L.DomUtil.create('button', 'location-search-toggle');
button.innerHTML = '🔍';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.fontSize = '18px';
button.style.marginTop = '10px'; // Space below settings button
button.title = 'Search locations';
button.id = 'location-search-toggle';
return button;
}
});
// Add the search toggle control to the map
this.map.addControl(new SearchToggleControl({ position: 'topleft' }));
// Use setTimeout to ensure the DOM element is available
setTimeout(() => {
// Get reference to the created button
const toggleButton = document.getElementById('location-search-toggle');
if (toggleButton) {
// Create inline search bar
this.createInlineSearchBar();
// Store references
this.toggleButton = toggleButton;
this.searchVisible = false;
// Bind events
this.bindSearchEvents();
console.log('LocationSearch: Search button initialized successfully');
} else {
console.error('LocationSearch: Could not find search toggle button');
}
}, 100);
}
createInlineSearchBar() {
// Create inline search bar that appears next to the search button
const searchBar = document.createElement('div');
searchBar.className = 'location-search-bar absolute bg-white border border-gray-300 rounded-lg shadow-lg hidden';
searchBar.id = 'location-search-container'; // Use container ID for test compatibility
searchBar.style.width = '400px'; // Increased width for better usability
searchBar.style.maxHeight = '600px'; // Set max height for the entire search bar
searchBar.style.padding = '12px'; // Increased padding
searchBar.style.zIndex = '9999'; // Very high z-index to ensure visibility
searchBar.style.overflow = 'visible'; // Allow content to overflow but results area will scroll
searchBar.innerHTML = `
Suggestions
Results
`;
// Add search bar to the map container
this.map.getContainer().appendChild(searchBar);
// Store references
this.searchBar = searchBar;
this.searchInput = document.getElementById('location-search-input');
this.closeButton = document.getElementById('location-search-close');
this.suggestionsContainer = document.getElementById('location-search-suggestions');
this.suggestionsPanel = document.getElementById('location-search-suggestions-panel');
this.resultsContainer = document.getElementById('location-search-results');
this.resultsPanel = document.getElementById('location-search-results-panel');
// Set scrolling properties immediately for results container with !important
this.resultsContainer.style.setProperty('max-height', '400px', 'important');
this.resultsContainer.style.setProperty('overflow-y', 'scroll', 'important');
this.resultsContainer.style.setProperty('overflow-x', 'hidden', 'important');
this.resultsContainer.style.setProperty('min-height', '0', 'important');
this.resultsContainer.style.setProperty('display', 'block', 'important');
// Set scrolling properties for suggestions container with !important
this.suggestionsContainer.style.setProperty('max-height', '200px', 'important');
this.suggestionsContainer.style.setProperty('overflow-y', 'scroll', 'important');
this.suggestionsContainer.style.setProperty('overflow-x', 'hidden', 'important');
this.suggestionsContainer.style.setProperty('min-height', '0', 'important');
this.suggestionsContainer.style.setProperty('display', 'block', 'important');
console.log('LocationSearch: Set scrolling properties on containers');
// Prevent map scroll events when scrolling inside the search containers
this.preventMapScrollOnContainers();
// No clear button or default panel in inline mode
this.clearButton = null;
this.defaultPanel = null;
}
preventMapScrollOnContainers() {
// Prevent scroll events from bubbling to the map when scrolling inside search containers
const containers = [this.resultsContainer, this.suggestionsContainer, this.searchBar];
containers.forEach(container => {
if (container) {
// Prevent wheel events (scroll) from reaching the map
container.addEventListener('wheel', (e) => {
e.stopPropagation();
}, { passive: false });
// Prevent touch scroll events from reaching the map
container.addEventListener('touchstart', (e) => {
e.stopPropagation();
}, { passive: false });
container.addEventListener('touchmove', (e) => {
e.stopPropagation();
}, { passive: false });
container.addEventListener('touchend', (e) => {
e.stopPropagation();
}, { passive: false });
// Also prevent mousewheel for older browsers
container.addEventListener('mousewheel', (e) => {
e.stopPropagation();
}, { passive: false });
// Prevent DOMMouseScroll for Firefox
container.addEventListener('DOMMouseScroll', (e) => {
e.stopPropagation();
}, { passive: false });
console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar');
}
});
}
bindSearchEvents() {
// Toggle search bar visibility
this.toggleButton.addEventListener('click', (e) => {
console.log('Search button clicked!');
e.preventDefault();
e.stopPropagation();
this.showSearchBar();
});
// Close search bar
this.closeButton.addEventListener('click', () => {
this.hideSearchBar();
});
// Search on Enter key
this.searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) {
this.selectSuggestion(this.currentSuggestionIndex);
}
}
});
// Clear search (no clear button in inline mode, handled by close button)
// Handle real-time suggestions
this.searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length > 0) {
this.debouncedSuggestionSearch(query);
} else {
this.hideSuggestions();
this.showDefaultState();
}
});
// Handle keyboard navigation for suggestions
this.searchInput.addEventListener('keydown', (e) => {
if (this.suggestionsVisible) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.navigateSuggestions(1);
break;
case 'ArrowUp':
e.preventDefault();
this.navigateSuggestions(-1);
break;
case 'Escape':
this.hideSuggestions();
this.showDefaultState();
break;
}
}
});
// Close sidepanel on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.searchVisible) {
this.hideSearchBar();
}
});
// Close search bar when clicking outside (but not on map interactions)
document.addEventListener('click', (e) => {
if (this.searchVisible &&
!e.target.closest('#location-search-container') &&
!e.target.closest('#location-search-toggle') &&
!e.target.closest('.leaflet-container')) { // Don't close on map interactions
this.hideSearchBar();
}
});
// Maintain search bar position during map movements
this.map.on('movestart zoomstart', () => {
if (this.searchVisible) {
// Store current button position before map movement
this.storedButtonPosition = this.toggleButton.getBoundingClientRect();
}
});
// Reposition search bar after map movements to maintain relative position
this.map.on('moveend zoomend', () => {
if (this.searchVisible && this.storedButtonPosition) {
// Recalculate position based on new button position
this.repositionSearchBar();
}
});
}
showLoading() {
// Hide other panels and show results with loading
this.suggestionsPanel.classList.add('hidden');
this.resultsPanel.classList.remove('hidden');
this.resultsContainer.innerHTML = `
Searching for "${this.escapeHtml(this.currentSearchQuery)}"...
`;
}
showError(message) {
// Hide other panels and show results with error
this.suggestionsPanel.classList.add('hidden');
this.resultsPanel.classList.remove('hidden');
this.resultsContainer.innerHTML = `
⚠️
Search Failed
${this.escapeHtml(message)}
`;
}
displaySearchResults(data) {
// Hide other panels and show results
this.suggestionsPanel.classList.add('hidden');
this.resultsPanel.classList.remove('hidden');
if (!data.locations || data.locations.length === 0) {
this.resultsContainer.innerHTML = `
📍
No visits found
No visits found for "${this.escapeHtml(this.currentSearchQuery)}"
`;
}
groupVisitsByYear(visits) {
const groups = {};
visits.forEach(visit => {
const year = new Date(visit.date).getFullYear().toString();
if (!groups[year]) {
groups[year] = [];
}
groups[year].push(visit);
});
// Sort years descending (most recent first)
const sortedGroups = {};
Object.keys(groups)
.sort((a, b) => parseInt(b) - parseInt(a))
.forEach(year => {
sortedGroups[year] = groups[year];
});
return sortedGroups;
}
formatDateShort(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
bindResultEvents() {
// Bind click events to year toggles
const yearToggles = this.resultsContainer.querySelectorAll('.year-toggle');
yearToggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const locationIndex = parseInt(toggle.dataset.locationIndex);
const year = toggle.dataset.year;
this.toggleYear(locationIndex, year, toggle);
});
});
// Bind click events to individual visits
const visitResults = this.resultsContainer.querySelectorAll('.visit-item');
visitResults.forEach(visit => {
visit.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent triggering other clicks
const locationIndex = parseInt(visit.dataset.locationIndex);
const visitIndex = parseInt(visit.dataset.visitIndex);
this.focusOnVisit(this.searchResults[locationIndex], visitIndex);
});
});
}
toggleYear(locationIndex, year, toggleElement) {
const yearVisitsContainer = document.getElementById(`year-${locationIndex}-${year}`);
const arrow = toggleElement.querySelector('.year-arrow');
if (yearVisitsContainer.classList.contains('hidden')) {
// Show visits
yearVisitsContainer.classList.remove('hidden');
arrow.style.transform = 'rotate(90deg)';
arrow.textContent = '▼';
} else {
// Hide visits
yearVisitsContainer.classList.add('hidden');
arrow.style.transform = 'rotate(0deg)';
arrow.textContent = '▶';
}
}
focusOnLocation(location) {
const [lat, lon] = location.coordinates;
this.map.setView([lat, lon], 16);
// Flash the marker
const markers = this.searchMarkersLayer.getLayers();
const targetMarker = markers.find(marker => {
const latLng = marker.getLatLng();
return Math.abs(latLng.lat - lat) < 0.0001 && Math.abs(latLng.lng - lon) < 0.0001;
});
if (targetMarker) {
targetMarker.openPopup();
}
this.hideResults();
}
focusOnVisit(location, visitIndex) {
const visit = location.visits[visitIndex];
if (!visit) return;
// Navigate to the visit coordinates (more precise than location coordinates)
const [lat, lon] = visit.coordinates || location.coordinates;
this.map.setView([lat, lon], 18); // Higher zoom for individual visit
// Parse the visit timestamp to create a time filter
const visitDate = new Date(visit.date);
const startTime = new Date(visitDate.getTime() - (2 * 60 * 60 * 1000)); // 2 hours before
const endTime = new Date(visitDate.getTime() + (2 * 60 * 60 * 1000)); // 2 hours after
// Emit custom event for time filtering that other parts of the app can listen to
const timeFilterEvent = new CustomEvent('locationSearch:timeFilter', {
detail: {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
visitDate: visit.date,
location: location.place_name,
coordinates: [lat, lon]
}
});
document.dispatchEvent(timeFilterEvent);
// Create a special marker for the specific visit
this.addVisitMarker(lat, lon, visit, location);
// DON'T hide results - keep sidebar open
// this.hideResults();
}
addVisitMarker(lat, lon, visit, location) {
// Remove existing visit marker if any
if (this.visitMarker) {
this.map.removeLayer(this.visitMarker);
}
// Create a highlighted marker for the specific visit
this.visitMarker = L.circleMarker([lat, lon], {
radius: 12,
fillColor: '#22c55e', // Green color to distinguish from search results
color: '#ffffff',
weight: 3,
opacity: 1,
fillOpacity: 0.9
});
const popupContent = `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
Visit Details:
${this.formatDateTime(visit.date)}
Duration: ${visit.duration_estimate}
`;
this.visitMarker.bindPopup(popupContent, {
closeButton: true,
autoClose: false, // Don't auto-close when clicking elsewhere
closeOnEscapeKey: true, // Allow closing with Escape key
closeOnClick: false // Don't close when clicking on map
});
this.visitMarker.addTo(this.map);
this.visitMarker.openPopup();
// Add event listener to clean up when popup is closed
this.visitMarker.on('popupclose', () => {
if (this.visitMarker) {
this.map.removeLayer(this.visitMarker);
this.visitMarker = null;
}
});
// Store reference for manual cleanup if needed
this.currentVisitMarker = this.visitMarker;
}
clearSearch() {
this.searchInput.value = '';
this.hideResults();
this.clearSearchMarkers();
this.clearVisitMarker();
this.currentSearchQuery = '';
}
clearVisitMarker() {
if (this.visitMarker) {
this.map.removeLayer(this.visitMarker);
this.visitMarker = null;
}
if (this.currentVisitMarker) {
this.map.removeLayer(this.currentVisitMarker);
this.currentVisitMarker = null;
}
// Remove any visit notifications
const existingNotification = document.querySelector('.visit-navigation-notification');
if (existingNotification) {
existingNotification.remove();
}
}
showSearchBar() {
console.log('showSearchBar called');
if (!this.searchBar) {
console.error('Search bar element not found!');
return;
}
// Position the search bar to the right of the search button at same height
const buttonRect = this.toggleButton.getBoundingClientRect();
const mapRect = this.map.getContainer().getBoundingClientRect();
// Calculate position relative to the map container
const left = buttonRect.right - mapRect.left + 15; // 15px gap to the right of button
const top = buttonRect.top - mapRect.top; // Same height as button
console.log('Positioning search bar at:', { left, top });
// Position search bar next to the button
this.searchBar.style.left = left + 'px';
this.searchBar.style.top = top + 'px';
this.searchBar.style.transform = 'none'; // Remove any transforms
this.searchBar.style.position = 'absolute'; // Position relative to map container
// Show the search bar
this.searchBar.classList.remove('hidden');
this.searchBar.style.setProperty('display', 'block', 'important');
this.searchBar.style.visibility = 'visible';
this.searchBar.style.opacity = '1';
this.searchVisible = true;
console.log('Search bar positioned next to button');
// Focus the search input for immediate typing
setTimeout(() => {
if (this.searchInput) {
this.searchInput.focus();
}
}, 100);
}
repositionSearchBar() {
if (!this.searchBar || !this.searchVisible) return;
// Get current button position after map movement
const buttonRect = this.toggleButton.getBoundingClientRect();
const mapRect = this.map.getContainer().getBoundingClientRect();
// Calculate new position
const left = buttonRect.right - mapRect.left + 15;
const top = buttonRect.top - mapRect.top;
// Update search bar position
this.searchBar.style.left = left + 'px';
this.searchBar.style.top = top + 'px';
console.log('Search bar repositioned after map movement');
}
hideSearchBar() {
this.searchBar.classList.add('hidden');
this.searchBar.style.display = 'none';
this.searchVisible = false;
this.clearSearch();
this.hideResults();
this.hideSuggestions();
}
showDefaultState() {
// No default panel in inline mode, just hide suggestions and results
this.hideSuggestions();
this.hideResults();
}
clearSearchMarkers() {
// Note: No longer using search markers, but keeping method for compatibility
// Only clear visit markers if they exist
if (this.searchMarkersLayer) {
this.map.removeLayer(this.searchMarkersLayer);
this.searchMarkersLayer = null;
}
}
hideResults() {
if (this.resultsPanel) {
this.resultsPanel.classList.add('hidden');
}
}
// Suggestion-related methods
debouncedSuggestionSearch(query) {
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for debounced search
this.searchTimeout = setTimeout(() => {
this.performSuggestionSearch(query);
}, 300); // 300ms debounce delay
}
async performSuggestionSearch(query) {
if (query.length < 2) {
this.hideSuggestions();
return;
}
// Show loading state for suggestions
this.showSuggestionsLoading();
try {
const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Suggestions failed: ${response.status}`);
}
const data = await response.json();
this.displaySuggestions(data.suggestions || []);
} catch (error) {
console.error('Suggestion search error:', error);
this.hideSuggestions();
}
}
showSuggestionsLoading() {
// Hide other panels and show suggestions with loading
this.resultsPanel.classList.add('hidden');
this.suggestionsPanel.classList.remove('hidden');
this.suggestionsContainer.innerHTML = `
⏳
Finding suggestions...
`;
}
displaySuggestions(suggestions) {
if (!suggestions.length) {
this.hideSuggestions();
return;
}
// Hide other panels and show suggestions
this.resultsPanel.classList.add('hidden');
this.suggestionsPanel.classList.remove('hidden');
// Build suggestions HTML
let suggestionsHtml = '';
suggestions.forEach((suggestion, index) => {
const isActive = index === this.currentSuggestionIndex;
suggestionsHtml += `