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.
This commit is contained in:
parent
84d4fccb29
commit
6bfcbee7fc
5 changed files with 291 additions and 16 deletions
102
assets/js/app.ts
102
assets/js/app.ts
|
|
@ -1776,6 +1776,106 @@ const SitesMap = {
|
|||
}
|
||||
}
|
||||
|
||||
// CoverageLocationPicker shows a draggable marker on a Leaflet map so
|
||||
// the user can pick the radio's exact location. Initial marker comes
|
||||
// from data-marker-lat/lon attributes (set by the LiveView from the
|
||||
// chosen site or from latitude_override/longitude_override). Dragging
|
||||
// the marker pushes a `location_picked` event with the new {lat, lon}.
|
||||
const CoverageLocationPicker = {
|
||||
map: null as any,
|
||||
marker: null as any,
|
||||
programmaticMove: false,
|
||||
|
||||
mounted(this: any) {
|
||||
if (typeof L === 'undefined') {
|
||||
setTimeout(() => this.mounted(), 100)
|
||||
return
|
||||
}
|
||||
this.initMap()
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
this.syncMarkerFromAttrs()
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
this.map = null
|
||||
this.marker = null
|
||||
}
|
||||
},
|
||||
|
||||
initMap(this: any) {
|
||||
const lat = this.markerLat()
|
||||
const lon = this.markerLon()
|
||||
const hasFix = lat !== null && lon !== null
|
||||
|
||||
const center: [number, number] = hasFix ? [lat, lon] : [30.27, -97.74]
|
||||
const zoom = hasFix ? 15 : 6
|
||||
|
||||
this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true })
|
||||
.setView(center, zoom)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(this.map)
|
||||
|
||||
if (hasFix) {
|
||||
this.marker = L.marker([lat, lon], {
|
||||
draggable: true,
|
||||
autoPan: true,
|
||||
}).addTo(this.map)
|
||||
|
||||
this.marker.on('dragend', (e: any) => {
|
||||
const ll = e.target.getLatLng()
|
||||
this.pushEvent('location_picked', { lat: ll.lat, lon: ll.lng })
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
syncMarkerFromAttrs(this: any) {
|
||||
const lat = this.markerLat()
|
||||
const lon = this.markerLon()
|
||||
if (lat === null || lon === null) return
|
||||
|
||||
if (!this.marker) {
|
||||
// Site got picked after mount — create the marker now.
|
||||
this.marker = L.marker([lat, lon], {
|
||||
draggable: true,
|
||||
autoPan: true,
|
||||
}).addTo(this.map)
|
||||
this.marker.on('dragend', (e: any) => {
|
||||
const ll = e.target.getLatLng()
|
||||
this.pushEvent('location_picked', { lat: ll.lat, lon: ll.lng })
|
||||
})
|
||||
this.map.setView([lat, lon], 15)
|
||||
return
|
||||
}
|
||||
|
||||
const cur = this.marker.getLatLng()
|
||||
if (Math.abs(cur.lat - lat) > 1e-7 || Math.abs(cur.lng - lon) > 1e-7) {
|
||||
this.marker.setLatLng([lat, lon])
|
||||
this.map.panTo([lat, lon])
|
||||
}
|
||||
},
|
||||
|
||||
markerLat(this: any): number | null {
|
||||
const v = this.el.dataset.markerLat
|
||||
if (!v) return null
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
|
||||
markerLon(this: any): number | null {
|
||||
const v = this.el.dataset.markerLon
|
||||
if (!v) return null
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
}
|
||||
|
||||
// CoverageMap renders a single coverage's RSSI heatmap as a Leaflet
|
||||
// imageOverlay, with a marker at the antenna location and a configurable
|
||||
// opacity slider. Data attributes on the host element:
|
||||
|
|
@ -2536,7 +2636,7 @@ const WeathermapViewer = {
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 5000,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -90,6 +91,45 @@ defmodule ToweropsWeb.CoverageLive.Form 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} ->
|
||||
|
|
@ -200,6 +240,79 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
|> 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
|
||||
|
|
|
|||
|
|
@ -82,6 +82,50 @@
|
|||
/>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -43,24 +43,36 @@ Towerops.Coverages.Antenna.load_registry()
|
|||
|
||||
ExUnit.start()
|
||||
|
||||
# Skip the coverage-compute integration test when the GDAL CLI tools
|
||||
# aren't installed (e.g. CI test gate runs in a lean image without
|
||||
# gdal-bin). Locally we install GDAL via `brew install gdal` per
|
||||
# CLAUDE.md, so the test runs there.
|
||||
extra_excludes =
|
||||
if System.find_executable("gdal_translate") && System.find_executable("gdaldem") do
|
||||
[]
|
||||
else
|
||||
[:requires_gdal]
|
||||
end
|
||||
|
||||
# Exclude tests by default
|
||||
# Run specific tests with: mix test --only <tag>
|
||||
ExUnit.configure(
|
||||
exclude: [
|
||||
:integration,
|
||||
# Tests making real network calls (ping, DNS, SSL)
|
||||
:network,
|
||||
# SnmpKit-specific exclusions
|
||||
:performance,
|
||||
:snmpv3,
|
||||
:memory,
|
||||
:shell_integration,
|
||||
:optional,
|
||||
:needs_simulator,
|
||||
:manual,
|
||||
:real_device,
|
||||
:yecc_required
|
||||
]
|
||||
exclude:
|
||||
[
|
||||
:integration,
|
||||
# Tests making real network calls (ping, DNS, SSL)
|
||||
:network,
|
||||
# SnmpKit-specific exclusions
|
||||
:performance,
|
||||
:snmpv3,
|
||||
:memory,
|
||||
:shell_integration,
|
||||
:optional,
|
||||
:needs_simulator,
|
||||
:manual,
|
||||
:real_device,
|
||||
:yecc_required
|
||||
] ++ extra_excludes
|
||||
)
|
||||
|
||||
# Define mocks for testing
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ defmodule Towerops.Workers.CoverageWorkerTest do
|
|||
use Towerops.DataCase, async: false
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
# The compute pipeline shells out to GDAL (`gdal_translate`, `gdaldem`)
|
||||
# to write the GeoTIFF + colored PNG. CI's lean test image doesn't have
|
||||
# gdal-bin; test_helper.exs conditionally excludes this tag when those
|
||||
# binaries aren't on PATH.
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.CoveragesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
|
@ -11,6 +15,8 @@ defmodule Towerops.Workers.CoverageWorkerTest do
|
|||
alias Towerops.Test.StubTerrain
|
||||
alias Towerops.Workers.CoverageWorker
|
||||
|
||||
@moduletag :requires_gdal
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue