prop/assets/js/rover_map_hook.ts
Graham McIntire d64e502908
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.
2026-04-18 13:25:38 -05:00

277 lines
8.2 KiB
TypeScript

interface Score {
lat: number
lon: number
score: number
}
interface Station {
lat: number
lon: number
label: string
}
interface ColorScaleEntry {
min: number
r: number
g: number
b: number
}
interface RGB {
r: number
g: number
b: number
}
interface RoverMapHook extends ViewHook {
stations: Station[]
stationMarkers: L.LayerGroup
scoreOverlay: L.GridLayer | null
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 = {
mounted(this: RoverMapHook) {
this.stations = []
this.stationMarkers = L.layerGroup()
this.scoreOverlay = null
this.gridLookup = new Map()
this.colorScale = [
{ min: 80, r: 0, g: 255, b: 163 },
{ min: 65, r: 125, g: 255, b: 212 },
{ min: 50, r: 255, g: 229, b: 102 },
{ min: 33, r: 255, g: 144, b: 68 },
{ min: 0, r: 255, g: 79, b: 79 }
]
const map = L.map(this.el, {
center: [33.2, -97.1],
zoom: 7,
zoomControl: true,
attributionControl: false
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
this.map = map
this.stationMarkers.addTo(map)
// Render pre-loaded propagation scores
const initialScores: Score[] = JSON.parse(this.el.dataset.scores || "[]")
if (initialScores.length > 0) {
this.renderScores(initialScores)
}
// Report bounds so server can send scores for visible area
this.pushBounds = () => {
const b = map.getBounds()
this.pushEvent("map_bounds", {
south: b.getSouth(), north: b.getNorth(),
west: b.getWest(), east: b.getEast()
})
}
map.on("moveend", () => {
this.pushBounds()
})
this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => {
this.renderScores(scores)
})
this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => {
this.stations = stations
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) ---
renderScores(this: RoverMapHook, scores: Score[]) {
if (this.scoreOverlay) {
this.map.removeLayer(this.scoreOverlay)
this.scoreOverlay = null
}
if (!scores || scores.length === 0) return
this.gridLookup = new Map()
scores.forEach((s) => {
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}`
this.gridLookup.set(key, s.score)
})
const colorCache = new Array<string>(101)
for (let i = 0; i <= 100; i++) {
const c = this.scoreColorRGB(i)
colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)`
}
const self = this
const OverlayClass = L.GridLayer.extend({
createTile(coords: L.Coords) {
const tile = document.createElement("canvas")
const size = this.getTileSize()
tile.width = size.x
tile.height = size.y
const ctx = tile.getContext("2d")!
const step = 0.125
const nwPoint = coords.scaleBy(size)
const nw = this._map.unproject(nwPoint, coords.z)
const se = this._map.unproject(nwPoint.add(size), coords.z)
const latPerPx = (nw.lat - se.lat) / size.y
const lonPerPx = (se.lng - nw.lng) / size.x
const cellPxX = Math.max(1, Math.ceil(step / lonPerPx))
const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx)))
const absLatPerPx = Math.abs(latPerPx)
let lastColor: string | null = null
for (let py = 0; py < size.y; py += cellPxY) {
const lat = nw.lat - py * absLatPerPx
for (let px = 0; px < size.x; px += cellPxX) {
const lon = nw.lng + px * lonPerPx
const score = self.interpolateScore(lat, lon, step)
if (score !== null) {
const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))]
if (color !== lastColor) {
ctx.fillStyle = color
lastColor = color
}
ctx.fillRect(px, py, cellPxX, cellPxY)
}
}
}
return tile
}
})
this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) as L.GridLayer
this.scoreOverlay!.addTo(this.map)
},
interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null {
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)!
const lat0 = Math.floor(lat / step) * step
const lat1 = lat0 + step
const lon0 = Math.floor(lon / step) * step
const lon1 = lon0 + step
const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`)
const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`)
const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`)
const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`)
const corners = [s00, s10, s01, s11].filter((s): s is number => s !== undefined)
if (corners.length < 2) return null
if (corners.length === 4) {
const tLat = (lat - lat0) / step
const tLon = (lon - lon0) / step
return Math.round(
s00! * (1 - tLat) * (1 - tLon) + s10! * tLat * (1 - tLon) +
s01! * (1 - tLat) * tLon + s11! * tLat * tLon
)
}
return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length)
},
scoreColorRGB(this: RoverMapHook, score: number): RGB {
for (let i = 0; i < this.colorScale.length - 1; i++) {
const upper = this.colorScale[i]
const lower = this.colorScale[i + 1]
if (score >= lower.min) {
const range = upper.min - lower.min
const t = Math.min(1, (score - lower.min) / range)
return {
r: Math.round(lower.r + (upper.r - lower.r) * t),
g: Math.round(lower.g + (upper.g - lower.g) * t),
b: Math.round(lower.b + (upper.b - lower.b) * t)
}
}
}
const last = this.colorScale[this.colorScale.length - 1]
return { r: last.r, g: last.g, b: last.b }
},
// --- Station rendering ---
renderStations(this: RoverMapHook) {
this.stationMarkers.clearLayers()
for (const s of this.stations) {
const marker = L.circleMarker([s.lat, s.lon], {
radius: 7,
color: "#fff",
weight: 2,
fillColor: "#3b82f6",
fillOpacity: 0.9,
interactive: true,
pane: "markerPane"
})
marker.bindTooltip(s.label, {
permanent: true,
direction: "top",
offset: [0, -10],
className: "station-label"
})
this.stationMarkers.addLayer(marker)
}
this.fitBounds()
},
fitBounds(this: RoverMapHook) {
const points: [number, number][] = this.stations.map(s => [s.lat, s.lon])
if (points.length >= 2) {
this.map.fitBounds(points, { padding: [60, 60], maxZoom: 9 })
} else if (points.length === 1) {
this.map.setView(points[0], 8)
}
}
} as unknown as RoverMapHook