<.theme_toggle />
diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex
new file mode 100644
index 00000000..b637816f
--- /dev/null
+++ b/lib/microwaveprop_web/live/map_live.ex
@@ -0,0 +1,96 @@
+defmodule MicrowavepropWeb.MapLive do
+ @moduledoc false
+ use MicrowavepropWeb, :live_view
+
+ alias Microwaveprop.Propagation
+ alias Microwaveprop.Propagation.BandConfig
+
+ @default_band 10_000
+ @refresh_interval_ms to_timeout(minute: 5)
+
+ @impl true
+ def mount(_params, _session, socket) do
+ bands = BandConfig.all_bands()
+ scores = Propagation.latest_scores(@default_band)
+ valid_time = Propagation.latest_valid_time()
+
+ if connected?(socket) do
+ Process.send_after(self(), :refresh, @refresh_interval_ms)
+ end
+
+ {:ok,
+ assign(socket,
+ page_title: "Propagation Map",
+ bands: bands,
+ selected_band: @default_band,
+ scores: scores,
+ valid_time: valid_time
+ )}
+ end
+
+ @impl true
+ def handle_event("select_band", %{"value" => band_str}, socket) do
+ band = String.to_integer(band_str)
+ scores = Propagation.latest_scores(band)
+
+ socket =
+ socket
+ |> assign(:selected_band, band)
+ |> assign(:scores, scores)
+ |> push_event("update_scores", %{scores: scores})
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_info(:refresh, socket) do
+ scores = Propagation.latest_scores(socket.assigns.selected_band)
+ valid_time = Propagation.latest_valid_time()
+ Process.send_after(self(), :refresh, @refresh_interval_ms)
+
+ socket =
+ socket
+ |> assign(:scores, scores)
+ |> assign(:valid_time, valid_time)
+ |> push_event("update_scores", %{scores: scores})
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
Propagation Map
+
+
+
+
+ Updated: {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/live/map_live.hooks.js b/lib/microwaveprop_web/live/map_live.hooks.js
new file mode 100644
index 00000000..7aada7b9
--- /dev/null
+++ b/lib/microwaveprop_web/live/map_live.hooks.js
@@ -0,0 +1,87 @@
+export const PropagationMap = {
+ mounted() {
+ this.map = L.map(this.el, {
+ center: [38.0, -96.0],
+ zoom: 5,
+ minZoom: 4,
+ maxZoom: 10
+ })
+
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ attribution: "© OpenStreetMap contributors",
+ maxZoom: 19
+ }).addTo(this.map)
+
+ this.scoreLayer = L.layerGroup().addTo(this.map)
+
+ this.colorScale = [
+ { min: 80, color: "#00ffa3" },
+ { min: 65, color: "#7dffd4" },
+ { min: 50, color: "#ffe566" },
+ { min: 33, color: "#ff9044" },
+ { min: 0, color: "#ff4f4f" }
+ ]
+
+ const initialScores = JSON.parse(this.el.dataset.scores || "[]")
+ this.renderScores(initialScores)
+
+ this.handleEvent("update_scores", ({ scores }) => {
+ this.renderScores(scores)
+ })
+
+ const legend = L.control({ position: "bottomright" })
+ legend.onAdd = () => {
+ const div = L.DomUtil.create("div", "leaflet-legend")
+ div.innerHTML = `
+
+ Propagation
+ Excellent (80-100)
+ Good (65-79)
+ Marginal (50-64)
+ Poor (33-49)
+ Negligible (0-32)
+
+ `
+ return div
+ }
+ legend.addTo(this.map)
+ },
+
+ renderScores(scores) {
+ this.scoreLayer.clearLayers()
+ scores.forEach(({ lat, lon, score }) => {
+ const color = this.scoreColor(score)
+ const circle = L.circleMarker([lat, lon], {
+ radius: 6,
+ fillColor: color,
+ fillOpacity: 0.7,
+ color: color,
+ weight: 0
+ })
+ circle.bindPopup(
+ `
Score: ${score}/100${this.scoreTier(score)}
` +
+ `
${lat.toFixed(3)}\u00b0N, ${Math.abs(lon).toFixed(3)}\u00b0W`
+ )
+ this.scoreLayer.addLayer(circle)
+ })
+ },
+
+ scoreColor(score) {
+ for (const tier of this.colorScale) {
+ if (score >= tier.min) return tier.color
+ }
+ return "#ff4f4f"
+ },
+
+ scoreTier(score) {
+ if (score >= 80) return "EXCELLENT"
+ if (score >= 65) return "GOOD"
+ if (score >= 50) return "MARGINAL"
+ if (score >= 33) return "POOR"
+ return "NEGLIGIBLE"
+ },
+
+ destroyed() {
+ if (this.map) this.map.remove()
+ }
+}
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index 5b75f1f4..b4cfd872 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -20,6 +20,7 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home
live "/submit", SubmitLive
+ live "/map", MapLive
live "/qsos", QsoLive.Index
live "/qsos/:id", QsoLive.Show
end
diff --git a/test/microwaveprop/propagation/band_config_test.exs b/test/microwaveprop/propagation/band_config_test.exs
new file mode 100644
index 00000000..604ce247
--- /dev/null
+++ b/test/microwaveprop/propagation/band_config_test.exs
@@ -0,0 +1,28 @@
+defmodule Microwaveprop.Propagation.BandConfigTest do
+ use ExUnit.Case, async: true
+
+ alias Microwaveprop.Propagation.BandConfig
+
+ describe "all_bands/0" do
+ test "returns 8 bands" do
+ assert length(BandConfig.all_bands()) == 8
+ end
+
+ test "each band has freq_mhz and label keys" do
+ for band <- BandConfig.all_bands() do
+ assert is_integer(band.freq_mhz)
+ assert is_binary(band.label)
+ end
+ end
+
+ test "includes 10 GHz band" do
+ bands = BandConfig.all_bands()
+ assert Enum.any?(bands, &(&1.freq_mhz == 10_000))
+ end
+
+ test "bands are ordered by frequency" do
+ freqs = Enum.map(BandConfig.all_bands(), & &1.freq_mhz)
+ assert freqs == Enum.sort(freqs)
+ end
+ end
+end
diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs
new file mode 100644
index 00000000..b1734291
--- /dev/null
+++ b/test/microwaveprop/propagation_test.exs
@@ -0,0 +1,21 @@
+defmodule Microwaveprop.PropagationTest do
+ use ExUnit.Case, async: true
+
+ alias Microwaveprop.Propagation
+
+ describe "latest_scores/1" do
+ test "returns a list" do
+ assert is_list(Propagation.latest_scores(10_000))
+ end
+
+ test "returns empty list for stub implementation" do
+ assert Propagation.latest_scores(10_000) == []
+ end
+ end
+
+ describe "latest_valid_time/0" do
+ test "returns nil for stub implementation" do
+ assert Propagation.latest_valid_time() == nil
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs
new file mode 100644
index 00000000..498b49e3
--- /dev/null
+++ b/test/microwaveprop_web/live/map_live_test.exs
@@ -0,0 +1,46 @@
+defmodule MicrowavepropWeb.MapLiveTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ describe "mount" do
+ test "renders the propagation map page", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "Propagation Map"
+ assert html =~ "propagation-map"
+ end
+
+ test "renders all band selector buttons", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "10 GHz"
+ assert html =~ "24 GHz"
+ assert html =~ "1296 MHz"
+ assert html =~ "75 GHz"
+ end
+
+ test "defaults to 10 GHz band selected", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "btn-primary"
+ end
+
+ test "includes map container with data-scores", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ ~s(id="propagation-map")
+ assert html =~ "data-scores"
+ assert html =~ "phx-hook"
+ end
+ end
+
+ describe "select_band" do
+ test "updates selected band on click", %{conn: conn} do
+ {:ok, lv, _html} = live(conn, ~p"/map")
+
+ html =
+ lv
+ |> element("button[value='24000']")
+ |> render_click()
+
+ assert html =~ "24 GHz"
+ end
+ end
+end