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 {LocateMe, CopyLink} from "./locate_me_hook"
|
||||||
import {RoverMap} from "./rover_map_hook"
|
import {RoverMap} from "./rover_map_hook"
|
||||||
import {LocationMap} from "./location_map_hook"
|
import {LocationMap} from "./location_map_hook"
|
||||||
|
import {RoverLocationsMap} from "./rover_locations_map_hook"
|
||||||
import {RoverSlider} from "./rover_slider_hook"
|
import {RoverSlider} from "./rover_slider_hook"
|
||||||
import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_hook"
|
import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_hook"
|
||||||
import {BeaconMap} from "./beacon_map_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, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: initLiveStash({_csrf_token: csrfToken}),
|
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
|
// 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
|
Rover Locations
|
||||||
<:subtitle>Community-shared parking spots for portable rover ops.</:subtitle>
|
<:subtitle>Community-shared parking spots for portable rover ops.</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
|
<.link navigate={~p"/rover-locations/map"} class="btn btn-ghost">
|
||||||
|
<.icon name="hero-map" class="w-5 h-5" /> Map
|
||||||
|
</.link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="new"
|
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 "/skewt", SkewtLive
|
||||||
live "/rover", RoverLive
|
live "/rover", RoverLive
|
||||||
live "/rover-locations", RoverLocationsLive
|
live "/rover-locations", RoverLocationsLive
|
||||||
|
live "/rover-locations/map", RoverLocationsLive.Map
|
||||||
live "/rover-locations/:id", RoverLocationsLive.Show
|
live "/rover-locations/:id", RoverLocationsLive.Show
|
||||||
live "/rover-planning", RoverPlanningLive
|
live "/rover-planning", RoverPlanningLive
|
||||||
live "/rover-planning/new", RoverPlanningLive.Form, :new
|
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