diff --git a/assets/js/beacon_map_hook.ts b/assets/js/beacon_map_hook.ts
index 14d01ec2..f2314af8 100644
--- a/assets/js/beacon_map_hook.ts
+++ b/assets/js/beacon_map_hook.ts
@@ -1,3 +1,5 @@
+import {formatDistanceKm} from "./format"
+
interface CoverageCell {
lat: number
lon: number
@@ -82,7 +84,7 @@ export const BeaconMap = {
interactive: true
})
rect.bindTooltip(
- `${c.label}
${c.rx_dbm} dBm
${c.distance_km} km · score ${c.score}`,
+ `${c.label}
${c.rx_dbm} dBm
${formatDistanceKm(c.distance_km)} · score ${c.score}`,
{sticky: true}
)
rect.addTo(cellLayer)
diff --git a/assets/js/contacts_map_hook.ts b/assets/js/contacts_map_hook.ts
index 196c0c9d..b1319fbd 100644
--- a/assets/js/contacts_map_hook.ts
+++ b/assets/js/contacts_map_hook.ts
@@ -1,3 +1,5 @@
+import {formatDistanceKm} from "./format"
+
// Contact tuple indices: [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id]
type ContactTuple = [
number, number, number, number, number,
@@ -244,7 +246,7 @@ export const ContactsMap = {
color, weight: 2, opacity: 0.5
})
- const distStr = dist != null ? `${Math.round(dist)} km` : "—"
+ const distStr = formatDistanceKm(dist)
line.bindPopup(
`
` +
`
${s1 || "?"} ↔ ${s2 || "?"}` +
diff --git a/assets/js/format.ts b/assets/js/format.ts
new file mode 100644
index 00000000..6189d716
--- /dev/null
+++ b/assets/js/format.ts
@@ -0,0 +1,11 @@
+// Miles are the primary distance unit across the site. Km appears
+// parenthetically so metric readers aren't lost. Keep this in sync
+// with Microwaveprop.Format.distance_km/1 on the server side.
+export function formatDistanceKm(km: number | null | undefined): string {
+ if (km == null) return "—"
+ const mi = km / 1.609344
+ if (mi < 10) {
+ return `${mi.toFixed(1)} mi (${km.toFixed(1)} km)`
+ }
+ return `${Math.round(mi)} mi (${Math.round(km)} km)`
+}
diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts
index e3b603df..bfe7a8c5 100644
--- a/assets/js/propagation_map_hook.ts
+++ b/assets/js/propagation_map_hook.ts
@@ -1,6 +1,25 @@
import topbar from "../vendor/topbar"
+import { formatDistanceKm } from "./format"
import { updateGridOverlay } from "./maidenhead_grid"
+const KM2MI = 0.621371
+
+function kmRangeToMi(lowKm: number, highKm: number): string {
+ const lowMi = Math.round(lowKm * KM2MI)
+ const highMi = Math.round(highKm * KM2MI)
+ return `${lowMi}\u2013${highMi} mi (${Math.round(lowKm)}\u2013${Math.round(highKm)} km)`
+}
+
+function kmRangeOpenToMi(lowKm: number): string {
+ const lowMi = Math.round(lowKm * KM2MI)
+ return `${lowMi}+ mi (${Math.round(lowKm)}+ km)`
+}
+
+function kmCeilToMi(highKm: number): string {
+ const highMi = Math.round(highKm * KM2MI)
+ return `<${highMi} mi (<${Math.round(highKm)} km)`
+}
+
// --- Interfaces ---
interface FactorMeta {
@@ -335,11 +354,12 @@ function factorExplanation(key: string, score: number, detail: PointDetail): str
}
function rangeEstimate(score: number, detail: PointDetail): string {
- if (score >= 80) return `${detail.extended_range_km}\u2013${detail.exceptional_range_km}+ km`
- if (score >= 65) return `${detail.typical_range_km}\u2013${detail.extended_range_km} km`
- if (score >= 50) return `${Math.round(detail.typical_range_km * 0.7)}\u2013${detail.typical_range_km} km`
- if (score >= 33) return `${Math.round(detail.typical_range_km * 0.4)}\u2013${Math.round(detail.typical_range_km * 0.7)} km`
- return `<${Math.round(detail.typical_range_km * 0.4)} km`
+ const typical = detail.typical_range_km
+ if (score >= 80) return kmRangeOpenToMi(detail.extended_range_km)
+ if (score >= 65) return kmRangeToMi(typical, detail.extended_range_km)
+ if (score >= 50) return kmRangeToMi(typical * 0.7, typical)
+ if (score >= 33) return kmRangeToMi(typical * 0.4, typical * 0.7)
+ return kmCeilToMi(typical * 0.4)
}
/**
@@ -604,7 +624,7 @@ function buildScatterHTML(scatter: RainScatter): string {
const rows = top3.map(c => {
const dir = bearingLabel(c.bearing)
return `
- ${c.dbz} dBZ at ${Math.round(c.distance_km)} km ${dir} (${c.scatter_db} dB)
+ ${c.dbz} dBZ at ${formatDistanceKm(c.distance_km)} ${dir} (${c.scatter_db} dB)
`
}).join("")
diff --git a/lib/microwaveprop/format.ex b/lib/microwaveprop/format.ex
new file mode 100644
index 00000000..36864508
--- /dev/null
+++ b/lib/microwaveprop/format.ex
@@ -0,0 +1,30 @@
+defmodule Microwaveprop.Format do
+ @moduledoc """
+ Shared human-facing formatters. Miles are the primary distance unit
+ across the site; km appears parenthetically for readers working in
+ metric.
+ """
+
+ @doc """
+ Format a distance in km as "X mi (Y km)". Under 10 miles the output
+ carries one decimal so near-LOS paths don't round to zero; longer
+ distances round to whole numbers.
+ """
+ @spec distance_km(number() | Decimal.t() | nil) :: String.t()
+ def distance_km(nil), do: "—"
+
+ def distance_km(%Decimal{} = d), do: d |> Decimal.to_float() |> distance_km()
+
+ def distance_km(km) when is_number(km) do
+ mi = km / 1.609344
+
+ {mi_str, km_str} =
+ if mi < 10.0,
+ do: {one_decimal(mi), one_decimal(km)},
+ else: {Integer.to_string(round(mi)), Integer.to_string(round(km))}
+
+ "#{mi_str} mi (#{km_str} km)"
+ end
+
+ defp one_decimal(n), do: :erlang.float_to_binary(n * 1.0, decimals: 1)
+end
diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex
index 33709ab8..67ffe483 100644
--- a/lib/microwaveprop_web/live/contact_live/index.ex
+++ b/lib/microwaveprop_web/live/contact_live/index.ex
@@ -22,7 +22,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
grid2: %{label: "Grid 2", sortable: true, searchable: true},
band: %{label: "Band", sortable: true, renderer: &band_cell/1},
mode: %{label: "Mode", sortable: true, searchable: true},
- distance_km: %{label: "Distance (km)", sortable: true},
+ distance_km: %{label: "Distance", sortable: true, renderer: &distance_cell/1},
hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2},
flagged_invalid: %{label: "Flag", renderer: &flag_cell/1},
qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1},
@@ -94,6 +94,9 @@ defmodule MicrowavepropWeb.ContactLive.Index do
defp band_cell(%Decimal{} = d), do: Decimal.to_string(d, :normal)
defp band_cell(other), do: to_string(other)
+ defp distance_cell(nil), do: ""
+ defp distance_cell(km), do: Microwaveprop.Format.distance_km(km)
+
defp flag_cell(true) do
assigns = %{}
diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex
index 9adcbd5c..ed2b356f 100644
--- a/lib/microwaveprop_web/live/contact_live/show.ex
+++ b/lib/microwaveprop_web/live/contact_live/show.ex
@@ -1602,10 +1602,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp wind_speed_from_components(_, _), do: nil
- defp format_dist(km) do
- mi = km * 0.621371
- "#{:erlang.float_to_binary(mi, decimals: 1)} mi (#{:erlang.float_to_binary(km, decimals: 1)} km)"
- end
+ defp format_dist(km), do: Microwaveprop.Format.distance_km(km)
defp format_bearing(deg) do
whole = trunc(deg)
diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex
index e762fbde..c7f8f658 100644
--- a/lib/microwaveprop_web/live/path_live.ex
+++ b/lib/microwaveprop_web/live/path_live.ex
@@ -594,21 +594,7 @@ defmodule MicrowavepropWeb.PathLive do
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n)
- # Displays as `123 km (76 mi)`. Distances under 10 km keep one decimal
- # so near-LOS paths don't round to zero; everything larger rounds to
- # the nearest integer for easier reading.
- defp format_km_mi(nil), do: "—"
-
- defp format_km_mi(km) when is_number(km) do
- mi = km / 1.609344
-
- {km_str, mi_str} =
- if km < 10.0, do: {format_1d(km), format_1d(mi)}, else: {to_string(round(km)), to_string(round(mi))}
-
- "#{km_str} km (#{mi_str} mi)"
- end
-
- defp format_1d(n), do: :erlang.float_to_binary(n * 1.0, decimals: 1)
+ defp format_km_mi(km), do: Microwaveprop.Format.distance_km(km)
defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
defp format_utc_hour(_), do: "?"
diff --git a/lib/microwaveprop_web/live/user_profile_live.ex b/lib/microwaveprop_web/live/user_profile_live.ex
index 7be5f13c..c8afa68f 100644
--- a/lib/microwaveprop_web/live/user_profile_live.ex
+++ b/lib/microwaveprop_web/live/user_profile_live.ex
@@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
alias Microwaveprop.Accounts
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
+ alias Microwaveprop.Format
alias Microwaveprop.Radio
@impl true
@@ -99,7 +100,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
{contact.band && "#{contact.band} MHz"} |
{contact.mode} |
-
{contact.distance_km && "#{contact.distance_km} km"} |
+
{Format.distance_km(contact.distance_km)} |
@@ -187,7 +188,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
{contact.band && "#{contact.band} MHz"} |
{contact.mode} |
-
{contact.distance_km && "#{contact.distance_km} km"} |
+
{Format.distance_km(contact.distance_km)} |
diff --git a/test/microwaveprop/format_test.exs b/test/microwaveprop/format_test.exs
new file mode 100644
index 00000000..c60bac2d
--- /dev/null
+++ b/test/microwaveprop/format_test.exs
@@ -0,0 +1,25 @@
+defmodule Microwaveprop.FormatTest do
+ use ExUnit.Case, async: true
+
+ alias Microwaveprop.Format
+
+ describe "distance_km/1" do
+ test "returns em-dash for nil" do
+ assert Format.distance_km(nil) == "—"
+ end
+
+ test "formats large distances as whole numbers, miles first" do
+ assert Format.distance_km(235) == "146 mi (235 km)"
+ assert Format.distance_km(100.0) == "62 mi (100 km)"
+ end
+
+ test "formats short distances with one decimal" do
+ assert Format.distance_km(1.5) == "0.9 mi (1.5 km)"
+ assert Format.distance_km(9.6) == "6.0 mi (9.6 km)"
+ end
+
+ test "accepts Decimal input" do
+ assert Format.distance_km(Decimal.new("235")) == "146 mi (235 km)"
+ end
+ end
+end