Fix /map prod crash on reconnect and redraw reach polygon on band change

Two bugs on the propagation map:

1. LiveStash reconnect path crashed the LiveView with KeyError on
   :initial_scores_json. stash_assigns/2 only persists selected_band and
   selected_time, but the recovered-socket branch returned early without
   rebuilding any of the other mount-time assigns (bands, bounds, the
   initial score JSON payload, grid/radar toggles, antenna height).
   Users on the Phoenix longpoll fallback — or anyone whose websocket
   reconnected after a brief network blip — saw a blank page and a
   server-side 500. Refactor mount/3 to always compute ephemeral assigns,
   using recovered selected_band / selected_time when present and
   falling back to defaults otherwise.

2. The propagation reach polygon shown after clicking a point never
   redrew when the user changed bands or scrubbed the forecast timeline.
   The redraw logic only ran inside the point_detail handler and was
   gated on `hull.length >= 3`, so the stale polygon from the old band
   stuck around whenever the new band had no coverage at the clicked
   point. Extract a redrawReachPolygon/0 helper on the Leaflet hook that
   always clears the previous polygon first and pulls the tier color
   from the current score at the clicked location, then call it from
   update_scores (band change + server time scrub) and the cached
   timeline-scrub path. The point_detail handler delegates to the same
   helper so the polygon stays in sync with both the score grid and the
   detail panel.
This commit is contained in:
Graham McIntire 2026-04-12 15:42:44 -05:00
parent e7534bc784
commit eedd91c6ee
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 96 additions and 61 deletions

View file

@ -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<string, unknown> & {
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<string, unknown> & {
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<string, unknown> & {
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<string, unknown> & {
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<string, unknown> & {
// 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 })
}

View file

@ -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.