[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:
Claude
2026-03-29 22:43:33 +00:00
committed by Stan Grams
parent 8c5706f6c3
commit 2150f61828
3 changed files with 517 additions and 13 deletions
@@ -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: "0500 km", min: 0, max: 500 },
{ label: "5001k", min: 500, max: 1000 },
{ label: "1k2k", min: 1000, max: 2000 },
{ label: "2k5k", min: 2000, max: 5000 },
{ label: "5k10k", 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; }
}