From c5d6633bebbf57e5fd39ca2207e057e90659387d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 16:36:34 -0500 Subject: [PATCH] feat(rover): URL station persistence + 10-digit grids on calculated values --- lib/microwaveprop/radio/maidenhead.ex | 51 +++++++----- lib/microwaveprop/rover/compute.ex | 2 +- lib/microwaveprop_web/live/rover_live.ex | 98 ++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 27 deletions(-) diff --git a/lib/microwaveprop/radio/maidenhead.ex b/lib/microwaveprop/radio/maidenhead.ex index f20d94da..7b67d3f5 100644 --- a/lib/microwaveprop/radio/maidenhead.ex +++ b/lib/microwaveprop/radio/maidenhead.ex @@ -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 = <> - 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 <> <> - 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 <> <> + 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 <> <> - 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 <> <> + + 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 <> <> end end diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index 84f39bd2..30a0cc46 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -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, diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index cca7f13f..98a797a2 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -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})