Add terrain-aware viewshed on map click

Replace generic range circles with actual LOS coverage polygon computed
from SRTM elevation data. Casts 180 rays (every 2 degrees) from the
clicked point, runs Fresnel/diffraction analysis on each, and renders
the reachable area as a Leaflet polygon.

- Viewshed module with haversine forward, terrain sweep, async compute
- Antenna height control (default 8 ft) in map panel
- LiveView start_async/handle_async for non-blocking computation
- Remove signal icon from band selector
This commit is contained in:
Graham McIntire 2026-03-31 13:06:01 -05:00
parent 166f95e29d
commit 5eaa55448e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 382 additions and 55 deletions

View file

@ -264,11 +264,26 @@ export const PropagationMap = {
}
})
// Click on map to request factor detail from server
// Click on map to request viewshed + factor detail from server
this.map.on("click", (e) => {
this.rangeCircles.clearLayers()
// Show a temporary marker while computing
L.circleMarker([e.latlng.lat, e.latlng.lng], {
radius: 6,
color: "#fff",
weight: 2,
fillColor: "#888",
fillOpacity: 0.8,
interactive: false
}).addTo(this.rangeCircles)
// Always request terrain viewshed
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })
// Also request propagation detail if grid data exists
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
if (basic) {
this.showRangeCircles(basic)
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
}
})
@ -283,6 +298,37 @@ export const PropagationMap = {
}
})
this.handleEvent("viewshed_result", ({ origin, points }) => {
this.rangeCircles.clearLayers()
if (points && points.length > 0) {
const latlngs = points.map(p => [p.lat, p.lon])
L.polygon(latlngs, {
color: "#222",
weight: 2,
opacity: 0.8,
fillColor: "#000",
fillOpacity: 0.12,
interactive: false,
smoothFactor: 1
}).addTo(this.rangeCircles)
// Center marker colored by score tier if available
const basic = this.lookupPoint(origin.lat, origin.lon)
const markerColor = basic ? scoreTier(basic.score).color : "#888"
L.circleMarker([origin.lat, origin.lon], {
radius: 6,
color: "#fff",
weight: 2,
fillColor: markerColor,
fillOpacity: 1,
interactive: false
}).addTo(this.rangeCircles)
}
})
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
// Close popup on double-click so zoom works through it
@ -442,54 +488,6 @@ export const PropagationMap = {
return { ...data, ...this.bandInfo }
},
showRangeCircles(detail) {
this.rangeCircles.clearLayers()
const center = [detail.lat, detail.lon]
const score = detail.score
const tier = scoreTier(score)
let rangeKm
if (score >= 80) rangeKm = detail.exceptional_range_km
else if (score >= 65) rangeKm = detail.extended_range_km
else if (score >= 50) rangeKm = detail.typical_range_km
else if (score >= 33) rangeKm = Math.round(detail.typical_range_km * 0.6)
else rangeKm = Math.round(detail.typical_range_km * 0.3)
const typicalKm = detail.typical_range_km
L.circle(center, {
radius: rangeKm * 1000,
color: "#222",
weight: 3,
opacity: 0.9,
fillColor: "#000",
fillOpacity: 0.2,
dashArray: "8,6",
interactive: false
}).addTo(this.rangeCircles)
if (typicalKm < rangeKm) {
L.circle(center, {
radius: typicalKm * 1000,
color: "#222",
weight: 3,
opacity: 0.9,
fillColor: "#000",
fillOpacity: 0.25,
interactive: false
}).addTo(this.rangeCircles)
}
L.circleMarker(center, {
radius: 6,
color: "#fff",
weight: 2,
fillColor: tier.color,
fillOpacity: 1,
interactive: false
}).addTo(this.rangeCircles)
},
sendBounds() {
topbar.show()
const b = this.map.getBounds()

View file

@ -0,0 +1,133 @@
defmodule Microwaveprop.Terrain.Viewshed do
@moduledoc false
alias Microwaveprop.Terrain.Srtm
alias Microwaveprop.Terrain.TerrainAnalysis
@earth_radius_km 6371.0
@default_angular_step 2
@default_max_range_km 50
@doc "Compute destination lat/lon given origin, bearing (degrees), and distance (km)."
def destination_point(lat, lon, bearing_deg, dist_km) do
lat_rad = deg_to_rad(lat)
lon_rad = deg_to_rad(lon)
brg_rad = deg_to_rad(bearing_deg)
d = dist_km / @earth_radius_km
lat2 =
:math.asin(
:math.sin(lat_rad) * :math.cos(d) +
:math.cos(lat_rad) * :math.sin(d) * :math.cos(brg_rad)
)
lon2 =
lon_rad +
:math.atan2(
:math.sin(brg_rad) * :math.sin(d) * :math.cos(lat_rad),
:math.cos(d) - :math.sin(lat_rad) * :math.sin(lat2)
)
{rad_to_deg(lat2), rad_to_deg(lon2)}
end
@doc """
Find the max clear distance along a ray from TerrainAnalysis points.
Skips endpoints (first/last), returns the dist_km of the last clear
interior point before the first obstruction.
"""
def find_reach_km(points, max_range_km) do
interior = Enum.slice(points, 1..-2//1)
case find_first_obstructed_index(interior) do
nil ->
max_range_km
0 ->
0.0
idx ->
Enum.at(interior, idx - 1).dist_km
end
end
@doc """
Compute a terrain viewshed from a point. Returns a map with :origin
and :boundary (list of %{bearing, reach_km, lat, lon}).
Options:
- :freq_ghz frequency for Fresnel zone calc (default 10.0)
- :max_range_km max ray distance (default 50)
- :ant_height_m antenna height at both ends (default 2.4)
- :angular_step degrees between rays (default 2)
- :tiles_dir SRTM tiles directory (default from config)
"""
def compute(lat, lon, opts \\ []) do
freq_ghz = Keyword.get(opts, :freq_ghz, 10.0)
max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km)
ant_height_m = Keyword.get(opts, :ant_height_m, 2.4)
angular_step = Keyword.get(opts, :angular_step, @default_angular_step)
tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir())
bearings = Enum.to_list(0..359//angular_step)
boundary =
bearings
|> Task.async_stream(
fn bearing ->
compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir)
end,
max_concurrency: System.schedulers_online(),
timeout: 30_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, _} -> nil
end)
|> Enum.reject(&is_nil/1)
%{origin: %{lat: lat, lon: lon}, boundary: boundary}
end
@doc """
Analyse a single ray's profile. Public for testing.
Returns %{reach_km: float, verdict: string}.
"""
def analyse_ray(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m) do
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m)
reach_km = find_reach_km(analysis.points, dist_km)
%{reach_km: reach_km, verdict: analysis.verdict}
end
defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir) do
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, max_range_km)
case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do
{:ok, profile} ->
result = analyse_ray(profile, max_range_km, freq_ghz, ant_height_m, ant_height_m)
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, result.reach_km)
%{
bearing: bearing,
reach_km: result.reach_km,
lat: reach_lat,
lon: reach_lon
}
{:error, _} ->
%{bearing: bearing, reach_km: max_range_km, lat: end_lat, lon: end_lon}
end
end
defp srtm_tiles_dir do
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
end
defp find_first_obstructed_index(interior) do
Enum.find_index(interior, & &1.obstructed)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
end

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.MapLive do
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Terrain.Viewshed
@default_band 10_000
@initial_bounds %{
@ -31,7 +32,8 @@ defmodule MicrowavepropWeb.MapLive do
initial_scores_json: Jason.encode!(initial_scores),
valid_time: valid_time,
bounds: @initial_bounds,
grid_visible: false
grid_visible: false,
antenna_height_ft: 8
)}
end
@ -49,6 +51,16 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
height =
case Integer.parse(value) do
{h, _} when h >= 0 and h <= 200 -> h
_ -> 8
end
{:noreply, assign(socket, :antenna_height_ft, height)}
end
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
@ -76,6 +88,24 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
band_config = BandConfig.get(socket.assigns.selected_band)
freq_ghz = socket.assigns.selected_band / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
max_range_km = min(band_config.typical_range_km, 100)
socket =
start_async(socket, :viewshed, fn ->
Viewshed.compute(lat, lon,
freq_ghz: freq_ghz,
ant_height_m: ant_height_m,
max_range_km: max_range_km
)
end)
{:noreply, socket}
end
@impl true
def handle_info({:propagation_updated, valid_time}, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band, socket.assigns.bounds)
@ -88,6 +118,23 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
@impl true
def handle_async(:viewshed, {:ok, result}, socket) do
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
socket =
push_event(socket, "viewshed_result", %{
origin: result.origin,
points: points
})
{:noreply, socket}
end
def handle_async(:viewshed, {:exit, _reason}, socket) do
{:noreply, socket}
end
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
@ -143,10 +190,7 @@ defmodule MicrowavepropWeb.MapLive do
<%!-- Band selector --%>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
<span class="flex items-center gap-1.5">
<.icon name="hero-signal" class="size-4" />
{selected_label(@bands, @selected_band)}
</span>
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
@ -179,6 +223,21 @@ defmodule MicrowavepropWeb.MapLive do
<span class="text-sm">Grid squares</span>
</label>
<%!-- Antenna height --%>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<%!-- Latest data --%>
<div :if={@valid_time} class="text-xs opacity-70 px-1">
Data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})

