diff --git a/public/analytics.js b/public/analytics.js
index 1f3d26b8..a8b8f9ec 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -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 = `
Prefix
@@ -1383,20 +1390,20 @@
${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
- badge = 'ποΈ Local ';
+ badge = `ποΈ Local `;
tooltip = 'Nodes close enough for direct RF β probably genuine prefix collision';
} else if (c.classification === 'regional') {
- badge = 'β‘ Regional ';
+ badge = `β‘ Regional `;
tooltip = 'At edge of 915MHz range β could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
- badge = 'π Distant ';
+ badge = `π Distant `;
tooltip = 'Beyond typical LoRa range β likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = 'β Unknown ';
tooltip = 'Not enough coordinate data to classify';
}
const nodes = c.nodes || [];
- const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : 'β ';
+ const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : 'β ';
return `
${c.prefix}
${showAppearances ? `${(c.appearances || 0).toLocaleString()} ` : ''}
@@ -1412,9 +1419,9 @@
}).join('')}
- ποΈ Local <50km: true prefix collision, same mesh area
- β‘ Regional 50β200km: edge of LoRa range, possible atmospheric propagation
- π Distant >200km: beyond 915MHz range β internet bridge, MQTT gateway, or separate networks
+ ποΈ Local <${t50}: true prefix collision, same mesh area
+ β‘ Regional ${t50}β${t200}: edge of LoRa range, possible atmospheric propagation
+ π Distant >${t200}: beyond 915MHz range β internet bridge, MQTT gateway, or separate networks
`;
}
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(`${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'} ${esc(a.name)} β ${esc(b.name)}
`);
+ dists.push(`${formatDistance(km)} ${esc(a.name)} β ${esc(b.name)}
`);
} else {
dists.push(`? ${esc(a.name)} β ${esc(b.name)} (no coords)
`);
}
}
- if (dists.length > 1) dists.push(`Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}
`);
+ if (dists.length > 1) dists.push(`Total: ${formatDistance(total)}
`);
return dists.join('');
})()}
` : ''}
@@ -1787,16 +1794,17 @@
let html = `
${s.totalHops.toLocaleString()}
Total Hops Analyzed
${s.totalPaths.toLocaleString()}
Paths Analyzed
-
${s.avgDist} km
Avg Hop Distance
-
${s.maxDist} km
Max Hop Distance
+
${formatDistance(s.avgDist)}
Avg Hop Distance
+
${formatDistance(s.maxDist)}
Max Hop Distance
`;
// Category stats
const cats = data.catStats;
- html += `Distance by Link Type Type Count Avg (km) Median (km) Min (km) Max (km) `;
+ const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km';
+ html += `Distance by Link Type Type Count Avg (${distUnitLabel}) Median (${distUnitLabel}) Min (${distUnitLabel}) Max (${distUnitLabel}) `;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
- html += `${esc(cat)} ${st.count.toLocaleString()} ${st.avg} ${st.median} ${st.min} ${st.max} `;
+ html += `${esc(cat)} ${st.count.toLocaleString()} ${formatDistance(st.avg)} ${formatDistance(st.median)} ${formatDistance(st.min)} ${formatDistance(st.max)} `;
}
html += `
`;
@@ -1813,7 +1821,7 @@
}
// Top hops leaderboard
- html += `π Top 20 Longest Hops # From To Distance (km) Type SNR Packet `;
+ html += `π Top 20 Longest Hops # From To Distance (${distUnitLabel}) Type SNR Packet `;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `${esc(h.fromName)} ` : esc(h.fromName || '?');
@@ -1821,13 +1829,13 @@
const snr = h.snr != null ? h.snr + ' dB' : 'β ';
const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}β¦ ` : 'β';
const mapBtn = h.fromPk && h.toPk ? `πΊοΈ ` : '';
- html += `${i+1} ${fromLink} ${toLink} ${h.dist} ${esc(h.type)} ${snr} ${pktLink} ${mapBtn} `;
+ html += `${i+1} ${fromLink} ${toLink} ${formatDistance(h.dist)} ${esc(h.type)} ${snr} ${pktLink} ${mapBtn} `;
});
html += `
`;
// Top paths
if (data.topPaths.length) {
- html += `π€οΈ Top 10 Longest Multi-Hop Paths # Total Distance (km) Hops Route Packet `;
+ html += `π€οΈ Top 10 Longest Multi-Hop Paths # Total Distance (${distUnitLabel}) Hops Route Packet `;
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 ? `${esc(p.hash.slice(0, 12))}β¦ ` : 'β';
@@ -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 ? `πΊοΈ ` : '';
- html += `${i+1} ${p.totalDist} ${p.hopCount} ${route} ${pktLink} ${mapBtn} `;
+ html += `${i+1} ${formatDistance(p.totalDist)} ${p.hopCount} ${route} ${pktLink} ${mapBtn} `;
});
html += `
`;
}
@@ -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') {
diff --git a/public/app.js b/public/app.js
index 90a40bfd..8e2387a0 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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;
diff --git a/public/customize-v2.js b/public/customize-v2.js
index 1f6a16a0..f1df36d5 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -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 ? ' ' + n + ' ' : ''; })() },
{ 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 ? ' ' + n + ' ' : ''; })() },
{ id: 'export', label: 'π€', title: 'Export' }
];
return '' + 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 @@
'Locale (browser)
' +
(canCustom ? 'Custom Format' + _overrideDot('timestamps', 'customFormat') + ' ' +
'
' : '') +
+ 'Distances
' +
+ 'Distance Unit' + _overrideDot(null, 'distanceUnit') + ' ' +
+ '' +
+ 'Auto (browser locale) ' +
+ 'Kilometers (km) ' +
+ 'Miles (mi) ' +
+ '
' +
'';
}
@@ -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'));
});
diff --git a/public/nodes.js b/public/nodes.js
index 3512a3b5..2f02fcdd 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -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))
: 'β ';
var showOnMap = nb.pubkey
? ' π Map '
diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js
index d14fc029..04b13307 100644
--- a/test-frontend-helpers.js
+++ b/test-frontend-helpers.js
@@ -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)}`);