mirror of
https://github.com/smittix/intercept.git
synced 2026-07-12 19:48:14 -07:00
Merge branch 'main' of https://github.com/smittix/intercept
This commit is contained in:
@@ -0,0 +1,765 @@
|
||||
<!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>
|
||||
+66
-5
@@ -21,6 +21,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -95,6 +96,11 @@
|
||||
<span class="mode-name">Aircraft</span>
|
||||
<span class="mode-desc">ADS-B tracking</span>
|
||||
</a>
|
||||
<a href="/ais/dashboard" class="mode-card" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span>
|
||||
<span class="mode-name">Vessels</span>
|
||||
<span class="mode-desc">AIS ship tracking</span>
|
||||
</a>
|
||||
<button class="mode-card" onclick="selectMode('wifi')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||||
<span class="mode-name">WiFi</span>
|
||||
@@ -130,6 +136,11 @@
|
||||
<span class="mode-name">Meters</span>
|
||||
<span class="mode-desc">Utility meters</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('spystations')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||||
<span class="mode-name">Spy Stations</span>
|
||||
<span class="mode-desc">Number stations & diplomatic</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,9 +282,11 @@
|
||||
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span><span class="nav-label">433MHz</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('rtlamr')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span><span class="nav-label">Meters</span></button>
|
||||
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
|
||||
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="nav-label">Vessels</span></a>
|
||||
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-nav-dropdown" data-group="wireless">
|
||||
@@ -330,12 +343,14 @@
|
||||
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> 433MHz</button>
|
||||
<button class="mobile-nav-btn" data-mode="rtlamr" onclick="switchMode('rtlamr')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span> Meters</button>
|
||||
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span> Aircraft</a>
|
||||
<a href="/ais/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span> Vessels</a>
|
||||
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span> APRS</button>
|
||||
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> WiFi</button>
|
||||
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
|
||||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
|
||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
||||
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer Overlay -->
|
||||
@@ -446,6 +461,10 @@
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()"
|
||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
@@ -1438,6 +1457,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spy Stations Dashboard -->
|
||||
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
|
||||
<div class="spy-stations-header">
|
||||
<div class="spy-stations-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
|
||||
</svg>
|
||||
Number Stations & Diplomatic Networks
|
||||
</div>
|
||||
<div class="spy-stations-count">
|
||||
<span id="spyStationsVisibleCount">0</span> stations
|
||||
</div>
|
||||
</div>
|
||||
<div class="spy-stations-grid" id="spyStationsGrid">
|
||||
<!-- Station cards populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -1519,6 +1560,7 @@
|
||||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -1987,7 +2029,8 @@
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||
'tscm': 'security'
|
||||
'tscm': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -2046,6 +2089,8 @@
|
||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
const sensorStats = document.getElementById('sensorStats');
|
||||
const satelliteStats = document.getElementById('satelliteStats');
|
||||
@@ -2075,7 +2120,9 @@
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'aprs': 'APRS',
|
||||
'tscm': 'TSCM'
|
||||
'tscm': 'TSCM',
|
||||
'ais': 'AIS VESSELS',
|
||||
'spystations': 'SPY STATIONS'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -2085,12 +2132,14 @@
|
||||
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
|
||||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
|
||||
// Show/hide mode-specific timeline containers
|
||||
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
|
||||
@@ -2108,7 +2157,9 @@
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
'aprs': 'APRS Tracker',
|
||||
'tscm': 'TSCM Counter-Surveillance'
|
||||
'tscm': 'TSCM Counter-Surveillance',
|
||||
'ais': 'AIS Vessel Tracker',
|
||||
'spystations': 'Spy Stations'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -2126,7 +2177,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm') {
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -2152,7 +2203,7 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
@@ -2177,6 +2228,13 @@
|
||||
} else if (mode === 'satellite') {
|
||||
initPolarPlot();
|
||||
initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
// Check for incoming tune requests from Spy Stations
|
||||
if (typeof checkIncomingTuneRequest === 'function') {
|
||||
checkIncomingTuneRequest();
|
||||
}
|
||||
} else if (mode === 'spystations') {
|
||||
SpyStations.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10495,6 +10553,8 @@
|
||||
</div>
|
||||
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - Opens ADS-B
|
||||
Dashboard</span></div>
|
||||
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - Opens AIS
|
||||
Dashboard</span></div>
|
||||
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
|
||||
tracking</span></div>
|
||||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass
|
||||
@@ -10671,6 +10731,7 @@
|
||||
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
|
||||
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
|
||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!-- AIS VESSEL TRACKING MODE -->
|
||||
<div id="aisMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>AIS Vessel Tracking</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Track ships and vessels via AIS (Automatic Identification System) on 161.975 / 162.025 MHz.
|
||||
</div>
|
||||
<a href="/ais/dashboard" target="_blank" class="run-btn" style="display: inline-block; text-decoration: none; text-align: center; margin-bottom: 15px;">
|
||||
Open AIS Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="aisGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="aisStatusDisplay" class="info-text">
|
||||
<p>Status: <span id="aisStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Vessels: <span id="aisVesselCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
||||
Start AIS Tracking
|
||||
</button>
|
||||
<button class="stop-btn" id="stopAisBtn" onclick="stopAisTracking()" style="display: none;">
|
||||
Stop AIS Tracking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let aisEventSource = null;
|
||||
let aisVessels = {};
|
||||
|
||||
function startAisTracking() {
|
||||
const gain = document.getElementById('aisGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
|
||||
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') {
|
||||
document.getElementById('startAisBtn').style.display = 'none';
|
||||
document.getElementById('stopAisBtn').style.display = 'block';
|
||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
||||
startAisSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start AIS tracking');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopAisTracking() {
|
||||
fetch('/ais/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startAisBtn').style.display = 'block';
|
||||
document.getElementById('stopAisBtn').style.display = 'none';
|
||||
document.getElementById('aisStatusText').textContent = 'Standby';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-yellow)';
|
||||
document.getElementById('aisVesselCount').textContent = '0';
|
||||
if (aisEventSource) {
|
||||
aisEventSource.close();
|
||||
aisEventSource = null;
|
||||
}
|
||||
aisVessels = {};
|
||||
});
|
||||
}
|
||||
|
||||
function startAisSSE() {
|
||||
if (aisEventSource) aisEventSource.close();
|
||||
|
||||
aisEventSource = new EventSource('/ais/stream');
|
||||
aisEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'vessel') {
|
||||
aisVessels[data.mmsi] = data;
|
||||
document.getElementById('aisVesselCount').textContent = Object.keys(aisVessels).length;
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
aisEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
if (document.getElementById('stopAisBtn').style.display === 'block') {
|
||||
startAisSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/ais/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.tracking_active) {
|
||||
document.getElementById('startAisBtn').style.display = 'none';
|
||||
document.getElementById('stopAisBtn').style.display = 'block';
|
||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('aisVesselCount').textContent = data.vessel_count || 0;
|
||||
startAisSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!-- SPY STATIONS MODE -->
|
||||
<div id="spystationsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Spy Stations</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
|
||||
Active number stations and diplomatic HF radio networks. Data sourced from priyom.org.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Type</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="filterTypeNumber" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="color: var(--accent-cyan);">Number Stations</span>
|
||||
</label>
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="filterTypeDiplomatic" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="color: var(--accent-green);">Diplomatic Networks</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Country</h3>
|
||||
<div id="countryFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Mode</h3>
|
||||
<div id="modeFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Stats</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);" id="spyStatsNumber">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">NUMBER</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-green);" id="spyStatsDiplomatic">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">DIPLOMATIC</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-orange);" id="spyStatsCountries">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">COUNTRIES</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-purple);" id="spyStatsFreqs">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">FREQUENCIES</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Help</h3>
|
||||
<button class="preset-btn" onclick="SpyStations.showHelp()" style="width: 100%;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
About Spy Stations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://priyom.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Priyom.org
|
||||
</a>
|
||||
<a href="https://www.numbers-stations.com" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Numbers-Stations.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user