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.
128 lines
3.8 KiB
TypeScript
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 "&"
|
|
case "<": return "<"
|
|
case ">": return ">"
|
|
case "\"": return """
|
|
case "'": return "'"
|
|
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: "© 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 →</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)
|
|
}
|
|
}
|