diff --git a/assets/js/app.ts b/assets/js/app.ts index 41061adb..62c804bf 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -2047,6 +2047,288 @@ const CoverageMap = { }, } +// MultiCoverageMap renders every coverage in the org as a layered set +// of L.imageOverlays on a single map. The server pushes enabled-id +// changes via the "coverage:enabled-changed" event and full reloads +// via "coverage:list-changed". User clicks on the map fire a +// "probe_point" LiveView event with {lat, lon}. +type CoveragePayload = { + id: string + name: string + png: string + bbox: [number, number, number, number] // [min_lat, min_lon, max_lat, max_lon] + tx: { lat: number | null, lon: number | null, azimuth: number | null } + site: string | null +} +const MultiCoverageMap = { + map: null as any, + baseStreets: null as any, + baseSatellite: null as any, + layerCtl: null as any, + searchControl: null as any, + overlays: new Map(), + markers: new Map(), + enabled: new Set(), + coverages: [] as CoveragePayload[], + opacityListener: null as ((e: Event) => void) | null, + searchListener: null as ((e: KeyboardEvent) => void) | null, + probeMarker: null as any, + searchMarker: null as any, + + mounted(this: any) { + ensureLeaflet().then(() => this.init()).catch((e) => { + console.error('MultiCoverageMap: Leaflet failed to load', e) + }) + }, + + destroyed(this: any) { + if (this.opacityListener) { + const slider = document.getElementById('coverage-opacity-slider') + if (slider) slider.removeEventListener('input', this.opacityListener) + this.opacityListener = null + } + if (this.searchListener) { + const input = document.getElementById('coverage-map-search') + if (input) input.removeEventListener('keydown', this.searchListener as any) + this.searchListener = null + } + if (this.map) { + this.map.remove() + this.map = null + this.overlays.clear() + this.markers.clear() + this.enabled.clear() + } + }, + + init(this: any) { + this.coverages = JSON.parse(this.el.dataset.coverages || '[]') + const enabledList: string[] = JSON.parse(this.el.dataset.enabled || '[]') + this.enabled = new Set(enabledList) + + // Determine an initial view: average of TX positions, or first bbox. + const init = this.computeInitialView() + + // Inject the floating top-left toolbar (base toggle + search) outside + // Phoenix-managed DOM so phx-update="ignore" plays well. + this.injectToolbar() + + this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true, attributionControl: true }) + .setView([init.lat, init.lon], init.zoom) + + this.baseStreets = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }) + + this.baseSatellite = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + { + attribution: 'Tiles © Esri', + maxZoom: 19, + } + ) + this.baseSatellite.addTo(this.map) + + this.coverages.forEach((c) => this.addCoverageLayer(c)) + this.applyEnabled() + + this.map.on('click', (e: any) => { + this.placeProbeMarker(e.latlng.lat, e.latlng.lng) + this.pushEvent('probe_point', { lat: e.latlng.lat, lon: e.latlng.lng }) + }) + + this.bindOpacitySlider() + this.bindBaseToggle() + this.bindSearch() + + this.handleEvent('coverage:enabled-changed', ({ enabled }: { enabled: string[] }) => { + this.enabled = new Set(enabled) + this.applyEnabled() + }) + + this.handleEvent('coverage:list-changed', (payload: { coverages: CoveragePayload[], enabled: string[] }) => { + // Refresh the layer set entirely. + this.overlays.forEach((o) => this.map.removeLayer(o)) + this.markers.forEach((m) => this.map.removeLayer(m)) + this.overlays.clear() + this.markers.clear() + + this.coverages = payload.coverages + this.enabled = new Set(payload.enabled) + this.coverages.forEach((c) => this.addCoverageLayer(c)) + this.applyEnabled() + }) + }, + + computeInitialView(this: any): { lat: number, lon: number, zoom: number } { + if (this.coverages.length === 0) return { lat: 31.0, lon: -97.0, zoom: 6 } + const lats: number[] = [] + const lons: number[] = [] + this.coverages.forEach((c: CoveragePayload) => { + if (c.tx.lat !== null && c.tx.lon !== null) { + lats.push(c.tx.lat) + lons.push(c.tx.lon) + } + }) + if (lats.length === 0) { + const [minLat, minLon, maxLat, maxLon] = this.coverages[0].bbox + return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2, zoom: 12 } + } + const lat = lats.reduce((s, v) => s + v, 0) / lats.length + const lon = lons.reduce((s, v) => s + v, 0) / lons.length + return { lat, lon, zoom: lats.length === 1 ? 13 : 11 } + }, + + injectToolbar(this: any) { + if (document.getElementById('coverage-map-toolbar')) return + const toolbar = document.createElement('div') + toolbar.id = 'coverage-map-toolbar' + toolbar.className = + 'pointer-events-none fixed top-32 left-6 z-[1100] flex flex-col gap-2 w-72 max-w-[90vw]' + toolbar.innerHTML = ` +
+ + +
+
+ +
+ ` + document.body.appendChild(toolbar) + + this.cleanupToolbar = () => { + const el = document.getElementById('coverage-map-toolbar') + if (el) el.remove() + } + }, + + addCoverageLayer(this: any, c: CoveragePayload) { + const [minLat, minLon, maxLat, maxLon] = c.bbox + const overlay = L.imageOverlay(c.png, [[minLat, minLon], [maxLat, maxLon]], { + opacity: this.currentOpacity(), + interactive: false, + }) + this.overlays.set(c.id, overlay) + + if (c.tx.lat !== null && c.tx.lon !== null) { + const az = c.tx.azimuth ?? 0 + const icon = L.divIcon({ + className: 'coverage-antenna-marker', + html: ` +
+
+
+
+ `, + iconSize: [32, 32], + iconAnchor: [16, 16], + }) + const marker = L.marker([c.tx.lat, c.tx.lon], { icon, title: c.name }) + this.markers.set(c.id, marker) + } + }, + + applyEnabled(this: any) { + this.coverages.forEach((c: CoveragePayload) => { + const overlay = this.overlays.get(c.id) + const marker = this.markers.get(c.id) + const on = this.enabled.has(c.id) + if (overlay) { + if (on && !this.map.hasLayer(overlay)) overlay.addTo(this.map) + if (!on && this.map.hasLayer(overlay)) this.map.removeLayer(overlay) + } + if (marker) { + if (on && !this.map.hasLayer(marker)) marker.addTo(this.map) + if (!on && this.map.hasLayer(marker)) this.map.removeLayer(marker) + } + }) + }, + + bindOpacitySlider(this: any) { + const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null + if (!slider) return + this.opacityListener = (e: Event) => { + const v = parseInt((e.target as HTMLInputElement).value, 10) / 100 + this.overlays.forEach((o: any) => o.setOpacity(v)) + const label = document.getElementById('coverage-opacity-label') + if (label) label.textContent = `${Math.round(v * 100)}%` + } + slider.addEventListener('input', this.opacityListener) + }, + + currentOpacity(this: any): number { + const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null + if (!slider) return 0.7 + return parseInt(slider.value, 10) / 100 + }, + + bindBaseToggle(this: any) { + const streets = document.getElementById('coverage-base-streets') + const satellite = document.getElementById('coverage-base-satellite') + if (!streets || !satellite) return + streets.addEventListener('click', () => { + if (this.map.hasLayer(this.baseSatellite)) this.map.removeLayer(this.baseSatellite) + if (!this.map.hasLayer(this.baseStreets)) this.baseStreets.addTo(this.map) + streets.classList.add('text-gray-900', 'dark:text-white', 'bg-blue-100', 'dark:bg-blue-900/40') + satellite.classList.remove('text-gray-900', 'dark:text-white', 'bg-blue-100', 'dark:bg-blue-900/40') + }) + satellite.addEventListener('click', () => { + if (this.map.hasLayer(this.baseStreets)) this.map.removeLayer(this.baseStreets) + if (!this.map.hasLayer(this.baseSatellite)) this.baseSatellite.addTo(this.map) + satellite.classList.add('text-gray-900', 'dark:text-white', 'bg-blue-100', 'dark:bg-blue-900/40') + streets.classList.remove('text-gray-900', 'dark:text-white', 'bg-blue-100', 'dark:bg-blue-900/40') + }) + }, + + bindSearch(this: any) { + const input = document.getElementById('coverage-map-search') as HTMLInputElement | null + if (!input) return + this.searchListener = async (e: KeyboardEvent) => { + if (e.key !== 'Enter') return + const q = input.value.trim() + if (!q) return + try { + const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}` + const res = await fetch(url, { headers: { 'Accept': 'application/json' } }) + if (!res.ok) return + const data = await res.json() + if (!data || data.length === 0) return + const lat = parseFloat(data[0].lat) + const lon = parseFloat(data[0].lon) + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return + this.map.setView([lat, lon], 16) + if (this.searchMarker) this.map.removeLayer(this.searchMarker) + this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(data[0].display_name).openPopup() + } catch (err) { + console.error('Coverage search failed:', err) + } + } + input.addEventListener('keydown', this.searchListener as any) + }, + + placeProbeMarker(this: any, lat: number, lon: number) { + if (this.probeMarker) this.map.removeLayer(this.probeMarker) + const icon = L.divIcon({ + className: 'coverage-probe-marker', + html: `
`, + iconSize: [14, 14], + iconAnchor: [7, 7], + }) + this.probeMarker = L.marker([lat, lon], { icon, interactive: false }).addTo(this.map) + }, + + cleanupToolbar: null as null | (() => void), +} + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') @@ -2673,7 +2955,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, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, MultiCoverageMap, 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/coverages.ex b/lib/towerops/coverages.ex index c66a42aa..b266fb1e 100644 --- a/lib/towerops/coverages.ex +++ b/lib/towerops/coverages.ex @@ -22,6 +22,7 @@ defmodule Towerops.Coverages do import Ecto.Query alias Towerops.Coverages.Coverage + alias Towerops.Coverages.Raster alias Towerops.Repo alias Towerops.Sites.Site alias Towerops.Workers.CoverageWorker @@ -85,6 +86,70 @@ defmodule Towerops.Coverages do |> Repo.all() end + @doc """ + Lists ready coverages — those with a computed PNG/GeoTIFF and bbox — + for the unified map view. Site is preloaded for the toggle list. + """ + @spec list_ready_for_organization(Ecto.UUID.t()) :: [Coverage.t()] + def list_ready_for_organization(organization_id) do + Coverage + |> where(organization_id: ^organization_id) + |> where(status: "ready") + |> where([c], not is_nil(c.png_path) and not is_nil(c.bbox_min_lat)) + |> order_by(asc: :name) + |> preload(:site) + |> Repo.all() + end + + @doc """ + For each ready coverage in the organization, returns the + great-circle distance (m) and predicted RSSI (dBm) at `(lat, lon)`. + + Coverages whose bbox does not contain the point or that have no + raster on disk return `:no_coverage` for the rssi. + """ + @spec query_point(Ecto.UUID.t(), float(), float()) :: [ + %{ + coverage: Coverage.t(), + distance_m: float(), + rssi: float() | :no_coverage | nil + } + ] + def query_point(organization_id, lat, lon) when is_number(lat) and is_number(lon) do + organization_id + |> list_ready_for_organization() + |> Enum.map(&summarize_point(&1, lat, lon)) + end + + defp summarize_point(coverage, lat, lon) do + {tx_lat, tx_lon} = Coverage.location(coverage) || {coverage.bbox_min_lat, coverage.bbox_min_lon} + distance = haversine_m({tx_lat, tx_lon}, {lat, lon}) + + rssi = + case Raster.query_rssi(coverage, lat, lon) do + {:ok, :no_coverage} -> :no_coverage + {:ok, dbm} when is_number(dbm) -> dbm + {:error, _} -> nil + end + + %{coverage: coverage, distance_m: distance, rssi: rssi} + end + + defp haversine_m({lat1, lon1}, {lat2, lon2}) do + r = 6_371_000.0 + p1 = lat1 * :math.pi() / 180.0 + p2 = lat2 * :math.pi() / 180.0 + dp = (lat2 - lat1) * :math.pi() / 180.0 + dl = (lon2 - lon1) * :math.pi() / 180.0 + + a = + :math.sin(dp / 2) * :math.sin(dp / 2) + + :math.cos(p1) * :math.cos(p2) * :math.sin(dl / 2) * :math.sin(dl / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + r * c + end + @doc "Fetches a coverage scoped to an organization. Raises if not found." @spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() def get_coverage!(organization_id, id) do diff --git a/lib/towerops/coverages/raster.ex b/lib/towerops/coverages/raster.ex index 3c4c9576..85a8d57e 100644 --- a/lib/towerops/coverages/raster.ex +++ b/lib/towerops/coverages/raster.ex @@ -90,6 +90,85 @@ defmodule Towerops.Coverages.Raster do :ok end + @doc """ + Reads the RSSI value (dBm) at a single WGS84 point from the + coverage's GeoTIFF via `gdallocationinfo`. + + Returns: + * `{:ok, dbm}` — pixel had a valid value + * `{:ok, :no_coverage}` — pixel was the NoData sentinel + * `{:error, :outside_bbox}` — point is outside the raster's extent + * `{:error, :not_ready}` — coverage hasn't been computed yet + * `{:error, term}` — gdal failure + """ + @spec query_rssi(Coverage.t(), float(), float()) :: + {:ok, float() | :no_coverage} | {:error, term()} + def query_rssi(%Coverage{status: status, raster_path: nil}, _lat, _lon) when status != "ready", do: {:error, :not_ready} + + def query_rssi(%Coverage{raster_path: nil}, _lat, _lon), do: {:error, :not_ready} + + def query_rssi(%Coverage{} = coverage, lat, lon) when is_number(lat) and is_number(lon) do + if inside_bbox?(coverage, lat, lon) do + tif = absolute_raster_path(coverage) + + if File.exists?(tif) do + run_gdallocationinfo(tif, lon, lat) + else + {:error, :raster_missing} + end + else + {:error, :outside_bbox} + end + end + + defp inside_bbox?( + %Coverage{bbox_min_lat: min_lat, bbox_max_lat: max_lat, bbox_min_lon: min_lon, bbox_max_lon: max_lon}, + lat, + lon + ) + when is_number(min_lat) and is_number(max_lat) and is_number(min_lon) and is_number(max_lon) do + lat >= min_lat and lat <= max_lat and lon >= min_lon and lon <= max_lon + end + + defp inside_bbox?(_coverage, _lat, _lon), do: false + + defp absolute_raster_path(%Coverage{raster_path: rel}) do + Path.join(static_dir(), Path.relative_to(rel, "/")) + end + + # gdallocationinfo with -wgs84 -valonly prints just the pixel value or + # a blank line if outside, plus an error code line. + defp run_gdallocationinfo(tif, lon, lat) do + args = ["-valonly", "-wgs84", tif, Float.to_string(lon * 1.0), Float.to_string(lat * 1.0)] + + case System.cmd("gdallocationinfo", args, stderr_to_stdout: true) do + {output, 0} -> + parse_gdal_value(output) + + {output, code} -> + {:error, {:gdallocationinfo_failed, code, String.slice(output, 0, 200)}} + end + end + + defp parse_gdal_value(output) do + trimmed = output |> String.trim() |> String.split("\n") |> List.first() |> to_string() + + cond do + trimmed == "" -> + {:error, :outside_bbox} + + String.contains?(trimmed, "outside") -> + {:error, :outside_bbox} + + true -> + case Float.parse(trimmed) do + {v, _} when v >= @nan_sentinel * 0.99 -> {:ok, :no_coverage} + {v, _} -> {:ok, v} + :error -> {:error, {:bad_gdal_output, trimmed}} + end + end + end + defp output_dir(%Coverage{organization_id: org_id, id: id}) do Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)]) end diff --git a/lib/towerops_web/live/coverage_live/map.ex b/lib/towerops_web/live/coverage_live/map.ex new file mode 100644 index 00000000..1b4b7133 --- /dev/null +++ b/lib/towerops_web/live/coverage_live/map.ex @@ -0,0 +1,158 @@ +defmodule ToweropsWeb.CoverageLive.Map do + @moduledoc """ + cnHeat-style unified coverage map. + + Shows every ready coverage in the organisation as a single composite + heatmap on a Leaflet map, with: + + * a per-tower toggle list (top-right) including Show All / Hide All + * an opacity slider (bottom-centre) + * a base-map toggle (Streets / Satellite, top-left) + * an address search bar (Nominatim, top-left) + * a Distance Tool (click the map → side panel with each tower's + distance from the click and predicted RSSI at that location) + + All ready coverages share the same colour palette so the composite + read like a single map instead of N independent overlays. + """ + use ToweropsWeb, :live_view + + alias Towerops.Coverages + alias Towerops.Coverages.Coverage + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_scope.organization + + if connected?(socket), do: Coverages.subscribe_organization(organization.id) + + coverages = Coverages.list_ready_for_organization(organization.id) + + {:ok, + socket + |> assign(:page_title, t("Coverage Map")) + |> assign(:organization, organization) + |> assign(:coverages, coverages) + |> assign(:enabled_ids, Enum.map(coverages, & &1.id)) + |> assign(:probe, nil) + |> assign(:probe_units, "ft")} + end + + @impl true + def handle_event("toggle_coverage", %{"id" => id}, socket) do + enabled = + if id in socket.assigns.enabled_ids, + do: List.delete(socket.assigns.enabled_ids, id), + else: [id | socket.assigns.enabled_ids] + + {:noreply, + socket + |> assign(:enabled_ids, enabled) + |> push_event("coverage:enabled-changed", %{enabled: enabled})} + end + + @impl true + def handle_event("show_all", _params, socket) do + enabled = Enum.map(socket.assigns.coverages, & &1.id) + + {:noreply, + socket + |> assign(:enabled_ids, enabled) + |> push_event("coverage:enabled-changed", %{enabled: enabled})} + end + + @impl true + def handle_event("hide_all", _params, socket) do + {:noreply, + socket + |> assign(:enabled_ids, []) + |> push_event("coverage:enabled-changed", %{enabled: []})} + end + + @impl true + def handle_event("probe_point", %{"lat" => lat, "lon" => lon}, socket) do + lat = to_float(lat) + lon = to_float(lon) + + rows = + socket.assigns.organization.id + |> Coverages.query_point(lat, lon) + |> Enum.sort_by(& &1.distance_m) + + probe = %{lat: lat, lon: lon, rows: rows} + + {:noreply, assign(socket, :probe, probe)} + end + + @impl true + def handle_event("close_probe", _params, socket) do + {:noreply, assign(socket, :probe, nil)} + end + + @impl true + def handle_event("set_probe_units", %{"units" => units}, socket) when units in ["ft", "m"] do + {:noreply, assign(socket, :probe_units, units)} + end + + @impl true + def handle_info({:coverage_status, _status, _arg}, socket) do + coverages = Coverages.list_ready_for_organization(socket.assigns.organization.id) + + enabled = + socket.assigns.enabled_ids + |> MapSet.new() + |> MapSet.intersection(MapSet.new(Enum.map(coverages, & &1.id))) + |> MapSet.to_list() + + new_ids = + coverages + |> Enum.map(& &1.id) + |> Enum.reject(&(&1 in socket.assigns.enabled_ids)) + + enabled = enabled ++ new_ids + + {:noreply, + socket + |> assign(:coverages, coverages) + |> assign(:enabled_ids, enabled) + |> push_event("coverage:list-changed", %{ + coverages: coverages_payload(coverages), + enabled: enabled + })} + end + + @doc false + def coverages_payload(coverages) do + Enum.map(coverages, fn c -> + {tx_lat, tx_lon} = Coverage.location(c) || {nil, nil} + + %{ + id: c.id, + name: c.name, + png: c.png_path, + bbox: [c.bbox_min_lat, c.bbox_min_lon, c.bbox_max_lat, c.bbox_max_lon], + tx: %{lat: tx_lat, lon: tx_lon, azimuth: c.azimuth_deg}, + site: c.site && c.site.name + } + end) + end + + @doc false + def fmt_distance(m, "ft") when is_number(m), do: "#{:erlang.float_to_binary(m * 3.28084 / 5280.0, decimals: 2)} mi" + def fmt_distance(m, "m") when is_number(m), do: "#{:erlang.float_to_binary(m / 1000.0, decimals: 2)} km" + def fmt_distance(_, _), do: "—" + + @doc false + def fmt_rssi(:no_coverage), do: "N/A" + def fmt_rssi(nil), do: "—" + def fmt_rssi(v) when is_number(v), do: "#{:erlang.float_to_binary(v * 1.0, decimals: 1)} dBm" + + defp to_float(v) when is_number(v), do: v * 1.0 + + defp to_float(v) when is_binary(v) do + case Float.parse(v) do + {n, _} -> n + :error -> 0.0 + end + end +end diff --git a/lib/towerops_web/live/coverage_live/map.html.heex b/lib/towerops_web/live/coverage_live/map.html.heex new file mode 100644 index 00000000..3421cc72 --- /dev/null +++ b/lib/towerops_web/live/coverage_live/map.html.heex @@ -0,0 +1,247 @@ + + <.header> + {@page_title} + <:subtitle> + {t("All ready coverages overlaid. Click any point to query each tower's predicted RSSI.")} + + <:actions> + <.link navigate={~p"/coverage/list"} class="btn btn-outline btn-sm gap-1.5"> + <.icon name="hero-list-bullet" class="h-4 w-4" /> {t("List view")} + + <.button navigate={~p"/coverage/new"} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")} + + + + + <%= if @coverages == [] do %> +
+
+
+
+ <.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" /> +
+

{t("No ready coverages")}

+

+ {t( + "Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish." + )} +

+
+ <.button navigate={~p"/coverage/new"} variant="primary"> + <.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")} + +
+
+
+
+ <% else %> +
+
+ + <%!-- Tower toggle list (top-right, overlay above map) --%> +
+
+
+

{t("Towers")}

+
+ + +
+
+
    + <%= for c <- @coverages do %> +
  • + +
  • + <% end %> +
+
+
+ + <%!-- Opacity slider + legend (bottom-center, overlay above map) --%> +
+
+
+ + + 70% +
+
+ + ≥-50 + + + -65 + + + -75 + + + -85 + + ≤-95 dBm +
+
+
+ + <%!-- Distance Tool side panel — opens when the user clicks the map --%> +
+
+
+
+ <.icon name="hero-map-pin" class="h-4 w-4 text-blue-500" /> + + {:erlang.float_to_binary(@probe.lat * 1.0, decimals: 5)}, {:erlang.float_to_binary( + @probe.lon * 1.0, + decimals: 5 + )} + +
+ +
+
+ {t("Units:")} + + +
+ + + + + + + + + + <%= for row <- @probe.rows do %> + + + + + + <% end %> + +
+ {t("Tower")} + + {t("Distance")} + + RSSI +
+ {row.coverage.name} + + {fmt_distance(row.distance_m, @probe_units)} + + "text-gray-400" + + is_number(row.rssi) and row.rssi >= -75 -> + "text-green-600 dark:text-green-400 font-bold" + + is_number(row.rssi) and row.rssi >= -85 -> + "text-yellow-600 dark:text-yellow-400" + + is_number(row.rssi) -> + "text-red-600 dark:text-red-400" + + true -> + "text-gray-400" + end + ]}> + {fmt_rssi(row.rssi)} +
+
+
+ <% end %> +
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 2dcd7854..c261006c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -512,7 +512,8 @@ defmodule ToweropsWeb.Router do live "/sites-map", MapLive.Index, :index # RF coverage prediction - live "/coverage", CoverageLive.Index, :index + live "/coverage", CoverageLive.Map, :index + live "/coverage/list", CoverageLive.Index, :index live "/coverage/new", CoverageLive.Form, :new live "/coverage/:id", CoverageLive.Show, :show live "/coverage/:id/edit", CoverageLive.Form, :edit