fix(maps): same reconnect/visibility fix for rover and weather hooks

rover_map_hook and weather_map_hook share the same viewport-scoped
data flow as the propagation map — they hit the same blank-tile bug
after tab hide or socket reconnect. Add reconnected() + visibilitychange
handlers and document the requirement in CLAUDE.md so future hooks that
push map_bounds don't regress.
This commit is contained in:
Graham McIntire 2026-04-18 13:25:38 -05:00
parent 53cc2c9121
commit d64e502908
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 56 additions and 2 deletions

View file

@ -179,6 +179,7 @@ User submits QSO → enqueue_for_qso() → weather/hrrr/terrain/iemre workers
- Never use `@apply` in CSS
- No inline `<script>` tags; use colocated JS hooks with `.` prefix names
- Vendor deps must be imported into `app.js`/`app.css`, no external script `src` or link `href`
- LiveView hooks that push `map_bounds` (or any viewport-scoped data request) MUST implement `reconnected()` and a `document.visibilitychange` listener. Server assigns reset on reconnect while the hook keeps its stale client-side grid lookup — Leaflet tiles regenerated outside that extent paint blank. Both callbacks should call `invalidateSize()` + the bounds-push method; clean up the listener in `destroyed()`.
### Elixir
- Write idiomatic Elixir: use pattern matching, pipe operator, and the standard library

View file

@ -30,11 +30,15 @@ interface RoverMapHook extends ViewHook {
gridLookup: Map<string, number>
colorScale: ColorScaleEntry[]
map: L.Map
pushBounds: (this: RoverMapHook) => void
visibilityHandler: (() => void) | null
renderScores(this: RoverMapHook, scores: Score[]): void
interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null
scoreColorRGB(this: RoverMapHook, score: number): RGB
renderStations(this: RoverMapHook): void
fitBounds(this: RoverMapHook): void
reconnected(this: RoverMapHook): void
destroyed(this: RoverMapHook): void
}
export const RoverMap: RoverMapHook = {
@ -73,7 +77,7 @@ export const RoverMap: RoverMapHook = {
}
// Report bounds so server can send scores for visible area
const pushBounds = () => {
this.pushBounds = () => {
const b = map.getBounds()
this.pushEvent("map_bounds", {
south: b.getSouth(), north: b.getNorth(),
@ -82,7 +86,7 @@ export const RoverMap: RoverMapHook = {
}
map.on("moveend", () => {
pushBounds()
this.pushBounds()
})
this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => {
@ -94,6 +98,31 @@ export const RoverMap: RoverMapHook = {
this.renderStations()
})
// Refresh overlay when the tab returns to visibility. Tiles created while
// hidden paint blank if the retained gridLookup no longer covers the
// current viewport; re-fetching bounds triggers update_scores and a full
// overlay rebuild.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
this.pushBounds()
}
}
document.addEventListener("visibilitychange", this.visibilityHandler)
},
reconnected(this: RoverMapHook) {
if (this.map) {
this.map.invalidateSize()
this.pushBounds()
}
},
destroyed(this: RoverMapHook) {
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
},
// --- Propagation score overlay (same as main map) ---

View file

@ -91,9 +91,11 @@ interface WeatherMapHook extends ViewHook {
playbackTimer: ReturnType<typeof setInterval> | null
_escHandler: (e: KeyboardEvent) => void
_layerObserver: MutationObserver
visibilityHandler: (() => void) | null
mounted(this: WeatherMapHook): void
destroyed(this: WeatherMapHook): void
reconnected(this: WeatherMapHook): void
sendBounds(this: WeatherMapHook): void
positionDetailPanel(this: WeatherMapHook): void
buildLookup(this: WeatherMapHook): void
@ -509,6 +511,17 @@ export const WeatherMap: WeatherMapHook = {
}
})
// Tab may be hidden while Leaflet drops/creates tiles; retained
// gridLookup stops matching the visible viewport and new tiles paint
// blank. Re-fetch bounds on return so update_weather rebuilds the layer.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
this.sendBounds()
}
}
document.addEventListener("visibilitychange", this.visibilityHandler)
this._layerObserver = new MutationObserver(() => {
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) {
@ -550,6 +563,17 @@ export const WeatherMap: WeatherMapHook = {
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
},
reconnected(this: WeatherMapHook) {
if (this.map) {
this.map.invalidateSize()
this.sendBounds()
}
},
sendBounds(this: WeatherMapHook) {