towerops/lib/towerops_web/live/coverage_live/form.html.heex
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

241 lines
6.8 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @page_title}
]} />
<.header>
{@page_title}
<:subtitle>
{t(
"Configure the radio and antenna. Coverage will be computed in the background once you save."
)}
</:subtitle>
<:actions>
<span class="inline-flex items-center px-3 py-1.5 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 text-sm">
<span class="text-blue-700 dark:text-blue-300 font-medium mr-2">EIRP:</span>
<span class="text-blue-900 dark:text-blue-100 font-mono">
{Float.round(@computed_eirp, 1)} dBm
</span>
</span>
</:actions>
</.header>
<.form
for={@form}
id="coverage-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-8"
>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<%!-- Identity --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Identity")}
</h3>
<.input
field={@form[:name]}
type="text"
label={t("Name")}
placeholder={t("e.g. North sector 5 GHz")}
required
/>
<.input
field={@form[:site_id]}
type="select"
label={t("Site")}
prompt={t("Select a site")}
options={Enum.map(@sites, &{&1.name, &1.id})}
required
/>
<.input
:if={@devices != []}
field={@form[:device_id]}
type="select"
label={t("Attach to device (optional)")}
prompt={t("No specific device")}
options={Enum.map(@devices, &{device_label(&1), &1.id})}
/>
<p
:if={@form[:site_id].value not in [nil, ""] and @devices == []}
class="text-xs text-gray-500 dark:text-gray-400"
>
{t("This site has no devices yet — coverage will not be attached to a specific device.")}
</p>
<.input
field={@form[:antenna_slug]}
type="select"
label={t("Antenna")}
prompt={t("Select an antenna")}
options={@antenna_options}
required
/>
</div>
<%!-- Radio location: draggable marker on a Leaflet map. --%>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Radio location")}
</h3>
<button
:if={radio_overridden?(@form)}
type="button"
phx-click="use_site_location"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
>
{t("Use site location")}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Drag the marker to where the radio is mounted. Defaults to the site's coordinates.")}
</p>
<div
id="coverage-location-picker"
phx-hook="CoverageLocationPicker"
phx-update="ignore"
class="h-72 w-full rounded border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800"
data-marker-lat={radio_marker_lat(@form, @sites)}
data-marker-lon={radio_marker_lon(@form, @sites)}
>
</div>
<div
:if={radio_marker_lat(@form, @sites)}
class="text-xs font-mono text-gray-600 dark:text-gray-400"
>
{radio_marker_lat(@form, @sites)}, {radio_marker_lon(@form, @sites)}
<span :if={radio_overridden?(@form)} class="ml-2 text-blue-600 dark:text-blue-400">
({t("override")})
</span>
</div>
<.input field={@form[:latitude_override]} type="hidden" />
<.input field={@form[:longitude_override]} type="hidden" />
</div>
<%!-- Radio --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Radio")}
</h3>
<.input
field={@form[:frequency_ghz]}
type="number"
label={t("Frequency (GHz)")}
step="0.001"
min="0.7"
max="90"
required
/>
<.input
field={@form[:tx_power_dbm]}
type="number"
label={t("TX power (dBm)")}
step="0.1"
min="-10"
max="50"
required
/>
<.input
field={@form[:radius_mi]}
type="number"
label={t("Range (mi)")}
step="0.1"
min="0.3"
max="25"
required
/>
</div>
<%!-- Mounting --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Mounting")}
</h3>
<.input
field={@form[:height_agl_ft]}
type="number"
label={t("Height above ground (ft)")}
step="1"
min="3"
max="650"
required
/>
<.input
field={@form[:azimuth_deg]}
type="number"
label={t("Azimuth (°, true north)")}
step="1"
min="0"
max="360"
required
/>
<.input
field={@form[:downtilt_deg]}
type="number"
label={t("Tilt (°, positive = down)")}
step="0.1"
min="-10"
max="30"
/>
</div>
<%!-- Foliage --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Environment")}
</h3>
<div>
<label
for="coverage_foliage_tuning"
class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"
>
{t("Foliage tuning")}: {Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
</label>
<input
type="range"
id="coverage_foliage_tuning"
name="coverage[foliage_tuning]"
value={Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
min="0"
max="100"
step="1"
class="w-full"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("Higher values reduce predicted signal in wooded areas.")}
</p>
</div>
</div>
</div>
<div class="flex justify-end gap-2">
<.link navigate={~p"/coverage"} class="btn btn-outline">
{t("Cancel")}
</.link>
<.button type="submit" variant="primary">
{if @live_action == :new, do: t("Create coverage"), else: t("Save changes")}
</.button>
</div>
</.form>
</Layouts.authenticated>