- public/index.html — landing page at root - public/pages/ — all feature pages (regulations, loadboard, etc.) - public/js/ — api.js, nav.js, mock data files - All links updated to absolute paths (/pages/, /js/) - Express static path updated to serve from public/ - Seed script path updated for new mock data location - README updated with new project structure and setup guide - Added .env.example template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
424 lines
18 KiB
HTML
424 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Route & Weather Alerts | PilotEdge</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<style>
|
||
#map { height: 400px; width: 100%; border-radius: 0.75rem; }
|
||
.leaflet-popup-content { margin: 8px 12px; }
|
||
.leaflet-popup-content-wrapper { border-radius: 12px; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-50 min-h-screen flex flex-col">
|
||
|
||
<div id="main-nav"></div>
|
||
<div id="poc-banner"></div>
|
||
|
||
<!-- Page Header -->
|
||
<section class="bg-slate-900 text-white pt-24 pb-12 px-4">
|
||
<div class="max-w-7xl mx-auto">
|
||
<h1 class="text-3xl md:text-4xl font-bold mb-3">Route & Weather Alerts</h1>
|
||
<p class="text-lg text-gray-400 max-w-3xl">Know about closures, construction, and weather conditions on your permitted route BEFORE you depart.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Important Info Box -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-8 w-full">
|
||
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-6">
|
||
<div class="flex items-start gap-3">
|
||
<span class="text-2xl flex-shrink-0">ℹ️</span>
|
||
<div>
|
||
<h3 class="font-bold text-blue-900 text-lg mb-1">Important — Single-Trip Permitted Loads</h3>
|
||
<p class="text-blue-800 text-sm leading-relaxed">Single-trip permitted loads <strong>MUST</strong> follow their permitted route exactly. If a closure or construction zone is on your permitted route, <strong>contact the permit authority for a route amendment before departing.</strong> Deviating from a permitted route without authorization can result in fines, permit revocation, and liability issues.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Stats Bar -->
|
||
<section id="stats-bar" class="max-w-7xl mx-auto px-4 pt-6 w-full">
|
||
<!-- populated by JS -->
|
||
</section>
|
||
|
||
<!-- Filters -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-6 w-full">
|
||
<div class="bg-white rounded-2xl shadow-lg p-6">
|
||
<h2 class="text-lg font-bold text-slate-900 mb-4">Filter Alerts</h2>
|
||
<div class="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Type</label>
|
||
<select id="filter-type" class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-amber-400 focus:border-amber-400">
|
||
<option value="all">All Types</option>
|
||
<option value="construction">🚧 Construction</option>
|
||
<option value="closure">⛔ Closure</option>
|
||
<option value="wind">💨 Wind</option>
|
||
<option value="winter">❄️ Winter Storm</option>
|
||
<option value="fog">🌫️ Fog</option>
|
||
<option value="thunderstorm">⛈️ Thunderstorm</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Severity</label>
|
||
<select id="filter-severity" class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-amber-400 focus:border-amber-400">
|
||
<option value="all">All Severities</option>
|
||
<option value="minor">Minor</option>
|
||
<option value="moderate">Moderate</option>
|
||
<option value="major">Major</option>
|
||
<option value="critical">Critical</option>
|
||
<option value="advisory">Advisory</option>
|
||
<option value="watch">Watch</option>
|
||
<option value="warning">Warning</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<input type="checkbox" id="filter-oversize" class="w-4 h-4 text-amber-500 border-slate-300 rounded focus:ring-amber-400">
|
||
<label for="filter-oversize" class="text-sm font-medium text-slate-700">Affects oversize only</label>
|
||
</div>
|
||
<button onclick="resetFilters()" class="text-sm text-amber-600 hover:text-amber-700 font-medium underline">Reset</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tab Toggle -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-6 w-full">
|
||
<div class="flex gap-2">
|
||
<button id="tab-all" onclick="setTab('all')" class="px-5 py-2.5 rounded-xl text-sm font-semibold transition-colors">All Alerts</button>
|
||
<button id="tab-route" onclick="setTab('route')" class="px-5 py-2.5 rounded-xl text-sm font-semibold transition-colors">🚧 Route Conditions</button>
|
||
<button id="tab-weather" onclick="setTab('weather')" class="px-5 py-2.5 rounded-xl text-sm font-semibold transition-colors">🌦️ Weather Alerts</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Map -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-6 w-full">
|
||
<div class="bg-white rounded-2xl shadow-lg p-4">
|
||
<div id="map"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Route Conditions Cards -->
|
||
<section id="route-section" class="max-w-7xl mx-auto px-4 pt-8 w-full">
|
||
<h2 class="text-2xl font-bold text-slate-900 mb-4 flex items-center gap-2">🚧 Route Conditions</h2>
|
||
<div id="route-cards" class="grid md:grid-cols-2 gap-6">
|
||
<!-- populated by JS -->
|
||
</div>
|
||
<p id="route-empty" class="hidden text-slate-500 text-center py-8">No route conditions match your filters.</p>
|
||
</section>
|
||
|
||
<!-- Weather Alerts Cards -->
|
||
<section id="weather-section" class="max-w-7xl mx-auto px-4 pt-8 pb-8 w-full">
|
||
<h2 class="text-2xl font-bold text-slate-900 mb-4 flex items-center gap-2">🌦️ Weather Alerts</h2>
|
||
<div id="weather-cards" class="grid md:grid-cols-2 gap-6">
|
||
<!-- populated by JS -->
|
||
</div>
|
||
<p id="weather-empty" class="hidden text-slate-500 text-center py-8">No weather alerts match your filters.</p>
|
||
</section>
|
||
|
||
<div id="main-footer"></div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/nav.js"></script>
|
||
<script>
|
||
renderNav('alerts');
|
||
renderBanner();
|
||
renderFooter();
|
||
|
||
(async () => {
|
||
const alertData = await PilotEdge.getAlerts();
|
||
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
|
||
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
|
||
|
||
// ── State ──
|
||
let activeTab = 'all';
|
||
let map, markersLayer;
|
||
|
||
// ── Helpers ──
|
||
function formatDate(dateStr) {
|
||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
}
|
||
|
||
function formatDateTime(isoStr) {
|
||
return new Date(isoStr).toLocaleString('en-US', {
|
||
month: 'short', day: 'numeric', year: 'numeric',
|
||
hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
|
||
});
|
||
}
|
||
|
||
// ── Badge helpers ──
|
||
const typeBadge = {
|
||
construction: { label: '🚧 Construction', cls: 'bg-orange-100 text-orange-800' },
|
||
closure: { label: '⛔ Closure', cls: 'bg-red-100 text-red-800' },
|
||
wind: { label: '💨 Wind', cls: 'bg-blue-100 text-blue-800' },
|
||
winter: { label: '❄️ Winter Storm', cls: 'bg-indigo-100 text-indigo-800' },
|
||
fog: { label: '🌫️ Fog', cls: 'bg-gray-100 text-gray-700' },
|
||
thunderstorm: { label: '⛈️ Thunderstorm', cls: 'bg-purple-100 text-purple-800' }
|
||
};
|
||
|
||
const routeSeverityBadge = {
|
||
minor: { label: 'Minor', cls: 'bg-green-100 text-green-800' },
|
||
moderate: { label: 'Moderate', cls: 'bg-amber-100 text-amber-800' },
|
||
major: { label: 'Major', cls: 'bg-orange-100 text-orange-800' },
|
||
critical: { label: 'Critical', cls: 'bg-red-100 text-red-800' }
|
||
};
|
||
|
||
const weatherSeverityBadge = {
|
||
advisory: { label: 'Advisory', cls: 'bg-yellow-100 text-yellow-800' },
|
||
watch: { label: 'Watch', cls: 'bg-orange-100 text-orange-800' },
|
||
warning: { label: 'Warning', cls: 'bg-red-100 text-red-800' }
|
||
};
|
||
|
||
// ── Filtering ──
|
||
function getFilters() {
|
||
return {
|
||
type: document.getElementById('filter-type').value,
|
||
severity: document.getElementById('filter-severity').value,
|
||
oversizeOnly: document.getElementById('filter-oversize').checked
|
||
};
|
||
}
|
||
|
||
function filterRouteConditions() {
|
||
const f = getFilters();
|
||
return MOCK_ROUTE_CONDITIONS.filter(rc => {
|
||
if (f.type !== 'all' && rc.type !== f.type) return false;
|
||
if (f.severity !== 'all' && rc.severity !== f.severity) return false;
|
||
if (f.oversizeOnly && !rc.affectsOversize) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function filterWeatherAlerts() {
|
||
const f = getFilters();
|
||
return MOCK_WEATHER_ALERTS.filter(wa => {
|
||
if (f.type !== 'all' && wa.type !== f.type) return false;
|
||
if (f.severity !== 'all' && wa.severity !== f.severity) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function resetFilters() {
|
||
document.getElementById('filter-type').value = 'all';
|
||
document.getElementById('filter-severity').value = 'all';
|
||
document.getElementById('filter-oversize').checked = false;
|
||
renderAll();
|
||
}
|
||
|
||
// ── Stats bar ──
|
||
function renderStats() {
|
||
const routeCount = MOCK_ROUTE_CONDITIONS.length;
|
||
const weatherCount = MOCK_WEATHER_ALERTS.length;
|
||
const oversizeCount = MOCK_ROUTE_CONDITIONS.filter(r => r.affectsOversize).length;
|
||
|
||
document.getElementById('stats-bar').innerHTML = `
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
<div class="bg-white rounded-2xl shadow-lg p-5 flex items-center gap-4">
|
||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center text-2xl">🚧</div>
|
||
<div>
|
||
<p class="text-2xl font-bold text-slate-900">${routeCount}</p>
|
||
<p class="text-sm text-slate-500">Active Route Conditions</p>
|
||
</div>
|
||
</div>
|
||
<div class="bg-white rounded-2xl shadow-lg p-5 flex items-center gap-4">
|
||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center text-2xl">🌦️</div>
|
||
<div>
|
||
<p class="text-2xl font-bold text-slate-900">${weatherCount}</p>
|
||
<p class="text-sm text-slate-500">Active Weather Alerts</p>
|
||
</div>
|
||
</div>
|
||
<div class="bg-white rounded-2xl shadow-lg p-5 flex items-center gap-4">
|
||
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center text-2xl">⚠️</div>
|
||
<div>
|
||
<p class="text-2xl font-bold text-slate-900">${oversizeCount}</p>
|
||
<p class="text-sm text-slate-500">Alerts Affecting Oversize</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Tabs ──
|
||
function setTab(tab) {
|
||
activeTab = tab;
|
||
renderAll();
|
||
}
|
||
|
||
function renderTabButtons() {
|
||
['all', 'route', 'weather'].forEach(t => {
|
||
const btn = document.getElementById('tab-' + t);
|
||
if (t === activeTab) {
|
||
btn.className = 'px-5 py-2.5 rounded-xl text-sm font-semibold transition-colors bg-amber-500 text-slate-900 shadow-md';
|
||
} else {
|
||
btn.className = 'px-5 py-2.5 rounded-xl text-sm font-semibold transition-colors bg-white text-slate-600 hover:bg-slate-100 shadow';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Map ──
|
||
function initMap() {
|
||
map = L.map('map').setView([38.5, -98], 4);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors',
|
||
maxZoom: 18
|
||
}).addTo(map);
|
||
markersLayer = L.layerGroup().addTo(map);
|
||
}
|
||
|
||
function createIcon(color) {
|
||
return L.divIcon({
|
||
className: '',
|
||
html: `<div style="width:28px;height:28px;background:${color};border:3px solid white;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.35);"></div>`,
|
||
iconSize: [28, 28],
|
||
iconAnchor: [14, 14],
|
||
popupAnchor: [0, -16]
|
||
});
|
||
}
|
||
|
||
function renderMap() {
|
||
markersLayer.clearLayers();
|
||
const routes = (activeTab === 'all' || activeTab === 'route') ? filterRouteConditions() : [];
|
||
const weather = (activeTab === 'all' || activeTab === 'weather') ? filterWeatherAlerts() : [];
|
||
|
||
const routeColors = { construction: '#f97316', closure: '#ef4444' };
|
||
const weatherColors = { wind: '#3b82f6', winter: '#6366f1', fog: '#6b7280', thunderstorm: '#a855f7' };
|
||
|
||
routes.forEach(rc => {
|
||
const color = routeColors[rc.type] || '#f97316';
|
||
const sevInfo = routeSeverityBadge[rc.severity] || routeSeverityBadge.moderate;
|
||
const marker = L.marker([rc.location.lat, rc.location.lng], { icon: createIcon(color) });
|
||
marker.bindPopup(`
|
||
<div style="max-width:280px;">
|
||
<div style="font-weight:700;font-size:14px;margin-bottom:4px;">${rc.route}</div>
|
||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">${rc.location.desc}, ${rc.location.state}</div>
|
||
<div style="font-size:12px;margin-bottom:6px;">${rc.description}</div>
|
||
<div style="font-size:11px;color:#94a3b8;">${formatDate(rc.startDate)} – ${formatDate(rc.endDate)}</div>
|
||
${rc.affectsOversize ? '<div style="margin-top:6px;font-weight:700;color:#dc2626;font-size:12px;">⚠️ AFFECTS OVERSIZE</div>' : ''}
|
||
</div>
|
||
`);
|
||
markersLayer.addLayer(marker);
|
||
});
|
||
|
||
weather.forEach(wa => {
|
||
const color = weatherColors[wa.type] || '#3b82f6';
|
||
const marker = L.marker([wa.lat, wa.lng], { icon: createIcon(color) });
|
||
marker.bindPopup(`
|
||
<div style="max-width:280px;">
|
||
<div style="font-weight:700;font-size:14px;margin-bottom:4px;">${wa.region}</div>
|
||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">Routes: ${wa.routes.join(', ')}</div>
|
||
<div style="font-size:12px;margin-bottom:6px;">${wa.description}</div>
|
||
<div style="font-size:11px;color:#94a3b8;">Valid: ${formatDateTime(wa.validFrom)} – ${formatDateTime(wa.validTo)}</div>
|
||
</div>
|
||
`);
|
||
markersLayer.addLayer(marker);
|
||
});
|
||
}
|
||
|
||
// ── Route Condition Cards ──
|
||
function renderRouteCards() {
|
||
const section = document.getElementById('route-section');
|
||
const container = document.getElementById('route-cards');
|
||
const empty = document.getElementById('route-empty');
|
||
|
||
if (activeTab === 'weather') { section.classList.add('hidden'); return; }
|
||
section.classList.remove('hidden');
|
||
|
||
const items = filterRouteConditions();
|
||
if (items.length === 0) {
|
||
container.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
empty.classList.add('hidden');
|
||
|
||
container.innerHTML = items.map(rc => {
|
||
const tb = typeBadge[rc.type] || typeBadge.construction;
|
||
const sb = routeSeverityBadge[rc.severity] || routeSeverityBadge.moderate;
|
||
return `
|
||
<div class="bg-white rounded-2xl shadow-lg p-6 border-l-4 ${rc.type === 'closure' ? 'border-red-500' : 'border-orange-500'}">
|
||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${tb.cls}">${tb.label}</span>
|
||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${sb.cls}">${sb.label}</span>
|
||
${rc.affectsOversize ? '<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-600 text-white animate-pulse">⚠️ AFFECTS OVERSIZE</span>' : ''}
|
||
</div>
|
||
<h3 class="text-lg font-bold text-slate-900 mb-1">${rc.route}</h3>
|
||
<p class="text-sm text-slate-500 mb-2">${rc.location.desc}, ${rc.location.state}</p>
|
||
<p class="text-sm text-slate-700 mb-3">${rc.description}</p>
|
||
<div class="flex flex-wrap items-center gap-4 text-xs text-slate-500">
|
||
<span>📅 ${formatDate(rc.startDate)} – ${formatDate(rc.endDate)}</span>
|
||
<span>📡 ${rc.source}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Weather Alert Cards ──
|
||
function renderWeatherCards() {
|
||
const section = document.getElementById('weather-section');
|
||
const container = document.getElementById('weather-cards');
|
||
const empty = document.getElementById('weather-empty');
|
||
|
||
if (activeTab === 'route') { section.classList.add('hidden'); return; }
|
||
section.classList.remove('hidden');
|
||
|
||
const items = filterWeatherAlerts();
|
||
if (items.length === 0) {
|
||
container.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
empty.classList.add('hidden');
|
||
|
||
const borderColors = { wind: 'border-blue-500', winter: 'border-indigo-500', fog: 'border-gray-400', thunderstorm: 'border-purple-500' };
|
||
|
||
container.innerHTML = items.map(wa => {
|
||
const tb = typeBadge[wa.type] || typeBadge.wind;
|
||
const sb = weatherSeverityBadge[wa.severity] || weatherSeverityBadge.advisory;
|
||
const border = borderColors[wa.type] || 'border-blue-500';
|
||
return `
|
||
<div class="bg-white rounded-2xl shadow-lg p-6 border-l-4 ${border}">
|
||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${tb.cls}">${tb.label}</span>
|
||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${sb.cls}">${sb.label}</span>
|
||
</div>
|
||
<h3 class="text-lg font-bold text-slate-900 mb-1">${wa.region}</h3>
|
||
<p class="text-sm text-slate-500 mb-2">Affected routes: ${wa.routes.join(', ')}</p>
|
||
<p class="text-sm text-slate-700 mb-3">${wa.description}</p>
|
||
<div class="flex flex-wrap items-center gap-4 text-xs text-slate-500">
|
||
<span>🕐 ${formatDateTime(wa.validFrom)} – ${formatDateTime(wa.validTo)}</span>
|
||
<span>📡 ${wa.source}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Section visibility ──
|
||
function renderSectionVisibility() {
|
||
document.getElementById('route-section').classList.toggle('hidden', activeTab === 'weather');
|
||
document.getElementById('weather-section').classList.toggle('hidden', activeTab === 'route');
|
||
}
|
||
|
||
// ── Master render ──
|
||
function renderAll() {
|
||
renderTabButtons();
|
||
renderMap();
|
||
renderRouteCards();
|
||
renderWeatherCards();
|
||
}
|
||
|
||
// ── Init ──
|
||
initMap();
|
||
renderStats();
|
||
renderAll();
|
||
|
||
// Wire up filter change events
|
||
document.getElementById('filter-type').addEventListener('change', renderAll);
|
||
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
||
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|