feat(units): show distances in miles

Everything is computed in kilometres and converted once at display time, in
lib/units — storing miles anywhere would give the same number two sources of
truth and a rounding error that grows with every hop.

The grids capture the unit inside their column definitions, header and formatter
both, so a change is published to them the way the date format already is;
without that a toggle would only appear after a language change or a restart.
The preference is portable, like the other display ones.
This commit is contained in:
2026-08-28 08:25:02 +02:00
parent d8f0fd7b4f
commit 9a21c936b1
10 changed files with 92 additions and 14 deletions
+2
View File
@@ -128,6 +128,7 @@ const en: Dict = {
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
'qslm.lotwAllCalls': 'All my callsigns',
'qslm.lotwAllCallsTitle': "Download the confirmations of every callsign on the LoTW account, not just this profile's. A QSO made as F4BPO/P or TM2Q is confirmed at LoTW but never reaches an F4BPO profile without this.",
@@ -623,6 +624,7 @@ const fr: Dict = {
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
'qslm.lotwAllCalls': 'Tous mes indicatifs',
'qslm.lotwAllCallsTitle': "Télécharger les confirmations de tous les indicatifs du compte LoTW, pas seulement celui du profil. Un QSO fait en F4BPO/P ou TM2Q est confirmé chez LoTW mais n'atteint jamais un profil F4BPO sans cette option.",
+1
View File
@@ -40,6 +40,7 @@ const PORTABLE_KEYS = [
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+49
View File
@@ -0,0 +1,49 @@
// Distance units.
//
// Everything is COMPUTED in kilometres — the great-circle maths, the backend's
// distance_km on a spot, the map's path lengths — and converted once, here, at
// display time. Storing miles anywhere would mean two sources of truth for the
// same number and a rounding error that grows with every hop.
//
// The preference is portable (see lib/uiPref): an operator who works in miles
// works in miles on every machine they copy their folder to.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_MILES = 'opslog.distanceMiles';
const KM_PER_MILE = 1.609344; // statute miles, the ones a US licence is used in
export function useMiles(): boolean {
try { return localStorage.getItem(KEY_MILES) === '1'; } catch { return false; }
}
export function setUseMiles(on: boolean): void {
writeUiPref(KEY_MILES, on ? '1' : '0');
listeners.forEach((l) => l());
}
// subscribeDistanceUnit notifies on a change. The grids capture the unit inside
// their column definitions (header text and formatter both), so without this a
// toggle would only show up on the next language change or restart.
const listeners = new Set<() => void>();
export function subscribeDistanceUnit(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
// distanceValue converts a distance in km to the operator's unit, rounded to a
// whole unit — the precision the inputs actually justify (a 4-character grid is
// a square tens of kilometres wide).
export function distanceValue(km: number): number {
if (!isFinite(km)) return 0;
return Math.round(useMiles() ? km / KM_PER_MILE : km);
}
// distanceUnit is the short label: "km" or "mi".
export function distanceUnit(): string {
return useMiles() ? 'mi' : 'km';
}
// formatDistance is value + unit, thousands-separated: "12 345 km".
export function formatDistance(km: number): string {
return `${distanceValue(km).toLocaleString()} ${distanceUnit()}`;
}