Add grid toggle, use 0.25deg resolution for coverage scoring

- Toggle button for Maidenhead grid overlay (on by default)
- Coverage candidates now at 0.25° resolution (~28 km) instead of
  2°×1° Maidenhead grids, matching HRRR propagation grid density
- Coverage rectangles render as 0.25° cells for finer detail
- Grid overlay is separate from coverage coloring
This commit is contained in:
Graham McIntire 2026-04-07 16:31:37 -05:00
parent 79aca95bc1
commit caa03fed53
3 changed files with 62 additions and 22 deletions

View file

@ -19,6 +19,9 @@ export const RoverMap = {
this.stationMarkers.addTo(map)
this.coverageLayer.addTo(map)
this.gridLayer = L.layerGroup().addTo(map)
this.drawGridOverlay()
this.handleEvent("stations_updated", ({ stations }) => {
this.stations = stations
this.renderStations()
@ -27,6 +30,14 @@ export const RoverMap = {
this.handleEvent("coverage_updated", ({ grids }) => {
this.renderCoverage(grids)
})
this.handleEvent("toggle_grids", ({ show }) => {
if (show) {
this.gridLayer.addTo(this.map)
} else {
this.map.removeLayer(this.gridLayer)
}
})
},
renderStations() {
@ -64,10 +75,9 @@ export const RoverMap = {
const maxScore = Math.max(...grids.map(g => g.coverage_score))
for (const g of grids) {
// 4-char Maidenhead: 2° lon × 1° lat
// Compute grid square bounds from the grid letters
const bounds = this.gridBounds(g.grid)
if (!bounds) continue
// Render as 0.25° cells centered on the candidate point
const half = 0.125
const bounds = [[g.lat - half, g.lon - half], [g.lat + half, g.lon + half]]
const normalized = maxScore > 0 ? g.coverage_score / maxScore : 0
const color = this.coverageColor(normalized)
@ -149,6 +159,19 @@ export const RoverMap = {
return "#ff4f4f"
},
drawGridOverlay() {
for (let lat = 25; lat <= 50; lat += 1) {
L.polyline([[lat, -125], [lat, -66]], {
color: "#888", weight: 0.5, opacity: 0.3
}).addTo(this.gridLayer)
}
for (let lon = -125; lon <= -66; lon += 2) {
L.polyline([[25, lon], [50, lon]], {
color: "#888", weight: 0.5, opacity: 0.3
}).addTo(this.gridLayer)
}
},
fitBounds() {
const points = this.stations.map(s => [s.lat, s.lon])
if (points.length >= 2) {

View file

@ -79,20 +79,21 @@ defmodule Microwaveprop.Rover.Coverage do
min_lon = max(Enum.min(lons) - range_deg, -125.0)
max_lon = min(Enum.max(lons) + range_deg, -66.0)
# Generate at 4-char Maidenhead resolution (1° lat × 2° lon)
lat_range = Enum.to_list(trunc(min_lat)..trunc(max_lat))
lon_range = Enum.to_list(trunc(min_lon)..trunc(max_lon)//2)
# Generate candidates at 0.25° resolution (between HRRR 0.125° and Maidenhead 1-2°)
# This gives ~16x more candidates than Maidenhead but stays manageable
step = 0.25
# Use center of grid square
for_result =
for lat <- lat_range, lon <- lon_range do
clat = lat + 0.5
clon = lon + 1.0
grid = Geo.latlon_to_grid4(clat, clon)
{grid, clat, clon}
end
for lat <- float_range(min_lat, max_lat, step),
lon <- float_range(min_lon, max_lon, step) do
grid = Geo.latlon_to_grid4(lat, lon)
{grid, Float.round(lat, 3), Float.round(lon, 3)}
end
end
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
defp float_range(min, max, step) do
(Float.ceil(min / step) * step)
|> Stream.iterate(&(&1 + step))
|> Enum.take_while(&(&1 <= max))
end
defp fast_score_grid(grid, lat, lon, stations, band_mhz, max_range) do

View file

@ -32,7 +32,8 @@ defmodule MicrowavepropWeb.RoverLive do
selected_grid: nil,
resolving: false,
computing: false,
url_loaded: false
url_loaded: false,
show_grids: true
)}
end
@ -80,6 +81,11 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply, push_url(socket)}
end
def handle_event("toggle_grids", _params, socket) do
show = !socket.assigns.show_grids
{:noreply, socket |> assign(show_grids: show) |> push_event("toggle_grids", %{show: show})}
end
def handle_event("select_band", %{"band" => band_str}, socket) do
socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil)
{:noreply, push_url(socket)}
@ -254,11 +260,21 @@ defmodule MicrowavepropWeb.RoverLive do
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
<div class="font-bold text-sm">Rover Planner</div>
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<div class="flex gap-2">
<select phx-change="select_band" name="band" class="select select-bordered select-sm flex-1">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<button
type="button"
phx-click="toggle_grids"
class={["btn btn-sm btn-square", if(@show_grids, do: "btn-primary", else: "btn-ghost")]}
title="Toggle grid overlay"
>
<.icon name="hero-squares-2x2" class="size-4" />
</button>
</div>
<form phx-submit="add_station" class="flex gap-1">
<input