prop/assets/js/rover_locations_map_hook.ts
Graham McIntire 3c9fbcdec6
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.
2026-05-03 11:14:58 -05:00

128 lines
3.8 KiB
TypeScript

// 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)
}
}