Add /rover planner for microwave contest rovers
Interactive map-based tool for planning rover operating positions. Rovers enter stationary stations they want to work, then click the map to evaluate candidate operating grids. Features: - Add stationary stations by callsign or grid square - Click map to add operating positions (snaps to Maidenhead grid) - Async SRTM terrain analysis: each stop computes terrain profile to every station, shows CLEAR/BLOCKED verdict + diffraction loss - Terrain lines on map: green=workable, red=blocked, dashed=marginal - Propagation score and 18-hour forecast sparkline per stop - Route summary: driving distance, unique grid-station pairs - Band selector affects range limits and propagation scores - Numbered waypoint markers with score-colored backgrounds New files: - Geo module (shared haversine/bearing, used by PathLive too) - RoverLive with fullscreen map + sidebar - RoverMap JS hook with grid overlay, station/stop markers, route line - Nav links in header and map control panel
This commit is contained in:
parent
2f2f73809c
commit
7ffa20bee7
8 changed files with 693 additions and 22 deletions
|
|
@ -36,6 +36,7 @@ import {ContactsMap} from "./contacts_map_hook"
|
||||||
import {ElevationProfile} from "./elevation_profile_hook"
|
import {ElevationProfile} from "./elevation_profile_hook"
|
||||||
import {WeatherMap} from "./weather_map_hook"
|
import {WeatherMap} from "./weather_map_hook"
|
||||||
import {LocateMe, CopyLink} from "./locate_me_hook"
|
import {LocateMe, CopyLink} from "./locate_me_hook"
|
||||||
|
import {RoverMap} from "./rover_map_hook"
|
||||||
|
|
||||||
const UtcClock = {
|
const UtcClock = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -57,7 +58,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: initLiveStash({_csrf_token: csrfToken}),
|
params: initLiveStash({_csrf_token: csrfToken}),
|
||||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink},
|
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
|
|
|
||||||
193
assets/js/rover_map_hook.js
Normal file
193
assets/js/rover_map_hook.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
export const RoverMap = {
|
||||||
|
mounted() {
|
||||||
|
this.stations = []
|
||||||
|
this.stops = []
|
||||||
|
this.stationMarkers = L.layerGroup()
|
||||||
|
this.stopMarkers = L.layerGroup()
|
||||||
|
this.routeLine = null
|
||||||
|
this.terrainLines = L.layerGroup()
|
||||||
|
|
||||||
|
const map = L.map(this.el, {
|
||||||
|
center: [33.2, -97.1],
|
||||||
|
zoom: 7,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false
|
||||||
|
})
|
||||||
|
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
this.map = map
|
||||||
|
this.stationMarkers.addTo(map)
|
||||||
|
this.stopMarkers.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.stations = stations
|
||||||
|
this.renderStations()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.handleEvent("stops_updated", ({ stops }) => {
|
||||||
|
this.stops = stops
|
||||||
|
this.renderStops()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.handleEvent("stop_terrain", ({ index, reachable }) => {
|
||||||
|
this.renderTerrainLines(index, reachable)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStations() {
|
||||||
|
this.stationMarkers.clearLayers()
|
||||||
|
|
||||||
|
for (const s of this.stations) {
|
||||||
|
const marker = L.circleMarker([s.lat, s.lon], {
|
||||||
|
radius: 7,
|
||||||
|
color: "#fff",
|
||||||
|
weight: 2,
|
||||||
|
fillColor: "#3b82f6",
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
interactive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.bindTooltip(s.label, {
|
||||||
|
permanent: true,
|
||||||
|
direction: "top",
|
||||||
|
offset: [0, -10],
|
||||||
|
className: "station-label"
|
||||||
|
})
|
||||||
|
|
||||||
|
this.stationMarkers.addLayer(marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fitBounds()
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStops() {
|
||||||
|
this.stopMarkers.clearLayers()
|
||||||
|
this.terrainLines.clearLayers()
|
||||||
|
|
||||||
|
if (this.routeLine) {
|
||||||
|
this.map.removeLayer(this.routeLine)
|
||||||
|
this.routeLine = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeCoords = []
|
||||||
|
|
||||||
|
for (const stop of this.stops) {
|
||||||
|
routeCoords.push([stop.lat, stop.lon])
|
||||||
|
|
||||||
|
const color = stop.score >= 65 ? "#00ffa3" : stop.score >= 50 ? "#ffe566" : stop.score >= 33 ? "#ff9044" : "#ff4f4f"
|
||||||
|
|
||||||
|
const marker = L.circleMarker([stop.lat, stop.lon], {
|
||||||
|
radius: 10,
|
||||||
|
color: "#fff",
|
||||||
|
weight: 3,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
interactive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Number label
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `<div style="
|
||||||
|
background: ${color};
|
||||||
|
color: #000;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: bold; font-size: 11px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
">${stop.index + 1}</div>`,
|
||||||
|
className: "",
|
||||||
|
iconSize: [22, 22],
|
||||||
|
iconAnchor: [11, 11]
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMarker = L.marker([stop.lat, stop.lon], { icon, interactive: false })
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const stop = this.stops[stopIndex]
|
||||||
|
if (!stop) return
|
||||||
|
|
||||||
|
for (const r of reachable) {
|
||||||
|
const color = r.workable ? "#00ffa3" : r.verdict === "BLOCKED" ? "#ff4f4f" : "#ffe566"
|
||||||
|
const line = L.polyline([[stop.lat, stop.lon], [r.lat, r.lon]], {
|
||||||
|
color,
|
||||||
|
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)` : ""}`)
|
||||||
|
|
||||||
|
this.terrainLines.addLayer(line)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
drawGridOverlay() {
|
||||||
|
const gridLayer = L.layerGroup()
|
||||||
|
|
||||||
|
// Draw 4-char Maidenhead grid lines (2° lon x 1° lat)
|
||||||
|
for (let lat = 25; lat <= 50; lat += 1) {
|
||||||
|
L.polyline([[lat, -125], [lat, -66]], {
|
||||||
|
color: "#888",
|
||||||
|
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() {
|
||||||
|
const allPoints = [
|
||||||
|
...this.stations.map(s => [s.lat, s.lon]),
|
||||||
|
...this.stops.map(s => [s.lat, s.lon])
|
||||||
|
]
|
||||||
|
|
||||||
|
if (allPoints.length >= 2) {
|
||||||
|
this.map.fitBounds(allPoints, { padding: [40, 40], maxZoom: 10 })
|
||||||
|
} else if (allPoints.length === 1) {
|
||||||
|
this.map.setView(allPoints[0], 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/microwaveprop/geo.ex
Normal file
48
lib/microwaveprop/geo.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule Microwaveprop.Geo do
|
||||||
|
@moduledoc "Geodetic helper functions: distances, bearings, coordinate math."
|
||||||
|
|
||||||
|
@earth_radius_km 6371.0
|
||||||
|
|
||||||
|
@doc "Great-circle distance between two points in km."
|
||||||
|
def haversine_km(lat1, lon1, lat2, lon2) do
|
||||||
|
dlat = deg_to_rad(lat2 - lat1)
|
||||||
|
dlon = deg_to_rad(lon2 - lon1)
|
||||||
|
|
||||||
|
a =
|
||||||
|
:math.sin(dlat / 2) ** 2 +
|
||||||
|
:math.cos(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2
|
||||||
|
|
||||||
|
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Initial bearing from point 1 to point 2 in degrees (0-360)."
|
||||||
|
def bearing_deg(lat1, lon1, lat2, lon2) do
|
||||||
|
lat1r = deg_to_rad(lat1)
|
||||||
|
lat2r = deg_to_rad(lat2)
|
||||||
|
dlonr = deg_to_rad(lon2 - lon1)
|
||||||
|
y = :math.sin(dlonr) * :math.cos(lat2r)
|
||||||
|
x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr)
|
||||||
|
b = :math.atan2(y, x) * 180 / :math.pi()
|
||||||
|
Float.round(:math.fmod(b + 360, 360), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Center of a 4-character Maidenhead grid square."
|
||||||
|
def maidenhead_center(grid) when is_binary(grid) and byte_size(grid) >= 4 do
|
||||||
|
case Microwaveprop.Radio.Maidenhead.to_latlon(grid) do
|
||||||
|
{:ok, {lat, lon}} -> {lat, lon}
|
||||||
|
:error -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "4-character Maidenhead grid for a lat/lon."
|
||||||
|
def latlon_to_grid4(lat, lon) do
|
||||||
|
lon_field = trunc((lon + 180) / 20)
|
||||||
|
lat_field = trunc((lat + 90) / 10)
|
||||||
|
lon_sq = trunc(rem(trunc(lon + 180), 20) / 2)
|
||||||
|
lat_sq = trunc(rem(trunc(lat + 90), 10) / 1)
|
||||||
|
|
||||||
|
<<?A + lon_field, ?A + lat_field, ?0 + lon_sq, ?0 + lat_sq>>
|
||||||
|
end
|
||||||
|
|
||||||
|
defp deg_to_rad(d), do: d * :math.pi() / 180
|
||||||
|
end
|
||||||
|
|
@ -42,6 +42,7 @@ defmodule MicrowavepropWeb.Layouts do
|
||||||
<nav class="flex gap-2">
|
<nav class="flex gap-2">
|
||||||
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
||||||
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
|
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
|
||||||
|
<.link navigate="/rover" class="btn btn-ghost btn-sm">Rover</.link>
|
||||||
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
||||||
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
||||||
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,9 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
||||||
Path Calculator
|
Path Calculator
|
||||||
</.link>
|
</.link>
|
||||||
|
<.link navigate="/rover" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Rover Planner
|
||||||
|
</.link>
|
||||||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
||||||
Weather Map
|
Weather Map
|
||||||
</.link>
|
</.link>
|
||||||
|
|
|
||||||
|
|
@ -432,27 +432,8 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Geo helpers ──
|
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
||||||
|
defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
||||||
defp haversine_km(lat1, lon1, lat2, lon2) do
|
|
||||||
r = 6371.0
|
|
||||||
dlat = deg_to_rad(lat2 - lat1)
|
|
||||||
dlon = deg_to_rad(lon2 - lon1)
|
|
||||||
a = :math.sin(dlat / 2) ** 2 + :math.cos(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2
|
|
||||||
2 * r * :math.asin(:math.sqrt(a))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp bearing_deg(lat1, lon1, lat2, lon2) do
|
|
||||||
lat1r = deg_to_rad(lat1)
|
|
||||||
lat2r = deg_to_rad(lat2)
|
|
||||||
dlonr = deg_to_rad(lon2 - lon1)
|
|
||||||
y = :math.sin(dlonr) * :math.cos(lat2r)
|
|
||||||
x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr)
|
|
||||||
b = :math.atan2(y, x) * 180 / :math.pi()
|
|
||||||
Float.round(:math.fmod(b + 360, 360), 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp deg_to_rad(d), do: d * :math.pi() / 180
|
|
||||||
|
|
||||||
# ── Score tier helpers ──
|
# ── Score tier helpers ──
|
||||||
|
|
||||||
|
|
|
||||||
443
lib/microwaveprop_web/live/rover_live.ex
Normal file
443
lib/microwaveprop_web/live/rover_live.ex
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverLive do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
alias Microwaveprop.Geo
|
||||||
|
alias Microwaveprop.Propagation
|
||||||
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
alias Microwaveprop.Radio.CallsignClient
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||||
|
|
||||||
|
@band_options [
|
||||||
|
{"10 GHz", "10000"},
|
||||||
|
{"24 GHz", "24000"},
|
||||||
|
{"47 GHz", "47000"},
|
||||||
|
{"76 GHz", "75000"},
|
||||||
|
{"122 GHz", "122000"},
|
||||||
|
{"241 GHz", "241000"}
|
||||||
|
]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
assign(socket,
|
||||||
|
page_title: "Rover Planner",
|
||||||
|
band_options: @band_options,
|
||||||
|
band: 10_000,
|
||||||
|
stations: [],
|
||||||
|
station_input: "",
|
||||||
|
stops: [],
|
||||||
|
selected_stop: nil,
|
||||||
|
stop_detail: nil,
|
||||||
|
resolving: false,
|
||||||
|
computing_stop: false
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Station Management ──
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("add_station", %{"callsign" => input}, socket) do
|
||||||
|
input = String.trim(input)
|
||||||
|
|
||||||
|
if input == "" do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
send(self(), {:resolve_station, input})
|
||||||
|
{:noreply, assign(socket, resolving: true, station_input: "")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
||||||
|
idx = String.to_integer(idx_str)
|
||||||
|
stations = List.delete_at(socket.assigns.stations, idx)
|
||||||
|
{:noreply, socket |> assign(stations: stations) |> push_stations()}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_band", %{"band" => band_str}, socket) do
|
||||||
|
{:noreply, socket |> assign(band: String.to_integer(band_str)) |> push_stations()}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Stop Management (from JS map clicks) ──
|
||||||
|
|
||||||
|
def handle_event("add_stop", %{"lat" => lat, "lon" => lon}, socket) do
|
||||||
|
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
|
||||||
|
|
||||||
|
def handle_event("remove_stop", %{"index" => idx_str}, socket) do
|
||||||
|
idx = String.to_integer(idx_str)
|
||||||
|
stops = List.delete_at(socket.assigns.stops, idx)
|
||||||
|
|
||||||
|
selected =
|
||||||
|
if socket.assigns.selected_stop == idx, do: nil, else: socket.assigns.selected_stop
|
||||||
|
|
||||||
|
{:noreply, socket |> assign(stops: stops, selected_stop: selected, stop_detail: nil) |> push_stops()}
|
||||||
|
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
|
||||||
|
def handle_info({:resolve_station, input}, socket) do
|
||||||
|
result = resolve_station(input)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, station} ->
|
||||||
|
stations = socket.assigns.stations ++ [station]
|
||||||
|
{:noreply, socket |> assign(stations: stations, resolving: false) |> push_stations()}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:compute_stop_terrain, stop_idx}, socket) do
|
||||||
|
stop = Enum.at(socket.assigns.stops, stop_idx)
|
||||||
|
|
||||||
|
if is_nil(stop) do
|
||||||
|
{:noreply, assign(socket, computing_stop: false)}
|
||||||
|
else
|
||||||
|
stations = socket.assigns.stations
|
||||||
|
band_config = BandConfig.get(socket.assigns.band) || BandConfig.get(10_000)
|
||||||
|
freq_ghz = socket.assigns.band / 1000
|
||||||
|
|
||||||
|
reachable =
|
||||||
|
stations
|
||||||
|
|> Task.async_stream(
|
||||||
|
fn station ->
|
||||||
|
dist = Geo.haversine_km(stop.lat, stop.lon, station.lat, station.lon)
|
||||||
|
bearing = Geo.bearing_deg(stop.lat, stop.lon, station.lat, station.lon)
|
||||||
|
|
||||||
|
terrain =
|
||||||
|
case ElevationClient.fetch_elevation_profile(stop.lat, stop.lon, station.lat, station.lon, 64,
|
||||||
|
download: true
|
||||||
|
) do
|
||||||
|
{:ok, profile} ->
|
||||||
|
TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
in_range = dist <= (band_config.extended_range_km || 500)
|
||||||
|
|
||||||
|
%{
|
||||||
|
label: station.label,
|
||||||
|
lat: station.lat,
|
||||||
|
lon: station.lon,
|
||||||
|
dist_km: Float.round(dist, 1),
|
||||||
|
bearing: bearing,
|
||||||
|
verdict: terrain && terrain.verdict,
|
||||||
|
diffraction_db: terrain && Float.round(terrain.diffraction_db, 1),
|
||||||
|
in_range: in_range,
|
||||||
|
workable: in_range and terrain != nil and terrain.verdict != "BLOCKED"
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
max_concurrency: 4,
|
||||||
|
timeout: 15_000
|
||||||
|
)
|
||||||
|
|> 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)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(stops: stops, computing_stop: false)
|
||||||
|
|> push_stops()
|
||||||
|
|> push_event("stop_terrain", %{index: stop_idx, reachable: reachable})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helpers ──
|
||||||
|
|
||||||
|
defp resolve_station(input) do
|
||||||
|
input = String.trim(input)
|
||||||
|
|
||||||
|
if Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) and String.length(input) >= 4 do
|
||||||
|
case Maidenhead.to_latlon(input) do
|
||||||
|
{:ok, {lat, lon}} ->
|
||||||
|
{:ok, %{label: String.upcase(input), lat: lat, lon: lon, type: :grid}}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, "invalid grid"}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
case CallsignClient.locate(input) do
|
||||||
|
{:ok, info} ->
|
||||||
|
{:ok, %{label: String.upcase(info.callsign), lat: info.lat, lon: info.lon, type: :callsign}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp push_stations(socket) do
|
||||||
|
data =
|
||||||
|
Enum.map(socket.assigns.stations, fn s ->
|
||||||
|
%{label: s.label, lat: s.lat, lon: s.lon}
|
||||||
|
end)
|
||||||
|
|
||||||
|
push_event(socket, "stations_updated", %{stations: data})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp push_stops(socket) do
|
||||||
|
data =
|
||||||
|
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
|
||||||
|
|
||||||
|
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("FRESNEL_MINOR"), 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(_), do: "badge badge-sm"
|
||||||
|
|
||||||
|
# ── Render ──
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="relative w-screen h-screen overflow-hidden">
|
||||||
|
<%!-- Map --%>
|
||||||
|
<div
|
||||||
|
id="rover-map"
|
||||||
|
phx-hook="RoverMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-band={@band}
|
||||||
|
class="absolute inset-0 z-0"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Sidebar --%>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<%!-- Band selector --%>
|
||||||
|
<select
|
||||||
|
phx-change="select_band"
|
||||||
|
name="band"
|
||||||
|
class="select select-bordered select-sm w-full"
|
||||||
|
>
|
||||||
|
<%= for {label, value} <- @band_options do %>
|
||||||
|
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<%!-- Add station --%>
|
||||||
|
<form phx-submit="add_station" class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="callsign"
|
||||||
|
value={@station_input}
|
||||||
|
placeholder="Add station (call or grid)"
|
||||||
|
class="input input-bordered input-sm flex-1"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary" disabled={@resolving}>
|
||||||
|
<%= if @resolving do %>
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
<% else %>
|
||||||
|
Add
|
||||||
|
<% end %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<%!-- Station list --%>
|
||||||
|
<%= if @stations != [] do %>
|
||||||
|
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<%= for {station, idx} <- Enum.with_index(@stations) do %>
|
||||||
|
<div class="badge badge-outline gap-1">
|
||||||
|
{station.label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="remove_station"
|
||||||
|
phx-value-index={idx}
|
||||||
|
class="cursor-pointer opacity-50 hover:opacity-100"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Instructions --%>
|
||||||
|
<%= if @stations != [] and @stops == [] do %>
|
||||||
|
<div class="text-xs opacity-50 text-center py-2">
|
||||||
|
Click the map to add operating positions
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Route stops --%>
|
||||||
|
<%= if @stops != [] do %>
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
Route ({length(@stops)} stops, {total_driving_km(@stops)} km driving)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= for {stop, idx} <- Enum.with_index(@stops) do %>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
|
||||||
|
@selected_stop == idx && "ring-2 ring-primary"
|
||||||
|
]}
|
||||||
|
phx-click="select_stop"
|
||||||
|
phx-value-index={idx}
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="font-bold">
|
||||||
|
<span class="badge badge-sm badge-primary mr-1">{idx + 1}</span>
|
||||||
|
{stop.grid}
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= if stop.reachable != [] do %>
|
||||||
|
<div class="mt-1 opacity-70">
|
||||||
|
{Enum.count(stop.reachable, & &1.workable)}/{length(stop.reachable)} stations workable
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= if stop.forecast != [] do %>
|
||||||
|
<div class="flex items-end gap-px h-4 mt-1">
|
||||||
|
<%= for point <- stop.forecast do %>
|
||||||
|
<div
|
||||||
|
class="flex-1 rounded-t-sm"
|
||||||
|
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
|
||||||
|
title={"#{Calendar.strftime(point.valid_time, "%H:%M")} UTC: #{point.score}"}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% 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 %>
|
||||||
|
|
||||||
|
<%!-- Selected stop detail --%>
|
||||||
|
<%= if @stop_detail do %>
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
Stop Detail: {@stop_detail.grid}
|
||||||
|
</div>
|
||||||
|
<%= if @computing_stop 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>
|
||||||
|
<span class="font-bold">{r.label}</span>
|
||||||
|
<span class="opacity-50 ml-1">{r.dist_km} km / {r.bearing}°</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= if r.verdict do %>
|
||||||
|
<span class={verdict_badge(r.verdict)}>{r.verdict}</span>
|
||||||
|
<% end %>
|
||||||
|
<%= if r.diffraction_db && r.diffraction_db > 0 do %>
|
||||||
|
<span class="opacity-50 ml-1">{r.diffraction_db} dB</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -37,6 +37,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/contacts/map", ContactMapLive
|
live "/contacts/map", ContactMapLive
|
||||||
live "/contacts/:id", ContactLive.Show
|
live "/contacts/:id", ContactLive.Show
|
||||||
live "/path", PathLive
|
live "/path", PathLive
|
||||||
|
live "/rover", RoverLive
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
live "/backfill", BackfillLive
|
live "/backfill", BackfillLive
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue