prop/assets/js/weather_map_hook.ts
Graham McIntire 725a7e6bda
WeatherMap: forecast-hour timeline + top-right layer description
Two related UI changes driven by the same data:

* Move the selected-layer description out of the sidebar and into a
  dedicated top-right overlay on the map. The sidebar kept the same
  layer buttons but dropped the descriptive text; the new overlay
  shows group + layer name + description in one card. Mobile still
  shows the description in the collapsible panel since it has no
  top-right free real estate.

* Add a forecast-hour timeline bar at the bottom of the map, matching
  the propagation map. WeatherMapLive enumerates ProfilesFile on mount
  (the grid worker has been persisting f00..f18 on disk since
  commit 07ffcf5), pushes data-valid-times for the JS hook to render
  as buttons, and handles a new select_time event by reading the
  per-hour ProfilesFile on demand via Weather.weather_grid_at/2.

weather_grid_at/2 deliberately skips the GridCache write path for
non-latest hours — caching 18 × 92k rows would add ~300 MB per pod.
A ~2 MB ETF decode per scrub is fast enough for a click.

Subscribes to propagation:pipeline so the timeline picks up new
forecast hours live as they land, without waiting for the full chain
to finish.
2026-04-15 13:57:23 -05:00

773 lines
29 KiB
TypeScript

