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.
773 lines
29 KiB
TypeScript
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;">×</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 — ${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: "© 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 © 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
|