mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
1891 lines
80 KiB
HTML
1891 lines
80 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
|
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
|
blocked by external CDN reachability. -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
|
<!-- Core CSS -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
|
<!-- Deferred scripts -->
|
|
<script>
|
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
|
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
|
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
|
</script>
|
|
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
|
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
|
</head>
|
|
<body>
|
|
<!-- Radar background effects -->
|
|
<div class="radar-bg"></div>
|
|
<div class="scanline"></div>
|
|
|
|
<header class="header">
|
|
<div class="logo">
|
|
VESSEL RADAR
|
|
<span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT - AIS Tracking</span>
|
|
</div>
|
|
<div class="status-bar">
|
|
<!-- Agent Selector -->
|
|
<div class="agent-selector-compact" id="agentSection">
|
|
<select id="agentSelect" class="agent-select-sm" title="Select signal source">
|
|
<option value="local">Local</option>
|
|
</select>
|
|
<span class="agent-status-dot online" id="agentStatusDot"></span>
|
|
<label class="show-all-label" title="Show vessels from all agents on map">
|
|
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{% if not embedded %}
|
|
{% set active_mode = 'ais' %}
|
|
{% include 'partials/nav.html' with context %}
|
|
{% endif %}
|
|
|
|
<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>
|
|
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
|
</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">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" style="opacity: 0.5;">
|
|
<path fill="currentColor" d="M12 2L8 6V18L10 20H14L16 18V6L12 2Z"/>
|
|
</svg>
|
|
</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 class="panel dsc-messages">
|
|
<div class="panel-header">
|
|
<span>VHF DSC MESSAGES</span>
|
|
<div class="panel-indicator" id="dscIndicator"></div>
|
|
</div>
|
|
<div class="dsc-alert-summary" id="dscAlertSummary">
|
|
<span class="dsc-alert-count distress" id="dscDistressCount" title="Distress alerts">0 DISTRESS</span>
|
|
<span class="dsc-alert-count urgency" id="dscUrgencyCount" title="Urgency alerts">0 URGENCY</span>
|
|
</div>
|
|
<div class="dsc-list-content" id="dscMessageList">
|
|
<div class="no-messages">
|
|
<div>No DSC messages</div>
|
|
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</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="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
|
<input type="text" id="obsLon" value="{{ default_longitude }}" 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 class="control-group dsc-group">
|
|
<span class="control-group-label">VHF DSC</span>
|
|
<div class="control-group-items">
|
|
<select id="dscDeviceSelect" title="DSC SDR device (secondary)">
|
|
<option value="0">SDR 0</option>
|
|
</select>
|
|
<input type="number" id="dscGain" value="40" min="0" max="50" style="width: 50px;" title="Gain">
|
|
<button class="start-btn dsc-btn" id="dscStartBtn" onclick="toggleDscTracking()">START DSC</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// Bias-T helper (reads from main dashboard localStorage)
|
|
function getBiasTEnabled() {
|
|
return localStorage.getItem('biasTEnabled') === 'true';
|
|
}
|
|
|
|
// State
|
|
let vesselMap = null;
|
|
let vessels = {};
|
|
let markers = {};
|
|
let selectedMmsi = null;
|
|
let eventSource = null;
|
|
let aisPollTimer = null; // Polling fallback for agent mode
|
|
let isTracking = false;
|
|
|
|
// DSC State
|
|
let dscEventSource = null;
|
|
let isDscTracking = false;
|
|
let dscMessages = {};
|
|
let dscMarkers = {};
|
|
let dscAlertCounts = { distress: 0, urgency: 0 };
|
|
let dscCurrentAgent = null;
|
|
let dscPollTimer = null;
|
|
let showTrails = false;
|
|
let vesselTrails = {};
|
|
let trailLines = {};
|
|
let maxRange = 50;
|
|
const MAX_TRAIL_POINTS = 50;
|
|
|
|
// Observer location
|
|
let observerLocation = (function() {
|
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
|
return ObserverLocation.getForModule('ais_observerLocation');
|
|
}
|
|
const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
|
|
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
|
|
return { lat: defaultLat, lon: defaultLon };
|
|
})();
|
|
let rangeRingsLayer = null;
|
|
let observerMarker = null;
|
|
|
|
// GPS state
|
|
let gpsConnected = false;
|
|
let gpsEventSource = null;
|
|
let gpsReconnectTimeout = 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;
|
|
|
|
// Vessel SVG icon paths (top-down view, pointing up)
|
|
const VESSEL_ICONS = {
|
|
// Generic cargo/container ship - pointed bow, rectangular hull
|
|
cargo: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM10 8H14V16H10V8Z',
|
|
// Tanker - rounded bow, long hull
|
|
tanker: 'M12 2C10 2 8 4 8 6V18C8 19 9 20 10 20H14C15 20 16 19 16 18V6C16 4 14 2 12 2ZM10 8H14V16H10V8Z',
|
|
// Passenger/cruise - multiple decks indicated
|
|
passenger: 'M12 2L8 5V18L10 20H14L16 18V5L12 2ZM9 7H15V10H9V7ZM9 11H15V14H9V11ZM9 15H15V18H9V15Z',
|
|
// Tug - small, compact, powerful
|
|
tug: 'M12 4L9 7V16L10 18H14L15 16V7L12 4ZM10 9H14V14H10V9Z',
|
|
// Fishing vessel - with mast/outriggers
|
|
fishing: 'M12 2L12 5L8 8V17L10 19H14L16 17V8L12 5ZM6 10L8 12V15L6 13V10ZM18 10V13L16 15V12L18 10ZM10 10H14V15H10V10Z',
|
|
// Sailing vessel - sail shape
|
|
sailing: 'M12 2L12 6L8 10V18L10 20H14L16 18V10L12 6ZM12 3L16 8H12V3ZM10 11H14V17H10V11Z',
|
|
// Military - angular, aggressive bow
|
|
military: 'M12 1L7 6V8L8 9V18L10 20H14L16 18V9L17 8V6L12 1ZM10 10H14V16H10V10Z',
|
|
// High speed craft - sleek, pointed
|
|
hsc: 'M12 1L9 5V18L10 20H14L15 18V5L12 1ZM10 7H14V17H10V7Z',
|
|
// Search & rescue - distinctive cross marking
|
|
sar: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM11 8H13V11H16V13H13V16H11V13H8V11H11V8Z',
|
|
// Pilot vessel
|
|
pilot: 'M12 3L9 6V17L10 19H14L15 17V6L12 3ZM10 8H14V15H10V8ZM11 9V10H13V9H11Z',
|
|
// Law enforcement
|
|
law: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM10 8H14V10H10V8ZM11 11H13V16H11V11Z',
|
|
// Generic vessel (default)
|
|
default: 'M12 2L8 6V18L10 20H14L16 18V6L12 2Z'
|
|
};
|
|
|
|
// Vessel type colors
|
|
const VESSEL_COLORS = {
|
|
cargo: '#00d4ff', // Cyan
|
|
tanker: '#ff6b35', // Orange
|
|
passenger: '#a855f7', // Purple
|
|
tug: '#fbbf24', // Yellow
|
|
fishing: '#22c55e', // Green
|
|
sailing: '#60a5fa', // Light blue
|
|
military: '#ef4444', // Red
|
|
hsc: '#f472b6', // Pink
|
|
sar: '#ff0000', // Bright red
|
|
pilot: '#ffffff', // White
|
|
law: '#3b82f6', // Blue
|
|
default: '#00d4ff' // Cyan
|
|
};
|
|
|
|
// 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';
|
|
}
|
|
|
|
// Get vessel icon type from AIS ship type code
|
|
function getVesselIconType(type) {
|
|
if (!type) return 'default';
|
|
if (type === 30) return 'fishing';
|
|
if (type >= 31 && type <= 32) return 'tug';
|
|
if (type === 35) return 'military';
|
|
if (type >= 36 && type <= 37) return 'sailing';
|
|
if (type >= 40 && type < 50) return 'hsc';
|
|
if (type === 50) return 'pilot';
|
|
if (type === 51) return 'sar';
|
|
if (type === 52) return 'tug';
|
|
if (type === 55) return 'law';
|
|
if (type >= 60 && type < 70) return 'passenger';
|
|
if (type >= 70 && type < 80) return 'cargo';
|
|
if (type >= 80 && type < 90) return 'tanker';
|
|
return 'default';
|
|
}
|
|
|
|
// Create SVG vessel marker icon
|
|
function createVesselMarkerIcon(rotation, vesselType, isSelected = false) {
|
|
const path = VESSEL_ICONS[vesselType] || VESSEL_ICONS.default;
|
|
const color = VESSEL_COLORS[vesselType] || VESSEL_COLORS.default;
|
|
const size = 24;
|
|
const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color;
|
|
const glowSize = isSelected ? '8px' : '4px';
|
|
const trackingRing = isSelected ?
|
|
'<div class="tracking-ring"></div><div class="tracking-ring-inner"></div>' : '';
|
|
|
|
return L.divIcon({
|
|
className: 'vessel-marker' + (isSelected ? ' selected' : ''),
|
|
html: `${trackingRing}<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
|
|
<path fill="${color}" d="${path}"/>
|
|
</svg>`,
|
|
iconSize: [size, size],
|
|
iconAnchor: [size/2, size/2]
|
|
});
|
|
}
|
|
|
|
// Legacy function for vessel list icons (returns SVG string)
|
|
function getShipIconSvg(type, size = 18) {
|
|
const vesselType = getVesselIconType(type);
|
|
const path = VESSEL_ICONS[vesselType] || VESSEL_ICONS.default;
|
|
const color = VESSEL_COLORS[vesselType] || VESSEL_COLORS.default;
|
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="vertical-align: middle;">
|
|
<path fill="${color}" d="${path}"/>
|
|
</svg>`;
|
|
}
|
|
|
|
// 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 createFallbackGridLayer() {
|
|
const layer = L.gridLayer({
|
|
tileSize: 256,
|
|
updateWhenIdle: true,
|
|
attribution: 'Local fallback grid'
|
|
});
|
|
layer.createTile = function(coords) {
|
|
const tile = document.createElement('canvas');
|
|
tile.width = 256;
|
|
tile.height = 256;
|
|
const ctx = tile.getContext('2d');
|
|
|
|
ctx.fillStyle = '#07131c';
|
|
ctx.fillRect(0, 0, 256, 256);
|
|
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(256, 0);
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, 256);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(34, 197, 94, 0.10)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(128, 0);
|
|
ctx.lineTo(128, 256);
|
|
ctx.moveTo(0, 128);
|
|
ctx.lineTo(256, 128);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
|
ctx.font = '11px "JetBrains Mono", monospace';
|
|
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
|
|
|
return tile;
|
|
};
|
|
return layer;
|
|
}
|
|
|
|
async function initMap() {
|
|
// Guard against double initialization (e.g. bfcache restore)
|
|
const container = document.getElementById('vesselMap');
|
|
if (!container || container._leaflet_id) return;
|
|
|
|
if (observerLocation) {
|
|
document.getElementById('obsLat').value = observerLocation.lat;
|
|
document.getElementById('obsLon').value = observerLocation.lon;
|
|
}
|
|
|
|
vesselMap = L.map('vesselMap', {
|
|
center: [observerLocation.lat, observerLocation.lon],
|
|
zoom: 10,
|
|
zoomControl: true
|
|
});
|
|
|
|
// Use settings manager for tile layer (allows runtime changes)
|
|
window.vesselMap = vesselMap;
|
|
|
|
// Use a zero-network fallback so dashboard navigation stays fast even
|
|
// when internet map providers are slow or unreachable.
|
|
const fallbackTiles = createFallbackGridLayer().addTo(vesselMap);
|
|
|
|
// Then try to upgrade tiles via Settings (non-blocking)
|
|
if (typeof Settings !== 'undefined') {
|
|
try {
|
|
await Promise.race([
|
|
Settings.init(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
|
]);
|
|
vesselMap.removeLayer(fallbackTiles);
|
|
Settings.createTileLayer().addTo(vesselMap);
|
|
Settings.registerMap(vesselMap);
|
|
} catch (e) {
|
|
console.warn('Settings init failed/timed out, using fallback tiles:', e);
|
|
// fallback tiles already added above
|
|
}
|
|
}
|
|
|
|
// 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 => {
|
|
// Populate AIS device selector
|
|
const aisSelect = document.getElementById('aisDeviceSelect');
|
|
aisSelect.innerHTML = '';
|
|
if (devices.length === 0) {
|
|
aisSelect.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}`;
|
|
aisSelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// Populate DSC device selector
|
|
const dscSelect = document.getElementById('dscDeviceSelect');
|
|
dscSelect.innerHTML = '';
|
|
if (devices.length === 0) {
|
|
dscSelect.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}`;
|
|
dscSelect.appendChild(opt);
|
|
});
|
|
// Default to second device if available
|
|
if (devices.length > 1) {
|
|
dscSelect.value = devices[1].index;
|
|
}
|
|
}
|
|
})
|
|
.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 };
|
|
if (window.ObserverLocation) {
|
|
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
|
|
} else {
|
|
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]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function toggleTracking() {
|
|
if (isTracking) {
|
|
stopTracking();
|
|
} else {
|
|
startTracking();
|
|
}
|
|
}
|
|
|
|
async function startTracking() {
|
|
const device = document.getElementById('aisDeviceSelect').value;
|
|
const gain = document.getElementById('aisGain').value;
|
|
|
|
// Check if using agent mode
|
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
|
|
|
// For agent mode, check conflicts and route through proxy
|
|
if (useAgent) {
|
|
if (typeof checkAgentModeConflict === 'function' && !await checkAgentModeConflict('ais')) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
const data = result.result || result;
|
|
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));
|
|
return;
|
|
}
|
|
|
|
// Local mode - original behavior unchanged
|
|
fetch('/ais/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
|
})
|
|
.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() {
|
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
|
|
|
// Route to agent or local
|
|
const url = useAgent ? `/controller/agents/${aisCurrentAgent}/ais/stop` : '/ais/stop';
|
|
|
|
fetch(url, { 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;
|
|
}
|
|
if (aisPollTimer) {
|
|
clearInterval(aisPollTimer);
|
|
aisPollTimer = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start polling agent data as fallback when push isn't enabled.
|
|
*/
|
|
function startAisPolling() {
|
|
if (aisPollTimer) return;
|
|
if (typeof aisCurrentAgent === 'undefined' || aisCurrentAgent === 'local') return;
|
|
|
|
const pollInterval = 2000; // 2 seconds for AIS
|
|
console.log('Starting AIS agent polling fallback...');
|
|
|
|
aisPollTimer = setInterval(async () => {
|
|
if (!isTracking) {
|
|
clearInterval(aisPollTimer);
|
|
aisPollTimer = null;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/controller/agents/${aisCurrentAgent}/ais/data`);
|
|
if (!response.ok) return;
|
|
|
|
const result = await response.json();
|
|
const data = result.data || result;
|
|
|
|
// Get agent name
|
|
let agentName = 'Agent';
|
|
if (typeof agents !== 'undefined') {
|
|
const agent = agents.find(a => a.id == aisCurrentAgent);
|
|
if (agent) agentName = agent.name;
|
|
}
|
|
|
|
// Process vessels from polling response
|
|
if (data && data.vessels) {
|
|
Object.values(data.vessels).forEach(vessel => {
|
|
vessel._agent = agentName;
|
|
updateVessel(vessel);
|
|
});
|
|
} else if (data && Array.isArray(data)) {
|
|
data.forEach(vessel => {
|
|
vessel._agent = agentName;
|
|
updateVessel(vessel);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.debug('AIS agent poll error:', err);
|
|
}
|
|
}, pollInterval);
|
|
}
|
|
|
|
function startSSE() {
|
|
if (eventSource) eventSource.close();
|
|
|
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
|
const streamUrl = useAgent ? '/controller/stream/all' : '/ais/stream';
|
|
|
|
// Get agent name for filtering
|
|
let targetAgentName = null;
|
|
if (useAgent && typeof agents !== 'undefined') {
|
|
const agent = agents.find(a => a.id == aisCurrentAgent);
|
|
targetAgentName = agent ? agent.name : null;
|
|
}
|
|
|
|
eventSource = new EventSource(streamUrl);
|
|
eventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
|
|
if (useAgent) {
|
|
// Multi-agent stream format
|
|
if (data.type === 'keepalive') return;
|
|
|
|
// Filter to our agent
|
|
if (targetAgentName && data.agent_name && data.agent_name !== targetAgentName) {
|
|
return;
|
|
}
|
|
|
|
// Extract vessel data from push payload
|
|
if (data.scan_type === 'ais' && data.payload) {
|
|
const payload = data.payload;
|
|
if (payload.vessels) {
|
|
Object.values(payload.vessels).forEach(v => {
|
|
v._agent = data.agent_name;
|
|
updateVessel({ type: 'vessel', ...v });
|
|
});
|
|
} else if (payload.mmsi) {
|
|
payload._agent = data.agent_name;
|
|
updateVessel({ type: 'vessel', ...payload });
|
|
}
|
|
}
|
|
} else {
|
|
// Local stream format
|
|
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 !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
|
|
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 vesselType = getVesselIconType(vessel.ship_type);
|
|
const isSelected = mmsi === selectedMmsi;
|
|
const divIcon = createVesselMarkerIcon(heading, vesselType, isSelected);
|
|
|
|
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) {
|
|
const prevSelected = selectedMmsi;
|
|
selectedMmsi = mmsi;
|
|
|
|
// Update marker icons for previous and new selection
|
|
[prevSelected, mmsi].forEach(m => {
|
|
if (m && vessels[m] && markers[m]) {
|
|
const vessel = vessels[m];
|
|
const heading = vessel.heading || vessel.course || 0;
|
|
const vesselType = getVesselIconType(vessel.ship_type);
|
|
const isSelected = m === mmsi;
|
|
markers[m].setIcon(createVesselMarkerIcon(heading, vesselType, isSelected));
|
|
}
|
|
});
|
|
|
|
// 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 iconSvg = getShipIconSvg(vessel.ship_type, 28);
|
|
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">${iconSvg}</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 iconSvg = getShipIconSvg(v.ship_type, 20);
|
|
const category = getShipCategory(v.ship_type);
|
|
const agentBadge = v._agent ? `<span class="agent-badge">${v._agent}</span>` : '';
|
|
return `
|
|
<div class="vessel-item ${v.mmsi === selectedMmsi ? 'selected' : ''}"
|
|
data-mmsi="${v.mmsi}" onclick="selectVessel('${v.mmsi}')">
|
|
<div class="vessel-item-icon">${iconSvg}</div>
|
|
<div class="vessel-item-info">
|
|
<div class="vessel-item-name">${v.name || 'Unknown'}${agentBadge}</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';
|
|
}
|
|
|
|
// ============================================
|
|
// GPS FUNCTIONS (gpsd auto-connect)
|
|
// ============================================
|
|
async function autoConnectGps() {
|
|
try {
|
|
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'connected') {
|
|
gpsConnected = true;
|
|
startGpsStream();
|
|
showGpsIndicator(true);
|
|
console.log('GPS: Auto-connected to gpsd');
|
|
if (data.position) {
|
|
updateLocationFromGps(data.position);
|
|
}
|
|
} else {
|
|
console.log('GPS: gpsd not available -', data.message);
|
|
}
|
|
} catch (e) {
|
|
console.log('GPS: Auto-connect failed -', e.message);
|
|
}
|
|
}
|
|
|
|
function startGpsStream() {
|
|
if (gpsEventSource) {
|
|
gpsEventSource.close();
|
|
}
|
|
if (gpsReconnectTimeout) {
|
|
clearTimeout(gpsReconnectTimeout);
|
|
gpsReconnectTimeout = null;
|
|
}
|
|
|
|
gpsEventSource = new EventSource('/gps/stream');
|
|
gpsEventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
|
|
updateLocationFromGps(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('GPS parse error:', e);
|
|
}
|
|
};
|
|
gpsEventSource.onerror = (e) => {
|
|
// Don't log every error - connection suspends are normal
|
|
if (gpsEventSource) {
|
|
gpsEventSource.close();
|
|
gpsEventSource = null;
|
|
}
|
|
// Auto-reconnect after 5 seconds if still connected
|
|
if (gpsConnected && !gpsReconnectTimeout) {
|
|
gpsReconnectTimeout = setTimeout(() => {
|
|
gpsReconnectTimeout = null;
|
|
if (gpsConnected) {
|
|
startGpsStream();
|
|
}
|
|
}, 5000);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Reconnect GPS stream when tab becomes visible
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden && gpsConnected && !gpsEventSource) {
|
|
startGpsStream();
|
|
}
|
|
});
|
|
|
|
function updateLocationFromGps(position) {
|
|
observerLocation.lat = position.latitude;
|
|
observerLocation.lon = position.longitude;
|
|
document.getElementById('obsLat').value = position.latitude.toFixed(4);
|
|
document.getElementById('obsLon').value = position.longitude.toFixed(4);
|
|
|
|
// Update observer marker position
|
|
if (observerMarker) {
|
|
observerMarker.setLatLng([position.latitude, position.longitude]);
|
|
}
|
|
|
|
// Center map on GPS location (on first fix)
|
|
if (vesselMap && !vesselMap._gpsInitialized) {
|
|
vesselMap.setView([position.latitude, position.longitude], vesselMap.getZoom());
|
|
vesselMap._gpsInitialized = true;
|
|
}
|
|
|
|
// Redraw range rings at new position
|
|
drawRangeRings();
|
|
|
|
// Save to localStorage
|
|
if (window.ObserverLocation) {
|
|
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
|
|
} else {
|
|
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
|
|
}
|
|
}
|
|
|
|
function showGpsIndicator(show) {
|
|
const indicator = document.getElementById('gpsIndicator');
|
|
if (indicator) {
|
|
indicator.style.display = show ? 'inline-flex' : 'none';
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// DSC (Digital Selective Calling) Functions
|
|
// ============================================
|
|
|
|
function toggleDscTracking() {
|
|
if (isDscTracking) {
|
|
stopDscTracking();
|
|
} else {
|
|
startDscTracking();
|
|
}
|
|
}
|
|
|
|
function startDscTracking() {
|
|
const device = document.getElementById('dscDeviceSelect').value;
|
|
const gain = document.getElementById('dscGain').value;
|
|
|
|
// Check if using agent mode
|
|
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
|
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
|
|
|
|
// Check for remote SDR (only for local mode)
|
|
const remoteConfig = (!isAgentMode && typeof getRemoteSDRConfig === 'function')
|
|
? getRemoteSDRConfig() : null;
|
|
if (remoteConfig === false) return; // Validation failed
|
|
|
|
const requestBody = { device, gain };
|
|
|
|
// Add rtl_tcp params if using remote SDR
|
|
if (remoteConfig) {
|
|
requestBody.rtl_tcp_host = remoteConfig.host;
|
|
requestBody.rtl_tcp_port = remoteConfig.port;
|
|
}
|
|
|
|
// Determine endpoint based on agent mode
|
|
const endpoint = isAgentMode
|
|
? `/controller/agents/${aisCurrentAgent}/dsc/start`
|
|
: '/dsc/start';
|
|
|
|
fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
// Handle controller proxy response format
|
|
const scanResult = isAgentMode && data.result ? data.result : data;
|
|
|
|
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
|
isDscTracking = true;
|
|
document.getElementById('dscStartBtn').textContent = 'STOP DSC';
|
|
document.getElementById('dscStartBtn').classList.add('active');
|
|
document.getElementById('dscIndicator').classList.add('active');
|
|
startDscSSE(isAgentMode);
|
|
} else if (scanResult.error_type === 'DEVICE_BUSY') {
|
|
alert('SDR device is busy.\n\n' + (scanResult.suggestion || ''));
|
|
} else {
|
|
alert(scanResult.message || scanResult.error || 'Failed to start DSC');
|
|
}
|
|
})
|
|
.catch(err => alert('Error: ' + err.message));
|
|
}
|
|
|
|
function stopDscTracking() {
|
|
const isAgentMode = dscCurrentAgent !== null;
|
|
const endpoint = isAgentMode
|
|
? `/controller/agents/${dscCurrentAgent}/dsc/stop`
|
|
: '/dsc/stop';
|
|
|
|
fetch(endpoint, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
isDscTracking = false;
|
|
dscCurrentAgent = null;
|
|
document.getElementById('dscStartBtn').textContent = 'START DSC';
|
|
document.getElementById('dscStartBtn').classList.remove('active');
|
|
document.getElementById('dscIndicator').classList.remove('active');
|
|
if (dscEventSource) {
|
|
dscEventSource.close();
|
|
dscEventSource = null;
|
|
}
|
|
// Clear polling timer
|
|
if (dscPollTimer) {
|
|
clearInterval(dscPollTimer);
|
|
dscPollTimer = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
function startDscSSE(isAgentMode = false) {
|
|
if (dscEventSource) dscEventSource.close();
|
|
|
|
// Use different stream endpoint for agent mode
|
|
const streamUrl = isAgentMode ? '/controller/stream/all' : '/dsc/stream';
|
|
dscEventSource = new EventSource(streamUrl);
|
|
|
|
dscEventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
|
|
if (isAgentMode) {
|
|
// Handle multi-agent stream format
|
|
if (data.scan_type === 'dsc' && data.payload) {
|
|
const payload = data.payload;
|
|
if (payload.type === 'dsc_message') {
|
|
payload.agent_name = data.agent_name;
|
|
handleDscMessage(payload);
|
|
} else if (payload.type === 'error') {
|
|
console.error('DSC error:', payload.error);
|
|
if (payload.error_type === 'DEVICE_BUSY') {
|
|
alert('DSC: Device became busy. ' + (payload.suggestion || ''));
|
|
stopDscTracking();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Local stream format
|
|
if (data.type === 'dsc_message') {
|
|
handleDscMessage(data);
|
|
} else if (data.type === 'error') {
|
|
console.error('DSC error:', data.error);
|
|
if (data.error_type === 'DEVICE_BUSY') {
|
|
alert('DSC: Device became busy. ' + (data.suggestion || ''));
|
|
stopDscTracking();
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {}
|
|
};
|
|
|
|
dscEventSource.onerror = function() {
|
|
setTimeout(() => {
|
|
if (isDscTracking) startDscSSE(isAgentMode);
|
|
}, 2000);
|
|
};
|
|
|
|
// Start polling fallback for agent mode
|
|
if (isAgentMode) {
|
|
startDscPolling();
|
|
}
|
|
}
|
|
|
|
// Track last DSC message count for polling
|
|
let lastDscMessageCount = 0;
|
|
|
|
function startDscPolling() {
|
|
if (dscPollTimer) return;
|
|
lastDscMessageCount = 0;
|
|
|
|
const pollInterval = 2000;
|
|
dscPollTimer = setInterval(async () => {
|
|
if (!isDscTracking || !dscCurrentAgent) {
|
|
clearInterval(dscPollTimer);
|
|
dscPollTimer = null;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/controller/agents/${dscCurrentAgent}/dsc/data`);
|
|
if (!response.ok) return;
|
|
|
|
const data = await response.json();
|
|
const result = data.result || data;
|
|
const messages = result.data || [];
|
|
|
|
// Process new messages
|
|
if (messages.length > lastDscMessageCount) {
|
|
const newMessages = messages.slice(lastDscMessageCount);
|
|
newMessages.forEach(msg => {
|
|
const dscMsg = {
|
|
type: 'dsc_message',
|
|
...msg,
|
|
agent_name: result.agent_name || 'Remote Agent'
|
|
};
|
|
handleDscMessage(dscMsg);
|
|
});
|
|
lastDscMessageCount = messages.length;
|
|
}
|
|
} catch (err) {
|
|
console.error('DSC polling error:', err);
|
|
}
|
|
}, pollInterval);
|
|
}
|
|
|
|
function handleDscMessage(data) {
|
|
const msgId = data.id || data.source_mmsi + '_' + Date.now();
|
|
dscMessages[msgId] = data;
|
|
|
|
// Update alert counts
|
|
if (data.category === 'DISTRESS') {
|
|
dscAlertCounts.distress++;
|
|
} else if (data.category === 'URGENCY') {
|
|
dscAlertCounts.urgency++;
|
|
}
|
|
|
|
// Show prominent alert for distress/urgency
|
|
if (data.is_critical) {
|
|
showDistressAlert(data);
|
|
}
|
|
|
|
// Add position marker if coordinates present
|
|
if (data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
|
|
addDscPositionMarker(data);
|
|
}
|
|
|
|
updateDscMessageList();
|
|
updateDscAlertSummary();
|
|
}
|
|
|
|
function showDistressAlert(data) {
|
|
// Create alert notification
|
|
const alertDiv = document.createElement('div');
|
|
alertDiv.className = 'dsc-distress-alert';
|
|
alertDiv.innerHTML = `
|
|
<div class="dsc-alert-header">${data.category}</div>
|
|
<div class="dsc-alert-mmsi">MMSI: ${data.source_mmsi}</div>
|
|
${data.source_country ? `<div class="dsc-alert-country">${data.source_country}</div>` : ''}
|
|
${data.nature_of_distress ? `<div class="dsc-alert-nature">${data.nature_of_distress}</div>` : ''}
|
|
${data.latitude ? `<div class="dsc-alert-position">${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}</div>` : ''}
|
|
<button onclick="this.parentElement.remove()">ACKNOWLEDGE</button>
|
|
`;
|
|
document.body.appendChild(alertDiv);
|
|
|
|
// Auto-remove after 30 seconds
|
|
setTimeout(() => {
|
|
if (alertDiv.parentElement) alertDiv.remove();
|
|
}, 30000);
|
|
|
|
// Play alert sound if available
|
|
try {
|
|
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1yc3R3eXx+foCAfn59fHt5d3VzcWxnYlxVT0hCOzUuJx8YEAkDAP/+/v7+/v7+/v8AAAECAwUHCQsOEBMWGRwfIiUoKy4xNDc6PT9CRUdKTE5QUlRVV1hZWlpbW1taWVhXVlRTUU9NSkdEQT47ODUyLywpJiMgHRoXFBEOCwgFAwEA/v38+/r5+Pf29fTz8vHw7+7t7Ovq6ejn5uXk4+Lh4N/e3dzb2tnY19bV1NPS0dDPzs3MzMvLy8vMzM3Nzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufp6uvs7e7v8PHy8/T19vf4+fr7/P3+');
|
|
audio.volume = 0.5;
|
|
audio.play().catch(() => {});
|
|
} catch (e) {}
|
|
}
|
|
|
|
function addDscPositionMarker(data) {
|
|
const mmsi = data.source_mmsi;
|
|
|
|
// Remove existing marker
|
|
if (dscMarkers[mmsi]) {
|
|
vesselMap.removeLayer(dscMarkers[mmsi]);
|
|
}
|
|
|
|
// Create marker with distress icon
|
|
const isDistress = data.category === 'DISTRESS';
|
|
const color = isDistress ? '#ef4444' : (data.category === 'URGENCY' ? '#f59e0b' : '#4a9eff');
|
|
|
|
const icon = L.divIcon({
|
|
className: 'dsc-marker',
|
|
html: `<div class="dsc-marker-inner ${isDistress ? 'distress' : ''}" style="background: ${color};">
|
|
<span>⚠</span>
|
|
</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
});
|
|
|
|
dscMarkers[mmsi] = L.marker([data.latitude, data.longitude], { icon })
|
|
.addTo(vesselMap)
|
|
.bindPopup(`
|
|
<strong>${data.category}</strong><br>
|
|
MMSI: ${mmsi}<br>
|
|
${data.source_country ? `Country: ${data.source_country}<br>` : ''}
|
|
${data.nature_of_distress ? `Nature: ${data.nature_of_distress}<br>` : ''}
|
|
Position: ${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}
|
|
`);
|
|
|
|
// Pan to distress position
|
|
if (isDistress) {
|
|
vesselMap.setView([data.latitude, data.longitude], 12);
|
|
}
|
|
}
|
|
|
|
function updateDscMessageList() {
|
|
const container = document.getElementById('dscMessageList');
|
|
const msgArray = Object.values(dscMessages)
|
|
.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
|
|
if (msgArray.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="no-messages">
|
|
<div>No DSC messages</div>
|
|
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = msgArray.slice(0, 50).map(msg => {
|
|
const isDistress = msg.category === 'DISTRESS';
|
|
const isUrgency = msg.category === 'URGENCY';
|
|
const categoryClass = isDistress ? 'distress' : (isUrgency ? 'urgency' : '');
|
|
|
|
return `
|
|
<div class="dsc-message-item ${categoryClass}" data-id="${msg.id}">
|
|
<div class="dsc-message-header">
|
|
<span class="dsc-message-category">${msg.category}</span>
|
|
<span class="dsc-message-time">${formatDscTime(msg.timestamp)}</span>
|
|
</div>
|
|
<div class="dsc-message-mmsi">MMSI: ${msg.source_mmsi}</div>
|
|
${msg.source_country ? `<div class="dsc-message-country">${msg.source_country}</div>` : ''}
|
|
${msg.nature_of_distress ? `<div class="dsc-message-nature">${msg.nature_of_distress}</div>` : ''}
|
|
${msg.latitude ? `<div class="dsc-message-pos">${msg.latitude.toFixed(4)}, ${msg.longitude.toFixed(4)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatDscTime(timestamp) {
|
|
if (!timestamp) return '--:--';
|
|
try {
|
|
const d = new Date(timestamp);
|
|
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
} catch (e) {
|
|
return timestamp.slice(11, 19) || '--:--';
|
|
}
|
|
}
|
|
|
|
function updateDscAlertSummary() {
|
|
document.getElementById('dscDistressCount').textContent = `${dscAlertCounts.distress} DISTRESS`;
|
|
document.getElementById('dscUrgencyCount').textContent = `${dscAlertCounts.urgency} URGENCY`;
|
|
}
|
|
|
|
// Cross-reference DSC MMSI with AIS vessels
|
|
function crossReferenceDscWithAis(mmsi) {
|
|
const vessel = vessels[mmsi];
|
|
if (vessel) {
|
|
return {
|
|
name: vessel.name,
|
|
callsign: vessel.callsign,
|
|
ship_type: vessel.ship_type,
|
|
destination: vessel.destination
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initMap();
|
|
// Auto-connect to gpsd if available
|
|
autoConnectGps();
|
|
});
|
|
|
|
// Clean up SSE connections on page unload to prevent orphaned streams
|
|
window.addEventListener('pagehide', function() {
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
if (dscEventSource) { dscEventSource.close(); dscEventSource = null; }
|
|
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
|
|
});
|
|
</script>
|
|
|
|
<!-- Agent styles -->
|
|
<style>
|
|
.agent-selector-compact {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-right: 15px;
|
|
}
|
|
.agent-select-sm {
|
|
background: rgba(0, 40, 60, 0.8);
|
|
border: 1px solid var(--border-color, rgba(0, 200, 255, 0.3));
|
|
color: var(--text-primary, #e0f7ff);
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-family: var(--font-mono);
|
|
cursor: pointer;
|
|
}
|
|
.agent-select-sm:focus {
|
|
outline: none;
|
|
border-color: var(--accent-cyan, #00d4ff);
|
|
}
|
|
.agent-status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.agent-status-dot.online {
|
|
background: #4caf50;
|
|
box-shadow: 0 0 6px #4caf50;
|
|
}
|
|
.agent-status-dot.offline {
|
|
background: #f44336;
|
|
box-shadow: 0 0 6px #f44336;
|
|
}
|
|
.vessel-item .agent-badge {
|
|
font-size: 9px;
|
|
color: var(--accent-cyan, #00d4ff);
|
|
background: rgba(0, 200, 255, 0.1);
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
margin-left: 4px;
|
|
}
|
|
#agentModeWarning {
|
|
color: #f0ad4e;
|
|
font-size: 10px;
|
|
padding: 4px 8px;
|
|
background: rgba(240,173,78,0.1);
|
|
border-radius: 4px;
|
|
margin-top: 4px;
|
|
}
|
|
.show-all-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 10px;
|
|
color: var(--text-muted, #a0c4d0);
|
|
cursor: pointer;
|
|
margin-left: 8px;
|
|
}
|
|
.show-all-label input {
|
|
margin: 0;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
|
|
<!-- Settings Modal -->
|
|
{% include 'partials/settings-modal.html' %}
|
|
|
|
<!-- Help Modal -->
|
|
{% include 'partials/help-modal.html' %}
|
|
|
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
|
|
|
<!-- Agent Manager -->
|
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
|
<script>
|
|
// AIS-specific agent integration
|
|
let aisCurrentAgent = 'local';
|
|
|
|
function selectAisAgent(agentId) {
|
|
aisCurrentAgent = agentId;
|
|
currentAgent = agentId; // Update global agent state
|
|
|
|
if (agentId === 'local') {
|
|
loadDevices();
|
|
console.log('AIS: Using local device');
|
|
} else {
|
|
refreshAgentDevicesForAis(agentId);
|
|
syncAgentModeStates(agentId);
|
|
console.log(`AIS: Using agent ${agentId}`);
|
|
}
|
|
updateAgentStatus();
|
|
}
|
|
|
|
async function refreshAgentDevicesForAis(agentId) {
|
|
try {
|
|
const response = await fetch(`/controller/agents/${agentId}?refresh=true`);
|
|
const data = await response.json();
|
|
|
|
if (data.agent && data.agent.interfaces) {
|
|
const devices = data.agent.interfaces.devices || [];
|
|
populateAisDeviceSelects(devices);
|
|
|
|
// Update observer location if agent has GPS
|
|
if (data.agent.gps_coords) {
|
|
const gps = typeof data.agent.gps_coords === 'string'
|
|
? JSON.parse(data.agent.gps_coords)
|
|
: data.agent.gps_coords;
|
|
if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
|
|
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
|
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
|
updateObserverLoc();
|
|
console.log(`Updated observer location from agent GPS: ${gps.lat}, ${gps.lon}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to refresh agent devices:', error);
|
|
}
|
|
}
|
|
|
|
function populateAisDeviceSelects(devices) {
|
|
const aisSelect = document.getElementById('aisDeviceSelect');
|
|
const dscSelect = document.getElementById('dscDeviceSelect');
|
|
|
|
[aisSelect, dscSelect].forEach(select => {
|
|
if (!select) return;
|
|
select.innerHTML = '';
|
|
|
|
if (devices.length === 0) {
|
|
select.innerHTML = '<option value="0">No SDR found</option>';
|
|
} else {
|
|
devices.forEach(device => {
|
|
const opt = document.createElement('option');
|
|
opt.value = device.index;
|
|
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Override startTracking for agent support
|
|
const originalStartTracking = startTracking;
|
|
startTracking = async function() {
|
|
const useAgent = aisCurrentAgent !== 'local';
|
|
|
|
if (useAgent) {
|
|
// Check for conflicts
|
|
if (typeof checkAgentModeConflict === 'function' && !await checkAgentModeConflict('ais')) {
|
|
return;
|
|
}
|
|
|
|
const device = document.getElementById('aisDeviceSelect').value;
|
|
const gain = document.getElementById('aisGain').value;
|
|
|
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
// Handle controller proxy response (agent response is nested in 'result')
|
|
const scanResult = data.result || data;
|
|
if (scanResult.status === 'started' || scanResult.status === 'already_running' || scanResult.status === 'success') {
|
|
isTracking = true;
|
|
document.getElementById('startBtn').textContent = 'STOP';
|
|
document.getElementById('startBtn').classList.add('active');
|
|
document.getElementById('trackingDot').classList.add('active');
|
|
document.getElementById('trackingStatus').textContent = 'TRACKING (AGENT)';
|
|
document.getElementById('agentSelect').disabled = true;
|
|
startSessionTimer();
|
|
startSSE(); // Use multi-agent stream
|
|
startAisPolling(); // Also start polling as fallback
|
|
|
|
if (typeof agentRunningModes !== 'undefined' && !agentRunningModes.includes('ais')) {
|
|
agentRunningModes.push('ais');
|
|
}
|
|
} else {
|
|
alert(scanResult.message || 'Failed to start');
|
|
}
|
|
})
|
|
.catch(err => alert('Error: ' + err.message));
|
|
} else {
|
|
originalStartTracking();
|
|
}
|
|
};
|
|
|
|
// Override stopTracking for agent support
|
|
const originalStopTracking = stopTracking;
|
|
stopTracking = function() {
|
|
const useAgent = aisCurrentAgent !== 'local';
|
|
|
|
if (useAgent) {
|
|
fetch(`/controller/agents/${aisCurrentAgent}/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';
|
|
document.getElementById('agentSelect').disabled = false;
|
|
stopSSE();
|
|
|
|
if (typeof agentRunningModes !== 'undefined') {
|
|
agentRunningModes = agentRunningModes.filter(m => m !== 'ais');
|
|
}
|
|
})
|
|
.catch(err => console.error('Stop error:', err));
|
|
} else {
|
|
originalStopTracking();
|
|
}
|
|
};
|
|
|
|
// Hook into page init
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const agentSelect = document.getElementById('agentSelect');
|
|
if (agentSelect) {
|
|
agentSelect.addEventListener('change', function(e) {
|
|
selectAisAgent(e.target.value);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Show All Agents mode - display vessels from all agents on the map
|
|
let showAllAgentsMode = false;
|
|
let allAgentsEventSource = null;
|
|
|
|
function toggleShowAllAgents() {
|
|
const checkbox = document.getElementById('showAllAgents');
|
|
showAllAgentsMode = checkbox ? checkbox.checked : false;
|
|
|
|
const agentSelect = document.getElementById('agentSelect');
|
|
const startBtn = document.getElementById('startBtn');
|
|
|
|
if (showAllAgentsMode) {
|
|
// Disable individual agent selection and start button
|
|
if (agentSelect) agentSelect.disabled = true;
|
|
if (startBtn) startBtn.disabled = true;
|
|
|
|
// Connect to multi-agent stream (passive listening to all agents)
|
|
startAllAgentsStream();
|
|
|
|
document.getElementById('trackingStatus').textContent = 'ALL AGENTS';
|
|
document.getElementById('trackingDot').classList.add('active');
|
|
console.log('Show All Agents mode enabled');
|
|
} else {
|
|
// Re-enable controls
|
|
if (agentSelect) agentSelect.disabled = isTracking;
|
|
if (startBtn) startBtn.disabled = false;
|
|
|
|
// Stop multi-agent stream
|
|
stopAllAgentsStream();
|
|
|
|
if (!isTracking) {
|
|
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
|
document.getElementById('trackingDot').classList.remove('active');
|
|
}
|
|
console.log('Show All Agents mode disabled');
|
|
}
|
|
}
|
|
|
|
function startAllAgentsStream() {
|
|
if (allAgentsEventSource) allAgentsEventSource.close();
|
|
|
|
allAgentsEventSource = new EventSource('/controller/stream/all');
|
|
allAgentsEventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.type === 'keepalive') return;
|
|
|
|
// Handle AIS data from any agent
|
|
if (data.scan_type === 'ais' && data.payload) {
|
|
const payload = data.payload;
|
|
if (payload.vessels) {
|
|
Object.values(payload.vessels).forEach(v => {
|
|
v._agent = data.agent_name;
|
|
updateVessel({ type: 'vessel', ...v });
|
|
});
|
|
} else if (payload.mmsi) {
|
|
payload._agent = data.agent_name;
|
|
updateVessel({ type: 'vessel', ...payload });
|
|
}
|
|
}
|
|
|
|
// Handle DSC data from any agent
|
|
if (data.scan_type === 'dsc' && data.payload) {
|
|
const payload = data.payload;
|
|
if (payload.messages) {
|
|
payload.messages.forEach(msg => {
|
|
msg._agent = data.agent_name;
|
|
processDscMessage(msg);
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('All agents stream parse error:', err);
|
|
}
|
|
};
|
|
|
|
allAgentsEventSource.onerror = function() {
|
|
console.error('All agents stream error');
|
|
setTimeout(() => {
|
|
if (showAllAgentsMode) startAllAgentsStream();
|
|
}, 3000);
|
|
};
|
|
}
|
|
|
|
function stopAllAgentsStream() {
|
|
if (allAgentsEventSource) {
|
|
allAgentsEventSource.close();
|
|
allAgentsEventSource = null;
|
|
}
|
|
}
|
|
|
|
// Process DSC message (wrapper for addDscMessage if it exists)
|
|
function processDscMessage(msg) {
|
|
if (typeof addDscMessage === 'function') {
|
|
addDscMessage(msg);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|