diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index fc33cc45..1154f638 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -147,6 +147,7 @@ interface PropagationMapHook extends ViewHook { showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void + redrawReachPolygon(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null @@ -645,6 +646,7 @@ export const PropagationMap: Record & { mounted(this: PropagationMapHook): void showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void + redrawReachPolygon(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null scoreColorRGB(this: PropagationMapHook, score: number): RGB @@ -699,7 +701,14 @@ export const PropagationMap: Record & { this.renderScores(scores) if (this.selectedTime) this.forecastCache.set(this.selectedTime, scores) - // If a point was selected, refresh its detail with the new data + // Reach polygon is a pure function of scoreGrid + clickedLatLng, so we + // can refresh it immediately client-side without waiting on the server + // round-trip. Handles the case where the new band has no reach at all + // (hull empty → stale polygon from the old band is cleared). + this.redrawReachPolygon() + + // If the detail panel is open, refresh its factors/forecast for the + // new band via the server. The polygon is already up-to-date above. if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") { this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] }) } @@ -862,34 +871,10 @@ export const PropagationMap: Record & { this.drawScatterMarkers(detail.rain_scatter) } - // Draw propagation reach polygon based on contiguous good cells - if (this.scoreGrid && this.clickedLatLng) { - // Use MARGINAL threshold (50) as minimum for propagation reach - const minScore = 50 - const hull = propagationReach( - this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore - ) - if (hull.length >= 3) { - // Remove any previous reach polygon - if (this.reachPolygon) { - this.rangeCircles.removeLayer(this.reachPolygon) - } - const tier = scoreTier(detail.score) - this.reachPolygon = L.polygon( - hull.map(p => [p.lat, p.lon] as [number, number]), - { - color: tier.color, - weight: 2, - opacity: 0.6, - fillColor: tier.color, - fillOpacity: 0.08, - interactive: false, - smoothFactor: 1.5, - dashArray: "6 4" - } - ).addTo(this.rangeCircles) - } - } + // Draw propagation reach polygon from contiguous good cells. + // Shared helper handles cleanup + empty-hull case so the old + // polygon doesn't stick around when the new band has no reach. + this.redrawReachPolygon() } }) @@ -1013,9 +998,49 @@ export const PropagationMap: Record & { this.rangeCircles.clearLayers() if (this.scatterMarkers) this.scatterMarkers.clearLayers() this.clickedLatLng = null + this.reachPolygon = null this.lastDetail = null }, + // Redraws the propagation reach polygon around the currently-clicked + // point. Safe to call repeatedly — always removes the previous polygon + // first so we don't leave a stale shape from an earlier band on the map + // when the new band's reach is too small to hull. The polygon is a pure + // function of this.scoreGrid and this.clickedLatLng, so it can be + // refreshed on update_scores without waiting for the server's detail. + redrawReachPolygon(this: PropagationMapHook) { + if (this.reachPolygon) { + this.rangeCircles.removeLayer(this.reachPolygon) + this.reachPolygon = null + } + + if (!this.scoreGrid || !this.clickedLatLng) return + + const minScore = 50 // MARGINAL — minimum useful propagation + const hull = propagationReach( + this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore + ) + if (hull.length < 3) return + + const basic = this.lookupPoint(this.clickedLatLng[0], this.clickedLatLng[1]) + const centerScore = basic ? basic.score : 50 + const tier = scoreTier(centerScore) + + this.reachPolygon = L.polygon( + hull.map(p => [p.lat, p.lon] as [number, number]), + { + color: tier.color, + weight: 2, + opacity: 0.6, + fillColor: tier.color, + fillOpacity: 0.08, + interactive: false, + smoothFactor: 1.5, + dashArray: "6 4" + } + ).addTo(this.rangeCircles) + }, + renderScores(this: PropagationMapHook, scores: ScorePoint[]) { this.scores = scores @@ -1289,10 +1314,11 @@ export const PropagationMap: Record & { // Fast path — render instantly from preloaded cache, just inform // the server of the new selected time for state tracking. this.renderScores(cached) + this.redrawReachPolygon() this.pushEvent("set_selected_time", { time }) } else { // Cache miss — fall back to server roundtrip via the existing - // update_scores path. + // update_scores path (which also redraws the polygon). topbar.show() this.pushEvent("select_time", { time }) } diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 6f517d43..6aac10cf 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -22,40 +22,49 @@ defmodule MicrowavepropWeb.MapLive do socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC")) - case LiveStash.recover_state(socket) do - {:recovered, socket} -> - {:ok, socket} + # LiveStash only persists the keys passed to `stash_assigns/2` + # (selected_band + selected_time). On reconnect the recovered socket has + # just those — the rest of the mount-time assigns (bands, bounds, the + # initial score JSON payload, toggles, etc.) have to be rebuilt or the + # first render crashes with KeyError on :initial_scores_json. + {recovered_band, recovered_time, socket} = + case LiveStash.recover_state(socket) do + {:recovered, recovered} -> + {recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered} - _ -> - bands = BandConfig.all_bands() - valid_times = Propagation.available_valid_times(@default_band) - selected_time = closest_to_now(valid_times) - center = initial_center(session) - bounds = bounds_around(center) - initial_scores = Propagation.scores_at(@default_band, selected_time, bounds) + _ -> + {nil, nil, socket} + end - # preload_forecast runs on the first map_bounds event instead of - # here — mount only knows a hardcoded fallback bounding box, so - # preloading now populates the forecast cache with scores for the - # wrong viewport and forecast-hour clicks show a small square of - # coverage instead of the full map. + bands = BandConfig.all_bands() + selected_band = recovered_band || @default_band + valid_times = Propagation.available_valid_times(selected_band) + selected_time = recovered_time || closest_to_now(valid_times) + center = initial_center(session) + bounds = bounds_around(center) + initial_scores = Propagation.scores_at(selected_band, selected_time, bounds) - {:ok, - assign(socket, - page_title: "Propagation Prediction Map", - bands: bands, - selected_band: @default_band, - initial_scores_json: Jason.encode!(initial_scores), - valid_times: valid_times, - selected_time: selected_time, - bounds: bounds, - initial_center: center, - initial_zoom: @default_zoom, - grid_visible: false, - radar_visible: false, - antenna_height_ft: 33 - )} - end + # preload_forecast runs on the first map_bounds event instead of + # here — mount only knows a hardcoded fallback bounding box, so + # preloading now populates the forecast cache with scores for the + # wrong viewport and forecast-hour clicks show a small square of + # coverage instead of the full map. + + {:ok, + assign(socket, + page_title: "Propagation Prediction Map", + bands: bands, + selected_band: selected_band, + initial_scores_json: Jason.encode!(initial_scores), + valid_times: valid_times, + selected_time: selected_time, + bounds: bounds, + initial_center: center, + initial_zoom: @default_zoom, + grid_visible: false, + radar_visible: false, + antenna_height_ft: 33 + )} end # Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.