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:
parent
6e5409bcd9
commit
ff3a879dff
3 changed files with 239 additions and 46 deletions
|
|
@ -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…
|
||||
</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…</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} — Est. range: ${range} (CW)<br>
|
||||
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue