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 - Never use `@apply` in CSS
- No inline `<script>` tags; use colocated JS hooks with `.` prefix names - 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` - 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 ### Elixir
- Write idiomatic Elixir: use pattern matching, pipe operator, and the standard library - 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> gridLookup: Map<string, number>
colorScale: ColorScaleEntry[] colorScale: ColorScaleEntry[]
map: L.Map map: L.Map
pushBounds: (this: RoverMapHook) => void
visibilityHandler: (() => void) | null
renderScores(this: RoverMapHook, scores: Score[]): void renderScores(this: RoverMapHook, scores: Score[]): void
interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null
scoreColorRGB(this: RoverMapHook, score: number): RGB scoreColorRGB(this: RoverMapHook, score: number): RGB
renderStations(this: RoverMapHook): void renderStations(this: RoverMapHook): void
fitBounds(this: RoverMapHook): void fitBounds(this: RoverMapHook): void
reconnected(this: RoverMapHook): void
destroyed(this: RoverMapHook): void
} }
export const RoverMap: RoverMapHook = { export const RoverMap: RoverMapHook = {
@ -73,7 +77,7 @@ export const RoverMap: RoverMapHook = {
} }
// Report bounds so server can send scores for visible area // Report bounds so server can send scores for visible area
const pushBounds = () => { this.pushBounds = () => {
const b = map.getBounds() const b = map.getBounds()
this.pushEvent("map_bounds", { this.pushEvent("map_bounds", {
south: b.getSouth(), north: b.getNorth(), south: b.getSouth(), north: b.getNorth(),
@ -82,7 +86,7 @@ export const RoverMap: RoverMapHook = {
} }
map.on("moveend", () => { map.on("moveend", () => {
pushBounds() this.pushBounds()
}) })
this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => { this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => {
@ -94,6 +98,31 @@ export const RoverMap: RoverMapHook = {
this.renderStations() 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) --- // --- Propagation score overlay (same as main map) ---

View file

@ -91,9 +91,11 @@ interface WeatherMapHook extends ViewHook {
playbackTimer: ReturnType<typeof setInterval> | null playbackTimer: ReturnType<typeof setInterval> | null
_escHandler: (e: KeyboardEvent) => void _escHandler: (e: KeyboardEvent) => void
_layerObserver: MutationObserver _layerObserver: MutationObserver
visibilityHandler: (() => void) | null
mounted(this: WeatherMapHook): void mounted(this: WeatherMapHook): void
destroyed(this: WeatherMapHook): void destroyed(this: WeatherMapHook): void
reconnected(this: WeatherMapHook): void
sendBounds(this: WeatherMapHook): void sendBounds(this: WeatherMapHook): void
positionDetailPanel(this: WeatherMapHook): void positionDetailPanel(this: WeatherMapHook): void
buildLookup(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(() => { this._layerObserver = new MutationObserver(() => {
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) { if (newLayer && newLayer !== this.selectedLayer) {
@ -550,6 +563,17 @@ export const WeatherMap: WeatherMapHook = {
if (this._escHandler) document.removeEventListener("keydown", this._escHandler) if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer) if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
if (this.playbackTimer !== null) clearInterval(this.playbackTimer) 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) { sendBounds(this: WeatherMapHook) {