Highlight likely propagation duct, improve contact detail layout

- Mark duct closest to endpoint elevations as likely signal path
- Likely duct drawn with solid borders, bold label, stronger fill
- Other ducts shown dimmed with dashed borders
- Fix path info layout: table columns prevent El wrapping
- Surface observations as compact table, limited to 5 nearest stations
This commit is contained in:
Graham McIntire 2026-04-01 13:31:56 -05:00
parent 5f8c689035
commit 4aaa028ed1
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 55 additions and 20 deletions

View file

@ -22,12 +22,17 @@ const ductPlugin = {
const drawBase = Math.min(yBase, bottom)
if (drawTop >= drawBase) continue
ctx.fillStyle = "rgba(34, 197, 94, 0.12)"
const likely = duct.likely
const fillAlpha = likely ? 0.18 : 0.08
const strokeAlpha = likely ? 0.8 : 0.3
const lineWidth = likely ? 1.5 : 1
ctx.fillStyle = `rgba(34, 197, 94, ${fillAlpha})`
ctx.fillRect(left, drawTop, right - left, drawBase - drawTop)
ctx.strokeStyle = "rgba(34, 197, 94, 0.5)"
ctx.lineWidth = 1
ctx.setLineDash([4, 3])
ctx.strokeStyle = `rgba(34, 197, 94, ${strokeAlpha})`
ctx.lineWidth = lineWidth
ctx.setLineDash(likely ? [] : [4, 3])
ctx.beginPath()
ctx.moveTo(left, drawTop)
ctx.lineTo(right, drawTop)
@ -38,13 +43,15 @@ const ductPlugin = {
// Label
const labelY = Math.max(drawTop + 12, top + 12)
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
ctx.font = "9px sans-serif"
const textAlpha = likely ? 1.0 : 0.5
ctx.fillStyle = `rgba(34, 197, 94, ${textAlpha})`
ctx.font = likely ? "bold 10px sans-serif" : "9px sans-serif"
const labels = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"}
const units = {hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10"}
const prefix = labels[duct.source] || "Duct"
const unit = units[duct.source] || "M-units"
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} ${unit})`, left + 4, labelY)
const tag = likely ? " — likely path" : ""
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} ${unit})${tag}`, left + 4, labelY)
}
ctx.restore()
}
@ -69,7 +76,8 @@ export const ElevationProfile = {
base_ft: d.base_m_msl * M2FT,
top_ft: d.top_m_msl * M2FT,
strength: d.strength,
source: d.source || "hrrr"
source: d.source || "hrrr",
likely: d.likely || false
}))
const allElevs = [...elevations, ...losLine]

View file

@ -257,18 +257,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{format_band_ghz(@contact.band)} · {@contact.mode}
· {format_dist(@elevation_profile.dist_km)}
</div>
<div class="grid grid-cols-2 gap-x-8 text-sm font-mono mb-2">
<div>
{@contact.station1} &rarr; {@contact.station2}
<span class="ml-4">Az: {format_bearing(@elevation_profile.fwd_az)}</span>
<span class="ml-4">El: {@elevation_profile.fwd_el}&deg;</span>
</div>
<div>
{@contact.station2} &rarr; {@contact.station1}
<span class="ml-4">Az: {format_bearing(@elevation_profile.rev_az)}</span>
<span class="ml-4">El: {@elevation_profile.rev_el}&deg;</span>
</div>
</div>
<table class="text-sm font-mono mb-2 mx-auto">
<tr>
<td class="pr-6">{@contact.station1} &rarr; {@contact.station2}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.fwd_az)}</td>
<td>El: {@elevation_profile.fwd_el}&deg;</td>
</tr>
<tr>
<td class="pr-6">{@contact.station2} &rarr; {@contact.station1}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.rev_az)}</td>
<td>El: {@elevation_profile.rev_el}&deg;</td>
</tr>
</table>
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
<div class="bg-error/10 text-error font-semibold px-3 py-1 rounded mb-2 text-sm">
@ -645,6 +645,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> merge_nearby_ducts()
|> Enum.sort_by(& &1.strength, :desc)
|> Enum.take(3)
|> mark_likely_duct(tx_elev, rx_elev)
%{
points:
@ -785,6 +786,32 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|> Enum.reverse()
end
# Mark the duct most likely carrying the signal. The signal enters at
# surface level from each end, so the duct whose base is closest to
# the average endpoint elevation is the most probable propagation path.
defp mark_likely_duct([], _tx_elev, _rx_elev), do: []
defp mark_likely_duct(ducts, tx_elev, rx_elev) do
avg_elev = (tx_elev + rx_elev) / 2
{likely_idx, _} =
ducts
|> Enum.with_index()
|> Enum.min_by(fn {d, _idx} ->
# Distance from average endpoint elevation to the duct layer
# Prefer ducts that the endpoints are actually inside of
if avg_elev >= d.base_m_msl and avg_elev <= d.top_m_msl do
-d.strength
else
min(abs(d.base_m_msl - avg_elev), abs(d.top_m_msl - avg_elev))
end
end)
Enum.with_index(ducts, fn d, i ->
Map.put(d, :likely, i == likely_idx)
end)
end
defp band_to_ghz(nil), do: 10.0
defp band_to_ghz(band) do