import L from "leaflet";
import { showFlashMessage } from "./helpers";
import { applyThemeToButton } from "./theme_utils";
/**
* Manages visits functionality including displaying, fetching, and interacting with visits
*/
export class VisitsManager {
constructor(map, apiKey, userTheme = 'dark') {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
// Create custom panes for different visit types
if (!map.getPane('confirmedVisitsPane')) {
map.createPane('confirmedVisitsPane');
map.getPane('confirmedVisitsPane').style.zIndex = 450; // Above default overlay pane (400)
}
if (!map.getPane('suggestedVisitsPane')) {
map.createPane('suggestedVisitsPane');
map.getPane('suggestedVisitsPane').style.zIndex = 460; // Below confirmed visits but above base layers
}
this.visitCircles = L.layerGroup();
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
this.currentPopup = null;
this.drawerOpen = false;
this.selectionMode = false;
this.selectionRect = null;
this.isSelectionActive = false;
this.selectedPoints = [];
this.highlightedVisitId = null;
this.highlightedCircles = []; // Track multiple circles instead of just one
// Add CSS for visit highlighting
const style = document.createElement('style');
style.textContent = `
.visit-highlighted {
transition: all 0.3s ease-in-out;
}
`;
document.head.appendChild(style);
}
/**
* Formats a duration in seconds to a human-readable string
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration string
*/
formatDuration(seconds) {
const days = Math.floor(seconds / (24 * 60 * 60));
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
const minutes = Math.floor((seconds % (60 * 60)) / 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 && days === 0) parts.push(`${minutes}m`); // Only show minutes if less than a day
return parts.join(' ') || '< 1m';
}
/**
* Adds a button to toggle the visits drawer
*/
addDrawerButton() {
const DrawerControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
button.innerHTML = '⬅️'; // Left arrow icon
// 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.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
L.DomEvent.disableClickPropagation(button);
L.DomEvent.on(button, 'click', () => {
this.toggleDrawer();
});
return button;
}
});
this.map.addControl(new DrawerControl({ position: 'topright' }));
// Add the selection tool button
this.addSelectionButton();
}
/**
* Adds a button to enable/disable the area selection tool
*/
addSelectionButton() {
const SelectionControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
button.innerHTML = '⚓️';
button.title = 'Select Area';
button.id = 'selection-tool-button';
// 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.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.onclick = () => this.toggleSelectionMode();
return button;
}
});
new SelectionControl({ position: 'topright' }).addTo(this.map);
// Add CSS for selection button active state
const style = document.createElement('style');
style.textContent = `
#selection-tool-button.active {
border: 2px dashed #3388ff !important;
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
}
`;
document.head.appendChild(style);
}
/**
* Toggles the area selection mode
*/
toggleSelectionMode() {
// Clear any existing highlight
this.clearVisitHighlight();
this.isSelectionActive = !this.isSelectionActive;
if (this.selectionMode) {
// Disable selection mode
this.selectionMode = false;
this.map.dragging.enable();
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
} else {
// Enable selection mode
this.selectionMode = true;
document.getElementById('selection-tool-button').classList.add('active');
this.map.dragging.disable();
this.map.on('mousedown', this.onMouseDown, this);
showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.');
}
}
/**
* Handles the mousedown event to start the selection
*/
onMouseDown(e) {
// Clear any existing selection
this.clearSelection();
// Store start point and create rectangle
this.startPoint = e.latlng;
// Add mousemove and mouseup listeners
this.map.on('mousemove', this.onMouseMove, this);
this.map.on('mouseup', this.onMouseUp, this);
}
/**
* Handles the mousemove event to update the selection rectangle
*/
onMouseMove(e) {
if (!this.startPoint) return;
// If we already have a rectangle, update its bounds
if (this.selectionRect) {
const bounds = L.latLngBounds(this.startPoint, e.latlng);
this.selectionRect.setBounds(bounds);
} else {
// Create a new rectangle
this.selectionRect = L.rectangle(
L.latLngBounds(this.startPoint, e.latlng),
{ color: '#3388ff', weight: 2, fillOpacity: 0.1 }
).addTo(this.map);
}
}
/**
* Handles the mouseup event to complete the selection
*/
onMouseUp(e) {
// Remove the mouse event listeners
this.map.off('mousemove', this.onMouseMove, this);
this.map.off('mouseup', this.onMouseUp, this);
if (!this.selectionRect) return;
// Finalize the selection
this.isSelectionActive = true;
// Re-enable map dragging
this.map.dragging.enable();
// Disable selection mode
this.selectionMode = false;
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
// Fetch visits within the selection
this.fetchVisitsInSelection();
}
/**
* Clears the selection rectangle and resets selection state
*/
clearSelection() {
if (this.selectionRect) {
this.map.removeLayer(this.selectionRect);
this.selectionRect = null;
}
this.isSelectionActive = false;
this.startPoint = null;
this.selectedPoints = [];
// Clear all visit circles immediately
this.visitCircles.clearLayers();
this.confirmedVisitCircles.clearLayers();
// Always refresh visits data regardless of drawer state
// Layer visibility is now controlled by the layer control, not the drawer
this.fetchAndDisplayVisits();
// Reset drawer title
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = 'Recent Visits';
}
}
/**
* Fetches visits within the selected area
*/
async fetchVisitsInSelection() {
if (!this.selectionRect) return;
const bounds = this.selectionRect.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
try {
const response = await fetch(
`/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`,
{
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
}
}
);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const visits = await response.json();
// Filter points in the selected area from DOM data
this.filterPointsInSelection(bounds);
// Set selection as active to ensure date summary is displayed
this.isSelectionActive = true;
this.displayVisits(visits);
// Make sure the drawer is open
if (!this.drawerOpen) {
this.toggleDrawer();
}
// Add cancel selection button to the drawer
this.addSelectionCancelButton();
} catch (error) {
console.error('Error fetching visits in selection:', error);
showFlashMessage('error', 'Failed to load visits in selected area');
}
}
/**
* Filters points from DOM data that are within the selection bounds
* @param {L.LatLngBounds} bounds - The bounds of the selection rectangle
*/
filterPointsInSelection(bounds) {
if (!bounds) {
this.selectedPoints = [];
return;
}
// Get points from the DOM
const allPoints = this.getPointsData();
if (!allPoints || !allPoints.length) {
this.selectedPoints = [];
return;
}
// Filter points that are within the bounds
this.selectedPoints = allPoints.filter(point => {
// Point format is expected to be [lat, lng, ...other data]
const lat = parseFloat(point[0]);
const lng = parseFloat(point[1]);
if (isNaN(lat) || isNaN(lng)) return false;
return bounds.contains([lat, lng]);
});
}
/**
* Gets points data from the DOM
* @returns {Array} Array of points with coordinates and timestamps
*/
getPointsData() {
const mapElement = document.getElementById('map');
if (!mapElement) return [];
// Get coordinates data from the data attribute
const coordinatesAttr = mapElement.getAttribute('data-coordinates');
if (!coordinatesAttr) return [];
try {
return JSON.parse(coordinatesAttr);
} catch (e) {
console.error('Error parsing coordinates data:', e);
return [];
}
}
/**
* Groups visits by date
* @param {Array} visits - Array of visit objects
* @returns {Object} Object with dates as keys and counts as values
*/
groupVisitsByDate(visits) {
const dateGroups = {};
visits.forEach(visit => {
const startDate = new Date(visit.started_at);
const dateStr = startDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
if (!dateGroups[dateStr]) {
dateGroups[dateStr] = {
count: 0,
points: 0,
date: startDate
};
}
dateGroups[dateStr].count++;
});
// If we have selected points, count them by date
if (this.selectedPoints && this.selectedPoints.length > 0) {
this.selectedPoints.forEach(point => {
// Point timestamp is at index 4
const timestamp = point[4];
if (!timestamp) return;
// Convert timestamp to date string
const pointDate = new Date(parseInt(timestamp) * 1000);
const dateStr = pointDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
if (!dateGroups[dateStr]) {
dateGroups[dateStr] = {
count: 0,
points: 0,
date: pointDate
};
}
dateGroups[dateStr].points++;
});
}
return dateGroups;
}
/**
* Creates HTML for date summary panel
* @param {Object} dateGroups - Object with dates as keys and count/points values
* @returns {string} HTML string for date summary panel
*/
createDateSummaryHtml(dateGroups) {
// If there are no date groups, return empty string
if (Object.keys(dateGroups).length === 0) {
return '';
}
// Sort dates chronologically
const sortedDates = Object.keys(dateGroups).sort((a, b) => {
return dateGroups[a].date - dateGroups[b].date;
});
// Create HTML for each date group
const dateItems = sortedDates.map(dateStr => {
const pointsCount = dateGroups[dateStr].points || 0;
const visitsCount = dateGroups[dateStr].count || 0;
return `
${dateStr}
${pointsCount > 0 ? `
${pointsCount} pts
` : ''}
${visitsCount > 0 ? `
${visitsCount} visits
` : ''}
`;
}).join('');
// Create the whole panel
return `
Data in Selected Area
${dateItems}
`;
}
/**
* Adds a cancel button to the drawer to clear the selection
*/
addSelectionCancelButton() {
const container = document.getElementById('visits-list');
if (!container) return;
// Add cancel button at the top of the drawer if it doesn't exist
if (!document.getElementById('cancel-selection-button')) {
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
cancelButton.textContent = 'Cancel Area Selection';
cancelButton.onclick = () => this.clearSelection();
// Insert at the beginning of the container
container.insertBefore(cancelButton, container.firstChild);
}
}
/**
* Toggles the visibility of the visits drawer
*/
toggleDrawer() {
// Clear any existing highlight when drawer is toggled
this.clearVisitHighlight();
this.drawerOpen = !this.drawerOpen;
let drawer = document.getElementById('visits-drawer');
if (!drawer) {
drawer = this.createDrawer();
}
drawer.classList.toggle('open');
const drawerButton = document.querySelector('.drawer-button');
if (drawerButton) {
drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️';
}
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button');
controls.forEach(control => {
control.classList.toggle('controls-shifted');
});
// Update the drawer content if it's being opened - but don't fetch visits automatically
if (this.drawerOpen) {
const container = document.getElementById('visits-list');
if (container) {
container.innerHTML = `
No visits data loaded
Enable "Suggested Visits" or "Confirmed Visits" layers from the map controls to view visits.
`;
}
}
// Note: Layer visibility is now controlled by the layer control, not the drawer state
}
/**
* Creates the drawer element for displaying visits
* @returns {HTMLElement} The created drawer element
*/
createDrawer() {
const drawer = document.createElement('div');
drawer.id = 'visits-drawer';
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer';
// Add styles to make the drawer scrollable
drawer.style.overflowY = 'auto';
drawer.style.maxHeight = '100vh';
drawer.innerHTML = `
Recent Visits
Loading visits...
`;
// Prevent map zoom when scrolling the drawer
L.DomEvent.disableScrollPropagation(drawer);
// Prevent map pan/interaction when interacting with drawer
L.DomEvent.disableClickPropagation(drawer);
this.map.getContainer().appendChild(drawer);
return drawer;
}
/**
* Fetches visits data from the API and displays them
*/
async fetchAndDisplayVisits() {
try {
console.log('fetchAndDisplayVisits called');
// Clear any existing highlight before fetching new visits
this.clearVisitHighlight();
// If there's an active selection, don't perform time-based fetch
if (this.isSelectionActive && this.selectionRect) {
console.log('Active selection found, fetching visits in selection');
this.fetchVisitsInSelection();
return;
}
// Get current timeframe from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startAt = urlParams.get('start_at') || new Date().toISOString();
const endAt = urlParams.get('end_at') || new Date().toISOString();
console.log('Fetching visits for date range:', { startAt, endAt });
const response = await fetch(
`/api/v1/visits?start_at=${encodeURIComponent(startAt)}&end_at=${encodeURIComponent(endAt)}`,
{
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
}
}
);
if (!response.ok) {
console.error('Visits API response not ok:', response.status, response.statusText);
throw new Error('Network response was not ok');
}
const visits = await response.json();
console.log('Visits API response:', { count: visits.length, visits });
this.displayVisits(visits);
// Let the layer control manage visibility instead of drawer state
console.log('Visit circles populated - layer control will manage visibility');
console.log('visitCircles layer count:', this.visitCircles.getLayers().length);
console.log('confirmedVisitCircles layer count:', this.confirmedVisitCircles.getLayers().length);
// Check if the layers are currently enabled in the layer control and ensure they're visible
const layerControl = this.map._layers;
let suggestedVisitsEnabled = false;
let confirmedVisitsEnabled = false;
// Check layer control state
Object.values(layerControl || {}).forEach(layer => {
if (layer.name === 'Suggested Visits' && this.map.hasLayer(layer.layer)) {
suggestedVisitsEnabled = true;
}
if (layer.name === 'Confirmed Visits' && this.map.hasLayer(layer.layer)) {
confirmedVisitsEnabled = true;
}
});
console.log('Layer control state:', { suggestedVisitsEnabled, confirmedVisitsEnabled });
} catch (error) {
console.error('Error fetching visits:', error);
const container = document.getElementById('visits-list');
if (container) {
container.innerHTML = '
Error loading visits
';
}
}
}
/**
* Creates visit circles on the map (independent of drawer UI)
* @param {Array} visits - Array of visit objects
*/
createMapCircles(visits) {
if (!visits || visits.length === 0) {
console.log('No visits to create circles for');
return;
}
// Clear existing visit circles
console.log('Clearing existing visit circles');
this.visitCircles.clearLayers();
this.confirmedVisitCircles.clearLayers();
let suggestedCount = 0;
let confirmedCount = 0;
// Draw circles for all visits
visits
.filter(visit => visit.status !== 'declined')
.forEach(visit => {
if (visit.place?.latitude && visit.place?.longitude) {
const isConfirmed = visit.status === 'confirmed';
const isSuggested = visit.status === 'suggested';
console.log('Creating circle for visit:', {
id: visit.id,
status: visit.status,
lat: visit.place.latitude,
lng: visit.place.longitude,
isConfirmed,
isSuggested
});
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
color: isSuggested ? '#FFA500' : '#4A90E2', // Border color
fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color
fillOpacity: isSuggested ? 0.3 : 0.5,
radius: isConfirmed ? 110 : 80, // Increased size for confirmed visits
weight: 2,
interactive: true,
bubblingMouseEvents: false,
pane: isConfirmed ? 'confirmedVisitsPane' : 'suggestedVisitsPane', // Use appropriate pane
dashArray: isSuggested ? '4' : null // Dotted border for suggested
});
// Add the circle to the appropriate layer
if (isConfirmed) {
this.confirmedVisitCircles.addLayer(circle);
confirmedCount++;
console.log('Added confirmed visit circle to layer');
} else {
this.visitCircles.addLayer(circle);
suggestedCount++;
console.log('Added suggested visit circle to layer');
}
// Attach click event to the circle
circle.on('click', () => this.fetchPossiblePlaces(visit));
} else {
console.warn('Visit missing coordinates:', visit);
}
});
console.log('Visit circles created:', { suggestedCount, confirmedCount });
}
/**
* Displays visits on the map and in the drawer
* @param {Array} visits - Array of visit objects
*/
displayVisits(visits) {
// Always create map circles regardless of drawer state
this.createMapCircles(visits);
// Update drawer UI only if container exists
const container = document.getElementById('visits-list');
if (!container) {
console.log('No visits-list container found - skipping drawer UI update');
return;
}
// Update the drawer title if selection is active
if (this.isSelectionActive && this.selectionRect) {
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = `${visitsCount} visits found`;
}
} else {
// Reset title to default when not in selection mode
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = 'Recent Visits';
}
}
// Group visits by date and count
const dateGroups = this.groupVisitsByDate(visits || []);
// If we have points data and are in selection mode, calculate points per date
let dateGroupsHtml = '';
if (this.isSelectionActive && this.selectionRect) {
// Create a date summary panel
dateGroupsHtml = this.createDateSummaryHtml(dateGroups);
}
if (!visits || visits.length === 0) {
let noVisitsHtml = '