feat(rover): URL station persistence + 10-digit grids on calculated values

This commit is contained in:
Graham McIntire 2026-04-25 16:36:34 -05:00
parent 150762d21d
commit c5d6633beb
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 124 additions and 27 deletions

View file

@ -48,11 +48,11 @@ defmodule Microwaveprop.Radio.Maidenhead do
@doc """
Converts a lat/lon pair into a Maidenhead grid at the requested
precision (must be an even number between 4 and 8). Default precision
precision (must be an even number between 4 and 10). Default precision
is 6 (subsquare level, ~5'×2.5', ~7 km × 4.6 km at the equator).
"""
@spec from_latlon(number(), number(), pos_integer()) :: String.t()
def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8] do
def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8, 10] do
# Clamp into the open ranges the field encoder expects. Exactly
# `90.0` / `180.0` would otherwise overflow the trunc into 'S'
# (one past 'R'), producing grids outside the A..R alphabet.
@ -73,26 +73,37 @@ defmodule Microwaveprop.Radio.Maidenhead do
grid = <<?A + f1, ?A + f2, ?0 + s1, ?0 + s2>>
cond do
precision == 4 ->
grid
if precision == 4 do
grid
else
# Subsquare (a-x), 5'/60 lon, 2.5'/60 lat
ss1 = trunc(lon * 60.0 / 5.0)
ss2 = trunc(lat * 60.0 / 2.5)
lon = lon - ss1 * (5.0 / 60.0)
lat = lat - ss2 * (2.5 / 60.0)
grid = grid <> <<?a + ss1, ?a + ss2>>
precision >= 6 ->
# Subsquare (a-x), 5'/60 lon, 2.5'/60 lat
ss1 = trunc(lon * 60.0 / 5.0)
ss2 = trunc(lat * 60.0 / 2.5)
lon = lon - ss1 * (5.0 / 60.0)
lat = lat - ss2 * (2.5 / 60.0)
grid = grid <> <<?a + ss1, ?a + ss2>>
append_extended(grid, lat, lon, precision)
end
end
if precision == 8 do
# Extended square (0-9), (5/60)/10 lon, (2.5/60)/10 lat
e1 = trunc(lon * 60.0 / 0.5)
e2 = trunc(lat * 60.0 / 0.25)
grid <> <<?0 + e1, ?0 + e2>>
else
grid
end
defp append_extended(grid, _lat, _lon, 6), do: grid
defp append_extended(grid, lat, lon, precision) when precision in [8, 10] do
# Extended square (0-9), (5'/60)/10 lon, (2.5'/60)/10 lat
e1 = trunc(lon * 60.0 * 10.0 / 5.0)
e2 = trunc(lat * 60.0 * 10.0 / 2.5)
lon = lon - e1 * (5.0 / 60.0 / 10.0)
lat = lat - e2 * (2.5 / 60.0 / 10.0)
grid = grid <> <<?0 + e1, ?0 + e2>>
if precision == 8 do
grid
else
# Extended-extended subsquare (a-x), step is the extended-square step / 24
x1 = trunc(lon * 60.0 * 10.0 * 24.0 / 5.0)
x2 = trunc(lat * 60.0 * 10.0 * 24.0 / 2.5)
grid <> <<?a + x1, ?a + x2>>
end
end

View file

