Make point detail popup instant with client-side lookup and range circles

- Scores now include factors and valid_time in the viewport query,
  eliminating the server round-trip for popups
- Click shows two range circles: solid inner (typical range) and
  dashed outer (max estimated range), colored by score tier
- Circles disappear when popup closes
- Band info pushed to client on band switch for accurate range estimates
This commit is contained in:
Graham McIntire 2026-03-31 09:50:48 -05:00
parent 0c295c1558
commit 2e216d4244
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 88 additions and 34 deletions

View file

@ -105,8 +105,18 @@ export const PropagationMap = {
this.renderScores(scores)
})
this.handleEvent("point_detail", ({ detail }) => {
this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}")
this.rangeCircles = L.layerGroup().addTo(this.map)
this.handleEvent("update_band_info", ({ band_info }) => {
this.bandInfo = band_info
})
// Click on map to show factor breakdown + range circle — fully client-side
this.map.on("click", (e) => {
const detail = this.lookupPoint(e.latlng.lat, e.latlng.lng)
if (detail) {
this.showRangeCircles(detail)
this.detailPopup
.setLatLng([detail.lat, detail.lon])
.setContent(buildPopupHTML(detail))
@ -114,10 +124,8 @@ export const PropagationMap = {
}
})
// Click on map to get point detail
this.map.on("click", (e) => {
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
})
// Clear range circles when popup closes
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
this.el.addEventListener("show-loading", () => topbar.show(300))
@ -153,8 +161,11 @@ export const PropagationMap = {
if (scores.length === 0) return
this.gridLookup = new Map()
scores.forEach(({ lat, lon, score }) => {
this.gridLookup.set(`${lat.toFixed(3)},${lon.toFixed(3)}`, score)
this.gridData = new Map()
scores.forEach((s) => {
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}`
this.gridLookup.set(key, s.score)
this.gridData.set(key, s)
})
const self = this
@ -243,6 +254,62 @@ export const PropagationMap = {
return { r: last.r, g: last.g, b: last.b }
},
lookupPoint(lat, lon) {
if (!this.gridData) return null
const step = 0.125
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
const data = this.gridData.get(`${rlat},${rlon}`)
if (!data) return null
return { ...data, ...this.bandInfo }
},
showRangeCircles(detail) {
this.rangeCircles.clearLayers()
const center = [detail.lat, detail.lon]
const score = detail.score
const tier = scoreTier(score)
// Compute estimated range in km based on score tier
let rangeKm
if (score >= 80) rangeKm = detail.exceptional_range_km
else if (score >= 65) rangeKm = detail.extended_range_km
else if (score >= 50) rangeKm = detail.typical_range_km
else if (score >= 33) rangeKm = Math.round(detail.typical_range_km * 0.6)
else rangeKm = Math.round(detail.typical_range_km * 0.3)
// Outer circle — max estimated range (faded)
L.circle(center, {
radius: rangeKm * 1000,
color: tier.color,
weight: 2,
opacity: 0.6,
fillColor: tier.color,
fillOpacity: 0.06,
dashArray: "6,4"
}).addTo(this.rangeCircles)
// Inner circle — typical range (more visible)
const innerKm = Math.round(rangeKm * 0.6)
L.circle(center, {
radius: innerKm * 1000,
color: tier.color,
weight: 2,
opacity: 0.8,
fillColor: tier.color,
fillOpacity: 0.1
}).addTo(this.rangeCircles)
// Center marker
L.circleMarker(center, {
radius: 5,
color: "#fff",
weight: 2,
fillColor: tier.color,
fillOpacity: 1
}).addTo(this.rangeCircles)
},
sendBounds() {
topbar.show(300)
const b = this.map.getBounds()

View file

@ -104,7 +104,7 @@ defmodule Microwaveprop.Propagation do
latest_time ->
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^latest_time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score}
select: %{lat: gs.lat, lon: gs.lon, score: gs.score, factors: gs.factors, valid_time: gs.valid_time}
)
|> maybe_filter_bounds(bounds)
|> Repo.all()

View file

@ -37,36 +37,11 @@ defmodule MicrowavepropWeb.MapLive do
|> assign(:selected_band, band)
|> assign(:scores, scores)
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
{: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)
@ -92,6 +67,17 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
%{
band_label: config.label,
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_range_km
}
end
defp time_ago(dt) do
seconds = DateTime.diff(DateTime.utc_now(), dt)
@ -120,6 +106,7 @@ defmodule MicrowavepropWeb.MapLive do
phx-hook="PropagationMap"
phx-update="ignore"
data-scores="[]"
data-band-info={Jason.encode!(band_info(@selected_band))}
class="absolute inset-0 z-0"
>
</div>