View file

@ -0,0 +1,110 @@
defmodule Microwaveprop.Terrain.ViewshedTest do
use ExUnit.Case, async: true
alias Microwaveprop.Terrain.Viewshed
describe "destination_point/4" do
test "north bearing increases latitude, holds longitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 0, 100.0)
assert_in_delta lat, 32.899, 0.01
assert_in_delta lon, -97.0, 0.01
end
test "east bearing increases longitude, holds latitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 90, 100.0)
assert_in_delta lat, 32.0, 0.01
assert lon > -97.0
end
test "south bearing decreases latitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 180, 50.0)
assert lat < 32.0
assert_in_delta lon, -97.0, 0.01
end
test "zero distance returns origin" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 45, 0.0)
assert_in_delta lat, 32.0, 0.001
assert_in_delta lon, -97.0, 0.001
end
end
describe "find_reach_km/2" do
test "returns max range when no points are obstructed" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: false, dist_km: 30.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 50.0
end
test "returns distance of point before first obstruction" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: true, dist_km: 30.0},
%{obstructed: false, dist_km: 40.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 20.0
end
test "returns first interior point distance when obstruction is at second point" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: true, dist_km: 5.0},
%{obstructed: false, dist_km: 10.0}
]
# First point is endpoint (excluded), second is first interior and is obstructed
# No clear interior point before it, return minimum
assert Viewshed.find_reach_km(points, 10.0) == 0.0
end
test "ignores endpoint obstruction flags" do
# Endpoints (first/last) are never counted as obstructed by TerrainAnalysis
# but just to be safe, find_reach_km skips them
points = [
%{obstructed: true, dist_km: 0.0},
%{obstructed: false, dist_km: 25.0},
%{obstructed: true, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 50.0
end
end
describe "analyse_ray/5" do
test "returns full range for flat terrain with antenna heights" do
profile =
for i <- 0..10 do
f = i / 10
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: 200.0, dist_km: f * 10.0}
end
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
assert result.reach_km == 10.0
end
test "detects obstruction and returns reduced reach" do
# Use 10km total so earth bulge is negligible (~0.7m) and
# the 500m peak at index 5 is the only obstruction.
profile =
for i <- 0..10 do
f = i / 10
elev = if i == 5, do: 500.0, else: 200.0
%{lat: 32.9 + f * 0.009, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0}
end
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
assert result.reach_km < 10.0
assert result.reach_km > 0.0
end
end
end

View file

@ -58,6 +58,33 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
end
describe "antenna height" do
test "renders antenna height input with default 8 ft", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(name="height_ft")
assert html =~ ~s(value="8")
assert html =~ "ft"
end
test "updates antenna height on change", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
html = render_change(lv, "set_antenna_height", %{"height_ft" => "20"})
assert html =~ ~s(value="20")
end
end
describe "compute_viewshed" do
test "accepts compute_viewshed event without crashing", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
# Event should be accepted; async computation may fail (no SRTM in test)
# but the LiveView should not crash
render_click(lv, "compute_viewshed", %{"lat" => 32.9, "lon" => -97.0})
# If we get here without crash, the event handler exists
assert render(lv) =~ "propagation-map"
end
end
describe "toggle_grid" do
test "toggles grid visibility", %{conn: conn} do
{:ok, lv, html} = live(conn, ~p"/map")