Dock map controls to right sidebar with collapsible panel

- Move controls (band selector, grid toggle, nav links) to a docked
  right sidebar on desktop, keep floating overlay on mobile
- Point detail panel floats over the map bottom-left instead of inside
  the narrow sidebar
- Add collapse/expand toggle for the sidebar
- Add auth links (login/logout/settings) to sidebar
- Wrap public live routes in live_session with UserAuth on_mount
- Use daisyUI menu lists for sidebar navigation
This commit is contained in:
Graham McIntire 2026-04-08 12:34:36 -05:00
parent ee9275e0b9
commit e4fb422402
3 changed files with 300 additions and 160 deletions

View file

@ -195,11 +195,6 @@ function isMobile() {
return window.innerWidth < 768 return window.innerWidth < 768
} }
function closeButtonHTML() {
if (!isMobile()) return ""
return `<button onclick="document.getElementById('detail-panel').style.display='none'" style="position:absolute;top:8px;right:10px;background:rgba(255,255,255,0.15);border:none;color:#fff;font-size:18px;line-height:1;width:28px;height:28px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;">&times;</button>`
}
function mobileHandleHTML() { function mobileHandleHTML() {
if (!isMobile()) return "" if (!isMobile()) return ""
return `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>` return `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
@ -208,10 +203,13 @@ function mobileHandleHTML() {
function buildLoadingHTML(detail) { function buildLoadingHTML(detail) {
const tier = scoreTier(detail.score) const tier = scoreTier(detail.score)
return `<div style="position:relative;"> return `<div style="position:relative;">
${mobileHandleHTML()}${closeButtonHTML()} ${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;"> <div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:16px;">${detail.score}/100</strong> <div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:13px;font-weight:700;">${tier.label}</span> <strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div> </div>
<div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;"> <div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;">
<span class="loading loading-spinner loading-xs"></span> Loading&hellip; <span class="loading loading-spinner loading-xs"></span> Loading&hellip;
@ -325,10 +323,13 @@ function buildPopupHTML(detail, viewshedLoading) {
const minW = mobile ? "auto" : "260px" const minW = mobile ? "auto" : "260px"
return `<div style="min-width:${minW};position:relative;"> return `<div style="min-width:${minW};position:relative;">
${mobileHandleHTML()}${closeButtonHTML()} ${mobileHandleHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;"> <div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:16px;">${detail.score}/100</strong> <div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:13px;font-weight:700;">${tier.label}</span> <strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
</div>
<button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
</div> </div>
<div style="padding:6px 10px;"> <div style="padding:6px 10px;">
<div style="font-size:12px;opacity:0.7;margin-bottom:6px;"> <div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
@ -398,12 +399,22 @@ export const PropagationMap = {
this.bandInfo = band_info this.bandInfo = band_info
}) })
// Prevent control panel from eating map clicks/scrolls // Prevent control panel / sidebar from eating map clicks/scrolls
const controls = document.getElementById("map-controls") const controls = document.getElementById("map-controls")
if (controls) { if (controls) {
L.DomEvent.disableClickPropagation(controls) L.DomEvent.disableClickPropagation(controls)
L.DomEvent.disableScrollPropagation(controls) L.DomEvent.disableScrollPropagation(controls)
} }
const sidebar = document.getElementById("sidebar")
if (sidebar) {
L.DomEvent.disableClickPropagation(sidebar)
L.DomEvent.disableScrollPropagation(sidebar)
}
// Invalidate map size when sidebar is toggled
window.addEventListener("sidebar-toggle", () => {
setTimeout(() => this.map.invalidateSize(), 50)
})
// Grid overlay layer // Grid overlay layer
this.gridLayer = L.layerGroup() this.gridLayer = L.layerGroup()
@ -447,20 +458,25 @@ export const PropagationMap = {
if (basic && this.detailPanel) { if (basic && this.detailPanel) {
const merged = { ...basic, ...this.bandInfo, factors: null } const merged = { ...basic, ...this.bandInfo, factors: null }
this.detailPanel.innerHTML = buildLoadingHTML(merged) this.detailPanel.innerHTML = buildLoadingHTML(merged)
this.detailPanel.style.display = "block" this.showDetailPanel()
this.escHint.style.opacity = "1"
this.positionDetailPanel()
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
} }
}) })
// Detail panel — bottom sheet on mobile, positioned next to sidebar on desktop // Detail panel — floating overlay on the map
this.detailPanel = document.getElementById("detail-panel") this.detailPanel = document.getElementById("detail-panel")
this.viewshedLoading = false this.viewshedLoading = false
if (this.detailPanel) { if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel) L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel) L.DomEvent.disableScrollPropagation(this.detailPanel)
// Close button event delegation
this.detailPanel.addEventListener("click", (e) => {
if (e.target.closest(".detail-close-btn")) {
this.hideDetailPanel()
}
})
} }
this.lastDetail = null this.lastDetail = null
@ -469,9 +485,7 @@ export const PropagationMap = {
const merged = { ...detail, ...this.bandInfo } const merged = { ...detail, ...this.bandInfo }
this.lastDetail = merged this.lastDetail = merged
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading) this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
this.detailPanel.style.display = "block" this.showDetailPanel()
this.escHint.style.opacity = "1"
this.positionDetailPanel()
// Draw propagation reach polygon based on contiguous good cells // Draw propagation reach polygon based on contiguous good cells
if (this.gridLookup && this.clickedLatLng) { if (this.gridLookup && this.clickedLatLng) {
@ -563,24 +577,10 @@ export const PropagationMap = {
L.DomEvent.disableScrollPropagation(this.timelineEl) L.DomEvent.disableScrollPropagation(this.timelineEl)
} }
// ESC hint — shown on desktop when detail panel is visible
this.escHint = document.createElement("div")
this.escHint.className = "hidden md:block"
this.escHint.style.cssText = "position:absolute;top:12px;right:12px;z-index:1100;background:rgba(0,0,0,0.6);color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;opacity:0;transition:opacity 0.2s;pointer-events:none;"
this.escHint.textContent = "Press ESC to close"
this.el.appendChild(this.escHint)
// Escape key closes detail panel and range circles // Escape key closes detail panel and range circles
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (this.detailPanel) { this.hideDetailPanel()
this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
}
this.rangeCircles.clearLayers()
this.clickedLatLng = null
this.lastDetail = null
this.escHint.style.opacity = "0"
} }
}) })
@ -623,15 +623,19 @@ export const PropagationMap = {
legend.addTo(this.map) legend.addTo(this.map)
}, },
positionDetailPanel() { showDetailPanel() {
if (!this.detailPanel || isMobile()) return if (!this.detailPanel) return
// On desktop, position the panel below the control panel this.detailPanel.style.display = "block"
const controls = document.getElementById("map-controls") },
if (controls) {
const rect = controls.getBoundingClientRect() hideDetailPanel() {
this.detailPanel.style.left = rect.left + "px" if (this.detailPanel) {
this.detailPanel.style.top = (rect.bottom + 8) + "px" this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
} }
this.rangeCircles.clearLayers()
this.clickedLatLng = null
this.lastDetail = null
}, },
renderScores(scores) { renderScores(scores) {

View file

@ -256,56 +256,194 @@ defmodule MicrowavepropWeb.MapLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="relative w-screen h-screen overflow-hidden"> <div class="flex w-screen h-screen overflow-hidden">
<%!-- Full-page map --%> <%!-- Map container --%>
<div <div class="relative flex-1 min-w-0">
id="propagation-map" <%!-- Full-page map --%>
phx-hook="PropagationMap" <div
phx-update="ignore" id="propagation-map"
data-scores={@initial_scores_json} phx-hook="PropagationMap"
data-band-info={Jason.encode!(band_info(@selected_band))} phx-update="ignore"
data-valid-times={ data-scores={@initial_scores_json}
Jason.encode!( data-band-info={Jason.encode!(band_info(@selected_band))}
Enum.map(@valid_times, fn t -> data-valid-times={
%{time: DateTime.to_iso8601(t), label: format_time_label(t)} Jason.encode!(
end) 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: ""} )
class="absolute inset-0 z-0" }
> data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
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 class="bg-base-100/90 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 Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="utc-clock"
phx-hook="UtcClock"
class="font-mono text-xs opacity-70 tabular-nums"
>
</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>
<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="/rover" class="btn btn-xs btn-ghost justify-start">
Rover Planner
</.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>
</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> </div>
<%!-- Top-left control panel --%> <%!-- Desktop right sidebar --%>
<div <div
id="map-controls" id="sidebar"
class="absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)] md:left-14 md:top-3 md:max-w-none" 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="bg-base-100/90 shadow rounded-box border border-base-300 p-2 md:p-3 flex flex-col gap-2"> <%!-- 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="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span class="hidden md:inline">North Texas Microwave Society</span> <span>NTMS</span>
<span class="md:hidden">NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Map</div> <div class="font-normal text-xs opacity-70">Propagation Map</div>
</div> </div>
<div class="flex items-center gap-1.5 shrink-0"> <div class="flex items-center gap-1.5 shrink-0">
<div <div
id="utc-clock" id="utc-clock-desktop"
phx-hook="UtcClock" phx-hook="UtcClock"
class="font-mono text-xs opacity-70 tabular-nums" class="font-mono text-xs opacity-70 tabular-nums"
> >
</div> </div>
<button <button
id="mobile-panel-toggle" id="sidebar-collapse"
class="btn btn-xs btn-square btn-ghost md:hidden" class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('panel-extras').classList.toggle('hidden')" 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-bars-3" class="size-4" /> <.icon name="hero-chevron-right" class="size-4" />
</button> </button>
</div> </div>
</div> </div>
<%!-- Band selector (always visible) --%> <%!-- Band selector --%>
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between"> <div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)} {selected_label(@bands, @selected_band)}
@ -313,7 +451,7 @@ defmodule MicrowavepropWeb.MapLive do
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1" class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
> >
<li :for={band <- @bands}> <li :for={band <- @bands}>
<button <button
@ -330,82 +468,78 @@ defmodule MicrowavepropWeb.MapLive do
</ul> </ul>
</div> </div>
<%!-- Secondary controls (collapsed on mobile by default) --%> <%!-- Grid toggle --%>
<div id="panel-extras" class="hidden md:flex flex-col gap-2"> <label class="flex items-center gap-2 cursor-pointer px-1">
<%!-- Grid toggle --%> <input
<label class="flex items-center gap-2 cursor-pointer px-1"> type="checkbox"
<input class="toggle toggle-sm toggle-primary"
type="checkbox" checked={@grid_visible}
class="toggle toggle-sm toggle-primary" phx-click="toggle_grid"
checked={@grid_visible} />
phx-click="toggle_grid" <span class="text-sm">Grid squares</span>
/> </label>
<span class="text-sm">Grid squares</span>
</label>
<%!-- Antenna height --%> <%!-- Antenna height --%>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1"> <form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label> <label class="text-sm whitespace-nowrap">Antenna height</label>
<input <input
type="number" type="number"
name="height_ft" name="height_ft"
min="0" min="0"
max="200" max="200"
step="1" step="1"
value={@antenna_height_ft} value={@antenna_height_ft}
class="input input-xs w-16 text-right" class="input input-xs w-16 text-right"
/> />
<span class="text-xs opacity-70">ft</span> <span class="text-xs opacity-70">ft</span>
</form> </form>
<%!-- Links --%> <%!-- Navigation --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2"> <ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start"> <li>
Path Calculator <.link navigate="/path">Path Calculator</.link>
</.link> </li>
<.link navigate="/rover" class="btn btn-xs btn-ghost justify-start"> <li>
Rover Planner <.link navigate="/rover">Rover Planner</.link>
</.link> </li>
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start"> <li>
Weather Map <.link navigate="/weather">Weather Map</.link>
</.link> </li>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start"> <li>
Scoring Algorithm <.link navigate="/algo">Scoring Algorithm</.link>
</.link> </li>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start"> <li>
Submit a Contact <.link navigate="/submit">Submit a Contact</.link>
</.link> </li>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start"> <li>
Contact Training Data <.link navigate="/contacts">Contact Training Data</.link>
</.link> </li>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start"> <li>
Contact Map <.link navigate="/contacts/map">Contact Map</.link>
</.link> </li>
</div> </ul>
</div>
<%!-- 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> </div>
<%!-- Point detail panel bottom sheet on mobile, sidebar on desktop --%>
<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-3 md:bottom-auto md:left-auto md:right-auto md:max-h-[60vh] md:max-w-sm 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>
</div> </div>
<Layouts.flash_group flash={@flash} /> <Layouts.flash_group flash={@flash} />

View file

@ -46,20 +46,22 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home get "/", PageController, :home
live "/submit", SubmitLive live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/map", MapLive live "/submit", SubmitLive
live "/weather", WeatherMapLive live "/map", MapLive
live "/contacts", ContactLive.Index live "/weather", WeatherMapLive
live "/contacts/map", ContactMapLive live "/contacts", ContactLive.Index
live "/contacts/:id", ContactLive.Show live "/contacts/map", ContactMapLive
live "/path", PathLive live "/contacts/:id", ContactLive.Show
live "/rover", RoverLive live "/path", PathLive
live "/algo", AlgoLive live "/rover", RoverLive
live "/backfill", BackfillLive live "/algo", AlgoLive
live "/backfill", BackfillLive
# Beacons: read-only public views # Beacons: read-only public views
live "/beacons", BeaconLive.Index, :index live "/beacons", BeaconLive.Index, :index
live "/beacons/:id", BeaconLive.Show, :show live "/beacons/:id", BeaconLive.Show, :show
end
# Redirect old /qsos routes # Redirect old /qsos routes
get "/qsos", PageController, :redirect_contacts get "/qsos", PageController, :redirect_contacts