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 empty = 20 - filled
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) {
@ -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 `<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 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) {
<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;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>`
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;">
<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;">
const viewshedStatus = viewshedLoading
? `<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>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<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.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
</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}
</table>
</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>`
}
@ -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))

View file

@ -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.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 """
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

View file

@ -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
</.link>
</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>