mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
766 lines
31 KiB
HTML
766 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<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>
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
|
</head>
|
|
<body>
|
|
<!-- Radar background effects -->
|
|
<div class="radar-bg"></div>
|
|
<div class="scanline"></div>
|
|
|
|
<header class="header">
|
|
<div class="logo">
|
|
VESSEL RADAR
|
|
<span>// INTERCEPT - AIS Tracking</span>
|
|
</div>
|
|
<div class="status-bar">
|
|
<a href="/" class="back-link">Main Dashboard</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="stats-strip">
|
|
<div class="stats-strip-inner">
|
|
<div class="strip-stat">
|
|
<span class="strip-value" id="stripVesselsNow">0</span>
|
|
<span class="strip-label">VESSELS</span>
|
|
</div>
|
|
<div class="strip-stat">
|
|
<span class="strip-value" id="stripTotalSeen">0</span>
|
|
<span class="strip-label">SEEN</span>
|
|
</div>
|
|
<div class="strip-stat">
|
|
<span class="strip-value" id="stripMaxRange">0</span>
|
|
<span class="strip-label">MAX NM</span>
|
|
</div>
|
|
<div class="strip-stat">
|
|
<span class="strip-value" id="stripFastest">-</span>
|
|
<span class="strip-label">MAX KT</span>
|
|
</div>
|
|
<div class="strip-stat">
|
|
<span class="strip-value" id="stripClosest">-</span>
|
|
<span class="strip-label">NEAR NM</span>
|
|
</div>
|
|
<div class="strip-divider"></div>
|
|
<div class="strip-stat signal-stat" title="Signal quality (messages/sec)">
|
|
<span class="strip-value" id="stripSignal">--</span>
|
|
<span class="strip-label">SIGNAL</span>
|
|
</div>
|
|
<div class="strip-stat session-stat">
|
|
<span class="strip-value" id="stripSession">00:00:00</span>
|
|
<span class="strip-label">SESSION</span>
|
|
</div>
|
|
<div class="strip-divider"></div>
|
|
<div class="strip-status">
|
|
<div class="status-dot" id="trackingDot"></div>
|
|
<span id="trackingStatus">STANDBY</span>
|
|
</div>
|
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="dashboard">
|
|
<div class="main-display">
|
|
<div id="vesselMap"></div>
|
|
</div>
|
|
|
|
<div class="sidebar">
|
|
<div class="panel selected-vessel">
|
|
<div class="panel-header">
|
|
<span>SELECTED VESSEL</span>
|
|
<div class="panel-indicator"></div>
|
|
</div>
|
|
<div class="selected-info" id="selectedInfo">
|
|
<div class="no-vessel">
|
|
<div class="no-vessel-icon">🚢</div>
|
|
<div>Select a vessel</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel vessel-list">
|
|
<div class="panel-header">
|
|
<span>TRACKED VESSELS</span>
|
|
<div class="panel-indicator"></div>
|
|
</div>
|
|
<div class="vessel-list-content" id="vesselList">
|
|
<div class="no-vessel">
|
|
<div>No vessels detected</div>
|
|
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls-bar">
|
|
<div class="control-group">
|
|
<span class="control-group-label">DISPLAY</span>
|
|
<div class="control-group-items">
|
|
<label title="Show vessel trails"><input type="checkbox" id="showTrails" onchange="toggleTrails()"> Trails</label>
|
|
<label title="Show range rings"><input type="checkbox" id="showRangeRings" checked onchange="drawRangeRings()"> Rings</label>
|
|
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
|
|
<option value="10">10nm</option>
|
|
<option value="25">25nm</option>
|
|
<option value="50" selected>50nm</option>
|
|
<option value="100">100nm</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<span class="control-group-label">LOCATION</span>
|
|
<div class="control-group-items">
|
|
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
|
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group tracking-group">
|
|
<span class="control-group-label">AIS TRACKING</span>
|
|
<div class="control-group-items">
|
|
<select id="aisDeviceSelect" title="SDR device">
|
|
<option value="0">SDR 0</option>
|
|
</select>
|
|
<input type="number" id="aisGain" value="40" min="0" max="50" style="width: 50px;" title="Gain">
|
|
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// State
|
|
let vesselMap = null;
|
|
let vessels = {};
|
|
let markers = {};
|
|
let selectedMmsi = null;
|
|
let eventSource = null;
|
|
let isTracking = false;
|
|
let showTrails = false;
|
|
let vesselTrails = {};
|
|
let trailLines = {};
|
|
let maxRange = 50;
|
|
const MAX_TRAIL_POINTS = 50;
|
|
|
|
// Observer location
|
|
let observerLocation = { lat: 51.5074, lon: -0.1278 };
|
|
let rangeRingsLayer = null;
|
|
let observerMarker = null;
|
|
|
|
// Statistics
|
|
let stats = {
|
|
totalVesselsSeen: new Set(),
|
|
maxRange: 0,
|
|
fastestSpeed: 0,
|
|
closestDistance: Infinity,
|
|
sessionStart: null,
|
|
messagesReceived: 0,
|
|
messagesPerSecond: 0
|
|
};
|
|
|
|
// Session timer
|
|
let sessionTimerInterval = null;
|
|
let messageRateInterval = null;
|
|
let lastMessageCount = 0;
|
|
|
|
// Ship type to icon mapping
|
|
const SHIP_ICONS = {
|
|
30: '🐟', // Fishing
|
|
31: '🚢', // Towing
|
|
32: '🚢', // Towing
|
|
36: '⛵', // Sailing
|
|
37: '⛵', // Pleasure craft
|
|
60: '🚢', // Passenger
|
|
61: '🚢', // Passenger
|
|
62: '🚢', // Passenger
|
|
63: '🚢', // Passenger
|
|
64: '🚢', // Passenger
|
|
65: '🚢', // Passenger
|
|
66: '🚢', // Passenger
|
|
67: '🚢', // Passenger
|
|
68: '🚢', // Passenger
|
|
69: '🚢', // Passenger
|
|
70: '🚢', // Cargo
|
|
71: '🚢', // Cargo - hazardous A
|
|
72: '🚢', // Cargo - hazardous B
|
|
73: '🚢', // Cargo - hazardous C
|
|
74: '🚢', // Cargo - hazardous D
|
|
80: '🚢', // Tanker
|
|
81: '🚢', // Tanker - hazardous A
|
|
82: '🚢', // Tanker - hazardous B
|
|
83: '🚢', // Tanker - hazardous C
|
|
84: '🚢', // Tanker - hazardous D
|
|
default: '🚢' // Generic ship
|
|
};
|
|
|
|
// Ship type categories
|
|
function getShipCategory(type) {
|
|
if (!type) return 'Unknown';
|
|
if (type >= 20 && type < 30) return 'Wing in Ground';
|
|
if (type === 30) return 'Fishing';
|
|
if (type >= 31 && type <= 32) return 'Towing';
|
|
if (type >= 33 && type <= 34) return 'Dredging';
|
|
if (type === 35) return 'Military';
|
|
if (type >= 36 && type <= 37) return 'Sailing/Pleasure';
|
|
if (type >= 40 && type < 50) return 'High Speed Craft';
|
|
if (type === 50) return 'Pilot Vessel';
|
|
if (type === 51) return 'Search & Rescue';
|
|
if (type === 52) return 'Tug';
|
|
if (type === 53) return 'Port Tender';
|
|
if (type === 55) return 'Law Enforcement';
|
|
if (type >= 60 && type < 70) return 'Passenger';
|
|
if (type >= 70 && type < 80) return 'Cargo';
|
|
if (type >= 80 && type < 90) return 'Tanker';
|
|
return 'Other';
|
|
}
|
|
|
|
function getShipIcon(type) {
|
|
return SHIP_ICONS[type] || SHIP_ICONS.default;
|
|
}
|
|
|
|
// Navigation status text
|
|
const NAV_STATUS = {
|
|
0: 'Under way using engine',
|
|
1: 'At anchor',
|
|
2: 'Not under command',
|
|
3: 'Restricted maneuverability',
|
|
4: 'Constrained by draught',
|
|
5: 'Moored',
|
|
6: 'Aground',
|
|
7: 'Engaged in fishing',
|
|
8: 'Under way sailing',
|
|
11: 'Power-driven vessel towing astern',
|
|
12: 'Power-driven vessel pushing ahead',
|
|
14: 'AIS-SART active',
|
|
15: 'Undefined'
|
|
};
|
|
|
|
// Initialize map
|
|
function initMap() {
|
|
// Load saved observer location
|
|
const saved = localStorage.getItem('ais_observerLocation');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
if (parsed.lat && parsed.lon) {
|
|
observerLocation = parsed;
|
|
document.getElementById('obsLat').value = parsed.lat;
|
|
document.getElementById('obsLon').value = parsed.lon;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
vesselMap = L.map('vesselMap', {
|
|
center: [observerLocation.lat, observerLocation.lon],
|
|
zoom: 10,
|
|
zoomControl: true
|
|
});
|
|
|
|
// OpenStreetMap tile layer
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19
|
|
}).addTo(vesselMap);
|
|
|
|
// Add observer marker
|
|
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
|
|
radius: 8,
|
|
fillColor: '#22c55e',
|
|
color: '#22c55e',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.5
|
|
}).addTo(vesselMap);
|
|
observerMarker.bindTooltip('Observer', { permanent: false, direction: 'top' });
|
|
|
|
drawRangeRings();
|
|
loadDevices();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
setInterval(cleanupStaleVessels, 10000);
|
|
}
|
|
|
|
function loadDevices() {
|
|
fetch('/devices')
|
|
.then(r => r.json())
|
|
.then(devices => {
|
|
const select = document.getElementById('aisDeviceSelect');
|
|
select.innerHTML = '';
|
|
if (devices.length === 0) {
|
|
select.innerHTML = '<option value="0">No devices</option>';
|
|
} else {
|
|
devices.forEach((d, i) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = d.index;
|
|
opt.textContent = `SDR ${d.index}: ${d.name}`;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function updateObserverLoc() {
|
|
const lat = parseFloat(document.getElementById('obsLat').value);
|
|
const lon = parseFloat(document.getElementById('obsLon').value);
|
|
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
|
observerLocation = { lat, lon };
|
|
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
|
|
if (observerMarker) {
|
|
observerMarker.setLatLng([lat, lon]);
|
|
}
|
|
vesselMap.setView([lat, lon], vesselMap.getZoom());
|
|
drawRangeRings();
|
|
}
|
|
}
|
|
|
|
function drawRangeRings() {
|
|
if (rangeRingsLayer) {
|
|
vesselMap.removeLayer(rangeRingsLayer);
|
|
}
|
|
|
|
if (!document.getElementById('showRangeRings').checked) return;
|
|
|
|
const rings = [];
|
|
const nmToMeters = 1852;
|
|
const intervals = [maxRange / 4, maxRange / 2, maxRange * 3 / 4, maxRange];
|
|
|
|
intervals.forEach(nm => {
|
|
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
|
|
radius: nm * nmToMeters,
|
|
fill: false,
|
|
color: '#4a9eff',
|
|
opacity: 0.3,
|
|
weight: 1,
|
|
dashArray: '4 4'
|
|
});
|
|
rings.push(circle);
|
|
});
|
|
|
|
rangeRingsLayer = L.layerGroup(rings).addTo(vesselMap);
|
|
}
|
|
|
|
function updateRange() {
|
|
maxRange = parseInt(document.getElementById('rangeSelect').value);
|
|
drawRangeRings();
|
|
}
|
|
|
|
function toggleTrails() {
|
|
showTrails = document.getElementById('showTrails').checked;
|
|
Object.keys(trailLines).forEach(mmsi => {
|
|
if (trailLines[mmsi]) {
|
|
if (showTrails) {
|
|
trailLines[mmsi].addTo(vesselMap);
|
|
} else {
|
|
vesselMap.removeLayer(trailLines[mmsi]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleTracking() {
|
|
if (isTracking) {
|
|
stopTracking();
|
|
} else {
|
|
startTracking();
|
|
}
|
|
}
|
|
|
|
function startTracking() {
|
|
const device = document.getElementById('aisDeviceSelect').value;
|
|
const gain = document.getElementById('aisGain').value;
|
|
|
|
fetch('/ais/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device, gain })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'started' || data.status === 'already_running') {
|
|
isTracking = true;
|
|
document.getElementById('startBtn').textContent = 'STOP';
|
|
document.getElementById('startBtn').classList.add('active');
|
|
document.getElementById('trackingDot').classList.add('active');
|
|
document.getElementById('trackingStatus').textContent = 'TRACKING';
|
|
startSessionTimer();
|
|
startSSE();
|
|
} else {
|
|
alert(data.message || 'Failed to start');
|
|
}
|
|
})
|
|
.catch(err => alert('Error: ' + err.message));
|
|
}
|
|
|
|
function stopTracking() {
|
|
fetch('/ais/stop', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
isTracking = false;
|
|
document.getElementById('startBtn').textContent = 'START';
|
|
document.getElementById('startBtn').classList.remove('active');
|
|
document.getElementById('trackingDot').classList.remove('active');
|
|
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
|
stopSessionTimer();
|
|
updateSignalQuality();
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
function startSSE() {
|
|
if (eventSource) eventSource.close();
|
|
|
|
eventSource = new EventSource('/ais/stream');
|
|
eventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.type === 'vessel') {
|
|
updateVessel(data);
|
|
}
|
|
} catch (err) {}
|
|
};
|
|
|
|
eventSource.onerror = function() {
|
|
setTimeout(() => {
|
|
if (isTracking) startSSE();
|
|
}, 2000);
|
|
};
|
|
}
|
|
|
|
function updateVessel(data) {
|
|
const mmsi = data.mmsi;
|
|
if (!mmsi) return;
|
|
|
|
vessels[mmsi] = data;
|
|
stats.totalVesselsSeen.add(mmsi);
|
|
stats.messagesReceived++;
|
|
|
|
// Update statistics
|
|
if (data.speed && data.speed > stats.fastestSpeed) {
|
|
stats.fastestSpeed = data.speed;
|
|
}
|
|
|
|
if (data.lat && data.lon) {
|
|
const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon);
|
|
if (dist > stats.maxRange) stats.maxRange = dist;
|
|
if (dist < stats.closestDistance) stats.closestDistance = dist;
|
|
|
|
// Update trail
|
|
if (!vesselTrails[mmsi]) vesselTrails[mmsi] = [];
|
|
vesselTrails[mmsi].push({ lat: data.lat, lon: data.lon, time: Date.now() });
|
|
if (vesselTrails[mmsi].length > MAX_TRAIL_POINTS) {
|
|
vesselTrails[mmsi].shift();
|
|
}
|
|
|
|
// Update trail line
|
|
if (showTrails) {
|
|
const points = vesselTrails[mmsi].map(p => [p.lat, p.lon]);
|
|
if (trailLines[mmsi]) {
|
|
trailLines[mmsi].setLatLngs(points);
|
|
} else {
|
|
trailLines[mmsi] = L.polyline(points, {
|
|
color: '#4a9eff',
|
|
weight: 2,
|
|
opacity: 0.5
|
|
}).addTo(vesselMap);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateMarker(data);
|
|
updateStats();
|
|
updateVesselList();
|
|
|
|
if (mmsi === selectedMmsi) {
|
|
showVesselDetails(data);
|
|
}
|
|
}
|
|
|
|
function updateMarker(vessel) {
|
|
const mmsi = vessel.mmsi;
|
|
if (!vessel.lat || !vessel.lon) return;
|
|
|
|
const heading = vessel.heading || vessel.course || 0;
|
|
const icon = getShipIcon(vessel.ship_type);
|
|
|
|
const markerHtml = `
|
|
<div class="vessel-marker-inner" style="transform: rotate(${heading}deg);">
|
|
${icon}
|
|
</div>
|
|
`;
|
|
|
|
const divIcon = L.divIcon({
|
|
className: 'vessel-marker' + (mmsi === selectedMmsi ? ' selected' : ''),
|
|
html: markerHtml,
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 12]
|
|
});
|
|
|
|
if (markers[mmsi]) {
|
|
markers[mmsi].setLatLng([vessel.lat, vessel.lon]);
|
|
markers[mmsi].setIcon(divIcon);
|
|
} else {
|
|
markers[mmsi] = L.marker([vessel.lat, vessel.lon], { icon: divIcon })
|
|
.addTo(vesselMap)
|
|
.on('click', () => selectVessel(mmsi));
|
|
}
|
|
|
|
const name = vessel.name || 'Unknown';
|
|
markers[mmsi].bindTooltip(`${name}<br>MMSI: ${mmsi}`, { direction: 'top' });
|
|
}
|
|
|
|
function selectVessel(mmsi) {
|
|
selectedMmsi = mmsi;
|
|
|
|
// Update marker styles
|
|
Object.keys(markers).forEach(m => {
|
|
const el = markers[m].getElement();
|
|
if (el) {
|
|
el.querySelector('.vessel-marker-inner')?.parentElement?.classList.toggle('selected', m === mmsi);
|
|
}
|
|
});
|
|
|
|
// Update list selection
|
|
document.querySelectorAll('.vessel-item').forEach(el => {
|
|
el.classList.toggle('selected', el.dataset.mmsi === mmsi);
|
|
});
|
|
|
|
if (vessels[mmsi]) {
|
|
showVesselDetails(vessels[mmsi]);
|
|
}
|
|
}
|
|
|
|
function showVesselDetails(vessel) {
|
|
const container = document.getElementById('selectedInfo');
|
|
const icon = getShipIcon(vessel.ship_type);
|
|
const category = getShipCategory(vessel.ship_type);
|
|
const navStatus = NAV_STATUS[vessel.nav_status] || vessel.nav_status_text || 'Unknown';
|
|
|
|
container.innerHTML = `
|
|
<div class="vessel-header">
|
|
<div class="vessel-icon">${icon}</div>
|
|
<div>
|
|
<div class="vessel-name">${vessel.name || 'Unknown Vessel'}</div>
|
|
<div class="vessel-mmsi">MMSI: ${vessel.mmsi}</div>
|
|
</div>
|
|
</div>
|
|
<div class="vessel-details">
|
|
<div class="detail-item">
|
|
<div class="detail-label">Type</div>
|
|
<div class="detail-value">${category}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Callsign</div>
|
|
<div class="detail-value">${vessel.callsign || '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Speed</div>
|
|
<div class="detail-value">${vessel.speed ? vessel.speed + ' kt' : '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Course</div>
|
|
<div class="detail-value">${vessel.course ? vessel.course + '°' : '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Heading</div>
|
|
<div class="detail-value">${vessel.heading ? vessel.heading + '°' : '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Status</div>
|
|
<div class="detail-value" style="font-size: 10px;">${navStatus}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Destination</div>
|
|
<div class="detail-value">${vessel.destination || '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">ETA</div>
|
|
<div class="detail-value">${vessel.eta || '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Length</div>
|
|
<div class="detail-value">${vessel.length ? vessel.length + ' m' : '-'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Width</div>
|
|
<div class="detail-value">${vessel.width ? vessel.width + ' m' : '-'}</div>
|
|
</div>
|
|
<div class="detail-item" style="grid-column: span 2;">
|
|
<div class="detail-label">Position</div>
|
|
<div class="detail-value">${vessel.lat ? vessel.lat.toFixed(5) + ', ' + vessel.lon.toFixed(5) : '-'}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateVesselList() {
|
|
const container = document.getElementById('vesselList');
|
|
const vesselArray = Object.values(vessels).sort((a, b) => {
|
|
// Sort by name, then MMSI
|
|
const nameA = a.name || 'ZZZZZ';
|
|
const nameB = b.name || 'ZZZZZ';
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
if (vesselArray.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="no-vessel">
|
|
<div>No vessels detected</div>
|
|
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = vesselArray.map(v => {
|
|
const icon = getShipIcon(v.ship_type);
|
|
const category = getShipCategory(v.ship_type);
|
|
return `
|
|
<div class="vessel-item ${v.mmsi === selectedMmsi ? 'selected' : ''}"
|
|
data-mmsi="${v.mmsi}" onclick="selectVessel('${v.mmsi}')">
|
|
<div class="vessel-item-icon">${icon}</div>
|
|
<div class="vessel-item-info">
|
|
<div class="vessel-item-name">${v.name || 'Unknown'}</div>
|
|
<div class="vessel-item-type">${category} | ${v.mmsi}</div>
|
|
</div>
|
|
<div class="vessel-item-speed">${v.speed ? v.speed + ' kt' : '-'}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('stripVesselsNow').textContent = Object.keys(vessels).length;
|
|
document.getElementById('stripTotalSeen').textContent = stats.totalVesselsSeen.size;
|
|
document.getElementById('stripMaxRange').textContent = stats.maxRange.toFixed(1);
|
|
document.getElementById('stripFastest').textContent = stats.fastestSpeed > 0 ? stats.fastestSpeed.toFixed(1) : '-';
|
|
document.getElementById('stripClosest').textContent = stats.closestDistance < Infinity ? stats.closestDistance.toFixed(1) : '-';
|
|
}
|
|
|
|
function cleanupStaleVessels() {
|
|
const now = Date.now();
|
|
const maxAge = 600000; // 10 minutes
|
|
|
|
Object.keys(vessels).forEach(mmsi => {
|
|
const lastSeen = vessels[mmsi].last_seen * 1000;
|
|
if (now - lastSeen > maxAge) {
|
|
delete vessels[mmsi];
|
|
if (markers[mmsi]) {
|
|
vesselMap.removeLayer(markers[mmsi]);
|
|
delete markers[mmsi];
|
|
}
|
|
if (trailLines[mmsi]) {
|
|
vesselMap.removeLayer(trailLines[mmsi]);
|
|
delete trailLines[mmsi];
|
|
}
|
|
delete vesselTrails[mmsi];
|
|
}
|
|
});
|
|
|
|
updateVesselList();
|
|
}
|
|
|
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
|
// Haversine formula, returns nautical miles
|
|
const R = 3440.065; // Earth radius in nautical miles
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
return R * c;
|
|
}
|
|
|
|
function updateClock() {
|
|
const now = new Date();
|
|
const utc = now.toISOString().slice(11, 19);
|
|
document.getElementById('utcTime').textContent = utc + ' UTC';
|
|
}
|
|
|
|
// Session timer functions
|
|
function startSessionTimer() {
|
|
if (!stats.sessionStart) {
|
|
stats.sessionStart = Date.now();
|
|
}
|
|
if (sessionTimerInterval) clearInterval(sessionTimerInterval);
|
|
sessionTimerInterval = setInterval(updateSessionTimer, 1000);
|
|
|
|
// Start message rate tracking
|
|
if (messageRateInterval) clearInterval(messageRateInterval);
|
|
lastMessageCount = stats.messagesReceived;
|
|
messageRateInterval = setInterval(updateMessageRate, 1000);
|
|
}
|
|
|
|
function stopSessionTimer() {
|
|
if (sessionTimerInterval) {
|
|
clearInterval(sessionTimerInterval);
|
|
sessionTimerInterval = null;
|
|
}
|
|
if (messageRateInterval) {
|
|
clearInterval(messageRateInterval);
|
|
messageRateInterval = null;
|
|
}
|
|
}
|
|
|
|
function updateSessionTimer() {
|
|
if (!stats.sessionStart) return;
|
|
const elapsed = Date.now() - stats.sessionStart;
|
|
const hours = Math.floor(elapsed / 3600000);
|
|
const mins = Math.floor((elapsed % 3600000) / 60000);
|
|
const secs = Math.floor((elapsed % 60000) / 1000);
|
|
document.getElementById('stripSession').textContent =
|
|
`${hours.toString().padStart(2,'0')}:${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
|
|
}
|
|
|
|
function updateMessageRate() {
|
|
const currentCount = stats.messagesReceived;
|
|
stats.messagesPerSecond = currentCount - lastMessageCount;
|
|
lastMessageCount = currentCount;
|
|
updateSignalQuality();
|
|
}
|
|
|
|
// Signal quality display
|
|
function updateSignalQuality() {
|
|
const msgRate = stats.messagesPerSecond;
|
|
const el = document.getElementById('stripSignal');
|
|
const stat = el.closest('.strip-stat');
|
|
|
|
if (!isTracking || msgRate === 0) {
|
|
el.textContent = '--';
|
|
stat.classList.remove('good', 'warning', 'poor');
|
|
return;
|
|
}
|
|
|
|
// Signal quality based on message rate
|
|
// Good: >5 msg/s, Warning: 1-5, Poor: <1
|
|
if (msgRate >= 5) {
|
|
el.textContent = '●●●';
|
|
stat.classList.remove('warning', 'poor');
|
|
stat.classList.add('good');
|
|
} else if (msgRate >= 1) {
|
|
el.textContent = '●●○';
|
|
stat.classList.remove('good', 'poor');
|
|
stat.classList.add('warning');
|
|
} else {
|
|
el.textContent = '●○○';
|
|
stat.classList.remove('good', 'warning');
|
|
stat.classList.add('poor');
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', initMap);
|
|
</script>
|
|
</body>
|
|
</html>
|