391 lines
12 KiB
TypeScript
391 lines
12 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)
|
|
}
|
|
// Update radius before (re)drawing so the per-pixel clip uses the
|
|
// new value, otherwise the first paint can leak past the circle.
|
|
this.setDriveRadius(drive_radius_km)
|
|
if (!this.qualityLayer) {
|
|
this.qualityLayer = this.buildQualityLayer()
|
|
this.qualityLayer.addTo(this.map)
|
|
} else {
|
|
this.qualityLayer.redraw()
|
|
}
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clip the heatmap to the drive-radius circle: pixels farther
|
|
// than driveRadiusKm from home stay transparent so the overlay
|
|
// never extends past the dashed circle.
|
|
const cosLat = Math.cos(self.homeLat * Math.PI / 180)
|
|
const kmPerPxX = lonPerPx * 111.0 * Math.max(cosLat, 0.1)
|
|
const kmPerPxY = latPerPx * 111.0
|
|
const homePxX = (self.homeLon - nw.lng) / lonPerPx
|
|
const homePxY = (nw.lat - self.homeLat) / latPerPx
|
|
const radiusKmSq = self.driveRadiusKm * self.driveRadiusKm
|
|
|
|
const img = ctx.createImageData(w, h)
|
|
const data = img.data
|
|
|
|
for (let py = 0; py < h; py++) {
|
|
const dyKm = (py - homePxY) * kmPerPxY
|
|
for (let px = 0; px < w; px++) {
|
|
const dxKm = (px - homePxX) * kmPerPxX
|
|
if (dxKm * dxKm + dyKm * dyKm > radiusKmSq) continue
|
|
|
|
const i = py * w + px
|
|
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
|
|
}
|
|
}
|