Improve map page mobile usability

Collapsible control panel with hamburger toggle, detail panel as
bottom sheet, compact legend moved to top-right, and scaled timeline
buttons for narrow viewports.
This commit is contained in:
Graham McIntire 2026-04-02 12:59:55 -05:00
parent dc0121a520
commit 20ed47397b
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 149 additions and 75 deletions

View file

@ -190,9 +190,24 @@ function convexHull(points) {
return lower.concat(upper)
}
function isMobile() {
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() {
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>`
}
function buildLoadingHTML(detail) {
const tier = scoreTier(detail.score)
return `<div style="min-width:260px;">
return `<div style="position:relative;">
${mobileHandleHTML()}${closeButtonHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
@ -207,7 +222,7 @@ function buildForecastSvg(forecast) {
if (!forecast || forecast.length < 2) return ""
const marginL = 28, marginR = 6, marginT = 4, marginB = 16
const w = 260, h = 72
const w = isMobile() ? Math.min(260, window.innerWidth - 48) : 260, h = 72
const plotW = w - marginL - marginR
const plotH = h - marginT - marginB
const n = forecast.length
@ -305,7 +320,11 @@ function buildPopupHTML(detail, viewshedLoading) {
? `<div style="padding:4px 10px 6px;font-size:11px;opacity:0.6;border-top:1px solid rgba(255,255,255,0.15);display:flex;align-items:center;gap:6px;"><span class="loading loading-spinner loading-xs"></span> Computing terrain coverage&hellip;</div>`
: ""
return `<div style="min-width:260px;">
const mobile = isMobile()
const minW = mobile ? "auto" : "260px"
return `<div style="min-width:${minW};position:relative;">
${mobileHandleHTML()}${closeButtonHTML()}
<div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:baseline;">
<strong style="font-size:16px;">${detail.score}/100</strong>
<span style="font-size:13px;font-weight:700;">${tier.label}</span>
@ -550,13 +569,19 @@ export const PropagationMap = {
const merged = { ...basic, ...this.bandInfo, factors: null }
this.detailPanel.innerHTML = buildLoadingHTML(merged)
this.detailPanel.style.display = "block"
this.positionDetailPanel()
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
}
})
// Detail panel below sidebar instead of map popup
// Detail panel — bottom sheet on mobile, positioned next to sidebar on desktop
this.detailPanel = document.getElementById("detail-panel")
this.viewshedLoading = false
if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel)
}
this.lastDetail = null
this.handleEvent("point_detail", (detail) => {
@ -565,6 +590,7 @@ export const PropagationMap = {
this.lastDetail = merged
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
this.detailPanel.style.display = "block"
this.positionDetailPanel()
// Draw propagation reach polygon based on contiguous good cells
if (this.gridLookup && this.clickedLatLng) {
@ -679,27 +705,46 @@ export const PropagationMap = {
}
})
// Legend
const legend = L.control({ position: "bottomright" })
// Legend — compact on mobile, moved to top-right to avoid timeline overlap
const legend = L.control({ position: isMobile() ? "topright" : "bottomright" })
legend.onAdd = () => {
const div = L.DomUtil.create("div")
div.style.cssText = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
const mobile = isMobile()
const rows = [
["#00ffa3", "Excellent (80-100)"],
["#7dffd4", "Good (65-79)"],
["#ffe566", "Marginal (50-64)"],
["#ff9044", "Poor (33-49)"],
["#ff4f4f", "Negligible (0-32)"]
["#00ffa3", "Excellent", "80+"],
["#7dffd4", "Good", "65"],
["#ffe566", "Marginal", "50"],
["#ff9044", "Poor", "33"],
["#ff4f4f", "Negligible", "0"]
]
div.innerHTML = "<strong style='color:#333;'>Propagation</strong><br>" +
rows.map(([c, label]) =>
`<span style="background:${c};width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label}</span>`
if (mobile) {
div.style.cssText = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
div.innerHTML = rows.map(([c, , score]) =>
`<span style="background:${c};width:10px;height:10px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:3px;border:1px solid rgba(0,0,0,0.15);"></span>${score}`
).join("<br>")
} else {
div.style.cssText = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
div.innerHTML = "<strong style='color:#333;'>Propagation</strong><br>" +
rows.map(([c, label, score]) =>
`<span style="background:${c};width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})</span>`
).join("<br>")
}
return div
}
legend.addTo(this.map)
},
positionDetailPanel() {
if (!this.detailPanel || isMobile()) return
// On desktop, position the panel below the control panel
const controls = document.getElementById("map-controls")
if (controls) {
const rect = controls.getBoundingClientRect()
this.detailPanel.style.left = rect.left + "px"
this.detailPanel.style.top = (rect.bottom + 8) + "px"
}
},
renderScores(scores) {
this.scores = scores
if (this.scoreOverlay) {
@ -879,6 +924,12 @@ export const PropagationMap = {
}
})
const mobile = isMobile()
const btnPad = mobile ? "3px 5px" : "4px 8px"
const btnMinW = mobile ? "32px" : "38px"
const btnFont = mobile ? "10px" : "11px"
const subFont = mobile ? "8px" : "9px"
const buttons = items.map(t => {
const bg = t.isSelected ? "#00ffa3" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff")
@ -886,17 +937,19 @@ export const PropagationMap = {
const weight = t.isSelected ? "700" : "400"
const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00`
return `<button data-time="${t.time}" style="
padding:4px 8px;font-size:11px;font-weight:${weight};
padding:${btnPad};font-size:${btnFont};font-weight:${weight};
background:${bg};color:${fg};border:${border};border-radius:6px;
cursor:pointer;white-space:nowrap;min-width:38px;
cursor:pointer;white-space:nowrap;min-width:${btnMinW};
transition:background 0.15s;
">${t.label}<br><span style="font-size:9px;opacity:0.7;">${utcLabel}</span></button>`
">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
}).join("")
const labelText = mobile ? "Forecast" : "Propagation Forecast"
this.timelineEl.style.display = "block"
this.timelineEl.innerHTML = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 10px;display:flex;gap:3px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:90vw;">
<span style="font-size:10px;color:rgba(255,255,255,0.5);margin-right:4px;white-space:nowrap;">Propagation Forecast</span>
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);margin-right:4px;white-space:nowrap;">${labelText}</span>
${buttons}
</div>`

View file

@ -248,7 +248,7 @@ defmodule MicrowavepropWeb.MapLive do
@impl true
def render(assigns) do
~H"""
<div class="relative w-screen h-screen">
<div class="relative w-screen h-screen overflow-hidden">
<%!-- Full-page map --%>
<div
id="propagation-map"
@ -269,18 +269,32 @@ defmodule MicrowavepropWeb.MapLive do
</div>
<%!-- Top-left control panel --%>
<div id="map-controls" class="absolute top-3 left-14 z-[1000] flex flex-col gap-2">
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-3 flex flex-col gap-2">
<div class="font-bold text-sm leading-tight px-1 flex items-baseline justify-between gap-3">
<div>
North Texas Microwave Society
<div id="map-controls" 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">
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-2 md:p-3 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 class="hidden md:inline">North Texas Microwave Society</span>
<span class="md:hidden">NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Map</div>
</div>
<div id="utc-clock" phx-hook="UtcClock" class="font-mono text-xs opacity-70 tabular-nums">
<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 md:hidden"
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<%!-- Band selector --%>
<%!-- Band selector (always visible) --%>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
@ -305,64 +319,71 @@ defmodule MicrowavepropWeb.MapLive do
</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>
<%!-- Secondary controls (collapsed on mobile by default) --%>
<div id="panel-extras" class="hidden md:flex flex-col gap-2">
<%!-- 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>
<%!-- 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>
<%!-- 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>
<%!-- Links --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-calculator" class="size-3.5" /> Scoring Algorithm
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-map" class="size-3.5" /> Contact Map
</.link>
<%!-- Links --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-calculator" class="size-3.5" /> Scoring Algorithm
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-map" class="size-3.5" /> Contact Map
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail panel (populated by JS on click, ignored by LiveView patches) --%>
<div
id="detail-panel"
phx-update="ignore"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 overflow-hidden"
style="display:none;"
>
</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-4 left-1/2 -translate-x-1/2 z-[1000]"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
</div>