From 4aaa028ed1f89d895f421b78ce780c9409f966d1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 13:31:56 -0500 Subject: [PATCH] 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 --- assets/js/elevation_profile_hook.js | 24 ++++++--- .../live/contact_live/show.ex | 51 ++++++++++++++----- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/assets/js/elevation_profile_hook.js b/assets/js/elevation_profile_hook.js index aa5f4753..c1ba6a87 100644 --- a/assets/js/elevation_profile_hook.js +++ b/assets/js/elevation_profile_hook.js @@ -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] diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index dac0b96c..3dd79e51 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -257,18 +257,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do {format_band_ghz(@contact.band)} · {@contact.mode} · {format_dist(@elevation_profile.dist_km)} -
-
- {@contact.station1} → {@contact.station2} - Az: {format_bearing(@elevation_profile.fwd_az)} - El: {@elevation_profile.fwd_el}° -
-
- {@contact.station2} → {@contact.station1} - Az: {format_bearing(@elevation_profile.rev_az)} - El: {@elevation_profile.rev_el}° -
-
+ + + + + + + + + + + +
{@contact.station1} → {@contact.station2}Az: {format_bearing(@elevation_profile.fwd_az)}El: {@elevation_profile.fwd_el}°
{@contact.station2} → {@contact.station1}Az: {format_bearing(@elevation_profile.rev_az)}El: {@elevation_profile.rev_el}°
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
@@ -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