ice/archive/app-mapbox-refactored.js
Derek Slenk 96e2619aa2
All checks were successful
CI / Validate i18n Files (pull_request) Successful in 19s
Dependency Review / Review Dependencies (pull_request) Successful in 26s
CI / TypeScript Type Check (pull_request) Successful in 1m20s
CI / Lint Code (pull_request) Successful in 1m37s
CI / Build Project (pull_request) Successful in 1m32s
CI / Security Checks (pull_request) Successful in 1m35s
CI / Run Tests (Node 20) (pull_request) Successful in 1m42s
CI / Run Tests (Node 18) (pull_request) Successful in 1m49s
Code Quality / Code Quality Checks (pull_request) Successful in 1m57s
CI / Test Coverage (pull_request) Successful in 1m32s
feat: Add shared components and styling for icewatch application
- Created example-shared-components.html to demonstrate TypeScript-based shared header and footer components.
- Added original-style.css for theming with CSS variables and dark mode support.
- Introduced style-backup.css for legacy styles.
- Developed test-refactored.html for testing map components with Leaflet integration.
- Updated deployment documentation to reflect changes in log file paths and service names.
- Renamed project from "great-lakes-ice-report" to "icewatch" in package.json and package-lock.json.
- Updated Caddyfile for new log file path.
- Added S3 bucket policy for public read access to greatlakes-conditions.
- Removed old service file and created new systemd service for icewatch.
2025-07-17 13:56:32 -04:00

350 lines
No EOL
12 KiB
JavaScript

