[feat](trx-frontend): add bookmark locators for distance tooltips

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-03 19:38:10 +01:00
parent 1825a0a003
commit 5cdcdd3498
5 changed files with 127 additions and 11 deletions
@@ -3476,6 +3476,73 @@ function haversineKm(lat1, lon1, lat2, lon2) {
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function locatorToLatLon(locator) {
const raw = String(locator || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}([A-X]{2})?$/.test(raw)) return null;
let lon = -180;
let lat = -90;
lon += (raw.charCodeAt(0) - 65) * 20;
lat += (raw.charCodeAt(1) - 65) * 10;
lon += Number(raw.slice(2, 3)) * 2;
lat += Number(raw.slice(3, 4));
if (raw.length >= 6) {
lon += (raw.charCodeAt(4) - 65) * (5 / 60);
lat += (raw.charCodeAt(5) - 65) * (2.5 / 60);
lon += 2.5 / 60;
lat += 1.25 / 60;
} else {
lon += 1;
lat += 0.5;
}
return { lat, lon };
}
function formatDistanceKm(distKm) {
if (!Number.isFinite(distKm)) return null;
return distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`;
}
function bookmarkDistanceText(bm) {
if (!bm || serverLat == null || serverLon == null) return null;
const latLon = locatorToLatLon(bm.locator);
if (!latLon) return null;
return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon));
}
function buildBookmarkTooltipText(bm) {
if (!bm) return null;
const parts = [];
if (bm.name) parts.push(String(bm.name));
if (typeof bmFmtFreq === "function") parts.push(bmFmtFreq(bm.freq_hz));
if (bm.mode) parts.push(String(bm.mode));
if (bm.locator) parts.push(String(bm.locator));
const distance = bookmarkDistanceText(bm);
if (distance) parts.push(distance);
let text = parts.join(" · ");
if (bm.comment) {
text += (text ? "\n" : "") + String(bm.comment);
}
return text;
}
function nearestBookmarkForHz(hz, widthPx, range) {
const ref = typeof bmList !== "undefined" ? bmList : null;
if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) {
return null;
}
const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10);
let best = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bm of ref) {
const delta = Math.abs(Number(bm.freq_hz) - hz);
if (delta <= maxDeltaHz && delta < bestDelta) {
best = bm;
bestDelta = delta;
}
}
return best;
}
function formatTimeAgo(tsMs) {
if (!tsMs) return null;
const secs = Math.round((Date.now() - tsMs) / 1000);
@@ -5571,7 +5638,7 @@ function createBookmarkChip(bm, colorMap, options = {}) {
if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side");
}
span.title = bm.name + " \u2014 " + freqStr + (bm.comment ? "\n" + bm.comment : "");
span.title = buildBookmarkTooltipText(bm) || (bm.name + " \u2014 " + freqStr + (bm.comment ? "\n" + bm.comment : ""));
span.dataset.bmId = bm.id;
const labelHtml = options.sideStack
? (
@@ -5931,10 +5998,13 @@ if (spectrumCanvas) {
const edge = getBwEdgeHit(cssX, rect.width, range);
spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
const hz = canvasXToHz(cssX, rect.width, range);
const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range);
const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData);
const peakHz = peak?.hz ?? null;
const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : null;
if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) {
if (bookmark) {
spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark);
} else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) {
spectrumTooltip.textContent = peakDb
? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}`
: `Peak ${formatSpectrumFreq(peakHz)}`;
@@ -314,6 +314,9 @@
<label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label>
<div class="bm-label">Decoders
<div class="bm-decoder-checks">
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label>
@@ -338,6 +341,7 @@
<th>Frequency</th>
<th>Mode</th>
<th>BW</th>
<th>Locator</th>
<th>Category</th>
<th>Decoders</th>
<th>Comment</th>
@@ -58,6 +58,7 @@ function bmApplyFilters() {
filtered = text
? filtered.filter((bm) =>
(bm.name || "").toLowerCase().includes(text) ||
(bm.locator || "").toLowerCase().includes(text) ||
(bm.category || "").toLowerCase().includes(text) ||
(bm.comment || "").toLowerCase().includes(text)
)
@@ -117,6 +118,7 @@ function bmRender(list) {
const tr = document.createElement("tr");
tr.dataset.bmId = bm.id;
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
const locatorCell = bm.locator || "--";
const catCell = bm.category || "Uncategorised";
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
const commentCell = bm.comment || "";
@@ -125,6 +127,7 @@ function bmRender(list) {
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
`<td class="bm-col-bw">${bwCell}</td>` +
`<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
@@ -164,6 +167,7 @@ function bmOpenForm(bm) {
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
document.getElementById("bm-mode").value = bm ? bm.mode : "";
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
bmWriteDecoders(bm ? bm.decoders : []);
@@ -201,6 +205,7 @@ async function bmSave(e) {
const mode = document.getElementById("bm-mode").value.trim();
const bwStr = document.getElementById("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
const category = document.getElementById("bm-category-input").value.trim();
const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders();
@@ -210,7 +215,16 @@ async function bmSave(e) {
return;
}
const body = { name, freq_hz, mode, bandwidth_hz, category, comment, decoders };
const body = {
name,
freq_hz,
mode,
bandwidth_hz,
locator: locator || null,
category,
comment,
decoders,
};
try {
let resp;
@@ -744,6 +744,7 @@ pub struct BookmarkInput {
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: Option<u64>,
pub locator: Option<String>,
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
@@ -766,6 +767,17 @@ fn gen_bookmark_id() -> String {
hex::encode(rand::random::<[u8; 16]>())
}
fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
locator.and_then(|value| {
let trimmed = value.trim().to_uppercase();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
#[get("/bookmarks")]
pub async fn list_bookmarks(
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
@@ -801,6 +813,7 @@ pub async fn create_bookmark(
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
locator: normalize_bookmark_locator(body.locator.clone()),
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
@@ -835,6 +848,7 @@ pub async fn update_bookmark(
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
locator: normalize_bookmark_locator(body.locator.clone()),
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
@@ -15,6 +15,8 @@ pub struct Bookmark {
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: Option<u64>,
#[serde(default)]
pub locator: Option<String>,
pub comment: String,
pub category: String,
pub decoders: Vec<String>,
@@ -31,12 +33,24 @@ impl BookmarkStore {
let _ = std::fs::create_dir_all(parent);
}
let db = if path.exists() {
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
.unwrap_or_else(|_| {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
})
PickleDb::load(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
};
Self {
db: Arc::new(RwLock::new(db)),
@@ -94,8 +108,8 @@ impl BookmarkStore {
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
self.list().into_iter().any(|bm| {
bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex)
})
self.list()
.into_iter()
.any(|bm| bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex))
}
}