From bd54707987661ccac026e28fba13f0553325ff41 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 8 Apr 2026 04:36:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20distance=20unit=20preference=20?= =?UTF-8?q?=E2=80=94=20km,=20mi,=20or=20auto=20(#621)=20(#646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: you --- public/analytics.js | 44 +++++++++++--------- public/app.js | 36 +++++++++++++++++ public/customize-v2.js | 38 ++++++++++++++---- public/nodes.js | 2 +- test-frontend-helpers.js | 86 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 27 deletions(-) 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 = ` @@ -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 ` ${showAppearances ? `` : ''} @@ -1412,9 +1419,9 @@ }).join('')}
Prefix
${c.prefix}${(c.appearances || 0).toLocaleString()}
- 🏘️ 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

`; + const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km'; + html += `

Distance by Link Type

TypeCountAvg (km)Median (km)Min (km)Max (km)
`; for (const [cat, st] of Object.entries(cats)) { if (!st.count) continue; - html += ``; + html += ``; } html += `
TypeCountAvg (${distUnitLabel})Median (${distUnitLabel})Min (${distUnitLabel})Max (${distUnitLabel})
${esc(cat)}${st.count.toLocaleString()}${st.avg}${st.median}${st.min}${st.max}
${esc(cat)}${st.count.toLocaleString()}${formatDistance(st.avg)}${formatDistance(st.median)}${formatDistance(st.min)}${formatDistance(st.max)}
`; @@ -1813,7 +1821,7 @@ } // Top hops leaderboard - html += `

🏆 Top 20 Longest Hops

`; + html += `

🏆 Top 20 Longest Hops

#FromToDistance (km)TypeSNRPacket
`; 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 += ``; + html += ``; }); html += `
#FromToDistance (${distUnitLabel})TypeSNRPacket
${i+1}${fromLink}${toLink}${h.dist}${esc(h.type)}${snr}${pktLink}${mapBtn}
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${snr}${pktLink}${mapBtn}
`; // Top paths if (data.topPaths.length) { - html += `

🛤️ Top 10 Longest Multi-Hop Paths

`; + html += `

🛤️ Top 10 Longest Multi-Hop Paths

#Total Distance (km)HopsRoutePacket
`; 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 += ``; + html += ``; }); html += `
#Total Distance (${distUnitLabel})HopsRoutePacket
${i+1}${p.totalDist}${p.hopCount}${route}${pktLink}${mapBtn}
${i+1}${formatDistance(p.totalDist)}${p.hopCount}${route}${pktLink}${mapBtn}
`; } @@ -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 @@ '
' + (canCustom ? '
' + '
' : '') + + '

Distances

' + + '
' + + '
' + ''; } @@ -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 ? ' ' 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)}`);