diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js
index de01e211..c6ba812f 100644
--- a/assets/js/propagation_map_hook.js
+++ b/assets/js/propagation_map_hook.js
@@ -1,6 +1,5 @@
export const PropagationMap = {
mounted() {
- // Center on DFW airport, ~500 mile radius
this.map = L.map(this.el, {
center: [32.897, -97.038],
zoom: 7,
@@ -13,14 +12,15 @@ export const PropagationMap = {
maxZoom: 19
}).addTo(this.map)
- this.scoreLayer = L.layerGroup().addTo(this.map)
+ this.scoreOverlay = null
+ this.scores = []
this.colorScale = [
- { min: 80, color: "#00ffa3" },
- { min: 65, color: "#7dffd4" },
- { min: 50, color: "#ffe566" },
- { min: 33, color: "#ff9044" },
- { min: 0, color: "#ff4f4f" }
+ { 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 || "[]")
@@ -30,6 +30,7 @@ export const PropagationMap = {
this.renderScores(scores)
})
+ // Legend
const legend = L.control({ position: "bottomright" })
legend.onAdd = () => {
const div = L.DomUtil.create("div")
@@ -51,37 +52,118 @@ export const PropagationMap = {
},
renderScores(scores) {
- this.scoreLayer.clearLayers()
- scores.forEach(({ lat, lon, score }) => {
- const color = this.scoreColor(score)
- const circle = L.circleMarker([lat, lon], {
- radius: 6,
- fillColor: color,
- fillOpacity: 0.7,
- color: color,
- weight: 0
- })
- circle.bindPopup(
- `Score: ${score}/100
${this.scoreTier(score)}
` +
- `${lat.toFixed(3)}\u00b0N, ${Math.abs(lon).toFixed(3)}\u00b0W`
- )
- this.scoreLayer.addLayer(circle)
- })
- },
-
- scoreColor(score) {
- for (const tier of this.colorScale) {
- if (score >= tier.min) return tier.color
+ this.scores = scores
+ if (this.scoreOverlay) {
+ this.map.removeLayer(this.scoreOverlay)
}
- return "#ff4f4f"
+ 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)
},
- scoreTier(score) {
- if (score >= 80) return "EXCELLENT"
- if (score >= 65) return "GOOD"
- if (score >= 50) return "MARGINAL"
- if (score >= 33) return "POOR"
- return "NEGLIGIBLE"
+ 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() {
diff --git a/lib/microwaveprop/propagation/grid.ex b/lib/microwaveprop/propagation/grid.ex
index e6c7adb1..2cdaf652 100644
--- a/lib/microwaveprop/propagation/grid.ex
+++ b/lib/microwaveprop/propagation/grid.ex
@@ -1,13 +1,13 @@
defmodule Microwaveprop.Propagation.Grid do
- @moduledoc "CONUS grid definition for propagation scoring. 0.5 degree resolution (~55 km)."
+ @moduledoc "CONUS grid definition for propagation scoring. 0.125 degree resolution (~14 km)."
@lat_min 25.0
@lat_max 50.0
@lon_min -125.0
@lon_max -66.0
- @step 0.5
+ @step 0.125
- @doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.5 degree spacing."
+ @doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing."
def conus_points do
for lat <- float_range(@lat_min, @lat_max, @step),
lon <- float_range(@lon_min, @lon_max, @step) do
diff --git a/test/microwaveprop/propagation/grid_test.exs b/test/microwaveprop/propagation/grid_test.exs
index 76c6fe8c..91e51190 100644
--- a/test/microwaveprop/propagation/grid_test.exs
+++ b/test/microwaveprop/propagation/grid_test.exs
@@ -4,7 +4,7 @@ defmodule Microwaveprop.Propagation.GridTest do
alias Microwaveprop.Propagation.Grid
describe "conus_points/0" do
- test "generates grid at 0.5 degree resolution" do
+ test "generates grid at 0.125 degree resolution" do
points = Grid.conus_points()
assert length(points) > 5000
assert length(points) < 100_000
@@ -17,10 +17,10 @@ defmodule Microwaveprop.Propagation.GridTest do
end)
end
- test "points are on 0.5 degree grid" do
+ test "points are on 0.125 degree grid" do
Enum.each(Grid.conus_points(), fn {lat, lon} ->
- assert Float.round(lat * 2, 0) == lat * 2
- assert Float.round(lon * 2, 0) == lon * 2
+ assert Float.round(lat * 8, 0) == lat * 8
+ assert Float.round(lon * 8, 0) == lon * 8
end)
end
@@ -39,8 +39,8 @@ defmodule Microwaveprop.Propagation.GridTest do
end
describe "step/0" do
- test "returns 0.5" do
- assert Grid.step() == 0.5
+ test "returns 0.125" do
+ assert Grid.step() == 0.125
end
end