From b176390b7b86598fe206638dee8eb3bade999be5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 3 Apr 2026 15:15:23 -0500 Subject: [PATCH] Add WeatherMap JS hook with canvas heatmap and 6 weather layers --- assets/js/app.js | 3 +- assets/js/weather_map_hook.js | 369 ++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 assets/js/weather_map_hook.js diff --git a/assets/js/app.js b/assets/js/app.js index 396a2861..7a4ae0fc 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -34,6 +34,7 @@ import {PropagationMap} from "./propagation_map_hook" import {ContactMap} from "./contact_map_hook" import {ContactsMap} from "./contacts_map_hook" import {ElevationProfile} from "./elevation_profile_hook" +import {WeatherMap} from "./weather_map_hook" const UtcClock = { mounted() { @@ -55,7 +56,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), - hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile}, + hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/weather_map_hook.js b/assets/js/weather_map_hook.js new file mode 100644 index 00000000..06ebacff --- /dev/null +++ b/assets/js/weather_map_hook.js @@ -0,0 +1,369 @@ +// assets/js/weather_map_hook.js + +function isMobile() { + 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 => 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 => 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 => 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 => 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 => 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 => v ? "Yes" : "No", + label: "Ducting Detected", + unit: "" + } +} + +function interpolateColor(value, breakpoints) { + 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) { + if (!point) return "" + + const rows = [ + { label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" }, + { label: "Dewpoint Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" }, + { label: "Boundary Layer", value: COLOR_SCALES.bl_height.format(point.bl_height), color: "#ffe566" }, + { label: "Precipitable Water", value: COLOR_SCALES.pwat.format(point.pwat), color: "#4169e1" }, + { label: "Refractivity Gradient", value: COLOR_SCALES.refractivity_gradient.format(point.refractivity_gradient), color: "#00ffa3" }, + { label: "Ducting Detected", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" }, + { label: "Surface Pressure", value: point.surface_pressure_mb != null ? `${point.surface_pressure_mb.toFixed(1)} mb` : "N/A", color: "#aaa" } + ] + + const mobile = isMobile() + const closeBtn = mobile + ? `` + : "" + const handle = mobile + ? `
` + : "" + + const tableRows = rows.map(r => + ` + \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}
+
+
` +} + +export const WeatherMap = { + mounted() { + 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" + + const initialData = JSON.parse(this.el.dataset.weather || "[]") + if (initialData.length > 0) { + this.weatherData = initialData + this.buildLookup() + this.renderLayer() + } + + this.handleEvent("update_weather", ({ data }) => { + 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) + } + + this.handleEvent("point_detail", (detail) => { + if (detail && detail.lat && this.detailPanel) { + this.detailPanel.innerHTML = buildDetailHTML(detail) + this.detailPanel.style.display = "block" + this.positionDetailPanel() + } + }) + + this.clickMarker = L.layerGroup().addTo(this.map) + + this.map.on("click", (e) => { + 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) => { + if (e.key === "Escape" && this.detailPanel) { + this.detailPanel.style.display = "none" + this.detailPanel.innerHTML = "" + this.clickMarker.clearLayers() + } + } + document.addEventListener("keydown", this._escHandler) + + const controls = document.getElementById("weather-controls") + if (controls) { + L.DomEvent.disableClickPropagation(controls) + L.DomEvent.disableScrollPropagation(controls) + } + + this.sendBounds() + this.map.on("moveend", () => this.sendBounds()) + + this._layerObserver = new MutationObserver(() => { + const newLayer = this.el.dataset.selectedLayer + 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() { + if (this._layerObserver) this._layerObserver.disconnect() + if (this._escHandler) document.removeEventListener("keydown", this._escHandler) + }, + + sendBounds() { + const b = this.map.getBounds() + this.pushEvent("map_bounds", { + south: b.getSouth(), north: b.getNorth(), + west: b.getWest(), east: b.getEast() + }) + }, + + positionDetailPanel() { + 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.gridLookup = new Map() + this.weatherData.forEach(d => { + const key = `${d.lat.toFixed(3)},${d.lon.toFixed(3)}` + this.gridLookup.set(key, d) + }) + }, + + renderLayer() { + 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(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) + + 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) { + ctx.fillStyle = `rgba(${scale.trueColor.r},${scale.trueColor.g},${scale.trueColor.b},0.55)` + ctx.fillRect(px, py, cellPxX, cellPxY) + } + } else { + const c = interpolateColor(value, scale.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 }) + 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() { + 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.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.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 + } +}