- Node.js/Express backend with SQLite database - Interactive map with real-time location tracking - MapBox API integration for fast geocoding - Admin panel for content moderation - 24-hour auto-expiring reports - Deployment scripts for Debian 12 ARM64 - Caddy reverse proxy with automatic HTTPS
408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
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);
|
|
|
|
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');
|
|
|
|
let autocompleteTimeout;
|
|
let selectedIndex = -1;
|
|
let autocompleteResults = [];
|
|
let currentMarkers = [];
|
|
let updateInterval;
|
|
|
|
const clearMarkers = () => {
|
|
currentMarkers.forEach(marker => {
|
|
map.removeLayer(marker);
|
|
});
|
|
currentMarkers = [];
|
|
};
|
|
|
|
const showMarkers = locations => {
|
|
// Clear existing markers first
|
|
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';
|
|
};
|
|
|
|
const refreshLocations = () => {
|
|
fetch('/api/locations')
|
|
.then(res => res.json())
|
|
.then(locations => {
|
|
showMarkers(locations);
|
|
const countElement = document.getElementById('location-count');
|
|
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
|
|
|
// Add visual indicator of last update
|
|
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';
|
|
});
|
|
};
|
|
|
|
// Initial load
|
|
refreshLocations();
|
|
|
|
// Set up real-time updates every 30 seconds
|
|
updateInterval = setInterval(refreshLocations, 30000);
|
|
|
|
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';
|
|
|
|
// Try multiple geocoding strategies for intersections
|
|
const tryGeocode = async (query) => {
|
|
const cleanQuery = query.trim();
|
|
const startTime = performance.now();
|
|
|
|
console.log(`🔍 Starting geocoding for: "${cleanQuery}"`);
|
|
|
|
// Generate multiple search variations
|
|
const searches = [
|
|
cleanQuery, // Original query
|
|
cleanQuery.replace(' & ', ' and '), // Replace & with 'and'
|
|
cleanQuery.replace(' and ', ' & '), // Replace 'and' with &
|
|
cleanQuery.replace(' at ', ' & '), // Replace 'at' with &
|
|
`${cleanQuery}, Michigan`, // Add Michigan if not present
|
|
`${cleanQuery}, MI`, // Add MI if not present
|
|
];
|
|
|
|
// Add street type variations
|
|
const streetVariations = [];
|
|
searches.forEach(search => {
|
|
streetVariations.push(search);
|
|
streetVariations.push(search.replace(' St ', ' Street '));
|
|
streetVariations.push(search.replace(' Street ', ' St '));
|
|
streetVariations.push(search.replace(' Ave ', ' Avenue '));
|
|
streetVariations.push(search.replace(' Avenue ', ' Ave '));
|
|
streetVariations.push(search.replace(' Rd ', ' Road '));
|
|
streetVariations.push(search.replace(' Road ', ' Rd '));
|
|
streetVariations.push(search.replace(' Blvd ', ' Boulevard '));
|
|
streetVariations.push(search.replace(' Boulevard ', ' Blvd '));
|
|
});
|
|
|
|
// Remove duplicates
|
|
const uniqueSearches = [...new Set(streetVariations)];
|
|
console.log(`📋 Generated ${uniqueSearches.length} search variations`);
|
|
|
|
let attemptCount = 0;
|
|
for (const searchQuery of uniqueSearches) {
|
|
attemptCount++;
|
|
const queryStartTime = performance.now();
|
|
|
|
try {
|
|
console.log(`🌐 Attempt ${attemptCount}: "${searchQuery}"`);
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&q=${encodeURIComponent(searchQuery)}`);
|
|
const data = await response.json();
|
|
const queryTime = (performance.now() - queryStartTime).toFixed(2);
|
|
|
|
if (data && data.length > 0) {
|
|
// Prefer results that are in Michigan/Grand Rapids
|
|
const michiganResults = data.filter(item =>
|
|
item.display_name.toLowerCase().includes('michigan') ||
|
|
item.display_name.toLowerCase().includes('grand rapids') ||
|
|
item.display_name.toLowerCase().includes(', mi')
|
|
);
|
|
|
|
const result = michiganResults.length > 0 ? michiganResults[0] : data[0];
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
|
|
console.log(`✅ Geocoding successful in ${totalTime}ms (query: ${queryTime}ms)`);
|
|
console.log(`📍 Found: ${result.display_name}`);
|
|
console.log(`🎯 Coordinates: ${result.lat}, ${result.lon}`);
|
|
|
|
return result;
|
|
} else {
|
|
console.log(`❌ No results found (${queryTime}ms)`);
|
|
}
|
|
} catch (error) {
|
|
const queryTime = (performance.now() - queryStartTime).toFixed(2);
|
|
console.warn(`🚫 Geocoding failed for: "${searchQuery}" (${queryTime}ms)`, error);
|
|
}
|
|
}
|
|
|
|
// If intersection search fails, try individual streets for approximate location
|
|
if (cleanQuery.includes('&') || cleanQuery.includes(' and ')) {
|
|
console.log(`🔄 Trying fallback strategy for intersection`);
|
|
const streets = cleanQuery.split(/\s+(?:&|and)\s+/i);
|
|
if (streets.length >= 2) {
|
|
const firstStreet = streets[0].trim() + ', Grand Rapids, MI';
|
|
const fallbackStartTime = performance.now();
|
|
|
|
try {
|
|
console.log(`🌐 Fallback: "${firstStreet}"`);
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(firstStreet)}`);
|
|
const data = await response.json();
|
|
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
|
|
|
|
if (data && data.length > 0) {
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
console.log(`⚠️ Using approximate location from first street (${totalTime}ms total)`);
|
|
console.log(`📍 Approximate: ${data[0].display_name}`);
|
|
return data[0];
|
|
} else {
|
|
console.log(`❌ Fallback failed - no results (${fallbackTime}ms)`);
|
|
}
|
|
} catch (error) {
|
|
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
|
|
console.warn(`🚫 Fallback search failed: "${firstStreet}" (${fallbackTime}ms)`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
console.error(`💥 All geocoding strategies failed after ${totalTime}ms`);
|
|
throw new Error('Address not found with any search strategy');
|
|
};
|
|
|
|
tryGeocode(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 => {
|
|
// Immediately refresh all locations to show the new one
|
|
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 functionality
|
|
const fetchAddressSuggestions = async (query) => {
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Create search variations for better intersection handling
|
|
const searchQueries = [
|
|
query,
|
|
query.replace(' & ', ' and '),
|
|
query.replace(' and ', ' & '),
|
|
`${query}, Grand Rapids, MI`,
|
|
`${query}, Michigan`
|
|
];
|
|
|
|
let allResults = [];
|
|
|
|
// Try each search variation
|
|
for (const searchQuery of searchQueries) {
|
|
try {
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=3&countrycodes=us&addressdetails=1`
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (data && data.length > 0) {
|
|
allResults = allResults.concat(data);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Autocomplete search failed for: ${searchQuery}`);
|
|
}
|
|
}
|
|
|
|
// Filter and deduplicate results
|
|
const michiganResults = allResults.filter(item => {
|
|
return item.display_name.toLowerCase().includes('michigan') ||
|
|
item.display_name.toLowerCase().includes(', mi,') ||
|
|
item.display_name.toLowerCase().includes('grand rapids');
|
|
});
|
|
|
|
// Remove duplicates based on display_name
|
|
const uniqueResults = michiganResults.filter((item, index, arr) =>
|
|
arr.findIndex(other => other.display_name === item.display_name) === index
|
|
);
|
|
|
|
autocompleteResults = uniqueResults.slice(0, 5);
|
|
showAutocomplete(autocompleteResults);
|
|
} catch (error) {
|
|
console.error('Error fetching address suggestions:', error);
|
|
hideAutocomplete();
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
// Remove previous selection
|
|
items[selectedIndex]?.classList.remove('selected');
|
|
|
|
// Update index
|
|
if (direction === 'down') {
|
|
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
|
|
} else {
|
|
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
|
}
|
|
|
|
// Add new selection
|
|
items[selectedIndex].classList.add('selected');
|
|
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
};
|
|
|
|
// Event listeners for autocomplete
|
|
addressInput.addEventListener('input', (e) => {
|
|
clearTimeout(autocompleteTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
// Debounce the API calls
|
|
autocompleteTimeout = setTimeout(() => {
|
|
fetchAddressSuggestions(query);
|
|
}, 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;
|
|
}
|
|
});
|
|
|
|
// Hide autocomplete when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.autocomplete-container')) {
|
|
hideAutocomplete();
|
|
}
|
|
});
|
|
|
|
// Cleanup interval when page is unloaded
|
|
window.addEventListener('beforeunload', () => {
|
|
if (updateInterval) {
|
|
clearInterval(updateInterval);
|
|
}
|
|
});
|
|
});
|
|
|