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.
241 lines
6.8 KiB
Text
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>
|