feat(rover-locations): /rover-locations/map page with Maidenhead overlay

Adds a full-screen map view rendering every Good rover-location as a
green circle marker (popup links to the detail page). Uses the standard
OSM/Satellite + Maidenhead grid overlay pattern. Bad locations are
excluded. Adds a Map button next to Add location on the index header.
This commit is contained in:
Graham McIntire 2026-05-03 11:14:58 -05:00
parent 209244f364
commit 3c9fbcdec6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 237 additions and 1 deletions

View file

@ -21,6 +21,7 @@ import {WeatherMap} from "./weather_map_hook"
import {LocateMe, CopyLink} from "./locate_me_hook"
import {RoverMap} from "./rover_map_hook"
import {LocationMap} from "./location_map_hook"
import {RoverLocationsMap} from "./rover_locations_map_hook"
import {RoverSlider} from "./rover_slider_hook"
import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_hook"
import {BeaconMap} from "./beacon_map_hook"
@ -314,7 +315,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: initLiveStash({_csrf_token: csrfToken}),
hooks: {...colocatedHooks, PropagationMap, PathForecast, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, LocationMap, RoverSlider, HorizontalWheelScroll, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt},
hooks: {...colocatedHooks, PropagationMap, PathForecast, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, LocationMap, RoverLocationsMap, RoverSlider, HorizontalWheelScroll, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt},
})
// live_table rows are dead on their own — wire up whole-row clicks to

View file

@ -0,0 +1,128 @@
// Map used by /rover-locations/map — plots every "good" rover-location
// as a green circle marker with a popup linking to the detail page.
import { updateGridOverlay } from "./maidenhead_grid"
interface RoverPoint {
id: string
lat: number
lon: number
grid: string
notes: string | null
}
interface RoverLocationsMapHook extends ViewHook {
map: L.Map
gridLayer: L.LayerGroup
gridVisible: boolean
mounted(this: RoverLocationsMapHook): void
}
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, ch => {
switch (ch) {
case "&": return "&amp;"
case "<": return "&lt;"
case ">": return "&gt;"
case "\"": return "&quot;"
case "'": return "&#39;"
default: return ch
}
})
}
export const RoverLocationsMap = {
mounted(this: RoverLocationsMapHook) {
const points: RoverPoint[] = JSON.parse(this.el.dataset.points || "[]")
const defaultCenter: [number, number] = [32.897, -97.038]
const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors"
})
const satellite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ maxZoom: 21, maxNativeZoom: 19 }
)
const map = L.map(this.el, {
zoomControl: true,
scrollWheelZoom: true,
attributionControl: true,
layers: [osm]
}).setView(defaultCenter, 6)
this.map = map
L.control.layers(
{ "OSM": osm, "Satellite": satellite },
{},
{ position: "topright", collapsed: false }
).addTo(map)
// Maidenhead grid overlay + toggle.
this.gridLayer = L.layerGroup().addTo(map)
this.gridVisible = true
updateGridOverlay(map, this.gridLayer)
map.on("moveend", () => {
if (this.gridVisible) updateGridOverlay(map, this.gridLayer)
})
const GridToggle = L.Control.extend({
options: { position: "topright" as const },
onAdd: () => {
const btn = L.DomUtil.create("button", "leaflet-bar leaflet-control")
btn.innerHTML = "Grid"
btn.title = "Toggle Maidenhead grid"
btn.style.cssText =
"background:#fff;padding:4px 8px;font-size:12px;cursor:pointer;font-weight:600;"
L.DomEvent.disableClickPropagation(btn)
btn.onclick = () => {
this.gridVisible = !this.gridVisible
if (this.gridVisible) {
this.gridLayer.addTo(map)
updateGridOverlay(map, this.gridLayer)
btn.style.opacity = "1"
} else {
this.gridLayer.remove()
btn.style.opacity = "0.5"
}
}
return btn
}
})
new GridToggle().addTo(map)
const bounds: [number, number][] = []
for (const p of points) {
if (!Number.isFinite(p.lat) || !Number.isFinite(p.lon)) continue
const popupHtml =
`<div style="font-size:12px;line-height:1.5;min-width:160px;">` +
`<strong>${escapeHtml(p.grid)}</strong><br/>` +
`${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}<br/>` +
(p.notes ? `<div style="margin-top:4px;white-space:pre-line;">${escapeHtml(p.notes)}</div>` : "") +
`<a href="/rover-locations/${p.id}" style="color:#3b82f6;">View details &rarr;</a>` +
`</div>`
L.circleMarker([p.lat, p.lon], {
radius: 7,
color: "#15803d",
fillColor: "#22c55e",
fillOpacity: 0.9,
weight: 2
})
.bindPopup(popupHtml, { closeButton: true })
.bindTooltip(p.grid, { direction: "top", offset: [0, -8] })
.addTo(map)
bounds.push([p.lat, p.lon])
}
if (bounds.length > 0) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 10 })
}
setTimeout(() => map.invalidateSize(), 50)
}
}

