[feat](trx-frontend-http): improve scheduler and decode map controls
Remove settings rig pickers, restore the last scheduler cycle on release, fix FT8 locator role parsing, and add toggleable decode contact paths on the map. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -740,8 +740,8 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, activeRigId, disableSwitch);
|
||||
updateRigSubtitle(activeRigId);
|
||||
if (typeof reloadSchedulerRigSelect === "function") reloadSchedulerRigSelect();
|
||||
if (typeof reloadBackgroundDecodeRigSelect === "function") reloadBackgroundDecodeRigSelect();
|
||||
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
|
||||
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
|
||||
}
|
||||
|
||||
async function refreshRigList() {
|
||||
@@ -3746,8 +3746,10 @@ let aprsRadioPath = null;
|
||||
let selectedLocatorMarker = null;
|
||||
let selectedLocatorPulseRaf = null;
|
||||
let mapFullscreenListenerBound = false;
|
||||
let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false;
|
||||
const stationMarkers = new Map();
|
||||
const locatorMarkers = new Map();
|
||||
const decodeContactPaths = new Map();
|
||||
const mapMarkers = new Set();
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, wspr: true };
|
||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
@@ -4068,6 +4070,103 @@ function clearMapRadioPath() {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 ensureDecodeContactPathRendered(entry) {
|
||||
if (!entry || !aprsMap) return;
|
||||
const linePoints = [
|
||||
[entry.from.lat, entry.from.lon],
|
||||
[entry.to.lat, entry.to.lon],
|
||||
];
|
||||
if (!entry.line) {
|
||||
entry.line = L.polyline(linePoints, {
|
||||
className: "decode-contact-path",
|
||||
weight: 2.8,
|
||||
interactive: false,
|
||||
}).addTo(aprsMap);
|
||||
} else {
|
||||
entry.line.setLatLngs(linePoints);
|
||||
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: `<span class="decode-contact-distance-pill" title="${escapeMapHtml(title)}">${escapeMapHtml(entry.distanceText)}</span>`,
|
||||
});
|
||||
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 syncDecodeContactPathVisibility() {
|
||||
for (const entry of decodeContactPaths.values()) {
|
||||
const visible = mapDecodeContactPathsEnabled
|
||||
&& decodeLocatorPathVisibility(entry.sourceGrid)
|
||||
&& decodeLocatorPathVisibility(entry.targetGrid);
|
||||
if (!visible) {
|
||||
clearDecodeContactPathRender(entry);
|
||||
continue;
|
||||
}
|
||||
ensureDecodeContactPathRendered(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function setMapRadioPathTo(lat, lon, className = "aprs-radio-path") {
|
||||
clearMapRadioPath();
|
||||
if (serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) {
|
||||
@@ -4268,6 +4367,7 @@ function rebuildMapLocatorFilters() {
|
||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||
}
|
||||
syncLocatorMarkerStyles();
|
||||
syncDecodeContactPathVisibility();
|
||||
}
|
||||
|
||||
function markerPassesLocatorFilters(marker) {
|
||||
@@ -4447,6 +4547,7 @@ window.clearMapMarkersByType = function(type) {
|
||||
locatorMarkers.delete(key);
|
||||
}
|
||||
rebuildMapLocatorFilters();
|
||||
rebuildDecodeContactPaths();
|
||||
}
|
||||
|
||||
if (type === "bookmark") {
|
||||
@@ -4692,12 +4793,14 @@ function initAprsMap() {
|
||||
assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
|
||||
mapMarkers.add(entry.marker);
|
||||
}
|
||||
rebuildDecodeContactPaths();
|
||||
rebuildMapLocatorFilters();
|
||||
applyMapFilter();
|
||||
|
||||
const locatorPhaseEl = document.getElementById("map-locator-phase");
|
||||
const locatorChoiceEl = document.getElementById("map-locator-choice-filter");
|
||||
const mapSearchEl = document.getElementById("map-search-filter");
|
||||
const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle");
|
||||
const fullscreenBtn = document.getElementById("map-fullscreen-btn");
|
||||
if (locatorPhaseEl) {
|
||||
locatorPhaseEl.addEventListener("click", (e) => {
|
||||
@@ -4752,6 +4855,15 @@ function initAprsMap() {
|
||||
applyMapFilter();
|
||||
});
|
||||
}
|
||||
if (mapContactPathsToggleEl) {
|
||||
updateMapContactPathsToggle();
|
||||
mapContactPathsToggleEl.addEventListener("click", () => {
|
||||
mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled;
|
||||
saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled);
|
||||
updateMapContactPathsToggle();
|
||||
syncDecodeContactPathVisibility();
|
||||
});
|
||||
}
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.addEventListener("click", () => {
|
||||
toggleMapFullscreen();
|
||||
@@ -5425,6 +5537,14 @@ function applyMapFilter() {
|
||||
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 escapeMapHtml(input) {
|
||||
@@ -5461,9 +5581,10 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
|
||||
const title = type === "wspr" ? "WSPR" : "FT8";
|
||||
const rows = details
|
||||
.map((detail) => {
|
||||
const station = escapeMapHtml(String(detail?.station || "Unknown"));
|
||||
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),
|
||||
@@ -5493,6 +5614,62 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function rebuildDecodeContactPaths() {
|
||||
clearDecodeContactPaths();
|
||||
const stationLocators = new Map();
|
||||
const directedMessages = [];
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || (entry.sourceType !== "ft8" && 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) {
|
||||
directedMessages.push({
|
||||
source,
|
||||
target,
|
||||
sourceGrid: grid,
|
||||
tsMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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 key = [msg.source, msg.target].sort().join("::");
|
||||
const prev = decodeContactPaths.get(key);
|
||||
if (prev && prev.tsMs > msg.tsMs) continue;
|
||||
decodeContactPaths.set(key, {
|
||||
source: msg.source,
|
||||
target: msg.target,
|
||||
sourceGrid: msg.sourceGrid,
|
||||
targetGrid: targetLocator.grid,
|
||||
from: sourceCenter,
|
||||
to: targetCenter,
|
||||
tsMs: msg.tsMs,
|
||||
distanceText: formatDecodeContactDistance(
|
||||
haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon)
|
||||
),
|
||||
line: null,
|
||||
labelMarker: null,
|
||||
});
|
||||
}
|
||||
syncDecodeContactPathVisibility();
|
||||
}
|
||||
|
||||
function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
|
||||
const list = Array.isArray(bookmarks) ? bookmarks : [];
|
||||
const rows = list
|
||||
@@ -5591,23 +5768,41 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
|
||||
const markerType = type === "wspr" ? "wspr" : "ft8";
|
||||
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
|
||||
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
|
||||
const detailEntry = {
|
||||
station: stationId || 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,
|
||||
};
|
||||
const detailKey = stationId || `${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
|
||||
const locatorDetails = 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: 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,
|
||||
};
|
||||
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 (stationId) existing.stations.add(stationId);
|
||||
if (detailStationId) existing.stations.add(detailStationId);
|
||||
if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map();
|
||||
existing.stationDetails.set(detailKey, { ...detailEntry });
|
||||
existing.sourceType = markerType;
|
||||
@@ -5626,7 +5821,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
|
||||
}
|
||||
|
||||
const stations = new Set();
|
||||
if (stationId) stations.add(stationId);
|
||||
if (detailStationId) stations.add(detailStationId);
|
||||
const stationDetails = new Map();
|
||||
stationDetails.set(detailKey, { ...detailEntry });
|
||||
const bandMeta = collectBandMeta(
|
||||
@@ -5643,6 +5838,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
|
||||
locatorMarkers.set(key, { marker, grid, stations, stationDetails, sourceType: markerType, bandMeta });
|
||||
mapMarkers.add(marker);
|
||||
}
|
||||
rebuildDecodeContactPaths();
|
||||
rebuildMapLocatorFilters();
|
||||
applyMapFilter();
|
||||
};
|
||||
|
||||
@@ -684,6 +684,13 @@
|
||||
<span class="map-locator-filter-label">Search</span>
|
||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Paths</span>
|
||||
<div class="map-locator-phase-row">
|
||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
|
||||
<span class="map-locator-empty">Directed FT8 contacts with both locators known</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-stage">
|
||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||
@@ -699,9 +706,6 @@
|
||||
<div id="scheduler-panel" class="sch-panel">
|
||||
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label">Rig
|
||||
<select id="scheduler-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
||||
</label>
|
||||
<label class="sch-label">Mode
|
||||
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
|
||||
<option value="disabled">Disabled</option>
|
||||
@@ -805,9 +809,6 @@
|
||||
<div class="sch-section">
|
||||
<div class="sch-section-title">Configuration</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label">Rig
|
||||
<select id="background-decode-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
||||
</label>
|
||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
||||
<span class="bgd-toggle-row">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
|
||||
+8
-35
@@ -16,34 +16,16 @@
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || null;
|
||||
renderRigSelect();
|
||||
loadBackgroundDecode();
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
|
||||
function renderRigSelect() {
|
||||
const sel = document.getElementById("background-decode-rig-select");
|
||||
if (!sel) return;
|
||||
const rigs = typeof getAvailableRigIds === "function" ? getAvailableRigIds() : [];
|
||||
if (!rigs.length) return;
|
||||
const prevRigId = currentRigId;
|
||||
sel.innerHTML = "";
|
||||
rigs.forEach(function (rigId) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = rigId;
|
||||
opt.textContent = rigId;
|
||||
if (rigId === currentRigId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!currentRigId || !rigs.includes(currentRigId)) {
|
||||
currentRigId = rigs[0];
|
||||
sel.value = currentRigId;
|
||||
} else {
|
||||
sel.value = currentRigId;
|
||||
}
|
||||
if (currentRigId && currentRigId !== prevRigId) {
|
||||
loadBackgroundDecode();
|
||||
}
|
||||
function setBackgroundDecodeRig(rigId) {
|
||||
const nextRigId = rigId || null;
|
||||
if (nextRigId === currentRigId) return;
|
||||
currentRigId = nextRigId;
|
||||
if (!currentRigId) return;
|
||||
loadBackgroundDecode();
|
||||
}
|
||||
|
||||
function apiGetConfig(rigId) {
|
||||
@@ -346,15 +328,6 @@
|
||||
}
|
||||
|
||||
function wireBackgroundDecodeEvents() {
|
||||
const rigSel = document.getElementById("background-decode-rig-select");
|
||||
if (rigSel && !rigSel._wired) {
|
||||
rigSel._wired = true;
|
||||
rigSel.addEventListener("change", function () {
|
||||
currentRigId = rigSel.value;
|
||||
loadBackgroundDecode();
|
||||
});
|
||||
}
|
||||
|
||||
const addBtn = document.getElementById("background-decode-bookmark-add");
|
||||
if (addBtn && !addBtn._wired) {
|
||||
addBtn._wired = true;
|
||||
@@ -376,5 +349,5 @@
|
||||
|
||||
window.initBackgroundDecode = initBackgroundDecode;
|
||||
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||
window.reloadBackgroundDecodeRigSelect = renderRigSelect;
|
||||
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||
})();
|
||||
|
||||
@@ -110,7 +110,7 @@ function updateFt8Bar() {
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const rf = ft8BarRfText(msg);
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = escapeHtml((msg.message || "").toString());
|
||||
const text = ft8EscapeHtml((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
ft8BarOverlay.innerHTML = html;
|
||||
@@ -126,36 +126,43 @@ function renderFt8Message(message) {
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
const ch = message[i];
|
||||
if (isAlphaNum(ch)) {
|
||||
if (ft8IsAlphaNum(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (isMaidenheadGridToken(grid)) {
|
||||
if (ft8IsMaidenheadGridToken(grid)) {
|
||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||
} else {
|
||||
out += escapeHtml(token);
|
||||
out += ft8EscapeHtml(token);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
out += escapeHtml(ch);
|
||||
out += ft8EscapeHtml(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAllGrids(message) {
|
||||
function ft8TokenizeMessage(message) {
|
||||
return String(message || "")
|
||||
.toUpperCase()
|
||||
.split(/[^A-Z0-9/]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ft8ExtractAllGrids(message) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
if (isAlphaNum(message[i])) {
|
||||
if (ft8IsAlphaNum(message[i])) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (isMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||
seen.add(grid);
|
||||
out.push(grid);
|
||||
}
|
||||
@@ -167,47 +174,70 @@ function extractAllGrids(message) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractLikelyCallsign(message) {
|
||||
const tokens = String(message || "")
|
||||
.toUpperCase()
|
||||
.split(/[^A-Z0-9/]+/)
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return null;
|
||||
const head = tokens[0];
|
||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||
if (isLikelyCallsignToken(tokens[1])) return tokens[1];
|
||||
for (let i = 1; i < tokens.length; i += 1) {
|
||||
if (isLikelyCallsignToken(tokens[i])) return tokens[i];
|
||||
}
|
||||
return null;
|
||||
function ft8ExtractLocatorDetails(message) {
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
const grids = ft8ExtractAllGrids(String(message || ""));
|
||||
if (tokens.length === 0 || grids.length === 0) return [];
|
||||
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
||||
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
||||
const callsigns = [];
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
||||
}
|
||||
// Directed messages are usually "<target> <source> ...".
|
||||
if (isLikelyCallsignToken(tokens[0]) && isLikelyCallsignToken(tokens[1])) return tokens[1];
|
||||
|
||||
let source = null;
|
||||
let target = null;
|
||||
const head = tokens[0];
|
||||
if (callsigns.length > 0) {
|
||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||
source = callsigns[0];
|
||||
} else if (callsigns.length >= 2) {
|
||||
target = callsigns[0];
|
||||
source = callsigns[1];
|
||||
} else {
|
||||
source = callsigns[0];
|
||||
}
|
||||
}
|
||||
|
||||
return grids.map((grid) => ({
|
||||
grid,
|
||||
station: source || null,
|
||||
source: source || null,
|
||||
target: target || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function ft8ExtractLikelyCallsign(message) {
|
||||
const locatorDetails = ft8ExtractLocatorDetails(message);
|
||||
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
||||
return locatorDetails[0].station;
|
||||
}
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
for (const token of tokens) {
|
||||
if (isLikelyCallsignToken(token)) return token;
|
||||
if (ft8IsLikelyCallsignToken(token)) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyCallsignToken(token) {
|
||||
function ft8IsLikelyCallsignToken(token) {
|
||||
if (!token) return false;
|
||||
if (token.length < 3 || token.length > 12) return false;
|
||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
||||
if (isMaidenheadGridToken(token)) return false;
|
||||
if (ft8IsMaidenheadGridToken(token)) return false;
|
||||
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||
}
|
||||
|
||||
function isFtxFarewellToken(token) {
|
||||
function ft8IsFarewellToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||
}
|
||||
|
||||
function isMaidenheadGridToken(token) {
|
||||
function ft8IsMaidenheadGridToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
||||
}
|
||||
|
||||
function escapeHtml(input) {
|
||||
function ft8EscapeHtml(input) {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
@@ -215,7 +245,7 @@ function escapeHtml(input) {
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
function isAlphaNum(ch) {
|
||||
function ft8IsAlphaNum(ch) {
|
||||
return /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
@@ -321,13 +351,17 @@ document.getElementById("ft8-clear-btn").addEventListener("click", async () => {
|
||||
window.onServerFt8 = function(msg) {
|
||||
ft8Status.textContent = ft8Paused ? "Paused" : "Receiving";
|
||||
const raw = (msg.message || "").toString();
|
||||
const grids = extractAllGrids(raw);
|
||||
const station = extractLikelyCallsign(raw);
|
||||
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((detail) => detail.grid)
|
||||
: ft8ExtractAllGrids(raw);
|
||||
const station = ft8ExtractLikelyCallsign(raw);
|
||||
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
if (grids.length > 0 && window.ft8MapAddLocator) {
|
||||
window.ft8MapAddLocator(raw, grids, "ft8", station, {
|
||||
...msg,
|
||||
freq_hz: rfHz,
|
||||
locator_details: locatorDetails,
|
||||
});
|
||||
}
|
||||
addFt8Message({
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
function initScheduler(rigId, role) {
|
||||
schedulerRole = role;
|
||||
currentRigId = rigId || null;
|
||||
renderSchedulerRigSelect();
|
||||
loadScheduler();
|
||||
if (currentRigId) loadScheduler();
|
||||
startStatusPolling();
|
||||
}
|
||||
|
||||
@@ -35,29 +34,15 @@
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rig selector (mirrors current rig from app state)
|
||||
// Active rig (mirrors top-bar rig picker in app.js)
|
||||
// -------------------------------------------------------------------------
|
||||
function renderSchedulerRigSelect() {
|
||||
const sel = document.getElementById("scheduler-rig-select");
|
||||
if (!sel) return;
|
||||
// Populate from global rig list exposed by app.js
|
||||
const rigs = (typeof getAvailableRigIds === "function") ? getAvailableRigIds() : [];
|
||||
if (!rigs.length) return; // wait until rig list arrives
|
||||
sel.innerHTML = "";
|
||||
rigs.forEach(function (id) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
if (id === currentRigId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
// If currentRigId was unset, pick the first available rig and load its config.
|
||||
if (!currentRigId || !rigs.includes(currentRigId)) {
|
||||
currentRigId = rigs[0];
|
||||
sel.value = currentRigId;
|
||||
loadScheduler();
|
||||
pollStatus();
|
||||
}
|
||||
function setSchedulerRig(rigId) {
|
||||
const nextRigId = rigId || null;
|
||||
if (nextRigId === currentRigId) return;
|
||||
currentRigId = nextRigId;
|
||||
if (!currentRigId) return;
|
||||
loadScheduler();
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -495,15 +480,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
const rigSel = document.getElementById("scheduler-rig-select");
|
||||
if (rigSel) {
|
||||
rigSel.addEventListener("change", function () {
|
||||
currentRigId = rigSel.value;
|
||||
loadScheduler();
|
||||
pollStatus();
|
||||
});
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("scheduler-save-btn");
|
||||
if (saveBtn) saveBtn.addEventListener("click", saveScheduler);
|
||||
|
||||
@@ -579,5 +555,5 @@
|
||||
window.initScheduler = initScheduler;
|
||||
window.destroyScheduler = destroyScheduler;
|
||||
window.wireSchedulerEvents = wireSchedulerEvents;
|
||||
window.reloadSchedulerRigSelect = renderSchedulerRigSelect;
|
||||
window.setSchedulerRig = setSchedulerRig;
|
||||
})();
|
||||
|
||||
@@ -80,11 +80,12 @@ function vchanStartSchedulerReleasePolling() {
|
||||
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: true }),
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: true, rig_id: rigId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
|
||||
@@ -1690,6 +1690,34 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.aprs-radio-path { stroke: var(--accent-green) !important; stroke-opacity: 0.8 !important; stroke-dasharray: 10 5 !important; animation: aprs-radio-path-flow 0.7s linear infinite; }
|
||||
.locator-radio-path { stroke: var(--accent-green) !important; stroke-opacity: 0.9 !important; stroke-dasharray: 12 6 !important; animation: aprs-radio-path-flow 0.7s linear infinite; }
|
||||
.decode-contact-path {
|
||||
stroke: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow)) !important;
|
||||
stroke-opacity: 0.78 !important;
|
||||
stroke-dasharray: 9 6 !important;
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--accent-green) 34%, transparent));
|
||||
animation: aprs-radio-path-flow 0.85s linear infinite;
|
||||
}
|
||||
.decode-contact-distance-label {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.decode-contact-distance-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.25rem;
|
||||
padding: 0.16rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-green) 40%, var(--border-light));
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trx-locator-selected { stroke-opacity: 1 !important; stroke-width: 3.25px !important; filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent-green) 52%, transparent)); animation: trx-locator-breathe 1.6s ease-in-out infinite; }
|
||||
.trx-receiver-marker { stroke: var(--accent-green) !important; fill: var(--accent-green) !important; }
|
||||
.receiver-popup-active { font-size: 0.75em; background: rgba(194,75,26,0.15); color: var(--accent-green); border: 1px solid rgba(194,75,26,0.3); border-radius: 3px; padding: 0 0.25rem; margin-left: 0.3rem; vertical-align: middle; }
|
||||
|
||||
Reference in New Issue
Block a user