diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js
index 9b06f204..9442b405 100644
--- a/assets/js/propagation_map_hook.js
+++ b/assets/js/propagation_map_hook.js
@@ -29,7 +29,73 @@ function factorBar(score) {
const filled = Math.round(score / 5)
const empty = 20 - filled
const tier = scoreTier(score)
- return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}`
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}`
+}
+
+function factorExplanation(key, score, detail) {
+ const beneficial = detail.humidity_effect === "beneficial"
+ const band = detail.band_label || ""
+
+ switch (key) {
+ case "humidity":
+ if (beneficial) {
+ if (score >= 80) return "High moisture — strong ducting potential"
+ if (score >= 55) return "Moderate moisture — some ducting possible"
+ return "Dry air — ducting unlikely"
+ } else {
+ if (score >= 80) return "Low humidity — minimal absorption"
+ if (score >= 50) return "Moderate humidity — some absorption"
+ return "High humidity — significant absorption at this frequency"
+ }
+ case "time_of_day":
+ if (score >= 80) return "Sunrise window — peak inversion strength"
+ if (score >= 65) return "Evening/post-sunrise — inversions forming or eroding"
+ if (score >= 40) return "Night — gradual cooling, weak inversions"
+ return "Afternoon — convective mixing destroys inversions"
+ case "td_depression":
+ if (beneficial) {
+ if (score >= 75) return "Tight depression — moist boundary layer favors ducting"
+ if (score >= 50) return "Moderate spread — some moisture present"
+ return "Wide spread — dry air limits ducting formation"
+ } else {
+ if (score >= 80) return "Wide spread — dry air reduces absorption"
+ if (score >= 50) return "Moderate spread"
+ return "Tight depression — moisture increasing absorption"
+ }
+ case "refractivity":
+ if (score >= 80) return "Strong inversion gradient — ducting layer detected"
+ if (score >= 60) return "Moderate gradient — enhanced refraction"
+ if (score >= 45) return "Weak gradient — near standard atmosphere"
+ return "No significant inversion — standard refraction"
+ case "sky":
+ if (score >= 80) return "Clear skies — radiative cooling strengthens inversions"
+ if (score >= 50) return "Partly cloudy — some radiative cooling"
+ return "Overcast — clouds prevent inversion formation"
+ case "season":
+ if (beneficial) {
+ if (score >= 70) return "Peak ducting season (summer months)"
+ if (score >= 40) return "Transitional season — moderate ducting"
+ return "Weakest season for ducting at this frequency"
+ } else {
+ if (score >= 70) return "Peak season — cool, dry conditions"
+ if (score >= 40) return "Transitional season"
+ return "Summer humidity degrades propagation at this frequency"
+ }
+ case "wind":
+ if (score >= 80) return "Calm winds — inversions preserved"
+ if (score >= 55) return "Light winds — minor mixing"
+ return "Strong winds — turbulent mixing destroys inversions"
+ case "rain":
+ if (score >= 90) return "No rain — no path attenuation"
+ if (score >= 50) return "Light rain — minor attenuation"
+ return "Heavy rain — significant path loss"
+ case "pressure":
+ if (score >= 70) return "Rising pressure — increasing stability favors inversions"
+ if (score >= 55) return "Steady pressure — stable conditions"
+ return "Falling pressure — instability weakens inversions"
+ default:
+ return ""
+ }
}
function rangeEstimate(score, detail) {
@@ -40,12 +106,26 @@ function rangeEstimate(score, detail) {
return `<${Math.round(detail.typical_range_km * 0.4)} km`
}
-function buildPopupHTML(detail) {
+function buildLoadingHTML(detail) {
+ const tier = scoreTier(detail.score)
+ return `
+
+ ${detail.score}/100
+ ${tier.label}
+
+
+ Loading…
+
+
`
+}
+
+function buildPopupHTML(detail, viewshedLoading) {
const tier = scoreTier(detail.score)
const range = rangeEstimate(detail.score, detail)
const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
let rows = ""
+ let explanations = ""
for (const key of FACTOR_ORDER) {
const meta = FACTOR_META[key]
const value = detail.factors[key]
@@ -55,24 +135,39 @@ function buildPopupHTML(detail) {
${meta.label} |
${factorBar(value)} |
${value} |
- (${pct}%) |
+ (${pct}%) |
`
+ const expl = factorExplanation(key, value, detail)
+ const eTier = scoreTier(value)
+ explanations += `
+ \u25CF
+ ${meta.label}: ${expl}
+
`
}
- return `
-
+ const viewshedStatus = viewshedLoading
+ ? `
Computing terrain coverage…
`
+ : ""
+
+ return `
+
${detail.score}/100
${tier.label}
-
+
${detail.band_label} — Est. range: ${range} (CW)
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
-
+
+
+
Analysis
+ ${explanations}
+
+ ${viewshedStatus}
`
}
@@ -216,7 +311,6 @@ export const PropagationMap = {
this.scoreOverlay = null
this.scores = []
- this.detailPopup = L.popup({ maxWidth: 340 })
this.colorScale = [
{ min: 80, r: 0, g: 255, b: 163 },
@@ -269,9 +363,14 @@ export const PropagationMap = {
// Click on map to request viewshed + factor detail from server
this.map.on("click", (e) => {
this.rangeCircles.clearLayers()
+ this.viewshedLoading = true
+ this.lastDetail = null
+
+ // Remember click location for the center marker
+ this.clickedLatLng = [e.latlng.lat, e.latlng.lng]
// Show a temporary marker while computing
- L.circleMarker([e.latlng.lat, e.latlng.lng], {
+ L.circleMarker(this.clickedLatLng, {
radius: 6,
color: "#fff",
weight: 2,
@@ -283,25 +382,38 @@ export const PropagationMap = {
// Always request terrain viewshed
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })
- // Also request propagation detail if grid data exists
+ // Show panel immediately with whatever we have client-side
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
- if (basic) {
+ if (basic && this.detailPanel) {
+ const merged = { ...basic, ...this.bandInfo, factors: null }
+ this.detailPanel.innerHTML = buildLoadingHTML(merged)
+ this.detailPanel.style.display = "block"
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
}
})
+ // Detail panel below sidebar instead of map popup
+ this.detailPanel = document.getElementById("detail-panel")
+ this.viewshedLoading = false
+ this.lastDetail = null
+
this.handleEvent("point_detail", (detail) => {
- if (detail) {
+ if (detail && this.detailPanel) {
const merged = { ...detail, ...this.bandInfo }
- this.detailPopup
- .setLatLng([detail.lat, detail.lon])
- .setContent(buildPopupHTML(merged))
- .openOn(this.map)
+ this.lastDetail = merged
+ this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
+ this.detailPanel.style.display = "block"
}
})
this.handleEvent("viewshed_result", ({ origin, points }) => {
this.rangeCircles.clearLayers()
+ this.viewshedLoading = false
+
+ // Re-render panel without loading indicator
+ if (this.lastDetail && this.detailPanel) {
+ this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false)
+ }
if (points && points.length > 0) {
const latlngs = points.map(p => [p.lat, p.lon])
@@ -315,26 +427,23 @@ export const PropagationMap = {
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)
}
+
+ // Always redraw center marker at clicked location
+ const loc = this.clickedLatLng || [origin.lat, origin.lon]
+ const basic = this.lookupPoint(loc[0], loc[1])
+ const markerColor = basic ? scoreTier(basic.score).color : "#888"
+
+ L.circleMarker(loc, {
+ 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
- this.map.on("dblclick", () => this.map.closePopup())
this.el.addEventListener("show-loading", () => topbar.show(300))
diff --git a/lib/microwaveprop/terrain/viewshed.ex b/lib/microwaveprop/terrain/viewshed.ex
index ba848805..e789ec0c 100644
--- a/lib/microwaveprop/terrain/viewshed.ex
+++ b/lib/microwaveprop/terrain/viewshed.ex
@@ -51,6 +51,48 @@ defmodule Microwaveprop.Terrain.Viewshed do
end
end
+ @doc """
+ Compute effective reach accounting for both terrain and propagation conditions.
+ Higher propagation scores indicate ducting potential, which lets signals
+ propagate beyond terrain obstructions via atmospheric waveguide.
+ """
+ def effective_reach_km(analysis, max_range_km, score) do
+ case analysis.verdict do
+ "CLEAR" ->
+ max_range_km
+
+ "FRESNEL_MINOR" ->
+ max_range_km * 0.9
+
+ "FRESNEL_PARTIAL" ->
+ max_range_km * 0.7
+
+ "BLOCKED" ->
+ terrain_factor = terrain_reach_factor(analysis.diffraction_db)
+ ducting_factor = ducting_reach_factor(score)
+ max_range_km * max(terrain_factor, ducting_factor)
+ end
+ end
+
+ # Convert diffraction loss to a range reduction factor (0.0–1.0).
+ # Mild diffraction still allows significant propagation;
+ # heavy blockage attenuates sharply.
+ defp terrain_reach_factor(db) when db <= 3, do: 0.8
+ defp terrain_reach_factor(db) when db <= 6, do: 0.5
+ defp terrain_reach_factor(db) when db <= 12, do: 0.3
+ defp terrain_reach_factor(db) when db <= 20, do: 0.15
+ defp terrain_reach_factor(_db), do: 0.05
+
+ # Higher propagation scores mean stronger ducting potential —
+ # signals can ride atmospheric layers over terrain obstacles.
+ # Returns a floor factor so blocked paths still get range
+ # proportional to atmospheric conditions.
+ defp ducting_reach_factor(score) when score >= 80, do: 0.7
+ defp ducting_reach_factor(score) when score >= 65, do: 0.5
+ defp ducting_reach_factor(score) when score >= 50, do: 0.3
+ defp ducting_reach_factor(score) when score >= 33, do: 0.15
+ defp ducting_reach_factor(_score), do: 0.05
+
@doc """
Compute a terrain viewshed from a point. Returns a map with :origin
and :boundary (list of %{bearing, reach_km, lat, lon}).
@@ -66,6 +108,7 @@ defmodule Microwaveprop.Terrain.Viewshed 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)
+ score = Keyword.get(opts, :score, 50)
angular_step = Keyword.get(opts, :angular_step, @default_angular_step)
tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir())
@@ -75,7 +118,7 @@ defmodule Microwaveprop.Terrain.Viewshed do
bearings
|> Task.async_stream(
fn bearing ->
- compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir)
+ compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir)
end,
max_concurrency: System.schedulers_online(),
timeout: 30_000,
@@ -100,26 +143,37 @@ defmodule Microwaveprop.Terrain.Viewshed do
%{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)
+ defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir) do
+ # Check terrain within the radio horizon where terrain features matter.
+ # Beyond this, propagation is atmospheric (ducting/scatter) and terrain
+ # doesn't block — earth curvature is handled by the atmosphere.
+ terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km)
+ {end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_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)
+ analysis = TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_height_m, ant_height_m)
+ reach_km = effective_reach_km(analysis, max_range_km, score)
+ {reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km)
%{
bearing: bearing,
- reach_km: result.reach_km,
+ reach_km: reach_km,
lat: reach_lat,
lon: reach_lon
}
{:error, _} ->
- %{bearing: bearing, reach_km: max_range_km, lat: end_lat, lon: end_lon}
+ {end_lat2, end_lon2} = destination_point(origin_lat, origin_lon, bearing, max_range_km)
+ %{bearing: bearing, reach_km: max_range_km, lat: end_lat2, lon: end_lon2}
end
end
+ # Radio horizon distance in km for a given antenna height (K=4/3 atmosphere)
+ defp radio_horizon_km(height_m) do
+ :math.sqrt(2 * (4 / 3) * @earth_radius_km * 1000 * height_m) / 1000
+ end
+
defp srtm_tiles_dir do
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
end
diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex
index 8df90855..e2ace4fa 100644
--- a/lib/microwaveprop_web/live/map_live.ex
+++ b/lib/microwaveprop_web/live/map_live.ex
@@ -35,7 +35,7 @@ defmodule MicrowavepropWeb.MapLive do
valid_time: valid_time,
bounds: @initial_bounds,
grid_visible: false,
- antenna_height_ft: 8
+ antenna_height_ft: 33
)}
end
@@ -91,17 +91,27 @@ defmodule MicrowavepropWeb.MapLive do
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
+ band_mhz = socket.assigns.selected_band
+ band_config = BandConfig.get(band_mhz)
+ freq_ghz = band_mhz / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
- max_range_km = min(band_config.typical_range_km, 100)
+
+ # Use propagation score to determine atmospheric range potential
+ score =
+ case Propagation.point_detail(band_mhz, lat, lon) do
+ %{score: s} -> s
+ _ -> 50
+ end
+
+ max_range_km = score_range_km(score, band_config)
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
+ max_range_km: max_range_km,
+ score: score
)
end)
@@ -139,6 +149,16 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
+ defp score_range_km(score, config) do
+ cond do
+ score >= 80 -> config.exceptional_range_km
+ score >= 65 -> config.extended_range_km
+ score >= 50 -> config.typical_range_km
+ score >= 33 -> round(config.typical_range_km * 0.6)
+ true -> round(config.typical_range_km * 0.3)
+ end
+ end
+
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
@@ -146,7 +166,9 @@ defmodule MicrowavepropWeb.MapLive do
band_label: config.label,
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
- exceptional_range_km: config.exceptional_range_km
+ exceptional_range_km: config.exceptional_range_km,
+ humidity_effect: to_string(config.humidity_effect),
+ freq_mhz: config.freq_mhz
}
end
@@ -257,6 +279,14 @@ defmodule MicrowavepropWeb.MapLive do
+
+ <%!-- Point detail panel (populated by JS on click) --%>
+
+