/**
* MapBox implementation with table view and theme toggle using MapBase
*/
class MapBoxApp extends MapBase {
constructor() {
super();
this.config = { hasMapbox: false, mapboxAccessToken: null };
this.currentView = 'map'; // 'map' or 'table'
this.initializeConfig();
this.initializeMapBoxFeatures();
}
/**
* Initialize API configuration
*/
async initializeConfig() {
try {
const configResponse = await fetch('/api/config');
this.config = await configResponse.json();
console.log('🔧 API Configuration:', this.config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback');
} catch (error) {
console.warn('Failed to load API configuration, using fallback');
}
this.initializeMap();
}
/**
* Initialize MapBox-specific features
*/
initializeMapBoxFeatures() {
this.initializeViewToggle();
this.initializeThemeToggle();
}
/**
* Initialize the Leaflet map
*/
initializeMap() {
this.map = L.map('map').setView([42.9634, -85.6681], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(this.map);
}
/**
* Initialize view toggle functionality
*/
initializeViewToggle() {
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
if (mapViewBtn && tableViewBtn) {
mapViewBtn.addEventListener('click', () => this.switchView('map'));
tableViewBtn.addEventListener('click', () => this.switchView('table'));
}
}
/**
* Initialize theme toggle functionality
*/
initializeThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Apply saved theme on load
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.body.dataset.theme = savedTheme;
}
}
/**
* Toggle between light and dark theme
*/
toggleTheme() {
const currentTheme = document.body.dataset.theme || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.body.dataset.theme = newTheme;
localStorage.setItem('theme', newTheme);
}
/**
* Switch between map and table view
*/
switchView(viewType) {
this.currentView = viewType;
const mapView = document.getElementById('map-view');
const tableView = document.getElementById('table-view');
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
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(() => this.map.invalidateSize(), 100);
} else {
mapView.style.display = 'none';
tableView.style.display = 'block';
mapViewBtn.classList.remove('active');
tableViewBtn.classList.add('active');
}
}
/**
* Create custom marker for location
*/
createMarker(location) {
const isPersistent = !!location.persistent;
const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
const customIcon = this.createCustomIcon(isPersistent);
const marker = L.marker([location.latitude, location.longitude], {
icon: customIcon
})
.addTo(this.map)
.bindPopup(`<strong>${location.address}</strong><br>${location.description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
return marker;
}
/**
* Create custom icon for markers
*/
createCustomIcon(isPersistent = false) {
const iconColor = isPersistent ? '#28a745' : '#dc3545';
const iconSymbol = isPersistent ? '🔒' : '⚠️';
return L.divIcon({
className: 'custom-marker',
html: `
<div style="
background-color: ${iconColor};
width: 30px;
height: 30px;
border-radius: 50%;
border: 3px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
cursor: pointer;
">${iconSymbol}</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
});
}
/**
* Display locations (override to handle table view)
*/
displayLocations(locations) {
super.displayLocations(locations);
// Update table view if currently active
if (this.currentView === 'table') {
this.renderTable(locations);
}
}
/**
* Render table view
*/
renderTable(locations) {
const reportsTableBody = document.getElementById('reports-tbody');
const tableLocationCount = document.getElementById('table-location-count');
if (!reportsTableBody || !locations || locations.length === 0) {
if (reportsTableBody) {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
}
if (tableLocationCount) {
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 = window.getTimeAgo ? window.getTimeAgo(location.created_at) : '';
const timeRemaining = this.getTimeRemaining(location.created_at, location.persistent);
const remainingClass = this.getRemainingClass(location.created_at, location.persistent);
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' : ''}`;
}
/**
* Get time remaining for a report
*/
getTimeRemaining(createdAt, isPersistent) {
if (isPersistent) {
return '♾️ Persistent';
}
const created = new Date(createdAt);
const now = new Date();
const elapsed = now - created;
const remaining = (48 * 60 * 60 * 1000) - elapsed; // 48 hours in ms
if (remaining <= 0) {
return '⏰ Expired';
}
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return `${days}d ${remainingHours}h`;
}
return `${hours}h ${minutes}m`;
}
/**
* Get CSS class for time remaining
*/
getRemainingClass(createdAt, isPersistent) {
if (isPersistent) {
return 'persistent';
}
const created = new Date(createdAt);
const now = new Date();
const elapsed = now - created;
const remaining = (48 * 60 * 60 * 1000) - elapsed;
if (remaining <= 0) {
return 'expired';
} else if (remaining < 6 * 60 * 60 * 1000) { // Less than 6 hours
return 'expiring-soon';
} else if (remaining < 24 * 60 * 60 * 1000) { // Less than 24 hours
return 'expiring-today';
}
return 'fresh';
}
/**
* Search for addresses using MapBox or Nominatim fallback
*/
async searchAddress(query) {
try {
let results = [];
// Try MapBox first if available
if (this.config.hasMapbox && this.config.mapboxAccessToken) {
try {
results = await this.searchMapBox(query);
if (results.length > 0) {
this.showAutocomplete(results);
return;
}
} catch (error) {
console.warn('MapBox search failed, falling back to Nominatim:', error);
}
}
// Fallback to Nominatim
results = await this.searchNominatim(query);
this.showAutocomplete(results);
} catch (error) {
console.error('Error searching addresses:', error);
this.showAutocomplete([]);
}
}
/**
* Search using MapBox API
*/
async searchMapBox(query) {
const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${this.config.mapboxAccessToken}&country=us&proximity=-85.6681,42.9634&limit=5`);
const data = await response.json();
if (data.features && data.features.length > 0) {
return data.features.map(feature => ({
display_name: feature.place_name,
lat: feature.center[1],
lon: feature.center[0]
}));
}
return [];
}
/**
* Search using Nominatim as fallback
*/
async searchNominatim(query) {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(query + ', Michigan')}`);
const data = await response.json();
if (data && data.length > 0) {
return data.slice(0, 5);
}
return [];
}
/**
* Handle submission errors (override to handle profanity rejection)
*/
handleSubmitError(data) {
if (data.error === 'Submission rejected' && data.message) {
// Enhanced profanity rejection handling
this.showMessage(data.message, 'error', 10000);
} else {
super.handleSubmitError(data);
}
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new MapBoxApp();
});