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:
parent
e7534bc784
commit
eedd91c6ee
2 changed files with 96 additions and 61 deletions
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue