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
|
showDetailPanel(this: PropagationMapHook): void
|
||||||
hideDetailPanel(this: PropagationMapHook): void
|
hideDetailPanel(this: PropagationMapHook): void
|
||||||
|
redrawReachPolygon(this: PropagationMapHook): void
|
||||||
renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
|
renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
|
||||||
drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void
|
drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void
|
||||||
interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null
|
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
|
mounted(this: PropagationMapHook): void
|
||||||
showDetailPanel(this: PropagationMapHook): void
|
showDetailPanel(this: PropagationMapHook): void
|
||||||
hideDetailPanel(this: PropagationMapHook): void
|
hideDetailPanel(this: PropagationMapHook): void
|
||||||
|
redrawReachPolygon(this: PropagationMapHook): void
|
||||||
renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
|
renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
|
||||||
interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null
|
interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null
|
||||||
scoreColorRGB(this: PropagationMapHook, score: number): RGB
|
scoreColorRGB(this: PropagationMapHook, score: number): RGB
|
||||||
|
|
@ -699,7 +701,14 @@ export const PropagationMap: Record<string, unknown> & {
|
||||||
this.renderScores(scores)
|
this.renderScores(scores)
|
||||||
if (this.selectedTime) this.forecastCache.set(this.selectedTime, 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") {
|
if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
|
||||||
this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] })
|
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)
|
this.drawScatterMarkers(detail.rain_scatter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw propagation reach polygon based on contiguous good cells
|
// Draw propagation reach polygon from contiguous good cells.
|
||||||
if (this.scoreGrid && this.clickedLatLng) {
|
// Shared helper handles cleanup + empty-hull case so the old
|
||||||
// Use MARGINAL threshold (50) as minimum for propagation reach
|
// polygon doesn't stick around when the new band has no reach.
|
||||||
const minScore = 50
|
this.redrawReachPolygon()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1013,9 +998,49 @@ export const PropagationMap: Record<string, unknown> & {
|
||||||
this.rangeCircles.clearLayers()
|
this.rangeCircles.clearLayers()
|
||||||
if (this.scatterMarkers) this.scatterMarkers.clearLayers()
|
if (this.scatterMarkers) this.scatterMarkers.clearLayers()
|
||||||
this.clickedLatLng = null
|
this.clickedLatLng = null
|
||||||
|
this.reachPolygon = null
|
||||||
this.lastDetail = 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[]) {
|
renderScores(this: PropagationMapHook, scores: ScorePoint[]) {
|
||||||
this.scores = scores
|
this.scores = scores
|
||||||
|
|
||||||
|
|
@ -1289,10 +1314,11 @@ export const PropagationMap: Record<string, unknown> & {
|
||||||
// Fast path — render instantly from preloaded cache, just inform
|
// Fast path — render instantly from preloaded cache, just inform
|
||||||
// the server of the new selected time for state tracking.
|
// the server of the new selected time for state tracking.
|
||||||
this.renderScores(cached)
|
this.renderScores(cached)
|
||||||
|
this.redrawReachPolygon()
|
||||||
this.pushEvent("set_selected_time", { time })
|
this.pushEvent("set_selected_time", { time })
|
||||||
} else {
|
} else {
|
||||||
// Cache miss — fall back to server roundtrip via the existing
|
// Cache miss — fall back to server roundtrip via the existing
|
||||||
// update_scores path.
|
// update_scores path (which also redraws the polygon).
|
||||||
topbar.show()
|
topbar.show()
|
||||||
this.pushEvent("select_time", { time })
|
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"))
|
socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
|
||||||
|
|
||||||
case LiveStash.recover_state(socket) do
|
# LiveStash only persists the keys passed to `stash_assigns/2`
|
||||||
{:recovered, socket} ->
|
# (selected_band + selected_time). On reconnect the recovered socket has
|
||||||
{:ok, socket}
|
# 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()
|
{nil, nil, socket}
|
||||||
valid_times = Propagation.available_valid_times(@default_band)
|
end
|
||||||
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)
|
|
||||||
|
|
||||||
# preload_forecast runs on the first map_bounds event instead of
|
bands = BandConfig.all_bands()
|
||||||
# here — mount only knows a hardcoded fallback bounding box, so
|
selected_band = recovered_band || @default_band
|
||||||
# preloading now populates the forecast cache with scores for the
|
valid_times = Propagation.available_valid_times(selected_band)
|
||||||
# wrong viewport and forecast-hour clicks show a small square of
|
selected_time = recovered_time || closest_to_now(valid_times)
|
||||||
# coverage instead of the full map.
|
center = initial_center(session)
|
||||||
|
bounds = bounds_around(center)
|
||||||
|
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
|
||||||
|
|
||||||
{:ok,
|
# preload_forecast runs on the first map_bounds event instead of
|
||||||
assign(socket,
|
# here — mount only knows a hardcoded fallback bounding box, so
|
||||||
page_title: "Propagation Prediction Map",
|
# preloading now populates the forecast cache with scores for the
|
||||||
bands: bands,
|
# wrong viewport and forecast-hour clicks show a small square of
|
||||||
selected_band: @default_band,
|
# coverage instead of the full map.
|
||||||
initial_scores_json: Jason.encode!(initial_scores),
|
|
||||||
valid_times: valid_times,
|
{:ok,
|
||||||
selected_time: selected_time,
|
assign(socket,
|
||||||
bounds: bounds,
|
page_title: "Propagation Prediction Map",
|
||||||
initial_center: center,
|
bands: bands,
|
||||||
initial_zoom: @default_zoom,
|
selected_band: selected_band,
|
||||||
grid_visible: false,
|
initial_scores_json: Jason.encode!(initial_scores),
|
||||||
radar_visible: false,
|
valid_times: valid_times,
|
||||||
antenna_height_ft: 33
|
selected_time: selected_time,
|
||||||
)}
|
bounds: bounds,
|
||||||
end
|
initial_center: center,
|
||||||
|
initial_zoom: @default_zoom,
|
||||||
|
grid_visible: false,
|
||||||
|
radar_visible: false,
|
||||||
|
antenna_height_ft: 33
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
|
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue