From 0c295c1558f41ac2762973c471b5f74d83025c43 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 31 Mar 2026 09:45:50 -0500 Subject: [PATCH] Add click-to-inspect factor breakdown popup on map Click anywhere on the propagation map to see a detailed popup with: - Overall score and tier label with color - Estimated range for the selected band (CW mode) - All 9 scoring factors with visual bar charts, individual scores, and weight percentages - Grid point coordinates and data timestamp Factors are displayed in weight order so users can immediately see which atmospheric conditions are driving the prediction. --- assets/js/propagation_map_hook.js | 126 ++++++++++++++---- lib/microwaveprop/propagation.ex | 37 +++++ .../controllers/page_controller.ex | 2 +- lib/microwaveprop_web/live/map_live.ex | 26 ++++ .../controllers/page_controller_test.exs | 4 +- 5 files changed, 164 insertions(+), 31 deletions(-) diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js index 5889a140..babc8cbe 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.js @@ -1,5 +1,79 @@ import topbar from "../vendor/topbar" +const FACTOR_META = { + humidity: { label: "Humidity", weight: 0.20, unit: "g/m\u00b3" }, + time_of_day: { label: "Time of Day", weight: 0.20, unit: "" }, + td_depression: { label: "Td Depression", weight: 0.12, unit: "\u00b0F" }, + refractivity: { label: "Refractivity", weight: 0.10, unit: "N/km" }, + sky: { label: "Sky Cover", weight: 0.10, unit: "%" }, + season: { label: "Season", weight: 0.10, unit: "" }, + rain: { label: "Rain", weight: 0.08, unit: "" }, + wind: { label: "Wind", weight: 0.06, unit: "kts" }, + pressure: { label: "Pressure", weight: 0.04, unit: "mb" } +} + +const FACTOR_ORDER = [ + "humidity", "time_of_day", "td_depression", "refractivity", + "sky", "season", "rain", "wind", "pressure" +] + +function scoreTier(score) { + if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" } + if (score >= 65) return { label: "GOOD", color: "#7dffd4" } + if (score >= 50) return { label: "MARGINAL", color: "#ffe566" } + if (score >= 33) return { label: "POOR", color: "#ff9044" } + return { label: "NEGLIGIBLE", color: "#ff4f4f" } +} + +function factorBar(score) { + const filled = Math.round(score / 5) + const empty = 20 - filled + const tier = scoreTier(score) + return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` +} + +function rangeEstimate(score, detail) { + if (score >= 80) return `${detail.extended_range_km}\u2013${detail.exceptional_range_km}+ km` + if (score >= 65) return `${detail.typical_range_km}\u2013${detail.extended_range_km} km` + if (score >= 50) return `${Math.round(detail.typical_range_km * 0.7)}\u2013${detail.typical_range_km} km` + if (score >= 33) return `${Math.round(detail.typical_range_km * 0.4)}\u2013${Math.round(detail.typical_range_km * 0.7)} km` + return `<${Math.round(detail.typical_range_km * 0.4)} km` +} + +function buildPopupHTML(detail) { + const tier = scoreTier(detail.score) + const range = rangeEstimate(detail.score, detail) + const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" + + let rows = "" + for (const key of FACTOR_ORDER) { + const meta = FACTOR_META[key] + const value = detail.factors[key] + if (value === undefined) continue + const pct = Math.round(meta.weight * 100) + rows += ` + ${meta.label} + ${factorBar(value)} + ${value} + (${pct}%) + ` + } + + return `
+
+ ${detail.score}/100 + ${tier.label} +
+
+ ${detail.band_label} — Est. range: ${range} (CW)
+ ${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time} +
+ + ${rows} +
+
` +} + export const PropagationMap = { mounted() { this.map = L.map(this.el, { @@ -16,13 +90,14 @@ export const PropagationMap = { this.scoreOverlay = null this.scores = [] + this.detailPopup = L.popup({ maxWidth: 340 }) this.colorScale = [ - { min: 80, r: 0, g: 255, b: 163 }, // EXCELLENT - green - { min: 65, r: 125, g: 255, b: 212 }, // GOOD - light green - { min: 50, r: 255, g: 229, b: 102 }, // MARGINAL - yellow - { min: 33, r: 255, g: 144, b: 68 }, // POOR - orange - { min: 0, r: 255, g: 79, b: 79 } // NEGLIGIBLE - red + { 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 } ] this.handleEvent("update_scores", ({ scores }) => { @@ -30,10 +105,22 @@ export const PropagationMap = { this.renderScores(scores) }) - // Show topbar whenever the server is processing a band change + this.handleEvent("point_detail", ({ detail }) => { + if (detail) { + this.detailPopup + .setLatLng([detail.lat, detail.lon]) + .setContent(buildPopupHTML(detail)) + .openOn(this.map) + } + }) + + // Click on map to get point detail + this.map.on("click", (e) => { + this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) + }) + this.el.addEventListener("show-loading", () => topbar.show(300)) - // Send bounds to server on load and on pan/zoom this.sendBounds() this.map.on("moveend", () => this.sendBounds()) @@ -65,13 +152,11 @@ export const PropagationMap = { } if (scores.length === 0) return - // Build a lookup grid for interpolation this.gridLookup = new Map() scores.forEach(({ lat, lon, score }) => { this.gridLookup.set(`${lat.toFixed(3)},${lon.toFixed(3)}`, score) }) - // Create a custom canvas overlay const self = this this.scoreOverlay = L.GridLayer.extend({ createTile(coords) { @@ -83,15 +168,11 @@ export const PropagationMap = { const step = 0.125 const nwPoint = coords.scaleBy(size) - const sePoint = nwPoint.add(size) const nw = this._map.unproject(nwPoint, coords.z) - const se = this._map.unproject(sePoint, coords.z) + const se = this._map.unproject(nwPoint.add(size), coords.z) - // Pixel size in degrees const latPerPx = (nw.lat - se.lat) / size.y const lonPerPx = (se.lng - nw.lng) / size.x - - // Draw cells - each pixel block maps to a grid point const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) @@ -99,7 +180,6 @@ export const PropagationMap = { for (let px = 0; px < size.x; px += cellPxX) { const lat = nw.lat - py * Math.abs(latPerPx) const lon = nw.lng + px * lonPerPx - const score = self.interpolateScore(lat, lon, step) if (score !== null) { const color = self.scoreColorRGB(score) @@ -108,7 +188,6 @@ export const PropagationMap = { } } } - return tile } }) @@ -118,13 +197,10 @@ export const PropagationMap = { }, interpolateScore(lat, lon, step) { - // Snap to nearest grid point const rlat = (Math.round(lat / step) * step).toFixed(3) const rlon = (Math.round(lon / step) * step).toFixed(3) - const key = `${rlat},${rlon}` - if (this.gridLookup.has(key)) return this.gridLookup.get(key) + if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`) - // Try bilinear interpolation from 4 surrounding points const lat0 = Math.floor(lat / step) * step const lat1 = lat0 + step const lon0 = Math.floor(lon / step) * step @@ -135,7 +211,6 @@ export const PropagationMap = { const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`) const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`) - // Need at least 2 corners to interpolate const corners = [s00, s10, s01, s11].filter(s => s !== undefined) if (corners.length < 2) return null @@ -143,19 +218,14 @@ export const PropagationMap = { 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 + s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) + + s01 * (1 - tLat) * tLon + s11 * tLat * tLon ) } - - // Fallback: average available corners return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length) }, scoreColorRGB(score) { - // Interpolate between tier colors for smooth gradients for (let i = 0; i < this.colorScale.length - 1; i++) { const upper = this.colorScale[i] const lower = this.colorScale[i + 1] diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index ebeb6456..616437a2 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -4,6 +4,7 @@ defmodule Microwaveprop.Propagation do import Ecto.Query alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.Grid alias Microwaveprop.Propagation.GridScore alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Repo @@ -110,6 +111,42 @@ defmodule Microwaveprop.Propagation do end end + @doc "Get the full score and factors for a specific grid point, snapped to nearest grid." + def point_detail(band_mhz, lat, lon) do + step = Grid.step() + snapped_lat = Float.round(Float.round(lat / step) * step, 3) + snapped_lon = Float.round(Float.round(lon / step) * step, 3) + + latest_time_query = + from(gs in GridScore, + where: gs.band_mhz == ^band_mhz, + select: max(gs.valid_time) + ) + + case Repo.one(latest_time_query) do + nil -> + nil + + latest_time -> + Repo.one( + from(gs in GridScore, + where: + gs.band_mhz == ^band_mhz and + gs.valid_time == ^latest_time and + gs.lat == ^snapped_lat and + gs.lon == ^snapped_lon, + select: %{ + lat: gs.lat, + lon: gs.lon, + score: gs.score, + factors: gs.factors, + valid_time: gs.valid_time + } + ) + ) + end + end + defp maybe_filter_bounds(query, nil), do: query defp maybe_filter_bounds(query, %{"south" => s, "north" => n, "west" => w, "east" => e}) do diff --git a/lib/microwaveprop_web/controllers/page_controller.ex b/lib/microwaveprop_web/controllers/page_controller.ex index 6fd2d089..e821961d 100644 --- a/lib/microwaveprop_web/controllers/page_controller.ex +++ b/lib/microwaveprop_web/controllers/page_controller.ex @@ -2,6 +2,6 @@ defmodule MicrowavepropWeb.PageController do use MicrowavepropWeb, :controller def home(conn, _params) do - redirect(conn, to: ~p"/qsos") + redirect(conn, to: ~p"/map") end end diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 0ddc0c66..9e9452fb 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -41,6 +41,32 @@ defmodule MicrowavepropWeb.MapLive do {:noreply, socket} end + def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do + band = socket.assigns.selected_band + + case Propagation.point_detail(band, lat, lon) do + nil -> + {:noreply, push_event(socket, "point_detail", %{detail: nil})} + + detail -> + band_config = BandConfig.get(band) + + payload = %{ + lat: detail.lat, + lon: detail.lon, + score: detail.score, + factors: detail.factors, + valid_time: DateTime.to_iso8601(detail.valid_time), + band_label: band_config.label, + typical_range_km: band_config.typical_range_km, + extended_range_km: band_config.extended_range_km, + exceptional_range_km: band_config.exceptional_range_km + } + + {:noreply, push_event(socket, "point_detail", %{detail: payload})} + end + end + def handle_event("map_bounds", bounds, socket) do scores = Propagation.latest_scores(socket.assigns.selected_band, bounds) diff --git a/test/microwaveprop_web/controllers/page_controller_test.exs b/test/microwaveprop_web/controllers/page_controller_test.exs index 1f08dba8..dad2f6bf 100644 --- a/test/microwaveprop_web/controllers/page_controller_test.exs +++ b/test/microwaveprop_web/controllers/page_controller_test.exs @@ -1,8 +1,8 @@ defmodule MicrowavepropWeb.PageControllerTest do use MicrowavepropWeb.ConnCase - test "GET / redirects to /qsos", %{conn: conn} do + test "GET / redirects to /map", %{conn: conn} do conn = get(conn, ~p"/") - assert redirected_to(conn) == "/qsos" + assert redirected_to(conn) == "/map" end end