From a9a355be2afe9e2a68a1318a6859cfbc2e463292 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 15:53:30 -0500 Subject: [PATCH] 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. --- assets/js/beacon_map_hook.ts | 4 ++- assets/js/contacts_map_hook.ts | 4 ++- assets/js/format.ts | 11 +++++++ assets/js/propagation_map_hook.ts | 32 +++++++++++++++---- lib/microwaveprop/format.ex | 30 +++++++++++++++++ .../live/contact_live/index.ex | 5 ++- .../live/contact_live/show.ex | 5 +-- lib/microwaveprop_web/live/path_live.ex | 16 +--------- .../live/user_profile_live.ex | 5 +-- test/microwaveprop/format_test.exs | 25 +++++++++++++++ 10 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 assets/js/format.ts create mode 100644 lib/microwaveprop/format.ex create mode 100644 test/microwaveprop/format_test.exs 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