Corrections to the previous weather-map pass: * The "helper" that was supposed to live in the top-right was the color legend, not the layer description. Move the Leaflet legend control to `topright` (was `bottomright` on desktop). * Put the layer description back in the desktop sidebar where it used to live, now with a "Group · Label" header above the body text so it matches the new style in the mockup. * Add play/stop controls to the weather forecast timeline, ported from the propagation map: start plays "now" → end at 1s/step and loops; stop returns to the time closest to wall clock. A manual click interrupts playback the same way it does on /map. * Fix the reconnect/reload loop I introduced by making mount do a synchronous ProfilesFile read + derive for the full 92k-point grid. That was pushing mount past the LV socket join timeout on a cold pod, which makes the LV client fall back to a full HTTP reload every ~20 seconds (no server-side error). Mount now uses the original `latest_weather_grid/1` cache-only hot path and only `weather_grid_at/2` (sync disk read) on user timeline scrubs. * Clean up the playback timer in the hook's `destroyed` callback.
644 lines
22 KiB
Elixir
644 lines
22 KiB
Elixir
defmodule MicrowavepropWeb.WeatherMapLive do
|
||
@moduledoc false
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
alias Microwaveprop.Weather
|
||
|
||
@initial_bounds %{
|
||
"south" => 29.5,
|
||
"north" => 36.3,
|
||
"west" => -101.5,
|
||
"east" => -92.5
|
||
}
|
||
|
||
@layers [
|
||
# Surface
|
||
%{
|
||
id: "temperature",
|
||
label: "Temperature",
|
||
unit: "°C",
|
||
group: "Surface",
|
||
desc: "2m air temperature. Warm, humid air increases refractivity and ducting potential at lower frequencies."
|
||
},
|
||
%{
|
||
id: "dewpoint_depression",
|
||
label: "Td Depression",
|
||
unit: "°C",
|
||
group: "Surface",
|
||
desc:
|
||
"Temperature minus dewpoint. Small values (< 5°C) mean moist boundary layer — favorable for ducting at 10 GHz, but increases absorption above 24 GHz."
|
||
},
|
||
%{
|
||
id: "surface_rh",
|
||
label: "Humidity",
|
||
unit: "%",
|
||
group: "Surface",
|
||
desc:
|
||
"Relative humidity from surface T and Td. High RH supports refractivity gradients that bend microwave signals."
|
||
},
|
||
%{
|
||
id: "pwat",
|
||
label: "PWAT",
|
||
unit: "mm",
|
||
group: "Surface",
|
||
desc:
|
||
"Precipitable water — total moisture in the atmospheric column. High values signal rain fade risk for 24 GHz and above."
|
||
},
|
||
%{
|
||
id: "surface_refractivity",
|
||
label: "Refractivity",
|
||
unit: "N",
|
||
group: "Surface",
|
||
desc:
|
||
"Radio refractivity (N-units) at the surface. Typical values 280–380. Higher N means the atmosphere bends signals more toward the ground."
|
||
},
|
||
%{
|
||
id: "refractivity_gradient",
|
||
label: "N-Gradient",
|
||
unit: "N/km",
|
||
group: "Surface",
|
||
desc:
|
||
"Minimum refractivity gradient in the profile. Standard is −40 N/km. Below −157 N/km signals are trapped in a duct. More negative = stronger ducting."
|
||
},
|
||
%{
|
||
id: "bl_height",
|
||
label: "BL Height",
|
||
unit: "m",
|
||
group: "Surface",
|
||
desc:
|
||
"Planetary boundary layer height. Shallow BL (< 500m) concentrates moisture and heat near the surface, favoring temperature inversions and ducting."
|
||
},
|
||
# Upper Air
|
||
%{
|
||
id: "temp_850mb",
|
||
label: "T @ 850mb",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Temperature at 850 mb (~1500m altitude). Warm 850mb air over cool surface air indicates a capping inversion — a classic ducting setup."
|
||
},
|
||
%{
|
||
id: "dewpoint_850mb",
|
||
label: "Td @ 850mb",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Dewpoint at 850 mb. A sharp moisture drop between the surface and 850mb creates an elevated refractivity gradient that can form ducts."
|
||
},
|
||
%{
|
||
id: "lapse_rate",
|
||
label: "Lapse Rate",
|
||
unit: "°C/km",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Temperature decrease per km from surface to 700mb. Low rates (< 5 °C/km) mean stable air that preserves inversions. High rates (> 8) mean convective mixing that destroys them."
|
||
},
|
||
%{
|
||
id: "inversion_strength",
|
||
label: "Inversion",
|
||
unit: "°C",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Strongest temperature increase between adjacent levels. Inversions trap microwave signals. > 3°C is significant, > 5°C is strong."
|
||
},
|
||
%{
|
||
id: "inversion_base_m",
|
||
label: "Inv. Base",
|
||
unit: "m",
|
||
group: "Upper Air",
|
||
desc:
|
||
"Height (m AGL) where the strongest inversion begins. Surface-based inversions (< 200m) create surface ducts. Elevated inversions create elevated ducts."
|
||
},
|
||
# Ducting
|
||
%{
|
||
id: "ducting",
|
||
label: "Ducting",
|
||
unit: "",
|
||
group: "Ducting",
|
||
desc:
|
||
"Whether a trapping layer (modified refractivity decreasing with height) was detected in the profile. Green = duct present, signals can travel far beyond line of sight."
|
||
},
|
||
%{
|
||
id: "duct_base_m",
|
||
label: "Duct Base",
|
||
unit: "m",
|
||
group: "Ducting",
|
||
desc:
|
||
"Height of the lowest duct base. Surface ducts (< 100m) are most effective for ground-based stations. Elevated ducts require antennas near the duct height."
|
||
},
|
||
%{
|
||
id: "duct_strength",
|
||
label: "Duct Strength",
|
||
unit: "M",
|
||
group: "Ducting",
|
||
desc:
|
||
"Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies."
|
||
}
|
||
]
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
if connected?(socket) do
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
||
end
|
||
|
||
# Hot-path mount: use the cache-only latest_weather_grid for the
|
||
# initial payload so mount stays under the LV socket join timeout
|
||
# (~10s). Reading and deriving the full 92k-point ProfilesFile
|
||
# synchronously pushes mount past that window on a cold pod,
|
||
# which makes the LV client fall back to a full page reload.
|
||
data = Weather.latest_weather_grid(@initial_bounds)
|
||
latest_vt = if data == [], do: Weather.latest_grid_valid_time(), else: hd(data).valid_time
|
||
valid_times = Weather.available_weather_valid_times()
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Weather Map",
|
||
layers: @layers,
|
||
selected_layer: "refractivity_gradient",
|
||
initial_data_json: Jason.encode!(data),
|
||
valid_time: latest_vt,
|
||
valid_times: valid_times,
|
||
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
|
||
initial_selected_time: latest_vt && DateTime.to_iso8601(latest_vt),
|
||
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
|
||
bounds: @initial_bounds,
|
||
grid_visible: false,
|
||
radar_visible: false
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
|
||
{:noreply, assign(socket, :selected_layer, layer_id)}
|
||
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("map_bounds", bounds, socket) do
|
||
data =
|
||
case socket.assigns.valid_time do
|
||
nil -> Weather.latest_weather_grid(bounds)
|
||
vt -> Weather.weather_grid_at(vt, bounds)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:bounds, bounds)
|
||
|> push_event("update_weather", %{data: data})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("select_time", %{"time" => time_str}, socket) do
|
||
with {:ok, time, _} <- DateTime.from_iso8601(time_str),
|
||
true <- time in socket.assigns.valid_times do
|
||
data = Weather.weather_grid_at(time, socket.assigns.bounds)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:valid_time, time)
|
||
|> push_event("update_weather", %{data: data})
|
||
|
||
{:noreply, socket}
|
||
else
|
||
_ -> {:noreply, socket}
|
||
end
|
||
end
|
||
|
||
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
||
detail =
|
||
if socket.assigns.valid_time do
|
||
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
|
||
end
|
||
|
||
payload = detail || %{}
|
||
{:noreply, push_event(socket, "point_detail", payload)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:weather_updated, _valid_time}, socket) do
|
||
valid_times = Weather.available_weather_valid_times()
|
||
|
||
# Keep whatever the user has scrubbed to if it's still available,
|
||
# otherwise fall back to the latest on-disk time (GridCache-backed
|
||
# hot path). Never block mount/refresh on a ProfilesFile read for
|
||
# the happy path.
|
||
selected =
|
||
cond do
|
||
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
|
||
valid_times != [] -> List.last(valid_times)
|
||
true -> nil
|
||
end
|
||
|
||
data =
|
||
if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: []
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:valid_times, valid_times)
|
||
|> assign(:valid_time, selected)
|
||
|> push_event("update_weather", %{data: data})
|
||
|> push_event("update_timeline", %{
|
||
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
|
||
selected: selected && DateTime.to_iso8601(selected)
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
# A new forecast hour has landed. Re-enumerate the available times
|
||
# so the timeline picks up the extra hour, but keep the currently
|
||
# selected time where it is.
|
||
def handle_info({:propagation_pipeline_progress, _progress}, socket) do
|
||
valid_times = Weather.available_weather_valid_times()
|
||
|
||
if valid_times == socket.assigns.valid_times do
|
||
{:noreply, socket}
|
||
else
|
||
socket =
|
||
socket
|
||
|> assign(:valid_times, valid_times)
|
||
|> push_event("update_timeline", %{
|
||
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
|
||
selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time)
|
||
})
|
||
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
@group_order ["Surface", "Upper Air", "Ducting"]
|
||
|
||
defp group_layers(layers) do
|
||
layers
|
||
|> Enum.group_by(& &1.group)
|
||
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
|
||
end
|
||
|
||
defp layer_description(layers, selected_id) do
|
||
case Enum.find(layers, &(&1.id == selected_id)) do
|
||
%{desc: desc} -> desc
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp selected_layer_label(layers, selected_id) do
|
||
case Enum.find(layers, &(&1.id == selected_id)) do
|
||
%{label: label} -> label
|
||
_ -> ""
|
||
end
|
||
end
|
||
|
||
defp selected_layer_group_label(layers, selected_id) do
|
||
case Enum.find(layers, &(&1.id == selected_id)) do
|
||
%{group: group} -> group
|
||
_ -> ""
|
||
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="weather-map"
|
||
phx-hook="WeatherMap"
|
||
phx-update="ignore"
|
||
data-weather={@initial_data_json}
|
||
data-layers={Jason.encode!(@layers)}
|
||
data-selected-layer={@selected_layer}
|
||
data-valid-times={@initial_valid_times_json}
|
||
data-selected-time={@initial_selected_time}
|
||
class="absolute inset-0 z-0"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Bottom forecast timeline --%>
|
||
<div
|
||
id="weather-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>
|
||
|
||
<%!-- Mobile-only floating controls --%>
|
||
<div
|
||
id="weather-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">Weather Map</div>
|
||
<div class="font-normal text-[10px] opacity-60">
|
||
<%= if @valid_time do %>
|
||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
||
<% else %>
|
||
No data available
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="weather-utc-clock-mobile"
|
||
phx-hook="UtcClock"
|
||
phx-update="ignore"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
{@initial_utc_clock}
|
||
</div>
|
||
<button
|
||
id="weather-mobile-panel-toggle"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
|
||
>
|
||
<.icon name="hero-bars-3" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="weather-panel-extras" class="hidden flex-col gap-2">
|
||
<%!-- Layer pill buttons grouped --%>
|
||
<div class="flex flex-col gap-1.5">
|
||
<div :for={{group, layers} <- group_layers(@layers)}>
|
||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
||
{group}
|
||
</div>
|
||
<div class="flex flex-wrap gap-1">
|
||
<button
|
||
:for={layer <- layers}
|
||
phx-click="select_layer"
|
||
phx-value-layer={layer.id}
|
||
class={[
|
||
"btn btn-xs rounded-full",
|
||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
||
]}
|
||
>
|
||
{layer.label}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
||
{layer_description(@layers, @selected_layer)}
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-1 border-t border-base-300 pt-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>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
||
Propagation Map
|
||
</.link>
|
||
<.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="/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="/algo" class="btn btn-xs btn-ghost justify-start">
|
||
Scoring Algorithm
|
||
</.link>
|
||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
||
About
|
||
</.link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Point detail panel --%>
|
||
<div
|
||
id="weather-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-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
||
]}
|
||
style="display:none;"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
||
<button
|
||
id="weather-sidebar-expand"
|
||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
||
onclick="document.getElementById('weather-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="weather-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"
|
||
>
|
||
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
||
<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">Weather Map</div>
|
||
<div class="font-normal text-[10px] opacity-60">
|
||
<%= if @valid_time do %>
|
||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
||
<% else %>
|
||
No data available
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<div
|
||
id="weather-utc-clock-desktop"
|
||
phx-hook="UtcClock"
|
||
phx-update="ignore"
|
||
class="font-mono text-xs opacity-70 tabular-nums"
|
||
>
|
||
{@initial_utc_clock}
|
||
</div>
|
||
<button
|
||
id="weather-sidebar-collapse"
|
||
class="btn btn-xs btn-square btn-ghost"
|
||
title="Collapse sidebar"
|
||
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||
>
|
||
<.icon name="hero-chevron-right" class="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Layer pill buttons grouped --%>
|
||
<div class="flex flex-col gap-1.5">
|
||
<div :for={{group, layers} <- group_layers(@layers)}>
|
||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
||
{group}
|
||
</div>
|
||
<div class="flex flex-wrap gap-1">
|
||
<button
|
||
:for={layer <- layers}
|
||
phx-click="select_layer"
|
||
phx-value-layer={layer.id}
|
||
class={[
|
||
"btn btn-xs rounded-full",
|
||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
||
]}
|
||
>
|
||
{layer.label}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Layer description (group · name header + body) --%>
|
||
<div class="px-1">
|
||
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
|
||
{selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label(
|
||
@layers,
|
||
@selected_layer
|
||
)}
|
||
</div>
|
||
<div class="text-[11px] opacity-70 leading-snug">
|
||
{layer_description(@layers, @selected_layer)}
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Overlay toggles --%>
|
||
<div class="flex flex-col gap-1 border-t border-base-300 pt-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>
|
||
</div>
|
||
|
||
<%!-- Navigation --%>
|
||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||
<li>
|
||
<.link navigate="/map">Propagation Map</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/path">Path Calculator</.link>
|
||
</li>
|
||
<li>
|
||
<.link navigate="/beacons">Beacons</.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="/algo">Scoring Algorithm</.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>
|
||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||
{@current_scope.user.callsign}
|
||
</.link>
|
||
</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
|