prop/assets/js/rover_map_hook.ts

372 lines
11 KiB
TypeScript

import type { ViewHook } from "phoenix_live_view"
interface Station {
callsign: string
lat: number
lon: number
selected: boolean
}
interface Cell {
lat: number
lon: number
score: number
tier_color: string
}
interface RoverMapHook extends ViewHook {
map: L.Map
osmLayer: L.TileLayer
topoLayer: L.TileLayer | null
layersControl: L.Control.Layers
stations: Station[]
stationMarkers: L.LayerGroup
homeMarker: L.CircleMarker | null
driveCircle: L.Circle | null
qualityLayer: L.GridLayer | null
selectedCandidateMarker: L.CircleMarker | null
cellLookup: Map<string, Cell>
homeLat: number
homeLon: number
driveRadiusKm: number
visibilityHandler: (() => void) | null
driveRadiusListener: ((e: Event) => void) | null
renderStations(this: RoverMapHook): void
buildQualityLayer(this: RoverMapHook): L.GridLayer
setDriveRadius(this: RoverMapHook, km: number): void
}
const TIER_RGB: Record<string, [number, number, number]> = {
"#16a34a": [22, 163, 74], // Excellent
"#eab308": [234, 179, 8], // Good
"#f97316": [249, 115, 22] // Marginal
}
const TIER_ORDER = ["#16a34a", "#eab308", "#f97316"]
function kmToM(km: number): number {
return km * 1000
}
function cellKey(lat: number, lon: number): string {
return `${lat.toFixed(3)},${lon.toFixed(3)}`
}
function tierForScore(score: number): string | null {
if (score >= 10) return "#16a34a"
if (score >= 3) return "#eab308"
if (score >= 0) return "#f97316"
return null
}
function buildStationMarker(station: Station): L.CircleMarker {
return L.circleMarker([station.lat, station.lon], {
radius: 4,
color: "#fff",
weight: 1.5,
fillColor: station.selected ? "#0ea5e9" : "#64748b",
fillOpacity: 0.95,
interactive: true
}).bindTooltip(station.callsign, {
permanent: true,
direction: "right",
offset: [6, 0],
className: "rover-station-label"
})
}
export const RoverMap: Partial<RoverMapHook> = {
mounted(this: RoverMapHook) {
this.stations = []
this.stationMarkers = L.layerGroup()
this.homeMarker = null
this.driveCircle = null
this.qualityLayer = null
this.selectedCandidateMarker = null
this.cellLookup = new Map()
this.topoLayer = null
const ds = this.el.dataset
this.homeLat = parseFloat(ds.homeLat || "32.5")
this.homeLon = parseFloat(ds.homeLon || "-97.5")
this.driveRadiusKm = parseFloat(ds.driveRadiusKm || "100")
const map = L.map(this.el, {
center: [this.homeLat, this.homeLon],
zoom: 8,
zoomControl: true,
attributionControl: false
})
this.map = map
this.osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
})
this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
maxZoom: 17
})
this.osmLayer.addTo(map)
this.layersControl = L.control.layers(
{ "OSM": this.osmLayer, "Topo": this.topoLayer },
undefined,
{ position: "topright" }
).addTo(map)
// Silently fall back if OpenTopoMap is unreachable.
this.topoLayer.on("tileerror", () => {
if (!this.topoLayer) return
if (map.hasLayer(this.topoLayer)) {
map.removeLayer(this.topoLayer)
this.osmLayer.addTo(map)
}
this.layersControl.removeLayer(this.topoLayer)
this.topoLayer = null
})
this.stationMarkers.addTo(map)
this.homeMarker = L.circleMarker([this.homeLat, this.homeLon], {
radius: 7,
color: "#ffffff",
weight: 2,
fillColor: "#0ea5e9",
fillOpacity: 0.95,
pane: "markerPane"
}).addTo(map)
this.homeMarker.bindTooltip("Home", {
permanent: true,
direction: "top",
offset: [0, -10],
className: "rover-home-label"
})
this.driveCircle = L.circle([this.homeLat, this.homeLon], {
radius: kmToM(this.driveRadiusKm),
color: "#0ea5e9",
weight: 1.5,
dashArray: "8 6",
fill: false
}).addTo(map)
L.control.scale({ metric: false, imperial: true, position: "bottomleft" }).addTo(map)
// Render initial stations from data attribute
try {
const initial: Station[] = JSON.parse(this.el.dataset.stations || "[]")
this.stations = initial
this.renderStations()
} catch {
this.stations = []
}
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
}
}
document.addEventListener("visibilitychange", this.visibilityHandler)
this.driveRadiusListener = (e: Event) => {
const km = (e as CustomEvent<{ km: number }>).detail?.km
if (typeof km === "number") this.setDriveRadius(km)
}
window.addEventListener("rover:drive-radius", this.driveRadiusListener)
this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => {
this.stations = stations
this.renderStations()
})
this.handleEvent("update_drive_radius", ({ km }: { km: number }) => {
this.setDriveRadius(km)
})
this.handleEvent("update_home", ({ lat, lon }: { lat: number; lon: number }) => {
this.homeLat = lat
this.homeLon = lon
if (this.homeMarker) this.homeMarker.setLatLng([lat, lon])
if (this.driveCircle) this.driveCircle.setLatLng([lat, lon])
this.map.flyTo([lat, lon], Math.max(this.map.getZoom(), 8), { duration: 0.6 })
})
this.handleEvent("rover_results", (
{ cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number }
) => {
this.cellLookup = new Map()
for (const c of cells) {
this.cellLookup.set(cellKey(c.lat, c.lon), c)
}
if (!this.qualityLayer) {
this.qualityLayer = this.buildQualityLayer()
this.qualityLayer.addTo(this.map)
} else {
this.qualityLayer.redraw()
}
this.setDriveRadius(drive_radius_km)
})
this.handleEvent("focus_cell", (
{ lat, lon, zoom }: { lat: number; lon: number; zoom: number }
) => {
// Drop a single highlight marker at the picked spot, replacing
// any previous one — clicking another candidate moves it.
if (this.selectedCandidateMarker) {
this.map.removeLayer(this.selectedCandidateMarker)
}
this.selectedCandidateMarker = L.circleMarker([lat, lon], {
radius: 8,
color: "#0ea5e9",
weight: 2,
fillColor: "#0ea5e9",
fillOpacity: 0.6,
pane: "markerPane"
}).addTo(this.map)
this.map.flyTo([lat, lon], zoom, { duration: 0.6 })
})
},
reconnected(this: RoverMapHook) {
if (this.map) this.map.invalidateSize()
},
destroyed(this: RoverMapHook) {
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
if (this.driveRadiusListener) {
window.removeEventListener("rover:drive-radius", this.driveRadiusListener)
this.driveRadiusListener = null
}
},
setDriveRadius(this: RoverMapHook, km: number) {
this.driveRadiusKm = km
if (this.driveCircle) {
this.driveCircle.setRadius(kmToM(km))
this.driveCircle.setLatLng([this.homeLat, this.homeLon])
}
},
renderStations(this: RoverMapHook) {
this.stationMarkers.clearLayers()
for (const s of this.stations) {
this.stationMarkers.addLayer(buildStationMarker(s))
}
},
buildQualityLayer(this: RoverMapHook): L.GridLayer {
const self = this
const OverlayClass = L.GridLayer.extend({
createTile(coords: L.Coords) {
const tile = document.createElement("canvas")
const size = this.getTileSize()
tile.width = size.x
tile.height = size.y
const ctx = tile.getContext("2d")!
const w = size.x
const h = size.y
// tile lat/lon bounds
const nwPoint = coords.scaleBy(size)
const nw = this._map.unproject(nwPoint, coords.z)
const se = this._map.unproject(nwPoint.add(size), coords.z)
const latPerPx = (nw.lat - se.lat) / h // positive (lat decreases going down)
const lonPerPx = (se.lng - nw.lng) / w
if (latPerPx <= 0 || lonPerPx <= 0) return tile
// 0.125° grid spacing in pixels
const cellPxX = 0.125 / lonPerPx
const cellPxY = 0.125 / latPerPx
const cellPx = Math.max(1, Math.min(cellPxX, cellPxY))
const splatRadius = Math.ceil(3 * cellPx)
const sigma = 1.2 * cellPx
const twoSigmaSq = 2 * sigma * sigma
// padding cell radius for the candidate filter (3 cells in degrees)
const latPad = 3 * 0.125
const lonPad = 3 * 0.125
const minLat = se.lat - latPad
const maxLat = nw.lat + latPad
const minLon = nw.lng - lonPad
const maxLon = se.lng + lonPad
const tierBuffers: Record<string, Float32Array> = {
"#16a34a": new Float32Array(w * h),
"#eab308": new Float32Array(w * h),
"#f97316": new Float32Array(w * h)
}
for (const cell of self.cellLookup.values()) {
if (cell.lat < minLat || cell.lat > maxLat) continue
if (cell.lon < minLon || cell.lon > maxLon) continue
const tier = tierForScore(cell.score)
if (!tier) continue
// Cells may carry an explicit tier_color from the server; honor it.
const useTier = TIER_RGB[cell.tier_color] ? cell.tier_color : tier
const buf = tierBuffers[useTier]
if (!buf) continue
const cx = (cell.lon - nw.lng) / lonPerPx
const cy = (nw.lat - cell.lat) / latPerPx
const x0 = Math.max(0, Math.floor(cx - splatRadius))
const x1 = Math.min(w - 1, Math.ceil(cx + splatRadius))
const y0 = Math.max(0, Math.floor(cy - splatRadius))
const y1 = Math.min(h - 1, Math.ceil(cy + splatRadius))
for (let py = y0; py <= y1; py++) {
const dy = py - cy
for (let px = x0; px <= x1; px++) {
const dx = px - cx
const d2 = dx * dx + dy * dy
const a = Math.exp(-d2 / twoSigmaSq)
const idx = py * w + px
if (a > buf[idx]) buf[idx] = a
}
}
}
const img = ctx.createImageData(w, h)
const data = img.data
for (let i = 0; i < w * h; i++) {
let bestAlpha = 0
let bestRgb: [number, number, number] | null = null
for (const tierColor of TIER_ORDER) {
const a = tierBuffers[tierColor][i]
if (a > 0.05 && a > bestAlpha) {
bestAlpha = a
bestRgb = TIER_RGB[tierColor]
}
}
if (bestRgb && bestAlpha > 0) {
const o = i * 4
data[o] = bestRgb[0]
data[o + 1] = bestRgb[1]
data[o + 2] = bestRgb[2]
data[o + 3] = Math.min(255, Math.round(bestAlpha * 0.35 * 255))
}
}
ctx.putImageData(img, 0, 0)
return tile
}
})
return new OverlayClass({ opacity: 1.0 }) as L.GridLayer
}
}