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:
parent
ea96c93e2d
commit
cd8d7a0f52
12 changed files with 521 additions and 31 deletions
|
|
@ -31,6 +31,8 @@ import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
import {PropagationMap} from "./propagation_map_hook"
|
import {PropagationMap} from "./propagation_map_hook"
|
||||||
import {ContactMap} from "./contact_map_hook"
|
import {ContactMap} from "./contact_map_hook"
|
||||||
|
import {ContactsMap} from "./contacts_map_hook"
|
||||||
|
import {ElevationProfile} from "./elevation_profile_hook"
|
||||||
|
|
||||||
const UtcClock = {
|
const UtcClock = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -52,7 +54,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
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
|
// Show progress bar on live navigation and form submits
|
||||||
|
|
|
||||||
117
assets/js/contacts_map_hook.js
Normal file
117
assets/js/contacts_map_hook.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
139
assets/js/elevation_profile_hook.js
Normal file
139
assets/js/elevation_profile_hook.js
Normal 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
14
assets/vendor/chart.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -38,9 +38,10 @@ defmodule MicrowavepropWeb.Layouts do
|
||||||
~H"""
|
~H"""
|
||||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex-1 gap-4">
|
<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">
|
<nav class="flex gap-2">
|
||||||
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
<.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="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
||||||
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,11 @@ defmodule MicrowavepropWeb.AlgoLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="markdown-content">
|
<Layouts.app flash={@flash}>
|
||||||
<div class="mb-4">
|
<div class="markdown-content">
|
||||||
<.link navigate="/map" class="btn btn-lg btn-ghost text-lg">
|
{raw(@content)}
|
||||||
<.icon name="hero-arrow-left" class="size-4" /> Back to map
|
|
||||||
</.link>
|
|
||||||
</div>
|
</div>
|
||||||
{raw(@content)}
|
</Layouts.app>
|
||||||
</div>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} max_width="max-w-7xl">
|
<Layouts.app flash={@flash} max_width="max-w-7xl">
|
||||||
<.header>
|
<.header>
|
||||||
QSOs
|
Contacts
|
||||||
<:subtitle>{@total_entries} contacts</:subtitle>
|
<:subtitle>{@total_entries} contacts</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.link navigate={~p"/submit"} class="btn btn-primary">
|
<.link navigate={~p"/submit"} class="btn btn-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Terrain
|
alias Microwaveprop.Terrain
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
|
|
||||||
|
@earth_radius_m 6_371_000.0
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
contact = Radio.get_contact!(id)
|
contact = Radio.get_contact!(id)
|
||||||
|
|
@ -14,6 +18,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
solar = load_solar(contact)
|
solar = load_solar(contact)
|
||||||
hrrr = Weather.hrrr_for_contact(contact)
|
hrrr = Weather.hrrr_for_contact(contact)
|
||||||
terrain = Terrain.get_terrain_profile(contact.id)
|
terrain = Terrain.get_terrain_profile(contact.id)
|
||||||
|
elevation_profile = compute_elevation_profile(contact)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
@ -24,6 +29,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
solar: solar,
|
solar: solar,
|
||||||
hrrr: hrrr,
|
hrrr: hrrr,
|
||||||
terrain: terrain,
|
terrain: terrain,
|
||||||
|
elevation_profile: elevation_profile,
|
||||||
terrain_expanded: false,
|
terrain_expanded: false,
|
||||||
hrrr_profile_expanded: false,
|
hrrr_profile_expanded: false,
|
||||||
obs_sort_by: "station_name",
|
obs_sort_by: "station_name",
|
||||||
|
|
@ -151,7 +157,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<%= if @contact.pos1 && @contact.pos2 do %>
|
<%= 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
|
<div
|
||||||
id="contact-map"
|
id="contact-map"
|
||||||
phx-hook="ContactMap"
|
phx-hook="ContactMap"
|
||||||
|
|
@ -164,27 +172,72 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% 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} → {@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}°</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{@contact.station2} → {@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}°</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" />
|
<div class="divider" />
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
|
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
|
||||||
<.list>
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-1 text-sm">
|
||||||
<:item title="Station 1">{@contact.station1}</:item>
|
<div><span class="font-semibold">Station 1:</span> {@contact.station1}</div>
|
||||||
<:item title="Station 2">{@contact.station2}</:item>
|
<div><span class="font-semibold">Station 2:</span> {@contact.station2}</div>
|
||||||
<:item title="Band">{@contact.band} MHz</:item>
|
<div><span class="font-semibold">Band:</span> {@contact.band} MHz</div>
|
||||||
<:item title="Mode">{@contact.mode}</:item>
|
<div><span class="font-semibold">Mode:</span> {@contact.mode}</div>
|
||||||
<:item title="Grid 1">{@contact.grid1 || "—"}</:item>
|
<div><span class="font-semibold">Grid 1:</span> {@contact.grid1 || "—"}</div>
|
||||||
<:item title="Grid 2">{@contact.grid2 || "—"}</:item>
|
<div><span class="font-semibold">Grid 2:</span> {@contact.grid2 || "—"}</div>
|
||||||
<:item title="Distance">{@contact.distance_km || "—"} km</:item>
|
<div><span class="font-semibold">Distance:</span> {@contact.distance_km || "—"} km</div>
|
||||||
<:item title="Timestamp">
|
<div>
|
||||||
|
<span class="font-semibold">Timestamp:</span>
|
||||||
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
||||||
</:item>
|
</div>
|
||||||
<:item :if={@contact.user_submitted} title="Source">
|
<div :if={@contact.user_submitted}>
|
||||||
<span class="badge badge-info badge-sm">User Submitted</span>
|
<span class="badge badge-info badge-sm">User Submitted</span>
|
||||||
</:item>
|
</div>
|
||||||
<:item :if={@contact.user_submitted && @contact.submitter_email} title="Submitter">
|
<div :if={@contact.user_submitted && @contact.submitter_email}>
|
||||||
{@contact.submitter_email}
|
<span class="font-semibold">Submitter:</span> {@contact.submitter_email}
|
||||||
</:item>
|
</div>
|
||||||
</.list>
|
</div>
|
||||||
|
|
||||||
<div class="divider" />
|
<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) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
||||||
defp format_number(n), do: to_string(n)
|
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
|
defp format_wind(obs) do
|
||||||
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
case {obs.wind_direction_deg, obs.wind_speed_kts} do
|
||||||
{nil, nil} -> "—"
|
{nil, nil} -> "—"
|
||||||
|
|
@ -471,4 +535,93 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
|
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
|
||||||
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
||||||
defp terrain_verdict_class(_), do: "badge-ghost"
|
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
|
end
|
||||||
|
|
|
||||||
63
lib/microwaveprop_web/live/contact_map_live.ex
Normal file
63
lib/microwaveprop_web/live/contact_map_live.ex
Normal 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
|
||||||
|
|
@ -342,6 +342,9 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start gap-1.5">
|
<.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
|
<.icon name="hero-signal" class="size-3.5" /> Contact Training Data
|
||||||
</.link>
|
</.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/submit", SubmitLive
|
live "/submit", SubmitLive
|
||||||
live "/map", MapLive
|
live "/map", MapLive
|
||||||
live "/contacts", ContactLive.Index
|
live "/contacts", ContactLive.Index
|
||||||
|
live "/contacts/map", ContactMapLive
|
||||||
live "/contacts/:id", ContactLive.Show
|
live "/contacts/:id", ContactLive.Show
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,12 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Index" do
|
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")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
assert html =~ "QSOs"
|
assert html =~ "Contacts"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "table shows QSO data", %{conn: conn} do
|
test "table shows contact data", %{conn: conn} do
|
||||||
create_contact()
|
create_contact()
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
|
@ -78,7 +78,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Show" do
|
describe "Show" do
|
||||||
test "renders QSO detail fields", %{conn: conn} do
|
test "renders contact detail fields", %{conn: conn} do
|
||||||
contact = create_contact()
|
contact = create_contact()
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue