towerops/lib/towerops_web/live/coverage_live/form.ex
Graham McIntire 6bfcbee7fc feat: draggable Leaflet marker for radio location on coverage form
Adds a "Radio location" section to the coverage form with an embedded
Leaflet map. The marker starts at the parent site's coordinates and
the user can drag it to the actual radio location. A "Use site
location" button clears the override.

- New CoverageLocationPicker hook in assets/js/app.ts: creates the
  map, draggable marker, and pushes "location_picked" {lat, lon} on
  dragend. Re-creates the marker when the user picks a site that
  initially had no coords.
- form.html.heex: location section with map div (phx-hook), readout
  ("lat, lon - override" if set), and reset button. Hidden inputs for
  latitude_override / longitude_override so the form's params always
  carry the chosen coordinates.
- form.ex: handle_event "location_picked" updates the override fields
  in the changeset; "use_site_location" clears them. Helper functions
  radio_marker_lat/lon walk the form values, falling back to the
  selected site.

CI test gate fix:
- Worker test now requires GDAL CLI tools to write GeoTIFF + PNG.
  test_helper.exs detects whether gdal_translate / gdaldem are on
  PATH; when missing (CI's lean test image), :requires_gdal tagged
  tests are excluded. Locally with brew install gdal they still run.
2026-05-06 14:57:55 -05:00

350 lines
9.1 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Phoenix.HTML.Form, as: HTMLForm
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Coverage
alias Towerops.Devices
alias Towerops.Sites
# Defaults expressed in the user's preferred units (imperial / GHz).
@default_tx_power_dbm 18.0
@default_height_ft 100.0
@default_radius_mi 4.0
@default_frequency_ghz 5.8
@default_azimuth_deg 0.0
@default_downtilt_deg 0.0
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
antennas = Antenna.list()
{:ok,
socket
|> assign(:organization, organization)
|> assign(:sites, sites)
|> assign(:antennas, antennas)
|> assign(:antenna_options, antenna_options(antennas))
|> assign(:devices, [])}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
coverage = %Coverage{
organization_id: socket.assigns.organization.id,
tx_power_dbm: @default_tx_power_dbm,
azimuth_deg: @default_azimuth_deg,
downtilt_deg: @default_downtilt_deg,
foliage_tuning: 0,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0,
cable_loss_db: 0.0,
sm_gain_dbi: 0.0,
height_above_rooftop_m: 0.0,
height_agl_ft: @default_height_ft,
radius_mi: @default_radius_mi,
frequency_ghz: @default_frequency_ghz
}
socket
|> assign(:page_title, t("New Coverage"))
|> assign(:coverage, coverage)
|> assign_form(Coverages.change_coverage(coverage))
end
defp apply_action(socket, :edit, %{"id" => id}) do
coverage =
socket.assigns.organization.id
|> Coverages.get_coverage!(id)
|> populate_imperial_virtuals()
socket
|> assign(:page_title, t("Edit Coverage"))
|> assign(:coverage, coverage)
|> assign(:devices, devices_for_site(socket.assigns.organization.id, coverage.site_id))
|> assign_form(Coverages.change_coverage(coverage))
end
@impl true
def handle_event("validate", %{"coverage" => attrs}, socket) do
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
new_site_id = Map.get(attrs, "site_id")
socket = maybe_refresh_devices(socket, new_site_id)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("save", %{"coverage" => attrs}, socket) do
save(socket, socket.assigns.live_action, attrs)
end
@impl true
def handle_event("location_picked", %{"lat" => lat, "lon" => lon}, socket) do
attrs =
socket
|> current_form_attrs()
|> Map.put("latitude_override", to_string(lat))
|> Map.put("longitude_override", to_string(lon))
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("use_site_location", _params, socket) do
attrs =
socket
|> current_form_attrs()
|> Map.put("latitude_override", "")
|> Map.put("longitude_override", "")
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
defp current_form_attrs(socket) do
case socket.assigns[:form] do
nil -> %{}
form -> form.params || %{}
end
end
defp save(socket, :new, attrs) do
case Coverages.create_coverage(socket.assigns.organization.id, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage created"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save(socket, :edit, attrs) do
case Coverages.update_coverage(socket.assigns.coverage, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage updated"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, changeset) do
form = to_form(changeset)
eirp = compute_eirp_from_form(form)
socket
|> assign(:form, form)
|> assign(:computed_eirp, eirp)
end
# EIRP = TX power + antenna gain. Cable loss is intentionally not part
# of the user-facing formula (kept zero by default in the schema).
defp compute_eirp_from_form(form) do
tx_power = form_number(form, :tx_power_dbm, 0.0)
gain =
case form[:antenna_slug].value do
slug when is_binary(slug) and slug != "" ->
case Antenna.get(slug) do
%Antenna{gain_dbi: g} -> g
_ -> 0.0
end
_ ->
0.0
end
tx_power + gain
end
defp form_number(form, field, default) do
case form[field].value do
nil ->
default
"" ->
default
n when is_number(n) ->
n * 1.0
s when is_binary(s) ->
case Float.parse(s) do
{n, _} -> n
:error -> default
end
end
end
defp antenna_options(antennas) do
antennas
|> Enum.group_by(& &1.manufacturer)
|> Enum.sort_by(fn {mfr, _} -> mfr || "" end)
|> Enum.map(fn {mfr, ants} ->
{mfr || t("Other"),
Enum.map(ants, fn ant ->
{"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug}
end)}
end)
end
# Reload the devices dropdown when the user picks a different site.
# Clearing site_id clears the dropdown.
defp maybe_refresh_devices(socket, site_id) do
current_site_id =
case socket.assigns[:form] do
nil -> nil
form -> form[:site_id].value
end
if to_string(site_id) == to_string(current_site_id) do
socket
else
assign(socket, :devices, devices_for_site(socket.assigns.organization.id, site_id))
end
end
defp devices_for_site(_org_id, site_id) when site_id in [nil, ""], do: []
defp devices_for_site(org_id, site_id) do
site_id
|> Devices.list_site_devices()
|> Enum.filter(&(&1.organization_id == org_id))
end
@doc """
Latitude to display on the location-picker marker:
override (if set) → selected site's lat → nil.
"""
def radio_marker_lat(form, sites) do
case form_field_value(form, :latitude_override) do
lat when is_number(lat) ->
lat
lat when is_binary(lat) and lat != "" ->
case Float.parse(lat) do
{n, _} -> n
:error -> selected_site_lat(form, sites)
end
_ ->
selected_site_lat(form, sites)
end
end
@doc "Longitude to display on the location-picker marker."
def radio_marker_lon(form, sites) do
case form_field_value(form, :longitude_override) do
lon when is_number(lon) ->
lon
lon when is_binary(lon) and lon != "" ->
case Float.parse(lon) do
{n, _} -> n
:error -> selected_site_lon(form, sites)
end
_ ->
selected_site_lon(form, sites)
end
end
@doc "True if the form's lat/lon override is set (non-empty)."
def radio_overridden?(form) do
has_override?(form_field_value(form, :latitude_override)) and
has_override?(form_field_value(form, :longitude_override))
end
defp has_override?(nil), do: false
defp has_override?(""), do: false
defp has_override?(_), do: true
defp selected_site_lat(form, sites) do
case selected_site(form, sites) do
%{latitude: lat} when is_number(lat) -> lat
_ -> nil
end
end
defp selected_site_lon(form, sites) do
case selected_site(form, sites) do
%{longitude: lon} when is_number(lon) -> lon
_ -> nil
end
end
defp selected_site(form, sites) do
case form_field_value(form, :site_id) do
id when is_binary(id) and id != "" -> Enum.find(sites, &(&1.id == id))
_ -> nil
end
end
defp form_field_value(form, field) do
v = HTMLForm.input_value(form, field)
v
end
@doc false
def device_label(device) do
case device do
%{name: name, ip_address: ip} when is_binary(name) and name != "" ->
"#{name} (#{ip})"
%{ip_address: ip} ->
"#{ip}"
_ ->
""
end
end
defp populate_imperial_virtuals(%Coverage{} = c) do
%{
c
| height_agl_ft: m_to_ft(c.height_agl_m),
height_above_rooftop_ft: m_to_ft(c.height_above_rooftop_m),
receiver_height_ft: m_to_ft(c.receiver_height_m),
tx_clearance_ft: m_to_ft(c.tx_clearance_m),
radius_mi: m_to_mi(c.radius_m),
frequency_ghz: mhz_to_ghz(c.frequency_mhz)
}
end
defp m_to_ft(nil), do: nil
defp m_to_ft(m) when is_number(m), do: Float.round(m / 0.3048, 1)
defp m_to_mi(nil), do: nil
defp m_to_mi(m) when is_number(m), do: Float.round(m / 1609.344, 2)
defp mhz_to_ghz(nil), do: nil
defp mhz_to_ghz(mhz) when is_number(mhz), do: Float.round(mhz / 1000.0, 3)
end