Add Leaflet map integration with propagation score display

Vendor Leaflet 1.9.4 (JS, CSS, marker images) and wire it into
the esbuild/Tailwind asset pipeline. Create MapLive with band
selector buttons, auto-refresh, and a colocated JS hook that
renders propagation scores as color-coded circle markers with a
legend. Stub Propagation context and BandConfig modules provide
the data interface for the scoring pipeline.

Add nav bar with links to Map, QSOs, and Submit pages.
This commit is contained in:
Graham McIntire 2026-03-30 17:11:00 -05:00
parent 3228835636
commit 5a0113aac2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
16 changed files with 1017 additions and 1 deletions

View file

@ -1,3 +1,6 @@
/* Leaflet map CSS (vendored) */
@import "../vendor/leaflet/leaflet.css";
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */

View file

@ -17,6 +17,10 @@
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Leaflet map library (vendored)
import * as L from "../vendor/leaflet/leaflet.js"
window.L = L
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

661
assets/vendor/leaflet/leaflet.css vendored Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
assets/vendor/leaflet/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
defmodule Microwaveprop.Propagation do
@moduledoc """
Context module for propagation scoring and forecasting.
Provides functions to retrieve propagation scores for display on the map.
Score computation and grid workers will be implemented in subsequent tasks.
"""
@doc """
Returns the latest propagation scores for the given band frequency in MHz.
Each score is a map with `:lat`, `:lon`, and `:score` keys.
Returns an empty list until the scoring pipeline is operational.
"""
@spec latest_scores(integer()) :: [%{lat: float(), lon: float(), score: number()}]
def latest_scores(_band_freq_mhz) do
[]
end
@doc """
Returns the valid time of the most recent propagation score computation,
or nil if no scores have been computed yet.
"""
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do
nil
end
end

View file

@ -0,0 +1,24 @@
defmodule Microwaveprop.Propagation.BandConfig do
@moduledoc """
Band configuration for microwave propagation scoring.
Defines frequency bands and their display labels for the propagation map.
"""
@bands [
%{freq_mhz: 1_296, label: "1296 MHz"},
%{freq_mhz: 2_304, label: "2304 MHz"},
%{freq_mhz: 3_456, label: "3456 MHz"},
%{freq_mhz: 5_760, label: "5760 MHz"},
%{freq_mhz: 10_000, label: "10 GHz"},
%{freq_mhz: 24_000, label: "24 GHz"},
%{freq_mhz: 47_000, label: "47 GHz"},
%{freq_mhz: 75_000, label: "75 GHz"}
]
@doc """
Returns all band configurations as a list of maps with `:freq_mhz` and `:label` keys.
"""
@spec all_bands() :: [%{freq_mhz: integer(), label: String.t()}]
def all_bands, do: @bands
end

View file

@ -36,8 +36,19 @@ defmodule MicrowavepropWeb.Layouts do
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<div class="flex-1 gap-4">
<a href="/" class="font-semibold">Microwaveprop</a>
<nav class="flex gap-2">
<.link navigate="/map" class="btn btn-ghost btn-sm">
<.icon name="hero-map" class="size-4" /> Map
</.link>
<.link navigate="/qsos" class="btn btn-ghost btn-sm">
<.icon name="hero-signal" class="size-4" /> QSOs
</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">
<.icon name="hero-plus-circle" class="size-4" /> Submit
</.link>
</nav>
</div>
<div class="flex-none">
<.theme_toggle />

View file

