From c3a5cf4b1a7410916407f2d0297a077031a1ff67 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 31 Mar 2026 10:15:21 -0500 Subject: [PATCH] Add Maidenhead grid overlay, consolidate map controls, improve UX - Port grid square rendering from gridmap-web with toggle control - Consolidate band selector, grid toggle, data timestamp, and links into unified control panel - Add "How this works" (/algo) and "Submit a QSO" (/submit) links - Add back-to-map link on algo page - Darken range circles for visibility over propagation overlay - Fix popup blocking double-click zoom with pointer-events passthrough - Prevent control panel from intercepting map click/scroll events --- assets/css/app.css | 4 + assets/js/propagation_map_hook.js | 182 ++++++++++++++++-- lib/microwaveprop_web/live/algo_live.ex | 5 + lib/microwaveprop_web/live/map_live.ex | 99 +++++++--- test/microwaveprop_web/live/map_live_test.exs | 31 +++ 5 files changed, 272 insertions(+), 49 deletions(-) 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" /> - - -