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.
350 lines
9.1 KiB
Elixir
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
|