diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js index d570eea3..2ab1a3d9 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.js @@ -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() diff --git a/lib/microwaveprop/terrain/viewshed.ex b/lib/microwaveprop/terrain/viewshed.ex new file mode 100644 index 00000000..ba848805 --- /dev/null +++ b/lib/microwaveprop/terrain/viewshed.ex @@ -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 diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 5ae72626..e0496cb4 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 --%>