Two bugs on the propagation map: 1. LiveStash reconnect path crashed the LiveView with KeyError on :initial_scores_json. stash_assigns/2 only persists selected_band and selected_time, but the recovered-socket branch returned early without rebuilding any of the other mount-time assigns (bands, bounds, the initial score JSON payload, grid/radar toggles, antenna height). Users on the Phoenix longpoll fallback — or anyone whose websocket reconnected after a brief network blip — saw a blank page and a server-side 500. Refactor mount/3 to always compute ephemeral assigns, using recovered selected_band / selected_time when present and falling back to defaults otherwise. 2. The propagation reach polygon shown after clicking a point never redrew when the user changed bands or scrubbed the forecast timeline. The redraw logic only ran inside the point_detail handler and was gated on `hull.length >= 3`, so the stale polygon from the old band stuck around whenever the new band had no coverage at the clicked point. Extract a redrawReachPolygon/0 helper on the Leaflet hook that always clears the previous polygon first and pulls the tier color from the current score at the clicked location, then call it from update_scores (band change + server time scrub) and the cached timeline-scrub path. The point_detail handler delegates to the same helper so the polygon stays in sync with both the score grid and the detail panel.
677 lines
23 KiB
Elixir
677 lines
23 KiB
Elixir
defmodule MicrowavepropWeb.MapLive do
|
||
@moduledoc false
|
||
use MicrowavepropWeb, :live_view
|
||
use LiveStash
|
||
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Terrain.Viewshed
|
||
|
||
require Logger
|
||
|
||
@default_band 10_000
|
||
# Default map center when no visitor geolocation is available — DFW (EM12).
|
||
@default_center %{lat: 32.897, lon: -97.038}
|
||
@default_zoom 7
|
||
|
||
@impl true
|
||
def mount(_params, session, socket) do
|
||
if connected?(socket) do
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||
end
|
||
|
||
socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
|
||
|
||
# LiveStash only persists the keys passed to `stash_assigns/2`
|
||
# (selected_band + selected_time). On reconnect the recovered socket has
|
||
# just those — the rest of the mount-time assigns (bands, bounds, the
|
||
# initial score JSON payload, toggles, etc.) have to be rebuilt or the
|
||
# first render crashes with KeyError on :initial_scores_json.
|
||
{recovered_band, recovered_time, socket} =
|
||
case LiveStash.recover_state(socket) do
|
||
{:recovered, recovered} ->
|
||
{recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered}
|
||
|
||
_ ->
|
||
{nil, nil, socket}
|
||
end
|
||
|
||
bands = BandConfig.all_bands()
|
||
selected_band = recovered_band || @default_band
|
||
valid_times = Propagation.available_valid_times(selected_band)
|
||
selected_time = recovered_time || closest_to_now(valid_times)
|
||
center = initial_center(session)
|
||
bounds = bounds_around(center)
|
||
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
|
||
|
||
# preload_forecast runs on the first map_bounds event instead of
|
||
# here — mount only knows a hardcoded fallback bounding box, so
|
||
# preloading now populates the forecast cache with scores for the
|
||
# wrong viewport and forecast-hour clicks show a small square of
|
||
# coverage instead of the full map.
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Propagation Prediction Map",
|
||
bands: bands,
|
||
selected_band: selected_band,
|
||
initial_scores_json: Jason.encode!(initial_scores),
|
||
valid_times: valid_times,
|
||
selected_time: selected_time,
|
||
bounds: bounds,
|
||
initial_center: center,
|
||
initial_zoom: @default_zoom,
|
||
grid_visible: false,
|
||
radar_visible: false,
|
||
antenna_height_ft: 33
|
||
)}
|
||
end
|
||
|
||
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
|
||
defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
|
||
%{lat: lat, lon: lon}
|
||
end
|
||
|
||
defp initial_center(_session), do: @default_center
|
||
|
||
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
|
||
# around a given center so the initial HRRR score query still returns a
|
||
# useful tile set for the visible area.
|
||
defp bounds_around(%{lat: lat, lon: lon}) do
|
||
%{
|
||
"south" => lat - 3.4,
|
||
"north" => lat + 3.4,
|
||
"west" => lon - 4.5,
|
||
"east" => lon + 4.5
|
||
}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("select_band", %{"value" => band}, socket) do
|
||
band = if is_binary(band), do: String.to_integer(band), else: band
|
||
valid_times = Propagation.available_valid_times(band)
|
||
selected_time = closest_to_now(valid_times)
|
||
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
|
||
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|
||
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|> push_event("update_band_info", %{band_info: band_info(band)})
|
||
|> push_timeline()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("select_time", %{"time" => time_str}, socket) do
|
||
case DateTime.from_iso8601(time_str) do
|
||
{:ok, time, _} ->
|
||
scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:selected_time, time)
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|
||
{:noreply, socket}
|
||
|
||
_ ->
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
# Fast path: the client already has the preloaded hour's scores in its
|
||
# local cache and only needs the server to remember which time is selected
|
||
# (used later for point_detail and forecast state on reconnect).
|
||
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
|
||
case DateTime.from_iso8601(time_str) do
|
||
{:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)}
|
||
_ -> {:noreply, socket}
|
||
end
|
||
end
|
||
|
||
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
|
||
height =
|
||
case Integer.parse(value) do
|
||
{h, _} when h >= 0 and h <= 200 -> h
|
||
_ -> 8
|
||
end
|
||
|
||
{:noreply, assign(socket, :antenna_height_ft, height)}
|
||
end
|
||
|
||
def handle_event("toggle_grid", _params, socket) do
|
||
visible = !socket.assigns.grid_visible
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:grid_visible, visible)
|
||
|> push_event("toggle_grid", %{visible: visible})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("toggle_radar", _params, socket) do
|
||
visible = !socket.assigns.radar_visible
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:radar_visible, visible)
|
||
|> push_event("toggle_radar", %{visible: visible})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
||
band = socket.assigns.selected_band
|
||
detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time)
|
||
forecast = Propagation.point_forecast(band, lat, lon)
|
||
|
||
forecast_data =
|
||
Enum.map(forecast, fn f ->
|
||
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
|
||
end)
|
||
|
||
# Rain-scatter (NEXRAD cell overlay + scatter-dB readings in the detail
|
||
# panel) is disabled for now — the live radar WMS toggle covers the
|
||
# "is there rain?" question and the scatter-propagation feature needs
|
||
# more work before it's useful. To re-enable: restore the :rain_scatter
|
||
# payload key and the start_async/handle_async pair below.
|
||
payload =
|
||
if detail,
|
||
do: Map.put(detail, :forecast, forecast_data),
|
||
else: %{}
|
||
|
||
{:noreply, push_event(socket, "point_detail", payload)}
|
||
end
|
||
|
||
def handle_event("map_bounds", bounds, socket) do
|
||
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
|
||
|
||
# Refresh the forecast cache for the new viewport — the client cleared its
|
||
# local cache in sendBounds() so any timeline scrub would otherwise miss.
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:bounds, bounds)
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
|
||
band_mhz = socket.assigns.selected_band
|
||
band_config = BandConfig.get(band_mhz)
|
||
freq_ghz = band_mhz / 1_000
|
||
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
|
||
|
||
score =
|
||
case Propagation.point_detail(band_mhz, lat, lon) do
|
||
%{score: s} -> s
|
||
_ -> 50
|
||
end
|
||
|
||
max_range_km = score_range_km(score, band_config)
|
||
|
||
socket =
|
||
start_async(socket, :viewshed, fn ->
|
||
Viewshed.compute(lat, lon,
|
||
freq_ghz: freq_ghz,
|
||
ant_height_m: ant_height_m,
|
||
max_range_km: max_range_km,
|
||
score: score
|
||
)
|
||
end)
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info(:preload_forecast, socket) do
|
||
band = socket.assigns.selected_band
|
||
bounds = socket.assigns.bounds
|
||
selected = socket.assigns.selected_time
|
||
|
||
hours =
|
||
socket.assigns.valid_times
|
||
|> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq))
|
||
|> Enum.map(fn t ->
|
||
%{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
|
||
end)
|
||
|
||
{:noreply, push_event(socket, "preload_forecast", %{hours: hours})}
|
||
end
|
||
|
||
def handle_info({:propagation_updated, _valid_times}, socket) do
|
||
band = socket.assigns.selected_band
|
||
valid_times = Propagation.available_valid_times(band)
|
||
# Stay on the same selected time if still available, else pick earliest
|
||
selected_time =
|
||
if socket.assigns.selected_time in valid_times do
|
||
socket.assigns.selected_time
|
||
else
|
||
closest_to_now(valid_times)
|
||
end
|
||
|
||
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
|
||
|
||
send(self(), :preload_forecast)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(valid_times: valid_times, selected_time: selected_time)
|
||
|> push_event("update_scores", %{scores: scores})
|
||
|> push_timeline()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:viewshed, {:ok, result}, socket) do
|
||
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
||
Logger.info("Viewshed complete: #{length(points)} boundary points")
|
||
|
||
socket =
|
||
push_event(socket, "viewshed_result", %{
|
||
origin: result.origin,
|
||
points: points
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_async(:viewshed, {:exit, reason}, socket) do
|
||
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
|
||
{:noreply, socket}
|
||
end
|
||
|
||
defp push_timeline(socket) do
|
||
times =
|
||
Enum.map(socket.assigns.valid_times, fn t ->
|
||
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
||
end)
|
||
|
||
selected =
|
||
if socket.assigns.selected_time,
|
||
do: DateTime.to_iso8601(socket.assigns.selected_time)
|
||
|
||
push_event(socket, "update_timeline", %{times: times, selected: selected})
|
||
end
|
||
|
||
defp format_time_label(dt) do
|
||
# Show as "HH:MM" in UTC
|
||
Calendar.strftime(dt, "%H:%M")
|
||
end
|
||
|
||
defp closest_to_now([]), do: nil
|
||
|
||
defp closest_to_now(times) do
|
||
now = DateTime.utc_now()
|
||
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
|
||
end
|
||
|
||
defp score_range_km(score, config) do
|
||
cond do
|
||
score >= 80 -> config.exceptional_range_km
|
||
score >= 65 -> config.extended_range_km
|
||
score >= 50 -> config.typical_range_km
|
||
score >= 33 -> round(config.typical_range_km * 0.6)
|
||
true -> round(config.typical_range_km * 0.3)
|
||
end
|
||
end
|
||
|
||
defp band_info(band_mhz) do
|
||
config = BandConfig.get(band_mhz)
|
||
|
||
%{
|
||
band_label: config.label,
|
||
typical_range_km: config.typical_range_km,
|
||
extended_range_km: config.extended_range_km,
|
||
exceptional_range_km: config.exceptional_range_km,
|
||
humidity_effect: to_string(config.humidity_effect),
|
||
freq_mhz: config.freq_mhz
|
||
}
|
||
end
|
||
|
||
defp selected_label(bands, selected_band) do
|
||
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
|
||
nil -> "Select Band"
|
||
band -> band.label
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<div class="flex w-screen h-screen overflow-hidden">
|
||
<%!-- Map container --%>
|
||
<div class="relative flex-1 min-w-0">
|
||
<%!-- Full-page map --%>
|
||
<div
|
||
id="propagation-map"
|
||
phx-hook="PropagationMap"
|
||
phx-update="ignore"
|
||
data-scores={@initial_scores_json}
|
||
data-band-info={Jason.encode!(band_info(@selected_band))}
|
||
data-valid-times={
|
||
Jason.encode!(
|
||
Enum.map(@valid_times, fn t ->
|
||
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
|
||
end)
|
||
)
|
||
}
|
||
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
|
||
data-center-lat={@initial_center.lat}
|
||
data-center-lon={@initial_center.lon}
|
||
data-zoom={@initial_zoom}
|
||
class="absolute inset-0 z-0"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Mobile-only floating controls --%>
|
||
<div
|
||
id="map-controls"
|
||
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
||
>
|
||
<div
|
||
data-theme="dark"
|
||
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
||
>
|
||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<span>NTMS</span>
|
||
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="utc-clock"
|
||
phx-hook="UtcClock"
|
||
phx-update="ignore"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
{@initial_utc_clock}
|
||
</div>
|
||
<button
|
||
id="mobile-panel-toggle"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dropdown">
|
||
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||
{selected_label(@bands, @selected_band)}
|
||
<.icon name="hero-chevron-down" class="size-3" />
|
||
</div>
|
||
<ul
|
||
tabindex="0"
|
||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
|
||
>
|
||
<li :for={band <- @bands}>
|
||
<button
|
||
onclick="document.activeElement.blur()"
|
||
phx-click={
|
||
JS.dispatch("show-loading", to: "#propagation-map")
|
||
|> JS.push("select_band", value: %{value: band.freq_mhz})
|
||
}
|
||
class={[if(@selected_band == band.freq_mhz, do: "active")]}
|
||
>
|
||
{band.label}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div id="panel-extras" class="hidden flex-col gap-2">
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@grid_visible}
|
||
phx-click="toggle_grid"
|
||
/>
|
||
<span class="text-sm">Grid squares</span>
|
||
</label>
|
||
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@radar_visible}
|
||
phx-click="toggle_radar"
|
||
/>
|
||
<span class="text-sm">Weather radar</span>
|
||
</label>
|
||
|
||
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
|
||
<label class="text-sm whitespace-nowrap">Antenna height</label>
|
||
<input
|
||
type="number"
|
||
name="height_ft"
|
||
min="0"
|
||
max="200"
|
||
step="1"
|
||
value={@antenna_height_ft}
|
||
class="input input-xs w-16 text-right"
|
||
/>
|
||
<span class="text-xs opacity-70">ft</span>
|
||
</form>
|
||
|
||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
||
Path Calculator
|
||
</.link>
|
||
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||
Beacons
|
||
</.link>
|
||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
||
Weather Map
|
||
</.link>
|
||
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
||
Scoring Algorithm
|
||
</.link>
|
||
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
||
Submit a Contact
|
||
</.link>
|
||
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
||
Contact Training Data
|
||
</.link>
|
||
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
||
Contact Map
|
||
</.link>
|
||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
||
About
|
||
</.link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Point detail floating panel --%>
|
||
<div
|
||
id="detail-panel"
|
||
phx-update="ignore"
|
||
class={[
|
||
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
||
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
||
"md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
||
]}
|
||
style="display:none;"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Bottom timeline bar --%>
|
||
<div
|
||
id="forecast-timeline"
|
||
phx-update="ignore"
|
||
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
||
<button
|
||
id="sidebar-expand"
|
||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
||
onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<%!-- Desktop right sidebar --%>
|
||
<div
|
||
id="sidebar"
|
||
data-theme="dark"
|
||
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
||
>
|
||
<%!-- Sidebar controls --%>
|
||
<div class="p-3 flex flex-col gap-2 shrink-0">
|
||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<span>NTMS</span>
|
||
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="utc-clock-desktop"
|
||
phx-hook="UtcClock"
|
||
phx-update="ignore"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
{@initial_utc_clock}
|
||
</div>
|
||
<button
|
||
id="sidebar-collapse"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
title="Collapse sidebar"
|
||
onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-chevron-right" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Band selector --%>
|
||
<div class="dropdown">
|
||
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||
{selected_label(@bands, @selected_band)}
|
||
<.icon name="hero-chevron-down" class="size-3" />
|
||
</div>
|
||
<ul
|
||
tabindex="0"
|
||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
|
||
>
|
||
<li :for={band <- @bands}>
|
||
<button
|
||
onclick="document.activeElement.blur()"
|
||
phx-click={
|
||
JS.dispatch("show-loading", to: "#propagation-map")
|
||
|> JS.push("select_band", value: %{value: band.freq_mhz})
|
||
}
|
||
class={[if(@selected_band == band.freq_mhz, do: "active")]}
|
||
>
|
||
{band.label}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<%!-- Grid toggle --%>
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@grid_visible}
|
||
phx-click="toggle_grid"
|
||
/>
|
||
<span class="text-sm">Grid squares</span>
|
||
</label>
|
||
|
||
<%!-- Weather radar toggle --%>
|
||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||
<input
|
||
type="checkbox"
|
||
class="toggle toggle-sm toggle-primary"
|
||
checked={@radar_visible}
|
||
phx-click="toggle_radar"
|
||
/>
|
||
<span class="text-sm">Weather radar</span>
|
||
</label>
|
||
|
||
<%!-- Antenna height --%>
|
||
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
|
||
<label class="text-sm whitespace-nowrap">Antenna height</label>
|
||
<input
|
||
type="number"
|
||
name="height_ft"
|
||
min="0"
|
||
max="200"
|
||
step="1"
|
||
value={@antenna_height_ft}
|
||
class="input input-xs w-16 text-right"
|
||
/>
|
||
<span class="text-xs opacity-70">ft</span>
|
||
</form>
|
||
|
||
<%!-- Navigation --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<li>
|
||
<.link navigate="/path">Path Calculator</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/beacons">Beacons</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/weather">Weather Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/algo">Scoring Algorithm</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/submit">Submit a Contact</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts">Contact Training Data</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/contacts/map">Contact Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/about">About</.link>
|
||
</li>
|
||
</ul>
|
||
|
||
<%!-- Auth --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<%= if @current_scope && @current_scope.user do %>
|
||
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
|
||
<li>
|
||
<.link href={~p"/users/settings"}>Settings</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
||
</li>
|
||
<% else %>
|
||
<li>
|
||
<.link href={~p"/users/register"}>Register</.link>
|
||
</li>
|
||
<li>
|
||
<.link href={~p"/users/log-in"}>Log in</.link>
|
||
</li>
|
||
<% end %>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Layouts.flash_group flash={@flash} />
|
||
"""
|
||
end
|
||
end
|