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:
parent
dc0121a520
commit
20ed47397b
2 changed files with 149 additions and 75 deletions
|
|
@ -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;">×</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…</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>`
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue