diff --git a/assets/css/app.css b/assets/css/app.css
index 23c7fa9a..aa5bfd81 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -228,4 +228,8 @@
text-decoration: underline;
}
+/* Allow map interaction through non-content parts of Leaflet popups */
+.leaflet-popup { pointer-events: none; }
+.leaflet-popup-content-wrapper, .leaflet-popup-tip, .leaflet-popup-close-button { pointer-events: auto; }
+
/* This file is for your main application CSS */
diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js
index 62d0a6d6..8313e791 100644
--- a/assets/js/propagation_map_hook.js
+++ b/assets/js/propagation_map_hook.js
@@ -74,6 +74,130 @@ function buildPopupHTML(detail) {
`
}
+// --- Maidenhead Grid Square ---
+
+class GridSquare {
+ constructor(lat, lon) {
+ this.lat = lat
+ this.lon = ((lon % 360) + 540) % 360 - 180
+ }
+
+ encode(precision = 4) {
+ const adjLon = this.lon + 180
+ const adjLat = this.lat + 90
+ let g = ""
+ g += String.fromCharCode(65 + Math.floor(adjLon / 20))
+ g += String.fromCharCode(65 + Math.floor(adjLat / 10))
+ g += Math.floor((adjLon % 20) / 2)
+ g += Math.floor(adjLat % 10)
+ if (precision > 4) {
+ g += String.fromCharCode(97 + Math.floor(((adjLon % 2) * 60) / 5))
+ g += String.fromCharCode(97 + Math.floor(((adjLat % 1) * 60) / 2.5))
+ }
+ return g
+ }
+
+ static decode(grid) {
+ const f1 = grid.charCodeAt(0) - 65
+ const f2 = grid.charCodeAt(1) - 65
+ let west = f1 * 20 - 180
+ let south = f2 * 10 - 90
+ let w = 20, h = 10
+
+ if (grid.length >= 4) {
+ west += parseInt(grid[2]) * 2
+ south += parseInt(grid[3])
+ w = 2; h = 1
+ }
+ if (grid.length >= 6) {
+ west += (grid.charCodeAt(4) - 97) * 5 / 60
+ south += (grid.charCodeAt(5) - 97) * 2.5 / 60
+ w = 5 / 60; h = 2.5 / 60
+ }
+ return {
+ west, east: west + w, south, north: south + h,
+ center: { lat: south + h / 2, lon: west + w / 2 }
+ }
+ }
+}
+
+function drawGridSquare(grid, precision, bounds, zoom, layerGroup, drawnSet, map) {
+ if (grid.length < 2 || drawnSet.has(grid)) return
+ drawnSet.add(grid)
+
+ const c = GridSquare.decode(grid)
+ if (c.west > bounds.getEast() || c.east < bounds.getWest() ||
+ c.south > bounds.getNorth() || c.north < bounds.getSouth()) return
+
+ const field = precision <= 2
+
+ layerGroup.addLayer(L.rectangle(
+ [[c.south, c.west], [c.north, c.east]],
+ { color: "#E63946", weight: field ? 2.5 : 2, opacity: 0.8, fillOpacity: 0, interactive: false }
+ ))
+
+ const sw = map.latLngToContainerPoint([c.south, c.west])
+ const ne = map.latLngToContainerPoint([c.north, c.east])
+ const px = Math.abs(ne.x - sw.x)
+ const minW = field ? 30 : 40
+ if (px > minW) {
+ const len = field ? 2 : 4
+ const text = grid.substring(0, len)
+ const fs = Math.max(10, Math.min(14, Math.round(px / (len * 1.2))))
+ layerGroup.addLayer(L.marker([c.center.lat, c.center.lon], {
+ interactive: false,
+ icon: L.divIcon({
+ className: "grid-label",
+ html: `
${text}
`,
+ iconSize: [Math.round(len * fs * 0.8), Math.round(fs * 1.5)],
+ iconAnchor: [Math.round(len * fs * 0.4), Math.round(fs * 0.75)]
+ })
+ }))
+ }
+}
+
+function updateGridOverlay(map, gridLayer) {
+ gridLayer.clearLayers()
+ const drawn = new Set()
+ const bounds = map.getBounds()
+ const zoom = map.getZoom()
+ const showSquares = zoom >= 7
+
+ const south = Math.max(bounds.getSouth(), -90)
+ const north = Math.min(bounds.getNorth(), 90)
+
+ if (!showSquares) {
+ const fW = Math.floor((bounds.getWest() + 180) / 20) * 20 - 180
+ const fE = Math.ceil((bounds.getEast() + 180) / 20) * 20 - 180
+ const fS = Math.floor((south + 90) / 10) * 10 - 90
+ const fN = Math.ceil((north + 90) / 10) * 10 - 90
+ for (let lon = fW; lon < fE; lon += 20) {
+ for (let lat = fS; lat < fN; lat += 10) {
+ const nLon = ((lon % 360) + 540) % 360 - 180
+ const g = new GridSquare(lat + 5, nLon + 10).encode(4).substring(0, 2)
+ drawGridSquare(g, 2, bounds, zoom, gridLayer, drawn, map)
+ }
+ }
+ return
+ }
+
+ const wE = Math.floor((bounds.getWest() + 180) / 2) * 2 - 180
+ const eE = Math.ceil((bounds.getEast() + 180) / 2) * 2 - 180
+ const sE = Math.floor(south + 90) - 90
+ const nE = Math.ceil(north + 90) - 90
+ if (Math.ceil((eE - wE) / 2) * Math.ceil(nE - sE) > 500) return
+
+ for (let lon = wE; lon < eE; lon += 2) {
+ for (let lat = sE; lat < nE; lat += 1) {
+ const nLon = ((lon % 360) + 540) % 360 - 180
+ const g = new GridSquare(lat + 0.5, nLon + 1).encode(4)
+ drawGridSquare(g.substring(0, 4), 4, bounds, zoom, gridLayer, drawn, map)
+ }
+ }
+}
+
+// --- Hook ---
+
export const PropagationMap = {
mounted() {
this.map = L.map(this.el, {
@@ -112,7 +236,29 @@ export const PropagationMap = {
this.bandInfo = band_info
})
- // Click on map to show factor breakdown + range circle — fully client-side
+ // Prevent control panel from eating map clicks/scrolls
+ const controls = document.getElementById("map-controls")
+ if (controls) {
+ L.DomEvent.disableClickPropagation(controls)
+ L.DomEvent.disableScrollPropagation(controls)
+ }
+
+ // Grid overlay layer
+ this.gridLayer = L.layerGroup()
+ this.gridVisible = false
+
+ // Listen for grid toggle from LiveView
+ this.handleEvent("toggle_grid", ({ visible }) => {
+ this.gridVisible = visible
+ if (visible) {
+ this.gridLayer.addTo(this.map)
+ updateGridOverlay(this.map, this.gridLayer)
+ } else {
+ this.gridLayer.remove()
+ }
+ })
+
+ // Click on map to show factor breakdown + range circle
this.map.on("click", (e) => {
const detail = this.lookupPoint(e.latlng.lat, e.latlng.lng)
if (detail) {
@@ -124,13 +270,20 @@ export const PropagationMap = {
}
})
- // Clear range circles when popup closes
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
+ // Close popup on double-click so zoom works through it
+ this.map.on("dblclick", () => this.map.closePopup())
+
this.el.addEventListener("show-loading", () => topbar.show(300))
this.sendBounds()
- this.map.on("moveend", () => this.sendBounds())
+ this.map.on("moveend", () => {
+ this.sendBounds()
+ if (this.gridVisible) {
+ updateGridOverlay(this.map, this.gridLayer)
+ }
+ })
// Legend
const legend = L.control({ position: "bottomright" })
@@ -270,7 +423,6 @@ export const PropagationMap = {
const score = detail.score
const tier = scoreTier(score)
- // Compute estimated range in km based on score tier
let rangeKm
if (score >= 80) rangeKm = detail.exceptional_range_km
else if (score >= 65) rangeKm = detail.extended_range_km
@@ -280,32 +432,29 @@ export const PropagationMap = {
const typicalKm = detail.typical_range_km
- // Outer circle — max estimated range
L.circle(center, {
radius: rangeKm * 1000,
- color: tier.color,
- weight: 2,
- opacity: 0.8,
- fillColor: tier.color,
- fillOpacity: 0.08,
+ color: "#222",
+ weight: 3,
+ opacity: 0.9,
+ fillColor: "#000",
+ fillOpacity: 0.2,
dashArray: "8,6"
}).bindTooltip(`${rangeKm} km (max)`, { permanent: false, direction: "top" })
.addTo(this.rangeCircles)
- // Inner circle — typical range
if (typicalKm < rangeKm) {
L.circle(center, {
radius: typicalKm * 1000,
- color: tier.color,
- weight: 2,
+ color: "#222",
+ weight: 3,
opacity: 0.9,
- fillColor: tier.color,
- fillOpacity: 0.12
+ fillColor: "#000",
+ fillOpacity: 0.25
}).bindTooltip(`${typicalKm} km (typical)`, { permanent: false, direction: "top" })
.addTo(this.rangeCircles)
}
- // Center marker
L.circleMarker(center, {
radius: 6,
color: "#fff",
@@ -313,7 +462,6 @@ export const PropagationMap = {
fillColor: tier.color,
fillOpacity: 1
}).addTo(this.rangeCircles)
-
},
sendBounds() {
diff --git a/lib/microwaveprop_web/live/algo_live.ex b/lib/microwaveprop_web/live/algo_live.ex
index 3ed9a763..03431e32 100644
--- a/lib/microwaveprop_web/live/algo_live.ex
+++ b/lib/microwaveprop_web/live/algo_live.ex
@@ -18,6 +18,11 @@ defmodule MicrowavepropWeb.AlgoLive do
def render(assigns) do
~H"""
+
+ <.link navigate="/map" class="btn btn-lg btn-ghost text-lg">
+ <.icon name="hero-arrow-left" class="size-4" /> Back to map
+
+
{raw(@content)}
"""
diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex
index 697a6e71..53a59f18 100644
--- a/lib/microwaveprop_web/live/map_live.ex
+++ b/lib/microwaveprop_web/live/map_live.ex
@@ -23,7 +23,8 @@ defmodule MicrowavepropWeb.MapLive do
selected_band: @default_band,
scores: [],
valid_time: valid_time,
- bounds: nil
+ bounds: nil,
+ grid_visible: false
)}
end
@@ -42,6 +43,17 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
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("map_bounds", bounds, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band, bounds)
@@ -111,39 +123,62 @@ defmodule MicrowavepropWeb.MapLive do
>
- <%!-- Top-left controls overlay --%>
-
- <.link navigate="/" class="btn btn-sm bg-base-100/90 shadow border-base-300">
- <.icon name="hero-home" class="size-4" />
-
-
-
-
- <.icon name="hero-signal" class="size-4" />
- {selected_label(@bands, @selected_band)}
- <.icon name="hero-chevron-down" class="size-3" />
+ <%!-- Top-left control panel --%>
+
+
+ <%!-- Band selector --%>
+
+
+
+ <.icon name="hero-signal" class="size-4" />
+ {selected_label(@bands, @selected_band)}
+
+ <.icon name="hero-chevron-down" class="size-3" />
+
+
+
+ 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}
+
+
+
-
-
- 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}
-
-
-
-
-
- Latest data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
+ <%!-- Grid toggle --%>
+
+
+ Grid squares
+
+
+ <%!-- Latest data --%>
+
+ Data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
+
+
+ <%!-- Links --%>
+
+ <.link navigate="/algo" class="btn btn-xs btn-ghost justify-start gap-1.5">
+ <.icon name="hero-information-circle" class="size-3.5" /> How this works
+
+ <.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
+
+
diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs
index fe0e620e..48b226ce 100644
--- a/test/microwaveprop_web/live/map_live_test.exs
+++ b/test/microwaveprop_web/live/map_live_test.exs
@@ -28,6 +28,24 @@ defmodule MicrowavepropWeb.MapLiveTest do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "data-scores"
end
+
+ test "renders grid toggle", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "Grid squares"
+ assert html =~ ~s(phx-click="toggle_grid")
+ end
+
+ test "renders how this works link", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "How this works"
+ assert html =~ ~s(/algo)
+ end
+
+ test "renders submit QSO link", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/map")
+ assert html =~ "Submit a QSO"
+ assert html =~ ~s(/submit)
+ end
end
describe "select_band" do
@@ -39,4 +57,17 @@ defmodule MicrowavepropWeb.MapLiveTest do
assert html =~ "24 GHz"
end
end
+
+ describe "toggle_grid" do
+ test "toggles grid visibility", %{conn: conn} do
+ {:ok, lv, html} = live(conn, ~p"/map")
+ refute html =~ "checked"
+
+ html = render_click(lv, "toggle_grid")
+ assert html =~ "checked"
+
+ html = render_click(lv, "toggle_grid")
+ refute html =~ "checked"
+ end
+ end
end