Owner / admin click "Edit" on /rover-locations/:id and the marker
becomes draggable. As they drag, the JS hook pushes `location_dragged`
events with the new lat/lon; the LiveView updates `working_lat` /
`working_lon` / `grid` so the page shows the live preview without
writing to the DB. "Save" persists via Rover.update_location, "Cancel"
reverts both the assigns and the marker position.
Implementation notes:
- Switched the map marker from L.circleMarker to a draggable L.marker
with a divIcon (CircleMarker has no `dragging` handler).
- A second `draggingDotIcon` (amber, larger, grab cursor) makes the
edit affordance obvious.
- Server <-> hook coordination uses push_event:
set_marker_draggable / reset_marker. Drag results come back on
pushEvent("location_dragged", { lat, lon }).
- Coordinates row binds to working_lat/working_lon so the displayed
decimals + derived grid update on every drag, not only on save.
149 lines
5 KiB
TypeScript
149 lines
5 KiB
TypeScript
import type { ViewHook } from "phoenix_live_view"
|
|
import { updateGridOverlay } from "./maidenhead_grid"
|
|
|
|
interface LocationMapHook extends ViewHook {
|
|
map: L.Map
|
|
marker: L.Marker
|
|
gridLayer: L.LayerGroup
|
|
gridVisible: boolean
|
|
visibilityHandler: (() => void) | null
|
|
}
|
|
|
|
// Same red dot the old CircleMarker rendered, recreated as a divIcon so
|
|
// we can drop it on a real L.Marker (CircleMarker has no `dragging`
|
|
// handler and can't be made draggable).
|
|
const dotIcon = L.divIcon({
|
|
html:
|
|
'<div style="width:16px;height:16px;border-radius:50%;background:#ef4444;border:2px solid #fff;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
|
className: "rover-location-dot",
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8]
|
|
})
|
|
|
|
const draggingDotIcon = L.divIcon({
|
|
html:
|
|
'<div style="width:18px;height:18px;border-radius:50%;background:#f59e0b;border:2px solid #fff;box-shadow:0 0 6px rgba(0,0,0,.5);cursor:grab"></div>',
|
|
className: "rover-location-dot rover-location-dot-edit",
|
|
iconSize: [18, 18],
|
|
iconAnchor: [9, 9]
|
|
})
|
|
|
|
export const LocationMap: Partial<LocationMapHook> = {
|
|
mounted(this: LocationMapHook) {
|
|
const lat = parseFloat(this.el.dataset.lat || "0")
|
|
const lon = parseFloat(this.el.dataset.lon || "0")
|
|
|
|
const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19
|
|
})
|
|
const satellite = L.tileLayer(
|
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
|
{ maxZoom: 21, maxNativeZoom: 19 }
|
|
)
|
|
const topo = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 17
|
|
})
|
|
|
|
const map = L.map(this.el, {
|
|
center: [lat, lon],
|
|
zoom: 16,
|
|
zoomControl: true,
|
|
attributionControl: false
|
|
})
|
|
this.map = map
|
|
|
|
satellite.addTo(map)
|
|
|
|
L.control.layers(
|
|
{ "Satellite": satellite, "OSM": osm, "Topo": topo },
|
|
{},
|
|
{ position: "topright", collapsed: false }
|
|
).addTo(map)
|
|
|
|
// Draggable Marker (not CircleMarker — that one can't be dragged).
|
|
// Server flips draggability via `set_marker_draggable` push_event.
|
|
this.marker = L.marker([lat, lon], { icon: dotIcon, draggable: false }).addTo(map)
|
|
this.marker.bindPopup(`${lat.toFixed(6)}, ${lon.toFixed(6)}`)
|
|
|
|
this.marker.on("drag", (e: L.LeafletEvent) => {
|
|
const ll = (e.target as L.Marker).getLatLng()
|
|
this.marker.setPopupContent(`${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`)
|
|
})
|
|
|
|
// dragend pushes the new coords up so the LiveView can update its
|
|
// `working_lat` / `working_lon` and render the live grid preview.
|
|
this.marker.on("dragend", (e: L.DragEndEvent) => {
|
|
const ll = (e.target as L.Marker).getLatLng()
|
|
this.pushEvent("location_dragged", { lat: ll.lat, lon: ll.lng })
|
|
})
|
|
|
|
// 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)
|
|
|
|
// Server → hook events for edit mode.
|
|
this.handleEvent("set_marker_draggable", ({ draggable }: { draggable: boolean }) => {
|
|
if (draggable) {
|
|
this.marker.setIcon(draggingDotIcon)
|
|
this.marker.dragging?.enable()
|
|
} else {
|
|
this.marker.setIcon(dotIcon)
|
|
this.marker.dragging?.disable()
|
|
}
|
|
})
|
|
|
|
this.handleEvent("reset_marker", ({ lat, lon }: { lat: number; lon: number }) => {
|
|
this.marker.setLatLng([lat, lon])
|
|
this.marker.setPopupContent(`${lat.toFixed(6)}, ${lon.toFixed(6)}`)
|
|
})
|
|
|
|
// LiveView reconnect / tab visibility — Leaflet needs invalidateSize.
|
|
this.visibilityHandler = () => {
|
|
if (document.visibilityState === "visible") this.map.invalidateSize()
|
|
}
|
|
document.addEventListener("visibilitychange", this.visibilityHandler)
|
|
|
|
setTimeout(() => map.invalidateSize(), 50)
|
|
},
|
|
|
|
reconnected(this: LocationMapHook) {
|
|
this.map?.invalidateSize()
|
|
},
|
|
|
|
destroyed(this: LocationMapHook) {
|
|
if (this.visibilityHandler) {
|
|
document.removeEventListener("visibilitychange", this.visibilityHandler)
|
|
this.visibilityHandler = null
|
|
}
|
|
this.map?.remove()
|
|
}
|
|
}
|