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:
parent
d7fc120c28
commit
0c295c1558
5 changed files with 164 additions and 31 deletions
|
|
@ -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} — Est. range: ${range} (CW)<br>
|
||||
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue