diff --git a/assets/js/maidenhead_grid.js b/assets/js/maidenhead_grid.js
new file mode 100644
index 00000000..7706a4b2
--- /dev/null
+++ b/assets/js/maidenhead_grid.js
@@ -0,0 +1,122 @@
+// Maidenhead grid square overlay shared between the main propagation map
+// and the rover planner map. Draws red field/square rectangles with labels.
+
+export 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, 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)]
+ })
+ }))
+ }
+}
+
+export 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, 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, gridLayer, drawn, map)
+ }
+ }
+}
diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js
index e1a30694..df980e35 100644
--- a/assets/js/propagation_map_hook.js
+++ b/assets/js/propagation_map_hook.js
@@ -1,4 +1,5 @@
import topbar from "../vendor/topbar"
+import { updateGridOverlay } from "./maidenhead_grid"
const FACTOR_META = {
humidity: { label: "Humidity", weight: 0.20, unit: "g/m\u00b3" },
@@ -347,128 +348,6 @@ function buildPopupHTML(detail, viewshedLoading) {
`
}
-// --- 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 = {
diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.js
index 94bcf1f7..d54d9918 100644
--- a/assets/js/rover_map_hook.js
+++ b/assets/js/rover_map_hook.js
@@ -1,9 +1,13 @@
+import { updateGridOverlay } from "./maidenhead_grid"
+
export const RoverMap = {
mounted() {
this.stations = []
this.stationMarkers = L.layerGroup()
this.scoreOverlay = null
this.gridLookup = new Map()
+ this.gridLayer = L.layerGroup()
+ this.gridVisible = false
this.colorScale = [
{ min: 80, r: 0, g: 255, b: 163 },
@@ -44,6 +48,9 @@ export const RoverMap = {
map.on("moveend", () => {
pushBounds()
+ if (this.gridVisible) {
+ updateGridOverlay(map, this.gridLayer)
+ }
})
this.handleEvent("update_scores", ({ scores }) => {
@@ -54,6 +61,16 @@ export const RoverMap = {
this.stations = stations
this.renderStations()
})
+
+ this.handleEvent("toggle_grid", ({ visible }) => {
+ this.gridVisible = visible
+ if (visible) {
+ this.gridLayer.addTo(map)
+ updateGridOverlay(map, this.gridLayer)
+ } else {
+ this.gridLayer.remove()
+ }
+ })
},
// --- Propagation score overlay (same as main map) ---
diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex
index f68e4c27..deb84c4d 100644
--- a/lib/microwaveprop_web/live/rover_live.ex
+++ b/lib/microwaveprop_web/live/rover_live.ex
@@ -38,7 +38,8 @@ defmodule MicrowavepropWeb.RoverLive do
resolving: false,
url_loaded: false,
initial_scores_json: Jason.encode!(initial_scores),
- bounds: @initial_bounds
+ bounds: @initial_bounds,
+ grid_visible: false
)}
end
@@ -98,6 +99,17 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply, push_url(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.band, bounds)
@@ -212,6 +224,16 @@ defmodule MicrowavepropWeb.RoverLive do
<% end %>
+
+