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:
parent
53cc2c9121
commit
d64e502908
3 changed files with 56 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) ---
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue