feat(coverage): cnHeat-style unified multi-tower map at /coverage
- /coverage now renders a full-bleed Leaflet map of every ready coverage in the org as one composite heatmap; the prior table view moves to /coverage/list. - Top-right tower toggle list with Show All / Hide All. Disabled towers grey out and their overlay/marker drop from the map. - Bottom-center opacity slider controls all overlays in unison; the cnHeat dBm legend renders next to it. - Top-left base-layer toggle (OSM streets / Esri satellite) and a Nominatim address search bar that drops a marker. - Click anywhere on the map → Distance Tool side panel with each tower sorted by distance and the predicted RSSI at that point (read from each coverage's GeoTIFF via gdallocationinfo). ft/m unit toggle, NA for out-of-bbox, color-coded by signal quality. - Backend additions: Coverages.list_ready_for_organization/1, Coverages.query_point/3, Raster.query_rssi/3 (gdallocationinfo wrapper with bbox guard, NoData sentinel handling). - New JS hook MultiCoverageMap manages the layer set, toolbar, search, opacity, click-to-probe, and live updates via PubSub (coverage:enabled-changed and coverage:list-changed).
This commit is contained in:
parent
a0d72eee5c
commit
39a5892d09
6 changed files with 834 additions and 2 deletions
284
assets/js/app.ts
284
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<string, any>(),
|
||||||
|
markers: new Map<string, any>(),
|
||||||
|
enabled: new Set<string>(),
|
||||||
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = `
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-2 flex gap-1">
|
||||||
|
<button type="button" id="coverage-base-streets" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">Map</button>
|
||||||
|
<button type="button" id="coverage-base-satellite" class="flex-1 px-2 py-1 text-xs rounded font-medium text-gray-900 dark:text-white bg-blue-100 dark:bg-blue-900/40">Satellite</button>
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
||||||
|
<input type="text" id="coverage-map-search" placeholder="Search address..." class="w-full px-3 py-2 text-sm bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
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: `
|
||||||
|
<div style="position:relative;width:32px;height:32px">
|
||||||
|
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
|
||||||
|
transform:translate(-50%,-100%) rotate(${az}deg);
|
||||||
|
transform-origin:50% 100%;
|
||||||
|
border-left:18px solid transparent;
|
||||||
|
border-right:18px solid transparent;
|
||||||
|
border-bottom:36px solid rgba(59,130,246,0.35);"></div>
|
||||||
|
<div style="position:absolute;top:50%;left:50%;width:12px;height:12px;
|
||||||
|
transform:translate(-50%,-50%);
|
||||||
|
background:#2563eb;border:2px solid white;border-radius:50%;
|
||||||
|
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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: `<div style="width:14px;height:14px;border-radius:50%;background:#dc2626;border:3px solid white;box-shadow:0 0 0 1px rgba(0,0,0,0.4)"></div>`,
|
||||||
|
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<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
|
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
throw new Error('CSRF token meta tag not found')
|
throw new Error('CSRF token meta tag not found')
|
||||||
|
|
@ -2673,7 +2955,7 @@ const WeathermapViewer = {
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 5000,
|
longPollFallbackMs: 5000,
|
||||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
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
|
// Show progress bar on live navigation and form submits
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ defmodule Towerops.Coverages do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Towerops.Coverages.Coverage
|
alias Towerops.Coverages.Coverage
|
||||||
|
alias Towerops.Coverages.Raster
|
||||||
alias Towerops.Repo
|
alias Towerops.Repo
|
||||||
alias Towerops.Sites.Site
|
alias Towerops.Sites.Site
|
||||||
alias Towerops.Workers.CoverageWorker
|
alias Towerops.Workers.CoverageWorker
|
||||||
|
|
@ -85,6 +86,70 @@ defmodule Towerops.Coverages do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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."
|
@doc "Fetches a coverage scoped to an organization. Raises if not found."
|
||||||
@spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
|
@spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
|
||||||
def get_coverage!(organization_id, id) do
|
def get_coverage!(organization_id, id) do
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,85 @@ defmodule Towerops.Coverages.Raster do
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
defp output_dir(%Coverage{organization_id: org_id, id: id}) do
|
||||||
Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)])
|
Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)])
|
||||||
end
|
end
|
||||||
|
|
|
||||||
158
lib/towerops_web/live/coverage_live/map.ex
Normal file
158
lib/towerops_web/live/coverage_live/map.ex
Normal file
|
|
@ -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
|
||||||
247
lib/towerops_web/live/coverage_live/map.html.heex
Normal file
247
lib/towerops_web/live/coverage_live/map.html.heex
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
active_page="coverage"
|
||||||
|
>
|
||||||
|
<.header>
|
||||||
|
{@page_title}
|
||||||
|
<:subtitle>
|
||||||
|
{t("All ready coverages overlaid. Click any point to query each tower's predicted RSSI.")}
|
||||||
|
</:subtitle>
|
||||||
|
<: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")}
|
||||||
|
</.link>
|
||||||
|
<.button navigate={~p"/coverage/new"} variant="primary">
|
||||||
|
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<%= if @coverages == [] do %>
|
||||||
|
<div class="mt-6 flex items-center justify-center py-16">
|
||||||
|
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
|
||||||
|
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title text-gray-900 dark:text-white">{t("No ready coverages")}</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{t(
|
||||||
|
"Coverage maps appear here once the background compute finishes. Create or wait for a coverage to finish."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="card-actions mt-2">
|
||||||
|
<.button navigate={~p"/coverage/new"} variant="primary">
|
||||||
|
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div
|
||||||
|
id="coverage-multi-map"
|
||||||
|
phx-hook="MultiCoverageMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
class="relative mt-6 h-[78vh] w-full rounded-lg border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800 overflow-hidden"
|
||||||
|
data-coverages={Jason.encode!(coverages_payload(@coverages))}
|
||||||
|
data-enabled={Jason.encode!(@enabled_ids)}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Tower toggle list (top-right, overlay above map) --%>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed top-32 right-6 z-[1100] w-72 max-h-[60vh] overflow-y-auto"
|
||||||
|
id="coverage-tower-list"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{t("Towers")}</h3>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="show_all"
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{t("Show All")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="hide_all"
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{t("Hide All")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="py-1">
|
||||||
|
<%= for c <- @coverages do %>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_coverage"
|
||||||
|
phx-value-id={c.id}
|
||||||
|
class={[
|
||||||
|
"w-full text-left flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800",
|
||||||
|
if(c.id in @enabled_ids,
|
||||||
|
do: "text-gray-900 dark:text-white font-medium",
|
||||||
|
else: "text-gray-400 dark:text-gray-500"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span class={[
|
||||||
|
"inline-block h-2.5 w-2.5 rounded-full",
|
||||||
|
if(c.id in @enabled_ids,
|
||||||
|
do: "bg-green-500",
|
||||||
|
else: "bg-gray-300 dark:bg-gray-600"
|
||||||
|
)
|
||||||
|
]}>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm truncate flex-1">{c.name}</span>
|
||||||
|
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{c.site && c.site.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Opacity slider + legend (bottom-center, overlay above map) --%>
|
||||||
|
<div class="pointer-events-none fixed bottom-6 left-1/2 -translate-x-1/2 z-[1100] w-[640px] max-w-[90vw]">
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-3 space-y-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
for="coverage-opacity-slider"
|
||||||
|
class="text-xs font-medium text-gray-700 dark:text-gray-300 w-16"
|
||||||
|
>
|
||||||
|
{t("Opacity")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="coverage-opacity-slider"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="70"
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span id="coverage-opacity-label" class="text-xs font-mono w-10 text-right">70%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-[10px] font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
<span class="w-3 h-3 rounded inline-block" style="background:rgb(0,200,0)"></span>
|
||||||
|
<span>≥-50</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(200,230,0)">
|
||||||
|
</span>
|
||||||
|
<span>-65</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(255,165,0)">
|
||||||
|
</span>
|
||||||
|
<span>-75</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(220,60,60)">
|
||||||
|
</span>
|
||||||
|
<span>-85</span>
|
||||||
|
<span class="w-3 h-3 rounded inline-block ml-2" style="background:rgb(100,0,0)"></span>
|
||||||
|
<span>≤-95 dBm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Distance Tool side panel — opens when the user clicks the map --%>
|
||||||
|
<div
|
||||||
|
:if={@probe}
|
||||||
|
class="pointer-events-none fixed top-32 left-6 z-[1100] w-[420px] max-w-[90vw] max-h-[60vh] overflow-y-auto"
|
||||||
|
id="coverage-probe-panel"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
<.icon name="hero-map-pin" class="h-4 w-4 text-blue-500" />
|
||||||
|
<span>
|
||||||
|
{:erlang.float_to_binary(@probe.lat * 1.0, decimals: 5)}, {:erlang.float_to_binary(
|
||||||
|
@probe.lon * 1.0,
|
||||||
|
decimals: 5
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="close_probe"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 border-b border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300">{t("Units:")}</span>
|
||||||
|
<label class="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="probe-units"
|
||||||
|
checked={@probe_units == "ft"}
|
||||||
|
phx-click="set_probe_units"
|
||||||
|
phx-value-units="ft"
|
||||||
|
/> ft
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="probe-units"
|
||||||
|
checked={@probe_units == "m"}
|
||||||
|
phx-click="set_probe_units"
|
||||||
|
phx-value-units="m"
|
||||||
|
/> m
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="min-w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-1.5 text-left font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t("Tower")}
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t("Distance")}
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-1.5 text-right font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
RSSI
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
|
<%= for row <- @probe.rows do %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{row.coverage.name}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
{fmt_distance(row.distance_m, @probe_units)}
|
||||||
|
</td>
|
||||||
|
<td class={[
|
||||||
|
"px-3 py-1.5 text-right font-mono",
|
||||||
|
cond do
|
||||||
|
row.rssi == :no_coverage ->
|
||||||
|
"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)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</Layouts.authenticated>
|
||||||
|
|
@ -512,7 +512,8 @@ defmodule ToweropsWeb.Router do
|
||||||
live "/sites-map", MapLive.Index, :index
|
live "/sites-map", MapLive.Index, :index
|
||||||
|
|
||||||
# RF coverage prediction
|
# 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/new", CoverageLive.Form, :new
|
||||||
live "/coverage/:id", CoverageLive.Show, :show
|
live "/coverage/:id", CoverageLive.Show, :show
|
||||||
live "/coverage/:id/edit", CoverageLive.Form, :edit
|
live "/coverage/:id/edit", CoverageLive.Form, :edit
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue