From 2e216d42442e4fbad19413d08664b21026918b3e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 31 Mar 2026 09:50:48 -0500 Subject: [PATCH] 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 --- assets/js/propagation_map_hook.js | 81 +++++++++++++++++++++++--- lib/microwaveprop/propagation.ex | 2 +- lib/microwaveprop_web/live/map_live.ex | 39 +++++-------- 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js index babc8cbe..03003ccb 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.js @@ -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() diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 616437a2..57f42f72 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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() diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 9e9452fb..697a6e71 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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" >