Registered users can suggest edits to any contact's core fields (callsigns, grids, band, mode, timestamp). Edits enter an admin approval queue with field-by-field diff view. On approve, changes are applied and enrichment re-enqueued if grids/band changed. Users receive email notification on approve or reject. Also updates dependabot.yml for mix ecosystem.
600 lines
20 KiB
Elixir
600 lines
20 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
|
||
# Default map center when no visitor geolocation is available — DFW (EM12).
|
||
@default_center %{lat: 32.897, lon: -97.038}
|
||
@default_zoom 7
|
||
|
||
@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)
|
||
center = initial_center(session)
|
||
bounds = bounds_around(center)
|
||
initial_scores = Propagation.scores_at(@default_band, selected_time, bounds)
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Propagation Prediction Map",
|
||
bands: bands,
|
||
selected_band: @default_band,
|
||
initial_scores_json: Jason.encode!(initial_scores),
|
||
valid_times: valid_times,
|
||
selected_time: selected_time,
|
||
bounds: bounds,
|
||
initial_center: center,
|
||
initial_zoom: @default_zoom,
|
||
grid_visible: false,
|
||
antenna_height_ft: 33
|
||
)}
|
||
end
|
||
end
|
||
|
||
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
|
||
defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
|
||
%{lat: lat, lon: lon}
|
||
end
|
||
|
||
defp initial_center(_session), do: @default_center
|
||
|
||
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
|
||
# around a given center so the initial HRRR score query still returns a
|
||
# useful tile set for the visible area.
|
||
defp bounds_around(%{lat: lat, lon: lon}) do
|
||
%{
|
||
"south" => lat - 3.4,
|
||
"north" => lat + 3.4,
|
||
"west" => lon - 4.5,
|
||
"east" => lon + 4.5
|
||
}
|
||
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)
|
||
|
||
scatter = fetch_rain_scatter(lat, lon, band)
|
||
|
||
payload = if detail, do: detail |> Map.put(:forecast, forecast_data) |> Map.put(:rain_scatter, scatter), 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 fetch_rain_scatter(lat, lon, band_mhz) do
|
||
alias Microwaveprop.Propagation.RainScatter
|
||
alias Microwaveprop.Weather.NexradClient
|
||
|
||
freq_ghz = band_mhz / 1000.0
|
||
|
||
case NexradClient.fetch_rain_cells(lat, lon) do
|
||
{:ok, rain_cells} ->
|
||
cells = RainScatter.find_scatter_cells(rain_cells, lat, lon, freq_ghz)
|
||
classification = RainScatter.classify(cells)
|
||
%{cells: cells, classification: to_string(classification)}
|
||
|
||
{:error, _} ->
|
||
%{cells: [], classification: "none"}
|
||
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="flex w-screen h-screen overflow-hidden">
|
||
<%!-- Map container --%>
|
||
<div class="relative flex-1 min-w-0">
|
||
<%!-- 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: ""}
|
||
data-center-lat={@initial_center.lat}
|
||
data-center-lon={@initial_center.lon}
|
||
data-zoom={@initial_zoom}
|
||
class="absolute inset-0 z-0"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Mobile-only floating controls --%>
|
||
<div
|
||
id="map-controls"
|
||
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
||
>
|
||
<div
|
||
data-theme="dark"
|
||
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 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>NTMS</span>
|
||
<div class="font-normal text-xs opacity-70">Propagation Prediction 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"
|
||
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<div id="panel-extras" class="hidden flex-col gap-2">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||
Beacons
|
||
</.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>
|
||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
||
About
|
||
</.link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Point detail floating panel --%>
|
||
<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-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 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>
|
||
|
||
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
||
<button
|
||
id="sidebar-expand"
|
||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
||
onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<%!-- Desktop right sidebar --%>
|
||
<div
|
||
id="sidebar"
|
||
data-theme="dark"
|
||
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
||
>
|
||
<%!-- Sidebar controls --%>
|
||
<div class="p-3 flex flex-col gap-2 shrink-0">
|
||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<span>NTMS</span>
|
||
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="utc-clock-desktop"
|
||
phx-hook="UtcClock"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
</div>
|
||
<button
|
||
id="sidebar-collapse"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
title="Collapse sidebar"
|
||
onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-chevron-right" class="size-4" />
|
||
</button>
|
||
</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 z-10"
|
||
>
|
||
<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 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>
|
||
|
||
<%!-- Navigation --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<li>
|
||
<.link navigate="/path">Path Calculator</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/beacons">Beacons</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/weather">Weather Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/algo">Scoring Algorithm</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/submit">Submit a Contact</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts">Contact Training Data</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts/map">Contact Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/about">About</.link>
|
||
</li>
|
||
</ul>
|
||
|
||
<%!-- Auth --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<%= if @current_scope && @current_scope.user do %>
|
||
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
|
||
<li>
|
||
<.link href={~p"/users/settings"}>Settings</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
||
</li>
|
||
<% else %>
|
||
<li>
|
||
<.link href={~p"/users/register"}>Register</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-in"}>Log in</.link>
|
||
</li>
|
||
<% end %>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Layouts.flash_group flash={@flash} />
|
||
"""
|
||
end
|
||
end
|