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