prop/assets/js/propagation_map_hook.js
Graham McIntire 66639e3717
Increase grid to 0.125 degrees with smooth canvas overlay
Replace circle markers with a canvas tile layer that renders smooth,
flowing colored regions using bilinear interpolation between grid
points. Colors interpolate between tiers for gradients. ~95k grid
points at 0.125 degree resolution with wgrib2 extraction.
2026-03-31 08:52:07 -05:00

172 lines
5.8 KiB
JavaScript

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 }, // EXCELLENT - green
{ min: 65, r: 125, g: 255, b: 212 }, // GOOD - light green
{ min: 50, r: 255, g: 229, b: 102 }, // MARGINAL - yellow
{ min: 33, r: 255, g: 144, b: 68 }, // POOR - orange
{ min: 0, r: 255, g: 79, b: 79 } // NEGLIGIBLE - red
]
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
this.renderScores(initialScores)
this.handleEvent("update_scores", ({ scores }) => {
this.renderScores(scores)
})
// 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
// Build a lookup grid for interpolation
this.gridLookup = new Map()
scores.forEach(({ lat, lon, score }) => {
this.gridLookup.set(`${lat.toFixed(3)},${lon.toFixed(3)}`, score)
})
// Create a custom canvas overlay
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 sePoint = nwPoint.add(size)
const nw = this._map.unproject(nwPoint, coords.z)
const se = this._map.unproject(sePoint, coords.z)
// Pixel size in degrees
const latPerPx = (nw.lat - se.lat) / size.y
const lonPerPx = (se.lng - nw.lng) / size.x
// Draw cells - each pixel block maps to a grid point
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) {
// Snap to nearest grid point
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
const key = `${rlat},${rlon}`
if (this.gridLookup.has(key)) return this.gridLookup.get(key)
// Try bilinear interpolation from 4 surrounding points
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)}`)
// Need at least 2 corners to interpolate
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
)
}
// Fallback: average available corners
return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length)
},
scoreColorRGB(score) {
// Interpolate between tier colors for smooth gradients
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 }
},
destroyed() {
if (this.map) this.map.remove()
}
}