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 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 "pressure": if (score >= 70) return "Rising pressure — increasing stability favors inversions" if (score >= 55) return "Steady pressure — stable conditions" return "Falling pressure — instability weakens inversions" 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 closeButtonHTML() { if (!isMobile()) return "" return `` } function mobileHandleHTML() { if (!isMobile()) return "" return `
` } function buildLoadingHTML(detail) { const tier = scoreTier(detail.score) return `
${mobileHandleHTML()}${closeButtonHTML()}
${detail.score}/100 ${tier.label}
Loading…
` } 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 = `▲ Improving` else if (diff < -5) trend = `▼ Declining` else trend = `→ Steady` // 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 `${s} ` }).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 `${label}` }).join("") return `
Forecast (${n}h) ${trend}
${yLabels} ${xLabels} ${bestPt.score}
Best at ${bestUtc} — score ${bestPt.score} (${bestTier.label})
` } 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 += ` ${meta.label} ${factorBar(value)} ${value} (${pct}%) ` const expl = factorExplanation(key, value, detail) const eTier = scoreTier(value) explanations += `
\u25CF ${meta.label}: ${expl}
` } const viewshedStatus = viewshedLoading ? `
Computing terrain coverage…
` : "" const mobile = isMobile() const minW = mobile ? "auto" : "260px" return `
${mobileHandleHTML()}${closeButtonHTML()}
${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}
Analysis
${explanations}
${detail.forecast ? buildForecastSvg(detail.forecast) : ""} ${viewshedStatus}
` } // --- Maidenhead Grid Square --- class GridSquare { constructor(lat, lon) { this.lat = lat this.lon = ((lon % 360) + 540) % 360 - 180 } encode(precision = 4) { const adjLon = this.lon + 180 const adjLat = this.lat + 90 let g = "" g += String.fromCharCode(65 + Math.floor(adjLon / 20)) g += String.fromCharCode(65 + Math.floor(adjLat / 10)) g += Math.floor((adjLon % 20) / 2) g += Math.floor(adjLat % 10) if (precision > 4) { g += String.fromCharCode(97 + Math.floor(((adjLon % 2) * 60) / 5)) g += String.fromCharCode(97 + Math.floor(((adjLat % 1) * 60) / 2.5)) } return g } static decode(grid) { const f1 = grid.charCodeAt(0) - 65 const f2 = grid.charCodeAt(1) - 65 let west = f1 * 20 - 180 let south = f2 * 10 - 90 let w = 20, h = 10 if (grid.length >= 4) { west += parseInt(grid[2]) * 2 south += parseInt(grid[3]) w = 2; h = 1 } if (grid.length >= 6) { west += (grid.charCodeAt(4) - 97) * 5 / 60 south += (grid.charCodeAt(5) - 97) * 2.5 / 60 w = 5 / 60; h = 2.5 / 60 } return { west, east: west + w, south, north: south + h, center: { lat: south + h / 2, lon: west + w / 2 } } } } function drawGridSquare(grid, precision, bounds, zoom, layerGroup, drawnSet, map) { if (grid.length < 2 || drawnSet.has(grid)) return drawnSet.add(grid) const c = GridSquare.decode(grid) if (c.west > bounds.getEast() || c.east < bounds.getWest() || c.south > bounds.getNorth() || c.north < bounds.getSouth()) return const field = precision <= 2 layerGroup.addLayer(L.rectangle( [[c.south, c.west], [c.north, c.east]], { color: "#E63946", weight: field ? 2.5 : 2, opacity: 0.8, fillOpacity: 0, interactive: false } )) const sw = map.latLngToContainerPoint([c.south, c.west]) const ne = map.latLngToContainerPoint([c.north, c.east]) const px = Math.abs(ne.x - sw.x) const minW = field ? 30 : 40 if (px > minW) { const len = field ? 2 : 4 const text = grid.substring(0, len) const fs = Math.max(10, Math.min(14, Math.round(px / (len * 1.2)))) layerGroup.addLayer(L.marker([c.center.lat, c.center.lon], { interactive: false, icon: L.divIcon({ className: "grid-label", html: `
${text}
`, iconSize: [Math.round(len * fs * 0.8), Math.round(fs * 1.5)], iconAnchor: [Math.round(len * fs * 0.4), Math.round(fs * 0.75)] }) })) } } function updateGridOverlay(map, gridLayer) { gridLayer.clearLayers() const drawn = new Set() const bounds = map.getBounds() const zoom = map.getZoom() const showSquares = zoom >= 7 const south = Math.max(bounds.getSouth(), -90) const north = Math.min(bounds.getNorth(), 90) if (!showSquares) { const fW = Math.floor((bounds.getWest() + 180) / 20) * 20 - 180 const fE = Math.ceil((bounds.getEast() + 180) / 20) * 20 - 180 const fS = Math.floor((south + 90) / 10) * 10 - 90 const fN = Math.ceil((north + 90) / 10) * 10 - 90 for (let lon = fW; lon < fE; lon += 20) { for (let lat = fS; lat < fN; lat += 10) { const nLon = ((lon % 360) + 540) % 360 - 180 const g = new GridSquare(lat + 5, nLon + 10).encode(4).substring(0, 2) drawGridSquare(g, 2, bounds, zoom, gridLayer, drawn, map) } } return } const wE = Math.floor((bounds.getWest() + 180) / 2) * 2 - 180 const eE = Math.ceil((bounds.getEast() + 180) / 2) * 2 - 180 const sE = Math.floor(south + 90) - 90 const nE = Math.ceil(north + 90) - 90 if (Math.ceil((eE - wE) / 2) * Math.ceil(nE - sE) > 500) return for (let lon = wE; lon < eE; lon += 2) { for (let lat = sE; lat < nE; lat += 1) { const nLon = ((lon % 360) + 540) % 360 - 180 const g = new GridSquare(lat + 0.5, nLon + 1).encode(4) drawGridSquare(g.substring(0, 4), 4, bounds, zoom, gridLayer, drawn, map) } } } // --- Hook --- 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.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 from eating map clicks/scrolls const controls = document.getElementById("map-controls") if (controls) { L.DomEvent.disableClickPropagation(controls) L.DomEvent.disableScrollPropagation(controls) } // 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.detailPanel.style.display = "block" this.positionDetailPanel() this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) } }) // Detail panel — bottom sheet on mobile, positioned next to sidebar on desktop this.detailPanel = document.getElementById("detail-panel") this.viewshedLoading = false if (this.detailPanel) { L.DomEvent.disableClickPropagation(this.detailPanel) L.DomEvent.disableScrollPropagation(this.detailPanel) } 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.detailPanel.style.display = "block" this.positionDetailPanel() // 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") { if (this.detailPanel) { this.detailPanel.style.display = "none" this.detailPanel.innerHTML = "" } this.rangeCircles.clearLayers() this.clickedLatLng = null this.lastDetail = null } }) 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]) => `${score}` ).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 = "Propagation
" + rows.map(([c, label, score]) => `${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})` ).join("
") } return div } legend.addTo(this.map) }, positionDetailPanel() { if (!this.detailPanel || isMobile()) return // On desktop, position the panel below the control panel const controls = document.getElementById("map-controls") if (controls) { const rect = controls.getBoundingClientRect() this.detailPanel.style.left = rect.left + "px" this.detailPanel.style.top = (rect.bottom + 8) + "px" } }, 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 = `
Data: ${dateLabel} ${utcLabel}
` 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 = `
Data: ${dateLabel} ${utcLabel}
` 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 `` }).join("") const labelText = mobile ? "Forecast" : "Propagation Forecast" this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
${labelText} ${buttons}
` // 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() } }