diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.js index 80f98736..f176e5fe 100644 --- a/assets/js/rover_map_hook.js +++ b/assets/js/rover_map_hook.js @@ -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() diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index a1041717..873216b6 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -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" >