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