prop/assets/js/propagation_map_hook.js
Graham McIntire 2e216d4244
Make point detail popup instant with client-side lookup and range circles
- Scores now include factors and valid_time in the viewport query,
  eliminating the server round-trip for popups
- Click shows two range circles: solid inner (typical range) and
  dashed outer (max estimated range), colored by score tier
- Circles disappear when popup closes
- Band info pushed to client on band switch for accurate range estimates
2026-03-31 09:50:48 -05:00

327 lines
12 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} &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 #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: "&copy; 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.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
})
// Click on map to show factor breakdown + range circle — fully client-side
this.map.on("click", (e) => {
const detail = this.lookupPoint(e.latlng.lat, e.latlng.lng)
if (detail) {
this.showRangeCircles(detail)
this.detailPopup
.setLatLng([detail.lat, detail.lon])
.setContent(buildPopupHTML(detail))
.openOn(this.map)
}
})
// Clear range circles when popup closes
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
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()
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)
})
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 }
},
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 }
},
showRangeCircles(detail) {
this.rangeCircles.clearLayers()
const center = [detail.lat, detail.lon]
const score = detail.score
const tier = scoreTier(score)
// Compute estimated range in km based on score tier
let rangeKm
if (score >= 80) rangeKm = detail.exceptional_range_km
else if (score >= 65) rangeKm = detail.extended_range_km
else if (score >= 50) rangeKm = detail.typical_range_km
else if (score >= 33) rangeKm = Math.round(detail.typical_range_km * 0.6)
else rangeKm = Math.round(detail.typical_range_km * 0.3)
// Outer circle — max estimated range (faded)
L.circle(center, {
radius: rangeKm * 1000,
color: tier.color,
weight: 2,
opacity: 0.6,
fillColor: tier.color,
fillOpacity: 0.06,
dashArray: "6,4"
}).addTo(this.rangeCircles)
// Inner circle — typical range (more visible)
const innerKm = Math.round(rangeKm * 0.6)
L.circle(center, {
radius: innerKm * 1000,
color: tier.color,
weight: 2,
opacity: 0.8,
fillColor: tier.color,
fillOpacity: 0.1
}).addTo(this.rangeCircles)
// Center marker
L.circleMarker(center, {
radius: 5,
color: "#fff",
weight: 2,
fillColor: tier.color,
fillOpacity: 1
}).addTo(this.rangeCircles)
},
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()
}
}