- HrrrClient.hrrr_url accepts forecast_hour param (wrfsfcfHH.grib2) - PropagationGridWorker fetches all 19 forecast hours per run - Propagation.scores_at/3 queries scores at specific valid_time - Propagation.available_valid_times/1 returns all forecast times for timeline - Pruning keeps scores with valid_time >= now - 2h (forecast-aware) - MapLive: select_time event, timeline data pushed to JS - JS: forecast timeline bar at bottom of map with clickable hour buttons - PubSub broadcast sends list of valid_times instead of single time
348 lines
10 KiB
Elixir
348 lines
10 KiB
Elixir
defmodule MicrowavepropWeb.MapLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
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
|
|
bands = BandConfig.all_bands()
|
|
valid_times = Propagation.available_valid_times(@default_band)
|
|
# Default to the earliest (current analysis) hour
|
|
selected_time = List.first(valid_times)
|
|
initial_scores = Propagation.scores_at(@default_band, selected_time, @initial_bounds)
|
|
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
end
|
|
|
|
{: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
|
|
|
|
@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 = List.first(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)
|
|
|> 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
|
|
detail = Propagation.point_detail(socket.assigns.selected_band, lat, lon)
|
|
{:noreply, push_event(socket, "point_detail", detail || %{})}
|
|
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
|
|
List.first(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 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">
|
|
<%!-- 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-3 left-14 z-[1000] flex flex-col gap-2">
|
|
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-3 flex flex-col gap-2">
|
|
<div class="font-bold text-sm leading-tight px-1">
|
|
North Texas Microwave Society
|
|
<div class="font-normal text-xs opacity-70">Propagation Map</div>
|
|
</div>
|
|
|
|
<%!-- Band selector --%>
|
|
<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>
|
|
|
|
<%!-- 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</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="/algo" class="btn btn-xs btn-ghost justify-start gap-1.5">
|
|
<.icon name="hero-calculator" class="size-3.5" /> Scoring Algorithm
|
|
</.link>
|
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
|
|
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Point detail panel (populated by JS on click, ignored by LiveView patches) --%>
|
|
<div
|
|
id="detail-panel"
|
|
phx-update="ignore"
|
|
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 overflow-hidden"
|
|
style="display:none;"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Bottom timeline bar --%>
|
|
<div
|
|
id="forecast-timeline"
|
|
phx-update="ignore"
|
|
class="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1000]"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<Layouts.flash_group flash={@flash} />
|
|
"""
|
|
end
|
|
end
|