prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire a66d3094ca Add contact edit approval system with admin review queue
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.
2026-04-11 16:15:49 -05:00

600 lines
20 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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