[feat](trx-frontend-http): add Statistics panel, move summaries from Map tab
Extract the three summary sections (longest decode paths, strongest/weakest signals) from the Map tab into a new dedicated Statistics tab. Add new analytics: decode counters, unique stations/grids, decode rate, decode-by-type breakdown, band activity, per-receiver comparison, and DX distance histogram. The Statistics panel has its own receiver and history filters independent of the map view. https://claude.ai/code/session_01R9T4Byg7uw6qpkTsyVJd9k Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1113,6 +1113,7 @@ function updateMapRigFilter() {
|
||||
el.value = "";
|
||||
mapRigFilter = "";
|
||||
}
|
||||
updateStatsRigFilter();
|
||||
}
|
||||
|
||||
async function refreshRigList() {
|
||||
@@ -4291,7 +4292,7 @@ if (spectrumBwSweetBtn) {
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "settings", "about"];
|
||||
const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "settings", "about"];
|
||||
const TAB_PATHS = {
|
||||
main: "/",
|
||||
bookmarks: "/bookmarks",
|
||||
@@ -4341,6 +4342,9 @@ function navigateToTab(name, options = {}) {
|
||||
sizeAprsMapToViewport();
|
||||
if (aprsMap) setTimeout(() => aprsMap.invalidateSize(), 50);
|
||||
}
|
||||
if (name === "statistics") {
|
||||
scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
@@ -5291,9 +5295,7 @@ function syncDecodeContactPathVisibility() {
|
||||
}
|
||||
ensureDecodeContactPathRendered(entry);
|
||||
}
|
||||
renderMapQsoSummary();
|
||||
renderMapSignalSummary();
|
||||
renderMapWeakSignalSummary();
|
||||
scheduleStatsRender();
|
||||
updateMapPathsAnimationClass();
|
||||
}
|
||||
|
||||
@@ -7171,11 +7173,12 @@ function renderMapQsoSummary() {
|
||||
const listEl = document.getElementById("map-qso-summary-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const entries = Array.from(decodeContactPaths.values())
|
||||
.filter((entry) => entry
|
||||
&& Number.isFinite(entry.distanceKm)
|
||||
&& decodeContactPathMatchesCurrentMap(entry)
|
||||
&& _detailPassesRigFilter(entry))
|
||||
&& _statsDetailPassesRigFilter(entry)
|
||||
&& (!entry.tsMs || entry.tsMs >= cutoff))
|
||||
.sort((a, b) => {
|
||||
const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm);
|
||||
if (Math.abs(distanceDelta) > 0.001) return distanceDelta;
|
||||
@@ -7282,14 +7285,15 @@ function renderMapSignalSummary() {
|
||||
const listEl = document.getElementById("map-signal-summary-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const bestByStation = new Map();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
|
||||
if (!_locatorEntryVisibleOnMap(entry)) continue;
|
||||
if (!(entry.stationDetails instanceof Map)) continue;
|
||||
for (const detail of entry.stationDetails.values()) {
|
||||
if (!Number.isFinite(detail?.snr_db)) continue;
|
||||
if (!_detailPassesRigFilter(detail)) continue;
|
||||
if (!_statsDetailPassesRigFilter(detail)) continue;
|
||||
if (detail.ts_ms && detail.ts_ms < cutoff) continue;
|
||||
const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
|
||||
if (!station) continue;
|
||||
const snrDb = Number(detail.snr_db);
|
||||
@@ -7407,14 +7411,15 @@ function renderMapWeakSignalSummary() {
|
||||
const listEl = document.getElementById("map-weak-signal-summary-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const worstByStation = new Map();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
|
||||
if (!_locatorEntryVisibleOnMap(entry)) continue;
|
||||
if (!(entry.stationDetails instanceof Map)) continue;
|
||||
for (const detail of entry.stationDetails.values()) {
|
||||
if (!Number.isFinite(detail?.snr_db)) continue;
|
||||
if (!_detailPassesRigFilter(detail)) continue;
|
||||
if (!_statsDetailPassesRigFilter(detail)) continue;
|
||||
if (detail.ts_ms && detail.ts_ms < cutoff) continue;
|
||||
const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
|
||||
if (!station) continue;
|
||||
const snrDb = Number(detail.snr_db);
|
||||
@@ -7528,6 +7533,270 @@ function renderMapWeakSignalSummary() {
|
||||
listEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
// ── Statistics panel ─────────────────────────────────────────────────
|
||||
let statsRigFilter = "";
|
||||
let statsHistoryLimitMinutes = 1440;
|
||||
const statsDecodeLog = []; // {type, ts_ms, remote}
|
||||
const STATS_LOG_MAX = 50000;
|
||||
const STATS_TYPE_COLORS = {
|
||||
ft8: "#4fc3f7", ft4: "#81c784", ft2: "#aed581", wspr: "#ffb74d",
|
||||
aprs: "#ce93d8", hf_aprs: "#ba68c8", ais: "#90a4ae", vdes: "#78909c",
|
||||
cw: "#fff176",
|
||||
};
|
||||
const STATS_DX_BUCKETS = [
|
||||
{ label: "0–500 km", min: 0, max: 500 },
|
||||
{ label: "500–1k", min: 500, max: 1000 },
|
||||
{ label: "1k–2k", min: 1000, max: 2000 },
|
||||
{ label: "2k–5k", min: 2000, max: 5000 },
|
||||
{ label: "5k–10k", min: 5000, max: 10000 },
|
||||
{ label: "10k+ km", min: 10000, max: Infinity },
|
||||
];
|
||||
|
||||
function _statsHistoryCutoffMs() {
|
||||
return Date.now() - (statsHistoryLimitMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
function _statsDetailPassesRigFilter(detail) {
|
||||
if (!statsRigFilter) return true;
|
||||
if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter);
|
||||
return detail?.remote === statsRigFilter;
|
||||
}
|
||||
|
||||
function updateStatsRigFilter() {
|
||||
const el = document.getElementById("stats-rig-filter");
|
||||
if (!el) return;
|
||||
const prev = el.value;
|
||||
while (el.options.length > 1) el.remove(1);
|
||||
for (const id of lastRigIds) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = lastRigDisplayNames[id] || id;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
if (prev && lastRigIds.includes(prev)) {
|
||||
el.value = prev;
|
||||
} else {
|
||||
el.value = "";
|
||||
statsRigFilter = "";
|
||||
}
|
||||
}
|
||||
|
||||
function statsRecordDecode(type, remote) {
|
||||
statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: Date.now(), remote: remote || null });
|
||||
if (statsDecodeLog.length > STATS_LOG_MAX) {
|
||||
statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function _statsFilteredLog() {
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
return statsDecodeLog.filter((e) => {
|
||||
if (e.ts_ms < cutoff) return false;
|
||||
if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatsCounters() {
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const log = _statsFilteredLog();
|
||||
const totalDecodes = log.length;
|
||||
|
||||
const uniqueStations = new Set();
|
||||
const uniqueGrids = new Set();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || !(entry.stationDetails instanceof Map)) continue;
|
||||
for (const detail of entry.stationDetails.values()) {
|
||||
if (detail?.ts_ms && detail.ts_ms < cutoff) continue;
|
||||
if (!_statsDetailPassesRigFilter(detail)) continue;
|
||||
const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
|
||||
if (station) uniqueStations.add(station);
|
||||
}
|
||||
if (entry.grid) {
|
||||
const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some(
|
||||
(d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d)
|
||||
);
|
||||
if (hasVisible) uniqueGrids.add(entry.grid);
|
||||
}
|
||||
}
|
||||
|
||||
// Decode rate: decodes in last 60 seconds → per minute
|
||||
const rateWindow = Date.now() - 60000;
|
||||
const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length;
|
||||
|
||||
const setEl = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(val);
|
||||
};
|
||||
setEl("stats-total-decodes", totalDecodes.toLocaleString());
|
||||
setEl("stats-unique-stations", uniqueStations.size.toLocaleString());
|
||||
setEl("stats-unique-grids", uniqueGrids.size.toLocaleString());
|
||||
setEl("stats-decode-rate", recentCount.toLocaleString());
|
||||
}
|
||||
|
||||
function _renderBarChart(containerId, data, emptyMsg) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!data || data.length === 0 || data.every((d) => d.count === 0)) {
|
||||
el.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "stats-bar-empty";
|
||||
empty.textContent = emptyMsg || "No data available.";
|
||||
el.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const maxVal = Math.max(1, ...data.map((d) => d.count));
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const item of data) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "stats-bar-row";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "stats-bar-label";
|
||||
label.textContent = item.label;
|
||||
row.appendChild(label);
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "stats-bar-track";
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "stats-bar-fill";
|
||||
fill.style.width = `${(item.count / maxVal) * 100}%`;
|
||||
fill.style.background = item.color || "var(--accent-green)";
|
||||
track.appendChild(fill);
|
||||
row.appendChild(track);
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "stats-bar-count";
|
||||
count.textContent = item.count.toLocaleString();
|
||||
row.appendChild(count);
|
||||
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
el.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function renderStatsDecodeTypes() {
|
||||
const log = _statsFilteredLog();
|
||||
const counts = {};
|
||||
for (const e of log) {
|
||||
counts[e.type] = (counts[e.type] || 0) + 1;
|
||||
}
|
||||
const data = Object.entries(counts)
|
||||
.map(([type, count]) => ({
|
||||
label: type.toUpperCase(),
|
||||
count,
|
||||
color: STATS_TYPE_COLORS[type] || "#aaa",
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
_renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history.");
|
||||
}
|
||||
|
||||
function renderStatsBandActivity() {
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const bandCounts = {};
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || !(entry.stationDetails instanceof Map)) continue;
|
||||
for (const detail of entry.stationDetails.values()) {
|
||||
if (detail?.ts_ms && detail.ts_ms < cutoff) continue;
|
||||
if (!_statsDetailPassesRigFilter(detail)) continue;
|
||||
if (!Number.isFinite(detail?.freq_hz)) continue;
|
||||
const band = bandForHz(Number(detail.freq_hz));
|
||||
if (band) {
|
||||
bandCounts[band.label] = (bandCounts[band.label] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const data = Object.entries(bandCounts)
|
||||
.map(([label, count]) => ({
|
||||
label,
|
||||
count,
|
||||
color: locatorBandChipColor(label),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
_renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history.");
|
||||
}
|
||||
|
||||
function renderStatsRigCompare() {
|
||||
const section = document.getElementById("stats-rig-compare-section");
|
||||
if (!section) return;
|
||||
if (lastRigIds.length < 2) {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const rigCounts = {};
|
||||
for (const e of statsDecodeLog) {
|
||||
if (e.ts_ms < cutoff) continue;
|
||||
const rid = e.remote || "unknown";
|
||||
rigCounts[rid] = (rigCounts[rid] || 0) + 1;
|
||||
}
|
||||
const data = Object.entries(rigCounts)
|
||||
.map(([rid, count]) => ({
|
||||
label: lastRigDisplayNames[rid] || rid,
|
||||
count,
|
||||
color: "var(--accent-green)",
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
_renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver.");
|
||||
}
|
||||
|
||||
function renderStatsDxHistogram() {
|
||||
const cutoff = _statsHistoryCutoffMs();
|
||||
const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 }));
|
||||
for (const entry of decodeContactPaths.values()) {
|
||||
if (!entry || !Number.isFinite(entry.distanceKm)) continue;
|
||||
if (entry.tsMs && entry.tsMs < cutoff) continue;
|
||||
if (!_statsDetailPassesRigFilter(entry)) continue;
|
||||
const km = entry.distanceKm;
|
||||
for (const b of buckets) {
|
||||
if (km >= b.min && km < b.max) { b.count++; break; }
|
||||
}
|
||||
}
|
||||
const data = buckets.map((b) => ({
|
||||
label: b.label,
|
||||
count: b.count,
|
||||
color: "#4fc3f7",
|
||||
}));
|
||||
_renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history.");
|
||||
}
|
||||
|
||||
let _statsRenderPending = false;
|
||||
function scheduleStatsRender() {
|
||||
if (_statsRenderPending) return;
|
||||
_statsRenderPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
_statsRenderPending = false;
|
||||
renderStatsCounters();
|
||||
renderStatsDecodeTypes();
|
||||
renderStatsBandActivity();
|
||||
renderStatsRigCompare();
|
||||
renderStatsDxHistogram();
|
||||
renderMapQsoSummary();
|
||||
renderMapSignalSummary();
|
||||
renderMapWeakSignalSummary();
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up statistics panel controls
|
||||
(function() {
|
||||
const rigEl = document.getElementById("stats-rig-filter");
|
||||
if (rigEl) {
|
||||
rigEl.addEventListener("change", () => {
|
||||
statsRigFilter = rigEl.value;
|
||||
scheduleStatsRender();
|
||||
});
|
||||
}
|
||||
const histEl = document.getElementById("stats-history-limit");
|
||||
if (histEl) {
|
||||
histEl.value = String(statsHistoryLimitMinutes);
|
||||
histEl.addEventListener("change", () => {
|
||||
statsHistoryLimitMinutes = Number(histEl.value) || 1440;
|
||||
scheduleStatsRender();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
|
||||
const list = Array.isArray(bookmarks) ? bookmarks : [];
|
||||
const rows = list
|
||||
@@ -8661,6 +8930,10 @@ function dispatchDecodeMessage(msg) {
|
||||
if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg);
|
||||
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
|
||||
if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg);
|
||||
if (msg.type && msg.type !== "lrpt_image") {
|
||||
statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDecodeBatch(batch) {
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="statistics">
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></svg>
|
||||
<span class="tab-label">Statistics</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="settings">
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></svg>
|
||||
<span class="tab-label">Settings</span>
|
||||
@@ -959,11 +963,85 @@
|
||||
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
||||
<div id="aprs-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||
<div class="stats-controls">
|
||||
<div class="stats-control-group">
|
||||
<label class="stats-control-label" for="stats-rig-filter">Receiver</label>
|
||||
<select id="stats-rig-filter" class="stats-select">
|
||||
<option value="">All receivers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stats-control-group">
|
||||
<label class="stats-control-label" for="stats-history-limit">History</label>
|
||||
<select id="stats-history-limit" class="stats-select">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hr</option>
|
||||
<option value="180">3 hrs</option>
|
||||
<option value="360">6 hrs</option>
|
||||
<option value="720">12 hrs</option>
|
||||
<option value="1440" selected>24 hrs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-counters" id="stats-counters">
|
||||
<div class="stats-counter-card">
|
||||
<div class="stats-counter-value" id="stats-total-decodes">0</div>
|
||||
<div class="stats-counter-label">Total decodes</div>
|
||||
</div>
|
||||
<div class="stats-counter-card">
|
||||
<div class="stats-counter-value" id="stats-unique-stations">0</div>
|
||||
<div class="stats-counter-label">Unique stations</div>
|
||||
</div>
|
||||
<div class="stats-counter-card">
|
||||
<div class="stats-counter-value" id="stats-unique-grids">0</div>
|
||||
<div class="stats-counter-label">Unique grids</div>
|
||||
</div>
|
||||
<div class="stats-counter-card">
|
||||
<div class="stats-counter-value" id="stats-decode-rate">0</div>
|
||||
<div class="stats-counter-label">Decodes / min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="stats-section" aria-labelledby="stats-decode-type-title">
|
||||
<div class="stats-section-head">
|
||||
<div id="stats-decode-type-title" class="stats-section-title">Decodes by type</div>
|
||||
<div class="stats-section-subtitle">Breakdown of decoded signals by decoder type</div>
|
||||
</div>
|
||||
<div id="stats-decode-type-bars" class="stats-bar-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="stats-section" aria-labelledby="stats-band-activity-title">
|
||||
<div class="stats-section-head">
|
||||
<div id="stats-band-activity-title" class="stats-section-title">Band activity</div>
|
||||
<div class="stats-section-subtitle">Decode volume per amateur band</div>
|
||||
</div>
|
||||
<div id="stats-band-activity-bars" class="stats-bar-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="stats-section" aria-labelledby="stats-rig-compare-title" id="stats-rig-compare-section" style="display:none;">
|
||||
<div class="stats-section-head">
|
||||
<div id="stats-rig-compare-title" class="stats-section-title">Per-receiver comparison</div>
|
||||
<div class="stats-section-subtitle">Decode counts across connected receivers</div>
|
||||
</div>
|
||||
<div id="stats-rig-compare-bars" class="stats-bar-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="stats-section" aria-labelledby="stats-dx-histogram-title">
|
||||
<div class="stats-section-head">
|
||||
<div id="stats-dx-histogram-title" class="stats-section-title">DX distance distribution</div>
|
||||
<div class="stats-section-subtitle">Contact path distance histogram</div>
|
||||
</div>
|
||||
<div id="stats-dx-histogram-bars" class="stats-bar-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="map-qso-summary" aria-labelledby="map-qso-summary-title">
|
||||
<div class="map-qso-summary-head">
|
||||
<div>
|
||||
<div id="map-qso-summary-title" class="map-qso-summary-title">Longest decode paths</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 directed decode paths in the current map view</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 directed decode paths by distance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-qso-summary-list" class="map-qso-summary-list"></div>
|
||||
@@ -972,7 +1050,7 @@
|
||||
<div class="map-qso-summary-head">
|
||||
<div>
|
||||
<div id="map-signal-summary-title" class="map-qso-summary-title">Strongest decoded signal</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 strongest signals in the current map history</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 strongest signals by SNR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-signal-summary-list" class="map-qso-summary-list"></div>
|
||||
@@ -981,7 +1059,7 @@
|
||||
<div class="map-qso-summary-head">
|
||||
<div>
|
||||
<div id="map-weak-signal-summary-title" class="map-qso-summary-title">Weakest decoded signal</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 weakest signals in the current map history</div>
|
||||
<div class="map-qso-summary-subtitle">Top 5 weakest signals by SNR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-weak-signal-summary-list" class="map-qso-summary-list"></div>
|
||||
|
||||
@@ -2736,6 +2736,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
/* Shorten long tab labels to keep bottom nav compact */
|
||||
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
|
||||
.top-bar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
@@ -4939,3 +4940,155 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.sat-pred-header-current, .sat-pred-row-current { grid-template-columns: 1fr 4rem 5rem 5rem 4.5rem; font-size: 0.75rem; }
|
||||
.sat-pred-col-dir { display: none; }
|
||||
}
|
||||
|
||||
/* ── Statistics panel ──────────────────────────────────────────────── */
|
||||
#tab-statistics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 72rem;
|
||||
}
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.stats-control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats-control-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stats-select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.stats-counters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.stats-counter-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.9rem 0.7rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 76%, transparent);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 90%, transparent), color-mix(in srgb, var(--input-bg) 82%, transparent));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
|
||||
}
|
||||
.stats-counter-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stats-counter-label {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 76%, transparent);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 90%, transparent), color-mix(in srgb, var(--input-bg) 82%, transparent));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
|
||||
}
|
||||
.stats-section-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
.stats-section-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.stats-section-subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stats-bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.stats-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.stats-bar-label {
|
||||
min-width: 5.5rem;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.stats-bar-track {
|
||||
flex: 1;
|
||||
height: 1.15rem;
|
||||
border-radius: 0.4rem;
|
||||
background: color-mix(in srgb, var(--input-bg) 84%, transparent);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.stats-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.4rem;
|
||||
min-width: 2px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
.stats-bar-count {
|
||||
min-width: 3.2rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stats-bar-empty {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px dashed color-mix(in srgb, var(--border-light) 76%, transparent);
|
||||
background: color-mix(in srgb, var(--input-bg) 84%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
#tab-statistics { padding: 0.75rem; }
|
||||
.stats-counters { grid-template-columns: repeat(2, 1fr); }
|
||||
.stats-counter-value { font-size: 1.3rem; }
|
||||
.stats-bar-label { min-width: 4rem; font-size: 0.72rem; }
|
||||
.stats-section { padding: 0.8rem 0.85rem 0.9rem; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user