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 += `| ${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 += `| ${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 ? ` ` : "";
- rows += `| ${escapeMapHtml(name)}${active} |
`;
- }
- return ``;
- }
- 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 += `| ${escapeMapHtml(type)} |
`;
- if (path) rows += `| ${escapeMapHtml(path)} |
`;
- if (lat != null && lon != null)
- rows += `| ${lat.toFixed(5)}, ${lon.toFixed(5)} |
`;
- return ``;
- }
- 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 += `| ${escapeMapHtml(String(msg.mmsi || "--"))} |
`;
- rows += `| ${escapeMapHtml(String(msg.message_type || "--"))} |
`;
- if (distStr) rows += `| ${distStr} from TRX |
`;
- if (msg?.sog_knots != null) rows += `| ${Number(msg.sog_knots).toFixed(1)} kn |
`;
- if (msg?.cog_deg != null) rows += `| ${Number(msg.cog_deg).toFixed(1)}° |
`;
- if (msg?.heading_deg != null) rows += `| ${Number(msg.heading_deg).toFixed(0)}° |
`;
- if (msg?.nav_status != null) rows += `| ${escapeMapHtml(String(msg.nav_status))} |
`;
- if (msg?.lat != null && msg?.lon != null) rows += `| ${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 ``;
- }
- 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 += `| ${distStr} from TRX |
`;
- rows += `| ${escapeMapHtml(String(msg?.message_type ?? "--"))} |
`;
- if (Number.isFinite(msg?.source_id)) rows += `| ${escapeMapHtml(String(msg.source_id))} |
`;
- if (Number.isFinite(msg?.destination_id)) rows += `| ${escapeMapHtml(String(msg.destination_id))} |
`;
- if (msg?.lat != null && msg?.lon != null) rows += `| ${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)} |
`;
- if (Number.isFinite(msg?.sync_score)) rows += `| ${(Number(msg.sync_score) * 100).toFixed(0)}% |
`;
- if (msg?.fec_state) rows += `| ${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 ``;
- }
- 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 += `| ${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 += `| ${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 ? ` ` : "";
+ rows += `| ${escapeMapHtml(name)}${active} |
`;
+ }
+ return ``;
+ }
+ 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 += `| ${escapeMapHtml(type)} |
`;
+ if (path) rows += `| ${escapeMapHtml(path)} |
`;
+ if (lat != null && lon != null)
+ rows += `| ${lat.toFixed(5)}, ${lon.toFixed(5)} |
`;
+ return ``;
+ }
+ 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 += `| ${escapeMapHtml(String(msg.mmsi || "--"))} |
`;
+ rows += `| ${escapeMapHtml(String(msg.message_type || "--"))} |
`;
+ if (distStr) rows += `| ${distStr} from TRX |
`;
+ if (msg?.sog_knots != null) rows += `| ${Number(msg.sog_knots).toFixed(1)} kn |
`;
+ if (msg?.cog_deg != null) rows += `| ${Number(msg.cog_deg).toFixed(1)}° |
`;
+ if (msg?.heading_deg != null) rows += `| ${Number(msg.heading_deg).toFixed(0)}° |
`;
+ if (msg?.nav_status != null) rows += `| ${escapeMapHtml(String(msg.nav_status))} |
`;
+ if (msg?.lat != null && msg?.lon != null) rows += `| ${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 ``;
+ }
+ 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 += `| ${distStr} from TRX |
`;
+ rows += `| ${escapeMapHtml(String(msg?.message_type ?? "--"))} |
`;
+ if (Number.isFinite(msg?.source_id)) rows += `| ${escapeMapHtml(String(msg.source_id))} |
`;
+ if (Number.isFinite(msg?.destination_id)) rows += `| ${escapeMapHtml(String(msg.destination_id))} |
`;
+ if (msg?.lat != null && msg?.lon != null) rows += `| ${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)} |
`;
+ if (Number.isFinite(msg?.sync_score)) rows += `| ${(Number(msg.sync_score) * 100).toFixed(0)}% |
`;
+ if (msg?.fec_state) rows += `| ${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 ``;
+ }
+ 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 += `| ${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 += `| ${escapeMapHtml(type)} |
`;
if (path) rows += `| ${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 += `| ${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);
+});