[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));
|
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) {
|
function formatTimeAgo(tsMs) {
|
||||||
if (!tsMs) return null;
|
if (!tsMs) return null;
|
||||||
const secs = Math.round((Date.now() - tsMs) / 1000);
|
const secs = Math.round((Date.now() - tsMs) / 1000);
|
||||||
@@ -5571,7 +5638,7 @@ function createBookmarkChip(bm, colorMap, options = {}) {
|
|||||||
if (options.sideStack) {
|
if (options.sideStack) {
|
||||||
span.classList.add("spectrum-bookmark-chip-side");
|
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;
|
span.dataset.bmId = bm.id;
|
||||||
const labelHtml = options.sideStack
|
const labelHtml = options.sideStack
|
||||||
? (
|
? (
|
||||||
@@ -5931,10 +5998,13 @@ if (spectrumCanvas) {
|
|||||||
const edge = getBwEdgeHit(cssX, rect.width, range);
|
const edge = getBwEdgeHit(cssX, rect.width, range);
|
||||||
spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
|
spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
|
||||||
const hz = canvasXToHz(cssX, rect.width, range);
|
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 peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData);
|
||||||
const peakHz = peak?.hz ?? null;
|
const peakHz = peak?.hz ?? null;
|
||||||
const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : 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
|
spectrumTooltip.textContent = peakDb
|
||||||
? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}`
|
? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}`
|
||||||
: `Peak ${formatSpectrumFreq(peakHz)}`;
|
: `Peak ${formatSpectrumFreq(peakHz)}`;
|
||||||
|
|||||||
@@ -314,6 +314,9 @@
|
|||||||
<label class="bm-label">Bandwidth (Hz)
|
<label class="bm-label">Bandwidth (Hz)
|
||||||
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
|
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
|
||||||
</label>
|
</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-label">Decoders
|
||||||
<div class="bm-decoder-checks">
|
<div class="bm-decoder-checks">
|
||||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label>
|
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label>
|
||||||
@@ -338,6 +341,7 @@
|
|||||||
<th>Frequency</th>
|
<th>Frequency</th>
|
||||||
<th>Mode</th>
|
<th>Mode</th>
|
||||||
<th>BW</th>
|
<th>BW</th>
|
||||||
|
<th>Locator</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Decoders</th>
|
<th>Decoders</th>
|
||||||
<th>Comment</th>
|
<th>Comment</th>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function bmApplyFilters() {
|
|||||||
filtered = text
|
filtered = text
|
||||||
? filtered.filter((bm) =>
|
? filtered.filter((bm) =>
|
||||||
(bm.name || "").toLowerCase().includes(text) ||
|
(bm.name || "").toLowerCase().includes(text) ||
|
||||||
|
(bm.locator || "").toLowerCase().includes(text) ||
|
||||||
(bm.category || "").toLowerCase().includes(text) ||
|
(bm.category || "").toLowerCase().includes(text) ||
|
||||||
(bm.comment || "").toLowerCase().includes(text)
|
(bm.comment || "").toLowerCase().includes(text)
|
||||||
)
|
)
|
||||||
@@ -117,6 +118,7 @@ function bmRender(list) {
|
|||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.dataset.bmId = bm.id;
|
tr.dataset.bmId = bm.id;
|
||||||
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||||
|
const locatorCell = bm.locator || "--";
|
||||||
const catCell = bm.category || "Uncategorised";
|
const catCell = bm.category || "Uncategorised";
|
||||||
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
const commentCell = bm.comment || "";
|
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-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
||||||
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
||||||
`<td class="bm-col-bw">${bwCell}</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-cat">${bmEsc(catCell)}</td>` +
|
||||||
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
||||||
`<td class="bm-col-cmt">${bmEsc(commentCell)}</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-freq").value = bm ? bm.freq_hz : "";
|
||||||
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||||
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
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-category-input").value = bm ? (bm.category || "") : "";
|
||||||
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||||
bmWriteDecoders(bm ? bm.decoders : []);
|
bmWriteDecoders(bm ? bm.decoders : []);
|
||||||
@@ -201,6 +205,7 @@ async function bmSave(e) {
|
|||||||
const mode = document.getElementById("bm-mode").value.trim();
|
const mode = document.getElementById("bm-mode").value.trim();
|
||||||
const bwStr = document.getElementById("bm-bw").value;
|
const bwStr = document.getElementById("bm-bw").value;
|
||||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
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 category = document.getElementById("bm-category-input").value.trim();
|
||||||
const comment = document.getElementById("bm-comment").value.trim();
|
const comment = document.getElementById("bm-comment").value.trim();
|
||||||
const decoders = bmReadDecoders();
|
const decoders = bmReadDecoders();
|
||||||
@@ -210,7 +215,16 @@ async function bmSave(e) {
|
|||||||
return;
|
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 {
|
try {
|
||||||
let resp;
|
let resp;
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ pub struct BookmarkInput {
|
|||||||
pub freq_hz: u64,
|
pub freq_hz: u64,
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
pub bandwidth_hz: Option<u64>,
|
pub bandwidth_hz: Option<u64>,
|
||||||
|
pub locator: Option<String>,
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub decoders: Option<Vec<String>>,
|
pub decoders: Option<Vec<String>>,
|
||||||
@@ -766,6 +767,17 @@ fn gen_bookmark_id() -> String {
|
|||||||
hex::encode(rand::random::<[u8; 16]>())
|
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")]
|
#[get("/bookmarks")]
|
||||||
pub async fn list_bookmarks(
|
pub async fn list_bookmarks(
|
||||||
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||||
@@ -801,6 +813,7 @@ pub async fn create_bookmark(
|
|||||||
freq_hz: body.freq_hz,
|
freq_hz: body.freq_hz,
|
||||||
mode: body.mode.clone(),
|
mode: body.mode.clone(),
|
||||||
bandwidth_hz: body.bandwidth_hz,
|
bandwidth_hz: body.bandwidth_hz,
|
||||||
|
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||||
comment: body.comment.clone().unwrap_or_default(),
|
comment: body.comment.clone().unwrap_or_default(),
|
||||||
category: body.category.clone().unwrap_or_default(),
|
category: body.category.clone().unwrap_or_default(),
|
||||||
decoders: body.decoders.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,
|
freq_hz: body.freq_hz,
|
||||||
mode: body.mode.clone(),
|
mode: body.mode.clone(),
|
||||||
bandwidth_hz: body.bandwidth_hz,
|
bandwidth_hz: body.bandwidth_hz,
|
||||||
|
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||||
comment: body.comment.clone().unwrap_or_default(),
|
comment: body.comment.clone().unwrap_or_default(),
|
||||||
category: body.category.clone().unwrap_or_default(),
|
category: body.category.clone().unwrap_or_default(),
|
||||||
decoders: body.decoders.clone().unwrap_or_default(),
|
decoders: body.decoders.clone().unwrap_or_default(),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub struct Bookmark {
|
|||||||
pub freq_hz: u64,
|
pub freq_hz: u64,
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
pub bandwidth_hz: Option<u64>,
|
pub bandwidth_hz: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub locator: Option<String>,
|
||||||
pub comment: String,
|
pub comment: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
pub decoders: Vec<String>,
|
pub decoders: Vec<String>,
|
||||||
@@ -31,12 +33,24 @@ impl BookmarkStore {
|
|||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
let db = if path.exists() {
|
let db = if path.exists() {
|
||||||
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
PickleDb::load(
|
||||||
.unwrap_or_else(|_| {
|
path,
|
||||||
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
PickleDbDumpPolicy::AutoDump,
|
||||||
})
|
SerializationMethod::Json,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
PickleDb::new(
|
||||||
|
path,
|
||||||
|
PickleDbDumpPolicy::AutoDump,
|
||||||
|
SerializationMethod::Json,
|
||||||
|
)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
PickleDb::new(
|
||||||
|
path,
|
||||||
|
PickleDbDumpPolicy::AutoDump,
|
||||||
|
SerializationMethod::Json,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
db: Arc::new(RwLock::new(db)),
|
db: Arc::new(RwLock::new(db)),
|
||||||
@@ -94,8 +108,8 @@ impl BookmarkStore {
|
|||||||
|
|
||||||
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
|
/// 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 {
|
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
|
||||||
self.list().into_iter().any(|bm| {
|
self.list()
|
||||||
bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex)
|
.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