From 82121491c5688d35166ee6485eabdc0116b8bbf0 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 13:39:41 +0200 Subject: [PATCH] refactor: convert map feature to strict TypeScript --- .../assets/web/generated/map-core.js | 6077 ++++++++--------- .../assets/web/generated/plugin-loader.js | 16 +- .../trx-frontend-http/frontend/build.mjs | 2 +- .../frontend/package-lock.json | 18 + .../trx-frontend-http/frontend/package.json | 1 + .../frontend/src/{map-core.js => map-core.ts} | 916 ++- .../frontend/src/plugin-loader.ts | 14 +- .../frontend/tests/smoke.test.mjs | 13 + 8 files changed, 3606 insertions(+), 3451 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/frontend/src/{map-core.js => map-core.ts} (78%) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index 8246a1cd..5132f7f9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,3119 +1,3110 @@ "use strict"; -(function() { - "use strict"; - const { state: T, core: C, modules } = window.trx; - const { - saveSetting, - loadSetting, - showHint, - escapeMapHtml, - formatFreq, - formatFreqForHumans, - postPath, - scheduleUiFrameJob, - navigateToTab, - rigBadgeColor, - formatUptime, - latLonToMaidenhead, - locatorToLatLon, - haversineKm, - formatDistanceKm, - formatTimeAgo, - currentDecodeHistoryRetentionMs, - formatWavelength, - bookmarkDistanceText, - buildBookmarkTooltipText, - nearestBookmarkForHz - } = C; - function updateMapRigFilter() { - const el = document.getElementById("map-rig-filter"); - if (!el) return; - const prev = el.value; - while (el.options.length > 1) el.remove(1); - for (const id of T.lastRigIds) { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = T.lastRigDisplayNames[id] || id; - el.appendChild(opt); - } - if (prev && T.lastRigIds.includes(prev)) { - el.value = prev; - } else { - el.value = ""; - mapRigFilter = ""; - } - updateStatsRigFilter(); +(() => { + // src/map-core.ts + function mapEl(id) { + const element = document.querySelector(`#${CSS.escape(id)}`); + if (!element) throw new Error(`Missing map element #${id}`); + return element; } - let aprsMap = null; - let aprsMapBaseLayer = null; - let aprsMapReceiverMarker = null; - let aprsMapReceiverMarkers = {}; - let aprsRadioPaths = []; - let selectedLocatorMarker = null; - let selectedLocatorPulseRaf = null; - let mapFullscreenListenerBound = false; - let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; - let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; - let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; - const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; - const MAP_QSO_SUMMARY_LIMIT = 5; - const stationMarkers = /* @__PURE__ */ new Map(); - const locatorMarkers = /* @__PURE__ */ new Map(); - const decodeContactPaths = /* @__PURE__ */ new Map(); - let selectedMapQsoKey = null; - const mapMarkers = /* @__PURE__ */ new Set(); - const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; - const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; - const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; - let mapSearchFilter = ""; - let mapRigFilter = ""; - let mapHistoryPruneTimer = null; - let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( - Number(loadSetting("mapHistoryLimitMinutes", 1440)) - ); - const APRS_TRACK_MAX_POINTS = 64; - const AIS_TRACK_MAX_POINTS = 64; - const aisMarkers = /* @__PURE__ */ new Map(); - const vdesMarkers = /* @__PURE__ */ new Map(); - let selectedAprsTrackCall = null; - let selectedAisTrackMmsi = null; - const HAM_BANDS = [ - { label: "2200m", meters: 2200 }, - { label: "630m", meters: 630 }, - { label: "160m", meters: 160 }, - { label: "80m", meters: 80 }, - { label: "60m", meters: 60 }, - { label: "40m", meters: 40 }, - { label: "30m", meters: 30 }, - { label: "20m", meters: 20 }, - { label: "17m", meters: 17 }, - { label: "15m", meters: 15 }, - { label: "12m", meters: 12 }, - { label: "10m", meters: 10 }, - { label: "6m", meters: 6 }, - { label: "4m", meters: 4 }, - { label: "3m", meters: 3 }, - { label: "2m", meters: 2 }, - { label: "1m", meters: 1 }, - { label: "70cm", meters: 0.7 }, - { label: "23cm", meters: 0.23 }, - { label: "13cm", meters: 0.13 }, - { label: "9cm", meters: 0.09 }, - { label: "6cm", meters: 0.06 }, - { label: "3cm", meters: 0.03 } - ].map((band) => ({ - ...band, - nominalHz: 299792458 / band.meters - })); - function normalizeLocatorFreqHz(hz) { - if (!Number.isFinite(hz) || hz <= 0) return null; - if (hz >= 1e5) return hz; - const baseHz = Number(window.ft8BaseHz); - if (Number.isFinite(baseHz) && baseHz > 0) { - return baseHz + hz; - } - return hz; - } - function normalizeMapHistoryLimitMinutes(value) { - const minutes = Math.round(Number(value)); - return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; - } - function mapHistoryCutoffMs() { - return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; - } - function trimTrackHistory(history, cutoffMs, maxPoints) { - const list = Array.isArray(history) ? history : []; - const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); - if (trimmed.length > maxPoints) { - trimmed.splice(0, trimmed.length - maxPoints); - } - return trimmed; - } - function refreshAprsTrack(call, entry) { - if (!entry) return; - if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { - if (entry.track) { - entry.track.remove(); - entry.track = null; + var mapWindow = window; + (function() { + "use strict"; + const { state: T, core: C, modules } = mapWindow.trx; + const { + saveSetting, + loadSetting, + escapeMapHtml, + formatFreqForHumans, + scheduleUiFrameJob, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo + } = C; + function updateMapRigFilter() { + const el = mapEl("map-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); } - return; - } - if (entry.track) { - entry.track.setLatLngs(entry.trackPoints); - return; - } - const track = L.polyline(entry.trackPoints, { - color: "#f0be4d", - weight: 2, - opacity: 0.72, - lineCap: "round", - lineJoin: "round", - interactive: false - }); - track.__trxType = "aprs"; - track._aprsCall = call; - entry.track = track; - } - function refreshAisTrack(mmsi, entry) { - if (!entry) return; - if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { - if (entry.track) { - entry.track.remove(); - entry.track = null; + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + mapRigFilter = ""; } - return; + updateStatsRigFilter(); } - if (entry.track) { - entry.track.setLatLngs(entry.trackPoints); - return; - } - const track = L.polyline(entry.trackPoints, { - color: getAisAccentColor(), - weight: 2, - opacity: 0.68, - lineCap: "round", - lineJoin: "round", - interactive: false, - dashArray: "5 4" - }); - track.__trxType = "ais"; - track._aisMmsi = mmsi; - entry.track = track; - } - function removeMapMarker(marker) { - if (!marker) return; - if (marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); - mapMarkers.delete(marker); - } - function setRetainedMapMarkerVisible(marker, visible) { - if (!marker) return; - marker.__trxHistoryVisible = visible !== false; - if (!visible) { - if (marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); - } - } - function ensureAprsMarker(call, entry) { - if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; - _aprsAddMarkerToMap(call, entry); - } - function ensureAisMarker(key, entry) { - if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; - const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); - marker.__trxType = "ais"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._aisMmsi = String(key); - entry.marker = marker; - mapMarkers.add(marker); - } - function ensureVdesMarker(key, entry) { - if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; - const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { - radius: 5, - color: "#5c394f", - fillColor: "#c46392", - fillOpacity: 0.82 - }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); - marker.__trxType = "vdes"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._vdesKey = String(key); - entry.marker = marker; - mapMarkers.add(marker); - } - function ensureDecodeLocatorMarker(entry) { - if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; - const bounds = maidenheadToBounds(entry.grid); - if (!bounds) return; - const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); - const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); - const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); - marker.__trxType = entry.sourceType; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - sendLocatorOverlayToBack(marker); - assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); - entry.marker = marker; - mapMarkers.add(marker); - } - function pruneAprsEntry(call, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - const pktTsMs = Number(entry?.pkt?._tsMs); - const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); - if (canRenderMap) { - refreshAprsTrack(call, entry); - } else { - C.markDecodeMapSyncPending(); - } - if (!visible) { - if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { - selectedAprsTrackCall = null; - } - if (canRenderMap && entry?.track) { - entry.track.remove(); - entry.track = null; - } - if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); - return false; - } - if (!canRenderMap) return true; - ensureAprsMarker(call, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { - entry.marker.setLatLng([entry.lat, entry.lon]); - entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); - } - return true; - } - function pruneAisEntry(key, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - const msgTsMs = Number(entry?.msg?._tsMs); - const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); - if (canRenderMap) { - refreshAisTrack(key, entry); - } else { - C.markDecodeMapSyncPending(); - } - if (!visible) { - if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { - selectedAisTrackMmsi = null; - } - if (canRenderMap && entry?.track) { - entry.track.remove(); - entry.track = null; - } - if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); - return false; - } - if (!canRenderMap) return true; - ensureAisMarker(key, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { - updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); - } - return true; - } - function pruneLocatorEntry(key, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; - if (!(entry.allStationDetails instanceof Map)) { - entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); - } - const nextDetails = /* @__PURE__ */ new Map(); - for (const [detailKey, detail] of entry.allStationDetails.entries()) { - const tsMs = Number(detail?.ts_ms); - if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { - nextDetails.set(detailKey, detail); - } - } - entry.visibleInHistoryWindow = nextDetails.size > 0; - if (nextDetails.size === 0) { - entry.stationDetails = /* @__PURE__ */ new Map(); - entry.stations = /* @__PURE__ */ new Set(); - entry.bandMeta = /* @__PURE__ */ new Map(); - if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); - else C.markDecodeMapSyncPending(); - return false; - } - const nextStations = /* @__PURE__ */ new Set(); - for (const detail of nextDetails.values()) { - const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (source) nextStations.add(source); - } - entry.stationDetails = nextDetails; - entry.stations = nextStations; - entry.bandMeta = collectBandMeta( - Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz)) + let aprsMap = null; + let aprsMapBaseLayer = null; + const aprsMapReceiverMarkers = {}; + let aprsRadioPaths = []; + let selectedLocatorMarker = null; + let selectedLocatorPulseRaf = null; + let mapFullscreenListenerBound = false; + let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; + let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; + let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; + const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; + const MAP_QSO_SUMMARY_LIMIT = 5; + const stationMarkers = /* @__PURE__ */ new Map(); + const locatorMarkers = /* @__PURE__ */ new Map(); + const decodeContactPaths = /* @__PURE__ */ new Map(); + let selectedMapQsoKey = null; + const mapMarkers = /* @__PURE__ */ new Set(); + const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; + const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; + let mapSearchFilter = ""; + let mapRigFilter = ""; + let mapHistoryPruneTimer = null; + let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( + Number(loadSetting("mapHistoryLimitMinutes", 1440)) ); - const count = Math.max(nextDetails.size, nextStations.size || 0, 1); - if (!canRenderMap) { - C.markDecodeMapSyncPending(); - return true; - } - ensureDecodeLocatorMarker(entry); - setRetainedMapMarkerVisible(entry.marker, true); - if (entry.marker) { - entry.marker.setStyle(locatorStyleForEntry(entry, count)); - entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); - } - return true; - } - function pruneMapHistory() { - const cutoffMs = mapHistoryCutoffMs(); - for (const [call, entry] of stationMarkers.entries()) { - pruneAprsEntry(call, entry, cutoffMs); - } - for (const [key, entry] of aisMarkers.entries()) { - pruneAisEntry(key, entry, cutoffMs); - } - for (const [key, entry] of vdesMarkers.entries()) { - const tsMs = Number(entry?.msg?._tsMs); - const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - if (!visible) { - setRetainedMapMarkerVisible(entry?.marker, false); - continue; - } - ensureVdesMarker(key, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { - entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); - entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); - } - } - for (const [key, entry] of locatorMarkers.entries()) { - pruneLocatorEntry(key, entry, cutoffMs); - } - if (!aprsMap || T.decodeHistoryReplayActive) { - C.markDecodeMapSyncPending(); - return; - } - rebuildDecodeContactPaths(); - rebuildMapLocatorFilters(); - applyMapFilter(); - } - function locatorSourceLabel(type) { - if (type === "bookmark") return "Bookmarks"; - if (type === "wspr") return "WSPR"; - if (type === "ft4") return "FT4"; - if (type === "ft2") return "FT2"; - return "FT8"; - } - function mapSourceLabel(type) { - if (type === "bookmark") return "Bookmarks"; - return String(type || "").toUpperCase(); - } - function locatorFilterColor(type) { - const hues = locatorThemeHues(); - const lightTheme = C.currentTheme() === "light"; - const sat = lightTheme ? 66 : 76; - const light = lightTheme ? 42 : 56; - const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; - return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; - } - function mapSourceColor(type) { - if (type === "ais") return "#38bdf8"; - if (type === "vdes") return "#a78bfa"; - if (type === "sat") return "#f59e0b"; - if (type === "aprs") return "#00d17f"; - return locatorFilterColor(type); - } - function bandForHz(hz) { - const rfHz = normalizeLocatorFreqHz(hz); - if (!Number.isFinite(rfHz) || rfHz <= 0) return null; - let bestBand = null; - let bestDistance = Infinity; - for (const band of HAM_BANDS) { - const distance = Math.abs(Math.log(rfHz / band.nominalHz)); - if (distance < bestDistance) { - bestDistance = distance; - bestBand = band; - } - } - return bestBand; - } - function collectBandMeta(freqs) { - const out = /* @__PURE__ */ new Map(); - if (!Array.isArray(freqs)) return out; - for (const hz of freqs) { - const band = bandForHz(hz); - if (band && !out.has(band.label)) out.set(band.label, band.nominalHz); - } - return out; - } - function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { - if (!marker) return; - const safeMeta = bandMeta instanceof Map ? bandMeta : /* @__PURE__ */ new Map(); - marker._locatorFilterMeta = { - sourceType, - bands: new Set(safeMeta.keys()), - bandMeta: new Map(safeMeta) - }; - } - function parseMapColor(input) { - const value = String(input || "").trim(); - if (!value) return null; - const hex = value.match(/^#([0-9a-f]{3,8})$/i); - if (hex) { - const raw = hex[1]; - if (raw.length === 3 || raw.length === 4) { - const chars = raw.split(""); - return { - r: parseInt(chars[0] + chars[0], 16), - g: parseInt(chars[1] + chars[1], 16), - b: parseInt(chars[2] + chars[2], 16) - }; - } - if (raw.length === 6 || raw.length === 8) { - return { - r: parseInt(raw.slice(0, 2), 16), - g: parseInt(raw.slice(2, 4), 16), - b: parseInt(raw.slice(4, 6), 16) - }; - } - } - const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i); - if (rgb) { - return { - r: Math.max(0, Math.min(255, Number(rgb[1]))), - g: Math.max(0, Math.min(255, Number(rgb[2]))), - b: Math.max(0, Math.min(255, Number(rgb[3]))) - }; - } - return null; - } - function rgbToHsl(rgb) { - if (!rgb) return null; - const r = rgb.r / 255; - const g = rgb.g / 255; - const b = rgb.b / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - if (max === min) { - return { h: 0, s: 0, l: l * 100 }; - } - const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - let h; - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0); - break; - case g: - h = (b - r) / d + 2; - break; - default: - h = (r - g) / d + 4; - break; - } - return { h: h * 60 % 360, s: s * 100, l: l * 100 }; - } - function wrapHue(hue) { - const value = Number(hue) || 0; - return (value % 360 + 360) % 360; - } - function paletteHue(input, fallback) { - const hsl = rgbToHsl(parseMapColor(input)); - return Number.isFinite(hsl?.h) ? hsl.h : fallback; - } - function locatorThemeHues() { - const pal = C.canvasPalette(); - const baseHue = paletteHue(pal?.spectrumLine, 145); - const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); - const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); - return { - bookmark: wrapHue(baseHue), - ft8: wrapHue(peakHue), - ft4: wrapHue(peakHue + 30), - ft2: wrapHue(peakHue + 60), - wspr: wrapHue((waveHue + baseHue) / 2), - bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) - }; - } - function locatorBandIndex(label) { - const idx = HAM_BANDS.findIndex((band) => band.label === label); - return idx >= 0 ? idx : 0; - } - function locatorBandChipColor(label) { - const hues = locatorThemeHues(); - const lightTheme = C.currentTheme() === "light"; - const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); - const sat = lightTheme ? 68 : 78; - const light = lightTheme ? 44 : 58; - return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; - } - function locatorBandLabelForEntry(entry) { - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : /* @__PURE__ */ new Map(); - if (meta.size === 0) return null; - if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { - for (const label of mapLocatorFilter.bands) { - if (meta.has(label)) return label; - } - } - let bestLabel = null; - let bestHz = -Infinity; - for (const [label, hz] of meta.entries()) { - const value = Number.isFinite(hz) ? Number(hz) : 0; - if (value > bestHz) { - bestHz = value; - bestLabel = label; - } - } - return bestLabel; - } - function locatorHueForEntry(entry) { - const hues = locatorThemeHues(); - if (mapLocatorFilter.phase === "band") { - const label = locatorBandLabelForEntry(entry); - if (label) { - return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); - } - } - if (entry?.sourceType === "bookmark") return hues.bookmark; - if (entry?.sourceType === "wspr") return hues.wspr; - if (entry?.sourceType === "ft4") return hues.ft4; - if (entry?.sourceType === "ft2") return hues.ft2; - return hues.ft8; - } - function locatorStyleForEntry(entry, count) { - const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); - const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); - const hue = locatorHueForEntry(entry); - const lightTheme = C.currentTheme() === "light"; - const strokeSat = lightTheme ? 62 : 74; - const fillSat = lightTheme ? 68 : 78; - const strokeLight = lightTheme ? 40 : 56; - const fillLight = lightTheme ? 60 : 42; - return { - color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`, - opacity: 0.42 + intensity * 0.5, - weight: 1 + intensity * 1.2, - fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, - fillOpacity: 0.16 + intensity * 0.34 - }; - } - function locatorEntryCount(entry) { - if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); - if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); - if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); - return 1; - } - function locatorEntryForMarker(marker) { - if (!marker) return null; - for (const entry of locatorMarkers.values()) { - if (entry?.marker === marker) return entry; - } - return null; - } - function syncLocatorMarkerStyles() { - for (const entry of locatorMarkers.values()) { - if (!entry?.marker) continue; - entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry))); - } - for (const entry of decodeContactPaths.values()) { - if (!entry?.line) continue; - const color = decodeContactPathColor(entry); - entry.line.setStyle({ color, opacity: 0.78 }); - } - } - function stopSelectedLocatorPulse() { - if (selectedLocatorPulseRaf != null) { - cancelAnimationFrame(selectedLocatorPulseRaf); - selectedLocatorPulseRaf = null; - } - } - function startSelectedLocatorPulse(marker) { - stopSelectedLocatorPulse(); - if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; - const tick = (ts) => { - if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { - return; - } - const entry = locatorEntryForMarker(marker); - const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); - const phase = ts % 1600 / 1600; - const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; - marker.setStyle({ - ...base, - opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), - weight: (base.weight || 1.8) + 1.8 * wave - }); - selectedLocatorPulseRaf = requestAnimationFrame(tick); - }; - selectedLocatorPulseRaf = requestAnimationFrame(tick); - } - function clearMapRadioPath() { - for (const p of aprsRadioPaths) p.remove(); - aprsRadioPaths = []; - } - function clearDecodeContactPathRender(entry) { - if (!entry) return; - if (entry.line) { - entry.line.remove(); - entry.line = null; - } - if (entry.labelMarker) { - entry.labelMarker.remove(); - entry.labelMarker = null; - } - } - function clearDecodeContactPaths() { - for (const entry of decodeContactPaths.values()) { - clearDecodeContactPathRender(entry); - } - decodeContactPaths.clear(); - updateMapPathsAnimationClass(); - } - const MAP_PATHS_STATIC_THRESHOLD = 20; - function updateMapPathsAnimationClass() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; - mapEl.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); - } - function formatDecodeContactDistance(distanceKm) { - const text = formatDistanceKm(distanceKm); - return text || "--"; - } - function decodeLocatorPathVisibility(grid) { - const normalizedGrid = String(grid || "").trim().toUpperCase(); - if (!normalizedGrid || !aprsMap) return false; - for (const entry of locatorMarkers.values()) { - if (!entry || entry.grid !== normalizedGrid) continue; - if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; - if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; - } - return false; - } - function midpointLatLon(a, b) { - if (!a || !b) return null; - if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { - return null; - } - return { - lat: (a.lat + b.lat) / 2, - lon: (a.lon + b.lon) / 2 - }; - } - function decodeContactPathColor(entry) { - if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); - const srcEntry = locatorMarkers.get(entry?.sourceGrid); - if (srcEntry) { - const label = locatorBandLabelForEntry(srcEntry); - if (label) return locatorBandChipColor(label); - return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color; - } - return locatorFilterColor("ft8"); - } - function ensureDecodeContactPathRendered(entry) { - if (!entry || !aprsMap) return; - const linePoints = [ - [entry.from.lat, entry.from.lon], - [entry.to.lat, entry.to.lon] - ]; - const color = decodeContactPathColor(entry); - if (!entry.line) { - entry.line = L.polyline(linePoints, { - color, - opacity: 0.78, - className: "decode-contact-path", - weight: 2.8, - interactive: false - }).addTo(aprsMap); - } else { - entry.line.setLatLngs(linePoints); - entry.line.setStyle({ color, opacity: 0.78 }); - if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); - } - const mid = midpointLatLon(entry.from, entry.to); - if (!mid) return; - const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; - const icon = L.divIcon({ - className: "decode-contact-distance-label", - html: `${escapeMapHtml(entry.distanceText)}` - }); - if (!entry.labelMarker) { - entry.labelMarker = L.marker([mid.lat, mid.lon], { - icon, - interactive: false, - keyboard: false, - zIndexOffset: 900 - }).addTo(aprsMap); - } else { - entry.labelMarker.setLatLng([mid.lat, mid.lon]); - entry.labelMarker.setIcon(icon); - if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); - } - if (typeof entry.line.bringToBack === "function") entry.line.bringToBack(); - } - function decodeContactPathMatchesCurrentMap(entry) { - return decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); - } - function decodeContactPathRenderVisible(entry) { - return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); - } - function syncDecodeContactPathVisibility() { - if (selectedMapQsoKey) { - const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); - if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) { - selectedMapQsoKey = null; - } - } - for (const entry of decodeContactPaths.values()) { - const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); - if (!visible) { - clearDecodeContactPathRender(entry); - continue; - } - ensureDecodeContactPathRendered(entry); - } - scheduleStatsRender(); - updateMapPathsAnimationClass(); - } - function _resolveReceiverLocations(rigIds) { - const seen = /* @__PURE__ */ new Set(); - const locations = []; - if (rigIds && rigIds.size) { - for (const rid of rigIds) { - const rig = T.serverRigs.find((r) => r.remote === rid); - if (rig && rig.latitude != null && rig.longitude != null) { - const key = _receiverLocationKey(rig.latitude, rig.longitude); - if (!seen.has(key)) { - seen.add(key); - locations.push([rig.latitude, rig.longitude]); - } - } - } - } - if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { - locations.push([T.serverLat, T.serverLon]); - } - return locations; - } - function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { - clearMapRadioPath(); - if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { - return; - } - const sources = _resolveReceiverLocations(rigIds); - for (const src of sources) { - aprsRadioPaths.push( - L.polyline( - [src, [lat, lon]], - { color, opacity: 0.85, weight: 2, interactive: false, className } - ).addTo(aprsMap) - ); - } - } - function locatorMarkerCenter(marker) { - if (!marker) return null; - if (typeof marker.getBounds === "function") { - const bounds = marker.getBounds(); - if (bounds && typeof bounds.getCenter === "function") { - const center = bounds.getCenter(); - if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { - return { lat: center.lat, lon: center.lng }; - } - } - } - if (typeof marker.getLatLng === "function") { - const ll = marker.getLatLng(); - if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { - return { lat: ll.lat, lon: ll.lng }; - } - } - return null; - } - function setLocatorMarkerHighlight(marker, enabled) { - const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; - if (!element) return; - element.classList.toggle("trx-locator-selected", !!enabled); - } - function setSelectedLocatorMarker(marker) { - if (selectedLocatorMarker && selectedLocatorMarker !== marker) { - setLocatorMarkerHighlight(selectedLocatorMarker, false); - const prevEntry = locatorEntryForMarker(selectedLocatorMarker); - if (prevEntry?.marker) { - prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry))); - } - } - stopSelectedLocatorPulse(); - selectedLocatorMarker = marker || null; - if (selectedLocatorMarker) { - setLocatorMarkerHighlight(selectedLocatorMarker, true); - startSelectedLocatorPulse(selectedLocatorMarker); - } - } - function isLocatorOverlay(marker) { - const type = marker?.__trxType; - return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; - } - function sendLocatorOverlayToBack(marker) { - if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; - marker.bringToBack(); - } - function renderMapLocatorChipRow(container, items, selectedSet, kind) { - if (!container) return; - container.replaceChildren(); - if (!Array.isArray(items) || items.length === 0) { - container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`; - return; - } - let helperText = ""; - const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; - const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); - if (kind === "source") { - if (noneSelected) { - helperText = "All sources visible — click to filter"; - } - } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { - helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; - } - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "map-locator-chip"; - const isActive = kind === "source" ? !!mapFilter[item.key] : selectedSet.has(item.key); - if (kind === "source" && noneSelected) { - btn.classList.add("is-default"); - } else if (!isActive) { - btn.classList.add("is-inactive"); - } - btn.dataset.filterKind = kind; - btn.dataset.filterKey = item.key; - btn.style.setProperty("--chip-color", item.color); - btn.innerHTML = `${escapeMapHtml(item.label)}`; - container.appendChild(btn); - } - if (helperText) { - const hint = document.createElement("span"); - hint.className = "map-locator-empty"; - hint.textContent = helperText; - container.appendChild(hint); - } - } - function renderMapLocatorPhaseRow(container, phase) { - if (!container) return; - container.replaceChildren(); - const phases = [ - { key: "type", label: "Source" }, - { key: "band", label: "Band" } - ]; - for (const item of phases) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "map-locator-phase-btn"; - if (phase === item.key) btn.classList.add("is-active"); - btn.dataset.phase = item.key; - btn.textContent = item.label; - container.appendChild(btn); - } - } - function renderMapLocatorLegend(phase, sourceItems, bandItems) { - const legendEl = document.getElementById("map-band-legend"); - if (!legendEl) return; - const isSourcePhase = phase === "type"; - const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; - if (items.length === 0) { - legendEl.classList.add("is-empty"); - legendEl.replaceChildren(); - return; - } - legendEl.classList.remove("is-empty"); - const rows = items.map((item) => { - const label = escapeMapHtml(item.label); - const color = escapeMapHtml(item.color); - return `${label}`; - }).join(""); - const title = isSourcePhase ? "Source Colors" : "Band Colors"; - legendEl.innerHTML = `
${title}
${rows}
`; - } - window.enableMapSourceFilter = function(key) { - if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { - mapFilter[key] = true; - rebuildMapLocatorFilters(); - applyMapFilter(); - } - }; - function rebuildMapLocatorFilters() { - const phaseEl = document.getElementById("map-locator-phase"); - const choiceEl = document.getElementById("map-locator-choice-filter"); - const choiceLabelEl = document.getElementById("map-locator-choice-label"); - const availableSources = /* @__PURE__ */ new Set(); - for (const entry of aisMarkers.values()) { - if (entry?.visibleInHistoryWindow) { - availableSources.add("ais"); - break; - } - } - for (const entry of vdesMarkers.values()) { - if (entry?.visibleInHistoryWindow) { - availableSources.add("vdes"); - break; - } - } - for (const entry of stationMarkers.values()) { - if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { - availableSources.add("aprs"); - break; - } - } - const bandMap = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - const sourceType = entry?.sourceType; - if (!sourceType) continue; - if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; - availableSources.add(sourceType); - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : /* @__PURE__ */ new Map(); - for (const [label, hz] of meta.entries()) { - if (!bandMap.has(label)) { - bandMap.set(label, { - key: label, - label, - color: locatorBandChipColor(label), - kind: "band", - sortHz: Number.isFinite(hz) ? hz : 0 - }); - continue; - } - const existing = bandMap.get(label); - if (existing && Number.isFinite(hz) && (!Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { - existing.sortHz = hz; - } - if (existing && !existing.color) { - existing.color = locatorBandChipColor(label); - } - } - } - for (const key of Array.from(mapLocatorFilter.bands)) { - if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); - } - const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ - key, - label: mapSourceLabel(key), - color: mapSourceColor(key), - kind: "source" + const APRS_TRACK_MAX_POINTS = 64; + const AIS_TRACK_MAX_POINTS = 64; + const aisMarkers = /* @__PURE__ */ new Map(); + const vdesMarkers = /* @__PURE__ */ new Map(); + let selectedAprsTrackCall = null; + let selectedAisTrackMmsi = null; + const HAM_BANDS = [ + { label: "2200m", meters: 2200 }, + { label: "630m", meters: 630 }, + { label: "160m", meters: 160 }, + { label: "80m", meters: 80 }, + { label: "60m", meters: 60 }, + { label: "40m", meters: 40 }, + { label: "30m", meters: 30 }, + { label: "20m", meters: 20 }, + { label: "17m", meters: 17 }, + { label: "15m", meters: 15 }, + { label: "12m", meters: 12 }, + { label: "10m", meters: 10 }, + { label: "6m", meters: 6 }, + { label: "4m", meters: 4 }, + { label: "3m", meters: 3 }, + { label: "2m", meters: 2 }, + { label: "1m", meters: 1 }, + { label: "70cm", meters: 0.7 }, + { label: "23cm", meters: 0.23 }, + { label: "13cm", meters: 0.13 }, + { label: "9cm", meters: 0.09 }, + { label: "6cm", meters: 0.06 }, + { label: "3cm", meters: 0.03 } + ].map((band) => ({ + ...band, + nominalHz: 299792458 / band.meters })); - const bandItems = Array.from(bandMap.values()).sort((a, b) => b.sortHz - a.sortHz || a.label.localeCompare(b.label)); - renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); - if (!phaseEl || !choiceEl || !choiceLabelEl) return; - renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); - if (mapLocatorFilter.phase === "band") { - choiceLabelEl.textContent = "Visible Bands"; - renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); - } else { - choiceLabelEl.textContent = "Visible Sources"; - renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); - } - syncLocatorMarkerStyles(); - syncDecodeContactPathVisibility(); - } - function markerPassesLocatorFilters(marker) { - const meta = marker?._locatorFilterMeta; - if (!meta) return true; - if (mapLocatorFilter.phase === "band") { - if (mapLocatorFilter.bands.size === 0) return true; - if (!(meta.bands instanceof Set)) return false; - for (const label of mapLocatorFilter.bands) { - if (meta.bands.has(label)) return true; + function normalizeLocatorFreqHz(hz) { + if (!Number.isFinite(hz) || hz <= 0) return null; + if (hz >= 1e5) return hz; + const baseHz = Number(mapWindow.ft8BaseHz); + if (Number.isFinite(baseHz) && baseHz > 0) { + return baseHz + hz; } - return false; + return hz; } - return true; - } - function markerSearchText(marker) { - const type = marker?.__trxType; - if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { - const entry = locatorEntryForMarker(marker); - const parts = []; - if (entry?.grid) parts.push(entry.grid); - if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); - if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); - if (Array.isArray(entry?.bookmarks)) { - for (const bm of entry.bookmarks) { - if (bm?.name) parts.push(String(bm.name)); - if (bm?.locator) parts.push(String(bm.locator)); - if (bm?.mode) parts.push(String(bm.mode)); - if (bm?.category) parts.push(String(bm.category)); - if (bm?.comment) parts.push(String(bm.comment)); - if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz)))); + function normalizeMapHistoryLimitMinutes(value) { + const minutes = Math.round(Number(value)); + return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; + } + function mapHistoryCutoffMs() { + return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; + } + function trimTrackHistory(history, cutoffMs, maxPoints) { + const list = Array.isArray(history) ? history : []; + const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); + if (trimmed.length > maxPoints) { + trimmed.splice(0, trimmed.length - maxPoints); + } + return trimmed; + } + function refreshAprsTrack(call, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; } + return; } - if (entry?.stations instanceof Set) { - parts.push(...Array.from(entry.stations.values()).map((v) => String(v))); + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; } - if (entry?.stationDetails instanceof Map) { - for (const detail of entry.stationDetails.values()) { - if (detail?.station) parts.push(String(detail.station)); - if (detail?.message) parts.push(String(detail.message)); - if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz)))); - } - } - return parts.join(" ").toLowerCase(); - } - if (type === "aprs") { - const call = marker?._aprsCall ? String(marker._aprsCall) : ""; - const entry = stationMarkers.get(call); - const info = entry?.info ? String(entry.info) : ""; - const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; - return `${call} ${info} ${pktRaw}`.toLowerCase(); - } - if (type === "ais") { - const key = marker?._aisMmsi ? String(marker._aisMmsi) : ""; - const msg = aisMarkers.get(key)?.msg; - return [ - key, - msg?.name, - msg?.callsign, - msg?.destination, - Number.isFinite(msg?.mmsi) ? String(msg.mmsi) : "", - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "" - ].join(" ").toLowerCase(); - } - if (type === "vdes") { - const key = marker?._vdesKey ? String(marker._vdesKey) : ""; - const msg = vdesMarkers.get(key)?.msg; - return [ - key, - msg?.name, - msg?.mmsi, - msg?.message, - msg?.raw, - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "" - ].join(" ").toLowerCase(); - } - return ""; - } - function markerPassesSearchFilter(marker) { - const query = String(mapSearchFilter || "").trim().toLowerCase(); - if (!query) return true; - const terms = query.split(/\s+/).filter(Boolean); - if (terms.length === 0) return true; - const haystack = markerSearchText(marker); - if (!haystack) return false; - return terms.every((term) => haystack.includes(term)); - } - function _receiverLocationKey(lat, lon) { - return lat.toFixed(6) + "," + lon.toFixed(6); - } - function syncAprsReceiverMarker() { - if (!aprsMap) return; - const locGroups = {}; - const activeId = T.lastActiveRigId || T.serverActiveRigId || null; - for (const rig of T.serverRigs) { - if (!rig || !rig.remote) continue; - const lat = rig.latitude, lon = rig.longitude; - if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; - const key = _receiverLocationKey(lat, lon); - if (!locGroups[key]) locGroups[key] = { lat, lon, rigs: [], hasActive: false }; - locGroups[key].rigs.push(rig.remote); - if (rig.remote === activeId) locGroups[key].hasActive = true; - } - if (T.serverLat != null && T.serverLon != null) { - const key = _receiverLocationKey(T.serverLat, T.serverLon); - if (!locGroups[key]) locGroups[key] = { lat: T.serverLat, lon: T.serverLon, rigs: [], hasActive: true }; - if (!locGroups[key].hasActive) locGroups[key].hasActive = true; - } - const seen = /* @__PURE__ */ new Set(); - let didInitialView = false; - for (const [key, group] of Object.entries(locGroups)) { - seen.add(key); - const latLng = [group.lat, group.lon]; - const isActive = group.hasActive; - let m = aprsMapReceiverMarkers[key]; - if (!m) { - m = L.circleMarker(latLng, { - radius: isActive ? 8 : 6, - className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), - fillOpacity: isActive ? 0.8 : 0.6 - }).addTo(aprsMap).bindPopup(""); - m._receiverLocKey = key; - m._receiverRigs = group.rigs; - aprsMapReceiverMarkers[key] = m; - if (isActive && !didInitialView) { - aprsMap.setView(latLng, Math.max(1, T.initialMapZoom)); - didInitialView = true; - } - } else { - m.setLatLng(latLng); - m._receiverRigs = group.rigs; - m.setRadius(isActive ? 8 : 6); - if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); - } - if (isActive) aprsMapReceiverMarker = m; - } - for (const key of Object.keys(aprsMapReceiverMarkers)) { - if (!seen.has(key)) { - const m = aprsMapReceiverMarkers[key]; - if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); - delete aprsMapReceiverMarkers[key]; - } - } - if (!seen.size) aprsMapReceiverMarker = null; - } - const satOverlays = /* @__PURE__ */ new Map(); - let satOverlaySeq = 0; - window.addSatMapOverlay = function(msg) { - if (!msg || !msg.geo_bounds || !msg.path) return; - const bounds = msg.geo_bounds; - if (!Array.isArray(bounds) || bounds.length !== 4) return; - const latLngBounds = L.latLngBounds( - [bounds[0], bounds[1]], - // SW - [bounds[2], bounds[3]] - // NE - ); - const key = "sat-" + ++satOverlaySeq; - const overlay = L.imageOverlay(msg.path, latLngBounds, { - opacity: 0.55, - interactive: true, - zIndex: 300 - }); - overlay.__trxType = "sat"; - overlay.__trxSatKey = key; - overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); - overlay.__trxHistoryVisible = true; - mapMarkers.add(overlay); - const decoder = "Meteor LRPT"; - const satellite = msg.satellite || "Unknown"; - const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; - overlay.bindPopup( - `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` - ); - let track = null; - if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { - const latlngs = msg.ground_track.map(function(pt) { - return [pt[0], pt[1]]; - }); - track = L.polyline(latlngs, { - color: mapSourceColor("sat"), + const track = L.polyline(entry.trackPoints, { + color: "#f0be4d", weight: 2, - opacity: 0.7, - dashArray: "6, 4" + opacity: 0.72, + lineCap: "round", + lineJoin: "round", + interactive: false }); - track.__trxType = "sat"; - track.__trxSatKey = key; - track.__trxRigIds = overlay.__trxRigIds; - track.__trxHistoryVisible = true; - mapMarkers.add(track); - if (aprsMap) { - track.addTo(aprsMap); - } + track.__trxType = "aprs"; + track._aprsCall = call; + entry.track = track; } - satOverlays.set(key, { overlay, track, msg }); - if (aprsMap) { - overlay.addTo(aprsMap); - } - applyMapFilter(); - }; - window.removeSatMapOverlay = function(key) { - const entry = satOverlays.get(key); - if (!entry) return; - if (entry.overlay) { - mapMarkers.delete(entry.overlay); - if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap); - } - if (entry.track) { - mapMarkers.delete(entry.track); - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - } - satOverlays.delete(key); - }; - window.clearSatMapOverlays = function() { - for (const [key] of satOverlays) { - window.removeSatMapOverlay(key); - } - }; - window.clearMapMarkersByType = function(type) { - if (type === "aprs") { - selectedAprsTrackCall = null; - stationMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); + function refreshAisTrack(mmsi, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; } - if (entry && entry.track) { - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - mapMarkers.delete(entry.track); - } - }); - stationMarkers.clear(); - return; - } - if (type === "ais") { - aisMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - if (entry && entry.track) { - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - mapMarkers.delete(entry.track); - } - }); - selectedAisTrackMmsi = null; - aisMarkers.clear(); - return; - } - if (type === "vdes") { - vdesMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - }); - vdesMarkers.clear(); - return; - } - if (type === "sat") { - window.clearSatMapOverlays(); - return; - } - if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { - const prefix = `${type}:`; - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith(prefix)) continue; - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - locatorMarkers.delete(key); - } - rebuildMapLocatorFilters(); - rebuildDecodeContactPaths(); - } - if (type === "bookmark") { - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith("bookmark:")) continue; - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - locatorMarkers.delete(key); - } - rebuildMapLocatorFilters(); - } - }; - function mapTileSpecForTheme(theme) { - if (theme === "dark") { - return { - url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - options: { - maxZoom: 19, - subdomains: "abcd", - attribution: '© OpenStreetMap © CARTO' - } - }; - } - return { - url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - options: { - maxZoom: 19, - attribution: '© OpenStreetMap' - } - }; - } - function updateMapBaseLayerForTheme(theme) { - if (!aprsMap) return; - if (aprsMapBaseLayer) { - aprsMap.removeLayer(aprsMapBaseLayer); - aprsMapBaseLayer = null; - } - const spec = mapTileSpecForTheme(theme); - aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); - } - function mapStageEl() { - return document.getElementById("map-stage"); - } - function mapIsFullscreen() { - const stage = mapStageEl(); - if (!stage) return false; - return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); - } - function mapExitFakeFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - stage.classList.remove("map-fake-fullscreen"); - document.body.classList.remove("map-fake-fullscreen-active"); - } - function mapEnterFakeFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - stage.classList.add("map-fake-fullscreen"); - document.body.classList.add("map-fake-fullscreen-active"); - } - function updateMapFullscreenButton() { - const btn = document.getElementById("map-fullscreen-btn"); - if (!btn) return; - btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; - } - function applyMapOverlayPanelVisibility() { - const panel = document.querySelector("#map-stage .map-overlay-panel"); - if (!panel) return; - panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); - } - function updateMapOverlayToggleButton() { - const btn = document.getElementById("map-overlay-toggle-btn"); - if (!btn) return; - btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; - } - async function toggleMapFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - try { - const isNative = document.fullscreenElement === stage || document.webkitFullscreenElement === stage; - const isFake = stage.classList.contains("map-fake-fullscreen"); - if (isNative) { - if (document.exitFullscreen) await document.exitFullscreen(); - else if (document.webkitExitFullscreen) await document.webkitExitFullscreen(); - } else if (isFake) { - mapExitFakeFullscreen(); - } else { - const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; - if (nativeFn) { - try { - await nativeFn.call(stage); - } catch (_) { - mapEnterFakeFullscreen(); - } - } else { - mapEnterFakeFullscreen(); - } - } - } catch (err) { - console.error("Map fullscreen toggle failed", err); - } finally { - updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); - } - } - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - const stage = mapStageEl(); - if (stage && stage.classList.contains("map-fake-fullscreen")) { - mapExitFakeFullscreen(); - updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); - } - } - }); - function materializeBufferedMapLayers() { - if (!aprsMap) return; - for (const [key, entry] of locatorMarkers) { - if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; - const bounds = maidenheadToBounds(entry.grid); - if (!bounds) continue; - entry.sourceType = "bookmark"; - entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); - entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); - entry.marker.__trxType = "bookmark"; - sendLocatorOverlayToBack(entry.marker); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); - mapMarkers.add(entry.marker); - } - pruneMapHistory(); - } - let stageResizeObserver = null; - function initAprsMap() { - if (typeof L === "undefined") return; - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; - sizeAprsMapToViewport(); - if (aprsMap) return; - const hasLocation = T.serverLat != null && T.serverLon != null; - const center = hasLocation ? [T.serverLat, T.serverLon] : [20, 0]; - const zoom = hasLocation ? T.initialMapZoom : 2; - aprsMap = L.map("aprs-map").setView(center, zoom); - const stage = mapStageEl(); - if (stage && typeof ResizeObserver !== "undefined") { - if (stageResizeObserver) stageResizeObserver.disconnect(); - stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport()); - stageResizeObserver.observe(stage); - } - updateMapBaseLayerForTheme(C.currentTheme()); - syncAprsReceiverMarker(); - aprsMap.on("popupopen", function(e) { - const marker = e.popup._source; - clearMapRadioPath(); - setSelectedLocatorMarker(null); - if (selectedAprsTrackCall) { - const prevEntry = stationMarkers.get(String(selectedAprsTrackCall)); - if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { - prevEntry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (selectedAisTrackMmsi) { - selectedAisTrackMmsi = null; - syncSelectedAisTrackVisibility(); - } - if (marker._receiverLocKey) { - e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); return; } + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; + } + const track = L.polyline(entry.trackPoints, { + color: getAisAccentColor(), + weight: 2, + opacity: 0.68, + lineCap: "round", + lineJoin: "round", + interactive: false, + dashArray: "5 4" + }); + track.__trxType = "ais"; + track._aisMmsi = mmsi; + entry.track = track; + } + function setRetainedMapMarkerVisible(marker, visible) { if (!marker) return; - const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; - if (marker._aprsCall) { - if (!ll) return; - const entry = stationMarkers.get(marker._aprsCall); - if (!entry) return; - e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); - refreshAprsTrack(String(marker._aprsCall), entry); - if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { - entry.track.addTo(aprsMap); - } - selectedAprsTrackCall = String(marker._aprsCall); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker._aisMmsi) { - if (!ll) return; - const entry = aisMarkers.get(String(marker._aisMmsi)); - if (!entry || !entry.msg) return; - e.popup.setContent(buildAisPopupHtml(entry.msg)); - refreshAisTrack(String(marker._aisMmsi), entry); - selectedAisTrackMmsi = String(marker._aisMmsi); - syncSelectedAisTrackVisibility(); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker._vdesKey) { - if (!ll) return; - const entry = vdesMarkers.get(String(marker._vdesKey)); - if (!entry || !entry.msg) return; - e.popup.setContent(buildVdesPopupHtml(entry.msg)); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { - const center2 = locatorMarkerCenter(marker); - if (center2) { - setSelectedLocatorMarker(marker); - const lEntry = locatorEntryForMarker(marker); - const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color : locatorFilterColor(marker.__trxType); - setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); - } - } else if (marker.__trxType === "bookmark") { - setSelectedLocatorMarker(marker); - } - }); - aprsMap.on("popupclose", function() { - clearMapRadioPath(); - setSelectedLocatorMarker(null); - if (selectedAprsTrackCall) { - const entry = stationMarkers.get(String(selectedAprsTrackCall)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (selectedAisTrackMmsi) { - selectedAisTrackMmsi = null; - syncSelectedAisTrackVisibility(); - } - }); - materializeBufferedMapLayers(); - const locatorPhaseEl = document.getElementById("map-locator-phase"); - const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); - const mapSearchEl = document.getElementById("map-search-filter"); - const mapHistoryLimitEl = document.getElementById("map-history-limit"); - const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle"); - const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); - const fullscreenBtn = document.getElementById("map-fullscreen-btn"); - const overlayToggleBtn = document.getElementById("map-overlay-toggle-btn"); - if (locatorPhaseEl) { - locatorPhaseEl.addEventListener("click", (e) => { - const btn = e.target.closest(".map-locator-phase-btn[data-phase]"); - if (!btn) return; - const phase = String(btn.dataset.phase || ""); - if (phase !== "type" && phase !== "band") return; - if (mapLocatorFilter.phase === phase) return; - mapLocatorFilter.phase = phase; - rebuildMapLocatorFilters(); - applyMapFilter(); - }); - } - if (locatorChoiceEl) { - locatorChoiceEl.addEventListener("click", (e) => { - const chip = e.target.closest(".map-locator-chip[data-filter-kind]"); - if (!chip) return; - const kind = String(chip.dataset.filterKind || ""); - const key = String(chip.dataset.filterKey || ""); - if (!key) return; - if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { - mapFilter[key] = !mapFilter[key]; - const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); - const anySelected = srcKeys.some((k) => mapFilter[k]); - if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { - const entry = stationMarkers.get(String(selectedAprsTrackCall)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) { - const entry = aisMarkers.get(String(selectedAisTrackMmsi)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAisTrackMmsi = null; - } - } else if (kind === "band") { - if (mapLocatorFilter.bands.has(key)) { - mapLocatorFilter.bands.delete(key); - } else { - mapLocatorFilter.bands.add(key); - } - } - rebuildMapLocatorFilters(); - applyMapFilter(); - }); - } - const mapRigFilterEl = document.getElementById("map-rig-filter"); - if (mapRigFilterEl) { - mapRigFilterEl.addEventListener("change", () => { - mapRigFilter = mapRigFilterEl.value; - applyMapFilter(); - }); - } - if (mapSearchEl) { - mapSearchEl.value = mapSearchFilter; - mapSearchEl.addEventListener("input", () => { - mapSearchFilter = String(mapSearchEl.value || "").trim(); - applyMapFilter(); - }); - } - if (mapHistoryLimitEl) { - mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); - mapHistoryLimitEl.addEventListener("change", () => { - mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value)); - mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); - saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes); - pruneMapHistory(); - }); - } - if (mapP2pPathsToggleEl) { - updateMapP2pPathsToggle(); - mapP2pPathsToggleEl.addEventListener("click", () => { - mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled; - saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled); - updateMapP2pPathsToggle(); - if (!mapP2pRadioPathsEnabled) clearMapRadioPath(); - }); - } - if (mapContactPathsToggleEl) { - updateMapContactPathsToggle(); - mapContactPathsToggleEl.addEventListener("click", () => { - mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; - saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); - updateMapContactPathsToggle(); - syncDecodeContactPathVisibility(); - }); - } - if (fullscreenBtn) { - fullscreenBtn.addEventListener("click", () => { - toggleMapFullscreen(); - }); - updateMapFullscreenButton(); - } - applyMapOverlayPanelVisibility(); - updateMapOverlayToggleButton(); - if (overlayToggleBtn) { - overlayToggleBtn.addEventListener("click", () => { - mapOverlayPanelVisible = !mapOverlayPanelVisible; - saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible); - applyMapOverlayPanelVisibility(); - updateMapOverlayToggleButton(); - }); - } - if (!mapFullscreenListenerBound) { - const onFullscreenChange = () => { - updateMapFullscreenButton(); - sizeAprsMapToViewport(); - }; - document.addEventListener("fullscreenchange", onFullscreenChange); - document.addEventListener("webkitfullscreenchange", onFullscreenChange); - mapFullscreenListenerBound = true; - } - if (!mapHistoryPruneTimer) { - mapHistoryPruneTimer = setInterval(() => { - pruneMapHistory(); - }, 60 * 1e3); - } - rebuildMapLocatorFilters(); - } - function sizeAprsMapToViewport() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; - const stage = mapStageEl(); - if (mapIsFullscreen() && stage) { - const isFake = stage.classList.contains("map-fake-fullscreen"); - const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; - const target2 = Math.max(260, Math.floor(stageHeight)); - mapEl.style.height = `${target2}px`; - if (aprsMap) aprsMap.invalidateSize(); - return; - } - const mapRect = mapEl.getBoundingClientRect(); - const width = mapEl.clientWidth || mapRect.width; - const footer = document.querySelector(".footer"); - let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; - if (!mapIsFullscreen() && footer) { - const fr = footer.getBoundingClientRect(); - if (fr.top > mapRect.top + 50) bottom = fr.top; - } - const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); - const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; - const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); - const minHeight = Math.min(260, available); - const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); - mapEl.style.height = `${target}px`; - if (aprsMap) aprsMap.invalidateSize(); - } - function aprsSymbolIcon(symbolTable, symbolCode) { - if (!symbolTable || !symbolCode) return null; - const sheet = symbolTable === "/" ? 0 : 1; - const code = symbolCode.charCodeAt(0) - 33; - const col = code % 16; - const row = Math.floor(code / 16); - const bgX = -(col * 24); - const bgY = -(row * 24); - const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`; - return L.divIcon({ - className: "", - html: `
`, - iconSize: [24, 24], - iconAnchor: [12, 12], - popupAnchor: [0, -12] - }); - } - window.navigateToAprsMap = function(lat, lon) { - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = document.getElementById("tab-map"); - if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); - sizeAprsMapToViewport(); - if (aprsMap) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - sizeAprsMapToViewport(); - aprsMap.invalidateSize(); - aprsMap.setView([lat, lon], 13); - }); - }); - } - }; - window.navigateToMapLocator = function(grid, preferredType = null) { - const normalizedGrid = String(grid || "").trim().toUpperCase(); - if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = document.getElementById("tab-map"); - if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); - sizeAprsMapToViewport(); - if (!aprsMap) return false; - const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; - const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; - let entry = null; - for (const key of keys) { - entry = locatorMarkers.get(key); - if (entry?.marker) break; - } - if (!entry?.marker) return false; - if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { - mapFilter[pref] = true; - rebuildMapLocatorFilters(); - applyMapFilter(); - } - const marker = entry.marker; - if (!aprsMap.hasLayer(marker)) { - marker.addTo(aprsMap); - sendLocatorOverlayToBack(marker); - } - const center = locatorMarkerCenter(marker); - const focusMarker = () => { - if (!aprsMap || !marker) return; - sizeAprsMapToViewport(); - aprsMap.invalidateSize(); - if (center) { - const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); - aprsMap.setView([center.lat, center.lon], targetZoom); - if (marker.__trxType !== "bookmark") { - const fEntry = locatorEntryForMarker(marker); - const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color : locatorFilterColor(marker?.__trxType); - setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); - } - } - setSelectedLocatorMarker(marker); - if (typeof marker.openPopup === "function") marker.openPopup(); - }; - focusMarker(); - requestAnimationFrame(() => { - requestAnimationFrame(focusMarker); - }); - return true; - }; - function buildReceiverPopupHtml(rigIds) { - const call = T.serverCallsign || T.ownerCallsign || "Receiver"; - let meta = ""; - if (T.serverVersion) { - meta = `trx-server v${escapeMapHtml(T.serverVersion)}`; - if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`; - } - let rows = ""; - if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { - rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; - } - const rigSet = rigIds && rigIds.length ? new Set(rigIds) : null; - const firstRig = rigSet ? T.serverRigs.find((r) => rigSet.has(r.remote)) : null; - const popupLat = firstRig ? firstRig.latitude : T.serverLat; - const popupLon = firstRig ? firstRig.longitude : T.serverLon; - if (popupLat != null && popupLon != null) { - const grid = latLonToMaidenhead(popupLat, popupLon); - rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; - } - const rigsToShow = rigSet ? T.serverRigs.filter((r) => rigSet.has(r.remote)) : T.serverRigs; - for (const rig of rigsToShow) { - const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); - const active = rig.remote === T.serverActiveRigId ? ` active` : ""; - rows += `Rig${escapeMapHtml(name)}${active}`; - } - return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; - } - function buildAprsPopupHtml(call, lat, lon, info, pkt) { - const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; - const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const path = pkt?.path || null; - const type = pkt?.type || null; - let meta = [age, distStr].filter(Boolean).join(" · "); - let rows = ""; - if (type) rows += `Type${escapeMapHtml(type)}`; - if (path) rows += `Path${escapeMapHtml(path)}`; - if (lat != null && lon != null) - rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; - return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; - } - function buildAisPopupHtml(msg) { - const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; - const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); - let rows = ""; - rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; - rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; - if (distStr) rows += `Range${distStr} from TRX`; - if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; - if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; - if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; - if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; - if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; - const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); - const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); - const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; - const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; - return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; - } - function buildVdesPopupHtml(msg) { - const age = formatTimeAgo(msg?.ts_ms); - const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const meta = [ - age, - distStr, - msg?.message_label ? escapeMapHtml(msg.message_label) : null, - Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null - ].filter(Boolean).join(" · "); - let rows = ""; - if (distStr) rows += `Range${distStr} from TRX`; - rows += `Type${escapeMapHtml(String(msg?.message_type ?? "--"))}`; - if (Number.isFinite(msg?.source_id)) rows += `Source${escapeMapHtml(String(msg.source_id))}`; - if (Number.isFinite(msg?.destination_id)) rows += `Dest${escapeMapHtml(String(msg.destination_id))}`; - if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; - if (Number.isFinite(msg?.sync_score)) rows += `Sync${(Number(msg.sync_score) * 100).toFixed(0)}%`; - if (msg?.fec_state) rows += `FEC${escapeMapHtml(String(msg.fec_state))}`; - const info = [ - msg?.vessel_name, - msg?.callsign, - msg?.destination, - msg?.payload_preview - ].filter(Boolean).map(escapeMapHtml).join(" · "); - const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); - return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; - } - function aprsPositionsEqual(a, b) { - if (!a || !b) return false; - const aLat = Array.isArray(a) ? a[0] : a.lat; - const aLon = Array.isArray(a) ? a[1] : a.lon; - const bLat = Array.isArray(b) ? b[0] : b.lat; - const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; - } - function aisPositionsEqual(a, b) { - if (!a || !b) return false; - const aLat = Array.isArray(a) ? a[0] : a.lat; - const aLon = Array.isArray(a) ? a[1] : a.lon; - const bLat = Array.isArray(b) ? b[0] : b.lat; - const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; - } - function vdesMarkerKey(msg) { - if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; - if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; - if (msg?.lat != null && msg?.lon != null) { - return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`; - } - return null; - } - function _aprsAddMarkerToMap(call, entry) { - refreshAprsTrack(call, entry); - const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode); - const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); - const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { - radius: 6, - color: "#00d17f", - fillColor: "#00d17f", - fillOpacity: 0.8 - }).addTo(aprsMap).bindPopup(popupContent); - marker.__trxType = "aprs"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._aprsCall = call; - entry.marker = marker; - mapMarkers.add(marker); - } - window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { - const nextPoint = [lat, lon]; - const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); - const msgRigId = pkt?.rig_id || T.lastActiveRigId; - const existing = stationMarkers.get(call); - if (existing) { - existing.pkt = pkt; - existing.lat = lat; - existing.lon = lon; - existing.info = info; - existing.symbolTable = symbolTable; - existing.symbolCode = symbolCode; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; - const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; - if (!aprsPositionsEqual(prevPoint, nextPoint)) { - existing.trackHistory.push({ lat, lon, tsMs }); - } else if (prevPoint) { - prevPoint.tsMs = tsMs; - } - pruneAprsEntry(call, existing, mapHistoryCutoffMs()); - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - existing.marker.setLatLng([lat, lon]); - existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); - } - } else { - const entry = { - marker: null, - track: null, - trackHistory: [{ lat, lon, tsMs }], - trackPoints: [nextPoint], - type: "aprs", - pkt, - lat, - lon, - info, - symbolTable, - symbolCode, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - stationMarkers.set(call, entry); - pruneAprsEntry(call, entry, mapHistoryCutoffMs()); - if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); - if (aprsMap) scheduleDecodeMapMaintenance(); - } - }; - function syncSelectedAisTrackVisibility() { - if (!aprsMap) return; - const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; - aisMarkers.forEach((entry, key) => { - const track = entry?.track; - if (!track) return; - const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; - const onMap = aprsMap.hasLayer(track); - if (shouldShow && !onMap) { - track.addTo(aprsMap); - } - if (!shouldShow && onMap) { - track.removeFrom(aprsMap); - } - }); - } - function getAisAccentColor() { - return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; - } - function aisMarkerOptionsFromMessage(msg) { - const color = getAisAccentColor(); - return { - heading: msg?.heading_deg, - course: msg?.cog_deg, - speed: msg?.sog_knots, - color, - outline: "#00000055", - size: 22 - }; - } - function createAisMarker(lat, lon, msg) { - if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { - return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); - } - const color = getAisAccentColor(); - return L.circleMarker([lat, lon], { - radius: 6, - color, - fillColor: color, - fillOpacity: 0.82 - }); - } - function updateAisMarker(marker, msg, popupHtml) { - if (!marker) return; - marker.setLatLng([msg.lat, msg.lon]); - if (typeof marker.setAisState === "function") { - marker.setAisState(aisMarkerOptionsFromMessage(msg)); - } - if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { - const color = getAisAccentColor(); - marker.setStyle({ - radius: 6, - color, - fillColor: color, - fillOpacity: 0.84 - }); - } - marker.setPopupContent(popupHtml); - } - function refreshAisMarkerColors() { - const color = getAisAccentColor(); - aisMarkers.forEach((entry) => { - if (entry.marker) { - if (typeof entry.marker.setAisState === "function") { - entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {})); - } else if (typeof entry.marker.setStyle === "function") { - entry.marker.setStyle({ color, fillColor: color }); - } - } - if (entry.track && typeof entry.track.setStyle === "function") { - entry.track.setStyle({ color }); - } - }); - } - window.aisMapAddVessel = function(msg) { - if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; - const key = String(msg.mmsi); - const popupHtml = buildAisPopupHtml(msg); - const nextPoint = [msg.lat, msg.lon]; - const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); - const msgRigId = msg?.rig_id || T.lastActiveRigId; - const existing = aisMarkers.get(key); - if (existing) { - existing.msg = msg; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; - const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; - if (!aisPositionsEqual(prevPoint, nextPoint)) { - existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs }); - } else if (prevPoint) { - prevPoint.tsMs = tsMs; - } - pruneAisEntry(key, existing, mapHistoryCutoffMs()); - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - updateAisMarker(existing.marker, msg, popupHtml); - } - return; - } - aisMarkers.set(key, { - marker: null, - track: null, - trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], - trackPoints: [nextPoint], - msg, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }); - pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs()); - if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key)); - scheduleDecodeMapMaintenance(); - }; - window.vdesMapAddPoint = function(msg) { - if (msg == null || msg.lat == null || msg.lon == null) return; - const key = vdesMarkerKey(msg); - if (!key) return; - const popupHtml = buildVdesPopupHtml(msg); - const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); - const msgRigId = msg?.rig_id || T.lastActiveRigId; - const existing = vdesMarkers.get(key); - if (existing) { - existing.msg = msg; - existing.visibleInHistoryWindow = visible; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } + marker.__trxHistoryVisible = visible; if (!visible) { - if (!C.decodeHistoryMapRenderingDeferred()) { - setRetainedMapMarkerVisible(existing.marker, false); - } else { - C.markDecodeMapSyncPending(); + if (marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); } - return; + if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); } - if (!C.decodeHistoryMapRenderingDeferred()) { - ensureVdesMarker(key, existing); - setRetainedMapMarkerVisible(existing.marker, true); + } + function ensureAprsMarker(call, entry) { + if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; + _aprsAddMarkerToMap(call, entry); + } + function ensureAisMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); + marker.__trxType = "ais"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aisMmsi = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureVdesMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { + radius: 5, + color: "#5c394f", + fillColor: "#c46392", + fillOpacity: 0.82 + }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); + marker.__trxType = "vdes"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._vdesKey = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureDecodeLocatorMarker(entry) { + if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) return; + const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); + const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); + const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); + marker.__trxType = entry.sourceType; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + sendLocatorOverlayToBack(marker); + assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); + entry.marker = marker; + mapMarkers.add(marker); + } + function pruneAprsEntry(call, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const pktTsMs = Number(entry?.pkt?._tsMs); + const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAprsTrack(call, entry); } else { C.markDecodeMapSyncPending(); } - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - existing.marker.setLatLng([msg.lat, msg.lon]); - existing.marker.setPopupContent(popupHtml); + if (!visible) { + if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { + selectedAprsTrackCall = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; } - return; + if (!canRenderMap) return true; + ensureAprsMarker(call, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.lat != null && entry.lon != null) { + entry.marker.setLatLng([entry.lat, entry.lon]); + entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); + } + return true; } - const entry = { - marker: null, - msg, - visibleInHistoryWindow: visible, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - vdesMarkers.set(key, entry); - if (!visible) return; - if (!C.decodeHistoryMapRenderingDeferred()) { - ensureVdesMarker(key, entry); + function pruneAisEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const msgTsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAisTrack(key, entry); + } else { + C.markDecodeMapSyncPending(); + } + if (!visible) { + if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { + selectedAisTrackMmsi = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; + } + if (!canRenderMap) return true; + ensureAisMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.msg) { + updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); + } + return true; + } + function pruneLocatorEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; + if (!(entry.allStationDetails instanceof Map)) { + entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); + } + const nextDetails = /* @__PURE__ */ new Map(); + for (const [detailKey, detail] of entry.allStationDetails.entries()) { + const tsMs = Number(detail?.ts_ms); + if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { + nextDetails.set(detailKey, detail); + } + } + entry.visibleInHistoryWindow = nextDetails.size > 0; + if (nextDetails.size === 0) { + entry.stationDetails = /* @__PURE__ */ new Map(); + entry.stations = /* @__PURE__ */ new Set(); + entry.bandMeta = /* @__PURE__ */ new Map(); + if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); + else C.markDecodeMapSyncPending(); + return false; + } + const nextStations = /* @__PURE__ */ new Set(); + for (const detail of nextDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (source) nextStations.add(source); + } + entry.stationDetails = nextDetails; + entry.stations = nextStations; + entry.bandMeta = collectBandMeta( + Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz)) + ); + const count = Math.max(nextDetails.size, nextStations.size || 0, 1); + if (!canRenderMap) { + C.markDecodeMapSyncPending(); + return true; + } + ensureDecodeLocatorMarker(entry); setRetainedMapMarkerVisible(entry.marker, true); - } else { - C.markDecodeMapSyncPending(); - } - if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { - entry.marker.setPopupContent(popupHtml); - } - scheduleDecodeMapMaintenance(); - }; - let reverseGeocodeLastKey = null; - function reverseGeocodeLocation(lat, lon, grid) { - const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; - if (key === reverseGeocodeLastKey) return; - reverseGeocodeLastKey = key; - const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; - fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(r.status)).then((data) => { - const addr = data?.address; - if (!addr) return; - const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; - const country = addr.country || ""; - if (!city && !country) return; - const label = city && country ? `${city}, ${country}` : city || country; - T.lastCityLabel = label; - if (T.locationSubtitle) { - T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; + if (entry.marker && entry.grid && entry.sourceType) { + entry.marker.setStyle(locatorStyleForEntry(entry, count)); + entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); } - C.updateDocumentTitle(C.activeChannelRds()); - }).catch(() => { - }); - } - function maidenheadToBounds(grid) { - if (!grid || grid.length < 4) return null; - const g = grid.toUpperCase(); - const A = "A".charCodeAt(0); - const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; - const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; - const squareLon = parseInt(g[2], 10) * 2; - const squareLat = parseInt(g[3], 10) * 1; - let lon = fieldLon + squareLon; - let lat = fieldLat + squareLat; - let lonSpan = 2; - let latSpan = 1; - if (g.length >= 6) { - const subLon = (g.charCodeAt(4) - A) * (5 / 60); - const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); - lon += subLon; - lat += subLat; - lonSpan = 5 / 60; - latSpan = 2.5 / 60; + return true; } - return [ - [lat, lon], - [lat + latSpan, lon + lonSpan] - ]; - } - function applyMapFilter() { - if (!aprsMap) return; - const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); - const noneSelected = sourceKeys.every((k) => !mapFilter[k]); - mapMarkers.forEach((marker) => { - const type = marker.__trxType; - const sourceVisible = noneSelected ? DEFAULT_MAP_SOURCE_FILTER[type] !== void 0 ? DEFAULT_MAP_SOURCE_FILTER[type] : true : !!mapFilter[type]; - const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); - const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; - const onMap = aprsMap.hasLayer(marker); - if (visible && !onMap) { - marker.addTo(aprsMap); - sendLocatorOverlayToBack(marker); + function pruneMapHistory() { + const cutoffMs = mapHistoryCutoffMs(); + for (const [call, entry] of stationMarkers.entries()) { + pruneAprsEntry(call, entry, cutoffMs); + } + for (const [key, entry] of aisMarkers.entries()) { + pruneAisEntry(key, entry, cutoffMs); + } + for (const [key, entry] of vdesMarkers.entries()) { + const tsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + if (!visible) { + setRetainedMapMarkerVisible(entry?.marker, false); + continue; + } + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.msg?.lat != null && entry.msg.lon != null) { + entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); + entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); + } + } + for (const [key, entry] of locatorMarkers.entries()) { + pruneLocatorEntry(key, entry, cutoffMs); + } + if (!aprsMap || T.decodeHistoryReplayActive) { + C.markDecodeMapSyncPending(); + return; } - if (!visible && onMap) marker.removeFrom(aprsMap); - }); - syncSelectedAisTrackVisibility(); - syncDecodeContactPathVisibility(); - } - function updateMapContactPathsToggle() { - const btn = document.getElementById("map-contact-paths-toggle"); - if (!btn) return; - btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; - btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); - } - function updateMapP2pPathsToggle() { - const btn = document.getElementById("map-p2p-paths-toggle"); - if (!btn) return; - btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; - btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); - } - function scheduleDecodeMapMaintenance() { - if (C.decodeHistoryMapRenderingDeferred()) { - C.markDecodeMapSyncPending(); - return; - } - scheduleUiFrameJob("decode-map-maintenance", () => { rebuildDecodeContactPaths(); rebuildMapLocatorFilters(); applyMapFilter(); - }); - } - function formatDecodeLocatorTime(tsMs) { - if (!Number.isFinite(tsMs)) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); - } - function formatMapPopupFreq(hz) { - if (!Number.isFinite(hz)) return "--"; - const value = Number(hz); - if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; - if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; - if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; - return `${Math.round(value)} Hz`; - } - function buildDecodeLocatorTooltipHtml(grid, entry, type) { - const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; - details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); - const title = type === "wspr" ? "WSPR" : "FT8"; - const rows = details.map((detail) => { - const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); - const freq = formatMapPopupFreq(Number(detail?.freq_hz)); - const meta = [ - detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, - Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, - Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, - escapeMapHtml(freq) - ].filter(Boolean).join(" · "); - const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; - const rxHtml = remoteIds.map((rid) => { - const label = _receiverLabel(rid); - return label ? `
${escapeMapHtml(label)}
` : ""; - }).filter(Boolean).join(""); - const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; - return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; - }).join(""); - const count = Math.max( - 1, - details.length, - entry?.stations instanceof Set ? entry.stations.size : 0 - ); - return `
${escapeMapHtml(grid)}
${title} · ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; - } - function rebuildDecodeContactPaths() { - clearDecodeContactPaths(); - const stationLocators = /* @__PURE__ */ new Map(); - const directedMessages = []; - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - const grid = String(entry.grid || "").trim().toUpperCase(); - if (!grid || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); - const target = String(detail?.target || "").trim().toUpperCase(); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - if (source) { - const prev = stationLocators.get(source); - if (!prev || tsMs >= prev.tsMs) { - stationLocators.set(source, { grid, tsMs }); + } + function locatorSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + if (type === "wspr") return "WSPR"; + if (type === "ft4") return "FT4"; + if (type === "ft2") return "FT2"; + return "FT8"; + } + function mapSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + return String(type || "").toUpperCase(); + } + function locatorFilterColor(type) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const sat = lightTheme ? 66 : 76; + const light = lightTheme ? 42 : 56; + const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function mapSourceColor(type) { + if (type === "ais") return "#38bdf8"; + if (type === "vdes") return "#a78bfa"; + if (type === "sat") return "#f59e0b"; + if (type === "aprs") return "#00d17f"; + return locatorFilterColor(type); + } + function bandForHz(hz) { + const rfHz = normalizeLocatorFreqHz(hz); + if (rfHz == null || !Number.isFinite(rfHz) || rfHz <= 0) return null; + let bestBand = null; + let bestDistance = Infinity; + for (const band of HAM_BANDS) { + const distance = Math.abs(Math.log(rfHz / band.nominalHz)); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = band; + } + } + return bestBand; + } + function collectBandMeta(freqs) { + const out = /* @__PURE__ */ new Map(); + if (!Array.isArray(freqs)) return out; + for (const hz of freqs) { + const band = bandForHz(hz); + if (band && !out.has(band.label)) out.set(band.label, band.nominalHz); + } + return out; + } + function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { + if (!marker) return; + const safeMeta = bandMeta ?? /* @__PURE__ */ new Map(); + marker._locatorFilterMeta = { + sourceType, + bands: new Set(safeMeta.keys()), + bandMeta: new Map(safeMeta) + }; + } + function parseMapColor(input) { + const value = String(input || "").trim(); + if (!value) return null; + const hex = value.match(/^#([0-9a-f]{3,8})$/i); + if (hex) { + const raw = hex[1]; + if (!raw) return null; + if (raw.length === 3 || raw.length === 4) { + return { + r: parseInt(raw.charAt(0).repeat(2), 16), + g: parseInt(raw.charAt(1).repeat(2), 16), + b: parseInt(raw.charAt(2).repeat(2), 16) + }; + } + if (raw.length === 6 || raw.length === 8) { + return { + r: parseInt(raw.slice(0, 2), 16), + g: parseInt(raw.slice(2, 4), 16), + b: parseInt(raw.slice(4, 6), 16) + }; + } + } + const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i); + if (rgb) { + return { + r: Math.max(0, Math.min(255, Number(rgb[1]))), + g: Math.max(0, Math.min(255, Number(rgb[2]))), + b: Math.max(0, Math.min(255, Number(rgb[3]))) + }; + } + return null; + } + function rgbToHsl(rgb) { + if (!rgb) return null; + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) { + return { h: 0, s: 0, l: l * 100 }; + } + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + break; + } + return { h: h * 60 % 360, s: s * 100, l: l * 100 }; + } + function wrapHue(hue) { + const value = Number(hue) || 0; + return (value % 360 + 360) % 360; + } + function paletteHue(input, fallback) { + const hsl = rgbToHsl(parseMapColor(input)); + return hsl && Number.isFinite(hsl.h) ? hsl.h : fallback; + } + function locatorThemeHues() { + const pal = C.canvasPalette(); + const baseHue = paletteHue(pal?.spectrumLine, 145); + const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); + const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); + return { + bookmark: wrapHue(baseHue), + ft8: wrapHue(peakHue), + ft4: wrapHue(peakHue + 30), + ft2: wrapHue(peakHue + 60), + wspr: wrapHue((waveHue + baseHue) / 2), + bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) + }; + } + function locatorBandIndex(label) { + const idx = HAM_BANDS.findIndex((band) => band.label === label); + return idx >= 0 ? idx : 0; + } + function locatorBandChipColor(label) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + const sat = lightTheme ? 68 : 78; + const light = lightTheme ? 44 : 58; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function locatorBandLabelForEntry(entry) { + const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); + if (meta.size === 0) return null; + if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { + for (const label of mapLocatorFilter.bands) { + if (meta.has(label)) return label; + } + } + let bestLabel = null; + let bestHz = -Infinity; + for (const [label, hz] of meta.entries()) { + const value = Number.isFinite(hz) ? Number(hz) : 0; + if (value > bestHz) { + bestHz = value; + bestLabel = label; + } + } + return bestLabel; + } + function locatorHueForEntry(entry) { + const hues = locatorThemeHues(); + if (mapLocatorFilter.phase === "band") { + const label = locatorBandLabelForEntry(entry); + if (label) { + return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + } + } + if (entry?.sourceType === "bookmark") return hues.bookmark; + if (entry?.sourceType === "wspr") return hues.wspr; + if (entry?.sourceType === "ft4") return hues.ft4; + if (entry?.sourceType === "ft2") return hues.ft2; + return hues.ft8; + } + function locatorStyleForEntry(entry, count) { + const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); + const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); + const hue = locatorHueForEntry(entry); + const lightTheme = C.currentTheme() === "light"; + const strokeSat = lightTheme ? 62 : 74; + const fillSat = lightTheme ? 68 : 78; + const strokeLight = lightTheme ? 40 : 56; + const fillLight = lightTheme ? 60 : 42; + return { + color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`, + opacity: 0.42 + intensity * 0.5, + weight: 1 + intensity * 1.2, + fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, + fillOpacity: 0.16 + intensity * 0.34 + }; + } + function locatorEntryCount(entry) { + if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); + if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); + if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); + return 1; + } + function locatorEntryForMarker(marker) { + if (!marker) return null; + for (const entry of locatorMarkers.values()) { + if (entry?.marker === marker) return entry; + } + return null; + } + function syncLocatorMarkerStyles() { + for (const entry of locatorMarkers.values()) { + if (!entry?.marker) continue; + entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry))); + } + for (const entry of decodeContactPaths.values()) { + if (!entry?.line) continue; + const color = decodeContactPathColor(entry); + entry.line.setStyle({ color, opacity: 0.78 }); + } + } + function stopSelectedLocatorPulse() { + if (selectedLocatorPulseRaf != null) { + cancelAnimationFrame(selectedLocatorPulseRaf); + selectedLocatorPulseRaf = null; + } + } + function startSelectedLocatorPulse(marker) { + stopSelectedLocatorPulse(); + if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; + const tick = (ts) => { + if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { + return; + } + const entry = locatorEntryForMarker(marker); + if (!entry) return; + const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); + const phase = ts % 1600 / 1600; + const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; + marker.setStyle({ + ...base, + opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), + weight: (base.weight || 1.8) + 1.8 * wave + }); + selectedLocatorPulseRaf = requestAnimationFrame(tick); + }; + selectedLocatorPulseRaf = requestAnimationFrame(tick); + } + function clearMapRadioPath() { + for (const p of aprsRadioPaths) p.remove(); + aprsRadioPaths = []; + } + function clearDecodeContactPathRender(entry) { + if (!entry) return; + if (entry.line) { + entry.line.remove(); + entry.line = null; + } + if (entry.labelMarker) { + entry.labelMarker.remove(); + entry.labelMarker = null; + } + } + function clearDecodeContactPaths() { + for (const entry of decodeContactPaths.values()) { + clearDecodeContactPathRender(entry); + } + decodeContactPaths.clear(); + updateMapPathsAnimationClass(); + } + const MAP_PATHS_STATIC_THRESHOLD = 20; + function updateMapPathsAnimationClass() { + const mapContainer = mapEl("aprs-map"); + mapContainer.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); + } + function formatDecodeContactDistance(distanceKm) { + const text = formatDistanceKm(distanceKm); + return text || "--"; + } + function decodeLocatorPathVisibility(grid) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!normalizedGrid || !aprsMap) return false; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.grid !== normalizedGrid) continue; + if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; + if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; + } + return false; + } + function midpointLatLon(a, b) { + if (!a || !b) return null; + if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { + return null; + } + return { + lat: (a.lat + b.lat) / 2, + lon: (a.lon + b.lon) / 2 + }; + } + function decodeContactPathColor(entry) { + if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); + const srcEntry = entry.sourceGrid ? locatorMarkers.get(entry.sourceGrid) : void 0; + if (srcEntry) { + const label = locatorBandLabelForEntry(srcEntry); + if (label) return locatorBandChipColor(label); + return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color ?? locatorFilterColor("ft8"); + } + return locatorFilterColor("ft8"); + } + function ensureDecodeContactPathRendered(entry) { + if (!aprsMap || !entry.from || !entry.to) return; + const linePoints = [ + [entry.from.lat, entry.from.lon], + [entry.to.lat, entry.to.lon] + ]; + const color = decodeContactPathColor(entry); + if (!entry.line) { + entry.line = L.polyline(linePoints, { + color, + opacity: 0.78, + className: "decode-contact-path", + weight: 2.8, + interactive: false + }).addTo(aprsMap); + } else { + entry.line.setLatLngs(linePoints); + entry.line.setStyle({ color, opacity: 0.78 }); + if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); + } + const mid = midpointLatLon(entry.from, entry.to); + if (!mid) return; + const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; + const icon = L.divIcon({ + className: "decode-contact-distance-label", + html: `${escapeMapHtml(entry.distanceText)}` + }); + if (!entry.labelMarker) { + entry.labelMarker = L.marker([mid.lat, mid.lon], { + icon, + interactive: false, + keyboard: false, + zIndexOffset: 900 + }).addTo(aprsMap); + } else { + entry.labelMarker.setLatLng([mid.lat, mid.lon]); + entry.labelMarker.setIcon(icon); + if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); + } + if (entry.line && typeof entry.line.bringToBack === "function") entry.line.bringToBack(); + } + function decodeContactPathMatchesCurrentMap(entry) { + return !!entry.sourceGrid && !!entry.targetGrid && decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); + } + function decodeContactPathRenderVisible(entry) { + return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); + } + function syncDecodeContactPathVisibility() { + if (selectedMapQsoKey) { + const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); + if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) { + selectedMapQsoKey = null; + } + } + for (const entry of decodeContactPaths.values()) { + const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); + if (!visible) { + clearDecodeContactPathRender(entry); + continue; + } + ensureDecodeContactPathRendered(entry); + } + scheduleStatsRender(); + updateMapPathsAnimationClass(); + } + function _resolveReceiverLocations(rigIds) { + const seen = /* @__PURE__ */ new Set(); + const locations = []; + if (rigIds && rigIds.size) { + for (const rid of rigIds) { + const rig = T.serverRigs.find((r) => r.remote === rid); + if (rig && rig.latitude != null && rig.longitude != null) { + const key = _receiverLocationKey(rig.latitude, rig.longitude); + if (!seen.has(key)) { + seen.add(key); + locations.push([rig.latitude, rig.longitude]); + } } } - if (source && target && source !== target) { - const band = bandForHz(Number(detail?.freq_hz)); - directedMessages.push({ - source, - target, - sourceGrid: grid, - sourceType: entry.sourceType, - tsMs, - bandLabel: band?.label || null, - remote: detail?.remote || null - }); - } } + if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { + locations.push([T.serverLat, T.serverLon]); + } + return locations; } - for (const msg of directedMessages) { - const targetLocator = stationLocators.get(msg.target); - if (!targetLocator) continue; - if (msg.sourceGrid === targetLocator.grid) continue; - const sourceCenter = locatorToLatLon(msg.sourceGrid); - const targetCenter = locatorToLatLon(targetLocator.grid); - if (!sourceCenter || !targetCenter) continue; - const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); - const key = [msg.source, msg.target].sort().join("::"); - const prev = decodeContactPaths.get(key); - if (prev && prev.tsMs > msg.tsMs) continue; - decodeContactPaths.set(key, { - pathKey: key, - source: msg.source, - target: msg.target, - sourceGrid: msg.sourceGrid, - targetGrid: targetLocator.grid, - sourceType: msg.sourceType, - bandLabel: msg.bandLabel, - from: sourceCenter, - to: targetCenter, - tsMs: msg.tsMs, - distanceKm, - distanceText: formatDecodeContactDistance(distanceKm), - line: null, - labelMarker: null, - remote: msg.remote - }); - } - syncDecodeContactPathVisibility(); - } - function _receiverLabel(rigId) { - if (!rigId) return null; - const rig = T.serverRigs.find((r) => r.remote === rigId); - const name = T.lastRigDisplayNames[rigId] || rigId; - if (rig && rig.latitude != null && rig.longitude != null) { - const grid = latLonToMaidenhead(rig.latitude, rig.longitude); - return `${name} (${grid})`; - } - return name; - } - function _locatorEntryVisibleOnMap(entry) { - return entry?.marker && aprsMap && aprsMap.hasLayer(entry.marker); - } - function _detailPassesRigFilter(detail) { - if (!mapRigFilter) return true; - if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter); - return detail?.remote === mapRigFilter; - } - function renderMapQsoSummary() { - const listEl = document.getElementById("map-qso-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { - const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); - if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; - return Number(b.tsMs || 0) - Number(a.tsMs || 0); - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { - selectedMapQsoKey = null; - } - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); - card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); - card.addEventListener("click", () => { - selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey; - syncDecodeContactPathVisibility(); - if (selectedMapQsoKey && entry.sourceGrid) { - navigateToMapLocator(entry.sourceGrid, entry.sourceType); - } - }); - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const distance = document.createElement("span"); - distance.className = "map-qso-card-distance"; - distance.textContent = entry.distanceText || "--"; - head.appendChild(distance); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); + function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { + clearMapRadioPath(); + if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { + return; } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = _receiverLabel(entry.remote); - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - function renderMapSignalSummary() { - const listEl = document.getElementById("map-signal-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const bestByStation = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - if (!(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (!Number.isFinite(detail?.snr_db)) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (detail.ts_ms && detail.ts_ms < cutoff) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (!station) continue; - const snrDb = Number(detail.snr_db); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - const prev = bestByStation.get(station); - if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { - bestByStation.set(station, { - station, - snrDb, - tsMs, - grid: entry.grid, - sourceType: entry.sourceType, - bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null - }); - } - } - } - const entries = Array.from(bestByStation.values()).sort((a, b) => { - const delta = b.snrDb - a.snrDb; - if (Math.abs(delta) > 1e-3) return delta; - return b.tsMs - a.tsMs; - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No decoded signals with SNR data in the current map history."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - if (entry.grid) { - card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); - }); - } - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const snr = document.createElement("span"); - snr.className = "map-qso-card-distance"; - snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; - head.appendChild(snr); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = entry.station; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); - } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = _receiverLabel(entry.remote); - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = entry.grid || "--"; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - function renderMapWeakSignalSummary() { - const listEl = document.getElementById("map-weak-signal-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const worstByStation = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - if (!(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (!Number.isFinite(detail?.snr_db)) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (detail.ts_ms && detail.ts_ms < cutoff) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (!station) continue; - const snrDb = Number(detail.snr_db); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - const prev = worstByStation.get(station); - if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { - worstByStation.set(station, { - station, - snrDb, - tsMs, - grid: entry.grid, - sourceType: entry.sourceType, - bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null - }); - } - } - } - const entries = Array.from(worstByStation.values()).sort((a, b) => { - const delta = a.snrDb - b.snrDb; - if (Math.abs(delta) > 1e-3) return delta; - return b.tsMs - a.tsMs; - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No decoded signals with SNR data in the current map history."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - if (entry.grid) { - card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); - }); - } - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const snr = document.createElement("span"); - snr.className = "map-qso-card-distance"; - snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; - head.appendChild(snr); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = entry.station; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); - } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = _receiverLabel(entry.remote); - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = entry.grid || "--"; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - let statsRigFilter = ""; - let statsHistoryLimitMinutes = 1440; - const statsDecodeLog = []; - const STATS_LOG_MAX = 5e4; - const STATS_TYPE_COLORS = { - ft8: "#4fc3f7", - ft4: "#81c784", - ft2: "#aed581", - wspr: "#ffb74d", - aprs: "#ce93d8", - hf_aprs: "#ba68c8", - ais: "#90a4ae", - vdes: "#78909c", - cw: "#fff176" - }; - const STATS_DX_BUCKETS = [ - { label: "0–500 km", min: 0, max: 500 }, - { label: "500–1k", min: 500, max: 1e3 }, - { label: "1k–2k", min: 1e3, max: 2e3 }, - { label: "2k–5k", min: 2e3, max: 5e3 }, - { label: "5k–10k", min: 5e3, max: 1e4 }, - { label: "10k+ km", min: 1e4, max: Infinity } - ]; - function _statsHistoryCutoffMs() { - return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; - } - function _statsDetailPassesRigFilter(detail) { - if (!statsRigFilter) return true; - if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); - return detail?.remote === statsRigFilter; - } - function updateStatsRigFilter() { - const el = document.getElementById("stats-rig-filter"); - if (!el) return; - const prev = el.value; - while (el.options.length > 1) el.remove(1); - for (const id of T.lastRigIds) { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = T.lastRigDisplayNames[id] || id; - el.appendChild(opt); - } - if (prev && T.lastRigIds.includes(prev)) { - el.value = prev; - } else { - el.value = ""; - statsRigFilter = ""; - } - } - function statsRecordDecode(type, remote, tsMs) { - statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); - if (statsDecodeLog.length > STATS_LOG_MAX) { - statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); - } - } - function _statsFilteredLog() { - const cutoff = _statsHistoryCutoffMs(); - return statsDecodeLog.filter((e) => { - if (e.ts_ms < cutoff) return false; - if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false; - return true; - }); - } - function renderStatsCounters() { - const cutoff = _statsHistoryCutoffMs(); - const log = _statsFilteredLog(); - const totalDecodes = log.length; - const uniqueStations = /* @__PURE__ */ new Set(); - const uniqueGrids = /* @__PURE__ */ new Set(); - for (const entry of locatorMarkers.values()) { - if (!entry || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (detail?.ts_ms && detail.ts_ms < cutoff) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (station) uniqueStations.add(station); - } - if (entry.grid) { - const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some( - (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d) + const sources = _resolveReceiverLocations(rigIds); + for (const src of sources) { + aprsRadioPaths.push( + L.polyline( + [src, [lat, lon]], + { color, opacity: 0.85, weight: 2, interactive: false, className } + ).addTo(aprsMap) ); - if (hasVisible) uniqueGrids.add(entry.grid); } } - const rateWindow = Date.now() - 6e4; - const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; - const setEl = (id, val) => { - const el = document.getElementById(id); - if (el) el.textContent = String(val); - }; - setEl("stats-total-decodes", totalDecodes.toLocaleString()); - setEl("stats-unique-stations", uniqueStations.size.toLocaleString()); - setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); - setEl("stats-decode-rate", recentCount.toLocaleString()); - } - function _renderBarChart(containerId, data, emptyMsg) { - const el = document.getElementById(containerId); - if (!el) return; - if (!data || data.length === 0 || data.every((d) => d.count === 0)) { - el.replaceChildren(); - const empty = document.createElement("div"); - empty.className = "stats-bar-empty"; - empty.textContent = emptyMsg || "No data available."; - el.appendChild(empty); - return; - } - const maxVal = Math.max(1, ...data.map((d) => d.count)); - const fragment = document.createDocumentFragment(); - for (const item of data) { - const row = document.createElement("div"); - row.className = "stats-bar-row"; - const label = document.createElement("span"); - label.className = "stats-bar-label"; - label.textContent = item.label; - row.appendChild(label); - const track = document.createElement("div"); - track.className = "stats-bar-track"; - const fill = document.createElement("div"); - fill.className = "stats-bar-fill"; - fill.style.width = `${item.count / maxVal * 100}%`; - fill.style.background = item.color || "var(--accent-green)"; - track.appendChild(fill); - row.appendChild(track); - const count = document.createElement("span"); - count.className = "stats-bar-count"; - count.textContent = item.count.toLocaleString(); - row.appendChild(count); - fragment.appendChild(row); - } - el.replaceChildren(fragment); - } - function renderStatsDecodeTypes() { - const log = _statsFilteredLog(); - const counts = {}; - for (const e of log) { - counts[e.type] = (counts[e.type] || 0) + 1; - } - const data = Object.entries(counts).map(([type, count]) => ({ - label: type.toUpperCase(), - count, - color: STATS_TYPE_COLORS[type] || "#aaa" - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); - } - function renderStatsBandActivity() { - const cutoff = _statsHistoryCutoffMs(); - const bandCounts = {}; - for (const entry of locatorMarkers.values()) { - if (!entry || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (detail?.ts_ms && detail.ts_ms < cutoff) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (!Number.isFinite(detail?.freq_hz)) continue; - const band = bandForHz(Number(detail.freq_hz)); - if (band) { - bandCounts[band.label] = (bandCounts[band.label] || 0) + 1; + function locatorMarkerCenter(marker) { + if (!marker) return null; + if (typeof marker.getBounds === "function") { + const bounds = marker.getBounds(); + if (bounds && typeof bounds.getCenter === "function") { + const center = bounds.getCenter(); + if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { + return { lat: center.lat, lon: center.lng }; + } } } + if (typeof marker.getLatLng === "function") { + const ll = marker.getLatLng(); + if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { + return { lat: ll.lat, lon: ll.lng }; + } + } + return null; } - const data = Object.entries(bandCounts).map(([label, count]) => ({ - label, - count, - color: locatorBandChipColor(label) - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); - } - function renderStatsRigCompare() { - const section = document.getElementById("stats-rig-compare-section"); - if (!section) return; - if (T.lastRigIds.length < 2) { - section.style.display = "none"; - return; + function setLocatorMarkerHighlight(marker, enabled) { + const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; + if (!element) return; + element.classList.toggle("trx-locator-selected", !!enabled); } - section.style.display = ""; - const cutoff = _statsHistoryCutoffMs(); - const rigCounts = {}; - for (const e of statsDecodeLog) { - if (e.ts_ms < cutoff) continue; - const rid = e.remote || "unknown"; - rigCounts[rid] = (rigCounts[rid] || 0) + 1; + function setSelectedLocatorMarker(marker) { + if (selectedLocatorMarker && selectedLocatorMarker !== marker) { + setLocatorMarkerHighlight(selectedLocatorMarker, false); + const prevEntry = locatorEntryForMarker(selectedLocatorMarker); + if (prevEntry?.marker) { + prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry))); + } + } + stopSelectedLocatorPulse(); + selectedLocatorMarker = marker || null; + if (selectedLocatorMarker) { + setLocatorMarkerHighlight(selectedLocatorMarker, true); + startSelectedLocatorPulse(selectedLocatorMarker); + } } - const data = Object.entries(rigCounts).map(([rid, count]) => ({ - label: T.lastRigDisplayNames[rid] || rid, - count, - color: "var(--accent-green)" - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); - } - function renderStatsDxHistogram() { - const cutoff = _statsHistoryCutoffMs(); - const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); - for (const entry of decodeContactPaths.values()) { - if (!entry || !Number.isFinite(entry.distanceKm)) continue; - if (entry.tsMs && entry.tsMs < cutoff) continue; - if (!_statsDetailPassesRigFilter(entry)) continue; - const km = entry.distanceKm; - for (const b of buckets) { - if (km >= b.min && km < b.max) { - b.count++; + function isLocatorOverlay(marker) { + const type = marker?.__trxType; + return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; + } + function sendLocatorOverlayToBack(marker) { + if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; + marker.bringToBack(); + } + function renderMapLocatorChipRow(container, items, selectedSet, kind) { + if (!container) return; + container.replaceChildren(); + if (!Array.isArray(items) || items.length === 0) { + container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`; + return; + } + let helperText = ""; + const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; + const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); + if (kind === "source") { + if (noneSelected) { + helperText = "All sources visible — click to filter"; + } + } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { + helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; + } + for (const item of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-chip"; + const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key); + if (kind === "source" && noneSelected) { + btn.classList.add("is-default"); + } else if (!isActive) { + btn.classList.add("is-inactive"); + } + btn.dataset.filterKind = kind; + btn.dataset.filterKey = item.key; + btn.style.setProperty("--chip-color", item.color); + btn.innerHTML = `${escapeMapHtml(item.label)}`; + container.appendChild(btn); + } + if (helperText) { + const hint = document.createElement("span"); + hint.className = "map-locator-empty"; + hint.textContent = helperText; + container.appendChild(hint); + } + } + function renderMapLocatorPhaseRow(container, phase) { + if (!container) return; + container.replaceChildren(); + const phases = [ + { key: "type", label: "Source" }, + { key: "band", label: "Band" } + ]; + for (const item of phases) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-phase-btn"; + if (phase === item.key) btn.classList.add("is-active"); + btn.dataset.phase = item.key; + btn.textContent = item.label; + container.appendChild(btn); + } + } + function renderMapLocatorLegend(phase, sourceItems, bandItems) { + const legendEl = mapEl("map-band-legend"); + if (!legendEl) return; + const isSourcePhase = phase === "type"; + const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; + if (items.length === 0) { + legendEl.classList.add("is-empty"); + legendEl.replaceChildren(); + return; + } + legendEl.classList.remove("is-empty"); + const rows = items.map((item) => { + const label = escapeMapHtml(item.label); + const color = escapeMapHtml(item.color); + return `${label}`; + }).join(""); + const title = isSourcePhase ? "Source Colors" : "Band Colors"; + legendEl.innerHTML = `
${title}
${rows}
`; + } + mapWindow.enableMapSourceFilter = function(key) { + if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { + mapFilter[key] = true; + rebuildMapLocatorFilters(); + applyMapFilter(); + } + }; + function rebuildMapLocatorFilters() { + const phaseEl = mapEl("map-locator-phase"); + const choiceEl = mapEl("map-locator-choice-filter"); + const choiceLabelEl = mapEl("map-locator-choice-label"); + const availableSources = /* @__PURE__ */ new Set(); + for (const entry of aisMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("ais"); break; } } - } - const data = buckets.map((b) => ({ - label: b.label, - count: b.count, - color: "#4fc3f7" - })); - _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); - } - let _statsRenderPending = false; - let _statsControlsWired = false; - function _wireStatsControls() { - if (_statsControlsWired) return; - const rigEl = document.getElementById("stats-rig-filter"); - const histEl = document.getElementById("stats-history-limit"); - if (!rigEl && !histEl) return; - _statsControlsWired = true; - if (rigEl) { - rigEl.addEventListener("change", () => { - statsRigFilter = rigEl.value; - scheduleStatsRender(); - }); - } - if (histEl) { - histEl.value = String(statsHistoryLimitMinutes); - histEl.addEventListener("change", () => { - statsHistoryLimitMinutes = Number(histEl.value) || 1440; - scheduleStatsRender(); - }); - } - } - function scheduleStatsRender() { - if (_statsRenderPending) return; - _statsRenderPending = true; - requestAnimationFrame(() => { - _statsRenderPending = false; - _wireStatsControls(); - renderStatsCounters(); - renderStatsDecodeTypes(); - renderStatsBandActivity(); - renderStatsRigCompare(); - renderStatsDxHistogram(); - renderMapQsoSummary(); - renderMapSignalSummary(); - renderMapWeakSignalSummary(); - }); - } - function buildBookmarkLocatorPopupHtml(grid, bookmarks) { - const list = Array.isArray(bookmarks) ? bookmarks : []; - const rows = list.map((bm) => { - const title = escapeMapHtml(String(bm.name || "Bookmark")); - const freq = typeof bmFmtFreq === "function" ? escapeMapHtml(bmFmtFreq(bm.freq_hz)) : escapeMapHtml(String(bm.freq_hz || "--")); - const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; - return `${title} ${freq}${mode}`; - }).join("
"); - return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); - } - window.syncBookmarkMapLocators = function(bookmarks) { - const list = Array.isArray(bookmarks) ? bookmarks : []; - const grouped = /* @__PURE__ */ new Map(); - for (const bm of list) { - const grid = String(bm?.locator || "").trim().toUpperCase(); - if (!grid) continue; - const bounds = maidenheadToBounds(grid); - if (!bounds) continue; - const key = `bookmark:${grid}`; - const bucket = grouped.get(key); - if (bucket) { - bucket.bookmarks.push(bm); - } else { - grouped.set(key, { grid, bounds, bookmarks: [bm] }); + for (const entry of vdesMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("vdes"); + break; + } } - } - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith("bookmark:")) continue; - if (!grouped.has(key)) { - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); + for (const entry of stationMarkers.values()) { + if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { + availableSources.add("aprs"); + break; + } + } + const bandMap = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + const sourceType = entry?.sourceType; + if (!sourceType) continue; + if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; + availableSources.add(sourceType); + const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); + for (const [label, hz] of meta.entries()) { + if (!bandMap.has(label)) { + bandMap.set(label, { + key: label, + label, + color: locatorBandChipColor(label), + kind: "band", + sortHz: Number.isFinite(hz) ? hz : 0 + }); + continue; + } + const existing = bandMap.get(label); + if (existing && Number.isFinite(hz) && (existing.sortHz == null || !Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { + existing.sortHz = hz; + } + if (existing && !existing.color) { + existing.color = locatorBandChipColor(label); } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); } - locatorMarkers.delete(key); + } + for (const key of Array.from(mapLocatorFilter.bands)) { + if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); + } + const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + key, + label: mapSourceLabel(key), + color: mapSourceColor(key), + kind: "source" + })); + const bandItems = Array.from(bandMap.values()).sort((a, b) => (b.sortHz ?? 0) - (a.sortHz ?? 0) || a.label.localeCompare(b.label)); + renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); + if (!phaseEl || !choiceEl || !choiceLabelEl) return; + renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); + if (mapLocatorFilter.phase === "band") { + choiceLabelEl.textContent = "Visible Bands"; + renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); + } else { + choiceLabelEl.textContent = "Visible Sources"; + renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); + } + syncLocatorMarkerStyles(); + syncDecodeContactPathVisibility(); + } + function markerPassesLocatorFilters(marker) { + const meta = marker?._locatorFilterMeta; + if (!meta) return true; + if (mapLocatorFilter.phase === "band") { + if (mapLocatorFilter.bands.size === 0) return true; + if (!(meta.bands instanceof Set)) return false; + for (const label of mapLocatorFilter.bands) { + if (meta.bands.has(label)) return true; + } + return false; + } + return true; + } + function markerSearchText(marker) { + const type = marker?.__trxType; + if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const entry = locatorEntryForMarker(marker); + const parts = []; + if (entry?.grid) parts.push(entry.grid); + if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); + if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); + if (Array.isArray(entry?.bookmarks)) { + for (const bm of entry.bookmarks) { + if (bm?.name) parts.push(String(bm.name)); + if (bm?.locator) parts.push(String(bm.locator)); + if (bm?.mode) parts.push(String(bm.mode)); + if (bm?.category) parts.push(String(bm.category)); + if (bm?.comment) parts.push(String(bm.comment)); + if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz)))); + } + } + if (entry?.stations instanceof Set) { + parts.push(...Array.from(entry.stations.values()).map((v) => String(v))); + } + if (entry?.stationDetails instanceof Map) { + for (const detail of entry.stationDetails.values()) { + if (detail?.station) parts.push(String(detail.station)); + if (detail?.message) parts.push(String(detail.message)); + if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz)))); + } + } + return parts.join(" ").toLowerCase(); + } + if (type === "aprs") { + const call = marker?._aprsCall ? String(marker._aprsCall) : ""; + const entry = stationMarkers.get(call); + const info = entry?.info ? String(entry.info) : ""; + const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; + return `${call} ${info} ${pktRaw}`.toLowerCase(); + } + if (type === "ais") { + const key = marker?._aisMmsi ? String(marker._aisMmsi) : ""; + const msg = aisMarkers.get(key)?.msg; + return [ + key, + msg?.name, + msg?.callsign, + msg?.destination, + msg && Number.isFinite(msg.mmsi) ? String(msg.mmsi) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); + } + if (type === "vdes") { + const key = marker?._vdesKey ? String(marker._vdesKey) : ""; + const msg = vdesMarkers.get(key)?.msg; + return [ + key, + msg?.name, + msg?.mmsi, + msg?.message, + msg?.raw, + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); + } + return ""; + } + function markerPassesSearchFilter(marker) { + const query = String(mapSearchFilter || "").trim().toLowerCase(); + if (!query) return true; + const terms = query.split(/\s+/).filter(Boolean); + if (terms.length === 0) return true; + const haystack = markerSearchText(marker); + if (!haystack) return false; + return terms.every((term) => haystack.includes(term)); + } + function _receiverLocationKey(lat, lon) { + return lat.toFixed(6) + "," + lon.toFixed(6); + } + function syncAprsReceiverMarker() { + if (!aprsMap) return; + const locGroups = {}; + const activeId = T.lastActiveRigId || T.serverActiveRigId || null; + for (const rig of T.serverRigs) { + if (!rig || !rig.remote) continue; + const lat = rig.latitude, lon = rig.longitude; + if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const key = _receiverLocationKey(lat, lon); + const group = locGroups[key] ??= { lat, lon, rigs: /* @__PURE__ */ new Set(), hasActive: false }; + group.rigs.add(rig.remote); + if (rig.remote === activeId) group.hasActive = true; + } + if (T.serverLat != null && T.serverLon != null) { + const key = _receiverLocationKey(T.serverLat, T.serverLon); + const group = locGroups[key] ??= { lat: T.serverLat, lon: T.serverLon, rigs: /* @__PURE__ */ new Set(), hasActive: true }; + group.hasActive = true; + } + const seen = /* @__PURE__ */ new Set(); + let didInitialView = false; + for (const [key, group] of Object.entries(locGroups)) { + seen.add(key); + const latLng = [group.lat, group.lon]; + const isActive = group.hasActive; + let m = aprsMapReceiverMarkers[key]; + if (!m) { + m = L.circleMarker(latLng, { + radius: isActive ? 8 : 6, + className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), + fillOpacity: isActive ? 0.8 : 0.6 + }).addTo(aprsMap).bindPopup(""); + m._receiverLocKey = key; + m._receiverRigs = group.rigs; + aprsMapReceiverMarkers[key] = m; + if (isActive && !didInitialView) { + aprsMap.setView(latLng, Math.max(1, T.initialMapZoom)); + didInitialView = true; + } + } else { + m.setLatLng(latLng); + m._receiverRigs = group.rigs; + m.setRadius(isActive ? 8 : 6); + if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); + } + } + for (const key of Object.keys(aprsMapReceiverMarkers)) { + if (!seen.has(key)) { + const m = aprsMapReceiverMarkers[key]; + if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); + Reflect.deleteProperty(aprsMapReceiverMarkers, key); + } } } - for (const [key, next] of grouped.entries()) { - const existing = locatorMarkers.get(key); - const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); - const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz))); - if (existing) { - existing.grid = next.grid; - existing.bounds = next.bounds; - existing.bookmarks = next.bookmarks; - existing.sourceType = "bookmark"; - existing.bandMeta = bandMeta; - if (existing.marker) { - existing.marker.setBounds(next.bounds); - existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length)); - existing.marker.setPopupContent(popupHtml); - sendLocatorOverlayToBack(existing.marker); - assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); + const satOverlays = /* @__PURE__ */ new Map(); + let satOverlaySeq = 0; + mapWindow.addSatMapOverlay = function(msg) { + if (!msg || !Array.isArray(msg.geo_bounds) || typeof msg.path !== "string") return; + const bounds = msg.geo_bounds; + if (!Array.isArray(bounds) || bounds.length !== 4) return; + const latLngBounds = L.latLngBounds( + [Number(bounds[0]), Number(bounds[1])], + // SW + [Number(bounds[2]), Number(bounds[3])] + // NE + ); + const key = `sat-${++satOverlaySeq}`; + const overlay = L.imageOverlay(msg.path, latLngBounds, { + opacity: 0.55, + interactive: true, + zIndex: 300 + }); + overlay.__trxType = "sat"; + overlay.__trxSatKey = key; + overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); + overlay.__trxHistoryVisible = true; + mapMarkers.add(overlay); + const decoder = "Meteor LRPT"; + const satellite = msg.satellite || "Unknown"; + const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; + overlay.bindPopup( + `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` + ); + let track = null; + if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { + const latlngs = msg.ground_track.filter((pt) => Array.isArray(pt) && pt.length >= 2).map((pt) => [Number(pt[0]), Number(pt[1])]); + track = L.polyline(latlngs, { + color: mapSourceColor("sat"), + weight: 2, + opacity: 0.7, + dashArray: "6, 4" + }); + track.__trxType = "sat"; + track.__trxSatKey = key; + track.__trxRigIds = overlay.__trxRigIds ?? /* @__PURE__ */ new Set(); + track.__trxHistoryVisible = true; + mapMarkers.add(track); + if (aprsMap) { + track.addTo(aprsMap); } - continue; } - const entry = { - marker: null, - grid: next.grid, - bounds: next.bounds, - bookmarks: next.bookmarks, - sourceType: "bookmark", - bandMeta - }; - locatorMarkers.set(key, entry); + satOverlays.set(key, { overlay, track, msg }); if (aprsMap) { - entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); + overlay.addTo(aprsMap); + } + applyMapFilter(); + }; + mapWindow.removeSatMapOverlay = function(key) { + const entry = satOverlays.get(key); + if (!entry) return; + if (entry.overlay) { + mapMarkers.delete(entry.overlay); + if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap); + } + if (entry.track) { + mapMarkers.delete(entry.track); + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + } + satOverlays.delete(key); + }; + mapWindow.clearSatMapOverlays = function() { + for (const [key] of satOverlays) { + mapWindow.removeSatMapOverlay?.(key); + } + }; + mapWindow.clearMapMarkersByType = function(type) { + if (type === "aprs") { + selectedAprsTrackCall = null; + stationMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); + } + }); + stationMarkers.clear(); + return; + } + if (type === "ais") { + aisMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); + } + }); + selectedAisTrackMmsi = null; + aisMarkers.clear(); + return; + } + if (type === "vdes") { + vdesMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + }); + vdesMarkers.clear(); + return; + } + if (type === "sat") { + mapWindow.clearSatMapOverlays?.(); + return; + } + if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const prefix = `${type}:`; + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith(prefix)) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + rebuildDecodeContactPaths(); + } + if (type === "bookmark") { + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + } + }; + function mapTileSpecForTheme(theme) { + if (theme === "dark") { + return { + url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + options: { + maxZoom: 19, + subdomains: "abcd", + attribution: '© OpenStreetMap © CARTO' + } + }; + } + return { + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + options: { + maxZoom: 19, + attribution: '© OpenStreetMap' + } + }; + } + function updateMapBaseLayerForTheme(theme) { + if (!aprsMap) return; + if (aprsMapBaseLayer) { + aprsMap.removeLayer(aprsMapBaseLayer); + aprsMapBaseLayer = null; + } + const spec = mapTileSpecForTheme(theme); + aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); + } + function mapStageEl() { + return mapEl("map-stage"); + } + function mapIsFullscreen() { + const stage = mapStageEl(); + if (!stage) return false; + return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); + } + function mapExitFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.remove("map-fake-fullscreen"); + document.body.classList.remove("map-fake-fullscreen-active"); + } + function mapEnterFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.add("map-fake-fullscreen"); + document.body.classList.add("map-fake-fullscreen-active"); + } + function updateMapFullscreenButton() { + const btn = mapEl("map-fullscreen-btn"); + if (!btn) return; + btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; + } + function applyMapOverlayPanelVisibility() { + const panel = document.querySelector("#map-stage .map-overlay-panel"); + if (!panel) return; + panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); + } + function updateMapOverlayToggleButton() { + const btn = mapEl("map-overlay-toggle-btn"); + if (!btn) return; + btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; + } + async function toggleMapFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + try { + const webkitDocument = document; + const isNative = document.fullscreenElement === stage || webkitDocument.webkitFullscreenElement === stage; + const isFake = stage.classList.contains("map-fake-fullscreen"); + if (isNative) { + if (document.exitFullscreen) await document.exitFullscreen(); + else if (webkitDocument.webkitExitFullscreen) await webkitDocument.webkitExitFullscreen(); + } else if (isFake) { + mapExitFakeFullscreen(); + } else { + const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; + if (nativeFn) { + try { + await nativeFn.call(stage); + } catch { + mapEnterFakeFullscreen(); + } + } else { + mapEnterFakeFullscreen(); + } + } + } catch (err) { + console.error("Map fullscreen toggle failed", err); + } finally { + updateMapFullscreenButton(); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + }); + } + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const stage = mapStageEl(); + if (stage && stage.classList.contains("map-fake-fullscreen")) { + mapExitFakeFullscreen(); + updateMapFullscreenButton(); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + }); + } + } + }); + function materializeBufferedMapLayers() { + if (!aprsMap) return; + for (const [key, entry] of locatorMarkers) { + if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) continue; + entry.sourceType = "bookmark"; + entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); + entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); entry.marker.__trxType = "bookmark"; sendLocatorOverlayToBack(entry.marker); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); mapMarkers.add(entry.marker); } + pruneMapHistory(); } - rebuildMapLocatorFilters(); - applyMapFilter(); - }; - window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { - if (!Array.isArray(grids) || grids.length === 0) return; - const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; - const msgRigId = details?.rig_id || T.lastActiveRigId; - const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; - const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; - const locatorDetails = /* @__PURE__ */ new Map(); - if (Array.isArray(details?.locator_details)) { - for (const locatorDetail of details.locator_details) { - const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); - if (!grid) continue; - locatorDetails.set(grid, locatorDetail); + let stageResizeObserver = null; + function initAprsMap() { + if (typeof L === "undefined") return; + mapEl("aprs-map"); + sizeAprsMapToViewport(); + if (aprsMap) return; + const hasLocation = T.serverLat != null && T.serverLon != null; + const center = hasLocation && T.serverLat != null && T.serverLon != null ? [T.serverLat, T.serverLon] : [20, 0]; + const zoom = hasLocation ? T.initialMapZoom : 2; + aprsMap = L.map("aprs-map").setView(center, zoom); + const stage = mapStageEl(); + if (stage && typeof ResizeObserver !== "undefined") { + if (stageResizeObserver) stageResizeObserver.disconnect(); + stageResizeObserver = new ResizeObserver(() => { + sizeAprsMapToViewport(); + }); + stageResizeObserver.observe(stage); } - } - for (const grid of unique) { - const bounds = maidenheadToBounds(grid); - if (!bounds) continue; - const locatorDetail = locatorDetails.get(grid); - const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; - const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; - const detailStationId = sourceId || stationId; - const detailEntry = { - station: detailStationId || null, - source: sourceId || null, - target: targetId || null, - ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, - snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, - dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, - freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, - message: String(details?.message || message || "").trim() || null, - remote: msgRigId || null, - remotes: new Set(msgRigId ? [msgRigId] : []) - }; - const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; - const key = `${markerType}:${grid}`; - const existing = locatorMarkers.get(key); - if (existing) { - existing.grid = grid; - if (!(existing.allStationDetails instanceof Map)) { - existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); + updateMapBaseLayerForTheme(C.currentTheme()); + syncAprsReceiverMarker(); + aprsMap.on("popupopen", function(e) { + const marker = e.popup._source; + if (!marker) return; + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const prevEntry = stationMarkers.get(String(selectedAprsTrackCall)); + if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { + prevEntry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; } - const prevDetail = existing.allStationDetails.get(detailKey); - const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); - if (msgRigId) mergedRemotes.add(msgRigId); - existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); - existing.sourceType = markerType; + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + if (marker._receiverLocKey) { + e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); + return; + } + if (!marker) return; + const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; + if (marker._aprsCall) { + if (!ll) return; + const entry = stationMarkers.get(marker._aprsCall); + if (!entry) return; + e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); + refreshAprsTrack(String(marker._aprsCall), entry); + if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { + entry.track.addTo(aprsMap); + } + selectedAprsTrackCall = String(marker._aprsCall); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker._aisMmsi) { + if (!ll) return; + const entry = aisMarkers.get(String(marker._aisMmsi)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildAisPopupHtml(entry.msg)); + refreshAisTrack(String(marker._aisMmsi), entry); + selectedAisTrackMmsi = String(marker._aisMmsi); + syncSelectedAisTrackVisibility(); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker._vdesKey) { + if (!ll) return; + const entry = vdesMarkers.get(String(marker._vdesKey)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildVdesPopupHtml(entry.msg)); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { + const center2 = locatorMarkerCenter(marker); + if (center2) { + setSelectedLocatorMarker(marker); + const lEntry = locatorEntryForMarker(marker); + const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color ?? locatorFilterColor("ft8") : locatorFilterColor(marker.__trxType); + setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); + } + } else if (marker.__trxType === "bookmark") { + setSelectedLocatorMarker(marker); + } + }); + aprsMap.on("popupclose", function() { + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + }); + materializeBufferedMapLayers(); + const locatorPhaseEl = mapEl("map-locator-phase"); + const locatorChoiceEl = mapEl("map-locator-choice-filter"); + const mapSearchEl = mapEl("map-search-filter"); + const mapHistoryLimitEl = mapEl("map-history-limit"); + const mapP2pPathsToggleEl = mapEl("map-p2p-paths-toggle"); + const mapContactPathsToggleEl = mapEl("map-contact-paths-toggle"); + const fullscreenBtn = mapEl("map-fullscreen-btn"); + const overlayToggleBtn = mapEl("map-overlay-toggle-btn"); + if (locatorPhaseEl) { + locatorPhaseEl.addEventListener("click", (e) => { + const btn = e.target?.closest(".map-locator-phase-btn[data-phase]"); + if (!btn) return; + const phase = String(btn.dataset.phase || ""); + if (phase !== "type" && phase !== "band") return; + if (mapLocatorFilter.phase === phase) return; + mapLocatorFilter.phase = phase; + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + if (locatorChoiceEl) { + locatorChoiceEl.addEventListener("click", (e) => { + const chip = e.target?.closest(".map-locator-chip[data-filter-kind]"); + if (!chip) return; + const kind = String(chip.dataset.filterKind || ""); + const key = String(chip.dataset.filterKey || ""); + if (!key) return; + if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { + const sourceKey = key; + mapFilter[sourceKey] = !mapFilter[sourceKey]; + const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const anySelected = srcKeys.some((k) => mapFilter[k]); + if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) { + const entry = aisMarkers.get(String(selectedAisTrackMmsi)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAisTrackMmsi = null; + } + } else if (kind === "band") { + if (mapLocatorFilter.bands.has(key)) { + mapLocatorFilter.bands.delete(key); + } else { + mapLocatorFilter.bands.add(key); + } + } + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + const mapRigFilterEl = mapEl("map-rig-filter"); + if (mapRigFilterEl) { + mapRigFilterEl.addEventListener("change", () => { + mapRigFilter = mapRigFilterEl.value; + applyMapFilter(); + }); + } + if (mapSearchEl) { + mapSearchEl.value = mapSearchFilter; + mapSearchEl.addEventListener("input", () => { + mapSearchFilter = String(mapSearchEl.value || "").trim(); + applyMapFilter(); + }); + } + if (mapHistoryLimitEl) { + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + mapHistoryLimitEl.addEventListener("change", () => { + mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value)); + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes); + pruneMapHistory(); + }); + } + if (mapP2pPathsToggleEl) { + updateMapP2pPathsToggle(); + mapP2pPathsToggleEl.addEventListener("click", () => { + mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled; + saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled); + updateMapP2pPathsToggle(); + if (!mapP2pRadioPathsEnabled) clearMapRadioPath(); + }); + } + if (mapContactPathsToggleEl) { + updateMapContactPathsToggle(); + mapContactPathsToggleEl.addEventListener("click", () => { + mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; + saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); + updateMapContactPathsToggle(); + syncDecodeContactPathVisibility(); + }); + } + if (fullscreenBtn) { + fullscreenBtn.addEventListener("click", () => { + void toggleMapFullscreen(); + }); + updateMapFullscreenButton(); + } + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + if (overlayToggleBtn) { + overlayToggleBtn.addEventListener("click", () => { + mapOverlayPanelVisible = !mapOverlayPanelVisible; + saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible); + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + }); + } + if (!mapFullscreenListenerBound) { + const onFullscreenChange = () => { + updateMapFullscreenButton(); + sizeAprsMapToViewport(); + }; + document.addEventListener("fullscreenchange", onFullscreenChange); + document.addEventListener("webkitfullscreenchange", onFullscreenChange); + mapFullscreenListenerBound = true; + } + if (!mapHistoryPruneTimer) { + mapHistoryPruneTimer = setInterval(() => { + pruneMapHistory(); + }, 60 * 1e3); + } + rebuildMapLocatorFilters(); + } + function sizeAprsMapToViewport() { + const mapContainer = mapEl("aprs-map"); + const stage = mapStageEl(); + if (mapIsFullscreen() && stage) { + const isFake = stage.classList.contains("map-fake-fullscreen"); + const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; + const target2 = Math.max(260, Math.floor(stageHeight)); + mapContainer.style.height = `${target2}px`; + if (aprsMap) aprsMap.invalidateSize(); + return; + } + const mapRect = mapContainer.getBoundingClientRect(); + const width = mapContainer.clientWidth || mapRect.width; + const footer = document.querySelector(".footer"); + let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; + if (!mapIsFullscreen() && footer) { + const fr = footer.getBoundingClientRect(); + if (fr.top > mapRect.top + 50) bottom = fr.top; + } + const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); + const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; + const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); + const minHeight = Math.min(260, available); + const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); + mapContainer.style.height = `${target}px`; + if (aprsMap) aprsMap.invalidateSize(); + } + function aprsSymbolIcon(symbolTable, symbolCode) { + if (!symbolTable || !symbolCode) return null; + const table = symbolTable === "/" ? "primary" : "alternate"; + return L.divIcon({ + className: "", + html: `
${escapeMapHtml(symbolCode)}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -12] + }); + } + mapWindow.navigateToAprsMap = function(lat, lon) { + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (aprsMap) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + aprsMap?.invalidateSize(); + aprsMap?.setView([lat, lon], 13); + }); + }); + } + }; + mapWindow.navigateToMapLocator = function(grid, preferredType = null) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (!aprsMap) return false; + const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; + const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; + let entry = null; + for (const key of keys) { + entry = locatorMarkers.get(key); + if (entry?.marker) break; + } + if (!entry?.marker) return false; + if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { + mapFilter[pref] = true; + rebuildMapLocatorFilters(); + applyMapFilter(); + } + const marker = entry.marker; + if (!aprsMap.hasLayer(marker)) { + marker.addTo(aprsMap); + sendLocatorOverlayToBack(marker); + } + const center = locatorMarkerCenter(marker); + const focusMarker = () => { + if (!aprsMap || !marker) return; + sizeAprsMapToViewport(); + aprsMap.invalidateSize(); + if (center) { + const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); + aprsMap.setView([center.lat, center.lon], targetZoom); + if (marker.__trxType !== "bookmark") { + const fEntry = locatorEntryForMarker(marker); + const sourceType = marker.__trxType; + const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color ?? locatorFilterColor(sourceType) : locatorFilterColor(sourceType); + setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); + } + } + setSelectedLocatorMarker(marker); + if (typeof marker.openPopup === "function") marker.openPopup(); + }; + focusMarker(); + requestAnimationFrame(() => { + requestAnimationFrame(focusMarker); + }); + return true; + }; + function buildReceiverPopupHtml(rigIds) { + const call = T.serverCallsign || T.ownerCallsign || "Receiver"; + let meta = ""; + if (T.serverVersion) { + meta = `trx-server v${escapeMapHtml(T.serverVersion)}`; + if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`; + } + let rows = ""; + if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { + rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; + } + const rigList = rigIds ? Array.from(rigIds) : []; + const rigSet = rigList.length ? new Set(rigList) : null; + const firstRig = rigSet ? T.serverRigs.find((r) => !!r.remote && rigSet.has(r.remote)) : null; + const popupLat = firstRig ? firstRig.latitude : T.serverLat; + const popupLon = firstRig ? firstRig.longitude : T.serverLon; + if (popupLat != null && popupLon != null) { + const grid = latLonToMaidenhead(popupLat, popupLon); + rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; + } + const rigsToShow = rigSet ? T.serverRigs.filter((r) => !!r.remote && rigSet.has(r.remote)) : T.serverRigs; + for (const rig of rigsToShow) { + const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); + const active = rig.remote === T.serverActiveRigId ? ` active` : ""; + rows += `Rig${escapeMapHtml(name)}${active}`; + } + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; + } + function buildAprsPopupHtml(call, lat, lon, info, pkt) { + const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; + const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const path = pkt?.path || null; + const type = pkt?.type || null; + const meta = [age, distStr].filter(Boolean).join(" · "); + let rows = ""; + if (type) rows += `Type${escapeMapHtml(type)}`; + if (path) rows += `Path${escapeMapHtml(path)}`; + if (lat != null && lon != null) + rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; + } + function buildAisPopupHtml(msg) { + const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); + let rows = ""; + rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; + rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; + if (distStr) rows += `Range${distStr} from TRX`; + if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; + if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; + if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; + if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); + const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); + const vesselUrl = mapWindow.buildAisVesselUrl ? mapWindow.buildAisVesselUrl(msg?.mmsi) : null; + const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; + return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function buildVdesPopupHtml(msg) { + const age = msg.ts_ms == null ? "" : formatTimeAgo(msg.ts_ms); + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [ + age, + distStr, + msg?.message_label ? escapeMapHtml(msg.message_label) : null, + Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null + ].filter(Boolean).join(" · "); + let rows = ""; + if (distStr) rows += `Range${distStr} from TRX`; + rows += `Type${escapeMapHtml(String(msg?.message_type ?? "--"))}`; + if (Number.isFinite(msg?.source_id)) rows += `Source${escapeMapHtml(String(msg.source_id))}`; + if (Number.isFinite(msg?.destination_id)) rows += `Dest${escapeMapHtml(String(msg.destination_id))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + if (Number.isFinite(msg?.sync_score)) rows += `Sync${(Number(msg.sync_score) * 100).toFixed(0)}%`; + if (msg?.fec_state) rows += `FEC${escapeMapHtml(String(msg.fec_state))}`; + const info = [ + msg?.vessel_name, + msg?.callsign, + msg?.destination, + msg?.payload_preview + ].filter(Boolean).map(escapeMapHtml).join(" · "); + const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); + return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function aprsPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function aisPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function vdesMarkerKey(msg) { + if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; + if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; + if (msg?.lat != null && msg?.lon != null) { + return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`; + } + return null; + } + function _aprsAddMarkerToMap(call, entry) { + if (!aprsMap || entry.lat == null || entry.lon == null) return; + refreshAprsTrack(call, entry); + const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); + const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); + const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { + radius: 6, + color: "#00d17f", + fillColor: "#00d17f", + fillOpacity: 0.8 + }).addTo(aprsMap).bindPopup(popupContent); + marker.__trxType = "aprs"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aprsCall = call; + entry.marker = marker; + mapMarkers.add(marker); + } + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + const nextPoint = [lat, lon]; + const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); + const msgRigId = pkt?.rig_id || T.lastActiveRigId; + const existing = stationMarkers.get(call); + if (existing) { + existing.pkt = pkt; + existing.lat = lat; + existing.lon = lon; + existing.info = info; + existing.symbolTable = symbolTable; + existing.symbolCode = symbolCode; if (msgRigId) { if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); existing.rigIds.add(msgRigId); } - pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); - if (existing.marker) sendLocatorOverlayToBack(existing.marker); - scheduleDecodeMapMaintenance(); - continue; + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aprsPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat, lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAprsEntry(call, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([lat, lon]); + existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); + } + } else { + const entry = { + marker: null, + track: null, + trackHistory: [{ lat, lon, tsMs }], + trackPoints: [nextPoint], + type: "aprs", + pkt, + lat, + lon, + info, + symbolTable, + symbolCode, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + stationMarkers.set(call, entry); + pruneAprsEntry(call, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); + if (aprsMap) scheduleDecodeMapMaintenance(); } - const allStationDetails = /* @__PURE__ */ new Map(); - allStationDetails.set(detailKey, { ...detailEntry }); - const entry = { - marker: null, - grid, - stations: /* @__PURE__ */ new Set(), - stationDetails: /* @__PURE__ */ new Map(), - allStationDetails, - sourceType: markerType, - bandMeta: /* @__PURE__ */ new Map(), - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - locatorMarkers.set(key, entry); - pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); - if (entry.marker) sendLocatorOverlayToBack(entry.marker); + }; + function syncSelectedAisTrackVisibility() { + if (!aprsMap) return; + const map = aprsMap; + const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; + aisMarkers.forEach((entry, key) => { + const track = entry?.track; + if (!track) return; + const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; + const onMap = map.hasLayer(track); + if (shouldShow && !onMap) { + track.addTo(map); + } + if (!shouldShow && onMap) { + track.removeFrom(map); + } + }); } - scheduleDecodeMapMaintenance(); - }; - document.querySelectorAll(".sub-tab-bar").forEach((bar) => { - bar.addEventListener("click", (e) => { - const btn = e.target.closest(".sub-tab[data-subtab]"); - if (!btn) return; - bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); - btn.classList.add("active"); - const parent = bar.parentElement; - parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); - const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); - if (nextPanel) nextPanel.style.display = ""; - if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) { - requestAnimationFrame(() => { - if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + function getAisAccentColor() { + return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; + } + function aisMarkerOptionsFromMessage(msg) { + const color = getAisAccentColor(); + return { + heading: msg?.heading_deg, + course: msg?.cog_deg, + speed: msg?.sog_knots, + color, + outline: "#00000055", + size: 22 + }; + } + function createAisMarker(lat, lon, msg) { + if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { + return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); + } + const color = getAisAccentColor(); + return L.circleMarker([lat, lon], { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.82 + }); + } + function updateAisMarker(marker, msg, popupHtml) { + if (!marker || msg.lat == null || msg.lon == null) return; + marker.setLatLng([msg.lat, msg.lon]); + if (typeof marker.setAisState === "function") { + marker.setAisState(aisMarkerOptionsFromMessage(msg)); + } + if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { + const color = getAisAccentColor(); + marker.setRadius(6); + marker.setStyle({ + color, + fillColor: color, + fillOpacity: 0.84 }); } - if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { - window.clearSatPredictionDom(); - } - }); - }); - window.addEventListener("resize", () => { - const mapTab = document.getElementById("tab-map"); - if (!mapTab || mapTab.style.display === "none") return; - sizeAprsMapToViewport(); - }); - function flushDeferredDecodeMapSync() { - if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; - T.decodeMapSyncPending = false; - scheduleUiFrameJob("decode-map-maintenance", () => { - pruneMapHistory(); - }); - } - function autoInitIfVisible() { - const panel = document.getElementById("tab-map"); - if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") { - _initMapWhenReady(); + marker.setPopupContent(popupHtml); } - } - modules.map = { - initAprsMap, - sizeAprsMapToViewport, - syncAprsReceiverMarker, - updateMapRigFilter, - updateStatsRigFilter, - statsRecordDecode, - scheduleStatsRender, - get aprsMap() { - return aprsMap; - }, - get stationMarkers() { - return stationMarkers; - }, - get locatorMarkers() { - return locatorMarkers; - }, - get aisMarkers() { - return aisMarkers; - }, - get vdesMarkers() { - return vdesMarkers; - }, - get decodeContactPaths() { - return decodeContactPaths; - }, - pruneMapHistory, - aprsSymbolIcon, - buildAprsPopupHtml, - buildAisPopupHtml, - buildVdesPopupHtml, - ensureAprsMarker, - ensureAisMarker, - ensureVdesMarker, - ensureDecodeLocatorMarker, - aprsPositionsEqual, - aisPositionsEqual, - refreshAprsTrack, - refreshAisTrack, - updateAisMarker, - createAisMarker, - getAisAccentColor, - refreshAisMarkerColors, - setMapRadioPathTo, - buildReceiverPopupHtml, - rebuildDecodeContactPaths, - syncDecodeContactPathVisibility, - scheduleDecodeMapMaintenance, - renderMapLocatorLegend, - rebuildMapLocatorFilters, - renderMapQsoSummary, - renderMapSignalSummary, - renderMapWeakSignalSummary, - vdesMarkerKey, - aisMarkerOptionsFromMessage, - materializeBufferedMapLayers, - flushDeferredDecodeMapSync, - bandForHz, - reverseGeocodeLocation - }; - autoInitIfVisible(); + function refreshAisMarkerColors() { + const color = getAisAccentColor(); + aisMarkers.forEach((entry) => { + if (entry.marker) { + if (typeof entry.marker.setAisState === "function") { + entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {})); + } else if (typeof entry.marker.setStyle === "function") { + entry.marker.setStyle({ color, fillColor: color }); + } + } + if (entry.track && typeof entry.track.setStyle === "function") { + entry.track.setStyle({ color }); + } + }); + } + mapWindow.aisMapAddVessel = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; + const key = String(msg.mmsi); + const popupHtml = buildAisPopupHtml(msg); + const nextPoint = [msg.lat, msg.lon]; + const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = aisMarkers.get(key); + if (existing) { + existing.msg = msg; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aisPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAisEntry(key, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + updateAisMarker(existing.marker, msg, popupHtml); + } + return; + } + const entry = { + marker: null, + track: null, + trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], + trackPoints: [nextPoint], + msg, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + aisMarkers.set(key, entry); + pruneAisEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAisMarker(key, entry); + scheduleDecodeMapMaintenance(); + }; + mapWindow.vdesMapAddPoint = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null) return; + const key = vdesMarkerKey(msg); + if (!key) return; + const popupHtml = buildVdesPopupHtml(msg); + const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = vdesMarkers.get(key); + if (existing) { + existing.msg = msg; + existing.visibleInHistoryWindow = visible; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!visible) { + if (!C.decodeHistoryMapRenderingDeferred()) { + setRetainedMapMarkerVisible(existing.marker, false); + } else { + C.markDecodeMapSyncPending(); + } + return; + } + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, existing); + setRetainedMapMarkerVisible(existing.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([msg.lat, msg.lon]); + existing.marker.setPopupContent(popupHtml); + } + return; + } + const entry = { + marker: null, + msg, + visibleInHistoryWindow: visible, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + vdesMarkers.set(key, entry); + if (!visible) return; + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { + entry.marker.setPopupContent(popupHtml); + } + scheduleDecodeMapMaintenance(); + }; + let reverseGeocodeLastKey = null; + function reverseGeocodeLocation(lat, lon, grid) { + const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; + if (key === reverseGeocodeLastKey) return; + reverseGeocodeLastKey = key; + const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; + fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Reverse geocode failed: ${r.status}`))).then((data) => { + const addr = data?.address; + if (!addr) return; + const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; + const country = addr.country || ""; + if (!city && !country) return; + const label = city && country ? `${city}, ${country}` : city || country; + T.lastCityLabel = label; + if (T.locationSubtitle) { + T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; + } + C.updateDocumentTitle?.(); + }).catch(() => { + }); + } + function maidenheadToBounds(grid) { + if (!grid || grid.length < 4) return null; + const g = grid.toUpperCase(); + const A = "A".charCodeAt(0); + const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; + const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; + const squareLon = parseInt(g.charAt(2), 10) * 2; + const squareLat = parseInt(g.charAt(3), 10); + let lon = fieldLon + squareLon; + let lat = fieldLat + squareLat; + let lonSpan = 2; + let latSpan = 1; + if (g.length >= 6) { + const subLon = (g.charCodeAt(4) - A) * (5 / 60); + const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); + lon += subLon; + lat += subLat; + lonSpan = 5 / 60; + latSpan = 2.5 / 60; + } + return [ + [lat, lon], + [lat + latSpan, lon + lonSpan] + ]; + } + function applyMapFilter() { + if (!aprsMap) return; + const map = aprsMap; + const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const noneSelected = sourceKeys.every((k) => !mapFilter[k]); + mapMarkers.forEach((marker) => { + const type = marker.__trxType; + const sourceVisible = noneSelected ? type == null || DEFAULT_MAP_SOURCE_FILTER[type] : type == null || !!mapFilter[type]; + const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); + const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; + const onMap = map.hasLayer(marker); + if (visible && !onMap) { + marker.addTo(map); + sendLocatorOverlayToBack(marker); + } + if (!visible && onMap) marker.removeFrom(map); + }); + syncSelectedAisTrackVisibility(); + syncDecodeContactPathVisibility(); + } + function updateMapContactPathsToggle() { + const btn = mapEl("map-contact-paths-toggle"); + if (!btn) return; + btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; + btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); + } + function updateMapP2pPathsToggle() { + const btn = mapEl("map-p2p-paths-toggle"); + if (!btn) return; + btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; + btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); + } + function scheduleDecodeMapMaintenance() { + if (C.decodeHistoryMapRenderingDeferred()) { + C.markDecodeMapSyncPending(); + return; + } + scheduleUiFrameJob("decode-map-maintenance", () => { + rebuildDecodeContactPaths(); + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + function formatDecodeLocatorTime(tsMs) { + if (!Number.isFinite(tsMs)) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + function formatMapPopupFreq(hz) { + if (!Number.isFinite(hz)) return "--"; + const value = Number(hz); + if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; + if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; + if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; + return `${Math.round(value)} Hz`; + } + function buildDecodeLocatorTooltipHtml(grid, entry, type) { + const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; + details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); + const title = type === "wspr" ? "WSPR" : "FT8"; + const rows = details.map((detail) => { + const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); + const freq = formatMapPopupFreq(Number(detail?.freq_hz)); + const meta = [ + detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, + Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, + Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, + escapeMapHtml(freq) + ].filter(Boolean).join(" · "); + const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; + const rxHtml = remoteIds.map((rid) => { + const label = _receiverLabel(rid); + return label ? `
${escapeMapHtml(label)}
` : ""; + }).filter(Boolean).join(""); + const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; + return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; + }).join(""); + const count = Math.max( + 1, + details.length, + entry?.stations instanceof Set ? entry.stations.size : 0 + ); + return `
${escapeMapHtml(grid)}
${title} · ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; + } + function rebuildDecodeContactPaths() { + clearDecodeContactPaths(); + const stationLocators = /* @__PURE__ */ new Map(); + const directedMessages = []; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + const grid = String(entry.grid || "").trim().toUpperCase(); + if (!grid || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + const target = String(detail?.target || "").trim().toUpperCase(); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + if (source) { + const prev = stationLocators.get(source); + if (!prev || tsMs >= prev.tsMs) { + stationLocators.set(source, { grid, tsMs }); + } + } + if (source && target && source !== target) { + const band = bandForHz(Number(detail?.freq_hz)); + directedMessages.push({ + source, + target, + sourceGrid: grid, + sourceType: entry.sourceType, + tsMs, + bandLabel: band?.label || null, + remote: detail?.remote || null + }); + } + } + } + for (const msg of directedMessages) { + const targetLocator = stationLocators.get(msg.target); + if (!targetLocator) continue; + if (msg.sourceGrid === targetLocator.grid) continue; + const sourceCenter = locatorToLatLon(msg.sourceGrid); + const targetCenter = locatorToLatLon(targetLocator.grid); + if (!sourceCenter || !targetCenter) continue; + const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); + const key = [msg.source, msg.target].sort().join("::"); + const prev = decodeContactPaths.get(key); + if (prev?.tsMs != null && prev.tsMs > msg.tsMs) continue; + decodeContactPaths.set(key, { + pathKey: key, + source: msg.source, + target: msg.target, + sourceGrid: msg.sourceGrid, + targetGrid: targetLocator.grid, + sourceType: msg.sourceType, + bandLabel: msg.bandLabel, + from: sourceCenter, + to: targetCenter, + tsMs: msg.tsMs, + distanceKm, + distanceText: formatDecodeContactDistance(distanceKm), + line: null, + labelMarker: null, + remote: msg.remote + }); + } + syncDecodeContactPathVisibility(); + } + function _receiverLabel(rigId) { + if (!rigId) return null; + const rig = T.serverRigs.find((r) => r.remote === rigId); + const name = T.lastRigDisplayNames[rigId] || rigId; + if (rig && rig.latitude != null && rig.longitude != null) { + const grid = latLonToMaidenhead(rig.latitude, rig.longitude); + return `${name} (${grid})`; + } + return name; + } + function renderMapQsoSummary() { + const listEl = mapEl("map-qso-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { + const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); + if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; + return Number(b.tsMs || 0) - Number(a.tsMs || 0); + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { + selectedMapQsoKey = null; + } + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); + card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); + card.addEventListener("click", () => { + selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; + syncDecodeContactPathVisibility(); + if (selectedMapQsoKey && entry.sourceGrid) { + mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); + } + }); + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const distance = document.createElement("span"); + distance.className = "map-qso-card-distance"; + distance.textContent = entry.distanceText || "--"; + head.appendChild(distance); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapSignalSummary() { + const listEl = mapEl("map-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const bestByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = bestByStation.get(station); + if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + bestByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(bestByStation.values()).sort((a, b) => { + const delta = b.snrDb - a.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapWeakSignalSummary() { + const listEl = mapEl("map-weak-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const worstByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = worstByStation.get(station); + if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + worstByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(worstByStation.values()).sort((a, b) => { + const delta = a.snrDb - b.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + let statsRigFilter = ""; + let statsHistoryLimitMinutes = 1440; + const statsDecodeLog = []; + const STATS_LOG_MAX = 5e4; + const STATS_TYPE_COLORS = { + ft8: "#4fc3f7", + ft4: "#81c784", + ft2: "#aed581", + wspr: "#ffb74d", + aprs: "#ce93d8", + hf_aprs: "#ba68c8", + ais: "#90a4ae", + vdes: "#78909c", + cw: "#fff176" + }; + const STATS_DX_BUCKETS = [ + { label: "0–500 km", min: 0, max: 500 }, + { label: "500–1k", min: 500, max: 1e3 }, + { label: "1k–2k", min: 1e3, max: 2e3 }, + { label: "2k–5k", min: 2e3, max: 5e3 }, + { label: "5k–10k", min: 5e3, max: 1e4 }, + { label: "10k+ km", min: 1e4, max: Infinity } + ]; + function _statsHistoryCutoffMs() { + return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; + } + function _statsDetailPassesRigFilter(detail) { + if (!statsRigFilter) return true; + if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); + return detail?.remote === statsRigFilter; + } + function updateStatsRigFilter() { + const el = mapEl("stats-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); + } + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + statsRigFilter = ""; + } + } + function statsRecordDecode(type, remote, tsMs) { + statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); + if (statsDecodeLog.length > STATS_LOG_MAX) { + statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); + } + } + function _statsFilteredLog() { + const cutoff = _statsHistoryCutoffMs(); + return statsDecodeLog.filter((e) => { + if (e.ts_ms < cutoff) return false; + if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false; + return true; + }); + } + function renderStatsCounters() { + const cutoff = _statsHistoryCutoffMs(); + const log = _statsFilteredLog(); + const totalDecodes = log.length; + const uniqueStations = /* @__PURE__ */ new Set(); + const uniqueGrids = /* @__PURE__ */ new Set(); + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (station) uniqueStations.add(station); + } + if (entry.grid) { + const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some( + (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d) + ); + if (hasVisible) uniqueGrids.add(entry.grid); + } + } + const rateWindow = Date.now() - 6e4; + const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; + const setEl = (id, val) => { + const el = mapEl(id); + if (el) el.textContent = String(val); + }; + setEl("stats-total-decodes", totalDecodes.toLocaleString()); + setEl("stats-unique-stations", uniqueStations.size.toLocaleString()); + setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); + setEl("stats-decode-rate", recentCount.toLocaleString()); + } + function _renderBarChart(containerId, data, emptyMsg) { + const el = mapEl(containerId); + if (!el) return; + if (!data || data.length === 0 || data.every((d) => d.count === 0)) { + el.replaceChildren(); + const empty = document.createElement("div"); + empty.className = "stats-bar-empty"; + empty.textContent = emptyMsg || "No data available."; + el.appendChild(empty); + return; + } + const maxVal = Math.max(1, ...data.map((d) => d.count)); + const fragment = document.createDocumentFragment(); + for (const item of data) { + const row = document.createElement("div"); + row.className = "stats-bar-row"; + const label = document.createElement("span"); + label.className = "stats-bar-label"; + label.textContent = item.label; + row.appendChild(label); + const track = document.createElement("div"); + track.className = "stats-bar-track"; + const fill = document.createElement("div"); + fill.className = "stats-bar-fill"; + fill.style.width = `${item.count / maxVal * 100}%`; + fill.style.background = item.color || "var(--accent-green)"; + track.appendChild(fill); + row.appendChild(track); + const count = document.createElement("span"); + count.className = "stats-bar-count"; + count.textContent = item.count.toLocaleString(); + row.appendChild(count); + fragment.appendChild(row); + } + el.replaceChildren(fragment); + } + function renderStatsDecodeTypes() { + const log = _statsFilteredLog(); + const counts = {}; + for (const e of log) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + const data = Object.entries(counts).map(([type, count]) => ({ + label: type.toUpperCase(), + count, + color: STATS_TYPE_COLORS[type] || "#aaa" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); + } + function renderStatsBandActivity() { + const cutoff = _statsHistoryCutoffMs(); + const bandCounts = {}; + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (!Number.isFinite(detail?.freq_hz)) continue; + const band = bandForHz(Number(detail.freq_hz)); + if (band) { + bandCounts[band.label] = (bandCounts[band.label] || 0) + 1; + } + } + } + const data = Object.entries(bandCounts).map(([label, count]) => ({ + label, + count, + color: locatorBandChipColor(label) + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); + } + function renderStatsRigCompare() { + const section = mapEl("stats-rig-compare-section"); + if (!section) return; + if (T.lastRigIds.length < 2) { + section.style.display = "none"; + return; + } + section.style.display = ""; + const cutoff = _statsHistoryCutoffMs(); + const rigCounts = {}; + for (const e of statsDecodeLog) { + if (e.ts_ms < cutoff) continue; + const rid = e.remote || "unknown"; + rigCounts[rid] = (rigCounts[rid] || 0) + 1; + } + const data = Object.entries(rigCounts).map(([rid, count]) => ({ + label: T.lastRigDisplayNames[rid] || rid, + count, + color: "var(--accent-green)" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); + } + function renderStatsDxHistogram() { + const cutoff = _statsHistoryCutoffMs(); + const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); + for (const entry of decodeContactPaths.values()) { + if (!entry || !Number.isFinite(entry.distanceKm)) continue; + if (entry.tsMs && entry.tsMs < cutoff) continue; + if (!_statsDetailPassesRigFilter(entry)) continue; + const km = entry.distanceKm; + if (km == null) continue; + for (const b of buckets) { + if (km >= b.min && km < b.max) { + b.count++; + break; + } + } + } + const data = buckets.map((b) => ({ + label: b.label, + count: b.count, + color: "#4fc3f7" + })); + _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); + } + let _statsRenderPending = false; + let _statsControlsWired = false; + function _wireStatsControls() { + if (_statsControlsWired) return; + const rigEl = mapEl("stats-rig-filter"); + const histEl = mapEl("stats-history-limit"); + if (!rigEl && !histEl) return; + _statsControlsWired = true; + if (rigEl) { + rigEl.addEventListener("change", () => { + statsRigFilter = rigEl.value; + scheduleStatsRender(); + }); + } + if (histEl) { + histEl.value = String(statsHistoryLimitMinutes); + histEl.addEventListener("change", () => { + statsHistoryLimitMinutes = Number(histEl.value) || 1440; + scheduleStatsRender(); + }); + } + } + function scheduleStatsRender() { + if (_statsRenderPending) return; + _statsRenderPending = true; + requestAnimationFrame(() => { + _statsRenderPending = false; + _wireStatsControls(); + renderStatsCounters(); + renderStatsDecodeTypes(); + renderStatsBandActivity(); + renderStatsRigCompare(); + renderStatsDxHistogram(); + renderMapQsoSummary(); + renderMapSignalSummary(); + renderMapWeakSignalSummary(); + }); + } + function buildBookmarkLocatorPopupHtml(grid, bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const rows = list.map((bm) => { + const title = escapeMapHtml(String(bm.name || "Bookmark")); + const freq = escapeMapHtml(formatFreqForHumans(Number(bm.freq_hz))); + const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; + return `${title} ${freq}${mode}`; + }).join("
"); + return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); + } + mapWindow.syncBookmarkMapLocators = function(bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const grouped = /* @__PURE__ */ new Map(); + for (const bm of list) { + const grid = String(bm?.locator || "").trim().toUpperCase(); + if (!grid) continue; + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const key = `bookmark:${grid}`; + const bucket = grouped.get(key); + if (bucket) { + bucket.bookmarks.push(bm); + } else { + grouped.set(key, { grid, bounds, bookmarks: [bm] }); + } + } + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (!grouped.has(key)) { + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + } + for (const [key, next] of grouped.entries()) { + const existing = locatorMarkers.get(key); + const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); + const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz))); + if (existing) { + existing.grid = next.grid; + existing.bounds = next.bounds; + existing.bookmarks = next.bookmarks; + existing.sourceType = "bookmark"; + existing.bandMeta = bandMeta; + if (existing.marker) { + existing.marker.setBounds(next.bounds); + existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length)); + existing.marker.setPopupContent(popupHtml); + sendLocatorOverlayToBack(existing.marker); + assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); + } + continue; + } + const entry = { + marker: null, + grid: next.grid, + bounds: next.bounds, + bookmarks: next.bookmarks, + sourceType: "bookmark", + bandMeta + }; + locatorMarkers.set(key, entry); + if (aprsMap) { + entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); + entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); + assignLocatorMarkerMeta(entry.marker, entry.sourceType ?? "bookmark", entry.bandMeta); + mapMarkers.add(entry.marker); + } + } + rebuildMapLocatorFilters(); + applyMapFilter(); + }; + mapWindow.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { + if (!Array.isArray(grids) || grids.length === 0) return; + const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; + const msgRigId = details?.rig_id || T.lastActiveRigId; + const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; + const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; + const locatorDetails = /* @__PURE__ */ new Map(); + if (Array.isArray(details?.locator_details)) { + for (const locatorDetail of details.locator_details) { + const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); + if (!grid) continue; + locatorDetails.set(grid, locatorDetail); + } + } + for (const grid of unique) { + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const locatorDetail = locatorDetails.get(grid); + const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; + const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; + const detailStationId = sourceId || stationId; + const detailEntry = { + station: detailStationId || null, + source: sourceId || null, + target: targetId || null, + ts_ms: details && Number.isFinite(details.ts_ms) ? Number(details.ts_ms) : null, + snr_db: details && Number.isFinite(details.snr_db) ? Number(details.snr_db) : null, + dt_s: details && Number.isFinite(details.dt_s) ? Number(details.dt_s) : null, + freq_hz: details && Number.isFinite(details.freq_hz) ? Number(details.freq_hz) : null, + message: String(details?.message || message || "").trim() || null, + remote: msgRigId || null, + remotes: new Set(msgRigId ? [msgRigId] : []) + }; + const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; + const key = `${markerType}:${grid}`; + const existing = locatorMarkers.get(key); + if (existing) { + existing.grid = grid; + if (!(existing.allStationDetails instanceof Map)) { + existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); + } + const prevDetail = existing.allStationDetails.get(detailKey); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); + if (msgRigId) mergedRemotes.add(msgRigId); + existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); + existing.sourceType = markerType; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); + if (existing.marker) sendLocatorOverlayToBack(existing.marker); + scheduleDecodeMapMaintenance(); + continue; + } + const allStationDetails = /* @__PURE__ */ new Map(); + allStationDetails.set(detailKey, { ...detailEntry }); + const entry = { + marker: null, + grid, + stations: /* @__PURE__ */ new Set(), + stationDetails: /* @__PURE__ */ new Map(), + allStationDetails, + sourceType: markerType, + bandMeta: /* @__PURE__ */ new Map(), + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + locatorMarkers.set(key, entry); + pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); + if (entry.marker) sendLocatorOverlayToBack(entry.marker); + } + scheduleDecodeMapMaintenance(); + }; + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + bar.addEventListener("click", (e) => { + const btn = e.target?.closest(".sub-tab[data-subtab]"); + if (!btn) return; + bar.querySelectorAll(".sub-tab").forEach((t) => { + t.classList.remove("active"); + }); + btn.classList.add("active"); + const parent = bar.parentElement; + if (!parent) return; + parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); + const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); + if (nextPanel) nextPanel.style.display = ""; + if (btn.dataset.subtab === "cw" && mapWindow.refreshCwTonePicker) { + requestAnimationFrame(() => { + if (mapWindow.refreshCwTonePicker) mapWindow.refreshCwTonePicker(); + }); + } + if (btn.dataset.subtab !== "sat" && typeof mapWindow.clearSatPredictionDom === "function") { + mapWindow.clearSatPredictionDom(); + } + }); + }); + window.addEventListener("resize", () => { + const mapTab = mapEl("tab-map"); + if (!mapTab || mapTab.style.display === "none") return; + sizeAprsMapToViewport(); + }); + function flushDeferredDecodeMapSync() { + if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; + T.decodeMapSyncPending = false; + scheduleUiFrameJob("decode-map-maintenance", () => { + pruneMapHistory(); + }); + } + function autoInitIfVisible() { + const panel = mapEl("tab-map"); + if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") { + _initMapWhenReady(); + } + } + modules.map = { + initAprsMap, + sizeAprsMapToViewport, + syncAprsReceiverMarker, + updateMapRigFilter, + updateStatsRigFilter, + statsRecordDecode, + scheduleStatsRender, + get aprsMap() { + return aprsMap; + }, + get stationMarkers() { + return stationMarkers; + }, + get locatorMarkers() { + return locatorMarkers; + }, + get aisMarkers() { + return aisMarkers; + }, + get vdesMarkers() { + return vdesMarkers; + }, + get decodeContactPaths() { + return decodeContactPaths; + }, + pruneMapHistory, + aprsSymbolIcon, + buildAprsPopupHtml, + buildAisPopupHtml, + buildVdesPopupHtml, + ensureAprsMarker, + ensureAisMarker, + ensureVdesMarker, + ensureDecodeLocatorMarker, + aprsPositionsEqual, + aisPositionsEqual, + refreshAprsTrack, + refreshAisTrack, + updateAisMarker, + createAisMarker, + getAisAccentColor, + refreshAisMarkerColors, + setMapRadioPathTo, + buildReceiverPopupHtml, + rebuildDecodeContactPaths, + syncDecodeContactPathVisibility, + scheduleDecodeMapMaintenance, + renderMapLocatorLegend, + rebuildMapLocatorFilters, + renderMapQsoSummary, + renderMapSignalSummary, + renderMapWeakSignalSummary, + vdesMarkerKey, + aisMarkerOptionsFromMessage, + materializeBufferedMapLayers, + flushDeferredDecodeMapSync, + bandForHz, + reverseGeocodeLocation + }; + autoInitIfVisible(); + })(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js index 090bf81f..2fa74545 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js @@ -10,25 +10,11 @@ const pluginGroups = { }; const loaded = /* @__PURE__ */ new Set(); const loading = /* @__PURE__ */ new Map(); -const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js", "/scheduler.js"]); -function loadLegacyScript(path) { - return new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = path; - script.addEventListener("load", () => { - resolve(); - }, { once: true }); - script.addEventListener("error", () => { - reject(new Error(`Failed to load plugin script: ${path}`)); - }, { once: true }); - document.body.appendChild(script); - }); -} async function loadPlugin(path) { if (loaded.has(path)) return; const pending = loading.get(path); if (pending) return pending; - const request = (modulePlugins.has(path) ? import(path).then(() => void 0) : loadLegacyScript(path)).then(() => { + const request = import(path).then(() => { loaded.add(path); loading.delete(path); }).catch((error) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 3d56c4d1..6730b90e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -18,7 +18,6 @@ await build({ "api-client": path.join(sourceDir, "api", "client.ts"), app: path.join(sourceDir, "app.js"), "ui-core": path.join(sourceDir, "ui-core.ts"), - "map-core": path.join(sourceDir, "map-core.js"), "plugin-loader": path.join(sourceDir, "plugin-loader.ts"), "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"), screenshot: path.join(sourceDir, "screenshot.ts"), @@ -52,6 +51,7 @@ await build({ vchan: path.join(sourceDir, "plugins", "vchan.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), + "map-core": path.join(sourceDir, "map-core.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json index 730bd925..b9470a7a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "devDependencies": { "@eslint/js": "9.39.2", + "@types/leaflet": "1.9.22", "esbuild": "0.25.12", "eslint": "9.39.2", "globals": "16.5.0", @@ -691,6 +692,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -698,6 +706,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.51.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index 45646d52..19839d6f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", + "@types/leaflet": "1.9.22", "esbuild": "0.25.12", "eslint": "9.39.2", "globals": "16.5.0", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts similarity index 78% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts index 2e69dd8b..7a3cccb5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts @@ -2,22 +2,195 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import type * as Leaflet from "leaflet"; + +export {}; + +interface TrackSymbolFactory { + trackSymbol(latlng: Leaflet.LatLngExpression, options?: Record): Leaflet.Marker; + trxAisTrackSymbol?: (latlng: Leaflet.LatLngExpression, options?: Record) => Leaflet.Marker; +} +declare const L: typeof Leaflet & TrackSymbolFactory; + +/* Map controls are server-rendered and required while this feature is active. */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* The transition bridge exposes stateless core helpers as object methods. */ +/* eslint-disable @typescript-eslint/unbound-method */ +/* Wire payload values are normalized deliberately at display boundaries. */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-conversion, @typescript-eslint/no-base-to-string */ +type MapElement = HTMLElement & HTMLInputElement & HTMLSelectElement & HTMLCanvasElement; +type WebkitDocument = Document & { + webkitFullscreenElement?: Element | null; + webkitExitFullscreen?: () => Promise; +}; +type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => Promise }; +function mapEl(id: string): MapElement { + const element = document.querySelector(`#${CSS.escape(id)}`); + if (!element) throw new Error(`Missing map element #${id}`); + return element as MapElement; +} + +type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat"; +interface TrackPoint { lat: number; lon: number; tsMs: number } +interface DecodeDetail { + station?: string | null; source?: string | null; target?: string | null; + ts_ms?: number | null; snr_db?: number | null; dt_s?: number | null; + freq_hz?: number | null; message?: string | null; remote?: string | null; + remotes?: Set; +} +interface MapMessage { + lat?: number | null; lon?: number | null; _tsMs?: number; ts_ms?: number | null; + mmsi?: string | number; remote?: string | null; rig_id?: string | null; + [key: string]: unknown; +} +interface LayerMetadata { + __trxType?: string; __trxRigIds?: Set; __trxHistoryVisible?: boolean; + __trxSatKey?: string; _aprsCall?: string; _aisMmsi?: string; _vdesKey?: string; + _receiverLocKey?: string; _receiverRigs?: Set; _locatorFilterMeta?: LocatorFilterMetadata; + _path?: HTMLElement; +} +type TrxLayer = Leaflet.Layer & LayerMetadata & { + addTo(map: Leaflet.Map | Leaflet.LayerGroup): TrxLayer; + bindPopup(content: string): TrxLayer; + bringToBack(): TrxLayer; + getBounds(): Leaflet.LatLngBounds; + getLatLng(): Leaflet.LatLng; + openPopup(): TrxLayer; + remove(): TrxLayer; + removeFrom(map: Leaflet.Map | Leaflet.LayerGroup): TrxLayer; + setLatLng(latlng: Leaflet.LatLngExpression): TrxLayer; + setLatLngs(latlngs: Leaflet.LatLngExpression[]): TrxLayer; + setIcon(icon: Leaflet.Icon | Leaflet.DivIcon): TrxLayer; + getElement(): HTMLElement | null; + setOpacity(opacity: number): TrxLayer; + setPopupContent(content: string): TrxLayer; + setRadius(radius: number): TrxLayer; + setStyle(style: Leaflet.PathOptions): TrxLayer; + setBounds(bounds: Leaflet.LatLngBoundsExpression): TrxLayer; + setAisState?(state: Record): void; +}; +interface MapEntry { + marker?: TrxLayer | null; + track?: TrxLayer | null; + trackPoints?: Leaflet.LatLngExpression[]; + trackHistory?: TrackPoint[]; + lat?: number; lon?: number; info?: string; pkt?: MapMessage; msg?: MapMessage; + visibleInHistoryWindow?: boolean; + grid?: string; sourceType?: DecoderSource; rigIds?: Set; + stations?: Set; stationDetails?: Map; + allStationDetails?: Map; bandMeta?: Map; + bookmarks?: Array>; + bounds?: Leaflet.LatLngBoundsExpression; + symbolTable?: string; symbolCode?: string; bandLabel?: string | null; + overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null; + pathKey?: string; sourceGrid?: string; targetGrid?: string; + from?: LatLon; to?: LatLon; + distanceKm?: number; distanceText?: string; snrDb?: number; tsMs?: number; + type?: string; remote?: string | null; station?: string | null; source?: string | null; target?: string | null; +} +type MapFilterKey = DecoderSource; +interface LocatorFilterMetadata { + sourceType: DecoderSource; + bands: Set; + bandMeta: Map; +} +interface FilterChip { key: string; label: string; color: string; kind: string; sortHz?: number } +interface LatLon { lat: number; lon: number } +interface MapColor { r: number; g: number; b: number } +interface ReceiverGroup { lat: number; lon: number; rigs: Set; hasActive: boolean } +interface SatelliteOverlay { overlay: TrxLayer; track: TrxLayer | null; msg: MapMessage } +interface StatsDecode { type: string; ts_ms: number; remote: string | null } +interface StatsBar { label: string; count: number; color: string } +interface BookmarkGroup { grid: string; bounds: Leaflet.LatLngBoundsLiteral; bookmarks: Array> } +interface SignalSummary { + station: string; snrDb: number; tsMs: number; grid: string | undefined; + sourceType: DecoderSource; bandLabel: string | null; remote: string | null; +} + +interface MapState { + _activeTab: string; + decodeHistoryReplayActive: boolean; + decodeMapSyncPending: boolean; + initialMapZoom: number; + lastActiveRigId: string | null; + lastCityLabel: string; + lastRigDisplayNames: Record; + lastRigIds: string[]; + locationSubtitle: HTMLElement | null; + ownerCallsign: string; + serverActiveRigId: string | null; + serverBuildDate: string; + serverCallsign: string; + serverLat: number | null; + serverLon: number | null; + serverRigs: Array<{ remote?: string; latitude?: number | null; longitude?: number | null; display_name?: string; manufacturer?: string; model?: string }>; + serverVersion: string; +} + +interface MapCore { + saveSetting(key: string, value: unknown): void; + loadSetting(key: string, fallback: unknown): unknown; + showHint(message: string): void; + escapeMapHtml(value: unknown): string; + formatFreq(value: number): string; + formatFreqForHumans(value: number): string; + postPath(path: string): Promise; + scheduleUiFrameJob(key: string, callback: () => void): void; + navigateToTab(tab: string): void; + rigBadgeColor(id: string): string; + formatUptime(seconds: number): string; + latLonToMaidenhead(lat: number, lon: number): string; + locatorToLatLon(locator: string): { lat: number; lon: number } | null; + haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number; + formatDistanceKm(km: number): string; + formatTimeAgo(timestampMs: number): string; + currentDecodeHistoryRetentionMs(): number; + formatWavelength(frequencyHz: number): string; + bookmarkDistanceText(bookmark: Record): string; + buildBookmarkTooltipText(bookmark: Record): string; + nearestBookmarkForHz(frequencyHz: number, widthPx: number, range: Record): Record | null; + activeChannelRds(): { textContent?: string } | null; + canvasPalette(): { spectrumLine?: string; waveformLine?: string; waveformPeak?: string }; + currentTheme(): string; + decodeHistoryMapRenderingDeferred(): boolean; + markDecodeMapSyncPending(): void; + updateDocumentTitle?(): void; +} + +interface MapWindow extends Window { + trx: { state: MapState; core: MapCore; modules: { map?: unknown } }; + ft8BaseHz?: number; + refreshCwTonePicker?(): void; + clearSatPredictionDom?(): void; + buildAisVesselUrl?(mmsi: unknown): string | null; + enableMapSourceFilter?(key: DecoderSource): void; + addSatMapOverlay?(message: MapMessage): void; + removeSatMapOverlay?(key: string): void; + clearSatMapOverlays?(): void; + clearMapMarkersByType?(type: DecoderSource): void; + navigateToAprsMap?(lat: number, lon: number): void; + navigateToMapLocator?(grid: string, preferredType?: string | null): void; + aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void; + aisMapAddVessel?(message: MapMessage): void; + vdesMapAddPoint?(message: MapMessage): void; + syncBookmarkMapLocators?(bookmarks: Array>): void; + mapAddLocator?(message: string, grids: string[], type?: string, station?: string | null, details?: MapMessage | null): void; +} +const mapWindow = window as unknown as MapWindow; + // Map, statistics, and geolocation module (lazy-loaded on map tab activation). // Communicates with app.js through explicit state/core/module services. (function () { "use strict"; - const { state: T, core: C, modules } = window.trx; + const { state: T, core: C, modules } = mapWindow.trx; // Destructure shared utility functions for convenience - const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, - postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, - formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm, - formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs, - formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText, - nearestBookmarkForHz } = C; + const { saveSetting, loadSetting, escapeMapHtml, formatFreqForHumans, + scheduleUiFrameJob, latLonToMaidenhead, locatorToLatLon, haversineKm, + formatDistanceKm, formatTimeAgo } = C; function updateMapRigFilter() { - const el = document.getElementById("map-rig-filter"); + const el = mapEl("map-rig-filter"); if (!el) return; const prev = el.value; while (el.options.length > 1) el.remove(1); @@ -37,39 +210,38 @@ } // --- Leaflet Map (lazy-initialized) --- - let aprsMap = null; - let aprsMapBaseLayer = null; - let aprsMapReceiverMarker = null; - let aprsMapReceiverMarkers = {}; // keyed by rig remote id - let aprsRadioPaths = []; - let selectedLocatorMarker = null; - let selectedLocatorPulseRaf = null; + let aprsMap: Leaflet.Map | null = null; + let aprsMapBaseLayer: Leaflet.TileLayer | null = null; + const aprsMapReceiverMarkers: Record = {}; + let aprsRadioPaths: TrxLayer[] = []; + let selectedLocatorMarker: TrxLayer | null = null; + let selectedLocatorPulseRaf: number | null = null; let mapFullscreenListenerBound = false; let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; const MAP_QSO_SUMMARY_LIMIT = 5; - const stationMarkers = new Map(); - const locatorMarkers = new Map(); - const decodeContactPaths = new Map(); - let selectedMapQsoKey = null; - const mapMarkers = new Set(); - const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; - const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; - const mapLocatorFilter = { phase: "band", bands: new Set() }; + const stationMarkers = new Map(); + const locatorMarkers = new Map(); + const decodeContactPaths = new Map(); + let selectedMapQsoKey: string | null = null; + const mapMarkers = new Set(); + const DEFAULT_MAP_SOURCE_FILTER: Record = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const mapFilter: Record = { ...DEFAULT_MAP_SOURCE_FILTER }; + const mapLocatorFilter: { phase: "band" | "type"; bands: Set } = { phase: "band", bands: new Set() }; let mapSearchFilter = ""; let mapRigFilter = ""; // "" = all rigs - let mapHistoryPruneTimer = null; + let mapHistoryPruneTimer: number | null = null; let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( Number(loadSetting("mapHistoryLimitMinutes", 1440)) ); const APRS_TRACK_MAX_POINTS = 64; const AIS_TRACK_MAX_POINTS = 64; - const aisMarkers = new Map(); - const vdesMarkers = new Map(); - let selectedAprsTrackCall = null; - let selectedAisTrackMmsi = null; + const aisMarkers = new Map(); + const vdesMarkers = new Map(); + let selectedAprsTrackCall: string | null = null; + let selectedAisTrackMmsi: string | null = null; const HAM_BANDS = [ { label: "2200m", meters: 2200 }, { label: "630m", meters: 630 }, @@ -99,17 +271,17 @@ nominalHz: 299_792_458 / band.meters, })); - function normalizeLocatorFreqHz(hz) { + function normalizeLocatorFreqHz(hz: number): number | null { if (!Number.isFinite(hz) || hz <= 0) return null; if (hz >= 100_000) return hz; - const baseHz = Number(window.ft8BaseHz); + const baseHz = Number(mapWindow.ft8BaseHz); if (Number.isFinite(baseHz) && baseHz > 0) { return baseHz + hz; } return hz; } - function normalizeMapHistoryLimitMinutes(value) { + function normalizeMapHistoryLimitMinutes(value: number): number { const minutes = Math.round(Number(value)); return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; } @@ -118,7 +290,7 @@ return Date.now() - (mapHistoryLimitMinutes * 60 * 1000); } - function trimTrackHistory(history, cutoffMs, maxPoints) { + function trimTrackHistory(history: TrackPoint[] | undefined, cutoffMs: number, maxPoints: number): TrackPoint[] { const list = Array.isArray(history) ? history : []; const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); if (trimmed.length > maxPoints) { @@ -127,7 +299,7 @@ return trimmed; } - function refreshAprsTrack(call, entry) { + function refreshAprsTrack(call: string, entry: MapEntry): void { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (entry.track) { @@ -147,13 +319,13 @@ lineCap: "round", lineJoin: "round", interactive: false, - }); + }) as unknown as TrxLayer; track.__trxType = "aprs"; track._aprsCall = call; entry.track = track; } - function refreshAisTrack(mmsi, entry) { + function refreshAisTrack(mmsi: string, entry: MapEntry): void { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (entry.track) { @@ -174,25 +346,15 @@ lineJoin: "round", interactive: false, dashArray: "5 4", - }); + }) as unknown as TrxLayer; track.__trxType = "ais"; track._aisMmsi = mmsi; entry.track = track; } - function removeMapMarker(marker) { + function setRetainedMapMarkerVisible(marker: TrxLayer | null | undefined, visible: boolean): void { if (!marker) return; - if (marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); - mapMarkers.delete(marker); - } - - function setRetainedMapMarkerVisible(marker, visible) { - if (!marker) return; - marker.__trxHistoryVisible = visible !== false; + marker.__trxHistoryVisible = visible; if (!visible) { if (marker === selectedLocatorMarker) { setSelectedLocatorMarker(null); @@ -202,16 +364,16 @@ } } - function ensureAprsMarker(call, entry) { + function ensureAprsMarker(call: string, entry: MapEntry): void { if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; _aprsAddMarkerToMap(call, entry); } - function ensureAisMarker(key, entry) { + function ensureAisMarker(key: string, entry: MapEntry): void { if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg) .addTo(aprsMap) - .bindPopup(buildAisPopupHtml(entry.msg)); + .bindPopup(buildAisPopupHtml(entry.msg)) as unknown as TrxLayer; marker.__trxType = "ais"; marker.__trxRigIds = entry.rigIds || new Set(); marker._aisMmsi = String(key); @@ -219,14 +381,14 @@ mapMarkers.add(marker); } - function ensureVdesMarker(key, entry) { + function ensureVdesMarker(key: string, entry: MapEntry): void { if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { radius: 5, color: "#5c394f", fillColor: "#c46392", fillOpacity: 0.82, - }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); + }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)) as unknown as TrxLayer; marker.__trxType = "vdes"; marker.__trxRigIds = entry.rigIds || new Set(); marker._vdesKey = String(key); @@ -234,15 +396,15 @@ mapMarkers.add(marker); } - function ensureDecodeLocatorMarker(entry) { + function ensureDecodeLocatorMarker(entry: MapEntry): void { if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return; const bounds = maidenheadToBounds(entry.grid); if (!bounds) return; const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); - const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)) + const marker = L.rectangle(bounds as Leaflet.LatLngBoundsExpression, locatorStyleForEntry(entry, count)) .addTo(aprsMap) - .bindPopup(tooltipHtml); + .bindPopup(tooltipHtml) as unknown as TrxLayer; marker.__trxType = entry.sourceType; marker.__trxRigIds = entry.rigIds || new Set(); sendLocatorOverlayToBack(marker); @@ -251,7 +413,7 @@ mapMarkers.add(marker); } - function pruneAprsEntry(call, entry, cutoffMs) { + function pruneAprsEntry(call: string, entry: MapEntry, cutoffMs: number): boolean { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; const pktTsMs = Number(entry?.pkt?._tsMs); const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; @@ -277,14 +439,14 @@ if (!canRenderMap) return true; ensureAprsMarker(call, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.lat != null && entry.lon != null) { entry.marker.setLatLng([entry.lat, entry.lon]); entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); } return true; } - function pruneAisEntry(key, entry, cutoffMs) { + function pruneAisEntry(key: string, entry: MapEntry, cutoffMs: number): boolean { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; const msgTsMs = Number(entry?.msg?._tsMs); const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; @@ -310,21 +472,21 @@ if (!canRenderMap) return true; ensureAisMarker(key, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.msg) { updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); } return true; } - function pruneLocatorEntry(key, entry, cutoffMs) { + function pruneLocatorEntry(key: string, entry: MapEntry, cutoffMs: number): boolean { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return true; if (!(entry.allStationDetails instanceof Map)) { entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) - : new Map(); + : new Map(); } - const nextDetails = new Map(); + const nextDetails = new Map(); for (const [detailKey, detail] of entry.allStationDetails.entries()) { const tsMs = Number(detail?.ts_ms); if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { @@ -340,7 +502,7 @@ else C.markDecodeMapSyncPending(); return false; } - const nextStations = new Set(); + const nextStations = new Set(); for (const detail of nextDetails.values()) { const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); if (source) nextStations.add(source); @@ -357,7 +519,7 @@ } ensureDecodeLocatorMarker(entry); setRetainedMapMarkerVisible(entry.marker, true); - if (entry.marker) { + if (entry.marker && entry.grid && entry.sourceType) { entry.marker.setStyle(locatorStyleForEntry(entry, count)); entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); @@ -383,7 +545,7 @@ } ensureVdesMarker(key, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.msg?.lat != null && entry.msg.lon != null) { entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); } @@ -400,7 +562,7 @@ applyMapFilter(); } - function locatorSourceLabel(type) { + function locatorSourceLabel(type: DecoderSource): string { if (type === "bookmark") return "Bookmarks"; if (type === "wspr") return "WSPR"; if (type === "ft4") return "FT4"; @@ -408,12 +570,12 @@ return "FT8"; } - function mapSourceLabel(type) { + function mapSourceLabel(type: DecoderSource): string { if (type === "bookmark") return "Bookmarks"; return String(type || "").toUpperCase(); } - function locatorFilterColor(type) { + function locatorFilterColor(type: DecoderSource): string { const hues = locatorThemeHues(); const lightTheme = C.currentTheme() === "light"; const sat = lightTheme ? 66 : 76; @@ -424,7 +586,7 @@ return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; } - function mapSourceColor(type) { + function mapSourceColor(type: DecoderSource): string { if (type === "ais") return "#38bdf8"; if (type === "vdes") return "#a78bfa"; if (type === "sat") return "#f59e0b"; @@ -432,10 +594,10 @@ return locatorFilterColor(type); } - function bandForHz(hz) { + function bandForHz(hz: number): (typeof HAM_BANDS)[number] | null { const rfHz = normalizeLocatorFreqHz(hz); - if (!Number.isFinite(rfHz) || rfHz <= 0) return null; - let bestBand = null; + if (rfHz == null || !Number.isFinite(rfHz) || rfHz <= 0) return null; + let bestBand: (typeof HAM_BANDS)[number] | null = null; let bestDistance = Infinity; for (const band of HAM_BANDS) { const distance = Math.abs(Math.log(rfHz / band.nominalHz)); @@ -447,8 +609,8 @@ return bestBand; } - function collectBandMeta(freqs) { - const out = new Map(); + function collectBandMeta(freqs: number[]): Map { + const out = new Map(); if (!Array.isArray(freqs)) return out; for (const hz of freqs) { const band = bandForHz(hz); @@ -457,9 +619,9 @@ return out; } - function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { + function assignLocatorMarkerMeta(marker: TrxLayer, sourceType: DecoderSource, bandMeta: Map | undefined): void { if (!marker) return; - const safeMeta = bandMeta instanceof Map ? bandMeta : new Map(); + const safeMeta = bandMeta ?? new Map(); marker._locatorFilterMeta = { sourceType, bands: new Set(safeMeta.keys()), @@ -467,18 +629,18 @@ }; } - function parseMapColor(input) { + function parseMapColor(input: unknown): MapColor | null { const value = String(input || "").trim(); if (!value) return null; const hex = value.match(/^#([0-9a-f]{3,8})$/i); if (hex) { const raw = hex[1]; + if (!raw) return null; if (raw.length === 3 || raw.length === 4) { - const chars = raw.split(""); return { - r: parseInt(chars[0] + chars[0], 16), - g: parseInt(chars[1] + chars[1], 16), - b: parseInt(chars[2] + chars[2], 16), + r: parseInt(raw.charAt(0).repeat(2), 16), + g: parseInt(raw.charAt(1).repeat(2), 16), + b: parseInt(raw.charAt(2).repeat(2), 16), }; } if (raw.length === 6 || raw.length === 8) { @@ -500,7 +662,7 @@ return null; } - function rgbToHsl(rgb) { + function rgbToHsl(rgb: MapColor | null): { h: number; s: number; l: number } | null { if (!rgb) return null; const r = rgb.r / 255; const g = rgb.g / 255; @@ -513,7 +675,7 @@ } const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - let h; + let h: number; switch (max) { case r: h = ((g - b) / d) + (g < b ? 6 : 0); @@ -528,14 +690,14 @@ return { h: (h * 60) % 360, s: s * 100, l: l * 100 }; } - function wrapHue(hue) { + function wrapHue(hue: number): number { const value = Number(hue) || 0; return ((value % 360) + 360) % 360; } - function paletteHue(input, fallback) { + function paletteHue(input: unknown, fallback: number): number { const hsl = rgbToHsl(parseMapColor(input)); - return Number.isFinite(hsl?.h) ? hsl.h : fallback; + return hsl && Number.isFinite(hsl.h) ? hsl.h : fallback; } function locatorThemeHues() { @@ -553,12 +715,12 @@ }; } - function locatorBandIndex(label) { + function locatorBandIndex(label: string): number { const idx = HAM_BANDS.findIndex((band) => band.label === label); return idx >= 0 ? idx : 0; } - function locatorBandChipColor(label) { + function locatorBandChipColor(label: string): string { const hues = locatorThemeHues(); const lightTheme = C.currentTheme() === "light"; const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); @@ -567,15 +729,15 @@ return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; } - function locatorBandLabelForEntry(entry) { - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map(); + function locatorBandLabelForEntry(entry: MapEntry): string | null { + const meta = entry?.bandMeta ?? new Map(); if (meta.size === 0) return null; if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { for (const label of mapLocatorFilter.bands) { if (meta.has(label)) return label; } } - let bestLabel = null; + let bestLabel: string | null = null; let bestHz = -Infinity; for (const [label, hz] of meta.entries()) { const value = Number.isFinite(hz) ? Number(hz) : 0; @@ -587,7 +749,7 @@ return bestLabel; } - function locatorHueForEntry(entry) { + function locatorHueForEntry(entry: MapEntry): number { const hues = locatorThemeHues(); if (mapLocatorFilter.phase === "band") { const label = locatorBandLabelForEntry(entry); @@ -602,7 +764,7 @@ return hues.ft8; } - function locatorStyleForEntry(entry, count) { + function locatorStyleForEntry(entry: MapEntry, count: number): Leaflet.PathOptions { const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); const hue = locatorHueForEntry(entry); @@ -620,14 +782,14 @@ }; } - function locatorEntryCount(entry) { + function locatorEntryCount(entry: MapEntry | null): number { if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); return 1; } - function locatorEntryForMarker(marker) { + function locatorEntryForMarker(marker: TrxLayer): MapEntry | null { if (!marker) return null; for (const entry of locatorMarkers.values()) { if (entry?.marker === marker) return entry; @@ -654,14 +816,15 @@ } } - function startSelectedLocatorPulse(marker) { + function startSelectedLocatorPulse(marker: TrxLayer): void { stopSelectedLocatorPulse(); if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; - const tick = (ts) => { + const tick = (ts: number): void => { if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { return; } const entry = locatorEntryForMarker(marker); + if (!entry) return; const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); const phase = (ts % 1600) / 1600; const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; @@ -680,7 +843,7 @@ aprsRadioPaths = []; } - function clearDecodeContactPathRender(entry) { + function clearDecodeContactPathRender(entry: MapEntry): void { if (!entry) return; if (entry.line) { entry.line.remove(); @@ -702,17 +865,16 @@ const MAP_PATHS_STATIC_THRESHOLD = 20; function updateMapPathsAnimationClass() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; - mapEl.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); + const mapContainer = mapEl("aprs-map"); + mapContainer.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); } - function formatDecodeContactDistance(distanceKm) { + function formatDecodeContactDistance(distanceKm: number): string { const text = formatDistanceKm(distanceKm); return text || "--"; } - function decodeLocatorPathVisibility(grid) { + function decodeLocatorPathVisibility(grid: string): boolean { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!normalizedGrid || !aprsMap) return false; for (const entry of locatorMarkers.values()) { @@ -723,7 +885,7 @@ return false; } - function midpointLatLon(a, b) { + function midpointLatLon(a: LatLon | undefined, b: LatLon | undefined): LatLon | null { if (!a || !b) return null; if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { return null; @@ -734,20 +896,20 @@ }; } - function decodeContactPathColor(entry) { + function decodeContactPathColor(entry: MapEntry): string { if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); - const srcEntry = locatorMarkers.get(entry?.sourceGrid); + const srcEntry = entry.sourceGrid ? locatorMarkers.get(entry.sourceGrid) : undefined; if (srcEntry) { const label = locatorBandLabelForEntry(srcEntry); if (label) return locatorBandChipColor(label); - return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color; + return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color ?? locatorFilterColor("ft8"); } return locatorFilterColor("ft8"); } - function ensureDecodeContactPathRendered(entry) { - if (!entry || !aprsMap) return; - const linePoints = [ + function ensureDecodeContactPathRendered(entry: MapEntry): void { + if (!aprsMap || !entry.from || !entry.to) return; + const linePoints: Leaflet.LatLngTuple[] = [ [entry.from.lat, entry.from.lon], [entry.to.lat, entry.to.lon], ]; @@ -759,7 +921,7 @@ className: "decode-contact-path", weight: 2.8, interactive: false, - }).addTo(aprsMap); + }).addTo(aprsMap) as unknown as TrxLayer; } else { entry.line.setLatLngs(linePoints); entry.line.setStyle({ color, opacity: 0.78 }); @@ -778,21 +940,22 @@ interactive: false, keyboard: false, zIndexOffset: 900, - }).addTo(aprsMap); + }).addTo(aprsMap) as unknown as TrxLayer; } else { entry.labelMarker.setLatLng([mid.lat, mid.lon]); entry.labelMarker.setIcon(icon); if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); } - if (typeof entry.line.bringToBack === "function") entry.line.bringToBack(); + if (entry.line && typeof entry.line.bringToBack === "function") entry.line.bringToBack(); } - function decodeContactPathMatchesCurrentMap(entry) { - return decodeLocatorPathVisibility(entry.sourceGrid) + function decodeContactPathMatchesCurrentMap(entry: MapEntry): boolean { + return !!entry.sourceGrid && !!entry.targetGrid + && decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); } - function decodeContactPathRenderVisible(entry) { + function decodeContactPathRenderVisible(entry: MapEntry): boolean { return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); } @@ -817,10 +980,10 @@ updateMapPathsAnimationClass(); } - function _resolveReceiverLocations(rigIds) { + function _resolveReceiverLocations(rigIds: Set | undefined): Leaflet.LatLngTuple[] { // Return all unique receiver locations for the given rig(s) - const seen = new Set(); - const locations = []; + const seen = new Set(); + const locations: Leaflet.LatLngTuple[] = []; if (rigIds && rigIds.size) { for (const rid of rigIds) { const rig = T.serverRigs.find(r => r.remote === rid); @@ -840,7 +1003,7 @@ return locations; } - function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { + function setMapRadioPathTo(lat: number, lon: number, color: string, className = "aprs-radio-path", rigIds?: Set): void { clearMapRadioPath(); if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { return; @@ -851,12 +1014,12 @@ L.polyline( [src, [lat, lon]], { color, opacity: 0.85, weight: 2, interactive: false, className } - ).addTo(aprsMap) + ).addTo(aprsMap) as unknown as TrxLayer ); } } - function locatorMarkerCenter(marker) { + function locatorMarkerCenter(marker: TrxLayer): LatLon | null { if (!marker) return null; if (typeof marker.getBounds === "function") { const bounds = marker.getBounds(); @@ -876,13 +1039,13 @@ return null; } - function setLocatorMarkerHighlight(marker, enabled) { + function setLocatorMarkerHighlight(marker: TrxLayer, enabled: boolean): void { const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; if (!element) return; element.classList.toggle("trx-locator-selected", !!enabled); } - function setSelectedLocatorMarker(marker) { + function setSelectedLocatorMarker(marker: TrxLayer | null): void { if (selectedLocatorMarker && selectedLocatorMarker !== marker) { setLocatorMarkerHighlight(selectedLocatorMarker, false); const prevEntry = locatorEntryForMarker(selectedLocatorMarker); @@ -898,17 +1061,17 @@ } } - function isLocatorOverlay(marker) { + function isLocatorOverlay(marker: TrxLayer): boolean { const type = marker?.__trxType; return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; } - function sendLocatorOverlayToBack(marker) { + function sendLocatorOverlayToBack(marker: TrxLayer): void { if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; marker.bringToBack(); } - function renderMapLocatorChipRow(container, items, selectedSet, kind) { + function renderMapLocatorChipRow(container: HTMLElement, items: FilterChip[], selectedSet: Set | null, kind: "band" | "source"): void { if (!container) return; container.replaceChildren(); if (!Array.isArray(items) || items.length === 0) { @@ -916,7 +1079,7 @@ return; } let helperText = ""; - const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; + const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : []; const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); if (kind === "source") { if (noneSelected) { @@ -929,7 +1092,7 @@ const btn = document.createElement("button"); btn.type = "button"; btn.className = "map-locator-chip"; - const isActive = kind === "source" ? !!mapFilter[item.key] : selectedSet.has(item.key); + const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key); if (kind === "source" && noneSelected) { btn.classList.add("is-default"); } else if (!isActive) { @@ -949,7 +1112,7 @@ } } - function renderMapLocatorPhaseRow(container, phase) { + function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void { if (!container) return; container.replaceChildren(); const phases = [ @@ -967,8 +1130,8 @@ } } - function renderMapLocatorLegend(phase, sourceItems, bandItems) { - const legendEl = document.getElementById("map-band-legend"); + function renderMapLocatorLegend(phase: "band" | "type", sourceItems: FilterChip[], bandItems: FilterChip[]): void { + const legendEl = mapEl("map-band-legend"); if (!legendEl) return; const isSourcePhase = phase === "type"; const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) @@ -991,7 +1154,7 @@ legendEl.innerHTML = `
${title}
${rows}
`; } - window.enableMapSourceFilter = function(key) { + mapWindow.enableMapSourceFilter = function(key) { if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { mapFilter[key] = true; rebuildMapLocatorFilters(); @@ -1000,11 +1163,11 @@ }; function rebuildMapLocatorFilters() { - const phaseEl = document.getElementById("map-locator-phase"); - const choiceEl = document.getElementById("map-locator-choice-filter"); - const choiceLabelEl = document.getElementById("map-locator-choice-label"); + const phaseEl = mapEl("map-locator-phase"); + const choiceEl = mapEl("map-locator-choice-filter"); + const choiceLabelEl = mapEl("map-locator-choice-label"); - const availableSources = new Set(); + const availableSources = new Set(); for (const entry of aisMarkers.values()) { if (entry?.visibleInHistoryWindow) { availableSources.add("ais"); @@ -1023,13 +1186,13 @@ break; } } - const bandMap = new Map(); + const bandMap = new Map(); for (const entry of locatorMarkers.values()) { const sourceType = entry?.sourceType; if (!sourceType) continue; if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; availableSources.add(sourceType); - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map(); + const meta = entry?.bandMeta ?? new Map(); for (const [label, hz] of meta.entries()) { if (!bandMap.has(label)) { bandMap.set(label, { @@ -1042,7 +1205,7 @@ continue; } const existing = bandMap.get(label); - if (existing && Number.isFinite(hz) && (!Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { + if (existing && Number.isFinite(hz) && (existing.sortHz == null || !Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { existing.sortHz = hz; } if (existing && !existing.color) { @@ -1055,7 +1218,7 @@ if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); } - const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] + const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[]) .filter((key) => availableSources.has(key)) .map((key) => ({ key, @@ -1064,7 +1227,7 @@ kind: "source", })); const bandItems = Array.from(bandMap.values()) - .sort((a, b) => (b.sortHz - a.sortHz) || a.label.localeCompare(b.label)); + .sort((a, b) => ((b.sortHz ?? 0) - (a.sortHz ?? 0)) || a.label.localeCompare(b.label)); renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); if (!phaseEl || !choiceEl || !choiceLabelEl) return; @@ -1081,7 +1244,7 @@ syncDecodeContactPathVisibility(); } - function markerPassesLocatorFilters(marker) { + function markerPassesLocatorFilters(marker: TrxLayer): boolean { const meta = marker?._locatorFilterMeta; if (!meta) return true; if (mapLocatorFilter.phase === "band") { @@ -1095,11 +1258,11 @@ return true; } - function markerSearchText(marker) { + function markerSearchText(marker: TrxLayer): string { const type = marker?.__trxType; if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { const entry = locatorEntryForMarker(marker); - const parts = []; + const parts: string[] = []; if (entry?.grid) parts.push(entry.grid); if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); @@ -1140,9 +1303,9 @@ msg?.name, msg?.callsign, msg?.destination, - Number.isFinite(msg?.mmsi) ? String(msg.mmsi) : "", - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "", + msg && Number.isFinite(msg.mmsi) ? String(msg.mmsi) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "", ].join(" ").toLowerCase(); } if (type === "vdes") { @@ -1154,14 +1317,14 @@ msg?.mmsi, msg?.message, msg?.raw, - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "", ].join(" ").toLowerCase(); } return ""; } - function markerPassesSearchFilter(marker) { + function markerPassesSearchFilter(marker: TrxLayer): boolean { const query = String(mapSearchFilter || "").trim().toLowerCase(); if (!query) return true; const terms = query.split(/\s+/).filter(Boolean); @@ -1171,36 +1334,36 @@ return terms.every((term) => haystack.includes(term)); } - function _receiverLocationKey(lat, lon) { + function _receiverLocationKey(lat: number, lon: number): string { return lat.toFixed(6) + "," + lon.toFixed(6); } function syncAprsReceiverMarker() { if (!aprsMap) return; // Build unique locations from all rigs - const locGroups = {}; // key -> { lat, lon, rigs: [...] } + const locGroups: Record = {}; const activeId = T.lastActiveRigId || T.serverActiveRigId || null; for (const rig of T.serverRigs) { if (!rig || !rig.remote) continue; const lat = rig.latitude, lon = rig.longitude; if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; const key = _receiverLocationKey(lat, lon); - if (!locGroups[key]) locGroups[key] = { lat, lon, rigs: [], hasActive: false }; - locGroups[key].rigs.push(rig.remote); - if (rig.remote === activeId) locGroups[key].hasActive = true; + const group = locGroups[key] ??= { lat, lon, rigs: new Set(), hasActive: false }; + group.rigs.add(rig.remote); + if (rig.remote === activeId) group.hasActive = true; } // Fallback: if active rig has SSE location but isn't in T.serverRigs yet if (T.serverLat != null && T.serverLon != null) { const key = _receiverLocationKey(T.serverLat, T.serverLon); - if (!locGroups[key]) locGroups[key] = { lat: T.serverLat, lon: T.serverLon, rigs: [], hasActive: true }; - if (!locGroups[key].hasActive) locGroups[key].hasActive = true; + const group = locGroups[key] ??= { lat: T.serverLat, lon: T.serverLon, rigs: new Set(), hasActive: true }; + group.hasActive = true; } - const seen = new Set(); + const seen = new Set(); let didInitialView = false; for (const [key, group] of Object.entries(locGroups)) { seen.add(key); - const latLng = [group.lat, group.lon]; + const latLng: Leaflet.LatLngTuple = [group.lat, group.lon]; const isActive = group.hasActive; let m = aprsMapReceiverMarkers[key]; if (!m) { @@ -1208,7 +1371,7 @@ radius: isActive ? 8 : 6, className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), fillOpacity: isActive ? 0.8 : 0.6, - }).addTo(aprsMap).bindPopup(""); + }).addTo(aprsMap).bindPopup("") as unknown as TrxLayer; m._receiverLocKey = key; m._receiverRigs = group.rigs; aprsMapReceiverMarkers[key] = m; @@ -1222,42 +1385,39 @@ m.setRadius(isActive ? 8 : 6); if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); } - // Keep legacy reference for the active-rig location marker - if (isActive) aprsMapReceiverMarker = m; } // Remove markers for locations no longer present for (const key of Object.keys(aprsMapReceiverMarkers)) { if (!seen.has(key)) { const m = aprsMapReceiverMarkers[key]; if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); - delete aprsMapReceiverMarkers[key]; + Reflect.deleteProperty(aprsMapReceiverMarkers, key); } } - if (!seen.size) aprsMapReceiverMarker = null; } // --------------------------------------------------------------------------- // Weather satellite image overlays on the map // --------------------------------------------------------------------------- - const satOverlays = new Map(); // key -> { overlay, track, msg } + const satOverlays = new Map(); let satOverlaySeq = 0; - window.addSatMapOverlay = function(msg) { - if (!msg || !msg.geo_bounds || !msg.path) return; + mapWindow.addSatMapOverlay = function(msg) { + if (!msg || !Array.isArray(msg.geo_bounds) || typeof msg.path !== "string") return; const bounds = msg.geo_bounds; // bounds = [south, west, north, east] if (!Array.isArray(bounds) || bounds.length !== 4) return; const latLngBounds = L.latLngBounds( - [bounds[0], bounds[1]], // SW - [bounds[2], bounds[3]] // NE + [Number(bounds[0]), Number(bounds[1])], // SW + [Number(bounds[2]), Number(bounds[3])] // NE ); - const key = "sat-" + (++satOverlaySeq); + const key = `sat-${++satOverlaySeq}`; const overlay = L.imageOverlay(msg.path, latLngBounds, { opacity: 0.55, interactive: true, zIndex: 300, - }); + }) as unknown as TrxLayer; overlay.__trxType = "sat"; overlay.__trxSatKey = key; overlay.__trxRigIds = msg.rig_id ? new Set([msg.rig_id]) : new Set(); @@ -1278,18 +1438,20 @@ ); // Add ground track polyline if available - let track = null; + let track: TrxLayer | null = null; if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { - const latlngs = msg.ground_track.map(function(pt) { return [pt[0], pt[1]]; }); + const latlngs: Leaflet.LatLngTuple[] = msg.ground_track + .filter((pt): pt is unknown[] => Array.isArray(pt) && pt.length >= 2) + .map((pt) => [Number(pt[0]), Number(pt[1])]); track = L.polyline(latlngs, { color: mapSourceColor("sat"), weight: 2, opacity: 0.7, dashArray: "6, 4", - }); + }) as unknown as TrxLayer; track.__trxType = "sat"; track.__trxSatKey = key; - track.__trxRigIds = overlay.__trxRigIds; + track.__trxRigIds = overlay.__trxRigIds ?? new Set(); track.__trxHistoryVisible = true; mapMarkers.add(track); if (aprsMap) { @@ -1305,7 +1467,7 @@ applyMapFilter(); }; - window.removeSatMapOverlay = function(key) { + mapWindow.removeSatMapOverlay = function(key) { const entry = satOverlays.get(key); if (!entry) return; if (entry.overlay) { @@ -1319,13 +1481,13 @@ satOverlays.delete(key); }; - window.clearSatMapOverlays = function() { + mapWindow.clearSatMapOverlays = function() { for (const [key] of satOverlays) { - window.removeSatMapOverlay(key); + mapWindow.removeSatMapOverlay?.(key); } }; - window.clearMapMarkersByType = function(type) { + mapWindow.clearMapMarkersByType = function(type) { if (type === "aprs") { selectedAprsTrackCall = null; stationMarkers.forEach((entry) => { @@ -1370,7 +1532,7 @@ } if (type === "sat") { - window.clearSatMapOverlays(); + mapWindow.clearSatMapOverlays?.(); return; } @@ -1409,7 +1571,7 @@ } }; - function mapTileSpecForTheme(theme) { + function mapTileSpecForTheme(theme: string): { url: string; options: Leaflet.TileLayerOptions } { if (theme === "dark") { return { url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", @@ -1429,7 +1591,7 @@ }; } - function updateMapBaseLayerForTheme(theme) { + function updateMapBaseLayerForTheme(theme: string): void { if (!aprsMap) return; if (aprsMapBaseLayer) { aprsMap.removeLayer(aprsMapBaseLayer); @@ -1440,14 +1602,14 @@ } function mapStageEl() { - return document.getElementById("map-stage"); + return mapEl("map-stage"); } function mapIsFullscreen() { const stage = mapStageEl(); if (!stage) return false; return document.fullscreenElement === stage - || document.webkitFullscreenElement === stage + || (document as WebkitDocument).webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); } @@ -1466,7 +1628,7 @@ } function updateMapFullscreenButton() { - const btn = document.getElementById("map-fullscreen-btn"); + const btn = mapEl("map-fullscreen-btn"); if (!btn) return; btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; } @@ -1478,7 +1640,7 @@ } function updateMapOverlayToggleButton() { - const btn = document.getElementById("map-overlay-toggle-btn"); + const btn = mapEl("map-overlay-toggle-btn"); if (!btn) return; btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; } @@ -1487,21 +1649,22 @@ const stage = mapStageEl(); if (!stage) return; try { - const isNative = document.fullscreenElement === stage || document.webkitFullscreenElement === stage; + const webkitDocument = document as WebkitDocument; + const isNative = document.fullscreenElement === stage || webkitDocument.webkitFullscreenElement === stage; const isFake = stage.classList.contains("map-fake-fullscreen"); if (isNative) { if (document.exitFullscreen) await document.exitFullscreen(); - else if (document.webkitExitFullscreen) await document.webkitExitFullscreen(); + else if (webkitDocument.webkitExitFullscreen) await webkitDocument.webkitExitFullscreen(); } else if (isFake) { mapExitFakeFullscreen(); } else { // Try native fullscreen; fall back to CSS fake fullscreen when the // API is unavailable or blocked (e.g. mobile Safari). - const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; + const nativeFn = stage.requestFullscreen || (stage as WebkitElement).webkitRequestFullscreen; if (nativeFn) { try { await nativeFn.call(stage); - } catch (_) { + } catch { mapEnterFakeFullscreen(); } } else { @@ -1512,7 +1675,7 @@ console.error("Map fullscreen toggle failed", err); } finally { updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); + requestAnimationFrame(() => { sizeAprsMapToViewport(); }); } } @@ -1523,7 +1686,7 @@ if (stage && stage.classList.contains("map-fake-fullscreen")) { mapExitFakeFullscreen(); updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); + requestAnimationFrame(() => { sizeAprsMapToViewport(); }); } } }); @@ -1536,9 +1699,9 @@ if (!bounds) continue; entry.sourceType = "bookmark"; entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); - entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)) + entry.marker = L.rectangle(bounds as Leaflet.LatLngBoundsExpression, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)) .addTo(aprsMap) - .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); + .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])) as unknown as TrxLayer; entry.marker.__trxType = "bookmark"; sendLocatorOverlayToBack(entry.marker); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); @@ -1547,16 +1710,17 @@ pruneMapHistory(); } - let stageResizeObserver = null; + let stageResizeObserver: ResizeObserver | null = null; function initAprsMap() { if (typeof L === "undefined") return; - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; + mapEl("aprs-map"); sizeAprsMapToViewport(); if (aprsMap) return; const hasLocation = T.serverLat != null && T.serverLon != null; - const center = hasLocation ? [T.serverLat, T.serverLon] : [20, 0]; + const center: Leaflet.LatLngTuple = hasLocation && T.serverLat != null && T.serverLon != null + ? [T.serverLat, T.serverLon] + : [20, 0]; const zoom = hasLocation ? T.initialMapZoom : 2; aprsMap = L.map("aprs-map").setView(center, zoom); @@ -1570,7 +1734,7 @@ const stage = mapStageEl(); if (stage && typeof ResizeObserver !== "undefined") { if (stageResizeObserver) stageResizeObserver.disconnect(); - stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport()); + stageResizeObserver = new ResizeObserver(() => { sizeAprsMapToViewport(); }); stageResizeObserver.observe(stage); } updateMapBaseLayerForTheme(C.currentTheme()); @@ -1578,7 +1742,8 @@ // Rebuild popup content on open (keeps age/distance/rig list fresh) aprsMap.on("popupopen", function(e) { - const marker = e.popup._source; + const marker = (e.popup as Leaflet.Popup & { _source?: TrxLayer })._source; + if (!marker) return; clearMapRadioPath(); setSelectedLocatorMarker(null); if (selectedAprsTrackCall) { @@ -1641,7 +1806,7 @@ if (center) { setSelectedLocatorMarker(marker); const lEntry = locatorEntryForMarker(marker); - const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color : locatorFilterColor(marker.__trxType); + const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color ?? locatorFilterColor("ft8") : locatorFilterColor(marker.__trxType); setMapRadioPathTo(center.lat, center.lon, lColor, "locator-radio-path", marker.__trxRigIds); } } else if (marker.__trxType === "bookmark") { @@ -1667,17 +1832,17 @@ materializeBufferedMapLayers(); - const locatorPhaseEl = document.getElementById("map-locator-phase"); - const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); - const mapSearchEl = document.getElementById("map-search-filter"); - const mapHistoryLimitEl = document.getElementById("map-history-limit"); - const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle"); - const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); - const fullscreenBtn = document.getElementById("map-fullscreen-btn"); - const overlayToggleBtn = document.getElementById("map-overlay-toggle-btn"); + const locatorPhaseEl = mapEl("map-locator-phase"); + const locatorChoiceEl = mapEl("map-locator-choice-filter"); + const mapSearchEl = mapEl("map-search-filter"); + const mapHistoryLimitEl = mapEl("map-history-limit"); + const mapP2pPathsToggleEl = mapEl("map-p2p-paths-toggle"); + const mapContactPathsToggleEl = mapEl("map-contact-paths-toggle"); + const fullscreenBtn = mapEl("map-fullscreen-btn"); + const overlayToggleBtn = mapEl("map-overlay-toggle-btn"); if (locatorPhaseEl) { locatorPhaseEl.addEventListener("click", (e) => { - const btn = e.target.closest(".map-locator-phase-btn[data-phase]"); + const btn = (e.target as Element | null)?.closest(".map-locator-phase-btn[data-phase]"); if (!btn) return; const phase = String(btn.dataset.phase || ""); if (phase !== "type" && phase !== "band") return; @@ -1689,15 +1854,16 @@ } if (locatorChoiceEl) { locatorChoiceEl.addEventListener("click", (e) => { - const chip = e.target.closest(".map-locator-chip[data-filter-kind]"); + const chip = (e.target as Element | null)?.closest(".map-locator-chip[data-filter-kind]"); if (!chip) return; const kind = String(chip.dataset.filterKind || ""); const key = String(chip.dataset.filterKey || ""); if (!key) return; if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { // toggle the clicked source; when none are selected everything is shown - mapFilter[key] = !mapFilter[key]; - const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const sourceKey = key as MapFilterKey; + mapFilter[sourceKey] = !mapFilter[sourceKey]; + const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]; const anySelected = srcKeys.some((k) => mapFilter[k]); if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { const entry = stationMarkers.get(String(selectedAprsTrackCall)); @@ -1724,7 +1890,7 @@ applyMapFilter(); }); } - const mapRigFilterEl = document.getElementById("map-rig-filter"); + const mapRigFilterEl = mapEl("map-rig-filter"); if (mapRigFilterEl) { mapRigFilterEl.addEventListener("change", () => { mapRigFilter = mapRigFilterEl.value; @@ -1767,7 +1933,7 @@ } if (fullscreenBtn) { fullscreenBtn.addEventListener("click", () => { - toggleMapFullscreen(); + void toggleMapFullscreen(); }); updateMapFullscreenButton(); } @@ -1799,8 +1965,7 @@ } function sizeAprsMapToViewport() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; + const mapContainer = mapEl("aprs-map"); const stage = mapStageEl(); if (mapIsFullscreen() && stage) { // For CSS fake fullscreen use window.innerHeight directly — clientHeight @@ -1811,12 +1976,12 @@ ? window.innerHeight : (stage.clientHeight || stage.getBoundingClientRect().height); const target = Math.max(260, Math.floor(stageHeight)); - mapEl.style.height = `${target}px`; + mapContainer.style.height = `${target}px`; if (aprsMap) aprsMap.invalidateSize(); return; } - const mapRect = mapEl.getBoundingClientRect(); - const width = mapEl.clientWidth || mapRect.width; + const mapRect = mapContainer.getBoundingClientRect(); + const width = mapContainer.clientWidth || mapRect.width; const footer = document.querySelector(".footer"); let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom @@ -1832,36 +1997,30 @@ : Math.floor(window.innerHeight * 0.75); const minHeight = Math.min(260, available); const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); - mapEl.style.height = `${target}px`; + mapContainer.style.height = `${target}px`; if (aprsMap) aprsMap.invalidateSize(); } - function aprsSymbolIcon(symbolTable, symbolCode) { + function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null { if (!symbolTable || !symbolCode) return null; - const sheet = symbolTable === "/" ? 0 : 1; - const code = symbolCode.charCodeAt(0) - 33; - const col = code % 16; - const row = Math.floor(code / 16); - const bgX = -(col * 24); - const bgY = -(row * 24); - const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`; + const table = symbolTable === "/" ? "primary" : "alternate"; return L.divIcon({ className: "", - html: `
`, + html: `
${escapeMapHtml(symbolCode)}
`, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -12] }); } - window.navigateToAprsMap = function(lat, lon) { + mapWindow.navigateToAprsMap = function(lat, lon) { // Activate the map tab T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); }); const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = document.getElementById("tab-map"); + document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); + const mapPanel = mapEl("tab-map"); if (mapPanel) mapPanel.style.display = ""; initAprsMap(); sizeAprsMapToViewport(); @@ -1869,23 +2028,23 @@ requestAnimationFrame(() => { requestAnimationFrame(() => { sizeAprsMapToViewport(); - aprsMap.invalidateSize(); - aprsMap.setView([lat, lon], 13); + aprsMap?.invalidateSize(); + aprsMap?.setView([lat, lon], 13); }); }); } }; - window.navigateToMapLocator = function(grid, preferredType = null) { + mapWindow.navigateToMapLocator = function(grid, preferredType = null) { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); }); const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = document.getElementById("tab-map"); + document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); + const mapPanel = mapEl("tab-map"); if (mapPanel) mapPanel.style.display = ""; initAprsMap(); @@ -1924,7 +2083,8 @@ aprsMap.setView([center.lat, center.lon], targetZoom); if (marker.__trxType !== "bookmark") { const fEntry = locatorEntryForMarker(marker); - const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color : locatorFilterColor(marker?.__trxType); + const sourceType = marker.__trxType as DecoderSource; + const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color ?? locatorFilterColor(sourceType) : locatorFilterColor(sourceType); setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); } } @@ -1945,7 +2105,7 @@ - function buildReceiverPopupHtml(rigIds) { + function buildReceiverPopupHtml(rigIds: Iterable | undefined): string { const call = T.serverCallsign || T.ownerCallsign || "Receiver"; let meta = ""; if (T.serverVersion) { @@ -1957,8 +2117,9 @@ rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; } // Show location from first matching rig or active rig - const rigSet = rigIds && rigIds.length ? new Set(rigIds) : null; - const firstRig = rigSet ? T.serverRigs.find(r => rigSet.has(r.remote)) : null; + const rigList = rigIds ? Array.from(rigIds) : []; + const rigSet = rigList.length ? new Set(rigList) : null; + const firstRig = rigSet ? T.serverRigs.find(r => !!r.remote && rigSet.has(r.remote)) : null; const popupLat = firstRig ? firstRig.latitude : T.serverLat; const popupLon = firstRig ? firstRig.longitude : T.serverLon; if (popupLat != null && popupLon != null) { @@ -1967,7 +2128,7 @@ } // Show rigs at this location const rigsToShow = rigSet - ? T.serverRigs.filter(r => rigSet.has(r.remote)) + ? T.serverRigs.filter(r => !!r.remote && rigSet.has(r.remote)) : T.serverRigs; for (const rig of rigsToShow) { const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); @@ -1982,7 +2143,7 @@ ``; } - function buildAprsPopupHtml(call, lat, lon, info, pkt) { + function buildAprsPopupHtml(call: string, lat: number, lon: number, info: string, pkt?: MapMessage): string { const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : (pkt?._ts || null); const distKm = (T.serverLat != null && T.serverLon != null) ? haversineKm(T.serverLat, T.serverLon, lat, lon) @@ -1993,7 +2154,7 @@ const path = pkt?.path || null; const type = pkt?.type || null; - let meta = [age, distStr].filter(Boolean).join(" · "); + const meta = [age, distStr].filter(Boolean).join(" · "); let rows = ""; if (type) rows += `Type${escapeMapHtml(type)}`; if (path) rows += `Path${escapeMapHtml(path)}`; @@ -2008,7 +2169,7 @@ ``; } - function buildAisPopupHtml(msg) { + function buildAisPopupHtml(msg: MapMessage): string { const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null) ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) @@ -2028,7 +2189,7 @@ if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); - const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; + const vesselUrl = mapWindow.buildAisVesselUrl ? mapWindow.buildAisVesselUrl(msg?.mmsi) : null; const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; @@ -2040,8 +2201,8 @@ ``; } - function buildVdesPopupHtml(msg) { - const age = formatTimeAgo(msg?.ts_ms); + function buildVdesPopupHtml(msg: MapMessage): string { + const age = msg.ts_ms == null ? "" : formatTimeAgo(msg.ts_ms); const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null) ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; @@ -2077,7 +2238,7 @@ ``; } - function aprsPositionsEqual(a, b) { + function aprsPositionsEqual(a: TrackPoint | Leaflet.LatLngTuple | undefined, b: TrackPoint | Leaflet.LatLngTuple): boolean { if (!a || !b) return false; const aLat = Array.isArray(a) ? a[0] : a.lat; const aLon = Array.isArray(a) ? a[1] : a.lon; @@ -2086,7 +2247,7 @@ return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001; } - function aisPositionsEqual(a, b) { + function aisPositionsEqual(a: TrackPoint | Leaflet.LatLngTuple | undefined, b: TrackPoint | Leaflet.LatLngTuple): boolean { if (!a || !b) return false; const aLat = Array.isArray(a) ? a[0] : a.lat; const aLon = Array.isArray(a) ? a[1] : a.lon; @@ -2095,7 +2256,7 @@ return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001; } - function vdesMarkerKey(msg) { + function vdesMarkerKey(msg: MapMessage): string | null { if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; if (msg?.lat != null && msg?.lon != null) { @@ -2104,15 +2265,16 @@ return null; } - function _aprsAddMarkerToMap(call, entry) { + function _aprsAddMarkerToMap(call: string, entry: MapEntry): void { + if (!aprsMap || entry.lat == null || entry.lon == null) return; refreshAprsTrack(call, entry); - const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode); + const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); - const marker = icon + const marker = (icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8 - }).addTo(aprsMap).bindPopup(popupContent); + }).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer; marker.__trxType = "aprs"; marker.__trxRigIds = entry.rigIds || new Set(); marker._aprsCall = call; @@ -2120,8 +2282,8 @@ mapMarkers.add(marker); } - window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { - const nextPoint = [lat, lon]; + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + const nextPoint: Leaflet.LatLngTuple = [lat, lon]; const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const msgRigId = pkt?.rig_id || T.lastActiveRigId; const existing = stationMarkers.get(call); @@ -2149,7 +2311,7 @@ existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); } } else { - const entry = { + const entry: MapEntry = { marker: null, track: null, trackHistory: [{ lat, lon, tsMs }], @@ -2172,17 +2334,18 @@ function syncSelectedAisTrackVisibility() { if (!aprsMap) return; + const map = aprsMap; const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; aisMarkers.forEach((entry, key) => { const track = entry?.track; if (!track) return; const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; - const onMap = aprsMap.hasLayer(track); + const onMap = map.hasLayer(track); if (shouldShow && !onMap) { - track.addTo(aprsMap); + track.addTo(map); } if (!shouldShow && onMap) { - track.removeFrom(aprsMap); + track.removeFrom(map); } }); } @@ -2191,7 +2354,7 @@ return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; } - function aisMarkerOptionsFromMessage(msg) { + function aisMarkerOptionsFromMessage(msg: MapMessage): Record { const color = getAisAccentColor(); return { heading: msg?.heading_deg, @@ -2203,7 +2366,7 @@ }; } - function createAisMarker(lat, lon, msg) { + function createAisMarker(lat: number, lon: number, msg: MapMessage): Leaflet.Marker | Leaflet.CircleMarker { if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); } @@ -2216,16 +2379,16 @@ }); } - function updateAisMarker(marker, msg, popupHtml) { - if (!marker) return; + function updateAisMarker(marker: TrxLayer, msg: MapMessage, popupHtml: string): void { + if (!marker || msg.lat == null || msg.lon == null) return; marker.setLatLng([msg.lat, msg.lon]); if (typeof marker.setAisState === "function") { marker.setAisState(aisMarkerOptionsFromMessage(msg)); } if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { const color = getAisAccentColor(); + marker.setRadius(6); marker.setStyle({ - radius: 6, color, fillColor: color, fillOpacity: 0.84, @@ -2250,11 +2413,11 @@ }); } - window.aisMapAddVessel = function(msg) { + mapWindow.aisMapAddVessel = function(msg) { if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; const key = String(msg.mmsi); const popupHtml = buildAisPopupHtml(msg); - const nextPoint = [msg.lat, msg.lon]; + const nextPoint: Leaflet.LatLngTuple = [msg.lat, msg.lon]; const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); const msgRigId = msg?.rig_id || T.lastActiveRigId; const existing = aisMarkers.get(key); @@ -2277,20 +2440,21 @@ } return; } - aisMarkers.set(key, { + const entry: MapEntry = { marker: null, track: null, trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], trackPoints: [nextPoint], msg, rigIds: new Set(msgRigId ? [msgRigId] : []), - }); - pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs()); - if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key)); + }; + aisMarkers.set(key, entry); + pruneAisEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAisMarker(key, entry); scheduleDecodeMapMaintenance(); }; - window.vdesMapAddPoint = function(msg) { + mapWindow.vdesMapAddPoint = function(msg) { if (msg == null || msg.lat == null || msg.lon == null) return; const key = vdesMarkerKey(msg); if (!key) return; @@ -2326,7 +2490,7 @@ } return; } - const entry = { + const entry: MapEntry = { marker: null, msg, visibleInHistoryWindow: visible, @@ -2346,16 +2510,16 @@ scheduleDecodeMapMaintenance(); }; - let reverseGeocodeLastKey = null; - function reverseGeocodeLocation(lat, lon, grid) { + let reverseGeocodeLastKey: string | null = null; + function reverseGeocodeLocation(lat: number, lon: number, grid: string): void { const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; if (key === reverseGeocodeLastKey) return; reverseGeocodeLastKey = key; const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; fetch(url, { headers: { "User-Agent": "trx-rs" } }) - .then((r) => r.ok ? r.json() : Promise.reject(r.status)) - .then((data) => { - const addr = data?.address; + .then((r) => r.ok ? r.json() : Promise.reject(new Error(`Reverse geocode failed: ${r.status}`))) + .then((data: unknown) => { + const addr = (data as { address?: Record })?.address; if (!addr) return; const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; const country = addr.country || ""; @@ -2365,20 +2529,20 @@ if (T.locationSubtitle) { T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; } - C.updateDocumentTitle(C.activeChannelRds()); + C.updateDocumentTitle?.(); }) .catch(() => {}); } - function maidenheadToBounds(grid) { + function maidenheadToBounds(grid: string): Leaflet.LatLngBoundsLiteral | null { if (!grid || grid.length < 4) return null; const g = grid.toUpperCase(); const A = "A".charCodeAt(0); const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; - const squareLon = parseInt(g[2], 10) * 2; - const squareLat = parseInt(g[3], 10) * 1; + const squareLon = parseInt(g.charAt(2), 10) * 2; + const squareLat = parseInt(g.charAt(3), 10); let lon = fieldLon + squareLon; let lat = fieldLat + squareLat; @@ -2402,13 +2566,14 @@ function applyMapFilter() { if (!aprsMap) return; - const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const map = aprsMap; + const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]; const noneSelected = sourceKeys.every((k) => !mapFilter[k]); mapMarkers.forEach((marker) => { - const type = marker.__trxType; + const type = marker.__trxType as MapFilterKey | undefined; const sourceVisible = noneSelected - ? DEFAULT_MAP_SOURCE_FILTER[type] !== undefined ? DEFAULT_MAP_SOURCE_FILTER[type] : true - : !!mapFilter[type]; + ? type == null || DEFAULT_MAP_SOURCE_FILTER[type] + : type == null || !!mapFilter[type]; const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || (marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter)); @@ -2417,26 +2582,26 @@ && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; - const onMap = aprsMap.hasLayer(marker); + const onMap = map.hasLayer(marker); if (visible && !onMap) { - marker.addTo(aprsMap); + marker.addTo(map); sendLocatorOverlayToBack(marker); } - if (!visible && onMap) marker.removeFrom(aprsMap); + if (!visible && onMap) marker.removeFrom(map); }); syncSelectedAisTrackVisibility(); syncDecodeContactPathVisibility(); } function updateMapContactPathsToggle() { - const btn = document.getElementById("map-contact-paths-toggle"); + const btn = mapEl("map-contact-paths-toggle"); if (!btn) return; btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); } function updateMapP2pPathsToggle() { - const btn = document.getElementById("map-p2p-paths-toggle"); + const btn = mapEl("map-p2p-paths-toggle"); if (!btn) return; btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); @@ -2454,7 +2619,7 @@ }); } - function formatDecodeLocatorTime(tsMs) { + function formatDecodeLocatorTime(tsMs: number): string { if (!Number.isFinite(tsMs)) return "--:--:--"; return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", @@ -2463,7 +2628,7 @@ }); } - function formatMapPopupFreq(hz) { + function formatMapPopupFreq(hz: number): string { if (!Number.isFinite(hz)) return "--"; const value = Number(hz); if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(6).replace(/\.?0+$/, "")} GHz`; @@ -2472,7 +2637,7 @@ return `${Math.round(value)} Hz`; } - function buildDecodeLocatorTooltipHtml(grid, entry, type) { + function buildDecodeLocatorTooltipHtml(grid: string, entry: MapEntry, type: DecoderSource): string { const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; @@ -2526,8 +2691,11 @@ function rebuildDecodeContactPaths() { clearDecodeContactPaths(); - const stationLocators = new Map(); - const directedMessages = []; + const stationLocators = new Map(); + const directedMessages: Array<{ + source: string; target: string; sourceGrid: string; sourceType: DecoderSource; + tsMs: number; bandLabel: string | null; remote: string | null; + }> = []; for (const entry of locatorMarkers.values()) { if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; const grid = String(entry.grid || "").trim().toUpperCase(); @@ -2566,7 +2734,7 @@ const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); const key = [msg.source, msg.target].sort().join("::"); const prev = decodeContactPaths.get(key); - if (prev && prev.tsMs > msg.tsMs) continue; + if (prev?.tsMs != null && prev.tsMs > msg.tsMs) continue; decodeContactPaths.set(key, { pathKey: key, source: msg.source, @@ -2588,7 +2756,7 @@ syncDecodeContactPathVisibility(); } - function _receiverLabel(rigId) { + function _receiverLabel(rigId: string): string | null { if (!rigId) return null; const rig = T.serverRigs.find(r => r.remote === rigId); const name = T.lastRigDisplayNames[rigId] || rigId; @@ -2599,18 +2767,8 @@ return name; } - function _locatorEntryVisibleOnMap(entry) { - return entry?.marker && aprsMap && aprsMap.hasLayer(entry.marker); - } - - function _detailPassesRigFilter(detail) { - if (!mapRigFilter) return true; - if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter); - return detail?.remote === mapRigFilter; - } - function renderMapQsoSummary() { - const listEl = document.getElementById("map-qso-summary-list"); + const listEl = mapEl("map-qso-summary-list"); if (!listEl) return; const cutoff = _statsHistoryCutoffMs(); @@ -2646,10 +2804,10 @@ card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); card.addEventListener("click", () => { - selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey; + selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; syncDecodeContactPathVisibility(); if (selectedMapQsoKey && entry.sourceGrid) { - navigateToMapLocator(entry.sourceGrid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); } }); @@ -2698,7 +2856,7 @@ meta.appendChild(age); } - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; @@ -2722,11 +2880,11 @@ } function renderMapSignalSummary() { - const listEl = document.getElementById("map-signal-summary-list"); + const listEl = mapEl("map-signal-summary-list"); if (!listEl) return; const cutoff = _statsHistoryCutoffMs(); - const bestByStation = new Map(); + const bestByStation = new Map(); for (const entry of locatorMarkers.values()) { if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; if (!(entry.stationDetails instanceof Map)) continue; @@ -2776,7 +2934,7 @@ card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); }); } @@ -2825,7 +2983,7 @@ meta.appendChild(age); } - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; @@ -2848,11 +3006,11 @@ } function renderMapWeakSignalSummary() { - const listEl = document.getElementById("map-weak-signal-summary-list"); + const listEl = mapEl("map-weak-signal-summary-list"); if (!listEl) return; const cutoff = _statsHistoryCutoffMs(); - const worstByStation = new Map(); + const worstByStation = new Map(); for (const entry of locatorMarkers.values()) { if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; if (!(entry.stationDetails instanceof Map)) continue; @@ -2902,7 +3060,7 @@ card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); }); } @@ -2951,7 +3109,7 @@ meta.appendChild(age); } - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; @@ -2976,9 +3134,9 @@ // ── Statistics panel ───────────────────────────────────────────────── let statsRigFilter = ""; let statsHistoryLimitMinutes = 1440; - const statsDecodeLog = []; // {type, ts_ms, remote} + const statsDecodeLog: StatsDecode[] = []; const STATS_LOG_MAX = 50000; - const STATS_TYPE_COLORS = { + const STATS_TYPE_COLORS: Record = { ft8: "#4fc3f7", ft4: "#81c784", ft2: "#aed581", wspr: "#ffb74d", aprs: "#ce93d8", hf_aprs: "#ba68c8", ais: "#90a4ae", vdes: "#78909c", cw: "#fff176", @@ -2996,14 +3154,14 @@ return Date.now() - (statsHistoryLimitMinutes * 60 * 1000); } - function _statsDetailPassesRigFilter(detail) { + function _statsDetailPassesRigFilter(detail: { remote?: string | null; remotes?: Set }): boolean { if (!statsRigFilter) return true; if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); return detail?.remote === statsRigFilter; } function updateStatsRigFilter() { - const el = document.getElementById("stats-rig-filter"); + const el = mapEl("stats-rig-filter"); if (!el) return; const prev = el.value; while (el.options.length > 1) el.remove(1); @@ -3021,14 +3179,14 @@ } } - function statsRecordDecode(type, remote, tsMs) { + function statsRecordDecode(type: string, remote: string | null | undefined, tsMs: number | null | undefined): void { statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); if (statsDecodeLog.length > STATS_LOG_MAX) { statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); } } - function _statsFilteredLog() { + function _statsFilteredLog(): StatsDecode[] { const cutoff = _statsHistoryCutoffMs(); return statsDecodeLog.filter((e) => { if (e.ts_ms < cutoff) return false; @@ -3042,8 +3200,8 @@ const log = _statsFilteredLog(); const totalDecodes = log.length; - const uniqueStations = new Set(); - const uniqueGrids = new Set(); + const uniqueStations = new Set(); + const uniqueGrids = new Set(); for (const entry of locatorMarkers.values()) { if (!entry || !(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { @@ -3064,8 +3222,8 @@ const rateWindow = Date.now() - 60000; const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; - const setEl = (id, val) => { - const el = document.getElementById(id); + const setEl = (id: string, val: string | number): void => { + const el = mapEl(id); if (el) el.textContent = String(val); }; setEl("stats-total-decodes", totalDecodes.toLocaleString()); @@ -3074,8 +3232,8 @@ setEl("stats-decode-rate", recentCount.toLocaleString()); } - function _renderBarChart(containerId, data, emptyMsg) { - const el = document.getElementById(containerId); + function _renderBarChart(containerId: string, data: StatsBar[], emptyMsg: string): void { + const el = mapEl(containerId); if (!el) return; if (!data || data.length === 0 || data.every((d) => d.count === 0)) { el.replaceChildren(); @@ -3117,7 +3275,7 @@ function renderStatsDecodeTypes() { const log = _statsFilteredLog(); - const counts = {}; + const counts: Record = {}; for (const e of log) { counts[e.type] = (counts[e.type] || 0) + 1; } @@ -3133,7 +3291,7 @@ function renderStatsBandActivity() { const cutoff = _statsHistoryCutoffMs(); - const bandCounts = {}; + const bandCounts: Record = {}; for (const entry of locatorMarkers.values()) { if (!entry || !(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { @@ -3157,7 +3315,7 @@ } function renderStatsRigCompare() { - const section = document.getElementById("stats-rig-compare-section"); + const section = mapEl("stats-rig-compare-section"); if (!section) return; if (T.lastRigIds.length < 2) { section.style.display = "none"; @@ -3165,7 +3323,7 @@ } section.style.display = ""; const cutoff = _statsHistoryCutoffMs(); - const rigCounts = {}; + const rigCounts: Record = {}; for (const e of statsDecodeLog) { if (e.ts_ms < cutoff) continue; const rid = e.remote || "unknown"; @@ -3189,6 +3347,7 @@ if (entry.tsMs && entry.tsMs < cutoff) continue; if (!_statsDetailPassesRigFilter(entry)) continue; const km = entry.distanceKm; + if (km == null) continue; for (const b of buckets) { if (km >= b.min && km < b.max) { b.count++; break; } } @@ -3205,8 +3364,8 @@ let _statsControlsWired = false; function _wireStatsControls() { if (_statsControlsWired) return; - const rigEl = document.getElementById("stats-rig-filter"); - const histEl = document.getElementById("stats-history-limit"); + const rigEl = mapEl("stats-rig-filter"); + const histEl = mapEl("stats-history-limit"); if (!rigEl && !histEl) return; // template not yet cloned _statsControlsWired = true; if (rigEl) { @@ -3240,14 +3399,12 @@ }); } - function buildBookmarkLocatorPopupHtml(grid, bookmarks) { + function buildBookmarkLocatorPopupHtml(grid: string, bookmarks: Array>): string { const list = Array.isArray(bookmarks) ? bookmarks : []; const rows = list .map((bm) => { const title = escapeMapHtml(String(bm.name || "Bookmark")); - const freq = typeof bmFmtFreq === "function" - ? escapeMapHtml(bmFmtFreq(bm.freq_hz)) - : escapeMapHtml(String(bm.freq_hz || "--")); + const freq = escapeMapHtml(formatFreqForHumans(Number(bm.freq_hz))); const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; return `${title} ${freq}${mode}`; }) @@ -3255,9 +3412,9 @@ return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); } - window.syncBookmarkMapLocators = function(bookmarks) { + mapWindow.syncBookmarkMapLocators = function(bookmarks) { const list = Array.isArray(bookmarks) ? bookmarks : []; - const grouped = new Map(); + const grouped = new Map(); for (const bm of list) { const grid = String(bm?.locator || "").trim().toUpperCase(); if (!grid) continue; @@ -3307,7 +3464,7 @@ continue; } - const entry = { + const entry: MapEntry = { marker: null, grid: next.grid, bounds: next.bounds, @@ -3319,10 +3476,10 @@ if (aprsMap) { entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)) .addTo(aprsMap) - .bindPopup(popupHtml); + .bindPopup(popupHtml) as unknown as TrxLayer; entry.marker.__trxType = "bookmark"; sendLocatorOverlayToBack(entry.marker); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + assignLocatorMarkerMeta(entry.marker, entry.sourceType ?? "bookmark", entry.bandMeta); mapMarkers.add(entry.marker); } } @@ -3331,15 +3488,15 @@ applyMapFilter(); }; - window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { + mapWindow.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { if (!Array.isArray(grids) || grids.length === 0) return; const markerType = type === "wspr" ? "wspr" : (type === "ft4" ? "ft4" : (type === "ft2" ? "ft2" : "ft8")); const msgRigId = details?.rig_id || T.lastActiveRigId; const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; - const locatorDetails = new Map(); + const locatorDetails = new Map(); if (Array.isArray(details?.locator_details)) { - for (const locatorDetail of details.locator_details) { + for (const locatorDetail of details.locator_details as MapMessage[]) { const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); if (!grid) continue; locatorDetails.set(grid, locatorDetail); @@ -3356,14 +3513,14 @@ ? String(locatorDetail.target).trim().toUpperCase() : ""; const detailStationId = sourceId || stationId; - const detailEntry = { + const detailEntry: DecodeDetail = { station: detailStationId || null, source: sourceId || null, target: targetId || null, - ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, - snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, - dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, - freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, + ts_ms: details && Number.isFinite(details.ts_ms) ? Number(details.ts_ms) : null, + snr_db: details && Number.isFinite(details.snr_db) ? Number(details.snr_db) : null, + dt_s: details && Number.isFinite(details.dt_s) ? Number(details.dt_s) : null, + freq_hz: details && Number.isFinite(details.freq_hz) ? Number(details.freq_hz) : null, message: String(details?.message || message || "").trim() || null, remote: msgRigId || null, remotes: new Set(msgRigId ? [msgRigId] : []), @@ -3376,10 +3533,10 @@ if (!(existing.allStationDetails instanceof Map)) { existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) - : new Map(); + : new Map(); } const prevDetail = existing.allStationDetails.get(detailKey); - const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set(); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set(); if (msgRigId) mergedRemotes.add(msgRigId); existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); existing.sourceType = markerType; @@ -3393,16 +3550,16 @@ continue; } - const allStationDetails = new Map(); + const allStationDetails = new Map(); allStationDetails.set(detailKey, { ...detailEntry }); - const entry = { + const entry: MapEntry = { marker: null, grid, - stations: new Set(), - stationDetails: new Map(), + stations: new Set(), + stationDetails: new Map(), allStationDetails, sourceType: markerType, - bandMeta: new Map(), + bandMeta: new Map(), rigIds: new Set(msgRigId ? [msgRigId] : []), }; locatorMarkers.set(key, entry); @@ -3413,30 +3570,31 @@ }; // --- Sub-tab navigation --- - document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { bar.addEventListener("click", (e) => { - const btn = e.target.closest(".sub-tab[data-subtab]"); + const btn = (e.target as Element | null)?.closest(".sub-tab[data-subtab]"); if (!btn) return; - bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); + bar.querySelectorAll(".sub-tab").forEach((t) => { t.classList.remove("active"); }); btn.classList.add("active"); const parent = bar.parentElement; - parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); - const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); + if (!parent) return; + parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); + const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); if (nextPanel) nextPanel.style.display = ""; - if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) { + if (btn.dataset.subtab === "cw" && mapWindow.refreshCwTonePicker) { requestAnimationFrame(() => { - if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + if (mapWindow.refreshCwTonePicker) mapWindow.refreshCwTonePicker(); }); } // Clear SAT prediction DOM when leaving the SAT tab to reduce node count. - if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { - window.clearSatPredictionDom(); + if (btn.dataset.subtab !== "sat" && typeof mapWindow.clearSatPredictionDom === "function") { + mapWindow.clearSatPredictionDom(); } }); }); window.addEventListener("resize", () => { - const mapTab = document.getElementById("tab-map"); + const mapTab = mapEl("tab-map"); if (!mapTab || mapTab.style.display === "none") return; sizeAprsMapToViewport(); }); @@ -3454,7 +3612,7 @@ // Auto-init map if the map tab is already visible (e.g. direct /map navigation). // Delegates to _initMapWhenReady() in app.js which handles the Leaflet load race. function autoInitIfVisible() { - const panel = document.getElementById("tab-map"); + const panel = mapEl("tab-map"); if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") { _initMapWhenReady(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index ba58791b..46685f3d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -16,23 +16,11 @@ const pluginGroups: Readonly> = { const loaded = new Set(); const loading = new Map>(); -const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js", "/scheduler.js"]); - -function loadLegacyScript(path: string): Promise { - return new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = path; - script.addEventListener("load", () => { resolve(); }, { once: true }); - script.addEventListener("error", () => { reject(new Error(`Failed to load plugin script: ${path}`)); }, { once: true }); - document.body.appendChild(script); - }); -} - async function loadPlugin(path: string): Promise { if (loaded.has(path)) return; const pending = loading.get(path); if (pending) return pending; - const request = (modulePlugins.has(path) ? import(path).then(() => undefined) : loadLegacyScript(path)).then(() => { + const request = import(path).then(() => { loaded.add(path); loading.delete(path); }).catch((error: unknown) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs index 3d02b278..5d9b1dee 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs @@ -7,6 +7,8 @@ import test from "node:test"; import { readFile } from "node:fs/promises"; const indexPath = new URL("../../assets/web/index.html", import.meta.url); +const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url); +const mapCorePath = new URL("../src/map-core.ts", import.meta.url); test("index loads the shared UI before the application", async () => { const html = await readFile(indexPath, "utf8"); @@ -26,3 +28,14 @@ test("startup has no remote script or stylesheet dependencies", async () => { false, ); }); + +test("lazy frontend features use modules and local map symbols", async () => { + const [loader, map] = await Promise.all([ + readFile(pluginLoaderPath, "utf8"), + readFile(mapCorePath, "utf8"), + ]); + assert.equal(loader.includes("createElement(\"script\")"), false); + assert.match(loader, /import\(path\)/); + assert.match(map, /aprs-symbol-local/); + assert.equal(map.includes("raw.githubusercontent.com"), false); +});