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.
This commit is contained in:
Graham McIntire 2026-03-31 09:45:50 -05:00
parent d7fc120c28
commit 0c295c1558
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 164 additions and 31 deletions

View file

@ -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 `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:#555">${"\u2591".repeat(empty)}</span>`
}
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 += `<tr>
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
<td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
<td style="padding:1px 4px;font-size:10px;color:#888;">(${pct}%)</td>
</tr>`
}
return `<div style="color:#333;min-width:280px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="background:${tier.color};color:#000;padding:1px 8px;border-radius:4px;font-size:11px;font-weight:700;">${tier.label}</span>
</div>
<div style="font-size:12px;color:#666;margin-bottom:6px;">
${detail.band_label} &mdash; Est. range: ${range} (CW)<br>
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div>
<table style="width:100%;border-collapse:collapse;border-top:1px solid #ddd;padding-top:4px;">
${rows}
</table>
</div>`
}
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]

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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