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:
parent
1f89b5ddae
commit
a9a355be2a
10 changed files with 107 additions and 30 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
||||
|
|
|
|||
11
assets/js/format.ts
Normal file
11
assets/js/format.ts
Normal 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)`
|
||||
}
|
||||
|
|
@ -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("")
|
||||
|
||||
|
|
|
|||
30
lib/microwaveprop/format.ex
Normal file
30
lib/microwaveprop/format.ex
Normal 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
|
||||
|
|
@ -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 = %{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: "?"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
25
test/microwaveprop/format_test.exs
Normal file
25
test/microwaveprop/format_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue