// TM GNSS Guard - Dashboard JavaScript
// Auto-refreshes data every 10 seconds and updates the UI
let map = null;
let markers = {};
let currentData = null;
let lastFetchSucceeded = false;
let lastValidationTimestamp = null;
// Route visualization
let routeMarkers = [];
let routeCoords = []; // Store route coordinates for bounds calculation
let showRouteEnabled = getRoutePreference(); // Load from localStorage
let isInitialMapLoad = true; // Only fit bounds on initial load
// Get route preference from localStorage (defaults to true if not set)
function getRoutePreference() {
if (!window.SHOW_ROUTE) return false;
const stored = localStorage.getItem('showRoute');
return stored === null ? true : stored === 'true';
}
// Save route preference to localStorage
function saveRoutePreference(enabled) {
localStorage.setItem('showRoute', enabled ? 'true' : 'false');
}
// =============================================================================
// AUTO-REFRESH PAGE (every 1 hour to pick up deployments)
// =============================================================================
const PAGE_LOAD_TIME = Date.now();
const AUTO_REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
let lastVisibilityCheck = Date.now();
function checkAutoRefresh() {
const elapsed = Date.now() - PAGE_LOAD_TIME;
if (elapsed >= AUTO_REFRESH_INTERVAL_MS) {
console.log('Auto-refreshing page after 1 hour...');
window.location.reload();
}
}
// Check for refresh on visibility change (tab becomes active)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const now = Date.now();
// Only check if at least 10 seconds since last check (prevents rapid refreshes)
if (now - lastVisibilityCheck > 10000) {
lastVisibilityCheck = now;
checkAutoRefresh();
}
}
});
// Periodic check every 5 minutes while tab is active
setInterval(checkAutoRefresh, 5 * 60 * 1000);
// Initialize map
function initMap() {
// Create map centered on default location
map = L.map('map', {
zoomControl: true
}).setView([34.665151, 33.016326], 11);
// Dark basemap
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap & CARTO'
}).addTo(map);
// Recalculate marker offsets when zoom changes
map.on('zoomend', () => {
if (currentData) {
updateMap(currentData);
}
});
}
// Create marker icons (using local files)
function makeIcon(color) {
return new L.Icon({
iconUrl: `/static/markers/marker-icon-${color}.png`,
shadowUrl: '/static/markers/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34]
});
}
const iconPrimary = makeIcon('violet');
const iconPrimaryAlert = makeIcon('red');
const iconAIS = makeIcon('blue');
const iconStarlinkGps = makeIcon('yellow');
const iconStarlinkLocation = makeIcon('green');
const iconSecondary = makeIcon('grey');
// Format timestamp as relative time
function formatRelativeTime(timestampUnix) {
if (!timestampUnix) return '-';
const now = Date.now() / 1000;
const diff = now - timestampUnix;
if (diff < 0) return 'just now';
if (diff < 60) return `${Math.floor(diff)} sec ago`;
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
return `${Math.floor(diff / 86400)} days ago`;
}
// Format UTC timestamp for display
function formatUTCTimestamp(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
}
// Log event to event log
function logEvent(level, text) {
const eventLog = document.getElementById('eventLog');
if (!eventLog) return;
const div = document.createElement('div');
div.className = 'event level-' + level;
const spanLevel = document.createElement('span');
spanLevel.className = 'level';
spanLevel.textContent = level.toUpperCase();
div.appendChild(spanLevel);
const spanText = document.createElement('span');
const timestamp = new Date().toTimeString().slice(0, 8);
spanText.textContent = ' [' + timestamp + '] ' + text;
div.appendChild(spanText);
eventLog.appendChild(div);
// Limit to last 3 events (re-query each iteration since querySelectorAll is static)
while (eventLog.querySelectorAll('.event').length > 3) {
const firstEvent = eventLog.querySelector('.event');
if (firstEvent) {
eventLog.removeChild(firstEvent);
} else {
break;
}
}
}
// Update source card
function updateSource(sourceName, sourceData) {
const card = document.getElementById(`card-${sourceName}`);
const badge = document.getElementById(`badge-${sourceName}`);
const coordsEl = document.getElementById(`coords-${sourceName}`);
const updateEl = document.getElementById(`update-${sourceName}`);
if (!card || !badge) return;
// Reset card classes
card.classList.remove('ok', 'warn', 'crit', 'stale', 'offline');
badge.className = 'badge';
if (!sourceData.enabled) {
// Not configured
card.classList.add('offline');
badge.classList.add('badge-offline');
badge.textContent = 'NOT CONFIGURED';
if (coordsEl) {
coordsEl.textContent = 'No data source configured.';
}
if (updateEl) {
updateEl.textContent = '-';
updateEl.className = '';
}
} else if (sourceData.status === 'missing') {
// Missing data
card.classList.add('crit');
badge.classList.add('badge-danger');
badge.textContent = 'MISSING';
if (coordsEl) {
coordsEl.textContent = 'MISSING';
coordsEl.className = 'alert';
}
if (updateEl) {
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
updateEl.className = 'alert';
}
} else if (sourceData.status === 'stale' || sourceData.is_stale) {
// Stale data - has coordinates but timestamp is old
card.classList.add('stale');
badge.classList.add('badge-stale');
badge.textContent = 'STALE';
if (coordsEl && sourceData.coordinates) {
const lat = sourceData.coordinates.latitude;
const lon = sourceData.coordinates.longitude;
coordsEl.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
coordsEl.className = '';
}
if (updateEl) {
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
updateEl.className = 'stale-text';
}
} else {
// Has valid data
card.classList.add('ok');
badge.classList.add('badge-healthy');
badge.textContent = 'HEALTHY';
if (coordsEl && sourceData.coordinates) {
const lat = sourceData.coordinates.latitude;
const lon = sourceData.coordinates.longitude;
coordsEl.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
coordsEl.className = '';
}
if (updateEl) {
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
updateEl.className = '';
}
}
}
// Calculate offset for markers to spread them in a circle when close together
function calculateMarkerOffsets(sourceCoords, zoomLevel) {
if (Object.keys(sourceCoords).length <= 1) {
// Single marker, no offset needed
const result = {};
for (const [name, coord] of Object.entries(sourceCoords)) {
result[name] = { lat: coord.lat, lon: coord.lon, offsetLat: 0, offsetLon: 0 };
}
return result;
}
// Calculate centroid
let sumLat = 0, sumLon = 0, count = 0;
for (const coord of Object.values(sourceCoords)) {
sumLat += coord.lat;
sumLon += coord.lon;
count++;
}
const centroidLat = sumLat / count;
const centroidLon = sumLon / count;
// Check if markers are close together (within ~50 meters)
const closeThreshold = 0.0005; // ~50m in degrees
let maxDist = 0;
for (const coord of Object.values(sourceCoords)) {
const dist = Math.sqrt(
Math.pow(coord.lat - centroidLat, 2) +
Math.pow(coord.lon - centroidLon, 2)
);
maxDist = Math.max(maxDist, dist);
}
// If markers are spread out enough, don't offset
if (maxDist > closeThreshold) {
const result = {};
for (const [name, coord] of Object.entries(sourceCoords)) {
result[name] = { lat: coord.lat, lon: coord.lon, offsetLat: 0, offsetLon: 0 };
}
return result;
}
// Calculate offset radius based on zoom level (smaller offset when zoomed in)
// At zoom 15, offset ~30m; at zoom 10, offset ~100m
const baseOffset = 0.0003; // ~30m base offset
const zoomFactor = Math.pow(2, 15 - Math.min(zoomLevel, 18));
const offsetRadius = baseOffset * zoomFactor;
// Arrange markers in a circle around centroid
const result = {};
const sourceNames = Object.keys(sourceCoords);
const angleStep = (2 * Math.PI) / sourceNames.length;
sourceNames.forEach((name, index) => {
const angle = angleStep * index - Math.PI / 2; // Start from top
const offsetLat = offsetRadius * Math.cos(angle);
const offsetLon = offsetRadius * Math.sin(angle) * 1.5; // Adjust for latitude distortion
result[name] = {
lat: centroidLat + offsetLat,
lon: centroidLon + offsetLon,
offsetLat: offsetLat,
offsetLon: offsetLon,
originalLat: sourceCoords[name].lat,
originalLon: sourceCoords[name].lon
};
});
return result;
}
// Update map markers
function updateMap(data) {
if (!data.map_center) return;
const sourceMapping = {
'nmea_primary': { icon: iconPrimary, name: 'Primary GPS' },
'nmea_secondary': { icon: iconSecondary, name: 'Secondary GPS' },
'tm_ais': { icon: iconAIS, name: 'TM AIS GPS' },
'starlink_gps': { icon: iconStarlinkGps, name: 'Starlink GPS' },
'starlink_location': { icon: iconStarlinkLocation, name: 'Starlink Location' }
};
// Collect all valid source coordinates
const sourceCoords = {};
const allCoords = [];
Object.keys(sourceMapping).forEach(sourceName => {
const sourceData = data.sources[sourceName];
if (sourceData && sourceData.enabled && sourceData.status !== 'missing' && sourceData.coordinates) {
const lat = sourceData.coordinates.latitude;
const lon = sourceData.coordinates.longitude;
sourceCoords[sourceName] = { lat, lon };
allCoords.push([lat, lon]);
}
});
// Calculate offsets for overlapping markers
const zoomLevel = map.getZoom() || 13;
const offsetPositions = calculateMarkerOffsets(sourceCoords, zoomLevel);
// Update or create markers for each source
Object.keys(sourceMapping).forEach(sourceName => {
const sourceData = data.sources[sourceName];
if (!sourceData || !sourceData.enabled || sourceData.status === 'missing' || !sourceData.coordinates) {
// Remove marker if it exists
if (markers[sourceName]) {
map.removeLayer(markers[sourceName]);
delete markers[sourceName];
}
return;
}
const mapping = sourceMapping[sourceName];
const position = offsetPositions[sourceName];
// Use alert icon for primary if there's an alert
let icon = mapping.icon;
if (sourceName === 'nmea_primary' && data.has_alert && !data.is_valid) {
icon = iconPrimaryAlert;
}
// Build popup with original coordinates
const origLat = sourceData.coordinates.latitude;
const origLon = sourceData.coordinates.longitude;
const popupContent = `${mapping.name}
` +
`Lat: ${origLat.toFixed(6)}
` +
`Lon: ${origLon.toFixed(6)}`;
if (markers[sourceName]) {
markers[sourceName].setLatLng([position.lat, position.lon]).setIcon(icon);
markers[sourceName].setPopupContent(popupContent);
} else {
markers[sourceName] = L.marker([position.lat, position.lon], { icon: icon })
.addTo(map)
.bindPopup(popupContent);
}
});
// Fit map to show all markers (only on initial load, not on refresh)
// If route is enabled, wait for route data to load before fitting bounds
if (isInitialMapLoad && !window.SHOW_ROUTE) {
if (allCoords.length > 0) {
const bounds = L.latLngBounds(allCoords);
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 15
});
} else if (data.map_center && data.map_center.latitude && data.map_center.longitude) {
map.setView([data.map_center.latitude, data.map_center.longitude], 13);
}
isInitialMapLoad = false;
}
}
// Track alarm state
let alarmActive = false;
let alarmAcknowledged = false;
// Update global status pill and border frame
function updateGlobalStatus(data) {
const statusPill = document.getElementById('globalStatusPill');
const statusText = statusPill ? statusPill.querySelector('.status-text') : null;
const borderFrame = document.getElementById('healthBorderFrame');
if (statusPill) {
statusPill.classList.remove('warn', 'crit', 'alarm-active', 'alarm-acknowledged');
}
if (borderFrame) {
borderFrame.classList.remove('warn', 'crit');
}
if (data.is_valid) {
if (statusText) {
statusText.textContent = 'GNSS Integrity: Stable';
} else if (statusPill) {
statusPill.textContent = 'GNSS Integrity: Stable';
}
// Border frame hidden (no class) when healthy
// Reset alarm state when status returns to healthy
alarmActive = false;
alarmAcknowledged = false;
} else if (data.has_alert && data.max_distance_km !== null) {
if (statusText) {
statusText.textContent = 'GNSS Integrity: At Risk';
} else if (statusPill) {
statusPill.textContent = 'GNSS Integrity: At Risk';
}
if (statusPill) {
statusPill.classList.add('crit');
}
if (borderFrame) {
borderFrame.classList.add('crit');
}
// Alarm should be active in this state
if (!alarmAcknowledged) {
alarmActive = true;
}
} else {
if (statusText) {
statusText.textContent = 'GNSS Integrity: Degraded';
} else if (statusPill) {
statusPill.textContent = 'GNSS Integrity: Degraded';
}
if (statusPill) {
statusPill.classList.add('warn');
}
if (borderFrame) {
borderFrame.classList.add('warn');
}
// Alarm should be active in this state
if (!alarmAcknowledged) {
alarmActive = true;
}
}
// Update alarm visual state
updateAlarmVisualState();
}
// Update alarm visual state on the button
function updateAlarmVisualState() {
const statusPill = document.getElementById('globalStatusPill');
if (!statusPill) return;
statusPill.classList.remove('alarm-active', 'alarm-acknowledged');
if (alarmActive && !alarmAcknowledged) {
statusPill.classList.add('alarm-active');
} else if (alarmAcknowledged) {
statusPill.classList.add('alarm-acknowledged');
}
}
// Acknowledge alarm - called when user clicks the status button
async function acknowledgeAlarm() {
const statusPill = document.getElementById('globalStatusPill');
// Only allow acknowledgment if alarm is active
if (!alarmActive && !statusPill.classList.contains('warn') && !statusPill.classList.contains('crit')) {
console.log('No active alarm to acknowledge');
return;
}
try {
const response = await fetch('/api/alarm/acknowledge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
console.log('Alarm acknowledged:', data);
alarmAcknowledged = true;
alarmActive = false;
updateAlarmVisualState();
logEvent('info', 'Alarm acknowledged - buzzer muted');
} else {
console.error('Failed to acknowledge alarm:', response.statusText);
logEvent('warn', 'Failed to acknowledge alarm');
}
} catch (error) {
console.error('Error acknowledging alarm:', error);
logEvent('warn', 'Error acknowledging alarm: ' + error.message);
}
}
// Fetch alarm status from server
async function fetchAlarmStatus() {
try {
const response = await fetch('/api/alarm/status');
if (response.ok) {
const data = await response.json();
alarmActive = data.alarm_active;
alarmAcknowledged = data.alarm_acknowledged;
updateAlarmVisualState();
}
} catch (error) {
console.error('Error fetching alarm status:', error);
}
}
// Update alert banner
function updateAlertBanner(data) {
const banner = document.getElementById('alert-banner');
const alertText = document.getElementById('alertText');
const alertDistance = document.getElementById('alert-distance-value');
const alertIndicator = document.getElementById('alertIndicator');
if (!banner) return;
banner.classList.add('hidden');
banner.classList.remove('alert-critical', 'alert-warning');
if (data.has_alert && !data.is_valid && data.max_distance_km !== null) {
// Show distance alert
banner.classList.remove('hidden');
banner.classList.add('alert-critical');
if (alertDistance) {
alertDistance.textContent = `${data.max_distance_km.toFixed(1)} km`;
}
if (alertText) {
alertText.textContent = `GPS Jamming or Spoofing Alert! Location Distance: ${data.max_distance_km.toFixed(1)} km`;
}
if (alertIndicator) {
alertIndicator.style.background = 'var(--accent-red)';
}
} else if (data.has_alert) {
// Has alert but not distance-based (missing sources)
banner.classList.remove('hidden');
banner.classList.add('alert-warning');
if (alertText) {
alertText.textContent = 'Some GPS sources are missing or stale.';
}
if (alertIndicator) {
alertIndicator.style.background = 'var(--accent-amber)';
}
}
}
// Helper function to show degraded state
function showDegradedState(errorMessage) {
lastFetchSucceeded = false;
// Update status pill and border frame to degraded FIRST (before logging which could fail)
try {
const statusPill = document.getElementById('globalStatusPill');
const statusText = statusPill ? statusPill.querySelector('.status-text') : null;
if (statusPill) {
if (statusText) {
statusText.textContent = 'GNSS Integrity: Degraded';
} else {
statusPill.textContent = 'GNSS Integrity: Degraded';
}
statusPill.classList.remove('crit');
statusPill.classList.add('warn');
// Activate alarm state
if (!alarmAcknowledged) {
alarmActive = true;
updateAlarmVisualState();
}
}
const borderFrame = document.getElementById('healthBorderFrame');
if (borderFrame) {
borderFrame.classList.remove('crit');
borderFrame.classList.add('warn');
}
} catch (e) {
console.error('Error updating status pill:', e);
}
// Mark all "Updated" timestamps as stale/error (red)
try {
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
sources.forEach(sourceName => {
const updateEl = document.getElementById(`update-${sourceName}`);
if (updateEl) {
updateEl.classList.add('stale-text');
}
});
} catch (e) {
console.error('Error updating source timestamps:', e);
}
// Log the error message last
logEvent('crit', errorMessage);
}
// Fetch and update data
async function fetchData() {
try {
const response = await fetch('/api/status');
if (!response.ok) {
console.error('Failed to fetch status:', response.statusText);
showDegradedState(`Server error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
currentData = data;
lastFetchSucceeded = true;
// Update all sources
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
sources.forEach(sourceName => {
updateSource(sourceName, data.sources[sourceName]);
});
// If distance-based alert (GPS jamming/spoofing), mark ALL enabled sources as "AT RISK"
if (data.has_alert && !data.is_valid && data.max_distance_km !== null) {
sources.forEach(sourceName => {
const sourceData = data.sources[sourceName];
// Only mark sources that have coordinates (participated in distance validation)
if (sourceData && sourceData.enabled && sourceData.status !== 'missing' && sourceData.coordinates) {
const card = document.getElementById(`card-${sourceName}`);
const badge = document.getElementById(`badge-${sourceName}`);
if (card) {
card.classList.remove('ok', 'stale');
card.classList.add('crit');
}
if (badge) {
badge.className = 'badge badge-danger';
badge.textContent = 'AT RISK';
}
}
});
}
// Capture initial load flag before updateMap modifies it
const wasInitialLoad = isInitialMapLoad;
// Update map
updateMap(data);
// Update global status
updateGlobalStatus(data);
// Update alert banner
updateAlertBanner(data);
// Load route data if enabled (fit bounds on initial load)
if (window.SHOW_ROUTE && showRouteEnabled) {
loadRouteData(wasInitialLoad);
}
} catch (error) {
console.error('Error fetching data:', error);
showDegradedState('Failed to fetch status data: ' + error.message);
}
}
// Update relative times
function updateRelativeTimes() {
if (!currentData) return;
const sources = ['nmea_primary', 'nmea_secondary', 'tm_ais', 'starlink_gps', 'starlink_location'];
sources.forEach(sourceName => {
const sourceData = currentData.sources[sourceName];
const updateEl = document.getElementById(`update-${sourceName}`);
if (updateEl && sourceData && sourceData.enabled && sourceData.last_update_unix) {
updateEl.textContent = formatRelativeTime(sourceData.last_update_unix);
}
});
}
// Tab switching for mobile
function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
// Update button states
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update content visibility
tabContents.forEach(content => {
content.classList.remove('active');
if (content.id === `tab-${tabName}`) {
content.classList.add('active');
}
});
// Invalidate map size when switching to map tab
if (tabName === 'map' && map) {
setTimeout(() => {
map.invalidateSize();
}, 100);
}
});
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
// Initialize tabs
initTabs();
// Initialize map
initMap();
// Initialize route checkbox from stored preference
initRouteCheckbox();
// Initial log
logEvent('info', 'TM GNSS Guard dashboard initialized.');
// Initial data fetch
fetchData();
// Initial alarm status fetch
fetchAlarmStatus();
// Update relative times every second
setInterval(updateRelativeTimes, 1000);
// Fetch data every 10 seconds
setInterval(fetchData, 10000);
// Fetch alarm status every 5 seconds (more frequent to stay in sync with buzzer)
setInterval(fetchAlarmStatus, 5000);
// Log data fetch events (only when data actually changed)
setInterval(() => {
if (currentData && lastFetchSucceeded) {
// Only log if validation timestamp changed (new data from backend)
if (currentData.validation_timestamp !== lastValidationTimestamp) {
lastValidationTimestamp = currentData.validation_timestamp;
if (currentData.has_alert && !currentData.is_valid && currentData.max_distance_km !== null) {
logEvent('crit', `Server reports alert: distance ${currentData.max_distance_km.toFixed(1)} km`);
} else if (!currentData.is_valid) {
logEvent('warn', 'Server reports validation issue.');
} else {
logEvent('info', 'Server status OK.');
}
}
}
}, 10000);
});
// =============================================================================
// ROUTE VISUALIZATION (24h history)
// =============================================================================
async function loadRouteData(fitBoundsAfter = false) {
if (!window.SHOW_ROUTE) return;
try {
const response = await fetch('/api/route');
if (!response.ok) {
// If route fetch fails on initial load, still fit bounds to source markers
if (fitBoundsAfter) {
fitBoundsToMarkers();
isInitialMapLoad = false;
}
return;
}
const routeData = await response.json();
renderRoute(routeData);
// Fit bounds to include route on initial load
if (fitBoundsAfter) {
// Collect current source marker coordinates
const sourceCoords = [];
Object.values(markers).forEach(marker => {
const latlng = marker.getLatLng();
sourceCoords.push([latlng.lat, latlng.lng]);
});
// Use all route points (full 24h) for bounds calculation
const routePointCoords = [];
routeData.forEach(point => {
if (point.lat && point.lng) {
routePointCoords.push([point.lat, point.lng]);
}
});
const allCoords = [...sourceCoords, ...routePointCoords];
if (allCoords.length > 0) {
const bounds = L.latLngBounds(allCoords);
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 15
});
} else {
fitBoundsToMarkers();
}
isInitialMapLoad = false;
}
} catch (error) {
console.error('Error loading route:', error);
// If route fetch fails on initial load, still fit bounds to source markers
if (fitBoundsAfter) {
fitBoundsToMarkers();
isInitialMapLoad = false;
}
}
}
function fitBoundsToMarkers() {
const sourceCoords = [];
Object.values(markers).forEach(marker => {
const latlng = marker.getLatLng();
sourceCoords.push([latlng.lat, latlng.lng]);
});
if (sourceCoords.length > 0) {
const bounds = L.latLngBounds(sourceCoords);
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 15
});
} else if (currentData && currentData.map_center) {
map.setView([currentData.map_center.latitude, currentData.map_center.longitude], 13);
}
}
function renderRoute(routeData) {
clearRouteMarkers();
routeCoords = []; // Reset route coordinates
if (!showRouteEnabled || !routeData || routeData.length === 0) return;
// Create small circle markers for route points
routeData.forEach(point => {
if (!point.lat || !point.lng) return;
// Store coordinates for bounds calculation
routeCoords.push([point.lat, point.lng]);
// Determine color based on status
let color;
switch (point.status) {
case 'alert':
color = '#c62828'; // Red
break;
case 'degraded':
color = '#ffa726'; // Amber
break;
default:
color = '#1fad3a'; // Green
}
const marker = L.circleMarker([point.lat, point.lng], {
radius: 4,
fillColor: color,
color: color,
weight: 1,
opacity: 0.8,
fillOpacity: 0.6
}).addTo(map);
// Add popup with details
const popupContent = `