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
This commit is contained in:
parent
8f89bbdc02
commit
c3a5cf4b1a
5 changed files with 272 additions and 49 deletions
|
|
@ -228,4 +228,8 @@
|
||||||
text-decoration: underline;
|
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 */
|
/* This file is for your main application CSS */
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,130 @@ function buildPopupHTML(detail) {
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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: `<div style="font-size:${fs}px;font-weight:bold;color:#fff;background:rgba(0,0,0,0.5);padding:1px 4px;border-radius:3px;white-space:nowrap;width:fit-content;">${text}</div>`,
|
||||||
|
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 = {
|
export const PropagationMap = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.map = L.map(this.el, {
|
this.map = L.map(this.el, {
|
||||||
|
|
@ -112,7 +236,29 @@ export const PropagationMap = {
|
||||||
this.bandInfo = band_info
|
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) => {
|
this.map.on("click", (e) => {
|
||||||
const detail = this.lookupPoint(e.latlng.lat, e.latlng.lng)
|
const detail = this.lookupPoint(e.latlng.lat, e.latlng.lng)
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|
@ -124,13 +270,20 @@ export const PropagationMap = {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear range circles when popup closes
|
|
||||||
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
|
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.el.addEventListener("show-loading", () => topbar.show(300))
|
||||||
|
|
||||||
this.sendBounds()
|
this.sendBounds()
|
||||||
this.map.on("moveend", () => this.sendBounds())
|
this.map.on("moveend", () => {
|
||||||
|
this.sendBounds()
|
||||||
|
if (this.gridVisible) {
|
||||||
|
updateGridOverlay(this.map, this.gridLayer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Legend
|
// Legend
|
||||||
const legend = L.control({ position: "bottomright" })
|
const legend = L.control({ position: "bottomright" })
|
||||||
|
|
@ -270,7 +423,6 @@ export const PropagationMap = {
|
||||||
const score = detail.score
|
const score = detail.score
|
||||||
const tier = scoreTier(score)
|
const tier = scoreTier(score)
|
||||||
|
|
||||||
// Compute estimated range in km based on score tier
|
|
||||||
let rangeKm
|
let rangeKm
|
||||||
if (score >= 80) rangeKm = detail.exceptional_range_km
|
if (score >= 80) rangeKm = detail.exceptional_range_km
|
||||||
else if (score >= 65) rangeKm = detail.extended_range_km
|
else if (score >= 65) rangeKm = detail.extended_range_km
|
||||||
|
|
@ -280,32 +432,29 @@ export const PropagationMap = {
|
||||||
|
|
||||||
const typicalKm = detail.typical_range_km
|
const typicalKm = detail.typical_range_km
|
||||||
|
|
||||||
// Outer circle — max estimated range
|
|
||||||
L.circle(center, {
|
L.circle(center, {
|
||||||
radius: rangeKm * 1000,
|
radius: rangeKm * 1000,
|
||||||
color: tier.color,
|
color: "#222",
|
||||||
weight: 2,
|
weight: 3,
|
||||||
opacity: 0.8,
|
opacity: 0.9,
|
||||||
fillColor: tier.color,
|
fillColor: "#000",
|
||||||
fillOpacity: 0.08,
|
fillOpacity: 0.2,
|
||||||
dashArray: "8,6"
|
dashArray: "8,6"
|
||||||
}).bindTooltip(`${rangeKm} km (max)`, { permanent: false, direction: "top" })
|
}).bindTooltip(`${rangeKm} km (max)`, { permanent: false, direction: "top" })
|
||||||
.addTo(this.rangeCircles)
|
.addTo(this.rangeCircles)
|
||||||
|
|
||||||
// Inner circle — typical range
|
|
||||||
if (typicalKm < rangeKm) {
|
if (typicalKm < rangeKm) {
|
||||||
L.circle(center, {
|
L.circle(center, {
|
||||||
radius: typicalKm * 1000,
|
radius: typicalKm * 1000,
|
||||||
color: tier.color,
|
color: "#222",
|
||||||
weight: 2,
|
weight: 3,
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
fillColor: tier.color,
|
fillColor: "#000",
|
||||||
fillOpacity: 0.12
|
fillOpacity: 0.25
|
||||||
}).bindTooltip(`${typicalKm} km (typical)`, { permanent: false, direction: "top" })
|
}).bindTooltip(`${typicalKm} km (typical)`, { permanent: false, direction: "top" })
|
||||||
.addTo(this.rangeCircles)
|
.addTo(this.rangeCircles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center marker
|
|
||||||
L.circleMarker(center, {
|
L.circleMarker(center, {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
|
@ -313,7 +462,6 @@ export const PropagationMap = {
|
||||||
fillColor: tier.color,
|
fillColor: tier.color,
|
||||||
fillOpacity: 1
|
fillOpacity: 1
|
||||||
}).addTo(this.rangeCircles)
|
}).addTo(this.rangeCircles)
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
sendBounds() {
|
sendBounds() {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ defmodule MicrowavepropWeb.AlgoLive do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="markdown-content">
|
<div class="markdown-content">
|
||||||
|
<div class="mb-4">
|
||||||
|
<.link navigate="/map" class="btn btn-lg btn-ghost text-lg">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" /> Back to map
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
{raw(@content)}
|
{raw(@content)}
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
selected_band: @default_band,
|
selected_band: @default_band,
|
||||||
scores: [],
|
scores: [],
|
||||||
valid_time: valid_time,
|
valid_time: valid_time,
|
||||||
bounds: nil
|
bounds: nil,
|
||||||
|
grid_visible: false
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -42,6 +43,17 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
def handle_event("map_bounds", bounds, socket) do
|
||||||
scores = Propagation.latest_scores(socket.assigns.selected_band, bounds)
|
scores = Propagation.latest_scores(socket.assigns.selected_band, bounds)
|
||||||
|
|
||||||
|
|
@ -111,39 +123,62 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Top-left controls overlay --%>
|
<%!-- Top-left control panel --%>
|
||||||
<div class="absolute top-3 left-14 z-[1000] flex items-center gap-2">
|
<div id="map-controls" class="absolute top-3 left-14 z-[1000] flex flex-col gap-2">
|
||||||
<.link navigate="/" class="btn btn-sm bg-base-100/90 shadow border-base-300">
|
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-3 flex flex-col gap-2">
|
||||||
<.icon name="hero-home" class="size-4" />
|
<%!-- Band selector --%>
|
||||||
</.link>
|
<div class="dropdown">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||||||
<div class="dropdown">
|
<span class="flex items-center gap-1.5">
|
||||||
<div tabindex="0" role="button" class="btn btn-sm bg-base-100/90 shadow border-base-300">
|
<.icon name="hero-signal" class="size-4" />
|
||||||
<.icon name="hero-signal" class="size-4" />
|
{selected_label(@bands, @selected_band)}
|
||||||
{selected_label(@bands, @selected_band)}
|
</span>
|
||||||
<.icon name="hero-chevron-down" class="size-3" />
|
<.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>
|
||||||
<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">
|
<%!-- Grid toggle --%>
|
||||||
Latest data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
|
<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>
|
||||||
|
|
||||||
|
<%!-- Latest data --%>
|
||||||
|
<div :if={@valid_time} class="text-xs opacity-70 px-1">
|
||||||
|
Data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Links --%>
|
||||||
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
|
<.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>
|
||||||
|
<.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
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,24 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||||
assert html =~ "data-scores"
|
assert html =~ "data-scores"
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "select_band" do
|
describe "select_band" do
|
||||||
|
|
@ -39,4 +57,17 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
assert html =~ "24 GHz"
|
assert html =~ "24 GHz"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue