mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 12:12:51 +00:00
## Summary - **`app.js`**: `getDistanceUnit()`, `formatDistance(km)`, `formatDistanceRound(km)` helpers. Auto mode uses `navigator.language` — miles for `en-US`, `en-GB`, `my`, `lr`; km everywhere else. - **`customize-v2.js`**: Distance Unit preference (km / mi / auto) in Display Settings panel. Stored in `localStorage['meshcore-distance-unit']` via the existing apply pipeline. Override dot and reset work. Display tab badge counts it. - **`nodes.js`**: Neighbor table distance cell uses `formatDistance()`. - **`analytics.js`**: All rendered km values use `formatDistance()` or `formatDistanceRound()`. Column headers (`km`/`mi`) respond to the active unit. Collision classification thresholds (Local < 50 km / Regional 50–200 km / Distant > 200 km) also adapt. Default is `auto` — no change for existing users unless their locale maps to miles. ## Test plan - [x] `node test-frontend-helpers.js` — 456 passed, 0 failed (10 new formatDistance tests) - [ ] Set unit to **mi** in customize → Neighbors table shows `7.6 mi` instead of `12.3 km` - [ ] Analytics → Distance tab → stat cards, leaderboard, and column headers all show miles - [ ] Collision tool → Local/Regional/Distant thresholds show `31 mi` / `124 mi` - [ ] Route patterns popup shows miles per hop and total - [ ] Reset override dot → unit returns to auto Closes #621 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com>
This commit is contained in:
5 files changed
+179
-27
No files matched your search
+26
-18
@@ -136,9 +136,14 @@
|
||||
analyticsContent.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// Re-render when distance unit or theme changes
|
||||
_themeRefreshHandler = function () { renderTab(_currentTab); };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
var _themeRefreshHandler = null;
|
||||
let _currentTab = 'overview';
|
||||
|
||||
async function loadAnalytics() {
|
||||
@@ -1372,6 +1377,8 @@
|
||||
}
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
const t50 = formatDistanceRound(50);
|
||||
const t200 = formatDistanceRound(200);
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
<thead><tr>
|
||||
<th scope="col">Prefix</th>
|
||||
@@ -1383,20 +1390,20 @@
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within ${t50} — likely true collision, same RF neighborhood">🏘️ Local</span>`;
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes ${t50}–${t200} apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>`;
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >${t200} apart — beyond typical 915MHz range">🌐 Distant</span>`;
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
tooltip = 'Not enough coordinate data to classify';
|
||||
}
|
||||
const nodes = c.nodes || [];
|
||||
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : '<span class="text-muted">—</span>';
|
||||
const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.prefix}</td>
|
||||
${showAppearances ? `<td>${(c.appearances || 0).toLocaleString()}</td>` : ''}
|
||||
@@ -1412,9 +1419,9 @@
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
<strong>🏘️ Local</strong> <${t50}: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> ${t50}–${t200}: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
</div>`;
|
||||
}
|
||||
async function renderSubpaths(el) {
|
||||
@@ -1545,12 +1552,12 @@
|
||||
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${formatDistance(km)}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
} else {
|
||||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||||
}
|
||||
}
|
||||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}</strong></div>`);
|
||||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${formatDistance(total)}</strong></div>`);
|
||||
return dists.join('');
|
||||
})()}
|
||||
</div>` : ''}
|
||||
@@ -1787,16 +1794,17 @@
|
||||
let html = `<div class="analytics-grid">
|
||||
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatDistance(s.avgDist)}</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatDistance(s.maxDist)}</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
</div>`;
|
||||
|
||||
// Category stats
|
||||
const cats = data.catStats;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">Max (km)</th></tr></thead><tbody>`;
|
||||
const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km';
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (${distUnitLabel})</th><th scope="col">Median (${distUnitLabel})</th><th scope="col">Min (${distUnitLabel})</th><th scope="col">Max (${distUnitLabel})</th></tr></thead><tbody>`;
|
||||
for (const [cat, st] of Object.entries(cats)) {
|
||||
if (!st.count) continue;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${formatDistance(st.avg)}</td><td>${formatDistance(st.median)}</td><td>${formatDistance(st.min)}</td><td>${formatDistance(st.max)}</td></tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
@@ -1813,7 +1821,7 @@
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
@@ -1821,13 +1829,13 @@
|
||||
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
|
||||
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
|
||||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Top paths
|
||||
if (data.topPaths.length) {
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
data.topPaths.slice(0, 10).forEach((p, i) => {
|
||||
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
|
||||
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
|
||||
@@ -1836,7 +1844,7 @@
|
||||
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
|
||||
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
|
||||
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
html += `<tr><td>${i+1}</td><td><strong>${formatDistance(p.totalDist)}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
@@ -1864,7 +1872,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -108,6 +108,42 @@ function getHashParams() {
|
||||
return new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
}
|
||||
|
||||
function getDistanceUnit() {
|
||||
var stored = localStorage.getItem('meshcore-distance-unit');
|
||||
if (stored === 'km') return 'km';
|
||||
if (stored === 'mi') return 'mi';
|
||||
// 'auto' or no value — locale detection
|
||||
var milesLocales = ['en-us', 'en-gb'];
|
||||
var lang = (typeof navigator !== 'undefined' && navigator.language || '').toLowerCase();
|
||||
for (var i = 0; i < milesLocales.length; i++) {
|
||||
if (lang === milesLocales[i] || lang.startsWith(milesLocales[i] + '-')) return 'mi';
|
||||
}
|
||||
return 'km';
|
||||
}
|
||||
window.getDistanceUnit = getDistanceUnit;
|
||||
|
||||
function formatDistance(km) {
|
||||
if (km == null || isNaN(+km)) return '—';
|
||||
var d = +km;
|
||||
var unit = getDistanceUnit();
|
||||
if (unit === 'mi') {
|
||||
var mi = d / 1.60934;
|
||||
if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
|
||||
return mi.toFixed(1) + ' mi';
|
||||
}
|
||||
if (d < 1) return Math.round(d * 1000) + ' m';
|
||||
return d.toFixed(1) + ' km';
|
||||
}
|
||||
window.formatDistance = formatDistance;
|
||||
|
||||
function formatDistanceRound(km) {
|
||||
if (km == null || isNaN(+km)) return '—';
|
||||
var unit = getDistanceUnit();
|
||||
if (unit === 'mi') return Math.round(+km / 1.60934) + ' mi';
|
||||
return Math.round(+km) + ' km';
|
||||
}
|
||||
window.formatDistanceRound = formatDistanceRound;
|
||||
|
||||
function getTimestampMode() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-mode');
|
||||
if (saved === 'ago' || saved === 'absolute') return saved;
|
||||
|
||||
+30
-8
@@ -33,9 +33,10 @@
|
||||
'meshcore-live-heatmap-opacity'
|
||||
];
|
||||
|
||||
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity'];
|
||||
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit'];
|
||||
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
|
||||
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
|
||||
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
|
||||
|
||||
// CSS variable mapping (theme key → CSS custom property)
|
||||
var THEME_CSS_MAP = {
|
||||
@@ -503,6 +504,11 @@
|
||||
localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity);
|
||||
}
|
||||
|
||||
// Distance unit → sync to localStorage for all pages
|
||||
if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) {
|
||||
localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit);
|
||||
}
|
||||
|
||||
// Nav gradient
|
||||
if (themeSection.navBg) {
|
||||
var nav = document.querySelector('.top-nav');
|
||||
@@ -744,6 +750,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate distanceUnit
|
||||
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
|
||||
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors: errors };
|
||||
}
|
||||
@@ -895,7 +905,7 @@
|
||||
{ id: 'theme', label: '🎨', title: 'Theme', badge: _tabBadge(isDarkMode() ? 'themeDark' : 'theme') },
|
||||
{ id: 'nodes', label: '🎯', title: 'Colors', badge: (function () { var n = _countOverrides('nodeColors') + _countOverrides('typeColors'); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') },
|
||||
{ id: 'display', label: '🖥️', title: 'Display', badge: _tabBadge('timestamps') },
|
||||
{ id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'export', label: '📤', title: 'Export' }
|
||||
];
|
||||
return '<div class="cust-tabs">' + tabs.map(function (t) {
|
||||
@@ -1059,6 +1069,7 @@
|
||||
|
||||
function _renderDisplay() {
|
||||
var eff = _getEffective();
|
||||
var distUnit = typeof eff.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(eff.distanceUnit) >= 0 ? eff.distanceUnit : 'auto';
|
||||
var ts = (eff.timestamps) || {};
|
||||
var tsMode = ts.defaultMode === 'absolute' ? 'absolute' : 'ago';
|
||||
var tsTz = ts.timezone === 'utc' ? 'utc' : 'local';
|
||||
@@ -1086,6 +1097,13 @@
|
||||
'<option value="locale"' + (tsFmt === 'locale' ? ' selected' : '') + '>Locale (browser)</option></select></div>' +
|
||||
(canCustom ? '<div class="cust-field" data-ts-abs="custom"' + showAbs + '><label>Custom Format' + _overrideDot('timestamps', 'customFormat') + '</label>' +
|
||||
'<input type="text" data-cv2-field="timestamps.customFormat" value="' + escAttr(customFmt) + '" placeholder="YYYY-MM-DD HH:mm:ss"></div>' : '') +
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Distances</p>' +
|
||||
'<div class="cust-field"><label>Distance Unit' + _overrideDot(null, 'distanceUnit') + '</label>' +
|
||||
'<select data-cv2-select="distanceUnit" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
|
||||
'<option value="auto"' + (distUnit === 'auto' ? ' selected' : '') + '>Auto (browser locale)</option>' +
|
||||
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
|
||||
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -1324,12 +1342,16 @@
|
||||
container.querySelectorAll('[data-cv2-select]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var parts = sel.dataset.cv2Select.split('.');
|
||||
setOverride(parts[0], parts[1], sel.value);
|
||||
// Show/hide absolute-only fields
|
||||
if (parts[1] === 'defaultMode') {
|
||||
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
|
||||
el.style.display = sel.value === 'absolute' ? '' : 'none';
|
||||
});
|
||||
if (parts.length === 1) {
|
||||
setOverride(null, parts[0], sel.value);
|
||||
} else {
|
||||
setOverride(parts[0], parts[1], sel.value);
|
||||
// Show/hide absolute-only fields
|
||||
if (parts[1] === 'defaultMode') {
|
||||
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
|
||||
el.style.display = sel.value === 'absolute' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
|
||||
});
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@
|
||||
var scoreTitle = 'Observations: ' + nb.count;
|
||||
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
|
||||
var distanceCell = nb.distance_km != null
|
||||
? Number(nb.distance_km).toFixed(1) + ' km'
|
||||
? formatDistance(Number(nb.distance_km))
|
||||
: '<span class="text-muted">—</span>';
|
||||
var showOnMap = nb.pubkey
|
||||
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
|
||||
|
||||
@@ -4687,6 +4687,92 @@ console.log('\n=== packets.js: buildPacketsQuery ===');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatDistance / getDistanceUnit =====
|
||||
console.log('\n=== app.js: formatDistance ===');
|
||||
{
|
||||
function makeDistCtx(localeLang, storageUnit) {
|
||||
const ctx = makeSandbox();
|
||||
if (storageUnit !== undefined) ctx.localStorage.setItem('meshcore-distance-unit', storageUnit);
|
||||
ctx.navigator = { language: localeLang || 'en-BE' };
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('formatDistance: km mode, 12.3 km', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(12.3), '12.3 km');
|
||||
});
|
||||
test('formatDistance: km mode, sub-1km shows meters', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(0.45), '450 m');
|
||||
});
|
||||
test('formatDistance: mi mode, 12.3 km → 7.6 mi', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(12.3), '7.6 mi');
|
||||
});
|
||||
test('formatDistance: auto + en-US locale → mi', () => {
|
||||
const ctx = makeDistCtx('en-US', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
|
||||
});
|
||||
test('formatDistance: auto + en-GB locale → mi', () => {
|
||||
const ctx = makeDistCtx('en-GB', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
|
||||
});
|
||||
test('formatDistance: auto + fr-BE locale → km', () => {
|
||||
const ctx = makeDistCtx('fr-BE', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'km');
|
||||
});
|
||||
test('formatDistance: null input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(null), '—');
|
||||
});
|
||||
test('formatDistanceRound: 50 km → "50 km"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(50), '50 km');
|
||||
});
|
||||
test('formatDistanceRound: 50 km in mi mode → "31 mi"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistanceRound(50), '31 mi');
|
||||
});
|
||||
test('formatDistanceRound: 200 km in mi mode → "124 mi"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistanceRound(200), '124 mi');
|
||||
});
|
||||
test('formatDistance: 0 in km mode → "0 m"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(0), '0 m');
|
||||
});
|
||||
test('formatDistance: 0 in mi mode → "0 ft"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(0), '0 ft');
|
||||
});
|
||||
test('formatDistance: NaN input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(NaN), '—');
|
||||
});
|
||||
test('formatDistance: "abc" input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance('abc'), '—');
|
||||
});
|
||||
test('formatDistanceRound: null input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(null), '—');
|
||||
});
|
||||
test('formatDistanceRound: NaN input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(NaN), '—');
|
||||
});
|
||||
test('formatDistanceRound: 0 in km mode → "0 km"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(0), '0 km');
|
||||
});
|
||||
test('formatDistance: mi mode sub-0.1mi shows feet', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(0.01), '33 ft');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in new issue
Block a user