diff --git a/assets/js/app.ts b/assets/js/app.ts index c455587d..7e85fd5d 100644 --- a/assets/js/app.ts +++ b/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: '© OpenStreetMap 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 diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex index d307aee9..d02328f6 100644 --- a/lib/towerops_web/live/coverage_live/form.ex +++ b/lib/towerops_web/live/coverage_live/form.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex index b918580c..0bdc4fb7 100644 --- a/lib/towerops_web/live/coverage_live/form.html.heex +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -82,6 +82,50 @@ /> + <%!-- Radio location: draggable marker on a Leaflet map. --%> +
+ {t("Drag the marker to where the radio is mounted. Defaults to the site's coordinates.")} +
+ +