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}
+
+
+
`
+}
+
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