Add contact detail map, elevation profile chart, and UI improvements

- Leaflet map on contact detail pages showing both station locations
- SRTM elevation profile with Chart.js: terrain, LOS, Fresnel zone
- Path info: distance, azimuth, elevation angle in both directions
- Compact 4-column grid layout for contact details
- Contact map page with all contacts plotted
- Rename QSOs to Contacts in page header
- Wrap algo page in standard layout
This commit is contained in:
Graham McIntire 2026-04-01 12:34:03 -05:00
parent ea96c93e2d
commit cd8d7a0f52
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 521 additions and 31 deletions

View file

@ -31,6 +31,8 @@ import topbar from "../vendor/topbar"
import {PropagationMap} from "./propagation_map_hook"
import {ContactMap} from "./contact_map_hook"
import {ContactsMap} from "./contacts_map_hook"
import {ElevationProfile} from "./elevation_profile_hook"
const UtcClock = {
mounted() {
@ -52,7 +54,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap},
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile},
})
// Show progress bar on live navigation and form submits

View file

@ -0,0 +1,117 @@
// Band MHz -> color mapping
const BAND_COLORS = {
1296: "#94a3b8", // slate
2304: "#a78bfa", // violet
3456: "#818cf8", // indigo
5760: "#60a5fa", // blue
10000: "#34d399", // emerald
24000: "#fbbf24", // amber
47000: "#fb923c", // orange
68000: "#f87171", // red
75000: "#e879f9", // fuchsia
122000: "#f472b6", // pink
134000: "#fb7185", // rose
241000: "#ff5555", // bright red
}
const BAND_LABELS = {
1296: "1296 MHz", 2304: "2304 MHz", 3456: "3456 MHz", 5760: "5760 MHz",
10000: "10 GHz", 24000: "24 GHz", 47000: "47 GHz", 68000: "68 GHz",
75000: "76 GHz", 122000: "122 GHz", 134000: "134 GHz", 241000: "241 GHz"
}
function bandColor(band) {
return BAND_COLORS[band] || "#64748b"
}
export const ContactsMap = {
mounted() {
const contacts = JSON.parse(this.el.dataset.contacts)
this.map = L.map(this.el, {
center: [37.5, -96],
zoom: 5,
minZoom: 3,
maxZoom: 14,
preferCanvas: true
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
// Group contacts by band for layered rendering
const byBand = {}
for (const c of contacts) {
const band = c[4]
if (!byBand[band]) byBand[band] = []
byBand[band].push(c)
}
// Collect all endpoint coords for endpoint density layer
const endpointCounts = {}
// Draw lines per band using canvas polylines
const bandLayers = {}
for (const [band, entries] of Object.entries(byBand)) {
const color = bandColor(parseInt(band))
const lines = []
for (const c of entries) {
const [lat1, lon1, lat2, lon2] = c
lines.push(L.polyline([[lat1, lon1], [lat2, lon2]], {
color: color,
weight: 1,
opacity: 0.4,
interactive: false
}))
// Track endpoints
const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}`
const k2 = `${lat2.toFixed(2)},${lon2.toFixed(2)}`
endpointCounts[k1] = (endpointCounts[k1] || 0) + 1
endpointCounts[k2] = (endpointCounts[k2] || 0) + 1
}
const layer = L.layerGroup(lines)
bandLayers[band] = layer
layer.addTo(this.map)
}
// Draw endpoint dots sized by density
const dotLayer = L.layerGroup()
for (const [key, count] of Object.entries(endpointCounts)) {
const [lat, lon] = key.split(",").map(Number)
const radius = Math.min(2 + Math.log2(count) * 1.5, 10)
L.circleMarker([lat, lon], {
radius: radius,
color: "#fff",
weight: 1,
fillColor: "#3b82f6",
fillOpacity: 0.7,
interactive: false
}).addTo(dotLayer)
}
dotLayer.addTo(this.map)
// Build legend
const legend = L.control({position: "bottomright"})
legend.onAdd = function() {
const div = L.DomUtil.create("div")
div.style.cssText = "background:rgba(30,30,40,0.9);color:#fff;padding:8px 12px;border-radius:8px;font-size:11px;line-height:1.6;"
const sorted = Object.entries(byBand).sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
let html = "<div style='font-weight:700;margin-bottom:4px;'>Bands</div>"
for (const [band, entries] of sorted) {
const color = bandColor(parseInt(band))
const label = BAND_LABELS[parseInt(band)] || band + " MHz"
html += `<div><span style="display:inline-block;width:14px;height:3px;background:${color};margin-right:6px;vertical-align:middle;border-radius:1px;"></span>${label} (${entries.length.toLocaleString()})</div>`
}
div.innerHTML = html
return div
}
legend.addTo(this.map)
}
}

View file

@ -0,0 +1,139 @@
import Chart from "../vendor/chart.js"
const M2FT = 3.28084
const KM2MI = 0.621371
export const ElevationProfile = {
mounted() {
const data = JSON.parse(this.el.dataset.profile)
if (!data || !data.points || data.points.length === 0) return
this.renderChart(data)
},
renderChart(data) {
const points = data.points
const distances = points.map(p => p.dist_km * KM2MI)
const elevations = points.map(p => p.elev * M2FT)
const losLine = points.map(p => p.beam * M2FT)
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
const showFresnel = data.freq_mhz >= 1000
const allElevs = [...elevations, ...losLine]
const minElev = Math.min(...allElevs) - 60
const maxElev = Math.max(...allElevs) + 120
const canvas = this.el.querySelector("canvas")
const ctx = canvas.getContext("2d")
this.chart = new Chart(ctx, {
type: "line",
data: {
labels: distances,
datasets: [
{
label: "Terrain",
data: elevations,
borderColor: "#8B6914",
backgroundColor: "rgba(139, 105, 20, 0.3)",
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.1,
order: 3
},
{
label: "Line of Sight",
data: losLine,
borderColor: "#2563eb",
borderWidth: 2,
borderDash: [6, 3],
pointRadius: 0,
fill: false,
order: 1
},
...(showFresnel ? [
{
label: "Fresnel Zone",
data: fresnelLower,
borderColor: "rgba(239, 68, 68, 0.4)",
backgroundColor: "rgba(239, 68, 68, 0.08)",
borderWidth: 1,
borderDash: [3, 3],
pointRadius: 0,
fill: "+1",
order: 2
},
{
label: "_fresnelBase",
data: losLine,
borderWidth: 0,
pointRadius: 0,
fill: false,
order: 2
}
] : [])
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {duration: 300},
plugins: {
legend: {
display: true,
position: "top",
labels: {
boxWidth: 12,
font: {size: 10},
filter: item => !item.text.startsWith("_")
}
},
tooltip: {
callbacks: {
title: items => items[0].label + " mi",
label: item => {
if (item.dataset.label.startsWith("_")) return null
return item.dataset.label + ": " + Math.round(item.raw) + " ft"
}
}
}
},
scales: {
x: {
title: {display: true, text: "Distance (mi)", font: {size: 10}},
afterBuildTicks: axis => {
const last = distances.length - 1
const totalMi = distances[last]
const step = Math.ceil(totalMi / 7) || 1
const ticks = []
for (let mi = 0; mi < totalMi; mi += step) {
const idx = distances.findIndex(d => d >= mi)
if (idx >= 0) ticks.push({value: idx})
}
ticks.push({value: last})
axis.ticks = ticks
},
ticks: {
font: {size: 9},
autoSkip: false,
callback: val => distances[val] !== undefined ? Math.round(distances[val]) : ""
}
},
y: {
title: {display: true, text: "Elevation (ft)", font: {size: 10}},
ticks: {font: {size: 9}},
min: Math.max(0, minElev),
max: maxElev
}
}
}
})
},
destroyed() {
if (this.chart) {
this.chart.destroy()
this.chart = null
}
}
}

14
assets/vendor/chart.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -38,9 +38,10 @@ defmodule MicrowavepropWeb.Layouts do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1 gap-4">
<a href="/" class="font-semibold">Microwaveprop</a>
<a href="/" class="font-semibold">NTMS Propagation Prediction</a>
<nav class="flex gap-2">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
</nav>

View file

@ -13,14 +13,11 @@ defmodule MicrowavepropWeb.AlgoLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="markdown-content">
<div class="mb-4">
<.link navigate="/map" class="btn btn-lg btn-ghost text-lg">
<.icon name="hero-arrow-left" class="size-4" /> Back to map
</.link>
</div>
{raw(@content)}
</div>
</Layouts.app>
"""
end
end

View file

@ -72,7 +72,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
~H"""
<Layouts.app flash={@flash} max_width="max-w-7xl">
<.header>
QSOs
Contacts
<:subtitle>{@total_entries} contacts</:subtitle>
<:actions>
<.link navigate={~p"/submit"} class="btn btn-primary">

View file

@ -4,8 +4,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
alias Microwaveprop.Radio
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
@earth_radius_m 6_371_000.0
@impl true
def mount(%{"id" => id}, _session, socket) do
contact = Radio.get_contact!(id)
@ -14,6 +18,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
solar = load_solar(contact)
hrrr = Weather.hrrr_for_contact(contact)
terrain = Terrain.get_terrain_profile(contact.id)
elevation_profile = compute_elevation_profile(contact)
{:ok,
assign(socket,
@ -24,6 +29,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
solar: solar,
hrrr: hrrr,
terrain: terrain,
elevation_profile: elevation_profile,
terrain_expanded: false,
hrrr_profile_expanded: false,
obs_sort_by: "station_name",
@ -151,7 +157,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</.header>
<%= if @contact.pos1 && @contact.pos2 do %>
<p class="text-xs text-base-content/50 italic mb-1">Locations approximate, in the center of the grid squares</p>
<p class="text-xs text-base-content/50 italic mb-1">
Locations approximate, in the center of the grid squares
</p>
<div
id="contact-map"
phx-hook="ContactMap"
@ -164,27 +172,72 @@ defmodule MicrowavepropWeb.ContactLive.Show do
/>
<% end %>
<%= if @elevation_profile do %>
<div class="mb-4">
<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">
{format_dist(@elevation_profile.dist_km)}
</span>
<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">
{format_dist(@elevation_profile.dist_km)}
</span>
<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>
<%= 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">
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
</div>
<% end %>
<%= if @elevation_profile.verdict == "CLEAR" do %>
<div class="bg-success/10 text-success font-semibold px-3 py-1 rounded mb-2 text-sm">
Line of sight clear
</div>
<% end %>
<div
id="elevation-profile"
phx-hook="ElevationProfile"
phx-update="ignore"
data-profile={Jason.encode!(@elevation_profile)}
class="w-full h-64 border border-base-300 rounded-lg"
>
<canvas></canvas>
</div>
</div>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
<.list>
<:item title="Station 1">{@contact.station1}</:item>
<:item title="Station 2">{@contact.station2}</:item>
<:item title="Band">{@contact.band} MHz</:item>
<:item title="Mode">{@contact.mode}</:item>
<:item title="Grid 1">{@contact.grid1 || ""}</:item>
<:item title="Grid 2">{@contact.grid2 || ""}</:item>
<:item title="Distance">{@contact.distance_km || ""} km</:item>
<:item title="Timestamp">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">Station 1:</span> {@contact.station1}</div>
<div><span class="font-semibold">Station 2:</span> {@contact.station2}</div>
<div><span class="font-semibold">Band:</span> {@contact.band} MHz</div>
<div><span class="font-semibold">Mode:</span> {@contact.mode}</div>
<div><span class="font-semibold">Grid 1:</span> {@contact.grid1 || ""}</div>
<div><span class="font-semibold">Grid 2:</span> {@contact.grid2 || ""}</div>
<div><span class="font-semibold">Distance:</span> {@contact.distance_km || ""} km</div>
<div>
<span class="font-semibold">Timestamp:</span>
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
</:item>
<:item :if={@contact.user_submitted} title="Source">
</div>
<div :if={@contact.user_submitted}>
<span class="badge badge-info badge-sm">User Submitted</span>
</:item>
<:item :if={@contact.user_submitted && @contact.submitter_email} title="Submitter">
{@contact.submitter_email}
</:item>
</.list>
</div>
<div :if={@contact.user_submitted && @contact.submitter_email}>
<span class="font-semibold">Submitter:</span> {@contact.submitter_email}
</div>
</div>
<div class="divider" />
@ -450,6 +503,17 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n)
defp format_dist(km) do
mi = km * 0.621371
"#{:erlang.float_to_binary(mi, decimals: 1)} mi (#{:erlang.float_to_binary(km, decimals: 1)} km)"
end
defp format_bearing(deg) do
whole = trunc(deg)
frac = round((deg - whole) * 10)
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
end
defp format_wind(obs) do
case {obs.wind_direction_deg, obs.wind_speed_kts} do
{nil, nil} -> ""
@ -471,4 +535,93 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
defp terrain_verdict_class(_), do: "badge-ghost"
defp compute_elevation_profile(contact) do
with %{"lat" => lat1} <- contact.pos1,
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
%{"lat" => lat2} <- contact.pos2,
lon2 when is_number(lon2) <- contact.pos2["lon"] || contact.pos2["lng"],
{:ok, profile} <- ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256) do
freq_ghz = band_to_ghz(contact.band)
dist_km = haversine_km(lat1, lon1, lat2, lon2)
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
rev_az = initial_bearing(lat2, lon2, lat1, lon1)
tx_elev = hd(profile).elev
rx_elev = List.last(profile).elev
fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000)
first_obs =
case Enum.find(analysis.points, & &1.obstructed) do
nil -> nil
p -> p.dist_km
end
%{
points:
Enum.map(analysis.points, fn p ->
%{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km}
end),
freq_mhz: freq_ghz * 1000,
dist_km: dist_km,
fwd_az: fwd_az,
rev_az: rev_az,
fwd_el: fwd_el,
rev_el: rev_el,
verdict: analysis.verdict,
first_obstruction_km: first_obs
}
else
_ -> nil
end
end
defp band_to_ghz(nil), do: 10.0
defp band_to_ghz(band) do
band
|> Decimal.to_float()
|> Kernel./(1000)
end
defp haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
defp initial_bearing(lat1, lon1, lat2, lon2) do
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
dlon = deg_to_rad(lon2 - lon1)
y = :math.sin(dlon) * :math.cos(rlat2)
x =
:math.cos(rlat1) * :math.sin(rlat2) -
:math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
bearing = y |> :math.atan2(x) |> rad_to_deg()
Float.round(rem_float(bearing + 360, 360), 1)
end
defp elevation_angle(h_tx, h_rx, dist_m) do
k_r = 4 / 3 * @earth_radius_m
angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r)
Float.round(rad_to_deg(angle), 2)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
defp rem_float(a, b), do: a - Float.floor(a / b) * b
end

View file

@ -0,0 +1,63 @@
defmodule MicrowavepropWeb.ContactMapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@impl true
def mount(_params, _session, socket) do
contacts = load_contacts()
{:ok,
assign(socket,
page_title: "Contact Map",
contacts_json: Jason.encode!(contacts),
contact_count: length(contacts)
)}
end
defp load_contacts do
from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2),
select: %{
pos1: c.pos1,
pos2: c.pos2,
band: c.band
}
)
|> Repo.all()
|> Enum.map(fn c ->
lat1 = c.pos1["lat"]
lon1 = c.pos1["lon"] || c.pos1["lng"]
lat2 = c.pos2["lat"]
lon2 = c.pos2["lon"] || c.pos2["lng"]
band = if c.band, do: Decimal.to_integer(c.band), else: 0
if lat1 && lon1 && lat2 && lon2 do
[lat1, lon1, lat2, lon2, band]
end
end)
|> Enum.reject(&is_nil/1)
end
@impl true
def render(assigns) do
~H"""
<div
id="contacts-map"
phx-hook="ContactsMap"
phx-update="ignore"
class="fixed inset-0 z-0"
data-contacts={@contacts_json}
>
</div>
<div class="fixed top-3 left-14 z-[1000] bg-base-100/90 shadow rounded-box border border-base-300 px-3 py-2">
<div class="font-bold text-sm">Contact Map</div>
<div class="text-xs opacity-70">{Integer.to_string(@contact_count)} contacts</div>
</div>
"""
end
end

View file

@ -342,6 +342,9 @@ defmodule MicrowavepropWeb.MapLive do
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-map" class="size-3.5" /> Contact Map
</.link>
</div>
</div>

View file

@ -22,6 +22,7 @@ defmodule MicrowavepropWeb.Router do
live "/submit", SubmitLive
live "/map", MapLive
live "/contacts", ContactLive.Index
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
live "/algo", AlgoLive

View file

@ -29,12 +29,12 @@ defmodule MicrowavepropWeb.ContactLiveTest do
end
describe "Index" do
test "renders page with QSOs heading", %{conn: conn} do
test "renders page with Contacts heading", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "QSOs"
assert html =~ "Contacts"
end
test "table shows QSO data", %{conn: conn} do
test "table shows contact data", %{conn: conn} do
create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts")
@ -78,7 +78,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do
end
describe "Show" do
test "renders QSO detail fields", %{conn: conn} do
test "renders contact detail fields", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")