LiveView page for analyzing microwave propagation between two points. Features: - Source/destination input: callsign (via gridmap.org API) or Maidenhead grid - Band selector for all 8 microwave bands (10-241 GHz) - Leaflet map showing path line (reuses ContactMap hook) - Terrain elevation cross-section (reuses ElevationProfile hook) - Link summary: distance, bearing, terrain verdict - Loss budget: FSPL + O2 + H2O + rain + diffraction - Propagation score with 10-factor breakdown - Current atmospheric conditions from path-integrated HRRR New CallsignClient for gridmap.org location lookup. Navigation links added to main navbar and map control panel.
411 lines
13 KiB
Elixir
411 lines
13 KiB
Elixir
defmodule MicrowavepropWeb.MapLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
use LiveStash
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Terrain.Viewshed
|
|
|
|
require Logger
|
|
|
|
@default_band 10_000
|
|
@initial_bounds %{
|
|
"south" => 29.5,
|
|
"north" => 36.3,
|
|
"west" => -101.5,
|
|
"east" => -92.5
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
end
|
|
|
|
case LiveStash.recover_state(socket) do
|
|
{:recovered, socket} ->
|
|
{:ok, socket}
|
|
|
|
_ ->
|
|
bands = BandConfig.all_bands()
|
|
valid_times = Propagation.available_valid_times(@default_band)
|
|
selected_time = closest_to_now(valid_times)
|
|
initial_scores = Propagation.scores_at(@default_band, selected_time, @initial_bounds)
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Propagation Map",
|
|
bands: bands,
|
|
selected_band: @default_band,
|
|
initial_scores_json: Jason.encode!(initial_scores),
|
|
valid_times: valid_times,
|
|
selected_time: selected_time,
|
|
bounds: @initial_bounds,
|
|
grid_visible: false,
|
|
antenna_height_ft: 33
|
|
)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("select_band", %{"value" => band}, socket) do
|
|
band = if is_binary(band), do: String.to_integer(band), else: band
|
|
valid_times = Propagation.available_valid_times(band)
|
|
selected_time = closest_to_now(valid_times)
|
|
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|
|
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|> push_event("update_band_info", %{band_info: band_info(band)})
|
|
|> push_timeline()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("select_time", %{"time" => time_str}, socket) do
|
|
case DateTime.from_iso8601(time_str) do
|
|
{:ok, time, _} ->
|
|
scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:selected_time, time)
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|
|
{:noreply, socket}
|
|
|
|
_ ->
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
|
|
height =
|
|
case Integer.parse(value) do
|
|
{h, _} when h >= 0 and h <= 200 -> h
|
|
_ -> 8
|
|
end
|
|
|
|
{:noreply, assign(socket, :antenna_height_ft, height)}
|
|
end
|
|
|
|
def handle_event("toggle_grid", _params, socket) do
|
|
visible = !socket.assigns.grid_visible
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:grid_visible, visible)
|
|
|> push_event("toggle_grid", %{visible: visible})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
|
band = socket.assigns.selected_band
|
|
detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time)
|
|
forecast = Propagation.point_forecast(band, lat, lon)
|
|
|
|
forecast_data =
|
|
Enum.map(forecast, fn f ->
|
|
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
|
|
end)
|
|
|
|
payload = if detail, do: Map.put(detail, :forecast, forecast_data), else: %{}
|
|
{:noreply, push_event(socket, "point_detail", payload)}
|
|
end
|
|
|
|
def handle_event("map_bounds", bounds, socket) do
|
|
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:bounds, bounds)
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
|
|
band_mhz = socket.assigns.selected_band
|
|
band_config = BandConfig.get(band_mhz)
|
|
freq_ghz = band_mhz / 1_000
|
|
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
|
|
|
|
score =
|
|
case Propagation.point_detail(band_mhz, lat, lon) do
|
|
%{score: s} -> s
|
|
_ -> 50
|
|
end
|
|
|
|
max_range_km = score_range_km(score, band_config)
|
|
|
|
socket =
|
|
start_async(socket, :viewshed, fn ->
|
|
Viewshed.compute(lat, lon,
|
|
freq_ghz: freq_ghz,
|
|
ant_height_m: ant_height_m,
|
|
max_range_km: max_range_km,
|
|
score: score
|
|
)
|
|
end)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:propagation_updated, _valid_times}, socket) do
|
|
band = socket.assigns.selected_band
|
|
valid_times = Propagation.available_valid_times(band)
|
|
# Stay on the same selected time if still available, else pick earliest
|
|
selected_time =
|
|
if socket.assigns.selected_time in valid_times do
|
|
socket.assigns.selected_time
|
|
else
|
|
closest_to_now(valid_times)
|
|
end
|
|
|
|
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(valid_times: valid_times, selected_time: selected_time)
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|> push_timeline()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_async(:viewshed, {:ok, result}, socket) do
|
|
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
|
Logger.info("Viewshed complete: #{length(points)} boundary points")
|
|
|
|
socket =
|
|
push_event(socket, "viewshed_result", %{
|
|
origin: result.origin,
|
|
points: points
|
|
})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_async(:viewshed, {:exit, reason}, socket) do
|
|
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp push_timeline(socket) do
|
|
times =
|
|
Enum.map(socket.assigns.valid_times, fn t ->
|
|
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
|
end)
|
|
|
|
selected =
|
|
if socket.assigns.selected_time,
|
|
do: DateTime.to_iso8601(socket.assigns.selected_time)
|
|
|
|
push_event(socket, "update_timeline", %{times: times, selected: selected})
|
|
end
|
|
|
|
defp format_time_label(dt) do
|
|
# Show as "HH:MM" in UTC
|
|
Calendar.strftime(dt, "%H:%M")
|
|
end
|
|
|
|
defp closest_to_now([]), do: nil
|
|
|
|
defp closest_to_now(times) do
|
|
now = DateTime.utc_now()
|
|
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
|
|
end
|
|
|
|
defp score_range_km(score, config) do
|
|
cond do
|
|
score >= 80 -> config.exceptional_range_km
|
|
score >= 65 -> config.extended_range_km
|
|
score >= 50 -> config.typical_range_km
|
|
score >= 33 -> round(config.typical_range_km * 0.6)
|
|
true -> round(config.typical_range_km * 0.3)
|
|
end
|
|
end
|
|
|
|
defp band_info(band_mhz) do
|
|
config = BandConfig.get(band_mhz)
|
|
|
|
%{
|
|
band_label: config.label,
|
|
typical_range_km: config.typical_range_km,
|
|
extended_range_km: config.extended_range_km,
|
|
exceptional_range_km: config.exceptional_range_km,
|
|
humidity_effect: to_string(config.humidity_effect),
|
|
freq_mhz: config.freq_mhz
|
|
}
|
|
end
|
|
|
|
defp selected_label(bands, selected_band) do
|
|
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
|
|
nil -> "Select Band"
|
|
band -> band.label
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="relative w-screen h-screen overflow-hidden">
|
|
<%!-- Full-page map --%>
|
|
<div
|
|
id="propagation-map"
|
|
phx-hook="PropagationMap"
|
|
phx-update="ignore"
|
|
data-scores={@initial_scores_json}
|
|
data-band-info={Jason.encode!(band_info(@selected_band))}
|
|
data-valid-times={
|
|
Jason.encode!(
|
|
Enum.map(@valid_times, fn t ->
|
|
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
|
end)
|
|
)
|
|
}
|
|
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
|
|
class="absolute inset-0 z-0"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Top-left control panel --%>
|
|
<div
|
|
id="map-controls"
|
|
class="absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)] md:left-14 md:top-3 md:max-w-none"
|
|
>
|
|
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-2 md:p-3 flex flex-col gap-2">
|
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span class="hidden md:inline">North Texas Microwave Society</span>
|
|
<span class="md:hidden">NTMS</span>
|
|
<div class="font-normal text-xs opacity-70">Propagation Map</div>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
<div
|
|
id="utc-clock"
|
|
phx-hook="UtcClock"
|
|
class="font-mono text-xs opacity-70 tabular-nums"
|
|
>
|
|
</div>
|
|
<button
|
|
id="mobile-panel-toggle"
|
|
class="btn btn-xs btn-square btn-ghost md:hidden"
|
|
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
|
|
>
|
|
<.icon name="hero-bars-3" class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Band selector (always visible) --%>
|
|
<div class="dropdown">
|
|
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
|
{selected_label(@bands, @selected_band)}
|
|
<.icon name="hero-chevron-down" class="size-3" />
|
|
</div>
|
|
<ul
|
|
tabindex="0"
|
|
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
|
|
>
|
|
<li :for={band <- @bands}>
|
|
<button
|
|
onclick="document.activeElement.blur()"
|
|
phx-click={
|
|
JS.dispatch("show-loading", to: "#propagation-map")
|
|
|> JS.push("select_band", value: %{value: band.freq_mhz})
|
|
}
|
|
class={[if(@selected_band == band.freq_mhz, do: "active")]}
|
|
>
|
|
{band.label}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<%!-- Secondary controls (collapsed on mobile by default) --%>
|
|
<div id="panel-extras" class="hidden md:flex flex-col gap-2">
|
|
<%!-- Grid toggle --%>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@grid_visible}
|
|
phx-click="toggle_grid"
|
|
/>
|
|
<span class="text-sm">Grid squares</span>
|
|
</label>
|
|
|
|
<%!-- Antenna height --%>
|
|
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
|
|
<label class="text-sm whitespace-nowrap">Antenna height</label>
|
|
<input
|
|
type="number"
|
|
name="height_ft"
|
|
min="0"
|
|
max="200"
|
|
step="1"
|
|
value={@antenna_height_ft}
|
|
class="input input-xs w-16 text-right"
|
|
/>
|
|
<span class="text-xs opacity-70">ft</span>
|
|
</form>
|
|
|
|
<%!-- Links --%>
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
|
Path Calculator
|
|
</.link>
|
|
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
|
Weather Map
|
|
</.link>
|
|
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
|
Scoring Algorithm
|
|
</.link>
|
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
Submit a Contact
|
|
</.link>
|
|
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
Contact Training Data
|
|
</.link>
|
|
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
|
Contact Map
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Point detail panel — bottom sheet on mobile, sidebar on desktop --%>
|
|
<div
|
|
id="detail-panel"
|
|
phx-update="ignore"
|
|
class={[
|
|
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
|
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
|
"md:absolute md:top-3 md:bottom-auto md:left-auto md:right-auto md:max-h-[60vh] md:max-w-sm md:rounded-box"
|
|
]}
|
|
style="display:none;"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Bottom timeline bar --%>
|
|
<div
|
|
id="forecast-timeline"
|
|
phx-update="ignore"
|
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<Layouts.flash_group flash={@flash} />
|
|
"""
|
|
end
|
|
end
|