✨ New Features: - Toggle between map and table view for current reports - Table view shows location, details, reported time, and time remaining - Color-coded time remaining: urgent (red), warning (orange), normal (green) - Responsive design with mobile-optimized table layout - Real-time updates work in both map and table views - Sorted by most recent reports first 🎨 UI Improvements: - Professional toggle buttons with active state - Clean table design with hover effects - Accessibility-friendly with proper titles and tooltips - Mobile-responsive layout adjustments 🚀 Better UX: - Easy switching between visual map and detailed table - Time remaining countdown helps prioritize urgent reports - Searchable and scannable table format for quick review - Maintains all existing functionality while adding new view
546 lines
20 KiB
JavaScript
546 lines
20 KiB
JavaScript
document.addEventListener('DOMContentLoaded', async () => {
|
|
const map = L.map('map').setView([42.9634, -85.6681], 10);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 18,
|
|
}).addTo(map);
|
|
|
|
// Get API configuration
|
|
let config = { hasMapbox: false, mapboxAccessToken: null };
|
|
try {
|
|
const configResponse = await fetch('/api/config');
|
|
config = await configResponse.json();
|
|
console.log('🔧 API Configuration:', config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback');
|
|
} catch (error) {
|
|
console.warn('Failed to load API configuration, using fallback');
|
|
}
|
|
|
|
const locationForm = document.getElementById('location-form');
|
|
const messageDiv = document.getElementById('message');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const submitText = document.getElementById('submit-text');
|
|
const submitLoading = document.getElementById('submit-loading');
|
|
const addressInput = document.getElementById('address');
|
|
const autocompleteList = document.getElementById('autocomplete-list');
|
|
|
|
// View toggle elements
|
|
const mapViewBtn = document.getElementById('map-view-btn');
|
|
const tableViewBtn = document.getElementById('table-view-btn');
|
|
const mapView = document.getElementById('map-view');
|
|
const tableView = document.getElementById('table-view');
|
|
const reportsTableBody = document.getElementById('reports-tbody');
|
|
const tableLocationCount = document.getElementById('table-location-count');
|
|
|
|
let autocompleteTimeout;
|
|
let selectedIndex = -1;
|
|
let autocompleteResults = [];
|
|
let currentMarkers = [];
|
|
let updateInterval;
|
|
let currentLocations = [];
|
|
let currentView = 'map'; // 'map' or 'table'
|
|
|
|
const clearMarkers = () => {
|
|
currentMarkers.forEach(marker => {
|
|
map.removeLayer(marker);
|
|
});
|
|
currentMarkers = [];
|
|
};
|
|
|
|
const showMarkers = locations => {
|
|
clearMarkers();
|
|
|
|
locations.forEach(({ latitude, longitude, address, description, created_at }) => {
|
|
if (latitude && longitude) {
|
|
const timeAgo = getTimeAgo(created_at);
|
|
const marker = L.marker([latitude, longitude])
|
|
.addTo(map)
|
|
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>`);
|
|
currentMarkers.push(marker);
|
|
}
|
|
});
|
|
};
|
|
|
|
const getTimeAgo = (timestamp) => {
|
|
const now = new Date();
|
|
const reportTime = new Date(timestamp);
|
|
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
|
|
|
|
if (diffInMinutes < 1) return 'just now';
|
|
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
|
|
|
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
|
|
|
return 'over a day ago';
|
|
};
|
|
|
|
// Toggle between map and table view
|
|
const switchView = (viewType) => {
|
|
currentView = viewType;
|
|
|
|
if (viewType === 'map') {
|
|
mapView.style.display = 'block';
|
|
tableView.style.display = 'none';
|
|
mapViewBtn.classList.add('active');
|
|
tableViewBtn.classList.remove('active');
|
|
|
|
// Invalidate map size after showing
|
|
setTimeout(() => map.invalidateSize(), 100);
|
|
} else {
|
|
mapView.style.display = 'none';
|
|
tableView.style.display = 'block';
|
|
mapViewBtn.classList.remove('active');
|
|
tableViewBtn.classList.add('active');
|
|
|
|
// Render table with current data
|
|
renderTable(currentLocations);
|
|
}
|
|
};
|
|
|
|
// Render table view
|
|
const renderTable = (locations) => {
|
|
if (!locations || locations.length === 0) {
|
|
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
|
|
tableLocationCount.textContent = '0 active reports';
|
|
return;
|
|
}
|
|
|
|
// Sort by most recent first
|
|
const sortedLocations = [...locations].sort((a, b) =>
|
|
new Date(b.created_at) - new Date(a.created_at)
|
|
);
|
|
|
|
const tableHTML = sortedLocations.map(location => {
|
|
const timeAgo = getTimeAgo(location.created_at);
|
|
const timeRemaining = getTimeRemaining(location.created_at);
|
|
const remainingClass = getRemainingClass(location.created_at);
|
|
const reportedTime = new Date(location.created_at).toLocaleString();
|
|
|
|
return `
|
|
<tr>
|
|
<td class="location-cell" title="${location.address}">${location.address}</td>
|
|
<td class="details-cell" title="${location.description || 'No additional details'}">
|
|
${location.description || '<em>No additional details</em>'}
|
|
</td>
|
|
<td class="time-cell" title="${reportedTime}">${timeAgo}</td>
|
|
<td class="remaining-cell ${remainingClass}">${timeRemaining}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
reportsTableBody.innerHTML = tableHTML;
|
|
tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
|
};
|
|
|
|
// Calculate time remaining until 24-hour expiration
|
|
const getTimeRemaining = (timestamp) => {
|
|
const now = new Date();
|
|
const reportTime = new Date(timestamp);
|
|
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
|
|
const remaining = expirationTime - now;
|
|
|
|
if (remaining <= 0) return 'Expired';
|
|
|
|
const hours = Math.floor(remaining / (1000 * 60 * 60));
|
|
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
} else {
|
|
return `${minutes}m`;
|
|
}
|
|
};
|
|
|
|
// Get CSS class for time remaining
|
|
const getRemainingClass = (timestamp) => {
|
|
const now = new Date();
|
|
const reportTime = new Date(timestamp);
|
|
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
|
|
const remaining = expirationTime - now;
|
|
const hoursRemaining = remaining / (1000 * 60 * 60);
|
|
|
|
if (hoursRemaining <= 1) return 'urgent';
|
|
if (hoursRemaining <= 6) return 'warning';
|
|
return 'normal';
|
|
};
|
|
|
|
const refreshLocations = () => {
|
|
fetch('/api/locations')
|
|
.then(res => res.json())
|
|
.then(locations => {
|
|
currentLocations = locations;
|
|
|
|
// Update map view
|
|
showMarkers(locations);
|
|
const countElement = document.getElementById('location-count');
|
|
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
|
|
|
// Update table view if it's currently visible
|
|
if (currentView === 'table') {
|
|
renderTable(locations);
|
|
}
|
|
|
|
const now = new Date();
|
|
const timeStr = now.toLocaleTimeString('en-US', {
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
countElement.title = `Last updated: ${timeStr}`;
|
|
})
|
|
.catch(err => {
|
|
console.error('Error fetching locations:', err);
|
|
document.getElementById('location-count').textContent = 'Error loading locations';
|
|
if (currentView === 'table') {
|
|
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">Error loading reports</td></tr>';
|
|
}
|
|
});
|
|
};
|
|
|
|
refreshLocations();
|
|
updateInterval = setInterval(refreshLocations, 30000);
|
|
|
|
// MapBox Geocoding (Fast and Accurate!)
|
|
const mapboxGeocode = async (address) => {
|
|
const startTime = performance.now();
|
|
console.log(`🚀 MapBox Geocoding: "${address}"`);
|
|
|
|
try {
|
|
const encodedAddress = encodeURIComponent(address);
|
|
const response = await fetch(
|
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?` +
|
|
`access_token=${config.mapboxAccessToken}&` +
|
|
`country=US&` +
|
|
`region=MI&` +
|
|
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
|
|
`limit=1`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`MapBox API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
|
|
if (data.features && data.features.length > 0) {
|
|
const result = data.features[0];
|
|
console.log(`✅ MapBox geocoding successful in ${time}ms`);
|
|
console.log(`📍 Found: ${result.place_name}`);
|
|
|
|
return {
|
|
lat: result.center[1], // MapBox returns [lng, lat]
|
|
lon: result.center[0],
|
|
display_name: result.place_name
|
|
};
|
|
} else {
|
|
console.log(`❌ MapBox geocoding: no results (${time}ms)`);
|
|
throw new Error('No results found');
|
|
}
|
|
} catch (error) {
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
console.warn(`🚫 MapBox geocoding failed (${time}ms):`, error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Nominatim Fallback (Slower but free)
|
|
const nominatimGeocode = async (address) => {
|
|
const startTime = performance.now();
|
|
console.log(`🐌 Nominatim fallback: "${address}"`);
|
|
|
|
const searches = [
|
|
address,
|
|
`${address}, Michigan`,
|
|
`${address}, MI`,
|
|
`${address}, Grand Rapids, MI`
|
|
];
|
|
|
|
for (const query of searches) {
|
|
try {
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=us`);
|
|
const data = await response.json();
|
|
|
|
if (data && data.length > 0) {
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
console.log(`✅ Nominatim successful in ${time}ms`);
|
|
return data[0];
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Nominatim query failed: ${query}`);
|
|
}
|
|
}
|
|
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
console.error(`💥 All geocoding failed after ${time}ms`);
|
|
throw new Error('Address not found');
|
|
};
|
|
|
|
// Main geocoding function
|
|
const geocodeAddress = async (address) => {
|
|
if (config.hasMapbox) {
|
|
try {
|
|
return await mapboxGeocode(address);
|
|
} catch (error) {
|
|
console.warn('MapBox geocoding failed, trying Nominatim fallback');
|
|
return await nominatimGeocode(address);
|
|
}
|
|
} else {
|
|
return await nominatimGeocode(address);
|
|
}
|
|
};
|
|
|
|
// MapBox Autocomplete (Lightning Fast!)
|
|
const mapboxAutocomplete = async (query) => {
|
|
const startTime = performance.now();
|
|
console.log(`⚡ MapBox Autocomplete: "${query}"`);
|
|
|
|
try {
|
|
const encodedQuery = encodeURIComponent(query);
|
|
const response = await fetch(
|
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
|
|
`access_token=${config.mapboxAccessToken}&` +
|
|
`country=US&` +
|
|
`region=MI&` +
|
|
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
|
|
`types=address,poi&` +
|
|
`autocomplete=true&` +
|
|
`limit=5`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`MapBox API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
|
|
if (data.features && data.features.length > 0) {
|
|
// Filter for Michigan results
|
|
const michiganResults = data.features.filter(feature =>
|
|
feature.place_name.toLowerCase().includes('michigan') ||
|
|
feature.place_name.toLowerCase().includes(', mi,') ||
|
|
feature.context?.some(ctx => ctx.short_code === 'us-mi')
|
|
);
|
|
|
|
console.log(`⚡ MapBox autocomplete: ${michiganResults.length} results in ${time}ms`);
|
|
|
|
return michiganResults.slice(0, 5).map(feature => ({
|
|
display_name: feature.place_name,
|
|
mapbox_id: feature.id
|
|
}));
|
|
} else {
|
|
console.log(`❌ MapBox autocomplete: no results (${time}ms)`);
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
const time = (performance.now() - startTime).toFixed(2);
|
|
console.warn(`🚫 MapBox autocomplete failed (${time}ms):`, error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Nominatim Autocomplete Fallback
|
|
const nominatimAutocomplete = async (query) => {
|
|
console.log(`🐌 Nominatim autocomplete fallback: "${query}"`);
|
|
|
|
try {
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us&addressdetails=1`);
|
|
const data = await response.json();
|
|
|
|
return data.filter(item =>
|
|
item.display_name.toLowerCase().includes('michigan') ||
|
|
item.display_name.toLowerCase().includes(', mi,')
|
|
).slice(0, 5);
|
|
} catch (error) {
|
|
console.error('Nominatim autocomplete failed:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Main autocomplete function
|
|
const fetchAddressSuggestions = async (query) => {
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
let results = [];
|
|
|
|
if (config.hasMapbox) {
|
|
try {
|
|
results = await mapboxAutocomplete(query);
|
|
} catch (error) {
|
|
console.warn('MapBox autocomplete failed, trying Nominatim');
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
results = await nominatimAutocomplete(query);
|
|
}
|
|
|
|
autocompleteResults = results;
|
|
showAutocomplete(autocompleteResults);
|
|
};
|
|
|
|
// Form submission
|
|
locationForm.addEventListener('submit', e => {
|
|
e.preventDefault();
|
|
|
|
const address = document.getElementById('address').value;
|
|
const description = document.getElementById('description').value;
|
|
|
|
if (!address) return;
|
|
|
|
submitBtn.disabled = true;
|
|
submitText.style.display = 'none';
|
|
submitLoading.style.display = 'inline';
|
|
|
|
geocodeAddress(address)
|
|
.then(result => {
|
|
const { lat, lon } = result;
|
|
|
|
return fetch('/api/locations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
address,
|
|
latitude: parseFloat(lat),
|
|
longitude: parseFloat(lon),
|
|
description,
|
|
}),
|
|
});
|
|
})
|
|
.then(res => res.json())
|
|
.then(location => {
|
|
refreshLocations();
|
|
messageDiv.textContent = 'Location reported successfully!';
|
|
messageDiv.className = 'message success';
|
|
locationForm.reset();
|
|
})
|
|
.catch(err => {
|
|
console.error('Error reporting location:', err);
|
|
messageDiv.textContent = 'Error reporting location.';
|
|
messageDiv.className = 'message error';
|
|
})
|
|
.finally(() => {
|
|
submitBtn.disabled = false;
|
|
submitText.style.display = 'inline';
|
|
submitLoading.style.display = 'none';
|
|
messageDiv.style.display = 'block';
|
|
setTimeout(() => {
|
|
messageDiv.style.display = 'none';
|
|
}, 3000);
|
|
});
|
|
});
|
|
|
|
// Autocomplete UI functions
|
|
const showAutocomplete = (results) => {
|
|
if (results.length === 0) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
autocompleteList.innerHTML = '';
|
|
selectedIndex = -1;
|
|
|
|
results.forEach((result, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'autocomplete-item';
|
|
item.textContent = result.display_name;
|
|
item.addEventListener('click', () => selectAddress(result));
|
|
autocompleteList.appendChild(item);
|
|
});
|
|
|
|
autocompleteList.style.display = 'block';
|
|
};
|
|
|
|
const hideAutocomplete = () => {
|
|
autocompleteList.style.display = 'none';
|
|
selectedIndex = -1;
|
|
};
|
|
|
|
const selectAddress = (result) => {
|
|
addressInput.value = result.display_name;
|
|
hideAutocomplete();
|
|
addressInput.focus();
|
|
};
|
|
|
|
const updateSelection = (direction) => {
|
|
const items = autocompleteList.querySelectorAll('.autocomplete-item');
|
|
|
|
if (items.length === 0) return;
|
|
|
|
items[selectedIndex]?.classList.remove('selected');
|
|
|
|
if (direction === 'down') {
|
|
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
|
|
} else {
|
|
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
|
}
|
|
|
|
items[selectedIndex].classList.add('selected');
|
|
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
};
|
|
|
|
// Event listeners
|
|
addressInput.addEventListener('input', (e) => {
|
|
clearTimeout(autocompleteTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
// Ultra-fast debounce with MapBox
|
|
autocompleteTimeout = setTimeout(() => {
|
|
fetchAddressSuggestions(query);
|
|
}, config.hasMapbox ? 150 : 300);
|
|
});
|
|
|
|
addressInput.addEventListener('keydown', (e) => {
|
|
const isListVisible = autocompleteList.style.display === 'block';
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
if (isListVisible) {
|
|
e.preventDefault();
|
|
updateSelection('down');
|
|
}
|
|
break;
|
|
case 'ArrowUp':
|
|
if (isListVisible) {
|
|
e.preventDefault();
|
|
updateSelection('up');
|
|
}
|
|
break;
|
|
case 'Enter':
|
|
if (isListVisible && selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
selectAddress(autocompleteResults[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
hideAutocomplete();
|
|
break;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.autocomplete-container')) {
|
|
hideAutocomplete();
|
|
}
|
|
});
|
|
|
|
// View toggle event listeners
|
|
mapViewBtn.addEventListener('click', () => switchView('map'));
|
|
tableViewBtn.addEventListener('click', () => switchView('table'));
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
if (updateInterval) {
|
|
clearInterval(updateInterval);
|
|
}
|
|
});
|
|
});
|