diff --git a/assets/css/app.css b/assets/css/app.css index 7f490152..b34f75c3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -322,4 +322,20 @@ select.select { .leaflet-popup { pointer-events: none; } .leaflet-popup-content-wrapper, .leaflet-popup-tip, .leaflet-popup-close-button { pointer-events: auto; } +/* Rover-page station + home label tooltips */ +.rover-station-label, +.rover-home-label { + background: rgba(255, 255, 255, 0.92); + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + color: #0f172a; + font-size: 10px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; +} +.rover-station-label::before, +.rover-home-label::before { display: none; } + /* This file is for your main application CSS */ diff --git a/assets/js/rover_map_hook.ts b/assets/js/rover_map_hook.ts index f730c3cc..194a172f 100644 --- a/assets/js/rover_map_hook.ts +++ b/assets/js/rover_map_hook.ts @@ -66,7 +66,12 @@ function buildStationMarker(station: Station): L.CircleMarker { fillColor: station.selected ? "#0ea5e9" : "#64748b", fillOpacity: 0.95, interactive: true - }).bindTooltip(station.callsign, { direction: "top", offset: [0, -6] }) + }).bindTooltip(station.callsign, { + permanent: true, + direction: "right", + offset: [6, 0], + className: "rover-station-label" + }) } export const RoverMap: Partial = { @@ -144,7 +149,7 @@ export const RoverMap: Partial = { fill: false }).addTo(map) - L.control.scale({ metric: true, imperial: false, position: "bottomleft" }).addTo(map) + L.control.scale({ metric: false, imperial: true, position: "bottomleft" }).addTo(map) // Render initial stations from data attribute try { @@ -178,6 +183,14 @@ export const RoverMap: Partial = { this.setDriveRadius(km) }) + this.handleEvent("update_home", ({ lat, lon }: { lat: number; lon: number }) => { + this.homeLat = lat + this.homeLon = lon + if (this.homeMarker) this.homeMarker.setLatLng([lat, lon]) + if (this.driveCircle) this.driveCircle.setLatLng([lat, lon]) + this.map.flyTo([lat, lon], Math.max(this.map.getZoom(), 8), { duration: 0.6 }) + }) + this.handleEvent("rover_results", ( { cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number } ) => { diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index 98a797a2..29ab7fe9 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -9,7 +9,9 @@ defmodule MicrowavepropWeb.RoverLive do use MicrowavepropWeb, :live_view alias Microwaveprop.Accounts.User + alias Microwaveprop.Geocoder alias Microwaveprop.Propagation + alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Rover @@ -212,12 +214,20 @@ defmodule MicrowavepropWeb.RoverLive do end def handle_event("set_home_qth", params, socket) do - grid = params |> Map.get("home_grid", "") |> String.trim() - attrs = %{"home_grid" => grid} + input = params |> Map.get("home_grid", "") |> String.trim() - case update_home(socket, attrs) do - {:ok, home} -> - {:noreply, assign(socket, home: home, home_form_error: nil)} + case resolve_home_input(input) do + {:ok, %{lat: lat, lon: lon, grid: grid}} -> + case update_home(socket, %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}) do + {:ok, home} -> + {:noreply, + socket + |> assign(home: home, home_form_error: nil) + |> push_event("update_home", %{lat: home.lat, lon: home.lon})} + + {:error, msg} -> + {:noreply, assign(socket, home_form_error: msg)} + end {:error, msg} -> {:noreply, assign(socket, home_form_error: msg)} @@ -404,6 +414,11 @@ defmodule MicrowavepropWeb.RoverLive do end end + defp update_home(_socket, %{"home_lat" => lat, "home_lon" => lon} = attrs) when is_number(lat) and is_number(lon) do + grid = Map.get(attrs, "home_grid") || home_label(lat * 1.0, lon * 1.0) + {:ok, %{label: grid, grid: grid, lat: lat * 1.0, lon: lon * 1.0, elev_m: nil}} + end + defp update_home(_socket, %{"home_grid" => grid}) when is_binary(grid) and grid != "" do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> @@ -416,6 +431,43 @@ defmodule MicrowavepropWeb.RoverLive do defp update_home(_socket, _attrs), do: {:error, "grid required"} + # Accepts grid (EM13qc), callsign (W5ABC), or address ("100 Main St, Dallas TX"). + # Returns {:ok, %{lat, lon, grid}} or {:error, "..."}. + defp resolve_home_input(""), do: {:error, "enter a grid, callsign, or address"} + + defp resolve_home_input(input) do + cond do + Maidenhead.valid?(input) -> resolve_home_grid(input) + callsign?(input) -> resolve_home_callsign(input) + true -> resolve_home_address(input) + end + end + + defp resolve_home_grid(grid) do + case Maidenhead.to_latlon(grid) do + {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, grid: grid}} + :error -> {:error, "invalid grid"} + end + end + + defp resolve_home_callsign(call) do + case CallsignClient.locate(call) do + {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}} + {:error, msg} -> {:error, "callsign: #{msg}"} + end + end + + defp resolve_home_address(addr) do + case Geocoder.geocode(addr) do + {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}} + {:error, msg} -> {:error, "geocode: #{msg}"} + end + end + + defp callsign?(input) when is_binary(input) do + String.match?(input, ~r/^[A-Za-z0-9\/]{3,12}$/) and not Maidenhead.valid?(input) + end + defp changeset_first_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}) do "#{field}: #{msg}" end