// assets/js/weather_map_hook.ts // --- 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 selectedLayer: LayerId detailPanel: HTMLElement | null escHint: HTMLDivElement clickMarker: L.LayerGroup legend: L.Control _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 } // --- 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 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 ? `` : "" const handle = mobile ? `
` : "" const tableRows = rows.map(r => { if (r.separator) { return `
` } return ` \u25cf ${r.label} ${r.value} ` }).join("") const time = point.valid_time ? new Date(point.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" : "" return `
${handle}${closeBtn}
Weather Details
${point.lat.toFixed(3)}\u00b0N, ${Math.abs(point.lon).toFixed(3)}\u00b0W — ${time}
${tableRows}
` } // --- 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) } requestAnimationFrame(() => this.sendBounds()) this.map.on("moveend", () => this.sendBounds()) 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) }, destroyed(this: WeatherMapHook) { if (this._layerObserver) this._layerObserver.disconnect() if (this._escHandler) document.removeEventListener("keydown", this._escHandler) }, 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 = `${scale.label}
` + `Ducting
` + `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 => `${b.value}` ).join("
") } 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 = `${scale.label}
` + bp.map(b => `${b.value} ${scale.unit}` ).join("
") } return div } } as unknown as WeatherMapHook