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 `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` } 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 += ` ${meta.label} ${factorBar(value)} ${value} (${pct}%) ` } return `
${detail.score}/100 ${tier.label}
${detail.band_label} — Est. range: ${range} (CW)
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
${rows}
` } 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.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 = "Propagation
" + rows.map(([c, label]) => `${label}` ).join("
") 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) const typicalKm = detail.typical_range_km // Outer circle — max estimated range L.circle(center, { radius: rangeKm * 1000, color: tier.color, weight: 2, opacity: 0.8, fillColor: tier.color, fillOpacity: 0.08, dashArray: "8,6" }).bindTooltip(`${rangeKm} km (max)`, { permanent: false, direction: "top" }) .addTo(this.rangeCircles) // Inner circle — typical range if (typicalKm < rangeKm) { L.circle(center, { radius: typicalKm * 1000, color: tier.color, weight: 2, opacity: 0.9, fillColor: tier.color, fillOpacity: 0.12 }).bindTooltip(`${typicalKm} km (typical)`, { permanent: false, direction: "top" }) .addTo(this.rangeCircles) } // Center marker L.circleMarker(center, { radius: 6, 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() } }