View file

@ -357,6 +357,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
Rover Locations
<:subtitle>Community-shared parking spots for portable rover ops.</:subtitle>
<:actions>
<.link navigate={~p"/rover-locations/map"} class="btn btn-ghost">
<.icon name="hero-map" class="w-5 h-5" /> Map
</.link>
<button
type="button"
phx-click="new"

View file

@ -0,0 +1,68 @@
defmodule MicrowavepropWeb.RoverLocationsLive.Map do
@moduledoc """
`/rover-locations/map` full-screen map of every rover-location
marked Good. Each marker links to its detail page.
"""
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
@impl true
def mount(_params, _session, socket) do
points =
Location
|> where([l], l.status == :good)
|> Repo.all()
|> Enum.map(&point_payload/1)
{:ok,
assign(socket,
page_title: "Rover Locations Map",
points: points,
points_json: Jason.encode!(points)
)}
end
defp point_payload(%Location{} = loc) do
%{
id: loc.id,
lat: loc.lat,
lon: loc.lon,
grid: Maidenhead.from_latlon(loc.lat, loc.lon, 6),
notes: loc.notes
}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-7xl">
<.header>
Rover Locations Map
<:subtitle>
{length(@points)} good location{if length(@points) == 1, do: "", else: "s"}
click a marker for details.
</:subtitle>
<:actions>
<.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm">
<.icon name="hero-list-bullet" class="w-4 h-4" /> List
</.link>
</:actions>
</.header>
<div
id="rover-locations-map"
phx-hook="RoverLocationsMap"
phx-update="ignore"
data-points={@points_json}
class="h-[75vh] rounded border border-base-300 z-0"
>
</div>
</Layouts.app>
"""
end
end

View file

@ -197,6 +197,7 @@ defmodule MicrowavepropWeb.Router do
live "/skewt", SkewtLive
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
live "/rover-locations/map", RoverLocationsLive.Map
live "/rover-locations/:id", RoverLocationsLive.Show
live "/rover-planning", RoverPlanningLive
live "/rover-planning/new", RoverPlanningLive.Form, :new

View file

@ -0,0 +1,35 @@
defmodule MicrowavepropWeb.RoverLocationsLive.MapTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Rover
test "renders the map shell with a count + JS hook container", %{conn: conn} do
user = AccountsFixtures.user_fixture()
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
{:ok, _} = Rover.create_location(user, %{lat: 33.0, lon: -98.0, status: :good})
# `bad` rows must NOT show up on the map.
{:ok, _} = Rover.create_location(user, %{lat: 34.0, lon: -99.0, status: :bad})
{:ok, lv, html} = live(conn, ~p"/rover-locations/map")
assert html =~ "Rover Locations Map"
assert html =~ "2 good locations"
assert has_element?(lv, "#rover-locations-map[phx-hook=RoverLocationsMap]")
# Marker payload only includes the two good rows.
refute html =~ ~s("lat":34.0)
end
test "List link returns to /rover-locations", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/rover-locations/map")
assert has_element?(lv, "a[href='/rover-locations']", "List")
end
test "/rover-locations renders a Map link", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/rover-locations")
assert has_element?(lv, "a[href='/rover-locations/map']", "Map")
end
end