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
-4
View File
@@ -41,10 +41,6 @@
}
}
.radar-sweep {
transform-origin: 50% 50%;
}
/* Radar filter buttons */
.bt-radar-filter-btn {
transition: all 0.2s ease;
+31 -2
View File
@@ -140,7 +140,6 @@
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
}
.card-header {
@@ -178,7 +177,6 @@
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
}
@supports (clip-path: polygon(0 0)) {
@@ -233,9 +231,25 @@
background: var(--status-offline);
}
@keyframes panel-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.panel-indicator.active {
background: var(--status-online);
box-shadow: 0 0 8px var(--status-online);
animation: panel-pulse 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.panel-indicator.active {
animation: none;
}
}
[data-animations="off"] .panel-indicator.active {
animation: none;
}
.panel-content {
@@ -1152,3 +1166,18 @@ textarea:focus {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
}
/* Visuals Container Base Styles */
.visuals-container {
position: relative;
}
.visuals-container::after {
content: '';
position: absolute;
inset: 0;
background: var(--scanline);
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
+29 -17
View File
@@ -741,16 +741,17 @@
}
.mode-nav-btn:hover {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--border-color);
}
.mode-nav-btn.active {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--accent-cyan);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
padding-left: 12px; /* compensate for 2px border */
}
.mode-nav-btn.active .nav-icon {
@@ -838,7 +839,7 @@
}
.mode-nav-dropdown-btn:hover {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--border-color);
}
@@ -854,10 +855,11 @@
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--accent-cyan);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
padding-left: 12px;
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
@@ -901,9 +903,11 @@
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(74, 163, 255, 0.15);
padding-left: 10px;
}
/* Focus-visible states for nav elements */
@@ -1103,15 +1107,22 @@ a.nav-dashboard-btn:hover {
}
[data-theme="light"] .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.08);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.15);
padding-left: 12px;
}
[data-theme="light"] .mode-nav-dropdown-btn:hover,
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: rgba(31, 95, 168, 0.06);
}
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.06);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.12);
padding-left: 12px;
}
[data-theme="light"] .mode-nav-dropdown-menu {
@@ -1124,8 +1135,9 @@ a.nav-dashboard-btn:hover {
}
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.95);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.08);
border-left: 2px solid var(--accent-cyan);
padding-left: 10px;
}
[data-theme="light"] .nav-tool-btn {
+141
View File
@@ -0,0 +1,141 @@
/* ============================================================
MAP UTILS — Tactical overlay styles
Used by all map-using pages via map-utils.js
============================================================ */
/* --- HUD panel base ---
Absolutely positioned dark-glass panels over the Leaflet map container.
The map container already has position:relative set by Leaflet. */
.map-hud-panel {
position: absolute;
z-index: 1000;
padding: 6px 10px;
background: rgba(7, 9, 14, 0.72);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(74, 163, 255, 0.18);
border-radius: 4px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--text-secondary, #8ba0b8);
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}
/* Top-left: mode name + contact count */
.map-hud-tl {
top: 10px;
left: 10px;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.map-hud-mode {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim, #5a7080);
}
.map-hud-count {
font-size: 16px;
font-weight: 600;
color: var(--accent-cyan, #4aa3ff);
line-height: 1;
}
/* Top-right: UTC clock + status dot */
.map-hud-tr {
top: 10px;
right: 10px;
gap: 6px;
}
.map-hud-clock {
color: var(--text-secondary, #8ba0b8);
font-size: 11px;
}
.map-hud-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim, #5a7080);
flex-shrink: 0;
}
.map-hud-dot.online {
background: var(--status-online, #38c180);
box-shadow: 0 0 4px var(--status-online, #38c180);
}
.map-hud-dot.offline {
background: var(--status-error, #e85d5d);
}
/* --- Observer reticle ---
Rendered as a Leaflet divIcon; no extra CSS needed beyond pointer-events. */
.map-reticle {
pointer-events: none !important;
background: none !important;
border: none !important;
}
/* --- Range ring labels --- */
.map-range-label {
pointer-events: none !important;
background: none !important;
border: none !important;
}
.map-range-label span {
display: inline-block;
background: rgba(7, 9, 14, 0.7);
color: rgba(74, 163, 255, 0.7);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 9px;
padding: 1px 4px;
border-radius: 2px;
white-space: nowrap;
}
/* --- Dark glass popup ---
Applied via MapUtils.glassPopupOptions() className. */
.map-glass-popup .leaflet-popup-content-wrapper {
background: var(--bg-elevated, #161d28) !important;
border: 1px solid var(--border-color, rgba(74,163,255,0.15)) !important;
border-radius: 6px !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
padding: 0;
}
.map-glass-popup .leaflet-popup-content {
margin: 0;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--text-primary, #c8d8e8);
}
.map-glass-popup .leaflet-popup-tip-container {
display: none;
}
.map-glass-popup .leaflet-popup-close-button {
color: var(--text-dim, #5a7080);
font-size: 16px;
padding: 4px 6px;
}
.map-glass-popup .leaflet-popup-close-button:hover {
color: var(--text-primary, #c8d8e8);
}
+18 -5
View File
@@ -10,11 +10,11 @@
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
--bg-primary: #07090e;
--bg-secondary: #0b1018;
--bg-tertiary: #101520;
--bg-card: #0d1219;
--bg-elevated: #161d28;
--bg-overlay: rgba(8, 13, 20, 0.75);
--surface-glass: rgba(16, 25, 37, 0.82);
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
@@ -30,6 +30,7 @@
--accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-cyan-hover: #6bb3ff;
--accent-cyan-glow: rgba(74, 163, 255, 0.12);
--accent-green: #38c180;
--accent-green-hover: #16a34a;
--accent-green-dim: rgba(56, 193, 128, 0.18);
@@ -80,6 +81,15 @@
--grid-dot: rgba(255, 255, 255, 0.03);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
/* Scanline overlay texture */
--scanline: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.04) 2px,
rgba(0, 0, 0, 0.04) 4px
);
/* ============================================
SPACING SCALE
============================================ */
@@ -236,6 +246,9 @@
--grid-dot: rgba(12, 18, 24, 0.06);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--accent-cyan-glow: rgba(31, 95, 168, 0.08);
--scanline: none;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
+531 -359
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background -->
<rect width="100" height="100" fill="#0a0a0f"/>
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+14 -37
View File
@@ -73,6 +73,9 @@ const ProximityRadar = (function() {
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<clipPath id="radarClip">
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/>
</clipPath>
</defs>
<!-- Background gradient -->
@@ -94,10 +97,15 @@ const ProximityRadar = (function() {
}).join('')}
</g>
<!-- Sweep line (animated) -->
<line class="radar-sweep" x1="${center}" y1="${center}"
x2="${center}" y2="${CONFIG.padding}"
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
<!-- CSS-animated sweep group: trailing arcs + sweep line -->
<g class="bt-radar-sweep" clip-path="url(#radarClip)">
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${center + (center - CONFIG.padding)},${center} Z"
fill="#00b4d8" opacity="0.035"/>
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${Math.round(center + (center - CONFIG.padding) * Math.sin(Math.PI / 3))},${Math.round(center + (center - CONFIG.padding) * (1 - Math.cos(Math.PI / 3)))} Z"
fill="#00b4d8" opacity="0.07"/>
<line x1="${center}" y1="${center}" x2="${center}" y2="${CONFIG.padding}"
stroke="#00b4d8" stroke-width="1.5" opacity="0.75"/>
</g>
<!-- Center point -->
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
@@ -129,39 +137,6 @@ const ProximityRadar = (function() {
}
});
// Add sweep animation
animateSweep();
}
/**
* Animate the radar sweep line
*/
function animateSweep() {
const sweepLine = svg.querySelector('.radar-sweep');
if (!sweepLine) return;
let angle = 0;
const center = CONFIG.size / 2;
function rotate() {
if (isPaused) {
requestAnimationFrame(rotate);
return;
}
angle = (angle + 1) % 360;
const rad = (angle * Math.PI) / 180;
const radius = center - CONFIG.padding;
const x2 = center + Math.sin(rad) * radius;
const y2 = center - Math.cos(rad) * radius;
sweepLine.setAttribute('x2', x2);
sweepLine.setAttribute('y2', y2);
requestAnimationFrame(rotate);
}
requestAnimationFrame(rotate);
}
/**
@@ -493,6 +468,8 @@ const ProximityRadar = (function() {
*/
function setPaused(paused) {
isPaused = paused;
const sweep = svg?.querySelector('.bt-radar-sweep');
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
}
/**
+66 -10
View File
@@ -9,7 +9,8 @@ const Settings = {
'offline.assets_source': 'local',
'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
'offline.tile_server_url': '',
'offline.stadia_key': '',
},
// Tile provider configurations
@@ -42,7 +43,19 @@ const Settings = {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
subdomains: null
}
},
stadia_dark: {
url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
subdomains: null,
requiresKey: true,
},
tactical: {
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>',
subdomains: null,
requiresKey: true,
},
},
// Registry of maps that can be updated
@@ -213,8 +226,12 @@ const Settings = {
async _save(key, value) {
this._cache[key] = value;
// Save to localStorage as backup
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
// Save to localStorage as backup (exclude sensitive keys)
const SENSITIVE_KEYS = ['offline.stadia_key'];
const toStore = Object.fromEntries(
Object.entries(this._cache).filter(([k]) => !SENSITIVE_KEYS.includes(k))
);
localStorage.setItem('intercept_settings', JSON.stringify(toStore));
// Save to server
try {
@@ -292,6 +309,13 @@ const Settings = {
customRow.style.display = provider === 'custom' ? 'block' : 'none';
}
// Show/hide Stadia API key row
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
if (stadiaKeyRow) {
stadiaKeyRow.style.display =
(provider === 'stadia_dark' || provider === 'tactical') ? 'block' : 'none';
}
// Update tiles immediately for all providers.
this._updateMapTiles();
const activeConfig = this.getTileConfig();
@@ -307,6 +331,15 @@ const Settings = {
this._updateMapTiles();
},
/**
* Save Stadia Maps API key and refresh tiles.
* @param {string} key
*/
async setStadiaKey(key) {
await this._save('offline.stadia_key', (key || '').trim());
this._updateMapTiles();
},
/**
* Get current tile configuration
*/
@@ -322,15 +355,26 @@ const Settings = {
};
}
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
const baseConfig = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
// Robust fallback: if dark Carto is active and Cyber is preferred,
// keep Cyber theme enabled even when provider temporarily reverts.
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
return { ...config, mapTheme: 'cyber' };
if (baseConfig.requiresKey) {
const key = (this.get('offline.stadia_key') || '').trim();
if (!key) {
// No key — fall back to CartoDB dark so the map isn't broken
return this.tileProviders.cartodb_dark;
}
return {
...baseConfig,
url: baseConfig.url + '?api_key=' + encodeURIComponent(key),
};
}
return config;
// Robust fallback: keep Cyber theme when CartoDB dark is active and Cyber preferred.
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
return { ...baseConfig, mapTheme: 'cyber' };
}
return baseConfig;
},
/**
@@ -643,6 +687,18 @@ const Settings = {
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
}
// Stadia key input
const stadiaKeyInput = document.getElementById('stadiaKeyInput');
if (stadiaKeyInput) {
stadiaKeyInput.value = this.get('offline.stadia_key') || '';
}
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
if (stadiaKeyRow) {
const currentProvider = this.get('offline.tile_provider');
stadiaKeyRow.style.display =
(currentProvider === 'stadia_dark' || currentProvider === 'tactical') ? 'block' : 'none';
}
// Theme select
const themeSelect = document.getElementById('themeSelect');
if (themeSelect) {
+366
View File
@@ -0,0 +1,366 @@
/**
* MapUtils shared Leaflet map initialisation and tactical overlays.
*
* Usage:
* const map = MapUtils.init('myMapDiv', { center: [51.5, -0.1], zoom: 8 });
* const overlays = MapUtils.addTacticalOverlays(map, {
* rangeRings: { center: [51.5, -0.1], intervals: [50, 100, 150, 200] },
* observerReticle: { latlng: [51.5, -0.1] },
* hudPanels: { modeName: 'ADS-B', getContactCount: () => 0 },
* scaleBar: true,
* });
* overlays.updateCount(42);
*/
const MapUtils = {
/**
* Initialise a Leaflet map with Settings-managed tile layer.
* Adds a canvas fallback grid immediately, then upgrades to the
* configured tile provider asynchronously without blocking.
*
* @param {string} containerId - DOM element id
* @param {Object} [options]
* @param {number[]} [options.center=[20,0]]
* @param {number} [options.zoom=4]
* @param {number} [options.minZoom=2]
* @param {number} [options.maxZoom=18]
* @param {boolean} [options.zoomControl=true]
* @param {boolean} [options.attributionControl=true]
* @returns {L.Map|null}
*/
init(containerId, options = {}) {
const container = document.getElementById(containerId);
if (!container) return null;
// Guard against double init (e.g. back/forward cache restore)
if (container._leaflet_id) return null;
const map = L.map(containerId, {
center: options.center || [20, 0],
zoom: options.zoom ?? 4,
minZoom: options.minZoom ?? 2,
maxZoom: options.maxZoom ?? 18,
zoomControl: options.zoomControl !== false,
attributionControl: options.attributionControl !== false,
});
const fallback = this.createFallbackGridLayer().addTo(map);
this._upgradeTiles(map, fallback);
return map;
},
/**
* Async: replace the fallback canvas grid with the Settings tile layer.
* @private
*/
async _upgradeTiles(map, fallback) {
if (typeof Settings === 'undefined') return;
try {
await Settings.init();
if (!map || map._removed) return;
const layer = Settings.createTileLayer();
let loaded = false;
layer.once('load', () => {
loaded = true;
if (map.hasLayer(fallback)) map.removeLayer(fallback);
});
layer.on('tileerror', () => {
if (!loaded) {
console.warn('MapUtils: tile error — keeping fallback grid');
}
});
layer.addTo(map);
Settings.registerMap(map);
} catch (e) {
console.warn('MapUtils: settings init failed, keeping fallback:', e);
}
},
/**
* Create a zero-network canvas fallback grid layer.
* @returns {L.GridLayer}
*/
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 = '#07090e';
ctx.fillRect(0, 0, 256, 256);
// Major grid lines
ctx.strokeStyle = 'rgba(74,163,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();
// Minor grid lines
ctx.strokeStyle = 'rgba(74,163,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(74,163,255,0.25)';
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} ${coords.x},${coords.y}`, 8, 18);
return tile;
};
return layer;
},
/**
* Add tactical overlays to a map.
*
* @param {L.Map} map
* @param {Object} [options]
* @param {Object} [options.rangeRings]
* { center: [lat,lng], intervals: number[], unit: 'nm'|'km' }
* @param {Object} [options.observerReticle]
* { latlng: [lat,lng] }
* @param {Object} [options.hudPanels]
* { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean }
* @param {boolean} [options.graticule]
* @param {boolean} [options.scaleBar]
*
* @returns {Object} handles
* { updateCount(n), updateStatus(online), showGraticule(), hideGraticule(),
* updateReticle(latlng), removeAll() }
*/
addTacticalOverlays(map, options = {}) {
const handles = {};
const cleanupFns = [];
// --- Scale bar ---
if (options.scaleBar !== false) {
const scale = L.control.scale({ imperial: true, metric: true, position: 'bottomright' });
scale.addTo(map);
cleanupFns.push(() => scale.remove());
}
// --- Range rings ---
let rangeRingsLayer = null;
if (options.rangeRings) {
rangeRingsLayer = this._buildRangeRings(map, options.rangeRings);
}
handles.rangeRingsLayer = rangeRingsLayer;
// --- Observer reticle ---
let reticleMarker = null;
if (options.observerReticle) {
reticleMarker = this._buildReticle(options.observerReticle.latlng);
reticleMarker.addTo(map);
cleanupFns.push(() => map.removeLayer(reticleMarker));
}
handles.updateReticle = (latlng) => {
if (reticleMarker) reticleMarker.setLatLng(latlng);
};
// --- HUD panels ---
let hudHandles = { updateCount: () => {}, updateStatus: () => {} };
if (options.hudPanels) {
hudHandles = this._buildHudPanels(map, options.hudPanels);
cleanupFns.push(() => hudHandles.remove());
}
handles.updateCount = hudHandles.updateCount;
handles.updateStatus = hudHandles.updateStatus;
// --- Graticule ---
let graticuleLayer = null;
const buildGraticule = () => {
if (graticuleLayer) map.removeLayer(graticuleLayer);
graticuleLayer = this._buildGraticule(map);
graticuleLayer.addTo(map);
};
const removeGraticule = () => {
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
};
if (options.graticule) {
buildGraticule();
map.on('zoomend', buildGraticule);
cleanupFns.push(() => {
map.off('zoomend', buildGraticule);
removeGraticule();
});
}
handles.showGraticule = () => {
buildGraticule();
map.on('zoomend', buildGraticule);
};
handles.hideGraticule = () => {
map.off('zoomend', buildGraticule);
removeGraticule();
};
handles.removeAll = () => cleanupFns.forEach(fn => fn());
// Auto-cleanup when Leaflet map is removed
const autoCleanup = () => {
cleanupFns.forEach(fn => fn());
map.off('remove', autoCleanup);
};
map.on('remove', autoCleanup);
const originalRemoveAll = handles.removeAll;
handles.removeAll = () => {
map.off('remove', autoCleanup);
originalRemoveAll();
};
return handles;
},
/**
* Build dashed range rings around a centre point.
* @private
*/
_buildRangeRings(map, opts) {
const { center, intervals, unit = 'nm' } = opts;
const metersPerUnit = unit === 'km' ? 1000 : 1852;
const layer = L.layerGroup();
intervals.forEach(dist => {
const meters = dist * metersPerUnit;
L.circle(center, {
radius: meters,
color: '#4aa3ff',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.3,
dashArray: '4 4',
interactive: false,
}).addTo(layer);
// Label at accurate north point of ring (Leaflet handles earth curvature)
const labelLat = L.circle(center, { radius: meters }).getBounds().getNorth();
L.marker([labelLat, center[1]], {
icon: L.divIcon({
className: 'map-range-label',
html: `<span>${Math.round(dist)} ${unit}</span>`,
iconSize: [50, 14],
iconAnchor: [25, 7],
}),
interactive: false,
}).addTo(layer);
});
layer.addTo(map);
return layer;
},
/**
* Build a crosshair SVG marker.
* @private
*/
_buildReticle(latlng) {
const icon = L.divIcon({
className: 'map-reticle',
html: `<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14" cy="14" r="4" stroke="#4aa3ff" stroke-width="1.5"/>
<line x1="14" y1="2" x2="14" y2="9" stroke="#4aa3ff" stroke-width="1.5"/>
<line x1="14" y1="19" x2="14" y2="26" stroke="#4aa3ff" stroke-width="1.5"/>
<line x1="2" y1="14" x2="9" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
<line x1="19" y1="14" x2="26" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
</svg>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
return L.marker(latlng, { icon, interactive: false, zIndexOffset: -100 });
},
/**
* Build HUD corner panels and attach them to the map container.
* Returns update handles.
* @private
*/
_buildHudPanels(map, opts) {
const { modeName = '', getContactCount = () => 0, getSdrStatus = () => null } = opts;
const container = map.getContainer();
// Top-left: mode name + contact count
const tl = document.createElement('div');
tl.className = 'map-hud-panel map-hud-tl';
tl.innerHTML = `
<span class="map-hud-mode">${modeName}</span>
<span class="map-hud-count">0</span>
`;
container.appendChild(tl);
const countEl = tl.querySelector('.map-hud-count');
// Top-right: UTC clock + SDR status dot
const tr = document.createElement('div');
tr.className = 'map-hud-panel map-hud-tr';
tr.innerHTML = `
<span class="map-hud-clock"></span>
<span class="map-hud-dot"></span>
`;
container.appendChild(tr);
const clockEl = tr.querySelector('.map-hud-clock');
const dotEl = tr.querySelector('.map-hud-dot');
// Clock tick
const updateClock = () => {
if (!document.body.contains(container)) return;
clockEl.textContent = new Date().toISOString().substring(11, 19) + ' UTC';
};
updateClock();
const clockInterval = setInterval(updateClock, 1000);
return {
updateCount(n) {
countEl.textContent = n;
},
updateStatus(online) {
dotEl.className = `map-hud-dot ${online === true ? 'online' : online === false ? 'offline' : ''}`;
},
remove() {
clearInterval(clockInterval);
tl.remove();
tr.remove();
},
};
},
/**
* Build a 10° lat/lon graticule as a Leaflet layer group.
* Only draws lines visible in the current map bounds (+ 10% margin).
* @private
*/
_buildGraticule(map) {
const layer = L.layerGroup();
const bounds = map.getBounds().pad(0.1);
const step = 10;
const style = { color: 'rgba(74,163,255,0.12)', weight: 1, interactive: false };
const latMin = Math.floor(bounds.getSouth() / step) * step;
const latMax = Math.ceil(bounds.getNorth() / step) * step;
const lonMin = Math.floor(bounds.getWest() / step) * step;
const lonMax = Math.ceil(bounds.getEast() / step) * step;
for (let lat = latMin; lat <= latMax; lat += step) {
if (lat < -90 || lat > 90) continue;
L.polyline([[lat, lonMin], [lat, lonMax]], style).addTo(layer);
}
for (let lon = lonMin; lon <= lonMax; lon += step) {
L.polyline([[-90, lon], [90, lon]], style).addTo(layer);
}
return layer;
},
/**
* Return Leaflet popup options for dark-glass style.
* @returns {Object}
*/
glassPopupOptions() {
return { className: 'map-glass-popup', maxWidth: 340 };
},
};
+139 -111
View File
@@ -36,6 +36,8 @@ const BluetoothMode = (function() {
// Device list filter
let currentDeviceFilter = 'all';
let sortBy = 'rssi';
let sortListenersBound = false;
let currentSearchTerm = '';
let visibleDeviceCount = 0;
let pendingDeviceFlush = false;
@@ -118,6 +120,7 @@ const BluetoothMode = (function() {
// Initialize device list filters
initDeviceFilters();
initSortControls();
initListInteractions();
// Set initial panel states
@@ -129,7 +132,7 @@ const BluetoothMode = (function() {
*/
function initDeviceFilters() {
if (filterListenersBound) return;
const filterContainer = document.getElementById('btDeviceFilters');
const filterContainer = document.getElementById('btFilterGroup');
if (filterContainer) {
filterContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.bt-filter-btn');
@@ -158,17 +161,27 @@ const BluetoothMode = (function() {
filterListenersBound = true;
}
function initSortControls() {
if (sortListenersBound) return;
sortListenersBound = true;
const sortGroup = document.getElementById('btSortGroup');
if (!sortGroup) return;
sortGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.bt-sort-btn');
if (!btn) return;
const sort = btn.dataset.sort;
if (!sort) return;
sortBy = sort;
sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderAllDevices();
});
}
function initListInteractions() {
if (listListenersBound) return;
if (deviceContainer) {
deviceContainer.addEventListener('click', (event) => {
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
if (locateBtn) {
event.preventDefault();
locateById(locateBtn.dataset.locateId);
return;
}
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
if (!row) return;
selectDevice(row.dataset.btDeviceId);
@@ -1008,6 +1021,15 @@ const BluetoothMode = (function() {
const statusText = document.getElementById('statusText');
if (statusDot) statusDot.classList.toggle('running', scanning);
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
// Drive the per-panel scan indicator
const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none';
if (scanText) {
scanText.textContent = scanning ? 'SCANNING' : 'IDLE';
scanText.classList.toggle('active', scanning);
}
}
function resetStats() {
@@ -1366,10 +1388,30 @@ const BluetoothMode = (function() {
}
}
/**
* Re-render all devices in the current sort order, then re-apply the active filter.
*/
function renderAllDevices() {
if (!deviceContainer) return;
if (devices.size === 0) return;
deviceContainer.innerHTML = '';
const sorted = [...devices.values()].sort((a, b) => {
if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF');
if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0);
if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999);
return 0;
});
sorted.forEach(device => renderDevice(device, false));
applyDeviceFilter();
if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId);
}
function createSimpleDeviceCard(device) {
const protocol = device.protocol || 'ble';
const rssi = device.rssi_current;
const rssiColor = getRssiColor(rssi);
const inBaseline = device.in_baseline || false;
const isNew = !inBaseline;
const hasName = !!device.name;
@@ -1380,58 +1422,69 @@ const BluetoothMode = (function() {
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
// Signal bar
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
const fillClass = rssi == null ? 'weak'
: rssi >= -60 ? 'strong'
: rssi >= -75 ? 'medium' : 'weak';
const displayName = device.name || formatDeviceId(device.address);
const name = escapeHtml(displayName);
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
const seenCount = device.seen_count || 0;
const searchIndex = [
displayName,
device.address,
device.manufacturer_name,
device.tracker_name,
device.tracker_type,
agentName
displayName, device.address, device.manufacturer_name,
device.tracker_name, device.tracker_type, agentName
].filter(Boolean).join(' ').toLowerCase();
// Protocol badge - compact
// Protocol badge
const protoBadge = protocol === 'ble'
? '<span class="bt-proto-badge ble">BLE</span>'
: '<span class="bt-proto-badge classic">CLASSIC</span>';
// Tracker badge - show if device is detected as tracker
// Tracker badge
let trackerBadge = '';
if (isTracker) {
const confColor = trackerConfidence === 'high' ? '#ef4444' :
trackerConfidence === 'medium' ? '#f97316' : '#eab308';
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' :
trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
const typeLabel = trackerType === 'airtag' ? 'AirTag' :
trackerType === 'tile' ? 'Tile' :
trackerType === 'samsung_smarttag' ? 'SmartTag' :
trackerType === 'findmy_accessory' ? 'FindMy' :
trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
const confColor = trackerConfidence === 'high' ? '#ef4444'
: trackerConfidence === 'medium' ? '#f97316' : '#eab308';
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)'
: trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
const typeLabel = trackerType === 'airtag' ? 'AirTag'
: trackerType === 'tile' ? 'Tile'
: trackerType === 'samsung_smarttag' ? 'SmartTag'
: trackerType === 'findmy_accessory' ? 'FindMy'
: trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor
+ ';font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;">' + typeLabel + '</span>';
}
// IRK badge - show if paired IRK is available
let irkBadge = '';
if (device.has_irk) {
irkBadge = '<span class="bt-irk-badge">IRK</span>';
}
// IRK badge
const irkBadge = device.has_irk ? '<span class="bt-irk-badge">IRK</span>' : '';
// Risk badge - show if risk score is significant
// Risk badge
let riskBadge = '';
if (riskScore >= 0.3) {
const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor + ';font-size:8px;margin-left:4px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor
+ ';font-size:8px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
}
// Status indicator
// MAC cluster badge
const clusterBadge = device.mac_cluster_count > 1
? '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>'
: '';
// Flag badges (top-right, before status dot)
const hFlags = device.heuristic_flags || [];
let flagBadges = '';
if (device.is_persistent || hFlags.includes('persistent'))
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
if (device.is_beacon_like || hFlags.includes('beacon_like'))
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
if (device.is_strong_stable || hFlags.includes('strong_stable'))
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
// Status dot
let statusDot;
if (isTracker && trackerConfidence === 'high') {
statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
@@ -1441,74 +1494,55 @@ const BluetoothMode = (function() {
statusDot = '<span class="bt-status-dot known"></span>';
}
// Distance display
// Bottom meta
const metaLabel = mfr || addr; // already HTML-escaped
const distM = device.estimated_distance_m;
let distStr = '';
if (distM != null) {
distStr = '~' + distM.toFixed(1) + 'm';
}
const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : '';
let metaHtml = '<span>' + metaLabel + '</span>';
if (distStr) metaHtml += '<span>' + distStr + '</span>';
metaHtml += '<span class="bt-row-rssi ' + fillClass + '">' + (rssi != null ? rssi : '—') + '</span>';
if (seenBefore) metaHtml += '<span class="bt-history-badge">SEEN</span>';
if (agentName !== 'Local')
metaHtml += '<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">'
+ escapeHtml(agentName) + '</span>';
// Behavioral flag badges
const hFlags = device.heuristic_flags || [];
let flagBadges = '';
if (device.is_persistent || hFlags.includes('persistent')) {
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
}
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
}
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
}
// Left border colour
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444'
: isTracker ? '#f97316'
: rssi != null && rssi >= -60 ? 'var(--accent-green)'
: rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)'
: 'var(--accent-red)';
// MAC cluster badge
let clusterBadge = '';
if (device.mac_cluster_count > 1) {
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
}
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
if (distStr) secondaryParts.push(distStr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
}
const secondaryInfo = secondaryParts.join(' · ');
// Row border color - highlight trackers in red/orange
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
isTracker ? '#f97316' : rssiColor;
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
'<div class="bt-row-main">' +
'<div class="bt-row-left">' +
protoBadge +
'<span class="bt-device-name">' + name + '</span>' +
trackerBadge +
irkBadge +
riskBadge +
flagBadges +
clusterBadge +
'</div>' +
'<div class="bt-row-right">' +
'<div class="bt-rssi-container">' +
'<div class="bt-rssi-bar-bg"><div class="bt-rssi-bar" style="width:' + rssiPercent + '%;background:' + rssiColor + ';"></div></div>' +
'<span class="bt-rssi-value" style="color:' + rssiColor + ';">' + (rssi != null ? rssi : '--') + '</span>' +
'</div>' +
statusDot +
'</div>' +
'</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' +
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
'<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>' +
'</div>' +
'</div>';
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '"'
+ ' data-bt-device-id="' + escapeAttr(device.device_id) + '"'
+ ' data-is-new="' + isNew + '"'
+ ' data-has-name="' + hasName + '"'
+ ' data-rssi="' + (rssi ?? -100) + '"'
+ ' data-is-tracker="' + isTracker + '"'
+ ' data-search="' + escapeAttr(searchIndex) + '"'
+ ' role="button" tabindex="0" data-keyboard-activate="true"'
+ ' style="border-left-color:' + borderColor + ';">'
// Top line
+ '<div class="bt-row-top">'
+ '<div class="bt-row-top-left">'
+ protoBadge
+ '<span class="bt-row-name' + (hasName ? '' : ' bt-unnamed') + '">' + name + '</span>'
+ trackerBadge + irkBadge + riskBadge + clusterBadge
+ '</div>'
+ '<div class="bt-row-top-right">'
+ flagBadges + statusDot
+ '</div>'
+ '</div>'
// Bottom line
+ '<div class="bt-row-bottom">'
+ '<div class="bt-signal-bar-wrap">'
+ '<div class="bt-signal-track">'
+ '<div class="bt-signal-fill ' + fillClass + '" style="width:' + rssiPercent.toFixed(1) + '%"></div>'
+ '</div>'
+ '</div>'
+ '<div class="bt-row-meta">' + metaHtml + '</div>'
+ '</div>'
+ '</div>';
}
function getRssiColor(rssi) {
@@ -1756,14 +1790,8 @@ const BluetoothMode = (function() {
mac_cluster_count: device.mac_cluster_count || 0
};
// If BtLocate is already loaded, hand off directly
if (typeof BtLocate !== 'undefined') {
BtLocate.handoff(payload);
return;
}
// Switch to bt_locate mode first — this loads the script, styles,
// and initializes the module. Then hand off the device data.
// Always switch to bt_locate mode first (loads script + styles if needed,
// initializes the module), then hand off device data.
if (typeof switchMode === 'function') {
switchMode('bt_locate').then(function() {
if (typeof BtLocate !== 'undefined') {
-5
View File
@@ -1792,11 +1792,6 @@ const BtLocate = (function() {
const irkInput = document.getElementById('btLocateIrk');
if (irkInput) irkInput.value = deviceInfo.irk_hex;
}
// Switch to bt_locate mode
if (typeof switchMode === 'function') {
switchMode('bt_locate');
}
}
function clearHandoff() {
+74 -64
View File
@@ -13,12 +13,14 @@ const SSTV = (function() {
let issMap = null;
let issMarker = null;
let issTrackLine = null;
let issTrackPast = null;
let issPosition = null;
let issUpdateInterval = null;
let countdownInterval = null;
let nextPassData = null;
let pendingMapInvalidate = false;
let locationListenersAttached = false;
let issUpdateInterval = null;
let issTrackInterval = null;
let countdownInterval = null;
let nextPassData = null;
let pendingMapInvalidate = false;
let locationListenersAttached = false;
// ISS frequency
const ISS_FREQ = 145.800;
@@ -93,12 +95,12 @@ const SSTV = (function() {
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
locationListenersAttached = true;
}
}
if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
locationListenersAttached = true;
}
}
/**
* Save location from input fields
@@ -250,12 +252,19 @@ const SSTV = (function() {
// Create ISS marker (will be positioned when we get data)
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
// Create ground track line
// Past track (dimmer, solid)
issTrackPast = L.polyline([], {
color: '#00d4ff',
weight: 1.5,
opacity: 0.3,
}).addTo(issMap);
// Future track (brighter, dashed)
issTrackLine = L.polyline([], {
color: '#00d4ff',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
opacity: 0.7,
dashArray: '6, 4'
}).addTo(issMap);
issMap.on('resize moveend zoomend', () => {
@@ -272,9 +281,12 @@ const SSTV = (function() {
*/
function startIssTracking() {
updateIssPosition();
// Update every 5 seconds
updateIssTrack();
if (issUpdateInterval) clearInterval(issUpdateInterval);
issUpdateInterval = setInterval(updateIssPosition, 5000);
// Track refreshes every 5 minutes — one orbit is ~93 min so this keeps it current
if (issTrackInterval) clearInterval(issTrackInterval);
issTrackInterval = setInterval(updateIssTrack, 5 * 60 * 1000);
}
/**
@@ -285,6 +297,52 @@ const SSTV = (function() {
clearInterval(issUpdateInterval);
issUpdateInterval = null;
}
if (issTrackInterval) {
clearInterval(issTrackInterval);
issTrackInterval = null;
}
}
/**
* Fetch and render the ISS ground track from the backend (TLE-propagated).
*/
async function updateIssTrack() {
try {
const response = await fetch('/sstv/iss-track');
const data = await response.json();
if (data.status !== 'ok' || !issTrackLine || !issTrackPast) return;
const pastPts = [], futurePts = [];
for (const pt of data.track) {
(pt.past ? pastPts : futurePts).push([pt.lat, pt.lon]);
}
// Split future track at antimeridian crossings to avoid long horizontal lines
const futureSegments = _splitAtAntimeridian(futurePts);
const pastSegments = _splitAtAntimeridian(pastPts);
issTrackLine.setLatLngs(futureSegments);
issTrackPast.setLatLngs(pastSegments);
} catch (err) {
console.error('Failed to fetch ISS track:', err);
}
}
/**
* Split an array of [lat, lon] points into segments at antimeridian crossings.
*/
function _splitAtAntimeridian(points) {
const segments = [];
let current = [];
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (current.length > 1) segments.push(current);
current = [];
}
current.push(points[i]);
}
if (current.length > 1) segments.push(current);
return segments;
}
/**
@@ -486,55 +544,7 @@ const SSTV = (function() {
issMarker.setLatLng([lat, lon]);
}
// Calculate and draw ground track
if (issTrackLine) {
const trackPoints = [];
const inclination = 51.6; // ISS orbital inclination in degrees
// Generate orbit track points
for (let offset = -180; offset <= 180; offset += 3) {
let trackLon = lon + offset;
// Normalize longitude
while (trackLon > 180) trackLon -= 360;
while (trackLon < -180) trackLon += 360;
// Calculate latitude based on orbital inclination
const phase = (offset / 360) * 2 * Math.PI;
const currentPhase = Math.asin(Math.max(-1, Math.min(1, lat / inclination)));
let trackLat = inclination * Math.sin(phase + currentPhase);
// Clamp to valid range
trackLat = Math.max(-inclination, Math.min(inclination, trackLat));
trackPoints.push([trackLat, trackLon]);
}
// Split track at antimeridian to avoid line across map
const segments = [];
let currentSegment = [];
for (let i = 0; i < trackPoints.length; i++) {
if (i > 0) {
const prevLon = trackPoints[i - 1][1];
const currLon = trackPoints[i][1];
if (Math.abs(currLon - prevLon) > 180) {
// Crossed antimeridian
if (currentSegment.length > 0) {
segments.push(currentSegment);
}
currentSegment = [];
}
}
currentSegment.push(trackPoints[i]);
}
if (currentSegment.length > 0) {
segments.push(currentSegment);
}
// Use only the longest segment or combine if needed
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
}
// Track is fetched separately by updateIssTrack() via /sstv/iss-track
// Pan map to follow ISS only when the map pane is currently renderable.
if (isMapContainerVisible()) {
+11 -10
View File
@@ -1561,21 +1561,22 @@ const WeatherSat = (function() {
const spans = labels.querySelectorAll('span');
if (spans.length !== hours.length) return;
const tz = typeof InterceptTime !== 'undefined' ? InterceptTime.getTimezone() : 'UTC';
const ianaName = typeof InterceptTime !== 'undefined' ? InterceptTime.getIANA() : undefined;
hours.forEach((h, i) => {
if (selectedTimezone === 'UTC' || selectedTimezone === 'local') {
spans[i].textContent = h === 24 ? '24:00' : `${String(h).padStart(2, '0')}:00`;
if (h === 24) {
spans[i].textContent = '24:00';
return;
}
if (tz === 'UTC' || tz === 'local') {
spans[i].textContent = `${String(h).padStart(2, '0')}:00`;
} else {
// Show timezone-adjusted labels
const d = new Date();
d.setHours(h, 0, 0, 0);
const tz = TZ_MAP[selectedTimezone];
const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
if (tz) opts.timeZone = tz;
if (h === 24) {
spans[i].textContent = '24:00';
} else {
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
}
if (ianaName) opts.timeZone = ianaName;
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
}
});
}
+1917 -1876
View File
File diff suppressed because it is too large Load Diff