prop/assets/js/propagation_map_hook.js
Graham McIntire bcdbf0b53a Show per-duct layer heights in map detail panel
When clicking a grid point with ducting, the panel now shows each
duct layer with base-top height in feet, thickness in meters, and
minimum trapped frequency. Data flows from Duct.analyze through
the scoring factors as a ducts array.
2026-04-11 14:13:24 -05:00

917 lines
36 KiB
JavaScript

import topbar from "../vendor/topbar"
import { updateGridOverlay } from "./maidenhead_grid"
// Weights must match BandConfig.weights() in band_config.ex
// Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12)
const FACTOR_META = {
rain: { label: "Rain", weight: 0.1362, unit: "" },
humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" },
pwat: { label: "PWAT", weight: 0.1128, unit: "mm" },
season: { label: "Season", weight: 0.1112, unit: "" },
refractivity: { label: "Refractivity", weight: 0.1049, unit: "N/km" },
pressure: { label: "Pressure", weight: 0.1032, unit: "mb" },
td_depression: { label: "Td Depression", weight: 0.0978, unit: "\u00b0F" },
sky: { label: "Sky Cover", weight: 0.08, unit: "%" },
wind: { label: "Wind", weight: 0.08, unit: "kts" },
time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" }
}
const FACTOR_ORDER = [
"rain", "humidity", "pwat", "season", "refractivity",
"pressure", "td_depression", "sky", "wind", "time_of_day"
]
function scoreTier(score) {
if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" }
if (score >= 65) return { label: "GOOD", color: "#7dffd4" }
if (score >= 50) return { label: "MARGINAL", color: "#ffe566" }
if (score >= 33) return { label: "POOR", color: "#ff9044" }
return { label: "NEGLIGIBLE", color: "#ff4f4f" }
}
function factorBar(score) {
const filled = Math.round(score / 5)
const empty = 20 - filled
const tier = scoreTier(score)
return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
}
function factorExplanation(key, score, detail) {
const beneficial = detail.humidity_effect === "beneficial"
const band = detail.band_label || ""
switch (key) {
case "humidity":
if (beneficial) {
if (score >= 80) return "High moisture — strong ducting potential"
if (score >= 55) return "Moderate moisture — some ducting possible"
return "Dry air — ducting unlikely"
} else {
if (score >= 80) return "Low humidity — minimal absorption"
if (score >= 50) return "Moderate humidity — some absorption"
return "High humidity — significant absorption at this frequency"
}
case "time_of_day":
if (score >= 80) return "Sunrise window — peak inversion strength"
if (score >= 65) return "Evening/post-sunrise — inversions forming or eroding"
if (score >= 40) return "Night — gradual cooling, weak inversions"
return "Afternoon — convective mixing destroys inversions"
case "td_depression":
if (beneficial) {
if (score >= 75) return "Tight depression — moist boundary layer favors ducting"
if (score >= 50) return "Moderate spread — some moisture present"
return "Wide spread — dry air limits ducting formation"
} else {
if (score >= 80) return "Wide spread — dry air reduces absorption"
if (score >= 50) return "Moderate spread"
return "Tight depression — moisture increasing absorption"
}
case "refractivity":
if (score >= 80) return "Strong inversion gradient — ducting layer detected"
if (score >= 60) return "Moderate gradient — enhanced refraction"
if (score >= 45) return "Weak gradient — near standard atmosphere"
return "No significant inversion — standard refraction"
case "sky":
if (score >= 80) return "Clear skies — radiative cooling strengthens inversions"
if (score >= 50) return "Partly cloudy — some radiative cooling"
return "Overcast — clouds prevent inversion formation"
case "season":
if (beneficial) {
if (score >= 70) return "Peak ducting season (summer months)"
if (score >= 40) return "Transitional season — moderate ducting"
return "Weakest season for ducting at this frequency"
} else {
if (score >= 70) return "Peak season — cool, dry conditions"
if (score >= 40) return "Transitional season"
return "Summer humidity degrades propagation at this frequency"
}
case "wind":
if (score >= 80) return "Calm winds — inversions preserved"
if (score >= 55) return "Light winds — minor mixing"
return "Strong winds — turbulent mixing destroys inversions"
case "rain":
if (score >= 90) return "No rain — no path attenuation"
if (score >= 50) return "Light rain — minor attenuation"
return "Heavy rain — significant path loss"
case "pwat":
if (beneficial) {
if (score >= 80) return "High column moisture — strong refractivity"
if (score >= 50) return "Moderate PWAT — some refractivity enhancement"
return "Low PWAT — limited moisture for ducting"
} else {
if (score >= 80) return "Low column moisture — minimal absorption"
if (score >= 50) return "Moderate PWAT"
return "High PWAT — significant absorption at this frequency"
}
case "pressure":
if (score >= 70) return "Low pressure — frontal boundaries favor ducting"
if (score >= 45) return "Normal pressure — average conditions"
return "High pressure — stable ridge, inversions cap at wrong altitude"
default:
return ""
}
}
function rangeEstimate(score, detail) {
if (score >= 80) return `${detail.extended_range_km}\u2013${detail.exceptional_range_km}+ km`
if (score >= 65) return `${detail.typical_range_km}\u2013${detail.extended_range_km} km`
if (score >= 50) return `${Math.round(detail.typical_range_km * 0.7)}\u2013${detail.typical_range_km} km`
if (score >= 33) return `${Math.round(detail.typical_range_km * 0.4)}\u2013${Math.round(detail.typical_range_km * 0.7)} km`
return `<${Math.round(detail.typical_range_km * 0.4)} km`
}
/**
* Flood-fill outward from a grid point, collecting all contiguous cells
* with score >= minScore. Returns array of {lat, lon} boundary points
* as a convex hull for drawing a polygon.
*/
function propagationReach(gridLookup, startLat, startLon, minScore) {
const step = 0.125
const maxKm = 300
const snap = (v) => (Math.round(v / step) * step).toFixed(3)
const startKey = `${snap(startLat)},${snap(startLon)}`
const startScore = gridLookup.get(startKey)
if (startScore == null || startScore < minScore) return []
const cosLat = Math.cos(startLat * Math.PI / 180)
const kmPerDegLat = 111.0
const kmPerDegLon = 111.0 * cosLat
const visited = new Set()
const reachable = []
const queue = [startKey]
visited.add(startKey)
// BFS flood fill through grid, capped at maxKm from origin
while (queue.length > 0) {
const key = queue.shift()
const [latStr, lonStr] = key.split(",")
const lat = parseFloat(latStr)
const lon = parseFloat(lonStr)
const dLat = (lat - startLat) * kmPerDegLat
const dLon = (lon - startLon) * kmPerDegLon
if (Math.sqrt(dLat * dLat + dLon * dLon) > maxKm) continue
reachable.push({ lat, lon })
// Check 4 neighbors
const neighbors = [
[lat + step, lon],
[lat - step, lon],
[lat, lon + step],
[lat, lon - step]
]
for (const [nlat, nlon] of neighbors) {
const nkey = `${nlat.toFixed(3)},${nlon.toFixed(3)}`
if (visited.has(nkey)) continue
visited.add(nkey)
const score = gridLookup.get(nkey)
if (score != null && score >= minScore) {
queue.push(nkey)
}
}
}
if (reachable.length < 3) return reachable
// Compute convex hull for polygon boundary
return convexHull(reachable)
}
function convexHull(points) {
// Graham scan
points.sort((a, b) => a.lon - b.lon || a.lat - b.lat)
const cross = (o, a, b) =>
(a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon)
const lower = []
for (const p of points) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
lower.pop()
lower.push(p)
}
const upper = []
for (let i = points.length - 1; i >= 0; i--) {
const p = points[i]
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
upper.pop()
upper.push(p)
}
upper.pop()
lower.pop()
return lower.concat(upper)
}
function isMobile() {
return window.innerWidth < 768
}
function mobileHandleHTML() {
if (!isMobile()) return ""
return `<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>`
}
function buildLoadingHTML(detail) {
const tier = scoreTier(detail.score)
return `<div style="position:relative;">
${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:baseline;gap:8px;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div>
<div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;">
<span class="loading loading-spinner loading-xs"></span> Loading&hellip;
</div>
</div>`
}
function buildForecastSvg(forecast) {
if (!forecast || forecast.length < 2) return ""
const marginL = 28, marginR = 6, marginT = 4, marginB = 16
const w = isMobile() ? Math.min(260, window.innerWidth - 48) : 260, h = 72
const plotW = w - marginL - marginR
const plotH = h - marginT - marginB
const n = forecast.length
const now = new Date()
const points = forecast.map((f, i) => {
const x = marginL + (i / (n - 1)) * plotW
const y = marginT + plotH * (1 - f.score / 100)
return { x, y, score: f.score, time: new Date(f.time) }
})
const polyline = points.map(p => `${p.x},${p.y}`).join(" ")
// "Now" dot
const nowIdx = points.reduce((best, p, i) =>
Math.abs(p.time - now) < Math.abs(points[best].time - now) ? i : best, 0)
const nowPt = points[nowIdx]
// Trend (from now onward)
const futurePoints = points.filter(p => p.time >= now)
const firstScore = futurePoints.length > 0 ? futurePoints[0].score : points[0].score
const lastScore = points[points.length - 1].score
const diff = lastScore - firstScore
let trend = ""
if (diff > 5) trend = `<span style="color:#00ffa3;">&#9650; Improving</span>`
else if (diff < -5) trend = `<span style="color:#ff4f4f;">&#9660; Declining</span>`
else trend = `<span style="color:#ffe566;">&#8594; Steady</span>`
// Best time (only future points)
const bestPool = futurePoints.length > 0 ? futurePoints : points
const bestPt = bestPool.reduce((best, p) => p.score > best.score ? p : best, bestPool[0])
const bestUtc = `${bestPt.time.getUTCHours().toString().padStart(2, "0")}:00 UTC`
const bestTier = scoreTier(bestPt.score)
// Y-axis: score labels
const yLabels = [0, 50, 100].map(s => {
const y = marginT + plotH * (1 - s / 100)
return `<text x="${marginL - 4}" y="${y + 3}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="end">${s}</text>
<line x1="${marginL}" y1="${y}" x2="${marginL + plotW}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`
}).join("")
// X-axis: time labels (first, middle, last)
const timeIdxs = [0, Math.floor(n / 2), n - 1]
const xLabels = timeIdxs.map(i => {
const p = points[i]
const label = `${p.time.getUTCHours().toString().padStart(2, "0")}:00`
return `<text x="${p.x}" y="${h - 2}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="middle">${label}</text>`
}).join("")
return `
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Forecast (${n}h)</span>
<span style="font-size:11px;">${trend}</span>
</div>
<svg width="${w}" height="${h}" style="display:block;">
${yLabels}
${xLabels}
<polyline points="${polyline}" fill="none" stroke="#00ffa3" stroke-width="2" stroke-linejoin="round"/>
<circle cx="${nowPt.x}" cy="${nowPt.y}" r="3" fill="#fff" stroke="#00ffa3" stroke-width="1.5"/>
<circle cx="${bestPt.x}" cy="${bestPt.y}" r="3" fill="${bestTier.color}" stroke="#fff" stroke-width="1"/>
<text x="${bestPt.x}" y="${bestPt.y - 6}" font-size="8" fill="${bestTier.color}" text-anchor="middle" font-weight="700">${bestPt.score}</text>
</svg>
<div style="font-size:11px;margin-top:3px;color:${bestTier.color};">Best at ${bestUtc} &mdash; score ${bestPt.score} (${bestTier.label})</div>
</div>`
}
function buildPopupHTML(detail, viewshedLoading) {
const tier = scoreTier(detail.score)
const range = rangeEstimate(detail.score, detail)
const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
let rows = ""
let explanations = ""
for (const key of FACTOR_ORDER) {
const meta = FACTOR_META[key]
const value = detail.factors[key]
if (value === undefined) continue
const pct = Math.round(meta.weight * 100)
rows += `<tr>
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
<td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
<td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
</tr>`
const expl = factorExplanation(key, value, detail)
const eTier = scoreTier(value)
explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
<span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
<span><strong>${meta.label}:</strong> ${expl}</span>
</div>`
}
const viewshedStatus = viewshedLoading
? `<div style="padding:4px 10px 6px;font-size:11px;opacity:0.6;border-top:1px solid rgba(255,255,255,0.15);display:flex;align-items:center;gap:6px;"><span class="loading loading-spinner loading-xs"></span> Computing terrain coverage&hellip;</div>`
: ""
const mobile = isMobile()
const minW = mobile ? "auto" : "260px"
return `<div style="min-width:${minW};position:relative;">
${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:baseline;gap:8px;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div>
<div style="padding:6px 10px;">
<div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
${detail.band_label} &mdash; Est. range: ${range} (CW)<br>
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div>
<table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">
${rows}
</table>
</div>
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
${explanations}
</div>
${detail.factors.duct_info ? buildDuctInfoHTML(detail.factors.duct_info) : ""}
${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
${viewshedStatus}
</div>`
}
function buildDuctInfoHTML(info) {
const layers = info.ducts || []
let layerRows = ""
if (layers.length > 0) {
layerRows = layers.map((d, i) => {
const baseFt = Math.round(d.base_m * 3.281)
const topFt = Math.round(d.top_m * 3.281)
const freq = d.min_freq_ghz != null ? `\u2265${d.min_freq_ghz.toFixed(1)} GHz` : ""
return `<div style="font-size:11px;padding:1px 0;">
Layer ${i + 1}: <strong>${baseFt}\u2013${topFt} ft</strong> (${d.thickness_m} m) ${freq}
</div>`
}).join("")
}
return `
<div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Ducting &mdash; ${info.duct_count} layer${info.duct_count !== 1 ? "s" : ""}</div>
${layerRows}
</div>`
}
// --- Hook ---
export const PropagationMap = {
mounted() {
const centerLat = parseFloat(this.el.dataset.centerLat)
const centerLon = parseFloat(this.el.dataset.centerLon)
const zoom = parseInt(this.el.dataset.zoom, 10)
const center =
Number.isFinite(centerLat) && Number.isFinite(centerLon)
? [centerLat, centerLon]
: [32.897, -97.038]
this.map = L.map(this.el, {
center,
zoom: Number.isFinite(zoom) ? 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.scoreOverlay = null
this.scores = []
this.colorScale = [
{ min: 80, r: 0, g: 255, b: 163 },
{ min: 65, r: 125, g: 255, b: 212 },
{ min: 50, r: 255, g: 229, b: 102 },
{ min: 33, r: 255, g: 144, b: 68 },
{ min: 0, r: 255, g: 79, b: 79 }
]
// Render pre-loaded scores immediately (no round-trip needed)
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
if (initialScores.length > 0) {
this.renderScores(initialScores)
}
this.handleEvent("update_scores", ({ scores }) => {
topbar.hide()
this.renderScores(scores)
// If a point was selected, refresh its detail with the new data
if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] })
}
})
this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}")
this.rangeCircles = L.layerGroup().addTo(this.map)
this.handleEvent("update_band_info", ({ band_info }) => {
this.bandInfo = band_info
})
// Prevent control panel / sidebar from eating map clicks/scrolls
const controls = document.getElementById("map-controls")
if (controls) {
L.DomEvent.disableClickPropagation(controls)
L.DomEvent.disableScrollPropagation(controls)
}
const sidebar = document.getElementById("sidebar")
if (sidebar) {
L.DomEvent.disableClickPropagation(sidebar)
L.DomEvent.disableScrollPropagation(sidebar)
}
// Invalidate map size when sidebar is toggled
window.addEventListener("sidebar-toggle", () => {
setTimeout(() => this.map.invalidateSize(), 50)
})
// Grid overlay layer
this.gridLayer = L.layerGroup()
this.gridVisible = false
// Listen for grid toggle from LiveView
this.handleEvent("toggle_grid", ({ visible }) => {
this.gridVisible = visible
if (visible) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer)
} else {
this.gridLayer.remove()
}
})
// Click on map to request viewshed + factor detail from server
this.map.on("click", (e) => {
this.rangeCircles.clearLayers()
this.viewshedLoading = true
this.lastDetail = null
// Remember click location for the center marker
this.clickedLatLng = [e.latlng.lat, e.latlng.lng]
// Show a temporary marker while computing
L.circleMarker(this.clickedLatLng, {
radius: 6,
color: "#fff",
weight: 2,
fillColor: "#888",
fillOpacity: 0.8,
interactive: false
}).addTo(this.rangeCircles)
// Always request terrain viewshed
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })
// Show panel immediately with whatever we have client-side
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
if (basic && this.detailPanel) {
const merged = { ...basic, ...this.bandInfo, factors: null }
this.detailPanel.innerHTML = buildLoadingHTML(merged)
this.showDetailPanel()
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
}
})
// Detail panel — floating overlay on the map
this.detailPanel = document.getElementById("detail-panel")
this.viewshedLoading = false
if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel)
// Close button event delegation
this.detailPanel.addEventListener("click", (e) => {
if (e.target.closest(".detail-close-btn")) {
this.hideDetailPanel()
}
})
}
this.lastDetail = null
this.handleEvent("point_detail", (detail) => {
if (detail && this.detailPanel) {
const merged = { ...detail, ...this.bandInfo }
this.lastDetail = merged
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
this.showDetailPanel()
// Draw propagation reach polygon based on contiguous good cells
if (this.gridLookup && this.clickedLatLng) {
// Use MARGINAL threshold (50) as minimum for propagation reach
const minScore = 50
const hull = propagationReach(
this.gridLookup, this.clickedLatLng[0], this.clickedLatLng[1], minScore
)
if (hull.length >= 3) {
// Remove any previous reach polygon
if (this.reachPolygon) {
this.rangeCircles.removeLayer(this.reachPolygon)
}
const tier = scoreTier(detail.score)
this.reachPolygon = L.polygon(
hull.map(p => [p.lat, p.lon]),
{
color: tier.color,
weight: 2,
opacity: 0.6,
fillColor: tier.color,
fillOpacity: 0.08,
interactive: false,
smoothFactor: 1.5,
dashArray: "6 4"
}
).addTo(this.rangeCircles)
}
}
}
})
this.handleEvent("viewshed_result", ({ origin, points }) => {
this.rangeCircles.clearLayers()
this.viewshedLoading = false
// Re-render panel without loading indicator
if (this.lastDetail && this.detailPanel) {
this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false)
}
if (points && points.length > 0) {
const latlngs = points.map(p => [p.lat, p.lon])
L.polygon(latlngs, {
color: "#222",
weight: 2,
opacity: 0.8,
fillColor: "#000",
fillOpacity: 0.12,
interactive: false,
smoothFactor: 1
}).addTo(this.rangeCircles)
}
// Always redraw center marker at clicked location
const loc = this.clickedLatLng || [origin.lat, origin.lon]
const basic = this.lookupPoint(loc[0], loc[1])
const markerColor = basic ? scoreTier(basic.score).color : "#888"
L.circleMarker(loc, {
radius: 6,
color: "#fff",
weight: 2,
fillColor: markerColor,
fillOpacity: 1,
interactive: false
}).addTo(this.rangeCircles)
})
// --- Forecast timeline ---
this.timelineEl = document.getElementById("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 }) => {
this.timelineData = times
this.selectedTime = selected
this.renderTimeline()
})
// Prevent timeline from eating map clicks
if (this.timelineEl) {
L.DomEvent.disableClickPropagation(this.timelineEl)
L.DomEvent.disableScrollPropagation(this.timelineEl)
}
// Escape key closes detail panel and range circles
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.hideDetailPanel()
}
})
this.el.addEventListener("show-loading", () => topbar.show(300))
this.sendBounds()
this.map.on("moveend", () => {
this.sendBounds()
if (this.gridVisible) {
updateGridOverlay(this.map, this.gridLayer)
}
})
// Legend — compact on mobile, moved to top-right to avoid timeline overlap
const legend = L.control({ position: isMobile() ? "topright" : "bottomright" })
legend.onAdd = () => {
const div = L.DomUtil.create("div")
const mobile = isMobile()
const rows = [
["#00ffa3", "Excellent", "80+"],
["#7dffd4", "Good", "65"],
["#ffe566", "Marginal", "50"],
["#ff9044", "Poor", "33"],
["#ff4f4f", "Negligible", "0"]
]
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 = rows.map(([c, , score]) =>
`<span style="background:${c};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>${score}`
).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;'>Propagation</strong><br>" +
rows.map(([c, label, score]) =>
`<span style="background:${c};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;">${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})</span>`
).join("<br>")
}
return div
}
legend.addTo(this.map)
},
showDetailPanel() {
if (!this.detailPanel) return
this.detailPanel.style.display = "block"
},
hideDetailPanel() {
if (this.detailPanel) {
this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
}
this.rangeCircles.clearLayers()
this.clickedLatLng = null
this.lastDetail = null
},
renderScores(scores) {
this.scores = scores
if (this.scoreOverlay) {
this.map.removeLayer(this.scoreOverlay)
}
if (scores.length === 0) return
this.gridLookup = new Map()
this.gridData = new Map()
scores.forEach((s) => {
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}`
this.gridLookup.set(key, s.score)
this.gridData.set(key, s)
})
// Pre-calculate RGBA strings for scores 0-100
const colorCache = new Array(101)
for (let i = 0; i <= 100; i++) {
const c = this.scoreColorRGB(i)
colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)`
}
const self = this
this.scoreOverlay = 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)
let lastColor = null
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 score = self.interpolateScore(lat, lon, step)
if (score !== null) {
const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))]
if (color !== lastColor) {
ctx.fillStyle = color
lastColor = color
}
ctx.fillRect(px, py, cellPxX, cellPxY)
}
}
}
return tile
}
})
this.scoreOverlay = new (this.scoreOverlay)({ opacity: 1.0 })
this.scoreOverlay.addTo(this.map)
},
interpolateScore(lat, lon, step) {
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)
const lat0 = Math.floor(lat / step) * step
const lat1 = lat0 + step
const lon0 = Math.floor(lon / step) * step
const lon1 = lon0 + step
const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`)
const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`)
const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`)
const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`)
const corners = [s00, s10, s01, s11].filter(s => s !== undefined)
if (corners.length < 2) return null
if (corners.length === 4) {
const tLat = (lat - lat0) / step
const tLon = (lon - lon0) / step
return Math.round(
s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) +
s01 * (1 - tLat) * tLon + s11 * tLat * tLon
)
}
return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length)
},
scoreColorRGB(score) {
for (let i = 0; i < this.colorScale.length - 1; i++) {
const upper = this.colorScale[i]
const lower = this.colorScale[i + 1]
if (score >= lower.min) {
const range = upper.min - lower.min
const t = Math.min(1, (score - lower.min) / range)
return {
r: Math.round(lower.r + (upper.r - lower.r) * t),
g: Math.round(lower.g + (upper.g - lower.g) * t),
b: Math.round(lower.b + (upper.b - lower.b) * t)
}
}
}
const last = this.colorScale[this.colorScale.length - 1]
return { r: last.r, g: last.g, b: last.b }
},
lookupPoint(lat, lon) {
if (!this.gridData) return null
const step = 0.125
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
const data = this.gridData.get(`${rlat},${rlon}`)
if (!data) return null
return { ...data, ...this.bandInfo }
},
renderTimeline() {
if (!this.timelineEl || this.timelineData.length === 0) {
if (this.timelineEl) this.timelineEl.style.display = "none"
return
}
// Single time: show as a simple data timestamp, no buttons
if (this.timelineData.length === 1) {
const dt = new Date(this.timelineData[0].time)
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
}
// Hide timeline if all times are within 1 hour (no real forecast spread)
const firstDt = new Date(this.timelineData[0].time)
const lastDt = new Date(this.timelineData[this.timelineData.length - 1].time)
if (lastDt - firstDt < 3600000) {
const dt = firstDt
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()
const items = this.timelineData.map((t, idx) => {
const dt = new Date(t.time)
const isSelected = t.time === this.selectedTime
const isPast = dt < new Date(now - 1800000) // 30min grace
const offsetH = Math.round((dt - now) / 3600000)
return { ...t, dt, isSelected, isPast, offsetH, idx }
})
// "Now" = the item closest to current time
const closestIdx = items.reduce((best, item, i) =>
Math.abs(item.dt - now) < Math.abs(items[best].dt - now) ? 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 ? "#00ffa3" : (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 #00ffa3" : "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" : "Propagation 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);margin-right:4px;white-space:nowrap;">${labelText}</span>
${buttons}
</div>`
// Attach click handlers
this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => {
btn.addEventListener("click", () => {
this.selectedTime = btn.dataset.time
this.renderTimeline()
topbar.show()
this.pushEvent("select_time", { time: btn.dataset.time })
})
})
},
sendBounds() {
topbar.show()
const b = this.map.getBounds()
this.pushEvent("map_bounds", {
south: b.getSouth(),
north: b.getNorth(),
west: b.getWest(),
east: b.getEast()
})
},
destroyed() {
if (this.map) this.map.remove()
}
}