[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:
2026-03-13 00:45:12 +01:00
parent f7cbc0cb02
commit 4cca188d9f
8 changed files with 425 additions and 126 deletions
@@ -740,8 +740,8 @@ function applyRigList(activeRigId, rigIds, displayNames) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, activeRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, activeRigId, disableSwitch);
updateRigSubtitle(activeRigId); updateRigSubtitle(activeRigId);
if (typeof reloadSchedulerRigSelect === "function") reloadSchedulerRigSelect(); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof reloadBackgroundDecodeRigSelect === "function") reloadBackgroundDecodeRigSelect(); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
} }
async function refreshRigList() { async function refreshRigList() {
@@ -3746,8 +3746,10 @@ let aprsRadioPath = null;
let selectedLocatorMarker = null; let selectedLocatorMarker = null;
let selectedLocatorPulseRaf = null; let selectedLocatorPulseRaf = null;
let mapFullscreenListenerBound = false; let mapFullscreenListenerBound = false;
let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false;
const stationMarkers = new Map(); const stationMarkers = new Map();
const locatorMarkers = new Map(); const locatorMarkers = new Map();
const decodeContactPaths = new Map();
const mapMarkers = new Set(); const mapMarkers = new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, wspr: true }; const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, wspr: true };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; 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") { function setMapRadioPathTo(lat, lon, className = "aprs-radio-path") {
clearMapRadioPath(); clearMapRadioPath();
if (serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { if (serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) {
@@ -4268,6 +4367,7 @@ function rebuildMapLocatorFilters() {
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
} }
syncLocatorMarkerStyles(); syncLocatorMarkerStyles();
syncDecodeContactPathVisibility();
} }
function markerPassesLocatorFilters(marker) { function markerPassesLocatorFilters(marker) {
@@ -4447,6 +4547,7 @@ window.clearMapMarkersByType = function(type) {
locatorMarkers.delete(key); locatorMarkers.delete(key);
} }
rebuildMapLocatorFilters(); rebuildMapLocatorFilters();
rebuildDecodeContactPaths();
} }
if (type === "bookmark") { if (type === "bookmark") {
@@ -4692,12 +4793,14 @@ function initAprsMap() {
assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
mapMarkers.add(entry.marker); mapMarkers.add(entry.marker);
} }
rebuildDecodeContactPaths();
rebuildMapLocatorFilters(); rebuildMapLocatorFilters();
applyMapFilter(); applyMapFilter();
const locatorPhaseEl = document.getElementById("map-locator-phase"); const locatorPhaseEl = document.getElementById("map-locator-phase");
const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); const locatorChoiceEl = document.getElementById("map-locator-choice-filter");
const mapSearchEl = document.getElementById("map-search-filter"); const mapSearchEl = document.getElementById("map-search-filter");
const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle");
const fullscreenBtn = document.getElementById("map-fullscreen-btn"); const fullscreenBtn = document.getElementById("map-fullscreen-btn");
if (locatorPhaseEl) { if (locatorPhaseEl) {
locatorPhaseEl.addEventListener("click", (e) => { locatorPhaseEl.addEventListener("click", (e) => {
@@ -4752,6 +4855,15 @@ function initAprsMap() {
applyMapFilter(); applyMapFilter();
}); });
} }
if (mapContactPathsToggleEl) {
updateMapContactPathsToggle();
mapContactPathsToggleEl.addEventListener("click", () => {
mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled;
saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled);
updateMapContactPathsToggle();
syncDecodeContactPathVisibility();
});
}
if (fullscreenBtn) { if (fullscreenBtn) {
fullscreenBtn.addEventListener("click", () => { fullscreenBtn.addEventListener("click", () => {
toggleMapFullscreen(); toggleMapFullscreen();
@@ -5425,6 +5537,14 @@ function applyMapFilter() {
if (!visible && onMap) marker.removeFrom(aprsMap); if (!visible && onMap) marker.removeFrom(aprsMap);
}); });
syncSelectedAisTrackVisibility(); 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) { function escapeMapHtml(input) {
@@ -5461,9 +5581,10 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
const title = type === "wspr" ? "WSPR" : "FT8"; const title = type === "wspr" ? "WSPR" : "FT8";
const rows = details const rows = details
.map((detail) => { .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 freq = formatMapPopupFreq(Number(detail?.freq_hz));
const meta = [ 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?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null,
Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null,
escapeMapHtml(freq), escapeMapHtml(freq),
@@ -5493,6 +5614,62 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
`</div>`; `</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) { function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
const list = Array.isArray(bookmarks) ? bookmarks : []; const list = Array.isArray(bookmarks) ? bookmarks : [];
const rows = list const rows = list
@@ -5591,23 +5768,41 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
const markerType = type === "wspr" ? "wspr" : "ft8"; const markerType = type === "wspr" ? "wspr" : "ft8";
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
const detailEntry = { const locatorDetails = new Map();
station: stationId || null, if (Array.isArray(details?.locator_details)) {
ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, for (const locatorDetail of details.locator_details) {
snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, const grid = String(locatorDetail?.grid || "").trim().toUpperCase();
dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, if (!grid) continue;
freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, locatorDetails.set(grid, locatorDetail);
message: String(details?.message || message || "").trim() || null, }
}; }
const detailKey = stationId || `${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
for (const grid of unique) { for (const grid of unique) {
const bounds = maidenheadToBounds(grid); const bounds = maidenheadToBounds(grid);
if (!bounds) continue; 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 key = `${markerType}:${grid}`;
const existing = locatorMarkers.get(key); const existing = locatorMarkers.get(key);
if (existing) { if (existing) {
existing.grid = grid; existing.grid = grid;
if (stationId) existing.stations.add(stationId); if (detailStationId) existing.stations.add(detailStationId);
if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map(); if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map();
existing.stationDetails.set(detailKey, { ...detailEntry }); existing.stationDetails.set(detailKey, { ...detailEntry });
existing.sourceType = markerType; existing.sourceType = markerType;
@@ -5626,7 +5821,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
} }
const stations = new Set(); const stations = new Set();
if (stationId) stations.add(stationId); if (detailStationId) stations.add(detailStationId);
const stationDetails = new Map(); const stationDetails = new Map();
stationDetails.set(detailKey, { ...detailEntry }); stationDetails.set(detailKey, { ...detailEntry });
const bandMeta = collectBandMeta( 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 }); locatorMarkers.set(key, { marker, grid, stations, stationDetails, sourceType: markerType, bandMeta });
mapMarkers.add(marker); mapMarkers.add(marker);
} }
rebuildDecodeContactPaths();
rebuildMapLocatorFilters(); rebuildMapLocatorFilters();
applyMapFilter(); applyMapFilter();
}; };
@@ -684,6 +684,13 @@
<span class="map-locator-filter-label">Search</span> <span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." /> <input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div> </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>
<div id="map-stage"> <div id="map-stage">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button> <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 id="scheduler-panel" class="sch-panel">
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div> <div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
<div class="sch-row"> <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 <label class="sch-label">Mode
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode"> <select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
<option value="disabled">Disabled</option> <option value="disabled">Disabled</option>
@@ -805,9 +809,6 @@
<div class="sch-section"> <div class="sch-section">
<div class="sch-section-title">Configuration</div> <div class="sch-section-title">Configuration</div>
<div class="sch-row"> <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 <label class="sch-label bgd-toggle-wrap">Background decode
<span class="bgd-toggle-row"> <span class="bgd-toggle-row">
<input type="checkbox" id="background-decode-enabled" /> <input type="checkbox" id="background-decode-enabled" />
@@ -16,34 +16,16 @@
function initBackgroundDecode(rigId, role) { function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role; backgroundDecodeRole = role;
currentRigId = rigId || null; currentRigId = rigId || null;
renderRigSelect(); if (currentRigId) loadBackgroundDecode();
loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
} }
function renderRigSelect() { function setBackgroundDecodeRig(rigId) {
const sel = document.getElementById("background-decode-rig-select"); const nextRigId = rigId || null;
if (!sel) return; if (nextRigId === currentRigId) return;
const rigs = typeof getAvailableRigIds === "function" ? getAvailableRigIds() : []; currentRigId = nextRigId;
if (!rigs.length) return; if (!currentRigId) return;
const prevRigId = currentRigId; loadBackgroundDecode();
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 apiGetConfig(rigId) { function apiGetConfig(rigId) {
@@ -346,15 +328,6 @@
} }
function wireBackgroundDecodeEvents() { 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"); const addBtn = document.getElementById("background-decode-bookmark-add");
if (addBtn && !addBtn._wired) { if (addBtn && !addBtn._wired) {
addBtn._wired = true; addBtn._wired = true;
@@ -376,5 +349,5 @@
window.initBackgroundDecode = initBackgroundDecode; window.initBackgroundDecode = initBackgroundDecode;
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents; 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 dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const rf = ft8BarRfText(msg); const rf = ft8BarRfText(msg);
const detail = [snr, dt, rf].filter(Boolean).join(" · "); 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>`; 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; ft8BarOverlay.innerHTML = html;
@@ -126,36 +126,43 @@ function renderFt8Message(message) {
let i = 0; let i = 0;
while (i < message.length) { while (i < message.length) {
const ch = message[i]; const ch = message[i];
if (isAlphaNum(ch)) { if (ft8IsAlphaNum(ch)) {
let j = i + 1; 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 token = message.slice(i, j);
const grid = token.toUpperCase(); 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>`; out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
} else { } else {
out += escapeHtml(token); out += ft8EscapeHtml(token);
} }
i = j; i = j;
} else { } else {
out += escapeHtml(ch); out += ft8EscapeHtml(ch);
i += 1; i += 1;
} }
} }
return out; return out;
} }
function extractAllGrids(message) { function ft8TokenizeMessage(message) {
return String(message || "")
.toUpperCase()
.split(/[^A-Z0-9/]+/)
.filter(Boolean);
}
function ft8ExtractAllGrids(message) {
const out = []; const out = [];
const seen = new Set(); const seen = new Set();
let i = 0; let i = 0;
while (i < message.length) { while (i < message.length) {
if (isAlphaNum(message[i])) { if (ft8IsAlphaNum(message[i])) {
let j = i + 1; 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 token = message.slice(i, j);
const grid = token.toUpperCase(); const grid = token.toUpperCase();
if (isMaidenheadGridToken(grid) && !seen.has(grid)) { if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
seen.add(grid); seen.add(grid);
out.push(grid); out.push(grid);
} }
@@ -167,47 +174,70 @@ function extractAllGrids(message) {
return out; return out;
} }
function extractLikelyCallsign(message) { function ft8ExtractLocatorDetails(message) {
const tokens = String(message || "") const tokens = ft8TokenizeMessage(message);
.toUpperCase() const grids = ft8ExtractAllGrids(String(message || ""));
.split(/[^A-Z0-9/]+/) if (tokens.length === 0 || grids.length === 0) return [];
.filter(Boolean); const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
if (tokens.length === 0) return null; const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
const head = tokens[0]; const callsigns = [];
if (head === "CQ" || head === "DE" || head === "QRZ") { for (let i = 0; i < limit; i += 1) {
if (isLikelyCallsignToken(tokens[1])) return tokens[1]; if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
for (let i = 1; i < tokens.length; i += 1) {
if (isLikelyCallsignToken(tokens[i])) return tokens[i];
}
return null;
} }
// 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) { for (const token of tokens) {
if (isLikelyCallsignToken(token)) return token; if (ft8IsLikelyCallsignToken(token)) return token;
} }
return null; return null;
} }
function isLikelyCallsignToken(token) { function ft8IsLikelyCallsignToken(token) {
if (!token) return false; if (!token) return false;
if (token.length < 3 || token.length > 12) return false; if (token.length < 3 || token.length > 12) return false;
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") 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); 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(); const normalized = String(token || "").trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR"; return normalized === "RR73" || normalized === "73" || normalized === "RR";
} }
function isMaidenheadGridToken(token) { function ft8IsMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase(); 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 return input
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
@@ -215,7 +245,7 @@ function escapeHtml(input) {
.replaceAll("\"", "&quot;"); .replaceAll("\"", "&quot;");
} }
function isAlphaNum(ch) { function ft8IsAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch); return /[A-Za-z0-9]/.test(ch);
} }
@@ -321,13 +351,17 @@ document.getElementById("ft8-clear-btn").addEventListener("click", async () => {
window.onServerFt8 = function(msg) { window.onServerFt8 = function(msg) {
ft8Status.textContent = ft8Paused ? "Paused" : "Receiving"; ft8Status.textContent = ft8Paused ? "Paused" : "Receiving";
const raw = (msg.message || "").toString(); const raw = (msg.message || "").toString();
const grids = extractAllGrids(raw); const locatorDetails = ft8ExtractLocatorDetails(raw);
const station = extractLikelyCallsign(raw); const grids = locatorDetails.length > 0
? locatorDetails.map((detail) => detail.grid)
: ft8ExtractAllGrids(raw);
const station = ft8ExtractLikelyCallsign(raw);
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz); const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
if (grids.length > 0 && window.ft8MapAddLocator) { if (grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(raw, grids, "ft8", station, { window.ft8MapAddLocator(raw, grids, "ft8", station, {
...msg, ...msg,
freq_hz: rfHz, freq_hz: rfHz,
locator_details: locatorDetails,
}); });
} }
addFt8Message({ addFt8Message({
@@ -22,8 +22,7 @@
function initScheduler(rigId, role) { function initScheduler(rigId, role) {
schedulerRole = role; schedulerRole = role;
currentRigId = rigId || null; currentRigId = rigId || null;
renderSchedulerRigSelect(); if (currentRigId) loadScheduler();
loadScheduler();
startStatusPolling(); 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() { function setSchedulerRig(rigId) {
const sel = document.getElementById("scheduler-rig-select"); const nextRigId = rigId || null;
if (!sel) return; if (nextRigId === currentRigId) return;
// Populate from global rig list exposed by app.js currentRigId = nextRigId;
const rigs = (typeof getAvailableRigIds === "function") ? getAvailableRigIds() : []; if (!currentRigId) return;
if (!rigs.length) return; // wait until rig list arrives loadScheduler();
sel.innerHTML = ""; pollStatus();
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();
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -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"); const saveBtn = document.getElementById("scheduler-save-btn");
if (saveBtn) saveBtn.addEventListener("click", saveScheduler); if (saveBtn) saveBtn.addEventListener("click", saveScheduler);
@@ -579,5 +555,5 @@
window.initScheduler = initScheduler; window.initScheduler = initScheduler;
window.destroyScheduler = destroyScheduler; window.destroyScheduler = destroyScheduler;
window.wireSchedulerEvents = wireSchedulerEvents; window.wireSchedulerEvents = wireSchedulerEvents;
window.reloadSchedulerRigSelect = renderSchedulerRigSelect; window.setSchedulerRig = setSchedulerRig;
})(); })();
@@ -80,11 +80,12 @@ function vchanStartSchedulerReleasePolling() {
async function vchanToggleSchedulerRelease() { async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
try { try {
const resp = await fetch("/scheduler-control", { const resp = await fetch("/scheduler-control", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, 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}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json(); 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; } .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; } .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-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; } .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; } .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; }
@@ -635,6 +635,78 @@ async fn apply_scheduler_decoders(
} }
} }
async fn apply_last_scheduler_cycle(
rig_tx: &mpsc::Sender<RigRequest>,
rig_id: &str,
status_map: &SchedulerStatusMap,
bookmarks: &BookmarkStore,
) {
let status = {
let Ok(map) = status_map.read() else {
return;
};
map.get(rig_id).cloned()
};
let Some(status) = status else {
return;
};
let Some(bookmark_id) = status.last_bookmark_id else {
return;
};
let Some(bookmark) = bookmarks.get(&bookmark_id) else {
warn!(
"scheduler: last bookmark '{}' not found for rig '{}'",
bookmark_id, rig_id
);
return;
};
let extra_bookmarks: Vec<_> = status
.last_bookmark_ids
.iter()
.filter_map(|id| bookmarks.get(id))
.collect();
if let Some(center_hz) = status.last_center_hz {
if let Err(e) = scheduler_send(
rig_tx,
RigCommand::SetCenterFreq(Freq { hz: center_hz }),
rig_id.to_string(),
)
.await
{
warn!(
"scheduler: restore SetCenterFreq failed for '{}': {:?}",
rig_id, e
);
}
}
if let Err(e) = scheduler_send(
rig_tx,
RigCommand::SetFreq(Freq { hz: bookmark.freq_hz }),
rig_id.to_string(),
)
.await
{
warn!("scheduler: restore SetFreq failed for '{}': {:?}", rig_id, e);
return;
}
if let Err(e) = scheduler_send(
rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
rig_id.to_string(),
)
.await
{
warn!("scheduler: restore SetMode failed for '{}': {:?}", rig_id, e);
}
apply_scheduler_decoders(rig_tx, rig_id, &bookmark, &extra_bookmarks).await;
}
/// Send a single RigCommand from the scheduler context (fire-and-forget style). /// Send a single RigCommand from the scheduler context (fire-and-forget style).
async fn scheduler_send( async fn scheduler_send(
rig_tx: &mpsc::Sender<RigRequest>, rig_tx: &mpsc::Sender<RigRequest>,
@@ -725,6 +797,8 @@ pub struct SchedulerControlQuery {
pub struct SchedulerControlUpdate { pub struct SchedulerControlUpdate {
pub session_id: Uuid, pub session_id: Uuid,
pub released: bool, pub released: bool,
#[serde(default)]
pub rig_id: Option<String>,
} }
#[get("/scheduler-control")] #[get("/scheduler-control")]
@@ -739,6 +813,22 @@ pub async fn get_scheduler_control(
pub async fn put_scheduler_control( pub async fn put_scheduler_control(
body: web::Json<SchedulerControlUpdate>, body: web::Json<SchedulerControlUpdate>,
control: web::Data<SharedSchedulerControlManager>, control: web::Data<SharedSchedulerControlManager>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
status_map: web::Data<SchedulerStatusMap>,
bookmarks: web::Data<Arc<BookmarkStore>>,
) -> impl Responder { ) -> impl Responder {
HttpResponse::Ok().json(control.set_released(body.session_id, body.released)) let body = body.into_inner();
let summary = control.set_released(body.session_id, body.released);
if body.released && summary.all_released {
if let Some(rig_id) = body.rig_id.as_deref() {
apply_last_scheduler_cycle(
rig_tx.get_ref(),
rig_id,
status_map.get_ref(),
bookmarks.get_ref().as_ref(),
)
.await;
}
}
HttpResponse::Ok().json(summary)
} }