diff --git a/assets/js/app.ts b/assets/js/app.ts index a571f94a..edb37608 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/assets/js/rover_locations_map_hook.ts b/assets/js/rover_locations_map_hook.ts new file mode 100644 index 00000000..371d1f37 --- /dev/null +++ b/assets/js/rover_locations_map_hook.ts @@ -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 "&" + 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 = + `
` + + `${escapeHtml(p.grid)}
` + + `${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}
` + + (p.notes ? `
${escapeHtml(p.notes)}
` : "") + + `View details →` + + `
` + + 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) + } +} diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex index 4185a373..2087a364 100644 --- a/lib/microwaveprop_web/live/rover_locations_live.ex +++ b/lib/microwaveprop_web/live/rover_locations_live.ex @@ -357,6 +357,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive do Rover Locations <:subtitle>Community-shared parking spots for portable rover ops. <:actions> + <.link navigate={~p"/rover-locations/map"} class="btn btn-ghost"> + <.icon name="hero-map" class="w-5 h-5" /> Map +