Rework viewshed: score-aware NLOS range, sidebar detail panel, factor explanations

- Viewshed uses propagation score to set max range (ducting/NLOS distance)
- Terrain checked within radio horizon; clear paths get full atmospheric range
- Detail panel moves from map popup to sidebar for better visibility
- Panel shows instantly on click with loading state
- Dark opaque background for readability over map
- Factor analysis section explains each score with contextual descriptions
This commit is contained in:
Graham McIntire 2026-03-31 13:49:42 -05:00
parent 6e5409bcd9
commit ff3a879dff
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 239 additions and 46 deletions

View file

@ -29,7 +29,73 @@ function factorBar(score) {
const filled = Math.round(score / 5) const filled = Math.round(score / 5)
const empty = 20 - filled const empty = 20 - filled
const tier = scoreTier(score) const tier = scoreTier(score)
return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:#555">${"\u2591".repeat(empty)}</span>` return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
}
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) { function rangeEstimate(score, detail) {
@ -40,12 +106,26 @@ function rangeEstimate(score, detail) {
return `<${Math.round(detail.typical_range_km * 0.4)} km` return `<${Math.round(detail.typical_range_km * 0.4)} km`
} }
function buildPopupHTML(detail) { function buildLoadingHTML(detail) {
const tier = scoreTier(detail.score)
return `<div style="min-width:260px;">
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;">
<span class="loading loading-spinner loading-xs"></span> Loading&hellip;
</div>
</div>`
}
function buildPopupHTML(detail, viewshedLoading) {
const tier = scoreTier(detail.score) const tier = scoreTier(detail.score)
const range = rangeEstimate(detail.score, detail) const range = rangeEstimate(detail.score, detail)
const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
let rows = "" let rows = ""
let explanations = ""
for (const key of FACTOR_ORDER) { for (const key of FACTOR_ORDER) {
const meta = FACTOR_META[key] const meta = FACTOR_META[key]
const value = detail.factors[key] const value = detail.factors[key]
@ -55,24 +135,39 @@ function buildPopupHTML(detail) {
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td> <td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
<td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td> <td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td> <td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
<td style="padding:1px 4px;font-size:10px;color:#888;">(${pct}%)</td> <td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
</tr>` </tr>`
const expl = factorExplanation(key, value, detail)
const eTier = scoreTier(value)
explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
<span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
<span><strong>${meta.label}:</strong> ${expl}</span>
</div>`
} }
return `<div style="color:#333;min-width:280px;"> const viewshedStatus = viewshedLoading
<div style="background:${tier.color};color:#000;padding:6px 10px;margin:-1px -1px 0 -1px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;align-items:baseline;"> ? `<div style="padding:4px 10px 6px;font-size:11px;opacity:0.6;border-top:1px solid rgba(255,255,255,0.15);display:flex;align-items:center;gap:6px;"><span class="loading loading-spinner loading-xs"></span> Computing terrain coverage&hellip;</div>`
: ""
return `<div style="min-width:260px;">
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
<strong style="font-size:16px;">${detail.score}/100</strong> <strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span> <span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div> </div>
<div style="padding:6px 10px;"> <div style="padding:6px 10px;">
<div style="font-size:12px;color:#666;margin-bottom:6px;"> <div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
${detail.band_label} &mdash; Est. range: ${range} (CW)<br> ${detail.band_label} &mdash; Est. range: ${range} (CW)<br>
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time} ${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div> </div>
<table style="width:100%;border-collapse:collapse;border-top:1px solid #ddd;padding-top:4px;"> <table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">
${rows} ${rows}
</table> </table>
</div> </div>
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
${explanations}
</div>
${viewshedStatus}
</div>` </div>`
} }
@ -216,7 +311,6 @@ export const PropagationMap = {
this.scoreOverlay = null this.scoreOverlay = null
this.scores = [] this.scores = []
this.detailPopup = L.popup({ maxWidth: 340 })
this.colorScale = [ this.colorScale = [
{ min: 80, r: 0, g: 255, b: 163 }, { 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 // Click on map to request viewshed + factor detail from server
this.map.on("click", (e) => { this.map.on("click", (e) => {
this.rangeCircles.clearLayers() 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 // Show a temporary marker while computing
L.circleMarker([e.latlng.lat, e.latlng.lng], { L.circleMarker(this.clickedLatLng, {
radius: 6, radius: 6,
color: "#fff", color: "#fff",
weight: 2, weight: 2,
@ -283,25 +382,38 @@ export const PropagationMap = {
// Always request terrain viewshed // Always request terrain viewshed
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng }) 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) 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 }) 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) => { this.handleEvent("point_detail", (detail) => {
if (detail) { if (detail && this.detailPanel) {
const merged = { ...detail, ...this.bandInfo } const merged = { ...detail, ...this.bandInfo }
this.detailPopup this.lastDetail = merged
.setLatLng([detail.lat, detail.lon]) this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
.setContent(buildPopupHTML(merged)) this.detailPanel.style.display = "block"
.openOn(this.map)
} }
}) })
this.handleEvent("viewshed_result", ({ origin, points }) => { this.handleEvent("viewshed_result", ({ origin, points }) => {
this.rangeCircles.clearLayers() 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) { if (points && points.length > 0) {
const latlngs = points.map(p => [p.lat, p.lon]) const latlngs = points.map(p => [p.lat, p.lon])
@ -315,26 +427,23 @@ export const PropagationMap = {
interactive: false, interactive: false,
smoothFactor: 1 smoothFactor: 1
}).addTo(this.rangeCircles) }).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)) this.el.addEventListener("show-loading", () => topbar.show(300))

View file

@ -51,6 +51,48 @@ defmodule Microwaveprop.Terrain.Viewshed do
end end
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.01.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 """ @doc """
Compute a terrain viewshed from a point. Returns a map with :origin Compute a terrain viewshed from a point. Returns a map with :origin
and :boundary (list of %{bearing, reach_km, lat, lon}). 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) freq_ghz = Keyword.get(opts, :freq_ghz, 10.0)
max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km) max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km)
ant_height_m = Keyword.get(opts, :ant_height_m, 2.4) 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) angular_step = Keyword.get(opts, :angular_step, @default_angular_step)
tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir()) tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir())
@ -75,7 +118,7 @@ defmodule Microwaveprop.Terrain.Viewshed do
bearings bearings
|> Task.async_stream( |> Task.async_stream(
fn bearing -> 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, end,
max_concurrency: System.schedulers_online(), max_concurrency: System.schedulers_online(),
timeout: 30_000, timeout: 30_000,
@ -100,26 +143,37 @@ defmodule Microwaveprop.Terrain.Viewshed do
%{reach_km: reach_km, verdict: analysis.verdict} %{reach_km: reach_km, verdict: analysis.verdict}
end end
defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir) do defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir) do
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, max_range_km) # 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 case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do
{:ok, profile} -> {:ok, profile} ->
result = analyse_ray(profile, max_range_km, freq_ghz, ant_height_m, ant_height_m) analysis = TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_height_m, ant_height_m)
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, result.reach_km) 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, bearing: bearing,
reach_km: result.reach_km, reach_km: reach_km,
lat: reach_lat, lat: reach_lat,
lon: reach_lon lon: reach_lon
} }
{:error, _} -> {: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
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 defp srtm_tiles_dir do
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles")) Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
end end

View file

@ -35,7 +35,7 @@ defmodule MicrowavepropWeb.MapLive do
valid_time: valid_time, valid_time: valid_time,
bounds: @initial_bounds, bounds: @initial_bounds,
grid_visible: false, grid_visible: false,
antenna_height_ft: 8 antenna_height_ft: 33
)} )}
end end
@ -91,17 +91,27 @@ defmodule MicrowavepropWeb.MapLive do
end end
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
band_config = BandConfig.get(socket.assigns.selected_band) band_mhz = socket.assigns.selected_band
freq_ghz = socket.assigns.selected_band / 1_000 band_config = BandConfig.get(band_mhz)
freq_ghz = band_mhz / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048 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 = socket =
start_async(socket, :viewshed, fn -> start_async(socket, :viewshed, fn ->
Viewshed.compute(lat, lon, Viewshed.compute(lat, lon,
freq_ghz: freq_ghz, freq_ghz: freq_ghz,
ant_height_m: ant_height_m, ant_height_m: ant_height_m,
max_range_km: max_range_km max_range_km: max_range_km,
score: score
) )
end) end)
@ -139,6 +149,16 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket} {:noreply, socket}
end 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 defp band_info(band_mhz) do
config = BandConfig.get(band_mhz) config = BandConfig.get(band_mhz)
@ -146,7 +166,9 @@ defmodule MicrowavepropWeb.MapLive do
band_label: config.label, band_label: config.label,
typical_range_km: config.typical_range_km, typical_range_km: config.typical_range_km,
extended_range_km: config.extended_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 end
@ -257,6 +279,14 @@ defmodule MicrowavepropWeb.MapLive do
</.link> </.link>
</div> </div>
</div> </div>
<%!-- Point detail panel (populated by JS on click) --%>
<div
id="detail-panel"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 overflow-hidden"
style="display:none;"
>
</div>
</div> </div>
</div> </div>