prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire 0c295c1558
Add click-to-inspect factor breakdown popup on map
Click anywhere on the propagation map to see a detailed popup with:
- Overall score and tier label with color
- Estimated range for the selected band (CW mode)
- All 9 scoring factors with visual bar charts, individual scores,
  and weight percentages
- Grid point coordinates and data timestamp

Factors are displayed in weight order so users can immediately see
which atmospheric conditions are driving the prediction.
2026-03-31 09:45:50 -05:00

167 lines
4.8 KiB
Elixir

defmodule MicrowavepropWeb.MapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
@default_band 10_000
@impl true
def mount(_params, _session, socket) do
bands = BandConfig.all_bands()
valid_time = Propagation.latest_valid_time()
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,
scores: [],
valid_time: valid_time,
bounds: nil
)}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
scores = Propagation.latest_scores(band, socket.assigns.bounds)
socket =
socket
|> assign(:selected_band, band)
|> assign(:scores, scores)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
band = socket.assigns.selected_band
case Propagation.point_detail(band, lat, lon) do
nil ->
{:noreply, push_event(socket, "point_detail", %{detail: nil})}
detail ->
band_config = BandConfig.get(band)
payload = %{
lat: detail.lat,
lon: detail.lon,
score: detail.score,
factors: detail.factors,
valid_time: DateTime.to_iso8601(detail.valid_time),
band_label: band_config.label,
typical_range_km: band_config.typical_range_km,
extended_range_km: band_config.extended_range_km,
exceptional_range_km: band_config.exceptional_range_km
}
{:noreply, push_event(socket, "point_detail", %{detail: payload})}
end
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band, bounds)
socket =
socket
|> assign(:bounds, bounds)
|> assign(:scores, scores)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
@impl true
def handle_info({:propagation_updated, valid_time}, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band, socket.assigns.bounds)
socket =
socket
|> assign(:scores, scores)
|> assign(:valid_time, valid_time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
defp time_ago(dt) do
seconds = DateTime.diff(DateTime.utc_now(), dt)
cond do
seconds < 60 -> "just now"
seconds < 3600 -> "#{div(seconds, 60)}m ago"
seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
true -> "#{div(seconds, 86_400)}d ago"
end
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="[]"
class="absolute inset-0 z-0"
>
</div>
<%!-- Top-left controls overlay --%>
<div class="absolute top-3 left-14 z-[1000] flex items-center gap-2">
<.link navigate="/" class="btn btn-sm bg-base-100/90 shadow border-base-300">
<.icon name="hero-home" class="size-4" />
</.link>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm bg-base-100/90 shadow border-base-300">
<.icon name="hero-signal" class="size-4" />
{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
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
|> JS.dispatch("click", to: "#propagation-map")
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<div :if={@valid_time} class="badge badge-ghost bg-base-100/90 shadow text-xs">
Latest data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end