@ -125,7 +125,7 @@ defmodule Microwaveprop.Rover.Compute do
defp candidate_payload(cell, home) do
drive_min = DriveTime.drive_min(cell.distance_km)
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {cell.lat, cell.lon})
grid = Maidenhead.from_latlon(cell.lat, cell.lon, 6)
grid = Maidenhead.from_latlon(cell.lat, cell.lon, 10)
%{
grid: grid,

View file

@ -59,10 +59,65 @@ defmodule MicrowavepropWeb.RoverLive do
top_candidates: [],
station_form_error: nil,
home_form_error: nil,
scoring_loading: false
scoring_loading: false,
url_loaded?: false
)}
end
@impl true
def handle_params(params, _uri, socket) do
if socket.assigns.url_loaded? do
{:noreply, socket}
else
socket =
socket
|> apply_url_stations(params)
|> assign(url_loaded?: true)
{:noreply, socket}
end
end
defp apply_url_stations(%{assigns: %{persisted?: true}} = socket, _params), do: socket
defp apply_url_stations(socket, %{"stations" => stations_str}) when is_binary(stations_str) do
stations = parse_url_stations(stations_str)
if stations == [], do: socket, else: assign(socket, fixed_stations: stations)
end
defp apply_url_stations(socket, _params), do: socket
defp parse_url_stations(str) do
str
|> String.split(",", trim: true)
|> Enum.with_index()
|> Enum.flat_map(fn {entry, idx} -> entry |> parse_url_station_entry(idx) |> List.wrap() end)
end
defp parse_url_station_entry("-" <> rest, idx), do: parse_url_station_pair(rest, idx, false)
defp parse_url_station_entry(entry, idx), do: parse_url_station_pair(entry, idx, true)
defp parse_url_station_pair(entry, idx, selected?) do
with [call, grid] <- String.split(entry, ":", parts: 2),
call = String.upcase(String.trim(call)),
grid = String.trim(grid),
true <- call != "",
{:ok, {lat, lon}} <- Maidenhead.to_latlon(grid) do
%{
id: "url-#{idx}",
callsign: call,
grid: grid,
lat: lat,
lon: lon,
elevation_m: nil,
selected: selected?,
position: idx
}
else
_ -> nil
end
end
# ── Lifecycle helpers ────────────────────────────────────────────────
defp load_stations(socket) do
@ -104,7 +159,7 @@ defmodule MicrowavepropWeb.RoverLive do
end
end
defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 6)
defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 10)
defp drive_radius_km(max_drive_min), do: max_drive_min * @avg_speed_kmh / 60.0
@ -146,7 +201,10 @@ defmodule MicrowavepropWeb.RoverLive do
case create_station(socket, attrs) do
{:ok, stations} ->
{:noreply, assign(socket, fixed_stations: stations, station_form_error: nil)}
{:noreply,
socket
|> assign(fixed_stations: stations, station_form_error: nil)
|> sync_url()}
{:error, msg} ->
{:noreply, assign(socket, station_form_error: msg)}
@ -206,11 +264,39 @@ defmodule MicrowavepropWeb.RoverLive do
defp handle_station_mutation(socket, id, fun) do
case fun.(socket, id) do
{:ok, stations} -> {:noreply, assign(socket, fixed_stations: stations)}
{:error, _} -> {:noreply, socket}
{:ok, stations} ->
{:noreply, socket |> assign(fixed_stations: stations) |> sync_url()}
{:error, _} ->
{:noreply, socket}
end
end
defp sync_url(socket) do
push_patch(socket, to: rover_path(socket), replace: true)
end
defp rover_path(socket) do
case build_url_stations(socket.assigns.fixed_stations) do
"" -> ~p"/rover"
str -> ~p"/rover?#{[stations: str]}"
end
end
defp build_url_stations(stations) do
stations
|> Enum.map(&encode_url_station/1)
|> Enum.reject(&is_nil/1)
|> Enum.join(",")
end
defp encode_url_station(%{callsign: call, grid: grid, selected: sel}) when is_binary(grid) and grid != "" do
prefix = if sel, do: "", else: "-"
"#{prefix}#{call}:#{grid}"
end
defp encode_url_station(_), do: nil
defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do
user = current_user(socket)
@ -347,7 +433,7 @@ defmodule MicrowavepropWeb.RoverLive do
home: home
} = socket.assigns
grid = Maidenhead.from_latlon(lat, lon, 6)
grid = Maidenhead.from_latlon(lat, lon, 10)
elev_m = lookup_elev(lat, lon)
dist_km = haversine_km(home, %{lat: lat, lon: lon})