@ -0,0 +1,96 @@
defmodule MicrowavepropWeb.MapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
@default_band 10_000
@refresh_interval_ms to_timeout(minute: 5)
@impl true
def mount(_params, _session, socket) do
bands = BandConfig.all_bands()
scores = Propagation.latest_scores(@default_band)
valid_time = Propagation.latest_valid_time()
if connected?(socket) do
Process.send_after(self(), :refresh, @refresh_interval_ms)
end
{:ok,
assign(socket,
page_title: "Propagation Map",
bands: bands,
selected_band: @default_band,
scores: scores,
valid_time: valid_time
)}
end
@impl true
def handle_event("select_band", %{"value" => band_str}, socket) do
band = String.to_integer(band_str)
scores = Propagation.latest_scores(band)
socket =
socket
|> assign(:selected_band, band)
|> assign(:scores, scores)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
@impl true
def handle_info(:refresh, socket) do
scores = Propagation.latest_scores(socket.assigns.selected_band)
valid_time = Propagation.latest_valid_time()
Process.send_after(self(), :refresh, @refresh_interval_ms)
socket =
socket
|> assign(:scores, scores)
|> assign(:valid_time, valid_time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex flex-col h-[calc(100vh-4rem)]">
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-t-lg flex-wrap">
<h2 class="text-lg font-bold">Propagation Map</h2>
<div class="flex gap-1 flex-wrap">
<button
:for={band <- @bands}
phx-click="select_band"
value={band.freq_mhz}
class={[
"btn btn-sm",
if(@selected_band == band.freq_mhz, do: "btn-primary", else: "btn-ghost")
]}
>
{band.label}
</button>
</div>
<div :if={@valid_time} class="ml-auto text-sm text-base-content/60">
Updated: {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
</div>
</div>
<div
id="propagation-map"
phx-hook="PropagationMap"
phx-update="ignore"
data-scores={Jason.encode!(@scores)}
class="flex-1 rounded-b-lg z-0"
>
</div>
</div>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,87 @@
export const PropagationMap = {
mounted() {
this.map = L.map(this.el, {
center: [38.0, -96.0],
zoom: 5,
minZoom: 4,
maxZoom: 10
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
this.scoreLayer = L.layerGroup().addTo(this.map)
this.colorScale = [
{ min: 80, color: "#00ffa3" },
{ min: 65, color: "#7dffd4" },
{ min: 50, color: "#ffe566" },
{ min: 33, color: "#ff9044" },
{ min: 0, color: "#ff4f4f" }
]
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
this.renderScores(initialScores)
this.handleEvent("update_scores", ({ scores }) => {
this.renderScores(scores)
})
const legend = L.control({ position: "bottomright" })
legend.onAdd = () => {
const div = L.DomUtil.create("div", "leaflet-legend")
div.innerHTML = `
<div style="background:white;padding:8px 12px;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.3);font-size:12px;line-height:1.8;">
<strong>Propagation</strong><br>
<span style="background:#00ffa3;width:12px;height:12px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Excellent (80-100)<br>
<span style="background:#7dffd4;width:12px;height:12px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Good (65-79)<br>
<span style="background:#ffe566;width:12px;height:12px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Marginal (50-64)<br>
<span style="background:#ff9044;width:12px;height:12px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Poor (33-49)<br>
<span style="background:#ff4f4f;width:12px;height:12px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:4px;"></span> Negligible (0-32)
</div>
`
return div
}
legend.addTo(this.map)
},
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(
`<strong>Score: ${score}/100</strong><br>${this.scoreTier(score)}<br>` +
`<small>${lat.toFixed(3)}\u00b0N, ${Math.abs(lon).toFixed(3)}\u00b0W</small>`
)
this.scoreLayer.addLayer(circle)
})
},
scoreColor(score) {
for (const tier of this.colorScale) {
if (score >= tier.min) return tier.color
}
return "#ff4f4f"
},
scoreTier(score) {
if (score >= 80) return "EXCELLENT"
if (score >= 65) return "GOOD"
if (score >= 50) return "MARGINAL"
if (score >= 33) return "POOR"
return "NEGLIGIBLE"
},
destroyed() {
if (this.map) this.map.remove()
}
}

View file

@ -20,6 +20,7 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home
live "/submit", SubmitLive
live "/map", MapLive
live "/qsos", QsoLive.Index
live "/qsos/:id", QsoLive.Show
end

View file

@ -0,0 +1,28 @@
defmodule Microwaveprop.Propagation.BandConfigTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.BandConfig
describe "all_bands/0" do
test "returns 8 bands" do
assert length(BandConfig.all_bands()) == 8
end
test "each band has freq_mhz and label keys" do
for band <- BandConfig.all_bands() do
assert is_integer(band.freq_mhz)
assert is_binary(band.label)
end
end
test "includes 10 GHz band" do
bands = BandConfig.all_bands()
assert Enum.any?(bands, &(&1.freq_mhz == 10_000))
end
test "bands are ordered by frequency" do
freqs = Enum.map(BandConfig.all_bands(), & &1.freq_mhz)
assert freqs == Enum.sort(freqs)
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Microwaveprop.PropagationTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation
describe "latest_scores/1" do
test "returns a list" do
assert is_list(Propagation.latest_scores(10_000))
end
test "returns empty list for stub implementation" do
assert Propagation.latest_scores(10_000) == []
end
end
describe "latest_valid_time/0" do
test "returns nil for stub implementation" do
assert Propagation.latest_valid_time() == nil
end
end
end

View file

@ -0,0 +1,46 @@
defmodule MicrowavepropWeb.MapLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "mount" do
test "renders the propagation map page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Propagation Map"
assert html =~ "propagation-map"
end
test "renders all band selector buttons", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "10 GHz"
assert html =~ "24 GHz"
assert html =~ "1296 MHz"
assert html =~ "75 GHz"
end
test "defaults to 10 GHz band selected", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "btn-primary"
end
test "includes map container with data-scores", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="propagation-map")
assert html =~ "data-scores"
assert html =~ "phx-hook"
end
end
describe "select_band" do
test "updates selected band on click", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
html =
lv
|> element("button[value='24000']")
|> render_click()
assert html =~ "24 GHz"
end
end
end