- HrrrClient.hrrr_url accepts forecast_hour param (wrfsfcfHH.grib2) - PropagationGridWorker fetches all 19 forecast hours per run - Propagation.scores_at/3 queries scores at specific valid_time - Propagation.available_valid_times/1 returns all forecast times for timeline - Pruning keeps scores with valid_time >= now - 2h (forecast-aware) - MapLive: select_time event, timeline data pushed to JS - JS: forecast timeline bar at bottom of map with clickable hour buttons - PubSub broadcast sends list of valid_times instead of single time
702 lines
26 KiB
JavaScript
702 lines
26 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: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 "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`
|
|
}
|
|
|
|
function buildLoadingHTML(detail) {
|
|
const tier = scoreTier(detail.score)
|
|
return `<div style="min-width:260px;">
|
|
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
|
|
<strong style="font-size:16px;">${detail.score}/100</strong>
|
|
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
|
|
</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…
|
|
</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…</div>`
|
|
: ""
|
|
|
|
return `<div style="min-width:260px;">
|
|
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
|
|
<strong style="font-size:16px;">${detail.score}/100</strong>
|
|
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
|
|
</div>
|
|
<div style="padding:6px 10px;">
|
|
<div style="font-size:12px;opacity:0.7;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 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>
|
|
${viewshedStatus}
|
|
</div>`
|
|
}
|
|
|
|
// --- 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: `<div style="font-size:${fs}px;font-weight:bold;color:#fff;background:rgba(0,0,0,0.5);padding:1px 4px;border-radius:3px;white-space:nowrap;width:fit-content;">${text}</div>`,
|
|
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.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
|
}
|
|
})
|
|
|
|
// Detail panel below sidebar instead of map popup
|
|
this.detailPanel = document.getElementById("detail-panel")
|
|
this.viewshedLoading = false
|
|
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.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)
|
|
}
|
|
|
|
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
|
|
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)
|
|
})
|
|
|
|
// 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 <= 1) {
|
|
if (this.timelineEl) this.timelineEl.style.display = "none"
|
|
return
|
|
}
|
|
|
|
const now = new Date()
|
|
const items = this.timelineData.map(t => {
|
|
const dt = new Date(t.time)
|
|
const isSelected = t.time === this.selectedTime
|
|
const isPast = dt < now
|
|
const isNow = Math.abs(dt - now) < 3600000 // within 1 hour
|
|
const offsetH = Math.round((dt - now) / 3600000)
|
|
const label = isNow ? "Now" : (offsetH > 0 ? `+${offsetH}h` : `${offsetH}h`)
|
|
return { ...t, dt, isSelected, isPast, isNow, label, offsetH }
|
|
})
|
|
|
|
// Find trend: compare first (now) vs a few hours ahead
|
|
const nowItem = items.find(i => i.isNow) || items[0]
|
|
const futureItem = items.find(i => i.offsetH >= 3) || items[items.length - 1]
|
|
|
|
let trendHint = ""
|
|
if (nowItem && futureItem && this.gridLookup) {
|
|
// Sample center of viewport
|
|
const center = this.map.getCenter()
|
|
const nowScore = this.lookupPoint(center.lat, center.lng)
|
|
// Can't look up future scores client-side, so just show the time range
|
|
trendHint = ""
|
|
}
|
|
|
|
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 || t.isNow ? "700" : "400"
|
|
return `<button data-time="${t.time}" style="
|
|
padding:4px 8px;font-size:11px;font-weight:${weight};
|
|
background:${bg};color:${fg};border:${border};border-radius:6px;
|
|
cursor:pointer;white-space:nowrap;min-width:38px;
|
|
transition:background 0.15s;
|
|
">${t.label}<br><span style="font-size:9px;opacity:0.7;">${t.dt.getUTCHours().toString().padStart(2, "0")}:00</span></button>`
|
|
}).join("")
|
|
|
|
this.timelineEl.style.display = "block"
|
|
this.timelineEl.innerHTML = `
|
|
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 10px;display:flex;gap:3px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:90vw;">
|
|
<span style="font-size:10px;color:rgba(255,255,255,0.5);margin-right:4px;white-space:nowrap;">Forecast</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()
|
|
}
|
|
}
|