Merge main into misc-fixes and address PR #202 review

Sync with upstream main and fix required items from review:

- updateTimelineLabels() now uses InterceptTime API (getTimezone/getIANA)
  instead of the stale selectedTimezone/TZ_MAP globals that were removed
  during the earlier InterceptTime refactor — fixes ReferenceError on TZ
  change and pass refresh.

- Remove profiles: [basic] from the intercept service in
  docker-compose.yml so bare `docker compose up -d` still starts the
  main service. Profile-gated services (intercept-history, adsb_db)
  stay as-is.
This commit is contained in:
Mitch Ross
2026-04-24 16:34:09 -04:00
60 changed files with 6969 additions and 5301 deletions
+85 -120
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.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/core/map-utils.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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
@@ -340,6 +341,9 @@
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option>
</select>
<label title="SDR gain in dB (0 = auto)" style="display:flex;align-items:center;gap:3px;font-size:11px;">
Gain <input type="number" id="adsbGainInput" value="40" min="0" max="50" step="1" style="width:46px;" title="SDR gain in dB">
</label>
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
@@ -417,6 +421,7 @@
// STATE
// ============================================
let radarMap = null;
let mapOverlays = null;
let aircraft = {};
let markers = {};
let selectedIcao = null;
@@ -638,7 +643,6 @@
return { lat: defaultLat, lon: defaultLon };
})();
let rangeRingsLayer = null;
let observerMarker = null;
// GPS state
let gpsConnected = false;
@@ -1512,16 +1516,10 @@ ACARS: ${r.statistics.acarsMessages} messages`;
rangeRingsLayer.addLayer(label);
});
// Observer marker
if (observerMarker) radarMap.removeLayer(observerMarker);
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(radarMap);
// Observer reticle — update position via MapUtils handle
if (mapOverlays) {
mapOverlays.updateReticle([observerLocation.lat, observerLocation.lon]);
}
rangeRingsLayer.addTo(radarMap);
}
@@ -2220,112 +2218,33 @@ sudo make install</code>
now.toISOString().substring(11, 19) + ' UTC';
}
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 = '#08121c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
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(0, 212, 255, 0.08)';
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 upgradeRadarTilesFromSettings(fallbackTiles) {
if (typeof Settings === 'undefined') return;
try {
await Settings.init();
if (!radarMap) return;
const configuredLayer = Settings.createTileLayer();
let tileLoaded = false;
configuredLayer.once('load', () => {
tileLoaded = true;
if (radarMap && fallbackTiles && radarMap.hasLayer(fallbackTiles)) {
radarMap.removeLayer(fallbackTiles);
}
});
configuredLayer.on('tileerror', () => {
if (!tileLoaded) {
console.warn('ADS-B tile layer failed to load, keeping fallback grid');
}
});
configuredLayer.addTo(radarMap);
Settings.registerMap(radarMap);
} catch (e) {
console.warn('ADS-B: Settings/tile upgrade failed, using fallback grid:', e);
}
}
async function initMap() {
// Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('radarMap');
if (!container || container._leaflet_id) return;
radarMap = L.map('radarMap', {
radarMap = MapUtils.init('radarMap', {
center: [observerLocation.lat, observerLocation.lon],
zoom: 7,
minZoom: 3,
maxZoom: 15
maxZoom: 15,
});
// Use settings manager for tile layer (allows runtime changes)
if (!radarMap) return;
window.radarMap = radarMap;
// Use a zero-network fallback so dashboard navigation stays fast even
// when internet map providers are slow or unreachable.
const fallbackTiles = createFallbackGridLayer().addTo(radarMap);
// Fix map size after init
setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 200);
setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 500);
// Draw range rings after map is ready
setTimeout(() => drawRangeRings(), 100);
// Fix map size on mobile after initialization
setTimeout(() => {
if (radarMap) radarMap.invalidateSize();
}, 200);
// Additional invalidateSize to ensure all tiles load
setTimeout(() => {
if (radarMap) radarMap.invalidateSize();
}, 500);
// Upgrade tiles via Settings in the background without tearing down
// the local fallback grid until a real tile layer actually loads.
upgradeRadarTilesFromSettings(fallbackTiles);
// Tactical overlays: observer reticle + HUD panels + scale bar
// Range rings are managed by drawRangeRings() which handles toggle, range changes, and observer moves
mapOverlays = MapUtils.addTacticalOverlays(radarMap, {
observerReticle: { latlng: [observerLocation.lat, observerLocation.lon] },
hudPanels: {
modeName: 'ADS-B',
getContactCount: () => Object.keys(aircraft).length,
},
scaleBar: true,
});
}
// Handle window resize for map (especially important on mobile)
@@ -2445,6 +2364,7 @@ sudo make install</code>
const requestBody = {
device: adsbDevice,
sdr_type: adsbSdrType,
gain: parseInt(document.getElementById('adsbGainInput')?.value || '40'),
bias_t: getBiasTEnabled()
};
if (remoteConfig) {
@@ -2939,6 +2859,8 @@ sudo make install</code>
lastSeen: Date.now()
};
if (mapOverlays) mapOverlays.updateCount(Object.keys(aircraft).length);
checkAndAlertAircraft(icao, aircraft[icao]);
updateStatistics(icao, aircraft[icao]);
@@ -2974,12 +2896,17 @@ sudo make install</code>
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const callsign = ac.callsign || icao;
const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A';
const typeLabel = ac.type_desc || ac.type_code || '';
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
const isSelected = icao === selectedIcao;
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color || prevState.iconType !== iconType || prevState.isSelected !== isSelected;
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt;
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt || prevState.typeLabel !== typeLabel;
const tooltipContent = typeLabel
? `${callsign}<br><span style="opacity:0.75;font-size:10px">${typeLabel}</span><br>${alt}`
: `${callsign}<br>${alt}`;
if (markers[icao]) {
markers[icao].setLatLng([ac.lat, ac.lon]);
@@ -2988,7 +2915,7 @@ sudo make install</code>
}
if (tooltipChanged) {
markers[icao].unbindTooltip();
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
markers[icao].bindTooltip(tooltipContent, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
@@ -2996,24 +2923,32 @@ sudo make install</code>
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao, 'map'));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
markers[icao].bindTooltip(tooltipContent, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
markerState[icao] = { rotation, color, callsign, alt, iconType, isSelected };
markerState[icao] = { rotation, color, callsign, alt, typeLabel, iconType, isSelected };
}
// Aircraft type icon SVG paths
const AIRCRAFT_ICONS = {
// Widebody: wide wingspan, wide tail — 747, 777, A330, A380 etc.
widebody: 'M12 2L7 10H2v2l10 4 10-4v-2h-5L12 2zm0 14l-7 3v1h14v-1l-7-3z',
// Narrowbody / default jet — A320, B737 etc.
jet: 'M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z',
// Business jet: narrow swept wings set further aft
bizjet: 'M12 2L11 11H7v2l5 2.5 5-2.5v-2h-4L12 2zm0 13l-4 2v1h8v-1l-4-2z',
// Turboprop: straight high-aspect wings, engines forward
turboprop: 'M12 2L10 8H3v2.5l9 3.5 9-3.5V8h-7L12 2zm0 13l-5 2.5v1h10v-1l-5-2.5z',
helicopter: 'M12 4L10 6H8V8h1l3 8 3-8h1V6h-2L12 4zm-1 14v2H9v1h6v-1h-2v-2h-2zm7-7h-2v2h2v-2zM4 11h2v2H4v-2z',
// Light piston GA — C172, PA28 etc.
prop: 'M12 3L9 8H5v2l7 6 7-6v-2h-4L12 3zm0 12l-4 2v1h8v-1l-4-2z',
military: 'M12 2L7 9H3l1 3 8 6 8-6 1-3h-4L12 2zm0 14l-5 2.5V20h10v-1.5L12 16z',
glider: 'M12 4L10 8H4v1.5l8 4 8-4V8h-6L12 4zm0 10l-6 2v1h12v-1l-6-2z'
};
// Determine aircraft type from type_code
// Determine aircraft icon type from ICAO type_code
function getAircraftIconType(typeCode, isMilitary) {
if (isMilitary) return 'military';
if (!typeCode) return 'jet';
@@ -3022,22 +2957,51 @@ sudo make install</code>
// Helicopters
if (code.startsWith('H') || code.includes('HELI') ||
['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412', 'S76', 'A109', 'AW139', 'AW169'].some(h => code.includes(h))) {
['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412',
'S76', 'S92', 'A109', 'AW139', 'AW169', 'AW189', 'EC25', 'EC30', 'EC75', 'EC85',
'MI8', 'MI17', 'MI26', 'CH47', 'UH60', 'UH72', 'NH90'].some(h => code.includes(h))) {
return 'helicopter';
}
// Gliders
if (code.startsWith('G') || code.includes('GLID')) {
// Gliders / motorgliders
if (code.startsWith('G') || ['GLID', 'DG1', 'DG2', 'DG3', 'DG4', 'DG5', 'ASK', 'ASW',
'LS4', 'LS6', 'LS8', 'DUET', 'DISC', 'NIMB', 'PUCH', 'VENT'].some(g => code.includes(g))) {
return 'glider';
}
// Light props (common GA aircraft)
if (['C150', 'C152', 'C172', 'C182', 'C206', 'C208', 'C210', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'M20', 'BE35', 'BE36', 'BE58'].some(p => code.includes(p))) {
return 'prop';
// Widebody jets (twin-aisle)
if (['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S',
'B762', 'B763', 'B764', 'B772', 'B773', 'B77L', 'B77W', 'B778', 'B779',
'B788', 'B789', 'B78X',
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339',
'A342', 'A343', 'A345', 'A346', 'A359', 'A35K', 'A388',
'IL86', 'IL96', 'MD11', 'DC10', 'L101'].some(w => code.startsWith(w) || code === w)) {
return 'widebody';
}
// Turboprops
if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM'].some(t => code.includes(t))) {
// Business jets
if (['C25', 'C50', 'C51', 'C52', 'C55', 'C56', 'C65', 'C68', 'C70', 'C75',
'GLF', 'GLEX', 'G150', 'G200', 'G280', 'G450', 'G500', 'G550', 'G600', 'G650',
'LJ2', 'LJ3', 'LJ4', 'LJ5', 'LJ6', 'LJ7',
'F2TH', 'F900', 'F7X', 'F8X', 'DA50',
'CL30', 'CL35', 'CL60', 'CRJ1', 'CRJ2',
'E135', 'E145', 'PC24', 'BE40', 'HA4T', 'PRM1'].some(b => code.startsWith(b))) {
return 'bizjet';
}
// Turboprops (regional airliners and utility)
if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM', 'C208',
'PAY', 'BE99', 'BE9L', 'SW4', 'IL18', 'AN24', 'AN26', 'AN28',
'F27', 'F50', 'JS31', 'JS32', 'JS41', 'MA60', 'Y12'].some(t => code.startsWith(t) || code.includes(t))) {
return 'turboprop';
}
// Light piston GA
if (['C150', 'C152', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337',
'PA18', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46',
'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'TB9',
'M20', 'BE35', 'BE36', 'BE58', 'BE60',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10'].some(p => code.startsWith(p) || code.includes(p))) {
return 'prop';
}
@@ -3046,7 +3010,7 @@ sudo make install</code>
function createMarkerIcon(rotation, color, iconType = 'jet', isSelected = false) {
const path = AIRCRAFT_ICONS[iconType] || AIRCRAFT_ICONS.jet;
const size = iconType === 'helicopter' ? 22 : 24;
const size = iconType === 'helicopter' ? 22 : iconType === 'widebody' ? 26 : iconType === 'bizjet' ? 22 : 24;
const glowColor = isSelected ? 'rgba(255,255,255,0.9)' : color;
const glowSize = isSelected ? '10px' : '5px';
const trackingRing = isSelected ?
@@ -5750,6 +5714,7 @@ sudo make install</code>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
+18 -66
View File
@@ -15,6 +15,7 @@
<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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
<!-- Deferred scripts -->
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
@@ -203,6 +204,7 @@
// State
let vesselMap = null;
let aisMapOverlays = null;
let vessels = {};
let markers = {};
let selectedMmsi = null;
@@ -387,48 +389,6 @@
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');
@@ -439,34 +399,24 @@
document.getElementById('obsLon').value = observerLocation.lon;
}
vesselMap = L.map('vesselMap', {
center: [observerLocation.lat, observerLocation.lon],
zoom: 10,
zoomControl: true
vesselMap = MapUtils.init('vesselMap', {
center: [observerLocation.lat || 51.5, observerLocation.lon || -0.1],
zoom: 6,
minZoom: 2,
maxZoom: 18,
});
// Use settings manager for tile layer (allows runtime changes)
if (!vesselMap) return;
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);
setTimeout(() => { if (vesselMap) vesselMap.invalidateSize(); }, 200);
// 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
}
}
aisMapOverlays = MapUtils.addTacticalOverlays(vesselMap, {
hudPanels: {
modeName: 'AIS',
getContactCount: () => Object.keys(vessels).length,
},
scaleBar: true,
});
// Add observer marker
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
@@ -794,6 +744,7 @@
vessels[mmsi] = data;
stats.totalVesselsSeen.add(mmsi);
stats.messagesReceived++;
if (aisMapOverlays) aisMapOverlays.updateCount(Object.keys(vessels).length);
// Update statistics
if (data.speed && data.speed > stats.fastestSpeed) {
@@ -1637,6 +1588,7 @@
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
+281 -228
View File
@@ -66,6 +66,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-waveform.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
<!-- Deferred scripts - Leaflet, Chart.js, observer-location -->
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
@@ -834,15 +835,19 @@
<span class="wifi-status-label">Hidden:</span>
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
</div>
<div class="wifi-status-item" id="wifiScanStatus">
<span class="wifi-status-indicator idle"></span>
<span>Ready</span>
<div class="wifi-status-item">
<span class="wifi-status-label">Open:</span>
<span class="wifi-status-value" id="wifiOpenCount" style="color:var(--accent-red);">0</span>
</div>
<div class="wifi-scan-indicator" id="wifiScanIndicator">
<span class="wifi-scan-dot"></span>
<span class="wifi-scan-text">IDLE</span>
</div>
</div>
<!-- Main Content: 3-column layout -->
<div class="wifi-main-content">
<!-- LEFT: Networks Table -->
<!-- LEFT: Networks List -->
<div class="wifi-networks-panel">
<div class="wifi-networks-header">
<h5>Discovered Networks</h5>
@@ -853,39 +858,73 @@
<button class="wifi-filter-btn" data-filter="open">Open</button>
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
</div>
<div class="wifi-sort-controls">
<span class="wifi-sort-label">Sort:</span>
<button class="wifi-sort-btn active" data-sort="rssi">Signal</button>
<button class="wifi-sort-btn" data-sort="essid">SSID</button>
<button class="wifi-sort-btn" data-sort="channel">Ch</button>
</div>
</div>
<div class="wifi-networks-table-wrapper">
<table class="wifi-networks-table" id="wifiNetworkTable">
<thead>
<tr>
<th class="sortable" data-sort="essid">SSID</th>
<th class="sortable" data-sort="bssid">BSSID</th>
<th class="sortable" data-sort="channel">Ch</th>
<th class="sortable" data-sort="rssi">Signal</th>
<th class="sortable" data-sort="security">Security</th>
<th class="sortable" data-sort="clients">Clients</th>
<th class="col-agent sortable" data-sort="agent">Source</th>
</tr>
</thead>
<tbody id="wifiNetworkTableBody">
<tr class="wifi-network-placeholder">
<td colspan="7">
<div class="placeholder-text">Start scanning to discover networks</div>
</td>
</tr>
</tbody>
</table>
<div id="wifiNetworkList" class="wifi-network-list">
<div class="wifi-network-placeholder">
<p>No networks detected.<br>Start a scan to begin.</p>
</div>
</div>
</div>
</div>
<!-- CENTER: Proximity Radar -->
<div class="wifi-radar-panel">
<h5>Proximity Radar</h5>
<div id="wifiProximityRadar" class="wifi-radar-container"></div>
<div id="wifiProximityRadar" class="wifi-radar-container">
<svg width="100%" viewBox="0 0 210 210" id="wifiRadarSvg">
<defs>
<clipPath id="wifi-radar-clip">
<circle cx="105" cy="105" r="100"/>
</clipPath>
<filter id="wifi-glow-sm">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="wifi-glow-md">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Background rings (static) -->
<circle cx="105" cy="105" r="100" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.12"/>
<circle cx="105" cy="105" r="70" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.18"/>
<circle cx="105" cy="105" r="40" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.25"/>
<circle cx="105" cy="105" r="15" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.35"/>
<!-- Crosshairs -->
<line x1="5" y1="105" x2="205" y2="105" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
<line x1="105" y1="5" x2="105" y2="205" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
<!-- Rotating sweep group -->
<g class="wifi-radar-sweep" clip-path="url(#wifi-radar-clip)">
<!-- Primary trailing arc: 60° -->
<path d="M105,105 L105,5 A100,100 0 0,1 191.6,155 Z" fill="#00b4d8" opacity="0.08"/>
<!-- Secondary trailing arc: 90° -->
<path d="M105,105 L105,5 A100,100 0 0,1 205,105 Z" fill="#00b4d8" opacity="0.04"/>
<!-- Sweep line -->
<line x1="105" y1="105" x2="105" y2="5" stroke="#00b4d8" stroke-width="1.5" opacity="0.7"
filter="url(#wifi-glow-sm)"/>
</g>
<!-- Centre dot -->
<circle cx="105" cy="105" r="3" fill="#00b4d8" opacity="0.8"/>
<!-- Network dots (managed by renderRadar()) -->
<g id="wifiRadarDots"></g>
</svg>
</div>
<div class="wifi-zone-summary">
<div class="wifi-zone near">
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
<span class="wifi-zone-label">Near</span>
<span class="wifi-zone-label">Close</span>
</div>
<div class="wifi-zone mid">
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
@@ -898,95 +937,104 @@
</div>
</div>
<!-- RIGHT: Channel Analysis + Security -->
<!-- RIGHT: Channel Heatmap + Security Ring -->
<div class="wifi-analysis-panel">
<div class="wifi-channel-section">
<h5>Channel Analysis</h5>
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
<button class="channel-band-tab" data-band="5">5 GHz</button>
</div>
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
<div class="wifi-analysis-panel-header">
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn"
style="display:none" onclick="WiFiMode.closeDetail()">← Back</button>
</div>
<div class="wifi-security-section">
<h5>Security Overview</h5>
<div class="wifi-security-stats">
<div class="wifi-security-item wpa3">
<span class="wifi-security-dot"></span>
<span>WPA3</span>
<span class="wifi-security-count" id="wpa3Count">0</span>
</div>
<div class="wifi-security-item wpa2">
<span class="wifi-security-dot"></span>
<span>WPA2</span>
<span class="wifi-security-count" id="wpa2Count">0</span>
</div>
<div class="wifi-security-item wep">
<span class="wifi-security-dot"></span>
<span>WEP</span>
<span class="wifi-security-count" id="wepCount">0</span>
</div>
<div class="wifi-security-item open">
<span class="wifi-security-dot"></span>
<span>Open</span>
<span class="wifi-security-count" id="openCount">0</span>
</div>
</div>
</div>
</div>
</div>
<!-- Detail Drawer (slides up on network selection) -->
<div class="wifi-detail-drawer" id="wifiDetailDrawer">
<div class="wifi-detail-header">
<div class="wifi-detail-title">
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
</div>
<button class="wfl-locate-btn" onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()" title="Locate this AP">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
Locate
</button>
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">&times;</button>
</div>
<div class="wifi-detail-content" id="wifiDetailContent">
<div class="wifi-detail-grid">
<div class="wifi-detail-stat">
<span class="label">Signal</span>
<span class="value" id="wifiDetailRssi">--</span>
<!-- Default: heatmap + security ring -->
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<div class="wifi-heatmap-wrap">
<div class="wifi-heatmap-label">
2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans
</div>
<div class="wifi-heatmap-ch-labels" id="wifiHeatmapChLabels">
<!-- 11 channel labels (111), generated once by JS -->
</div>
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
<div class="wifi-heatmap-legend">
<span>Low</span>
<div class="wifi-heatmap-legend-grad"></div>
<span>High</span>
</div>
</div>
<div class="wifi-detail-stat">
<span class="label">Channel</span>
<span class="value" id="wifiDetailChannel">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Band</span>
<span class="value" id="wifiDetailBand">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Security</span>
<span class="value" id="wifiDetailSecurity">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Cipher</span>
<span class="value" id="wifiDetailCipher">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Vendor</span>
<span class="value" id="wifiDetailVendor">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Clients</span>
<span class="value" id="wifiDetailClients">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">First Seen</span>
<span class="value" id="wifiDetailFirstSeen">--</span>
<div class="wifi-security-ring-wrap">
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
</svg>
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
</div>
</div>
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
<div class="wifi-client-list"></div>
<!-- On network click: detail panel (wired in Task 5) -->
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
<div class="wifi-detail-inner">
<div class="wifi-detail-head">
<div class="wifi-detail-essid" id="wifiDetailEssid"></div>
<div class="wifi-detail-bssid" id="wifiDetailBssid"></div>
</div>
<div class="wifi-detail-signal-bar">
<div class="wifi-detail-signal-labels">
<span>Signal</span>
<span id="wifiDetailRssi"></span>
</div>
<div class="wifi-detail-signal-track">
<div class="wifi-detail-signal-fill" id="wifiDetailSignalFill" style="width:0%"></div>
</div>
</div>
<div class="wifi-detail-grid">
<div class="wifi-detail-stat">
<span class="label">Channel</span>
<span class="value" id="wifiDetailChannel"></span>
</div>
<div class="wifi-detail-stat">
<span class="label">Band</span>
<span class="value" id="wifiDetailBand"></span>
</div>
<div class="wifi-detail-stat">
<span class="label">Security</span>
<span class="value" id="wifiDetailSecurity"></span>
</div>
<div class="wifi-detail-stat">
<span class="label">Cipher</span>
<span class="value" id="wifiDetailCipher"></span>
</div>
<div class="wifi-detail-stat">
<span class="label">Clients</span>
<span class="value" id="wifiDetailClients"></span>
</div>
<div class="wifi-detail-stat">
<span class="label">First Seen</span>
<span class="value" id="wifiDetailFirstSeen"></span>
</div>
<div class="wifi-detail-stat" style="grid-column: 1 / -1;">
<span class="label">Vendor</span>
<span class="value" id="wifiDetailVendor" style="font-size:11px;"></span>
</div>
</div>
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
<div class="wifi-client-list"></div>
</div>
<div class="wifi-detail-actions">
<button class="wfl-locate-btn" title="Locate this AP"
onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="10" r="3"/>
<path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
</svg>
Locate
</button>
<button class="wifi-detail-close-btn" onclick="WiFiMode.closeDetail()">Close</button>
</div>
</div>
</div>
</div>
</div>
@@ -1139,6 +1187,10 @@
<div class="wifi-device-list-header">
<h5>Bluetooth Devices</h5>
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
<div class="bt-scan-indicator" id="btScanIndicator">
<span class="bt-scan-dot" style="display:none;"></span>
<span class="bt-scan-text">IDLE</span>
</div>
</div>
<div class="bt-list-summary" id="btListSummary">
<div class="bt-summary-item">
@@ -1178,16 +1230,25 @@
</div>
</div>
</div>
<div class="bt-controls-row">
<div class="bt-sort-group" id="btSortGroup">
<span class="bt-sort-label">Sort</span>
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
<button class="bt-sort-btn" data-sort="name">Name</button>
<button class="bt-sort-btn" data-sort="seen">Seen</button>
<button class="bt-sort-btn" data-sort="distance">Dist</button>
</div>
<div class="bt-filter-group" id="btFilterGroup">
<button class="bt-filter-btn active" data-filter="all">All</button>
<button class="bt-filter-btn" data-filter="new">New</button>
<button class="bt-filter-btn" data-filter="named">Named</button>
<button class="bt-filter-btn" data-filter="strong">Strong</button>
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
</div>
</div>
<div class="bt-device-toolbar">
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
</div>
<div class="bt-device-filters" id="btDeviceFilters">
<button class="bt-filter-btn active" data-filter="all">All</button>
<button class="bt-filter-btn" data-filter="new">New</button>
<button class="bt-filter-btn" data-filter="named">Named</button>
<button class="bt-filter-btn" data-filter="strong">Strong</button>
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
</div>
<div class="wifi-device-list-content" id="btDeviceListContent">
<div class="app-collection-state is-empty">Start scanning to discover Bluetooth devices</div>
</div>
@@ -4190,6 +4251,9 @@
// Initialize dropdown nav active state
updateDropdownActiveState();
// Restore nav group open/closed state from localStorage
initNavGroupState();
// Start SDR device status polling
startSdrStatusPolling();
@@ -4217,6 +4281,45 @@
if (!isOpen) {
dropdown.classList.add('open');
}
saveNavGroupState();
}
function initNavGroupState() {
const NAV_STATE_KEY = 'intercept_nav_groups';
let savedState = {};
try {
savedState = JSON.parse(localStorage.getItem(NAV_STATE_KEY) || '{}');
} catch (e) {
savedState = {};
}
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
const group = dropdown.dataset.group;
// If saved state says closed AND this group has no active item, close it
if (savedState[group] === false) {
const hasActive = dropdown.classList.contains('has-active');
if (!hasActive) {
dropdown.classList.remove('open');
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
} else if (savedState[group] === true) {
dropdown.classList.add('open');
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
if (btn) btn.setAttribute('aria-expanded', 'true');
}
});
}
function saveNavGroupState() {
const NAV_STATE_KEY = 'intercept_nav_groups';
const state = {};
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
state[dropdown.dataset.group] = dropdown.classList.contains('open');
});
try {
localStorage.setItem(NAV_STATE_KEY, JSON.stringify(state));
} catch (e) { /* storage full or unavailable */ }
}
function closeAllDropdowns() {
@@ -5369,8 +5472,11 @@
// Clear existing output
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
} else {
alert('Error: ' + data.message);
showInfo('Error: ' + (data.message || 'Failed to start sensor'));
}
})
.catch(err => {
showInfo('Error starting sensor: ' + err.message);
});
}
@@ -5608,8 +5714,8 @@
// Keep list manageable
const cards = output.querySelectorAll('.signal-card');
while (cards.length > 100) {
output.removeChild(output.lastChild);
for (let i = cards.length - 1; i >= 100; i--) {
cards[i].remove();
}
}
@@ -5892,14 +5998,8 @@
// Limit to max 50 unique meters (cards won't pile up since we update in place)
const cards = output.querySelectorAll('.signal-card.meter-aggregated');
while (cards.length > 50) {
// Remove oldest card (last one)
const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type');
if (oldestCard) {
output.removeChild(oldestCard);
} else {
break;
}
for (let i = cards.length - 1; i >= 50; i--) {
cards[i].remove();
}
}
@@ -6123,8 +6223,8 @@
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 102 }, // LNA(40)+VGA(62)
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 45 } // LNA(15)+Mix(15)+VGA(15)
};
// Current device list with SDR type info
@@ -6253,6 +6353,12 @@
if (caps) {
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
// Update max attribute on all mode gain inputs so constraints match the SDR
const gainMax = caps.gain_max;
['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
const el = document.getElementById(id);
if (el) el.max = gainMax;
});
}
}
@@ -6374,6 +6480,14 @@
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
// Warn if any SDR mode is currently running — bias-T is applied at
// start time and cannot be toggled on a running device.
const anyRunning = isRunning || isSensorRunning
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning)
|| (typeof isAisRunning !== 'undefined' && isAisRunning);
if (anyRunning) {
showInfo('Bias-T change will take effect after restarting the active SDR mode');
}
}
function getBiasTEnabled() {
@@ -7077,8 +7191,8 @@
// Limit messages displayed (keep placeholder/empty-state)
const cards = output.querySelectorAll('.signal-card');
while (cards.length > 100) {
output.removeChild(cards[cards.length - 1]);
for (let i = cards.length - 1; i >= 100; i--) {
cards[i].remove();
}
}
@@ -7442,7 +7556,7 @@
// Limit displayed devices
while (content.children.length > 50) {
content.removeChild(content.lastChild);
content.lastElementChild.remove();
}
}
@@ -9961,6 +10075,7 @@
// APRS Functions
// ============================================
let aprsMap = null;
let aprsMapOverlays = null;
let aprsMarkers = {};
let aprsEventSource = null;
let isAprsRunning = false;
@@ -10112,47 +10227,6 @@
});
}
function createAprsFallbackGridLayer() {
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 = '#08121c';
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(255, 255, 255, 0.06)';
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 initAprsMap() {
if (aprsMap) return;
@@ -10181,26 +10255,18 @@
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
aprsMap = MapUtils.init('aprsMap', {
center: [initialLat, initialLon],
zoom: initialZoom,
minZoom: 2,
maxZoom: 18,
});
if (!aprsMap) return;
window.aprsMap = aprsMap;
// Zero-network fallback so mode switches never block on external tiles.
const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
aprsMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(aprsMap);
Settings.registerMap(aprsMap);
} catch (e) {
console.warn('APRS: Settings init failed/timed out, using fallback tiles:', e);
}
}
aprsMapOverlays = MapUtils.addTacticalOverlays(aprsMap, {
scaleBar: true,
});
// Add user marker if GPS position is already available
if (gpsConnected && hasGpsLocation) {
@@ -10243,6 +10309,7 @@
} catch (_) {}
aprsMap = null;
window.aprsMap = null;
aprsMapOverlays = null;
}
aprsMarkers = {};
aprsUserMarker = null;
@@ -10729,7 +10796,7 @@
// Keep log manageable
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
logEl.lastElementChild.remove();
}
// Update map if position data
@@ -11302,6 +11369,7 @@
// Ground Track Map
let groundTrackMap = null;
let gpsMapOverlays = null;
let groundTrackLine = null;
let satMarker = null;
let observerMarker = null;
@@ -11311,47 +11379,24 @@
const mapContainer = document.getElementById('groundTrackMap');
if (!mapContainer || groundTrackMap) return;
groundTrackMap = L.map('groundTrackMap', {
groundTrackMap = MapUtils.init('groundTrackMap', {
center: [20, 0],
zoom: 1,
zoomControl: true,
attributionControl: false
attributionControl: false,
});
if (!groundTrackMap) return;
window.groundTrackMap = groundTrackMap;
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(groundTrackMap);
gpsMapOverlays = MapUtils.addTacticalOverlays(groundTrackMap, {
scaleBar: true,
});
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
groundTrackMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(groundTrackMap);
Settings.registerMap(groundTrackMap);
} catch (e) {
console.warn('Ground track: Settings init failed/timed out, using fallback tiles:', e);
}
}
// Add observer marker
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
observerMarker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: '#ff6600',
color: '#fff',
weight: 2,
fillOpacity: 1
}).addTo(groundTrackMap).bindPopup('Observer Location');
// Observer crosshair via MapUtils
const obsLat = parseFloat(document.getElementById('obsLat')?.value) || 51.5;
const obsLon = parseFloat(document.getElementById('obsLon')?.value) || -0.1;
observerMarker = MapUtils._buildReticle([obsLat, obsLon]);
observerMarker.addTo(groundTrackMap);
}
function updateGroundTrack(pass) {
@@ -12110,6 +12155,12 @@
async function startTscmSweep() {
const sweepType = document.getElementById('tscmSweepType').value;
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
const customRanges = sweepType === 'custom' ? [{
start: parseFloat(document.getElementById('tscmCustomStartMhz').value),
end: parseFloat(document.getElementById('tscmCustomEndMhz').value),
step: 0.1,
name: `Custom ${document.getElementById('tscmCustomStartMhz').value}${document.getElementById('tscmCustomEndMhz').value} MHz`
}] : null;
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
const btEnabled = document.getElementById('tscmBtEnabled').checked;
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
@@ -12150,7 +12201,8 @@
wifi_interface: wifiInterface,
bt_interface: btInterface,
sdr_device: sdrDevice ? parseInt(sdrDevice) : null,
verbose_results: verboseResults
verbose_results: verboseResults,
custom_ranges: customRanges
})
});
@@ -12919,7 +12971,7 @@
if (tscmSweepStartTime) {
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
const durations = { quick: 120, standard: 300, full: 900 };
const durations = { quick: 120, standard: 300, full: 900, custom: 300 };
const maxDuration = durations[sweepType] || 300;
const progress = Math.min(95, (elapsed / maxDuration) * 100);
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
@@ -16442,6 +16494,7 @@
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
<!-- Settings Manager -->
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
<!-- Alerts + Recording -->
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
+29 -1
View File
@@ -18,6 +18,23 @@
</div>
</div>
<div class="section">
<h3>NMEA UDP Forward</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Forward NMEA 0183 sentences to an external app (e.g. OpenCPN). Leave host blank to disable.
</p>
<div style="display: flex; gap: 8px;">
<div style="flex: 2;">
<label style="font-size: 10px; color: var(--text-dim);">Host</label>
<input type="text" id="aisUdpHost" placeholder="e.g. 192.168.1.10" style="width: 100%;">
</div>
<div style="flex: 1;">
<label style="font-size: 10px; color: var(--text-dim);">Port</label>
<input type="number" id="aisUdpPort" value="10110" min="1" max="65535" style="width: 100%;">
</div>
</div>
</div>
<div class="section">
<h3>Status</h3>
<div id="aisStatusDisplay" class="info-text">
@@ -110,11 +127,22 @@
function startAisTracking() {
const gain = document.getElementById('aisGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
const udpHost = document.getElementById('aisUdpHost').value.trim();
const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110;
const body = {
device, gain,
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
};
if (udpHost) {
body.udp_host = udpHost;
body.udp_port = udpPort;
}
fetch('/ais/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false })
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
+1 -1
View File
@@ -32,7 +32,7 @@
<h3>Settings</h3>
<div class="form-group">
<label for="gain">Gain (dB, 0 = auto)</label>
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
<input type="number" id="gain" value="0" min="0" max="50" step="0.5" placeholder="0 = auto">
</div>
<div class="form-group">
<label for="squelch">Squelch Level</label>
+16 -8
View File
@@ -278,6 +278,7 @@
// Map management
let radiosondeMap = null;
let radiosondeMapOverlays = null;
let radiosondeMarkers = new Map();
let radiosondeTracks = new Map();
let radiosondeTrackPoints = new Map();
@@ -295,16 +296,23 @@
}
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
radiosondeMap = L.map('radiosondeMapContainer', {
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
zoom: hasLocation ? 7 : 4,
zoomControl: true,
});
const observerLocation = hasLocation
? { lat: radiosondeStationLocation.lat, lon: radiosondeStationLocation.lon }
: { lat: 40, lon: -95 };
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap &copy; CARTO',
radiosondeMap = MapUtils.init('radiosondeMapContainer', {
center: [observerLocation.lat, observerLocation.lon],
zoom: hasLocation ? 7 : 4,
minZoom: 2,
maxZoom: 18,
}).addTo(radiosondeMap);
});
if (radiosondeMap) {
window.radiosondeMap = radiosondeMap;
radiosondeMapOverlays = MapUtils.addTacticalOverlays(radiosondeMap, {
hudPanels: { modeName: 'RADIOSONDE', getContactCount: () => 0 },
scaleBar: true,
});
}
// Add station marker if we have a location
if (hasLocation) {
+1 -1
View File
@@ -18,7 +18,7 @@
<h3>Settings</h3>
<div class="form-group">
<label for="sensorGain">Gain (dB, 0 = auto)</label>
<input type="text" id="sensorGain" value="0" placeholder="0-49 or 0 for auto">
<input type="number" id="sensorGain" value="0" min="0" max="50" step="0.5" placeholder="0 = auto">
</div>
<div class="form-group">
<label for="sensorPpm">PPM Correction</label>
+15 -1
View File
@@ -6,14 +6,28 @@
<div class="form-group">
<label>Sweep Type</label>
<select id="tscmSweepType">
<select id="tscmSweepType" onchange="document.getElementById('tscmCustomRangeControls').style.display = this.value === 'custom' ? 'block' : 'none'">
<option value="quick">Quick Scan (2 min)</option>
<option value="standard" selected>Standard (5 min)</option>
<option value="full">Full Sweep (15 min)</option>
<option value="wireless_cameras">Wireless Cameras</option>
<option value="body_worn">Body-Worn Devices</option>
<option value="gps_trackers">GPS Trackers</option>
<option value="custom">Custom Range</option>
</select>
<div id="tscmCustomRangeControls" style="display: none; margin-top: 8px;">
<div style="display: flex; gap: 8px;">
<div style="flex: 1;">
<label style="font-size: 10px; color: var(--text-dim);">Start (MHz)</label>
<input type="number" id="tscmCustomStartMhz" value="400" min="1" max="6000" step="1">
</div>
<div style="flex: 1;">
<label style="font-size: 10px; color: var(--text-dim);">End (MHz)</label>
<input type="number" id="tscmCustomEndMhz" value="500" min="1" max="6000" step="1">
</div>
</div>
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 3px;">Step: 100 kHz. Duration: ~5 min.</p>
</div>
</div>
<div class="form-group">
@@ -92,7 +92,8 @@
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
<input type="number" id="weatherSatGain" value="30" step="0.1" min="0" max="50">
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 3px;">Reduce if decoding fails on strong passes (ADC saturation).</p>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
+11
View File
@@ -77,6 +77,8 @@
<option value="openstreetmap">OpenStreetMap</option>
<option value="cartodb_light">CartoDB Positron</option>
<option value="esri_world">ESRI World Imagery</option>
<option value="stadia_dark">Stadia Alidade Dark</option>
<option value="tactical">Stadia Tactical (minimal)</option>
<option value="custom">Custom URL</option>
</select>
</div>
@@ -90,6 +92,15 @@
onchange="Settings.setCustomTileUrl(this.value)">
</div>
</div>
<div class="settings-row" id="stadiaKeyRow" style="display: none;">
<div class="settings-label" style="width: 100%;">
<span class="settings-label-text">Stadia API Key</span>
<span class="settings-label-desc">Free at <a href="https://client.stadiamaps.com/signup/" target="_blank" style="color: var(--accent-cyan);">stadiamaps.com</a></span>
<input type="text" id="stadiaKeyInput" class="settings-input"
placeholder="your-api-key-here"
onchange="Settings.setStadiaKey(this.value)">
</div>
</div>
</div>
<div class="settings-group">
+24 -96
View File
@@ -16,6 +16,7 @@
<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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
@@ -766,6 +767,7 @@
let passes = [];
let selectedPass = null;
let groundMap = null;
let satMapOverlays = null;
let satMarker = null;
let trackLine = null;
let observerMarker = null;
@@ -1906,103 +1908,35 @@
now.toISOString().substring(11, 19) + ' UTC';
}
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 = '#08121c';
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(255, 255, 255, 0.06)';
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 upgradeGroundTilesFromSettings(fallbackTiles) {
if (typeof Settings === 'undefined' || !groundMap) return;
try {
await Settings.init();
if (!groundMap) return;
const configuredLayer = Settings.createTileLayer();
let tileLoaded = false;
configuredLayer.once('load', () => {
tileLoaded = true;
if (groundMap && fallbackTiles && groundMap.hasLayer(fallbackTiles)) {
groundMap.removeLayer(fallbackTiles);
}
groundMap.invalidateSize(false);
});
configuredLayer.on('tileerror', () => {
if (!tileLoaded) {
console.warn('Satellite tile layer failed to load, keeping fallback grid');
}
});
configuredLayer.addTo(groundMap);
Settings.registerMap(groundMap);
} catch (e) {
console.warn('Satellite: Settings/tile upgrade failed, using fallback grid:', e);
}
}
async function initGroundMap() {
const container = document.getElementById('groundMap');
if (!container || container._leaflet_id) return;
const mapContainer = document.getElementById('groundMap');
if (!mapContainer || groundMap) return;
groundMap = L.map('groundMap', {
groundMap = MapUtils.init('groundMap', {
center: [20, 0],
zoom: 2,
zoom: 1,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
attributionControl: false,
});
if (!groundMap) return;
window.groundMap = groundMap;
// Use a zero-network fallback so dashboard navigation stays fast even
// when internet map providers are slow or unreachable.
const fallbackTiles = createFallbackGridLayer().addTo(groundMap);
satMapOverlays = MapUtils.addTacticalOverlays(groundMap, {
hudPanels: {
modeName: 'SAT TRACK',
getContactCount: () => 0,
},
scaleBar: true,
});
upgradeGroundTilesFromSettings(fallbackTiles);
// Add observer marker via reticle
const lat = parseFloat(document.getElementById('obsLat')?.value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon')?.value) || -0.1;
observerMarker = MapUtils._buildReticle([lat, lon]);
observerMarker.addTo(groundMap);
const lat = parseFloat(document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('obsLon')?.value);
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
groundMap.setView([lat, lon], 3);
}
requestAnimationFrame(() => groundMap?.invalidateSize(false));
setTimeout(() => groundMap?.invalidateSize(false), 250);
updateMapModeButtons();
@@ -2450,16 +2384,9 @@
}
}
const obsIcon = L.divIcon({
className: 'obs-marker',
html: `<div style="width: 12px; height: 12px; background: #ff9500; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 15px #ff9500;"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6]
});
observerMarker = L.marker([lat, lon], { icon: obsIcon })
.addTo(groundMap)
.bindPopup(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
observerMarker = MapUtils._buildReticle([lat, lon]);
observerMarker.addTo(groundMap);
observerMarker.bindPopup(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
}
function updateStats() {
@@ -3163,6 +3090,7 @@
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/ground_station_waterfall.js') }}"></script>
<script>