Rover planner: auto-compute coverage areas with terrain + propagation
Rewrites rover planner to automatically color the map after stations are entered. No manual stop selection needed — the system evaluates every Maidenhead grid in range and scores them by: - SRTM terrain analysis to each station (diffraction loss, LOS verdict) - HRRR propagation forecast (ducting, refractivity, atmospheric conditions) - Distance to stations vs band range limits - Propagation boost: good conditions (score 80+) make blocked paths viable through ducting, matching the app's core beyond-LOS prediction Coverage score formula: boosted path quality 30% + propagation 30% + distance 20% + station count 20%. Colored grid squares on the map show best (green) to worst (red) operating positions. Sidebar shows top 10 grids ranked with: workable station count, propagation score, best operating hour, 18-hour forecast sparkline. Click a grid for per-station detail: distance, terrain verdict, diffraction loss. URL stores stations + band for sharing: /rover?stations=W5ISP,K5TR&band=10000
This commit is contained in:
parent
902563510f
commit
9ee2b9dd1b
2 changed files with 383 additions and 329 deletions
|
|
@ -1,11 +1,8 @@
|
||||||
export const RoverMap = {
|
export const RoverMap = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.stations = []
|
this.stations = []
|
||||||
this.stops = []
|
|
||||||
this.stationMarkers = L.layerGroup()
|
this.stationMarkers = L.layerGroup()
|
||||||
this.stopMarkers = L.layerGroup()
|
this.coverageLayer = L.layerGroup()
|
||||||
this.routeLine = null
|
|
||||||
this.terrainLines = L.layerGroup()
|
|
||||||
|
|
||||||
const map = L.map(this.el, {
|
const map = L.map(this.el, {
|
||||||
center: [33.2, -97.1],
|
center: [33.2, -97.1],
|
||||||
|
|
@ -20,30 +17,15 @@ export const RoverMap = {
|
||||||
|
|
||||||
this.map = map
|
this.map = map
|
||||||
this.stationMarkers.addTo(map)
|
this.stationMarkers.addTo(map)
|
||||||
this.stopMarkers.addTo(map)
|
this.coverageLayer.addTo(map)
|
||||||
this.terrainLines.addTo(map)
|
|
||||||
|
|
||||||
// Draw Maidenhead grid overlay
|
|
||||||
this.drawGridOverlay()
|
|
||||||
|
|
||||||
// Click to add stop
|
|
||||||
map.on("click", (e) => {
|
|
||||||
this.pushEvent("add_stop", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle server events
|
|
||||||
this.handleEvent("stations_updated", ({ stations }) => {
|
this.handleEvent("stations_updated", ({ stations }) => {
|
||||||
this.stations = stations
|
this.stations = stations
|
||||||
this.renderStations()
|
this.renderStations()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.handleEvent("stops_updated", ({ stops }) => {
|
this.handleEvent("coverage_updated", ({ grids }) => {
|
||||||
this.stops = stops
|
this.renderCoverage(grids)
|
||||||
this.renderStops()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.handleEvent("stop_terrain", ({ index, reachable }) => {
|
|
||||||
this.renderTerrainLines(index, reachable)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -57,7 +39,8 @@ export const RoverMap = {
|
||||||
weight: 2,
|
weight: 2,
|
||||||
fillColor: "#3b82f6",
|
fillColor: "#3b82f6",
|
||||||
fillOpacity: 0.9,
|
fillOpacity: 0.9,
|
||||||
interactive: true
|
interactive: true,
|
||||||
|
pane: "markerPane"
|
||||||
})
|
})
|
||||||
|
|
||||||
marker.bindTooltip(s.label, {
|
marker.bindTooltip(s.label, {
|
||||||
|
|
@ -73,121 +56,105 @@ export const RoverMap = {
|
||||||
this.fitBounds()
|
this.fitBounds()
|
||||||
},
|
},
|
||||||
|
|
||||||
renderStops() {
|
renderCoverage(grids) {
|
||||||
this.stopMarkers.clearLayers()
|
this.coverageLayer.clearLayers()
|
||||||
this.terrainLines.clearLayers()
|
|
||||||
|
|
||||||
if (this.routeLine) {
|
if (!grids || grids.length === 0) return
|
||||||
this.map.removeLayer(this.routeLine)
|
|
||||||
this.routeLine = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeCoords = []
|
const maxScore = Math.max(...grids.map(g => g.coverage_score))
|
||||||
|
|
||||||
for (const stop of this.stops) {
|
for (const g of grids) {
|
||||||
routeCoords.push([stop.lat, stop.lon])
|
// 4-char Maidenhead: 2° lon × 1° lat
|
||||||
|
// Compute grid square bounds from the grid letters
|
||||||
|
const bounds = this.gridBounds(g.grid)
|
||||||
|
if (!bounds) continue
|
||||||
|
|
||||||
const color = stop.score >= 65 ? "#00ffa3" : stop.score >= 50 ? "#ffe566" : stop.score >= 33 ? "#ff9044" : "#ff4f4f"
|
const normalized = maxScore > 0 ? g.coverage_score / maxScore : 0
|
||||||
|
const color = this.coverageColor(normalized)
|
||||||
|
const opacity = 0.15 + normalized * 0.45
|
||||||
|
|
||||||
const marker = L.circleMarker([stop.lat, stop.lon], {
|
const rect = L.rectangle(bounds, {
|
||||||
radius: 10,
|
color: color,
|
||||||
color: "#fff",
|
weight: 1,
|
||||||
weight: 3,
|
opacity: 0.5,
|
||||||
fillColor: color,
|
fillColor: color,
|
||||||
fillOpacity: 0.9,
|
fillOpacity: opacity,
|
||||||
interactive: true
|
interactive: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Number label
|
const pctStations = Math.round(g.stations_in_range / g.total_stations * 100)
|
||||||
|
rect.bindTooltip(
|
||||||
|
`<b>${g.grid}</b><br>` +
|
||||||
|
`Coverage: ${g.coverage_score}/100<br>` +
|
||||||
|
`Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%)<br>` +
|
||||||
|
`Propagation: ${g.prop_score}/100` +
|
||||||
|
(g.best_hour ? `<br>Best: ${g.best_hour} UTC` : ""),
|
||||||
|
{ sticky: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
rect.on("click", () => {
|
||||||
|
this.pushEvent("select_grid", { grid: g.grid })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.coverageLayer.addLayer(rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rank labels to top 5
|
||||||
|
const top5 = grids.slice(0, 5)
|
||||||
|
for (let i = 0; i < top5.length; i++) {
|
||||||
|
const g = top5[i]
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
background: ${color};
|
background: ${this.coverageColor(1 - i * 0.15)};
|
||||||
color: #000;
|
color: #000;
|
||||||
width: 22px; height: 22px;
|
width: 24px; height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-weight: bold; font-size: 11px;
|
font-weight: bold; font-size: 12px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
||||||
">${stop.index + 1}</div>`,
|
">${i + 1}</div>`,
|
||||||
className: "",
|
className: "",
|
||||||
iconSize: [22, 22],
|
iconSize: [24, 24],
|
||||||
iconAnchor: [11, 11]
|
iconAnchor: [12, 12]
|
||||||
})
|
})
|
||||||
|
|
||||||
const iconMarker = L.marker([stop.lat, stop.lon], { icon, interactive: false })
|
L.marker([g.lat, g.lon], { icon, interactive: false, pane: "markerPane" })
|
||||||
|
.addTo(this.coverageLayer)
|
||||||
iconMarker.bindTooltip(`${stop.grid} — Score: ${stop.score || "?"}, ${stop.reachable_count} workable`, {
|
|
||||||
direction: "top",
|
|
||||||
offset: [0, -14]
|
|
||||||
})
|
|
||||||
|
|
||||||
this.stopMarkers.addLayer(marker)
|
|
||||||
this.stopMarkers.addLayer(iconMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route line
|
|
||||||
if (routeCoords.length >= 2) {
|
|
||||||
this.routeLine = L.polyline(routeCoords, {
|
|
||||||
color: "#fff",
|
|
||||||
weight: 3,
|
|
||||||
opacity: 0.7,
|
|
||||||
dashArray: "8 6"
|
|
||||||
}).addTo(this.map)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renderTerrainLines(stopIndex, reachable) {
|
gridBounds(grid) {
|
||||||
const stop = this.stops[stopIndex]
|
// Decode 4-char Maidenhead to lat/lon bounds
|
||||||
if (!stop) return
|
if (grid.length < 4) return null
|
||||||
|
const lonField = grid.charCodeAt(0) - 65 // A=0
|
||||||
|
const latField = grid.charCodeAt(1) - 65
|
||||||
|
const lonSq = parseInt(grid[2])
|
||||||
|
const latSq = parseInt(grid[3])
|
||||||
|
|
||||||
for (const r of reachable) {
|
const lon1 = lonField * 20 - 180 + lonSq * 2
|
||||||
const color = r.workable ? "#00ffa3" : r.verdict === "BLOCKED" ? "#ff4f4f" : "#ffe566"
|
const lat1 = latField * 10 - 90 + latSq * 1
|
||||||
const line = L.polyline([[stop.lat, stop.lon], [r.lat, r.lon]], {
|
const lon2 = lon1 + 2
|
||||||
color,
|
const lat2 = lat1 + 1
|
||||||
weight: 1.5,
|
|
||||||
opacity: 0.5,
|
|
||||||
dashArray: r.workable ? null : "4 4"
|
|
||||||
})
|
|
||||||
|
|
||||||
line.bindTooltip(`${r.label}: ${r.dist_km} km, ${r.verdict || "?"}${r.diffraction_db > 0 ? ` (${r.diffraction_db} dB)` : ""}`)
|
return [[lat1, lon1], [lat2, lon2]]
|
||||||
|
|
||||||
this.terrainLines.addLayer(line)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
drawGridOverlay() {
|
coverageColor(normalized) {
|
||||||
const gridLayer = L.layerGroup()
|
// Green (best) → Yellow → Red (worst)
|
||||||
|
if (normalized >= 0.7) return "#00ffa3"
|
||||||
// Draw 4-char Maidenhead grid lines (2° lon x 1° lat)
|
if (normalized >= 0.5) return "#7dffd4"
|
||||||
for (let lat = 25; lat <= 50; lat += 1) {
|
if (normalized >= 0.3) return "#ffe566"
|
||||||
L.polyline([[lat, -125], [lat, -66]], {
|
if (normalized >= 0.15) return "#ff9044"
|
||||||
color: "#888",
|
return "#ff4f4f"
|
||||||
weight: 0.5,
|
|
||||||
opacity: 0.3
|
|
||||||
}).addTo(gridLayer)
|
|
||||||
}
|
|
||||||
for (let lon = -125; lon <= -66; lon += 2) {
|
|
||||||
L.polyline([[25, lon], [50, lon]], {
|
|
||||||
color: "#888",
|
|
||||||
weight: 0.5,
|
|
||||||
opacity: 0.3
|
|
||||||
}).addTo(gridLayer)
|
|
||||||
}
|
|
||||||
|
|
||||||
gridLayer.addTo(this.map)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
fitBounds() {
|
fitBounds() {
|
||||||
const allPoints = [
|
const points = this.stations.map(s => [s.lat, s.lon])
|
||||||
...this.stations.map(s => [s.lat, s.lon]),
|
if (points.length >= 2) {
|
||||||
...this.stops.map(s => [s.lat, s.lon])
|
this.map.fitBounds(points, { padding: [60, 60], maxZoom: 9 })
|
||||||
]
|
} else if (points.length === 1) {
|
||||||
|
this.map.setView(points[0], 8)
|
||||||
if (allPoints.length >= 2) {
|
|
||||||
this.map.fitBounds(allPoints, { padding: [40, 40], maxZoom: 10 })
|
|
||||||
} else if (allPoints.length === 1) {
|
|
||||||
this.map.setView(allPoints[0], 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
defmodule MicrowavepropWeb.RoverLive do
|
defmodule MicrowavepropWeb.RoverLive do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Rover planner: enter stationary stations you want to work, select a band,
|
||||||
|
and the map colors areas by coverage quality — how many stations you can
|
||||||
|
reach from each point given current terrain and propagation conditions.
|
||||||
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
alias Microwaveprop.Geo
|
alias Microwaveprop.Geo
|
||||||
|
|
@ -28,15 +32,37 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
band: 10_000,
|
band: 10_000,
|
||||||
stations: [],
|
stations: [],
|
||||||
station_input: "",
|
station_input: "",
|
||||||
stops: [],
|
coverage: [],
|
||||||
selected_stop: nil,
|
selected_grid: nil,
|
||||||
stop_detail: nil,
|
|
||||||
resolving: false,
|
resolving: false,
|
||||||
computing_stop: false
|
computing: false,
|
||||||
|
url_loaded: false
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Station Management ──
|
@impl true
|
||||||
|
def handle_params(params, _uri, socket) do
|
||||||
|
if socket.assigns.url_loaded do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band
|
||||||
|
|
||||||
|
socket = assign(socket, band: band, url_loaded: true)
|
||||||
|
|
||||||
|
# Load stations from URL: ?stations=W5ISP,K5TR,EM00cd
|
||||||
|
case params["stations"] do
|
||||||
|
nil ->
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
stations_str ->
|
||||||
|
calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
|
||||||
|
send(self(), {:resolve_station_list, calls})
|
||||||
|
{:noreply, assign(socket, resolving: true)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Events ──
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("add_station", %{"callsign" => input}, socket) do
|
def handle_event("add_station", %{"callsign" => input}, socket) do
|
||||||
|
|
@ -53,134 +79,245 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
||||||
idx = String.to_integer(idx_str)
|
idx = String.to_integer(idx_str)
|
||||||
stations = List.delete_at(socket.assigns.stations, idx)
|
stations = List.delete_at(socket.assigns.stations, idx)
|
||||||
{:noreply, socket |> assign(stations: stations) |> push_stations()}
|
socket = assign(socket, stations: stations, coverage: [], selected_grid: nil)
|
||||||
|
if length(stations) >= 2, do: send(self(), :compute_coverage)
|
||||||
|
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
|
||||||
|
{:noreply, push_url(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_band", %{"band" => band_str}, socket) do
|
def handle_event("select_band", %{"band" => band_str}, socket) do
|
||||||
{:noreply, socket |> assign(band: String.to_integer(band_str)) |> push_stations()}
|
socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil)
|
||||||
|
if length(socket.assigns.stations) >= 2, do: send(self(), :compute_coverage)
|
||||||
|
{:noreply, push_url(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Stop Management (from JS map clicks) ──
|
def handle_event("select_grid", %{"grid" => grid}, socket) do
|
||||||
|
detail = Enum.find(socket.assigns.coverage, &(&1.grid == grid))
|
||||||
def handle_event("add_stop", %{"lat" => lat, "lon" => lon}, socket) do
|
{:noreply, assign(socket, selected_grid: detail)}
|
||||||
grid = Geo.latlon_to_grid4(lat, lon)
|
|
||||||
{clat, clon} = Geo.maidenhead_center(grid) || {lat, lon}
|
|
||||||
|
|
||||||
stop = %{
|
|
||||||
grid: grid,
|
|
||||||
lat: clat,
|
|
||||||
lon: clon,
|
|
||||||
score: nil,
|
|
||||||
forecast: [],
|
|
||||||
reachable: []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get propagation score
|
|
||||||
score_detail = Propagation.point_detail(socket.assigns.band, clat, clon)
|
|
||||||
forecast = Propagation.point_forecast(socket.assigns.band, clat, clon)
|
|
||||||
|
|
||||||
stop = %{stop | score: score_detail && score_detail.score, forecast: forecast}
|
|
||||||
|
|
||||||
stops = socket.assigns.stops ++ [stop]
|
|
||||||
|
|
||||||
# Async compute terrain to each station
|
|
||||||
send(self(), {:compute_stop_terrain, length(stops) - 1})
|
|
||||||
|
|
||||||
{:noreply, socket |> assign(stops: stops, computing_stop: true) |> push_stops()}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("remove_stop", %{"index" => idx_str}, socket) do
|
# ── Async ──
|
||||||
idx = String.to_integer(idx_str)
|
|
||||||
stops = List.delete_at(socket.assigns.stops, idx)
|
|
||||||
|
|
||||||
selected =
|
def handle_info({:resolve_station_list, calls}, socket) do
|
||||||
if socket.assigns.selected_stop == idx, do: nil, else: socket.assigns.selected_stop
|
stations =
|
||||||
|
calls
|
||||||
|
|> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000)
|
||||||
|
|> Enum.flat_map(fn
|
||||||
|
{:ok, {:ok, s}} -> [s]
|
||||||
|
_ -> []
|
||||||
|
end)
|
||||||
|
|
||||||
{:noreply, socket |> assign(stops: stops, selected_stop: selected, stop_detail: nil) |> push_stops()}
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(stations: stations, resolving: false)
|
||||||
|
|> push_event("stations_updated", %{stations: station_data(stations)})
|
||||||
|
|
||||||
|
if length(stations) >= 2, do: send(self(), :compute_coverage)
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_stop", %{"index" => idx_str}, socket) do
|
|
||||||
idx = String.to_integer(idx_str)
|
|
||||||
stop = Enum.at(socket.assigns.stops, idx)
|
|
||||||
|
|
||||||
{:noreply, assign(socket, selected_stop: idx, stop_detail: stop)}
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Async Handlers ──
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:resolve_station, input}, socket) do
|
def handle_info({:resolve_station, input}, socket) do
|
||||||
result = resolve_station(input)
|
case resolve_station(input) do
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, station} ->
|
{:ok, station} ->
|
||||||
stations = socket.assigns.stations ++ [station]
|
stations = socket.assigns.stations ++ [station]
|
||||||
{:noreply, socket |> assign(stations: stations, resolving: false) |> push_stations()}
|
socket = assign(socket, stations: stations, resolving: false)
|
||||||
|
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
|
||||||
|
if length(stations) >= 2, do: send(self(), :compute_coverage)
|
||||||
|
{:noreply, push_url(socket)}
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
|
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:compute_stop_terrain, stop_idx}, socket) do
|
def handle_info(:compute_coverage, socket) do
|
||||||
stop = Enum.at(socket.assigns.stops, stop_idx)
|
stations = socket.assigns.stations
|
||||||
|
band_mhz = socket.assigns.band
|
||||||
|
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
||||||
|
max_range = band_config.extended_range_km || 500
|
||||||
|
|
||||||
if is_nil(stop) do
|
socket = assign(socket, computing: true)
|
||||||
{:noreply, assign(socket, computing_stop: false)}
|
|
||||||
|
# Find the bounding box around all stations, expanded by max range
|
||||||
|
lats = Enum.map(stations, & &1.lat)
|
||||||
|
lons = Enum.map(stations, & &1.lon)
|
||||||
|
# ~1 degree ≈ 111 km
|
||||||
|
range_deg = max_range / 111.0
|
||||||
|
|
||||||
|
min_lat = max(Enum.min(lats) - range_deg, 25.0)
|
||||||
|
max_lat = min(Enum.max(lats) + range_deg, 50.0)
|
||||||
|
min_lon = max(Enum.min(lons) - range_deg, -125.0)
|
||||||
|
max_lon = min(Enum.max(lons) + range_deg, -66.0)
|
||||||
|
|
||||||
|
# Generate candidate grids (4-char Maidenhead = 2° lon × 1° lat)
|
||||||
|
for_result =
|
||||||
|
for lat <- Stream.iterate(Float.ceil(min_lat), &(&1 + 0.5)),
|
||||||
|
lat <= max_lat,
|
||||||
|
lon <- Stream.iterate(Float.ceil(min_lon), &(&1 + 1.0)),
|
||||||
|
lon <= max_lon do
|
||||||
|
grid = Geo.latlon_to_grid4(lat, lon)
|
||||||
|
{grid, lat, lon}
|
||||||
|
end
|
||||||
|
|
||||||
|
candidates =
|
||||||
|
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
|
||||||
|
|
||||||
|
# Score each candidate: how many stations in range × propagation quality
|
||||||
|
coverage =
|
||||||
|
candidates
|
||||||
|
|> Task.async_stream(
|
||||||
|
fn {grid, lat, lon} ->
|
||||||
|
score_grid(grid, lat, lon, stations, band_mhz, band_config, max_range)
|
||||||
|
end,
|
||||||
|
max_concurrency: System.schedulers_online(),
|
||||||
|
timeout: 30_000
|
||||||
|
)
|
||||||
|
|> Enum.flat_map(fn
|
||||||
|
{:ok, nil} -> []
|
||||||
|
{:ok, result} -> [result]
|
||||||
|
_ -> []
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(& &1.coverage_score, :desc)
|
||||||
|
|
||||||
|
# Push to JS for rendering
|
||||||
|
grid_data =
|
||||||
|
Enum.map(coverage, fn c ->
|
||||||
|
%{
|
||||||
|
grid: c.grid,
|
||||||
|
lat: c.lat,
|
||||||
|
lon: c.lon,
|
||||||
|
coverage_score: c.coverage_score,
|
||||||
|
stations_in_range: c.stations_in_range,
|
||||||
|
workable_count: c.workable_count,
|
||||||
|
total_stations: length(stations),
|
||||||
|
prop_score: c.prop_score,
|
||||||
|
best_hour: c.best_hour
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(coverage: coverage, computing: false, selected_grid: nil)
|
||||||
|
|> push_event("coverage_updated", %{grids: grid_data})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Coverage Scoring ──
|
||||||
|
|
||||||
|
defp score_grid(grid, lat, lon, stations, band_mhz, _band_config, max_range) do
|
||||||
|
freq_ghz = band_mhz / 1000
|
||||||
|
|
||||||
|
# Analyze terrain + distance to each station
|
||||||
|
station_analyses =
|
||||||
|
Enum.map(stations, fn s ->
|
||||||
|
dist = Geo.haversine_km(lat, lon, s.lat, s.lon)
|
||||||
|
in_range = dist <= max_range
|
||||||
|
|
||||||
|
# Run SRTM terrain analysis for in-range stations
|
||||||
|
{verdict, diffraction_db} =
|
||||||
|
if in_range do
|
||||||
|
case ElevationClient.fetch_elevation_profile(lat, lon, s.lat, s.lon, 64, download: true) do
|
||||||
|
{:ok, profile} ->
|
||||||
|
analysis = TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
||||||
|
{analysis.verdict, analysis.diffraction_db}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{nil, 0}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{nil, 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Path quality combines terrain AND propagation conditions.
|
||||||
|
# BLOCKED paths are still workable with ducting/enhanced propagation —
|
||||||
|
# that's the whole point of this app. Higher propagation score at this
|
||||||
|
# grid means atmospheric conditions can overcome terrain blockage.
|
||||||
|
path_quality =
|
||||||
|
case verdict do
|
||||||
|
"CLEAR" -> 1.0
|
||||||
|
"FRESNEL_MINOR" -> 0.9
|
||||||
|
"FRESNEL_PARTIAL" -> 0.7
|
||||||
|
"BLOCKED" when diffraction_db < 10 -> 0.5
|
||||||
|
"BLOCKED" when diffraction_db < 20 -> 0.35
|
||||||
|
"BLOCKED" when diffraction_db < 40 -> 0.2
|
||||||
|
"BLOCKED" -> 0.1
|
||||||
|
_ -> 0.4
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
label: s.label,
|
||||||
|
lat: s.lat,
|
||||||
|
lon: s.lon,
|
||||||
|
dist_km: dist,
|
||||||
|
in_range: in_range,
|
||||||
|
verdict: verdict,
|
||||||
|
diffraction_db: diffraction_db && Float.round(diffraction_db, 1),
|
||||||
|
path_quality: path_quality
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
in_range = Enum.filter(station_analyses, & &1.in_range)
|
||||||
|
|
||||||
|
if in_range == [] do
|
||||||
|
nil
|
||||||
else
|
else
|
||||||
stations = socket.assigns.stations
|
# Path-quality-weighted station score: combines terrain + atmospheric potential
|
||||||
band_config = BandConfig.get(socket.assigns.band) || BandConfig.get(10_000)
|
path_score = Enum.sum(Enum.map(in_range, & &1.path_quality)) / length(stations)
|
||||||
freq_ghz = socket.assigns.band / 1000
|
|
||||||
|
|
||||||
reachable =
|
# Distance factor
|
||||||
stations
|
distance_factor =
|
||||||
|> Task.async_stream(
|
in_range
|
||||||
fn station ->
|
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|
||||||
dist = Geo.haversine_km(stop.lat, stop.lon, station.lat, station.lon)
|
|> then(&(Enum.sum(&1) / length(stations)))
|
||||||
bearing = Geo.bearing_deg(stop.lat, stop.lon, station.lat, station.lon)
|
|
||||||
|
|
||||||
terrain =
|
# Propagation + ducting: best score in next 12 hours from HRRR forecast
|
||||||
case ElevationClient.fetch_elevation_profile(stop.lat, stop.lon, station.lat, station.lon, 64,
|
forecast = Propagation.point_forecast(band_mhz, lat, lon)
|
||||||
download: true
|
|
||||||
) do
|
|
||||||
{:ok, profile} ->
|
|
||||||
TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
|
||||||
|
|
||||||
{:error, _} ->
|
{prop_score, best_hour} =
|
||||||
nil
|
case forecast do
|
||||||
end
|
[_ | _] ->
|
||||||
|
best = Enum.max_by(forecast, & &1.score)
|
||||||
|
{best.score, Calendar.strftime(best.valid_time, "%H:%M")}
|
||||||
|
|
||||||
in_range = dist <= (band_config.extended_range_km || 500)
|
_ ->
|
||||||
|
{50, nil}
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
# Combined: path quality (terrain + ducting potential) 30%
|
||||||
label: station.label,
|
# + propagation forecast 30%
|
||||||
lat: station.lat,
|
# + distance factor 20%
|
||||||
lon: station.lon,
|
# + station count 20%
|
||||||
dist_km: Float.round(dist, 1),
|
# Propagation score amplifies blocked-path viability: score 80+ means
|
||||||
bearing: bearing,
|
# ducting conditions that can overcome 30+ dB of terrain blockage.
|
||||||
verdict: terrain && terrain.verdict,
|
station_pct = length(in_range) / length(stations)
|
||||||
diffraction_db: terrain && Float.round(terrain.diffraction_db, 1),
|
workable_count = Enum.count(in_range, &(&1.path_quality >= 0.2))
|
||||||
in_range: in_range,
|
|
||||||
workable: in_range and terrain != nil and terrain.verdict != "BLOCKED"
|
# Boost path_quality by propagation: good conditions make blocked paths viable
|
||||||
}
|
prop_boost = prop_score / 100
|
||||||
end,
|
boosted_path_score = min(1.0, path_score + prop_boost * 0.3)
|
||||||
max_concurrency: 4,
|
|
||||||
timeout: 15_000
|
coverage_score =
|
||||||
|
round(
|
||||||
|
boosted_path_score * 30 +
|
||||||
|
prop_boost * 30 +
|
||||||
|
distance_factor * 20 +
|
||||||
|
station_pct * 20
|
||||||
)
|
)
|
||||||
|> Enum.map(fn
|
|
||||||
{:ok, r} -> r
|
|
||||||
_ -> nil
|
|
||||||
end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
stop = %{stop | reachable: reachable}
|
%{
|
||||||
stops = List.replace_at(socket.assigns.stops, stop_idx, stop)
|
grid: grid,
|
||||||
|
lat: lat,
|
||||||
{:noreply,
|
lon: lon,
|
||||||
socket
|
coverage_score: coverage_score,
|
||||||
|> assign(stops: stops, computing_stop: false)
|
stations_in_range: length(in_range),
|
||||||
|> push_stops()
|
workable_count: workable_count,
|
||||||
|> push_event("stop_terrain", %{index: stop_idx, reachable: reachable})}
|
prop_score: prop_score,
|
||||||
|
best_hour: best_hour,
|
||||||
|
station_details: station_analyses,
|
||||||
|
forecast: forecast
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -208,69 +345,39 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp push_stations(socket) do
|
defp push_url(socket) do
|
||||||
data =
|
labels = Enum.map_join(socket.assigns.stations, ",", & &1.label)
|
||||||
Enum.map(socket.assigns.stations, fn s ->
|
|
||||||
%{label: s.label, lat: s.lat, lon: s.lon}
|
params =
|
||||||
|
then(%{"band" => to_string(socket.assigns.band)}, fn p ->
|
||||||
|
if labels == "", do: p, else: Map.put(p, "stations", labels)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
push_event(socket, "stations_updated", %{stations: data})
|
push_patch(socket, to: ~p"/rover?#{params}", replace: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp push_stops(socket) do
|
defp station_data(stations) do
|
||||||
data =
|
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end)
|
||||||
Enum.with_index(socket.assigns.stops, fn stop, idx ->
|
|
||||||
%{
|
|
||||||
index: idx,
|
|
||||||
grid: stop.grid,
|
|
||||||
lat: stop.lat,
|
|
||||||
lon: stop.lon,
|
|
||||||
score: stop.score,
|
|
||||||
reachable_count: Enum.count(stop.reachable, & &1.workable)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
push_event(socket, "stops_updated", %{stops: data})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp total_driving_km(stops) do
|
|
||||||
stops
|
|
||||||
|> Enum.chunk_every(2, 1, :discard)
|
|
||||||
|> Enum.map(fn [a, b] -> Geo.haversine_km(a.lat, a.lon, b.lat, b.lon) end)
|
|
||||||
|> Enum.sum()
|
|
||||||
|> Float.round(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp total_workable(stops) do
|
|
||||||
stops
|
|
||||||
|> Enum.flat_map(fn stop ->
|
|
||||||
stop.reachable
|
|
||||||
|> Enum.filter(& &1.workable)
|
|
||||||
|> Enum.map(fn r -> {stop.grid, r.label} end)
|
|
||||||
end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
|> length()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp tier_color(score) when score >= 80, do: "#00ffa3"
|
|
||||||
defp tier_color(score) when score >= 65, do: "#7dffd4"
|
|
||||||
defp tier_color(score) when score >= 50, do: "#ffe566"
|
|
||||||
defp tier_color(score) when score >= 33, do: "#ff9044"
|
|
||||||
defp tier_color(_), do: "#ff4f4f"
|
|
||||||
|
|
||||||
defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm"
|
defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm"
|
||||||
defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm"
|
defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm"
|
||||||
defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm"
|
defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm"
|
||||||
defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm"
|
defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm"
|
||||||
defp verdict_badge(_), do: "badge badge-sm"
|
defp verdict_badge(_), do: "badge badge-sm"
|
||||||
|
|
||||||
|
defp tier_color(score) when score >= 80, do: "#00ffa3"
|
||||||
|
defp tier_color(score) when score >= 65, do: "#7dffd4"
|
||||||
|
defp tier_color(score) when score >= 50, do: "#ffe566"
|
||||||
|
defp tier_color(score) when score >= 33, do: "#ff9044"
|
||||||
|
defp tier_color(_), do: "#ff4f4f"
|
||||||
|
|
||||||
# ── Render ──
|
# ── Render ──
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="relative w-screen h-screen overflow-hidden">
|
<div class="relative w-screen h-screen overflow-hidden">
|
||||||
<%!-- Map --%>
|
|
||||||
<div
|
<div
|
||||||
id="rover-map"
|
id="rover-map"
|
||||||
phx-hook="RoverMap"
|
phx-hook="RoverMap"
|
||||||
|
|
@ -284,18 +391,12 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
|
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
|
||||||
<div class="font-bold text-sm">Rover Planner</div>
|
<div class="font-bold text-sm">Rover Planner</div>
|
||||||
|
|
||||||
<%!-- Band selector --%>
|
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
|
||||||
<select
|
|
||||||
phx-change="select_band"
|
|
||||||
name="band"
|
|
||||||
class="select select-bordered select-sm w-full"
|
|
||||||
>
|
|
||||||
<%= for {label, value} <- @band_options do %>
|
<%= for {label, value} <- @band_options do %>
|
||||||
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<%!-- Add station --%>
|
|
||||||
<form phx-submit="add_station" class="flex gap-1">
|
<form phx-submit="add_station" class="flex gap-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -313,7 +414,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%!-- Station list --%>
|
|
||||||
<%= if @stations != [] do %>
|
<%= if @stations != [] do %>
|
||||||
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
|
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
|
|
@ -333,58 +433,57 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Instructions --%>
|
<%= if @computing do %>
|
||||||
<%= if @stations != [] and @stops == [] do %>
|
<div class="text-xs text-center py-3">
|
||||||
<div class="text-xs opacity-50 text-center py-2">
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Click the map to add operating positions
|
<div class="mt-1">Computing coverage areas...</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Route stops --%>
|
<%= if @stations != [] and !@computing and @coverage == [] do %>
|
||||||
<%= if @stops != [] do %>
|
<div class="text-xs opacity-50 text-center py-2">
|
||||||
<div class="divider my-0"></div>
|
Add at least 2 stations to see coverage
|
||||||
<div class="text-xs opacity-60">
|
|
||||||
Route ({length(@stops)} stops, {total_driving_km(@stops)} km driving)
|
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= for {stop, idx} <- Enum.with_index(@stops) do %>
|
<%!-- Top grids ranked --%>
|
||||||
|
<%= if @coverage != [] do %>
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
<div class="text-xs opacity-60">Best Operating Grids</div>
|
||||||
|
|
||||||
|
<%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
|
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
|
||||||
@selected_stop == idx && "ring-2 ring-primary"
|
@selected_grid && @selected_grid.grid == grid.grid && "ring-2 ring-primary"
|
||||||
]}
|
]}
|
||||||
phx-click="select_stop"
|
phx-click="select_grid"
|
||||||
phx-value-index={idx}
|
phx-value-grid={grid.grid}
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-bold">
|
<div class="font-bold">
|
||||||
<span class="badge badge-sm badge-primary mr-1">{idx + 1}</span>
|
<span
|
||||||
{stop.grid}
|
class="inline-block w-5 h-5 rounded-full text-center leading-5 text-[10px] font-bold mr-1"
|
||||||
</div>
|
style={"background-color: #{tier_color(grid.coverage_score)}; color: #000"}
|
||||||
<div class="flex gap-1 items-center">
|
|
||||||
<%= if stop.score do %>
|
|
||||||
<span class="font-mono font-bold" style={"color: #{tier_color(stop.score)}"}>
|
|
||||||
{stop.score}
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="remove_stop"
|
|
||||||
phx-value-index={idx}
|
|
||||||
class="btn btn-ghost btn-xs opacity-50"
|
|
||||||
>
|
>
|
||||||
x
|
{rank}
|
||||||
</button>
|
</span>
|
||||||
|
{grid.grid}
|
||||||
|
</div>
|
||||||
|
<div class="font-mono font-bold" style={"color: #{tier_color(grid.coverage_score)}"}>
|
||||||
|
{grid.coverage_score}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= if stop.reachable != [] do %>
|
<div class="mt-1 flex justify-between opacity-70">
|
||||||
<div class="mt-1 opacity-70">
|
<span>{grid.workable_count}/{grid.total_stations} workable</span>
|
||||||
{Enum.count(stop.reachable, & &1.workable)}/{length(stop.reachable)} stations workable
|
<span>Prop: {grid.prop_score}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<%= if grid.best_hour do %>
|
||||||
|
<div class="opacity-50">Best at {grid.best_hour} UTC</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if stop.forecast != [] do %>
|
<%= if grid.forecast != [] do %>
|
||||||
<div class="flex items-end gap-px h-4 mt-1">
|
<div class="flex items-end gap-px h-3 mt-1">
|
||||||
<%= for point <- stop.forecast do %>
|
<%= for point <- grid.forecast do %>
|
||||||
<div
|
<div
|
||||||
class="flex-1 rounded-t-sm"
|
class="flex-1 rounded-t-sm"
|
||||||
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
|
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
|
||||||
|
|
@ -396,41 +495,29 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Route summary --%>
|
|
||||||
<div class="bg-primary/10 rounded-lg p-2 text-xs">
|
|
||||||
<div class="font-bold">Route Summary</div>
|
|
||||||
<div>Unique grid-station pairs: {total_workable(@stops)}</div>
|
|
||||||
<div>Total driving: {total_driving_km(@stops)} km</div>
|
|
||||||
<div>
|
|
||||||
Est. drive time: {Float.round(total_driving_km(@stops) * 1.5 / 100, 1)} hrs
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Selected stop detail --%>
|
<%!-- Selected grid detail --%>
|
||||||
<%= if @stop_detail do %>
|
<%= if @selected_grid do %>
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
<div class="text-xs opacity-60">
|
<div class="text-xs opacity-60">
|
||||||
Stop Detail: {@stop_detail.grid}
|
{@selected_grid.grid} — Station Distances
|
||||||
</div>
|
</div>
|
||||||
<%= if @computing_stop do %>
|
<%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
|
||||||
<div class="text-xs text-center py-2">
|
|
||||||
<span class="loading loading-spinner loading-xs"></span> Computing terrain...
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= for r <- @stop_detail.reachable do %>
|
|
||||||
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
|
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-bold">{r.label}</span>
|
<span class="font-bold">{s.label}</span>
|
||||||
<span class="opacity-50 ml-1">{r.dist_km} km / {r.bearing}°</span>
|
<span class="opacity-50 ml-1">{Float.round(s.dist_km, 0)} km</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex gap-1 items-center">
|
||||||
<%= if r.verdict do %>
|
<%= if s.verdict do %>
|
||||||
<span class={verdict_badge(r.verdict)}>{r.verdict}</span>
|
<span class={verdict_badge(s.verdict)}>{s.verdict}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if r.diffraction_db && r.diffraction_db > 0 do %>
|
<%= if s.diffraction_db && s.diffraction_db > 0 do %>
|
||||||
<span class="opacity-50 ml-1">{r.diffraction_db} dB</span>
|
<span class="opacity-50 text-[10px]">{s.diffraction_db} dB</span>
|
||||||
|
<% end %>
|
||||||
|
<%= if !s.in_range do %>
|
||||||
|
<span class="badge badge-error badge-sm">out of range</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue