[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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user