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:
parent
209244f364
commit
3c9fbcdec6
6 changed files with 237 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
128
assets/js/rover_locations_map_hook.ts
Normal file
128
assets/js/rover_locations_map_hook.ts
Normal 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 "&"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
68
lib/microwaveprop_web/live/rover_locations_live/map.ex
Normal file
68
lib/microwaveprop_web/live/rover_locations_live/map.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue