From b4594fa71f113ebe0fba9d20a6466fd5cd56fd9c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 8 Apr 2026 09:04:44 -0500 Subject: [PATCH] Add grid squares toggle to rover planner Extract shared Maidenhead grid overlay into maidenhead_grid.js so both the main map and rover planner use the same implementation. Wire up the toggle_grid event and checkbox on the rover page matching the main map. --- assets/js/maidenhead_grid.js | 122 ++++++++++++++++++++++ assets/js/propagation_map_hook.js | 123 +---------------------- assets/js/rover_map_hook.js | 17 ++++ lib/microwaveprop_web/live/rover_live.ex | 24 ++++- 4 files changed, 163 insertions(+), 123 deletions(-) create mode 100644 assets/js/maidenhead_grid.js 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 %> + +