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 {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
|
||||
|
|
|
|||
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"""
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} → {@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" />
|
||||
|
||||
<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
|
||||
|
|
|
|||
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">
|
||||
<.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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue