Add HRRR propagation overlay to rover planner map
This commit is contained in:
parent
31fe3b8861
commit
1bee565d04
2 changed files with 191 additions and 3 deletions
|
|
@ -7,6 +7,16 @@ export const RoverMap = {
|
|||
this.coverageLayer = L.layerGroup()
|
||||
this.gridLayer = L.layerGroup()
|
||||
this.gridVisible = true
|
||||
this.scoreOverlay = null
|
||||
this.gridLookup = new Map()
|
||||
|
||||
this.colorScale = [
|
||||
{ min: 80, r: 0, g: 255, b: 163 },
|
||||
{ min: 65, r: 125, g: 255, b: 212 },
|
||||
{ min: 50, r: 255, g: 229, b: 102 },
|
||||
{ min: 33, r: 255, g: 144, b: 68 },
|
||||
{ min: 0, r: 255, g: 79, b: 79 }
|
||||
]
|
||||
|
||||
const map = L.map(this.el, {
|
||||
center: [33.2, -97.1],
|
||||
|
|
@ -29,10 +39,30 @@ export const RoverMap = {
|
|||
this.coverageLayer.addTo(map)
|
||||
this.gridLayer.addTo(map)
|
||||
|
||||
// Render pre-loaded propagation scores
|
||||
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
|
||||
if (initialScores.length > 0) {
|
||||
this.renderScores(initialScores)
|
||||
}
|
||||
|
||||
// Report bounds so server can send scores for visible area
|
||||
const pushBounds = () => {
|
||||
const b = map.getBounds()
|
||||
this.pushEvent("map_bounds", {
|
||||
south: b.getSouth(), north: b.getNorth(),
|
||||
west: b.getWest(), east: b.getEast()
|
||||
})
|
||||
}
|
||||
|
||||
// Draw grid and update on pan/zoom
|
||||
updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
|
||||
map.on("moveend", () => {
|
||||
if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
|
||||
pushBounds()
|
||||
})
|
||||
|
||||
this.handleEvent("update_scores", ({ scores }) => {
|
||||
this.renderScores(scores)
|
||||
})
|
||||
|
||||
this.handleEvent("stations_updated", ({ stations }) => {
|
||||
|
|
@ -57,6 +87,123 @@ export const RoverMap = {
|
|||
})
|
||||
},
|
||||
|
||||
// --- Propagation score overlay (same as main map) ---
|
||||
|
||||
renderScores(scores) {
|
||||
if (this.scoreOverlay) {
|
||||
this.map.removeLayer(this.scoreOverlay)
|
||||
this.scoreOverlay = null
|
||||
}
|
||||
if (!scores || scores.length === 0) return
|
||||
|
||||
this.gridLookup = new Map()
|
||||
scores.forEach((s) => {
|
||||
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}`
|
||||
this.gridLookup.set(key, s.score)
|
||||
})
|
||||
|
||||
const colorCache = new Array(101)
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const c = this.scoreColorRGB(i)
|
||||
colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)`
|
||||
}
|
||||
|
||||
const self = this
|
||||
const OverlayClass = L.GridLayer.extend({
|
||||
createTile(coords) {
|
||||
const tile = document.createElement("canvas")
|
||||
const size = this.getTileSize()
|
||||
tile.width = size.x
|
||||
tile.height = size.y
|
||||
const ctx = tile.getContext("2d")
|
||||
|
||||
const step = 0.125
|
||||
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) / size.y
|
||||
const lonPerPx = (se.lng - nw.lng) / size.x
|
||||
const cellPxX = Math.max(1, Math.ceil(step / lonPerPx))
|
||||
const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx)))
|
||||
const absLatPerPx = Math.abs(latPerPx)
|
||||
|
||||
let lastColor = null
|
||||
for (let py = 0; py < size.y; py += cellPxY) {
|
||||
const lat = nw.lat - py * absLatPerPx
|
||||
for (let px = 0; px < size.x; px += cellPxX) {
|
||||
const lon = nw.lng + px * lonPerPx
|
||||
const score = self.interpolateScore(lat, lon, step)
|
||||
if (score !== null) {
|
||||
const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))]
|
||||
if (color !== lastColor) {
|
||||
ctx.fillStyle = color
|
||||
lastColor = color
|
||||
}
|
||||
ctx.fillRect(px, py, cellPxX, cellPxY)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tile
|
||||
}
|
||||
})
|
||||
|
||||
this.scoreOverlay = new OverlayClass({ opacity: 1.0 })
|
||||
this.scoreOverlay.addTo(this.map)
|
||||
|
||||
// Ensure coverage and grid layers stay above score overlay
|
||||
if (this.coverageLayer._map) this.coverageLayer.bringToFront()
|
||||
},
|
||||
|
||||
interpolateScore(lat, lon, step) {
|
||||
const rlat = (Math.round(lat / step) * step).toFixed(3)
|
||||
const rlon = (Math.round(lon / step) * step).toFixed(3)
|
||||
if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)
|
||||
|
||||
const lat0 = Math.floor(lat / step) * step
|
||||
const lat1 = lat0 + step
|
||||
const lon0 = Math.floor(lon / step) * step
|
||||
const lon1 = lon0 + step
|
||||
|
||||
const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`)
|
||||
const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`)
|
||||
const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`)
|
||||
const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`)
|
||||
|
||||
const corners = [s00, s10, s01, s11].filter(s => s !== undefined)
|
||||
if (corners.length < 2) return null
|
||||
|
||||
if (corners.length === 4) {
|
||||
const tLat = (lat - lat0) / step
|
||||
const tLon = (lon - lon0) / step
|
||||
return Math.round(
|
||||
s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) +
|
||||
s01 * (1 - tLat) * tLon + s11 * tLat * tLon
|
||||
)
|
||||
}
|
||||
return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length)
|
||||
},
|
||||
|
||||
scoreColorRGB(score) {
|
||||
for (let i = 0; i < this.colorScale.length - 1; i++) {
|
||||
const upper = this.colorScale[i]
|
||||
const lower = this.colorScale[i + 1]
|
||||
if (score >= lower.min) {
|
||||
const range = upper.min - lower.min
|
||||
const t = Math.min(1, (score - lower.min) / range)
|
||||
return {
|
||||
r: Math.round(lower.r + (upper.r - lower.r) * t),
|
||||
g: Math.round(lower.g + (upper.g - lower.g) * t),
|
||||
b: Math.round(lower.b + (upper.b - lower.b) * t)
|
||||
}
|
||||
}
|
||||
}
|
||||
const last = this.colorScale[this.colorScale.length - 1]
|
||||
return { r: last.r, g: last.g, b: last.b }
|
||||
},
|
||||
|
||||
// --- Station and coverage rendering ---
|
||||
|
||||
renderStations() {
|
||||
this.stationMarkers.clearLayers()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
"""
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Radio.CallsignClient
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
|
|
@ -13,13 +14,27 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
|
||||
@band_options BandConfig.band_options()
|
||||
|
||||
@default_band 10_000
|
||||
@initial_bounds %{
|
||||
"south" => 29.5,
|
||||
"north" => 36.3,
|
||||
"west" => -101.5,
|
||||
"east" => -92.5
|
||||
}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||||
end
|
||||
|
||||
initial_scores = Propagation.latest_scores(@default_band, @initial_bounds)
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Rover Planner",
|
||||
band_options: @band_options,
|
||||
band: 10_000,
|
||||
band: @default_band,
|
||||
stations: [],
|
||||
station_input: "",
|
||||
coverage: [],
|
||||
|
|
@ -27,7 +42,9 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
resolving: false,
|
||||
computing: false,
|
||||
url_loaded: false,
|
||||
show_grids: true
|
||||
show_grids: true,
|
||||
initial_scores_json: Jason.encode!(initial_scores),
|
||||
bounds: @initial_bounds
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -81,10 +98,28 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
end
|
||||
|
||||
def handle_event("select_band", %{"band" => band_str}, socket) do
|
||||
socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil)
|
||||
band = String.to_integer(band_str)
|
||||
scores = Propagation.latest_scores(band, socket.assigns.bounds)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(band: band, coverage: [], selected_grid: nil)
|
||||
|> push_event("update_scores", %{scores: scores})
|
||||
|
||||
{:noreply, push_url(socket)}
|
||||
end
|
||||
|
||||
def handle_event("map_bounds", bounds, socket) do
|
||||
scores = Propagation.latest_scores(socket.assigns.band, bounds)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:bounds, bounds)
|
||||
|> push_event("update_scores", %{scores: scores})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("calculate", _params, socket) do
|
||||
if length(socket.assigns.stations) >= 2 and not socket.assigns.computing do
|
||||
send(self(), :compute_coverage)
|
||||
|
|
@ -132,6 +167,11 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_info({:propagation_updated, _valid_times}, socket) do
|
||||
scores = Propagation.latest_scores(socket.assigns.band, socket.assigns.bounds)
|
||||
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
|
||||
end
|
||||
|
||||
def handle_info(:compute_coverage, socket) do
|
||||
stations = socket.assigns.stations
|
||||
band_mhz = socket.assigns.band
|
||||
|
|
@ -246,6 +286,7 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
phx-hook="RoverMap"
|
||||
phx-update="ignore"
|
||||
data-band={@band}
|
||||
data-scores={@initial_scores_json}
|
||||
class="absolute inset-0 z-0"
|
||||
>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue