Click anywhere on the propagation map to see a detailed popup with: - Overall score and tier label with color - Estimated range for the selected band (CW mode) - All 9 scoring factors with visual bar charts, individual scores, and weight percentages - Grid point coordinates and data timestamp Factors are displayed in weight order so users can immediately see which atmospheric conditions are driving the prediction.
260 lines
9.5 KiB
JavaScript
260 lines
9.5 KiB
JavaScript
import topbar from "../vendor/topbar"
|
|
|
|
const FACTOR_META = {
|
|
humidity: { label: "Humidity", weight: 0.20, unit: "g/m\u00b3" },
|
|
time_of_day: { label: "Time of Day", weight: 0.20, unit: "" },
|
|
td_depression: { label: "Td Depression", weight: 0.12, unit: "\u00b0F" },
|
|
refractivity: { label: "Refractivity", weight: 0.10, unit: "N/km" },
|
|
sky: { label: "Sky Cover", weight: 0.10, unit: "%" },
|
|
season: { label: "Season", weight: 0.10, unit: "" },
|
|
rain: { label: "Rain", weight: 0.08, unit: "" },
|
|
wind: { label: "Wind", weight: 0.06, unit: "kts" },
|
|
pressure: { label: "Pressure", weight: 0.04, unit: "mb" }
|
|
}
|
|
|
|
const FACTOR_ORDER = [
|
|
"humidity", "time_of_day", "td_depression", "refractivity",
|
|
"sky", "season", "rain", "wind", "pressure"
|
|
]
|
|
|
|
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:#555">${"\u2591".repeat(empty)}</span>`
|
|
}
|
|
|
|
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`
|
|
}
|
|
|
|
function buildPopupHTML(detail) {
|
|
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 = ""
|
|
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;color:#888;">(${pct}%)</td>
|
|
</tr>`
|
|
}
|
|
|
|
return `<div style="color:#333;min-width:280px;">
|
|
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;">
|
|
<strong style="font-size:16px;">${detail.score}/100</strong>
|
|
<span style="background:${tier.color};color:#000;padding:1px 8px;border-radius:4px;font-size:11px;font-weight:700;">${tier.label}</span>
|
|
</div>
|
|
<div style="font-size:12px;color:#666;margin-bottom:6px;">
|
|
${detail.band_label} — Est. range: ${range} (CW)<br>
|
|
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
|
|
</div>
|
|
<table style="width:100%;border-collapse:collapse;border-top:1px solid #ddd;padding-top:4px;">
|
|
${rows}
|
|
</table>
|
|
</div>`
|
|
}
|
|
|
|
export const PropagationMap = {
|
|
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.scoreOverlay = null
|
|
this.scores = []
|
|
this.detailPopup = L.popup({ maxWidth: 340 })
|
|
|
|
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 }
|
|
]
|
|
|
|
this.handleEvent("update_scores", ({ scores }) => {
|
|
topbar.hide()
|
|
this.renderScores(scores)
|
|
})
|
|
|
|
this.handleEvent("point_detail", ({ detail }) => {
|
|
if (detail) {
|
|
this.detailPopup
|
|
.setLatLng([detail.lat, detail.lon])
|
|
.setContent(buildPopupHTML(detail))
|
|
.openOn(this.map)
|
|
}
|
|
})
|
|
|
|
// Click on map to get point detail
|
|
this.map.on("click", (e) => {
|
|
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
})
|
|
|
|
this.el.addEventListener("show-loading", () => topbar.show(300))
|
|
|
|
this.sendBounds()
|
|
this.map.on("moveend", () => this.sendBounds())
|
|
|
|
// Legend
|
|
const legend = L.control({ position: "bottomright" })
|
|
legend.onAdd = () => {
|
|
const div = L.DomUtil.create("div")
|
|
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;"
|
|
const rows = [
|
|
["#00ffa3", "Excellent (80-100)"],
|
|
["#7dffd4", "Good (65-79)"],
|
|
["#ffe566", "Marginal (50-64)"],
|
|
["#ff9044", "Poor (33-49)"],
|
|
["#ff4f4f", "Negligible (0-32)"]
|
|
]
|
|
div.innerHTML = "<strong style='color:#333;'>Propagation</strong><br>" +
|
|
rows.map(([c, label]) =>
|
|
`<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}</span>`
|
|
).join("<br>")
|
|
return div
|
|
}
|
|
legend.addTo(this.map)
|
|
},
|
|
|
|
renderScores(scores) {
|
|
this.scores = scores
|
|
if (this.scoreOverlay) {
|
|
this.map.removeLayer(this.scoreOverlay)
|
|
}
|
|
if (scores.length === 0) return
|
|
|
|
this.gridLookup = new Map()
|
|
scores.forEach(({ lat, lon, score }) => {
|
|
this.gridLookup.set(`${lat.toFixed(3)},${lon.toFixed(3)}`, score)
|
|
})
|
|
|
|
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)))
|
|
|
|
for (let py = 0; py < size.y; py += cellPxY) {
|
|
for (let px = 0; px < size.x; px += cellPxX) {
|
|
const lat = nw.lat - py * Math.abs(latPerPx)
|
|
const lon = nw.lng + px * lonPerPx
|
|
const score = self.interpolateScore(lat, lon, step)
|
|
if (score !== null) {
|
|
const color = self.scoreColorRGB(score)
|
|
ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},0.55)`
|
|
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 }
|
|
},
|
|
|
|
sendBounds() {
|
|
topbar.show(300)
|
|
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()
|
|
}
|
|
}
|