feat(format): miles-primary distance formatting sitewide

Add Microwaveprop.Format.distance_km/1 and a matching formatDistanceKm
helper for the JS side. Both emit "X mi (Y km)" with one decimal under
10 mi and whole numbers above.

Replace the ad-hoc format_dist/format_km_mi helpers with the shared
formatter and update every user-facing distance render: contact
detail, contacts index column, user profile contact/involving tables,
path finder summary + sounding list, contacts map line popups, beacon
coverage tooltip, and propagation map range estimate + rain scatter
cell popup.
This commit is contained in:
Graham McIntire 2026-04-18 15:53:30 -05:00
parent 1f89b5ddae
commit a9a355be2a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 107 additions and 30 deletions

View file

@ -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}<br>${c.rx_dbm} dBm<br>${c.distance_km} km · score ${c.score}`,
`${c.label}<br>${c.rx_dbm} dBm<br>${formatDistanceKm(c.distance_km)} · score ${c.score}`,
{sticky: true}
)
rect.addTo(cellLayer)

View file

@ -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(
`<div style="font-size:12px;line-height:1.5;">` +
`<strong>${s1 || "?"} &harr; ${s2 || "?"}</strong><br/>` +

11
assets/js/format.ts Normal file
View file

@ -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)`
}

View file

@ -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 `<div style="font-size:11px;padding:1px 0;">
${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)
</div>`
}).join("")

View file

@ -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

View file

@ -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 = %{}

View file

@ -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)

View file

@ -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: "?"

View file

@ -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
</td>
<td>{contact.band && "#{contact.band} MHz"}</td>
<td>{contact.mode}</td>
<td>{contact.distance_km && "#{contact.distance_km} km"}</td>
<td>{Format.distance_km(contact.distance_km)}</td>
</tr>
</tbody>
</table>
@ -187,7 +188,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
</td>
<td>{contact.band && "#{contact.band} MHz"}</td>
<td>{contact.mode}</td>
<td>{contact.distance_km && "#{contact.distance_km} km"}</td>
<td>{Format.distance_km(contact.distance_km)}</td>
</tr>
</tbody>
</table>

View file

@ -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