// assets/js/weather_map_hook.ts
import { updateGridOverlay } from "./maidenhead_grid"
// --- Type definitions ---
interface RGB {
r: number
g: number
b: number
}
interface Breakpoint extends RGB {
value: number
}
interface ContinuousColorScale {
breakpoints: Breakpoint[]
trueColor?: never
format: (v: number | null) => string
label: string
unit: string
}
interface BooleanColorScale {
breakpoints: null
trueColor: RGB
format: (v: boolean | null) => string
label: string
unit: string
}
type ColorScale = ContinuousColorScale | BooleanColorScale
type LayerId = keyof typeof COLOR_SCALES
interface WeatherPoint {
lat: number
lon: number
valid_time: string | null
temperature: number | null
dewpoint_depression: number | null
surface_rh: number | null
surface_pressure_mb: number | null
surface_refractivity: number | null
refractivity_gradient: number | null
bl_height: number | null
pwat: number | null
temp_850mb: number | null
dewpoint_850mb: number | null
lapse_rate: number | null
inversion_strength: number | null
inversion_base_m: number | null
ducting: boolean | null
duct_base_m: number | null
duct_strength: number | null
[key: string]: unknown
}
interface DetailRow {
label?: string
value?: string
color?: string
separator?: boolean
}
interface MapBounds {
south: number
north: number
west: number
east: number
}
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlay: L.GridLayer | null
weatherData: WeatherPoint[]
gridLookup: Map<string, WeatherPoint>
selectedLayer: LayerId
detailPanel: HTMLElement | null
escHint: HTMLDivElement
clickMarker: L.LayerGroup
legend: L.Control
gridLayer: L.LayerGroup
gridVisible: boolean
radarLayer: L.TileLayer.WMS | null
radarRefreshTimer: ReturnType<typeof setInterval> | null
timelineEl: HTMLElement | null
timelineData: string[]
selectedTime: string | null
_escHandler: (e: KeyboardEvent) => void
_layerObserver: MutationObserver
mounted(this: WeatherMapHook): void
destroyed(this: WeatherMapHook): void
sendBounds(this: WeatherMapHook): void
positionDetailPanel(this: WeatherMapHook): void
buildLookup(this: WeatherMapHook): void
renderLayer(this: WeatherMapHook): void
buildLegend(this: WeatherMapHook): HTMLElement
renderTimeline(this: WeatherMapHook): void
selectTimelineTime(this: WeatherMapHook, time: string): void
}
// --- Helpers ---
function isMobile(): boolean {
return window.innerWidth < 768
}
const COLOR_SCALES = {
refractivity_gradient: {
breakpoints: [
{ value: -500, r: 0, g: 255, b: 163 },
{ value: -300, r: 125, g: 255, b: 212 },
{ value: -200, r: 255, g: 229, b: 102 },
{ value: -100, r: 255, g: 144, b: 68 },
{ value: 0, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} N/km` : "N/A",
label: "Refractivity Gradient",
unit: "N/km"
},
bl_height: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 500, r: 125, g: 255, b: 212 },
{ value: 1000, r: 255, g: 229, b: 102 },
{ value: 2000, r: 255, g: 144, b: 68 },
{ value: 3000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m` : "N/A",
label: "Boundary Layer Height",
unit: "m"
},
dewpoint_depression: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 5, r: 125, g: 255, b: 212 },
{ value: 10, r: 255, g: 229, b: 102 },
{ value: 20, r: 255, g: 144, b: 68 },
{ value: 30, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A",
label: "Dewpoint Depression (T - Td)",
unit: "\u00b0C"
},
pwat: {
breakpoints: [
{ value: 0, r: 139, g: 90, b: 43 },
{ value: 15, r: 194, g: 165, b: 116 },
{ value: 30, r: 173, g: 216, b: 230 },
{ value: 45, r: 65, g: 105, b: 225 },
{ value: 60, r: 0, g: 0, b: 180 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} mm` : "N/A",
label: "Precipitable Water",
unit: "mm"
},
temperature: {
breakpoints: [
{ value: -20, r: 0, g: 0, b: 200 },
{ value: 0, r: 100, g: 149, b: 237 },
{ value: 15, r: 255, g: 255, b: 150 },
{ value: 30, r: 255, g: 140, b: 0 },
{ value: 45, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A",
label: "Surface Temperature",
unit: "\u00b0C"
},
ducting: {
breakpoints: null,
trueColor: { r: 0, g: 255, b: 163 },
format: (v: boolean | null): string => v ? "Yes" : "No",
label: "Ducting Detected",
unit: ""
},
surface_rh: {
breakpoints: [
{ value: 0, r: 255, g: 144, b: 68 },
{ value: 30, r: 255, g: 229, b: 102 },
{ value: 60, r: 125, g: 255, b: 212 },
{ value: 80, r: 0, g: 255, b: 163 },
{ value: 100, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)}%` : "N/A",
label: "Relative Humidity",
unit: "%"
},
surface_refractivity: {
breakpoints: [
{ value: 250, r: 255, g: 79, b: 79 },
{ value: 300, r: 255, g: 144, b: 68 },
{ value: 330, r: 255, g: 229, b: 102 },
{ value: 360, r: 125, g: 255, b: 212 },
{ value: 400, r: 0, g: 255, b: 163 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} N` : "N/A",
label: "Surface Refractivity",
unit: "N"
},
temp_850mb: {
breakpoints: [
{ value: -20, r: 0, g: 0, b: 200 },
{ value: -5, r: 100, g: 149, b: 237 },
{ value: 5, r: 255, g: 255, b: 150 },
{ value: 15, r: 255, g: 140, b: 0 },
{ value: 30, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Temperature at 850mb",
unit: "°C"
},
dewpoint_850mb: {
breakpoints: [
{ value: -20, r: 139, g: 90, b: 43 },
{ value: -5, r: 194, g: 165, b: 116 },
{ value: 5, r: 173, g: 216, b: 230 },
{ value: 15, r: 65, g: 105, b: 225 },
{ value: 25, r: 0, g: 0, b: 180 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Dewpoint at 850mb",
unit: "°C"
},
lapse_rate: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 4, r: 125, g: 255, b: 212 },
{ value: 6.5, r: 255, g: 229, b: 102 },
{ value: 8, r: 255, g: 144, b: 68 },
{ value: 10, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
label: "Lapse Rate",
unit: "°C/km"
},
inversion_strength: {
breakpoints: [
{ value: 0, r: 80, g: 80, b: 80 },
{ value: 1, r: 255, g: 229, b: 102 },
{ value: 3, r: 125, g: 255, b: 212 },
{ value: 5, r: 0, g: 255, b: 163 },
{ value: 10, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A",
label: "Inversion Strength",
unit: "°C"
},
inversion_base_m: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 500, r: 125, g: 255, b: 212 },
{ value: 1000, r: 255, g: 229, b: 102 },
{ value: 2000, r: 255, g: 144, b: 68 },
{ value: 3000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
label: "Inversion Base Height",
unit: "m"
},
duct_base_m: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 200, r: 125, g: 255, b: 212 },
{ value: 500, r: 255, g: 229, b: 102 },
{ value: 1000, r: 255, g: 144, b: 68 },
{ value: 2000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
label: "Duct Base Height",
unit: "m"
},
duct_strength: {
breakpoints: [
{ value: 0, r: 80, g: 80, b: 80 },
{ value: 5, r: 255, g: 229, b: 102 },
{ value: 10, r: 125, g: 255, b: 212 },
{ value: 20, r: 0, g: 255, b: 163 },
{ value: 40, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A",
label: "Duct Strength",
unit: "M"
}
} as const satisfies Record<string, ColorScale>
function interpolateColor(value: number | null, breakpoints: Breakpoint[]): RGB | null {
if (value == null) return null
if (value <= breakpoints[0].value) return breakpoints[0]
if (value >= breakpoints[breakpoints.length - 1].value) return breakpoints[breakpoints.length - 1]
for (let i = 0; i < breakpoints.length - 1; i++) {
const lo = breakpoints[i]
const hi = breakpoints[i + 1]
if (value >= lo.value && value <= hi.value) {
const t = (value - lo.value) / (hi.value - lo.value)
return {
r: Math.round(lo.r + (hi.r - lo.r) * t),
g: Math.round(lo.g + (hi.g - lo.g) * t),
b: Math.round(lo.b + (hi.b - lo.b) * t)
}
}
}
return breakpoints[breakpoints.length - 1]
}
function buildDetailHTML(point: WeatherPoint): string {
if (!point) return ""
const rows: DetailRow[] = [
{ label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" },
{ label: "Td Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" },
{ label: "Rel. Humidity", value: COLOR_SCALES.surface_rh.format(point.surface_rh), color: "#00ffa3" },
{ label: "Sfc Pressure", value: point.surface_pressure_mb != null ? `${point.surface_pressure_mb.toFixed(1)} mb` : "N/A", color: "#aaa" },
{ label: "Refractivity", value: COLOR_SCALES.surface_refractivity.format(point.surface_refractivity), color: "#00ffa3" },
{ label: "N-Gradient", value: COLOR_SCALES.refractivity_gradient.format(point.refractivity_gradient), color: "#00ffa3" },
{ label: "BL Height", value: COLOR_SCALES.bl_height.format(point.bl_height), color: "#ffe566" },
{ label: "PWAT", value: COLOR_SCALES.pwat.format(point.pwat), color: "#4169e1" },
{ separator: true },
{ label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" },
{ label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" },
{ label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" },
{ label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" },
{ label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" },
{ separator: true },
{ label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" },
{ label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" },
{ label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" }
]
const mobile = isMobile()
const closeBtn = mobile
? `<button onclick="document.getElementById('weather-detail-panel').style.display='none'" style="position:absolute;top:8px;right:10px;background:rgba(255,255,255,0.15);border:none;color:#fff;font-size:18px;line-height:1;width:28px;height:28px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;">&times;</button>`
: ""
const handle = mobile
? `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
: ""
const tableRows = rows.map(r => {
if (r.separator) {
return `<tr><td colspan="2" style="padding:2px 0;"><hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);"></td></tr>`
}
return `<tr>
<td style="padding:3px 8px 3px 0;font-size:12px;white-space:nowrap;"><span style="color:${r.color};">\u25cf</span> ${r.label}</td>
<td style="padding:3px 0;font-size:12px;text-align:right;font-weight:600;">${r.value}</td>
</tr>`
}).join("")
const time = point.valid_time
? new Date(point.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
: ""
return `<div style="min-width:${mobile ? 'auto' : '240px'};position:relative;">
${handle}${closeBtn}
<div style="background:rgba(255,255,255,0.1);padding:6px 10px;">
<strong style="font-size:14px;">Weather Details</strong>
</div>
<div style="padding:6px 10px;">
<div style="font-size:11px;opacity:0.6;margin-bottom:6px;">
${point.lat.toFixed(3)}\u00b0N, ${Math.abs(point.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div>
<table style="width:100%;border-collapse:collapse;">${tableRows}</table>
</div>
</div>`
}
// --- Hook ---
export const WeatherMap: WeatherMapHook = {
mounted(this: WeatherMapHook) {
this.map = L.map(this.el, {
center: [32.897, -97.038],
zoom: 7,
minZoom: 4,
maxZoom: 10
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
this.weatherOverlay = null
this.weatherData = []
this.gridLookup = new Map()
this.selectedLayer = (this.el.dataset.selectedLayer || "refractivity_gradient") as LayerId
const initialData: WeatherPoint[] = JSON.parse(this.el.dataset.weather || "[]")
if (initialData.length > 0) {
this.weatherData = initialData
this.buildLookup()
this.renderLayer()
}
this.handleEvent("update_weather", ({ data }: { data: WeatherPoint[] }) => {
this.weatherData = data
this.buildLookup()
this.renderLayer()
})
this.detailPanel = document.getElementById("weather-detail-panel")
if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel)
}
// ESC hint
this.escHint = document.createElement("div")
this.escHint.className = "hidden md:block"
this.escHint.style.cssText = "position:absolute;top:12px;right:12px;z-index:1100;background:rgba(0,0,0,0.6);color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;opacity:0;transition:opacity 0.2s;pointer-events:none;"
this.escHint.textContent = "Press ESC to close"
this.el.appendChild(this.escHint)
this.handleEvent("point_detail", (detail: WeatherPoint) => {
if (detail && detail.lat && this.detailPanel) {
this.detailPanel.innerHTML = buildDetailHTML(detail)
this.detailPanel.style.display = "block"
this.escHint.style.opacity = "1"
this.positionDetailPanel()
}
})
this.clickMarker = L.layerGroup().addTo(this.map)
this.map.on("click", (e: L.LeafletMouseEvent) => {
this.clickMarker.clearLayers()
L.circleMarker([e.latlng.lat, e.latlng.lng], {
radius: 6, color: "#fff", weight: 2,
fillColor: "#888", fillOpacity: 0.8, interactive: false
}).addTo(this.clickMarker)
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
})
this._escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape" && this.detailPanel) {
this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
this.clickMarker.clearLayers()
this.escHint.style.opacity = "0"
}
}
document.addEventListener("keydown", this._escHandler)
const controls = document.getElementById("weather-controls")
if (controls) {
L.DomEvent.disableClickPropagation(controls)
L.DomEvent.disableScrollPropagation(controls)
}
// Maidenhead grid overlay
this.gridLayer = L.layerGroup()
this.gridVisible = false
this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
this.gridVisible = visible
if (visible) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer)
} else {
this.gridLayer.remove()
}
})
// Weather radar overlay (ECCC GeoMet WMS — CONUS + Canada composite dBZ).
// Lazy-created on first toggle so we don't hit the service until asked.
this.radarLayer = null
this.radarRefreshTimer = null
this.handleEvent("toggle_radar", ({ visible }: { visible: boolean }) => {
if (visible) {
if (!this.radarLayer) {
this.radarLayer = L.tileLayer.wms("https://geo.weather.gc.ca/geomet", {
layers: "Radar_1km_dBZ-Extrapolation",
format: "image/png",
transparent: true,
opacity: 0.55,
version: "1.3.0",
attribution: "Radar &copy; Environment and Climate Change Canada"
})
}
this.radarLayer.addTo(this.map)
// ECCC radar updates every ~6 minutes; force a tile refresh on that
// cadence so the overlay stays current without a page reload.
this.radarRefreshTimer = setInterval(() => {
if (this.radarLayer) {
// @ts-expect-error setParams exists on WMS TileLayer but isn't in @types/leaflet yet
this.radarLayer.setParams({ _: Date.now() })
}
}, 6 * 60 * 1000)
} else {
if (this.radarLayer) this.radarLayer.remove()
if (this.radarRefreshTimer) {
clearInterval(this.radarRefreshTimer)
this.radarRefreshTimer = null
}
}
})
requestAnimationFrame(() => this.sendBounds())
this.map.on("moveend", () => {
this.sendBounds()
if (this.gridVisible) {
updateGridOverlay(this.map, this.gridLayer)
}
})
this._layerObserver = new MutationObserver(() => {
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) {
this.selectedLayer = newLayer
this.renderLayer()
}
})
this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] })
this.legend = L.control({ position: isMobile() ? "topright" : "bottomright" })
this.legend.onAdd = () => this.buildLegend()
this.legend.addTo(this.map)
// --- Forecast timeline ---
this.timelineEl = document.getElementById("weather-forecast-timeline")
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
this.selectedTime = this.el.dataset.selectedTime || null
if (this.timelineData.length > 1) {
this.renderTimeline()
}
this.handleEvent("update_timeline", ({ times, selected }: { times: string[]; selected: string | null }) => {
this.timelineData = times
if (selected) this.selectedTime = selected
this.renderTimeline()
})
// Prevent timeline from eating map clicks
if (this.timelineEl) {
L.DomEvent.disableClickPropagation(this.timelineEl)
L.DomEvent.disableScrollPropagation(this.timelineEl)
}
// Keep the layer-info overlay from passing through to the map.
const layerInfo = document.getElementById("weather-layer-info")
if (layerInfo) {
L.DomEvent.disableClickPropagation(layerInfo)
L.DomEvent.disableScrollPropagation(layerInfo)
}
},
destroyed(this: WeatherMapHook) {
if (this._layerObserver) this._layerObserver.disconnect()
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
},
sendBounds(this: WeatherMapHook) {
const b = this.map.getBounds()
this.pushEvent("map_bounds", {
south: b.getSouth(), north: b.getNorth(),
west: b.getWest(), east: b.getEast()
})
},
positionDetailPanel(this: WeatherMapHook) {
if (!this.detailPanel || isMobile()) return
const controls = document.getElementById("weather-controls")
if (controls) {
const rect = controls.getBoundingClientRect()
this.detailPanel.style.left = rect.left + "px"
this.detailPanel.style.top = (rect.bottom + 8) + "px"
}
},
buildLookup(this: WeatherMapHook) {
this.gridLookup = new Map()
this.weatherData.forEach(d => {
const key = `${d.lat.toFixed(3)},${d.lon.toFixed(3)}`
this.gridLookup.set(key, d)
})
},
renderLayer(this: WeatherMapHook) {
if (this.weatherOverlay) {
this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = null
}
if (this.weatherData.length === 0) return
const layerId = this.selectedLayer
const scale = COLOR_SCALES[layerId]
if (!scale) return
const self = this
const isDucting = layerId === "ducting"
const Overlay = L.GridLayer.extend({
createTile(this: L.GridLayer, 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 as any)._map.unproject(nwPoint, coords.z) as L.LatLng
const se = (this as any)._map.unproject(nwPoint.add(size), coords.z) as L.LatLng
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)
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 rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
const point = self.gridLookup.get(`${rlat},${rlon}`)
if (!point) continue
const value = point[layerId]
if (isDucting) {
if (value) {
const tc = (scale as BooleanColorScale).trueColor
ctx.fillStyle = `rgba(${tc.r},${tc.g},${tc.b},0.55)`
ctx.fillRect(px, py, cellPxX, cellPxY)
}
} else {
const c = interpolateColor(value as number | null, (scale as ContinuousColorScale).breakpoints)
if (c) {
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.55)`
ctx.fillRect(px, py, cellPxX, cellPxY)
}
}
}
}
return tile
}
})
this.weatherOverlay = new Overlay({ opacity: 1.0 }) as L.GridLayer
this.weatherOverlay.addTo(this.map)
if (this.legend) {
this.map.removeControl(this.legend)
this.legend = L.control({ position: isMobile() ? "topright" : "bottomright" })
this.legend.onAdd = () => this.buildLegend()
this.legend.addTo(this.map)
}
},
buildLegend(this: WeatherMapHook): HTMLElement {
const div = L.DomUtil.create("div")
const layerId = this.selectedLayer
const scale = COLOR_SCALES[layerId]
if (!scale) return div
const mobile = isMobile()
if (layerId === "ducting") {
const c = (scale as BooleanColorScale).trueColor
div.style.cssText = `background:#fff;color:#333;padding:${mobile ? '4px 6px' : '10px 14px'};border-radius:${mobile ? '6px' : '8px'};box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:${mobile ? '10px' : '13px'};line-height:2;`
div.innerHTML = `<strong style="color:#333;">${scale.label}</strong><br>` +
`<span style="background:rgb(${c.r},${c.g},${c.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>Ducting<br>` +
`<span style="background:transparent;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>None`
return div
}
const bp = (scale as ContinuousColorScale).breakpoints
if (mobile) {
div.style.cssText = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
div.innerHTML = bp.map(b =>
`<span style="background:rgb(${b.r},${b.g},${b.b});width:10px;height:10px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:3px;border:1px solid rgba(0,0,0,0.15);"></span>${b.value}`
).join("<br>")
} else {
div.style.cssText = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
div.innerHTML = `<strong style="color:#333;">${scale.label}</strong><br>` +
bp.map(b =>
`<span style="background:rgb(${b.r},${b.g},${b.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${b.value} ${scale.unit}</span>`
).join("<br>")
}
return div
},
renderTimeline(this: WeatherMapHook) {
if (!this.timelineEl || this.timelineData.length === 0) {
if (this.timelineEl) this.timelineEl.style.display = "none"
return
}
if (this.timelineData.length === 1) {
const dt = new Date(this.timelineData[0])
const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC`
const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}`
this.timelineEl.style.display = "block"
this.timelineEl.innerHTML = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
<span>Data: ${dateLabel} ${utcLabel}</span>
</div>`
return
}
const now = new Date()
type TItem = { time: string; dt: Date; isSelected: boolean; isPast: boolean; offsetH: number; label: string }
const items: TItem[] = this.timelineData.map((time) => {
const dt = new Date(time)
const isSelected = time === this.selectedTime
const isPast = dt.getTime() < now.getTime() - 1800000
const offsetH = Math.round((dt.getTime() - now.getTime()) / 3600000)
return { time, dt, isSelected, isPast, offsetH, label: "" }
})
const closestIdx = items.reduce((best, item, i) =>
Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0)
items.forEach((t, i) => {
if (i === closestIdx) {
t.label = "Now"
} else {
const diff = t.offsetH
t.label = diff > 0 ? `+${diff}h` : `${diff}h`
}
})
const mobile = isMobile()
const btnPad = mobile ? "3px 5px" : "4px 8px"
const btnMinW = mobile ? "32px" : "38px"
const btnFont = mobile ? "10px" : "11px"
const subFont = mobile ? "8px" : "9px"
const buttons = items.map(t => {
const bg = t.isSelected ? "#7dffd4" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff")
const border = t.isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)"
const weight = t.isSelected ? "700" : "400"
const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00`
return `<button data-time="${t.time}" style="
padding:${btnPad};font-size:${btnFont};font-weight:${weight};
background:${bg};color:${fg};border:${border};border-radius:6px;
cursor:pointer;white-space:nowrap;min-width:${btnMinW};
transition:background 0.15s;
">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
}).join("")
const labelText = mobile ? "Forecast" : "Weather Forecast"
this.timelineEl.style.display = "block"
this.timelineEl.innerHTML = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;margin-right:6px;flex-shrink:0;">${labelText}</span>
${buttons}
</div>`
this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
btn.addEventListener("click", () => {
const time = btn.dataset.time!
this.selectTimelineTime(time)
})
})
},
selectTimelineTime(this: WeatherMapHook, time: string) {
this.selectedTime = time
this.renderTimeline()
this.pushEvent("select_time", { time })
}
} as unknown as WeatherMapHook