diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index d39414c..f84038a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -3259,6 +3259,17 @@ window.clearMapMarkersByType = function(type) { locatorMarkers.delete(key); } } + + if (type === "bookmark") { + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + } }; function mapTileSpecForTheme(theme) { @@ -3387,6 +3398,22 @@ function initAprsMap() { _aprsAddMarkerToMap(call, entry); } } + for (const [key, entry] of locatorMarkers) { + if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) continue; + entry.marker = L.rectangle(bounds, locatorStyleForCount(entry.bookmarks?.length || 1, "bookmark")) + .addTo(aprsMap) + .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])) + .bindTooltip(buildBookmarkLocatorTooltipHtml(entry.grid, entry.bookmarks || []), { + className: "bookmark-locator-tip-shell", + direction: "top", + sticky: true, + opacity: 1, + }); + entry.marker.__trxType = "bookmark"; + mapMarkers.add(entry.marker); + } applyMapFilter(); const aisFilter = document.getElementById("map-filter-ais"); @@ -3963,6 +3990,7 @@ function applyMapFilter() { mapMarkers.forEach((marker) => { const type = marker.__trxType; const visible = + type === "bookmark" || (type === "ais" && mapFilter.ais) || (type === "vdes" && mapFilter.vdes) || (type === "aprs" && mapFilter.aprs) || @@ -3986,15 +4014,130 @@ function locatorStyleForCount(count, type) { const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); const isWspr = type === "wspr"; + const isBookmark = type === "bookmark"; return { - color: isWspr ? "#ff8f2a" : "#ffb020", + color: isBookmark ? "#38b48b" : (isWspr ? "#ff8f2a" : "#ffb020"), opacity: 0.45 + intensity * 0.5, weight: 1 + intensity * 1.2, - fillColor: isWspr ? "#ff6a3d" : "#ff9b1a", + fillColor: isBookmark ? "#22c55e" : (isWspr ? "#ff6a3d" : "#ff9b1a"), fillOpacity: 0.18 + intensity * 0.55, }; } +function buildBookmarkLocatorPopupHtml(grid, bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const rows = list + .map((bm) => { + const title = escapeMapHtml(String(bm.name || "Bookmark")); + const freq = typeof bmFmtFreq === "function" + ? escapeMapHtml(bmFmtFreq(bm.freq_hz)) + : escapeMapHtml(String(bm.freq_hz || "--")); + const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; + return `${title} ${freq}${mode}`; + }) + .join("
"); + return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); +} + +function buildBookmarkLocatorTooltipHtml(grid, bookmarks) { + const list = Array.isArray(bookmarks) ? [...bookmarks] : []; + list.sort((a, b) => Number(a?.freq_hz || 0) - Number(b?.freq_hz || 0)); + const rows = list + .map((bm) => { + const name = escapeMapHtml(String(bm?.name || "Bookmark")); + const freq = typeof bmFmtFreq === "function" + ? escapeMapHtml(bmFmtFreq(bm?.freq_hz)) + : escapeMapHtml(String(bm?.freq_hz || "--")); + const meta = [ + bm?.mode ? escapeMapHtml(String(bm.mode)) : null, + bm?.category ? escapeMapHtml(String(bm.category)) : null, + ].filter(Boolean).join(" · "); + return `
` + + `
` + + `${name}` + + `${freq}` + + `
` + + (meta ? `
${meta}
` : "") + + (bm?.comment ? `
${escapeMapHtml(String(bm.comment))}
` : "") + + `
`; + }) + .join(""); + return `
` + + `
${escapeMapHtml(grid)}
` + + `
${list.length} bookmark${list.length === 1 ? "" : "s"}
` + + rows + + `
`; +} + +window.syncBookmarkMapLocators = function(bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const grouped = new Map(); + for (const bm of list) { + const grid = String(bm?.locator || "").trim().toUpperCase(); + if (!grid) continue; + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const key = `bookmark:${grid}`; + const bucket = grouped.get(key); + if (bucket) { + bucket.bookmarks.push(bm); + } else { + grouped.set(key, { grid, bounds, bookmarks: [bm] }); + } + } + + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (!grouped.has(key)) { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + } + + for (const [key, next] of grouped.entries()) { + const existing = locatorMarkers.get(key); + const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); + if (existing) { + existing.grid = next.grid; + existing.bounds = next.bounds; + existing.bookmarks = next.bookmarks; + if (existing.marker) { + existing.marker.setBounds(next.bounds); + existing.marker.setStyle(locatorStyleForCount(next.bookmarks.length, "bookmark")); + existing.marker.setPopupContent(popupHtml); + existing.marker.setTooltipContent(buildBookmarkLocatorTooltipHtml(next.grid, next.bookmarks)); + } + continue; + } + + const entry = { + marker: null, + grid: next.grid, + bounds: next.bounds, + bookmarks: next.bookmarks, + }; + locatorMarkers.set(key, entry); + if (aprsMap) { + entry.marker = L.rectangle(next.bounds, locatorStyleForCount(next.bookmarks.length, "bookmark")) + .addTo(aprsMap) + .bindPopup(popupHtml) + .bindTooltip(buildBookmarkLocatorTooltipHtml(next.grid, next.bookmarks), { + className: "bookmark-locator-tip-shell", + direction: "top", + sticky: true, + opacity: 1, + }); + entry.marker.__trxType = "bookmark"; + mapMarkers.add(entry.marker); + } + } + + applyMapFilter(); +}; + window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null) { if (!aprsMap) initAprsMap(); if (!aprsMap) return; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js index 97a7843..3d13cb8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js @@ -43,6 +43,9 @@ async function bmFetch(categoryFilter) { console.error("Failed to fetch bookmarks:", e); bmList = []; } + if (typeof window.syncBookmarkMapLocators === "function") { + window.syncBookmarkMapLocators(bmList); + } bmSyncAccess(); bmApplyFilters(); bmRefreshCategoryFilter(categoryFilter); @@ -173,7 +176,7 @@ function bmOpenForm(bm) { bmWriteDecoders(bm ? bm.decoders : []); document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; - wrap.style.display = ""; + wrap.style.display = "flex"; document.getElementById("bm-name").focus(); } @@ -350,6 +353,19 @@ async function bmApply(bm) { // Form cancel document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm); + const formWrap = document.getElementById("bm-form-wrap"); + if (formWrap) { + formWrap.addEventListener("click", (e) => { + if (e.target === formWrap) bmCloseForm(); + }); + } + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") { + bmCloseForm(); + } + }); + // Table action buttons (event delegation) document.getElementById("bm-tbody").addEventListener("click", async (e) => { const tuneBtn = e.target.closest(".bm-tune-btn"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index a0e057d..6821d55 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1456,6 +1456,73 @@ small { color: var(--text-muted); } .aprs-popup-table td { padding: 0.06rem 0.3rem 0.06rem 0; vertical-align: top; font-size: 0.88em; } .aprs-popup-label { color: var(--text-muted); white-space: nowrap; padding-right: 0.5rem !important; } .aprs-popup-info { font-size: 0.85em; color: var(--text); border-top: 1px solid var(--border-light); padding-top: 0.25rem; margin-top: 0.1rem; word-break: break-word; } +.bookmark-locator-tip-shell { + background: transparent; + border: none; + box-shadow: none; +} +.bookmark-locator-tip-shell .leaflet-tooltip-content { + margin: 0; +} +.bookmark-locator-tip-shell.leaflet-tooltip-top::before { + border-top-color: color-mix(in srgb, var(--card-bg) 94%, transparent); +} +.bookmark-locator-tip { + min-width: 15rem; + max-width: 24rem; + padding: 0.55rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--accent-green) 26%, var(--border-light)); + border-radius: 0.65rem; + background: color-mix(in srgb, var(--card-bg) 94%, transparent); + color: var(--text); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); +} +.bookmark-locator-tip-title { + color: var(--accent-green); + font-weight: 700; + font-size: 0.9rem; + letter-spacing: 0.03em; +} +.bookmark-locator-tip-subtitle { + margin-top: 0.1rem; + margin-bottom: 0.45rem; + font-size: 0.75rem; + color: var(--text-muted); +} +.bookmark-locator-tip-row + .bookmark-locator-tip-row { + margin-top: 0.45rem; + padding-top: 0.4rem; + border-top: 1px solid color-mix(in srgb, var(--border-light) 70%, transparent); +} +.bookmark-locator-tip-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} +.bookmark-locator-tip-name { + font-weight: 600; + color: var(--text-heading); +} +.bookmark-locator-tip-freq { + flex: 0 0 auto; + font-size: 0.78rem; + color: var(--accent-yellow); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.bookmark-locator-tip-meta { + margin-top: 0.12rem; + font-size: 0.74rem; + color: var(--text-muted); +} +.bookmark-locator-tip-note { + margin-top: 0.2rem; + font-size: 0.76rem; + line-height: 1.3; + color: var(--text); + word-break: break-word; +} .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; } .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; } @@ -2517,12 +2584,28 @@ button:focus-visible, input:focus-visible, select:focus-visible { opacity: 0.88; } +#bm-form-wrap { + position: fixed; + inset: 0; + z-index: 120; + padding: 1.25rem; + background: rgba(7, 12, 18, 0.72); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + align-items: center; + justify-content: center; +} + .bm-form { background: var(--card-bg); border: 1px solid var(--border-light); - border-radius: 0.5rem; - padding: 0.9rem 1rem; - margin: 0 0.75rem 0.6rem; + border-radius: 0.8rem; + padding: 1rem 1.1rem; + margin: 0; + width: min(46rem, calc(100vw - 2.5rem)); + max-height: min(85vh, 42rem); + overflow: auto; + box-shadow: 0 1.2rem 2.6rem rgba(0, 0, 0, 0.38); } .bm-form-title { @@ -2544,9 +2627,11 @@ button:focus-visible, input:focus-visible, select:focus-visible { gap: 0.2rem; font-size: 0.78rem; color: var(--text-muted); + min-width: 0; } -.bm-label input { +.bm-label input, +.bm-label select { font-size: 0.88rem; padding: 0.35rem 0.5rem; } @@ -2559,9 +2644,11 @@ button:focus-visible, input:focus-visible, select:focus-visible { display: flex; gap: 0.5rem; margin-top: 0.75rem; + justify-content: flex-end; } -.bm-save-btn { +.bm-save-btn, +#bm-form-cancel { background: var(--accent-green); color: #fff; border: none; @@ -2571,10 +2658,40 @@ button:focus-visible, input:focus-visible, select:focus-visible { cursor: pointer; } -.bm-save-btn:hover { +.bm-save-btn:hover, +#bm-form-cancel:hover { opacity: 0.88; } +#bm-form-cancel { + background: var(--btn-bg); + color: var(--text); + border: 1px solid var(--btn-border); +} + +.bm-decoder-checks { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + min-height: 2.2rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border-light); + border-radius: 0.45rem; + background: var(--input-bg); +} + +.bm-decoder-check { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--text); + font-size: 0.84rem; +} + +.bm-decoder-check input { + margin: 0; +} + .bm-table { width: 100%; border-collapse: collapse;