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 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…
|
||||||
|
</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…</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} — Est. range: ${range} (CW)<br>
|
${detail.band_label} — Est. range: ${range} (CW)<br>
|
||||||
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
|
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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 """
|
@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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue