feat: mobile map de-cramp — collapsible Layers menu, feed chip, coverage details expander

- MobileControls: stacked top panel replaced with a floating bar (Layers menu
  toggle + Live feed chip) and a collapsible menu sheet: layers grid, view
  modes, LOS tools, node search, watchlist. Closes on Esc/outside tap.
- App: mobileFeedOpen state drives data-feed-open so the packet feed stays
  hidden on mobile until the Live chip is pressed (desktop unchanged).
- RfCoverageStatus: details dl + note wrap behind a Show/Hide toggle on
  mobile only; gradient + tier buttons stay visible.
- map-app.css: new ≤640px block (floating bar, menu sheet, feed gating,
  coverage details); removed superseded .mobile-controls/__primary rules.
This commit is contained in:
gadgethd committed 2026-08-12 13:16:21 +00:00
1 parent 9da23d0200
commit efa746d7bf
4 files changed
+423 -148

No files matched your search

+4 -1
View File
@@ -115,6 +115,7 @@ export const App: React.FC = () => {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => new URLSearchParams(window.location.search).get('node'));
const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(() => !!new URLSearchParams(window.location.search).get('node'));
const [fullScreenMap, setFullScreenMap] = useState(false);
const [mobileFeedOpen, setMobileFeedOpen] = useState(false);
const [highContrast, setHighContrast] = useState(() => localStorage.getItem('meshcore-contrast') === 'high');
// MapLibre map instance — used by MobileControls/NodeSearch for flyTo
const [mlMap, setMlMap] = useState<maplibregl.Map | null>(null);
@@ -524,7 +525,7 @@ export const App: React.FC = () => {
}, [handleShare]);
return (
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined} data-mobile-fullscreen={fullScreenMap ? 'true' : undefined}>
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined} data-mobile-fullscreen={fullScreenMap ? 'true' : undefined} data-feed-open={mobileFeedOpen ? 'true' : undefined}>
<AppTopBar
homeUrl={site.appHomeUrl}
wsState={wsState}
@@ -553,6 +554,8 @@ export const App: React.FC = () => {
onNodeSelect={setSelectedNodeId}
fullScreenMap={fullScreenMap}
onToggleFullScreenMap={() => setFullScreenMap((value) => !value)}
feedOpen={mobileFeedOpen}
onToggleFeed={() => setMobileFeedOpen((value) => !value)}
/>
<div className="map-layer">
@@ -1,3 +1,5 @@
import { useState } from 'react';
import {
rfNodeCoverageState,
type RfCoverageMeta,
@@ -38,6 +40,7 @@ export function RfCoverageStatus({
onClearNode?: () => void;
visible: boolean;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
if (!visible) return null;
const nodeEntry = nodePublicKey ? meta?.node_coverage?.[nodePublicKey.toLowerCase()] : undefined;
const product = nodePublicKey ? nodeEntry?.standard : meta?.coverage?.[tier];
@@ -94,6 +97,7 @@ export function RfCoverageStatus({
<div className="rf-coverage-gradient" aria-hidden="true" />
<div className="rf-coverage-gradient-labels"><span>Below threshold</span><span>Strong margin</span></div>
{product ? (
<div className={`rf-coverage-details-wrap${detailsOpen ? ' rf-coverage-details-wrap--open' : ''}`}>
<dl className="rf-coverage-details">
<div><dt>Frequency</dt><dd>{product.frequency_mhz.toFixed(3)} MHz</dd></div>
<div><dt>Generated</dt><dd>{new Date(product.generated_at ?? meta?.generated_at ?? '').toLocaleString()}</dd></div>
@@ -101,6 +105,16 @@ export function RfCoverageStatus({
<div><dt>Source</dt><dd>{meta?.run?.source_version ?? meta?.version ?? 'unknown'}</dd></div>
{nodePublicKey && <div><dt>Status</dt><dd>{nodeState === 'pending' ? 'Pending' : nodeState === 'stale' ? 'Stale' : 'Available'}</dd></div>}
</dl>
{product?.assumptions?.note && <p className="rf-coverage-note">{product.assumptions.note}</p>}
<button
type="button"
className="rf-coverage-details-toggle"
onClick={() => setDetailsOpen((value) => !value)}
aria-expanded={detailsOpen}
>
{detailsOpen ? 'Hide details ▴' : 'Show details ▾'}
</button>
</div>
) : (
<p className="rf-coverage-waiting">
{nodePublicKey
@@ -110,7 +124,6 @@ export function RfCoverageStatus({
: 'Standard coverage is being prepared.'}
</p>
)}
{product?.assumptions?.note && <p className="rf-coverage-note">{product.assumptions.note}</p>}
</div>
</section>
);
+180 -98
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type * as maplibregl from 'maplibre-gl';
import { NodeSearch } from '../Map/NodeSearch.js';
import { visibleFilterRows, type Filters } from '../FilterPanel/FilterPanel.js';
@@ -21,6 +21,8 @@ type MobileControlsProps = {
onNodeSelect: (nodeId: string) => void;
fullScreenMap: boolean;
onToggleFullScreenMap: () => void;
feedOpen: boolean;
onToggleFeed: () => void;
};
export const MobileControls: React.FC<MobileControlsProps> = ({
@@ -37,123 +39,203 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
onNodeSelect,
fullScreenMap,
onToggleFullScreenMap,
feedOpen,
onToggleFeed,
}) => {
const [showFilters, setShowFilters] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const customLosMode = useOverlayStore((state) => state.customLosMode);
const planRepeaterMode = useOverlayStore((state) => state.planRepeaterMode);
const setCustomLosMode = useOverlayStore((state) => state.setCustomLosMode);
const clearCustomLos = useOverlayStore((state) => state.clearCustomLos);
const setPlanRepeaterMode = useOverlayStore((state) => state.setPlanRepeaterMode);
return (
<div className="mobile-controls">
<div className={`mobile-controls__primary${fullScreenMap ? ' mobile-controls__primary--fullscreen' : ''}`}>
{!fullScreenMap && <MapModeSelector
activeMode={activeMode}
viewshedEnabled={viewshedEnabled}
onChange={onModeChange}
onShare={onShare}
shareLabel={shareLabel}
/>}
// Close the menu on Escape or on any tap outside the control cluster.
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (rootRef.current && target && !rootRef.current.contains(target)) {
setMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [menuOpen]);
const filterRows = visibleFilterRows(viewshedEnabled, heatmapEnabled, rfCoverageEnabled);
const activeFilterCount = filterRows.filter(({ key }) => filters[key]).length;
if (fullScreenMap) {
return (
<div className="mobile-controls mobile-controls--fullscreen" ref={rootRef}>
<button
type="button"
className="mobile-fullscreen-toggle"
onClick={onToggleFullScreenMap}
aria-pressed={fullScreenMap}
>
{fullScreenMap ? 'Restore panels' : 'Full-screen map'}
Restore panels
</button>
</div>
{!fullScreenMap && (
<div className="mobile-map-tools" aria-label="Map planning tools">
<button
type="button"
className={customLosMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
aria-pressed={customLosMode}
onClick={() => {
if (customLosMode) clearCustomLos();
else {
setPlanRepeaterMode(false);
setCustomLosMode(true);
}
}}
>
Line of sight
</button>
{viewshedEnabled && (
);
}
return (
<div className="mobile-controls" ref={rootRef}>
<div className="mobile-controls__bar">
<button
type="button"
className="mobile-menu-toggle"
onClick={() => setMenuOpen((value) => !value)}
aria-expanded={menuOpen}
aria-haspopup="dialog"
aria-controls="mobile-map-menu"
>
<span className="mobile-menu-toggle__icon" aria-hidden="true">☰</span>
<span className="mobile-menu-toggle__label">Layers</span>
{activeFilterCount > 0 && (
<span className="mobile-menu-toggle__badge">{activeFilterCount}</span>
)}
</button>
<button
type="button"
className="mobile-feed-toggle"
onClick={onToggleFeed}
aria-pressed={feedOpen && filters.livePackets}
>
<span aria-hidden="true">▤</span>
Live
</button>
</div>
{menuOpen && (
<div className="mobile-menu" id="mobile-map-menu" role="dialog" aria-label="Map settings">
<div className="mobile-menu__head">
<span className="mobile-menu__title">Map settings</span>
<button
type="button"
className={planRepeaterMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
aria-pressed={planRepeaterMode}
onClick={() => {
if (planRepeaterMode) setPlanRepeaterMode(false);
else {
clearCustomLos();
setPlanRepeaterMode(true);
}
}}
className="mobile-menu__close"
onClick={() => setMenuOpen(false)}
aria-label="Close menu"
>
Plan repeater
✕
</button>
)}
</div>
<div className="mobile-menu__body">
<section className="mobile-menu__section" aria-label="Layers">
<h3 className="mobile-menu__heading">Layers</h3>
<div className="mobile-filter-grid">
{filterRows.map(({ key, label, color, hollow }) => (
<button
type="button"
key={key}
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
aria-pressed={!!filters[key]}
>
<span className="filter-row__label">
{hollow ? (
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
) : (
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
)}
{label}
</span>
<span
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
/>
</button>
))}
</div>
{filters.hexClashes && (
<div className="filter-slider" style={{ margin: '10px 0 0' }}>
<label className="filter-slider__label" htmlFor="mobile-hex-clash-hops">
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
</label>
<input
id="mobile-hex-clash-hops"
className="filter-slider__input"
type="range"
min={0}
max={3}
step={1}
value={Math.round(filters.hexClashMaxHops)}
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
/>
</div>
)}
</section>
<section className="mobile-menu__section" aria-label="View">
<h3 className="mobile-menu__heading">View</h3>
<MapModeSelector
activeMode={activeMode}
viewshedEnabled={viewshedEnabled}
onChange={onModeChange}
onShare={onShare}
shareLabel={shareLabel}
/>
<button
type="button"
className="mobile-fullscreen-toggle"
onClick={onToggleFullScreenMap}
>
Full-screen map
</button>
</section>
<section className="mobile-menu__section" aria-label="Map planning tools">
<h3 className="mobile-menu__heading">Tools</h3>
<div className="mobile-map-tools">
<button
type="button"
className={customLosMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
aria-pressed={customLosMode}
onClick={() => {
if (customLosMode) clearCustomLos();
else {
setPlanRepeaterMode(false);
setCustomLosMode(true);
}
}}
>
Line of sight
</button>
{viewshedEnabled && (
<button
type="button"
className={planRepeaterMode ? 'mobile-map-tools__button mobile-map-tools__button--active' : 'mobile-map-tools__button'}
aria-pressed={planRepeaterMode}
onClick={() => {
if (planRepeaterMode) setPlanRepeaterMode(false);
else {
clearCustomLos();
setPlanRepeaterMode(true);
}
}}
>
Plan repeater
</button>
)}
</div>
</section>
<section className="mobile-menu__section" aria-label="Find a node">
<h3 className="mobile-menu__heading">Find</h3>
<div className="mobile-search">
<NodeSearch map={map} onNodeSelect={onNodeSelect} />
</div>
</section>
<section className="mobile-menu__section" aria-label="Watchlist">
<WatchlistPanel />
</section>
</div>
</div>
)}
{!fullScreenMap && <button
type="button"
className="mobile-legend-toggle"
onClick={() => setShowFilters((v) => !v)}
aria-expanded={showFilters}
>
<span>Layers</span>
<span>{showFilters ? 'Hide' : 'Show'}</span>
</button>}
{!fullScreenMap && <div className={`mobile-filter-wrap${showFilters ? '' : ' mobile-filter-wrap--hidden'}`}>
<div className="mobile-filter-grid">
{visibleFilterRows(viewshedEnabled, heatmapEnabled, rfCoverageEnabled).map(({ key, label, color, hollow }) => (
<button
type="button"
key={key}
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
aria-pressed={!!filters[key]}
>
<span className="filter-row__label">
{hollow ? (
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
) : (
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
)}
{label}
</span>
<span
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
/>
</button>
))}
</div>
{filters.hexClashes && (
<div className="filter-slider" style={{ margin: '0 8px 8px' }}>
<label className="filter-slider__label" htmlFor="mobile-hex-clash-hops">
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
</label>
<input
id="mobile-hex-clash-hops"
className="filter-slider__input"
type="range"
min={0}
max={3}
step={1}
value={Math.round(filters.hexClashMaxHops)}
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
/>
</div>
)}
</div>}
{!fullScreenMap && <div className="mobile-search">
<NodeSearch map={map} onNodeSelect={onNodeSelect} />
</div>}
{!fullScreenMap && <WatchlistPanel />}
</div>
);
};
+225 -48
View File
@@ -1736,60 +1736,12 @@
}
@media (max-width: 640px) {
.filter-panel { display: none; }
.mobile-controls {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
min-height: 0;
max-height: min(34dvh, 240px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.rf-coverage-status {
left: 8px;
bottom: 8px;
width: min(330px, calc(100% - 16px));
}
.rf-coverage-details { grid-template-columns: 1fr 1fr; }
.mobile-controls__primary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
min-width: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.mobile-controls__primary .map-modes {
display: contents;
}
.mobile-controls__primary .map-modes__buttons {
grid-column: 1 / -1;
min-width: 0;
}
.mobile-controls__primary .map-modes__button {
min-width: 0;
margin: 0;
}
.mobile-controls__primary .map-modes__share {
grid-column: 1;
min-width: 0;
margin: 0;
}
.mobile-controls__primary .mobile-fullscreen-toggle {
grid-column: 2;
min-width: 0;
margin: 0;
padding: 7px 8px;
}
.mobile-controls__primary--fullscreen {
grid-template-columns: minmax(0, 1fr);
}
.mobile-controls__primary--fullscreen .mobile-fullscreen-toggle {
grid-column: 1;
}
.mobile-map-tools {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2028,3 +1980,228 @@
bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
}
/* ═══ Mobile map menu — de-cramp pass (2026-08-12) ═════════════════════════
Appended after the earlier responsive blocks so these rules win the
cascade. Only active ≤640px. Desktop layout untouched. */
.rf-coverage-details-toggle {
display: none;
}
@media (max-width: 640px) {
.mobile-controls {
position: fixed;
top: 56px;
left: 8px;
z-index: 900;
display: flex;
flex-direction: column;
align-items: flex-start;
width: auto;
min-width: 0;
min-height: 0;
max-width: calc(100vw - 16px);
max-height: none;
overflow: visible;
overscroll-behavior: contain;
background: none;
border: 0;
border-bottom: 0;
}
.mobile-controls--fullscreen {
top: 56px;
right: 8px;
left: auto;
}
.mobile-controls__bar {
display: flex;
align-items: center;
gap: 8px;
}
.mobile-menu-toggle,
.mobile-feed-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 44px;
padding: 8px 13px;
border: 1px solid var(--border-bright);
border-radius: var(--radius-md);
background: rgba(8, 13, 20, 0.92);
color: var(--text-primary);
font: 700 11px var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.mobile-menu-toggle[aria-expanded='true'] {
border-color: var(--accent);
color: var(--accent);
}
.mobile-feed-toggle[aria-pressed='true'] {
border-color: var(--accent);
color: var(--accent);
background: rgba(0, 196, 255, 0.14);
}
.mobile-menu-toggle__icon {
font-size: 14px;
line-height: 1;
}
.mobile-menu-toggle__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: var(--bg-base);
font-size: 10px;
font-weight: 700;
}
.mobile-menu {
position: fixed;
top: 108px;
left: 8px;
z-index: 950;
width: min(350px, calc(100vw - 16px));
max-height: calc(100dvh - 124px);
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--border-bright);
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5);
}
.mobile-menu__head {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.mobile-menu__title {
color: var(--text-primary);
font: 700 11px var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mobile-menu__close {
padding: 4px 8px;
border: 0;
background: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
}
.mobile-menu__close:hover,
.mobile-menu__close:focus-visible {
color: var(--accent);
}
.mobile-menu__section {
padding: 10px 14px 12px;
border-top: 1px solid var(--border);
}
.mobile-menu__section:first-of-type {
border-top: 0;
}
.mobile-menu__heading {
margin: 0 0 9px;
color: var(--text-secondary);
font: 700 10px var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mobile-menu .mobile-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.mobile-menu .mobile-filter-grid .filter-row {
min-height: 44px;
padding: 9px 8px;
}
.mobile-menu .map-modes {
display: block;
margin: 0;
}
.mobile-menu .map-modes__buttons {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.mobile-menu .map-modes__button {
min-height: 40px;
margin: 0;
}
.mobile-menu .map-modes__share {
width: 100%;
margin: 8px 0 0;
}
.mobile-menu .mobile-fullscreen-toggle {
width: 100%;
margin-top: 8px;
padding: 9px 10px;
border: 1px solid var(--accent);
border-radius: var(--radius-md);
background: rgba(8, 13, 20, 0.92);
color: var(--text-primary);
font: 11px var(--font-mono);
}
.mobile-menu .mobile-map-tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 0;
border: 0;
}
.mobile-menu .mobile-map-tools__button {
min-height: 44px;
padding: 9px 8px;
border: 1px solid var(--border-bright);
border-radius: var(--radius);
background: var(--bg-panel-alt);
color: var(--text-primary);
font: 11px var(--font-mono);
}
.mobile-menu .mobile-search {
display: block;
padding: 0;
border: 0;
}
/* Packet feed stays hidden on mobile until the Live chip is pressed. */
.app-shell:not([data-feed-open='true']) .packet-feed {
display: none;
}
/* Coverage details collapse behind the toggle on mobile. */
.rf-coverage-details-wrap:not(.rf-coverage-details-wrap--open)
:is(.rf-coverage-details, .rf-coverage-note) {
display: none;
}
.rf-coverage-details-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--border-bright);
border-radius: var(--radius);
background: var(--bg-panel-alt);
color: var(--text-secondary);
font: 10px var(--font-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.rf-coverage-details-toggle:hover,
.rf-coverage-details-toggle:focus-visible {
color: var(--accent);
border-color: var(--accent);
}
}