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}
+
+
+
+
`
+}
+
+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
+ }
+}