diff --git a/assets/js/app.js b/assets/js/app.js
index 96c78e6b..85ebc662 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -36,6 +36,7 @@ import {ContactsMap} from "./contacts_map_hook"
import {ElevationProfile} from "./elevation_profile_hook"
import {WeatherMap} from "./weather_map_hook"
import {LocateMe, CopyLink} from "./locate_me_hook"
+import {RoverMap} from "./rover_map_hook"
const UtcClock = {
mounted() {
@@ -57,7 +58,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
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
diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.js
new file mode 100644
index 00000000..c39701ef
--- /dev/null
+++ b/assets/js/rover_map_hook.js
@@ -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: `
${stop.index + 1}
`,
+ 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)
+ }
+ }
+}
diff --git a/lib/microwaveprop/geo.ex b/lib/microwaveprop/geo.ex
new file mode 100644
index 00000000..b0c430a7
--- /dev/null
+++ b/lib/microwaveprop/geo.ex
@@ -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)
+
+ <>
+ end
+
+ defp deg_to_rad(d), do: d * :math.pi() / 180
+end
diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex
index 0c24a22d..1459daad 100644
--- a/lib/microwaveprop_web/components/layouts.ex
+++ b/lib/microwaveprop_web/components/layouts.ex
@@ -42,6 +42,7 @@ defmodule MicrowavepropWeb.Layouts do