Implement mobile web optimization and fix sidebar clipping

- Add comprehensive mobile viewport meta tags for better mobile experience
- Implement touch gesture support with long-press to show station info
- Create mobile-specific CSS with touch target optimization (44px minimum)
- Add safe area insets support for notched devices
- Position zoom controls at bottom-right on mobile for thumb accessibility
- Optimize map performance on mobile with canvas renderer
- Fix desktop sidebar toggle button clipping when slideover is open
- Improve responsive design for slideover panels and popups
- Add landscape mode and high DPI screen optimizations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2025-07-21 14:31:49 -05:00
parent 095289d6f0
commit 688fc6ba95
No known key found for this signature in database
8 changed files with 486 additions and 5 deletions

View file

@ -3,6 +3,9 @@
@source "../js";
@source "../../lib/aprsme_web";
/* Import mobile-specific styles */
@import "./mobile.css";
/* Vendor CSS files are imported via JavaScript/ESBuild */
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.

237
assets/css/mobile.css Normal file
View file

@ -0,0 +1,237 @@
/* Mobile-specific optimizations for APRS.me */
/* Prevent iOS bounce scrolling on the map */
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Ensure map takes full height on mobile */
@media (max-width: 768px) {
#map {
height: calc(100vh - 56px); /* Account for header */
height: calc(100dvh - 56px); /* Dynamic viewport height for mobile browsers */
touch-action: pan-x pan-y;
}
/* Optimize header for mobile */
.navbar {
padding: 0.5rem;
position: sticky;
top: 0;
z-index: 1000;
}
/* Larger touch targets for mobile controls */
.leaflet-control-zoom a,
.leaflet-control-layers-toggle,
.leaflet-control button {
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
font-size: 20px !important;
}
/* Better popup positioning on mobile */
.leaflet-popup {
bottom: 50px !important;
max-width: 90vw !important;
}
.leaflet-popup-content {
max-height: 50vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Optimize sidebar for mobile */
.map-sidebar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 40vh;
background: white;
border-top: 2px solid #e5e7eb;
transform: translateY(100%);
transition: transform 0.3s ease;
z-index: 1001;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.map-sidebar.open {
transform: translateY(0);
}
/* Mobile-friendly search box */
.search-container {
position: absolute;
top: 10px;
left: 10px;
right: 60px;
z-index: 1000;
}
.search-input {
width: 100%;
padding: 12px 16px;
font-size: 16px; /* Prevent zoom on iOS */
border-radius: 8px;
border: 1px solid #d1d5db;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Mobile-friendly buttons */
.btn, button {
min-height: 44px;
padding: 12px 16px;
font-size: 16px;
}
/* Hide non-essential elements on small screens */
.hide-mobile {
display: none !important;
}
/* Improve marker touch targets */
.leaflet-marker-icon {
margin-left: -12px !important;
margin-top: -41px !important;
}
/* Better clustering on mobile */
.marker-cluster {
width: 48px !important;
height: 48px !important;
margin-left: -24px !important;
margin-top: -24px !important;
}
/* Mobile-friendly trail duration controls */
.trail-controls {
position: fixed;
bottom: 60px;
right: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.trail-controls button {
width: 44px;
height: 44px;
border-radius: 50%;
background: white;
border: 2px solid #e5e7eb;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Improve dropdown menus on mobile */
select {
font-size: 16px; /* Prevent zoom on iOS */
padding: 12px;
min-height: 44px;
}
/* Mobile-friendly tooltips */
.tooltip {
font-size: 14px;
padding: 8px 12px;
}
/* Optimize data tables for mobile */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
font-size: 14px;
}
/* Safe area insets for devices with notches */
.navbar {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-top: env(safe-area-inset-top);
}
#map {
padding-bottom: env(safe-area-inset-bottom);
}
}
/* Landscape mode optimizations */
@media (max-width: 768px) and (orientation: landscape) {
.navbar {
padding: 0.25rem 0.5rem;
}
#map {
height: calc(100vh - 40px);
height: calc(100dvh - 40px);
}
.leaflet-control-zoom {
transform: scale(0.9);
}
}
/* High DPI screen optimizations */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.leaflet-tile {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* Disable hover effects on touch devices */
@media (hover: none) {
.leaflet-interactive:hover {
stroke-opacity: 1 !important;
fill-opacity: 0.2 !important;
}
a:hover, button:hover {
background-color: inherit !important;
}
}
/* Loading spinner for mobile */
.mobile-loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
}
/* Improve form inputs on mobile */
input[type="text"],
input[type="email"],
input[type="search"],
input[type="tel"],
input[type="url"],
input[type="password"],
textarea {
font-size: 16px; /* Prevent zoom on iOS */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* Better focus states for mobile */
:focus {
outline: 3px solid #6366f1;
outline-offset: 2px;
}
/* Optimize animations for mobile performance */
@media (max-width: 768px) {
* {
animation-duration: 0.2s !important;
transition-duration: 0.2s !important;
}
}

View file

@ -8,6 +8,7 @@ import type {
MapEventData
} from './types/map';
import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds, Polyline } from 'leaflet';
import type { LeafletTouchEvent, LeafletPopupEvent } from './types/leaflet-events';
import type { HeatLayer, MarkerClusterGroup, OverlappingMarkerSpiderfier, MarkerClusterGroupOptions, HeatLayerOptions, MarkerCluster, HeatLatLng } from './types/leaflet-plugins';
import type { APRSMarker } from './types/marker-extensions';
import type { BaseEventPayload } from './types/events';
@ -184,11 +185,33 @@ let MapAPRSMap = {
delete self.el._leaflet_id;
}
// Detect if mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
self.map = L.map(self.el, {
zoomControl: true,
zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one
attributionControl: true,
closePopupOnClick: true,
tap: true, // Enable tap events for mobile
tapTolerance: 30, // Increase tap tolerance for mobile
touchZoom: true,
bounceAtZoomLimits: false, // Disable bouncing at zoom limits
worldCopyJump: true, // Better panning behavior
preferCanvas: isMobile, // Use canvas renderer on mobile for better performance
zoomAnimation: !isMobile, // Disable zoom animations on mobile for performance
fadeAnimation: !isMobile, // Disable fade animations on mobile
markerZoomAnimation: !isMobile // Disable marker animations on mobile
}).setView([initialCenter.lat, initialCenter.lng], initialZoom);
// Add mobile-friendly zoom control if on mobile
if (isMobile) {
L.control.zoom({
position: 'bottomright'
}).addTo(self.map);
// Add touch gesture handling
self.setupMobileGestures();
}
} catch (error) {
console.error("Error initializing map:", error);
self.errors!.push(
@ -541,6 +564,81 @@ let MapAPRSMap = {
}
},
setupMobileGestures() {
const self = this as unknown as LiveViewHookContext;
// Long press timer
let longPressTimer: any = null;
let startX = 0;
let startY = 0;
const longPressDuration = 750; // 750ms for long press
const movementThreshold = 10; // pixels
const handleLongPress = (e: TouchEvent) => {
if (e.touches.length !== 1) return; // Only handle single touch
const touch = e.touches[0];
const latlng = self.map!.containerPointToLatLng(L.point(touch.clientX, touch.clientY));
// Find the nearest marker
let nearestMarker: APRSMarker | null = null;
let minDistance = Infinity;
self.markers.forEach((marker) => {
const markerLatLng = marker.getLatLng();
const distance = latlng.distanceTo(markerLatLng);
if (distance < minDistance && distance < 50) { // Within 50 meters
minDistance = distance;
nearestMarker = marker;
}
});
if (nearestMarker) {
nearestMarker.openPopup();
}
};
self.map!.on('touchstart', (e: LeafletTouchEvent) => {
if (e.originalEvent.touches.length !== 1) return;
const touch = e.originalEvent.touches[0];
startX = touch.clientX;
startY = touch.clientY;
longPressTimer = setTimeout(() => {
handleLongPress(e.originalEvent);
}, longPressDuration);
});
self.map!.on('touchmove', (e: LeafletTouchEvent) => {
if (!longPressTimer) return;
const touch = e.originalEvent.touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
// Cancel long press if moved too much
if (deltaX > movementThreshold || deltaY > movementThreshold) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
self.map!.on('touchend', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
self.map!.on('touchcancel', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
},
setupPopupNavigation() {
const self = this as unknown as LiveViewHookContext;
@ -1809,6 +1907,83 @@ let MapAPRSMap = {
// Restore original pushEvent (though it won't be used since we're destroyed)
self.pushEvent = originalPushEvent;
},
setupMobileGestures() {
const self = this as unknown as LiveViewHookContext;
if (!self.map) return;
// Long press to show station info
let longPressTimer: NodeJS.Timeout | null = null;
let touchStartPos: { x: number; y: number } | null = null;
self.map.on('touchstart', (e: LeafletTouchEvent) => {
touchStartPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY };
longPressTimer = setTimeout(() => {
// Get the closest marker to the touch point
const point = e.containerPoint;
let closestMarker: APRSMarker | null = null;
let closestDistance = Infinity;
self.markers.forEach((marker) => {
const markerPoint = self.map.latLngToContainerPoint(marker.getLatLng());
const distance = Math.sqrt(
Math.pow(markerPoint.x - point.x, 2) +
Math.pow(markerPoint.y - point.y, 2)
);
if (distance < closestDistance && distance < 50) { // 50px tolerance
closestDistance = distance;
closestMarker = marker;
}
});
if (closestMarker) {
closestMarker.openPopup();
}
}, 600); // 600ms for long press
});
self.map.on('touchmove', (e: LeafletTouchEvent) => {
if (longPressTimer && touchStartPos) {
const moveThreshold = 10; // pixels
const currentPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY };
const distance = Math.sqrt(
Math.pow(currentPos.x - touchStartPos.x, 2) +
Math.pow(currentPos.y - touchStartPos.y, 2)
);
if (distance > moveThreshold) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
});
self.map.on('touchend touchcancel', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
touchStartPos = null;
});
// Improve popup behavior on mobile
self.map.on('popupopen', (e: LeafletPopupEvent) => {
// Ensure popup is visible on mobile
const popup = e.popup;
const px = self.map.project(popup.getLatLng());
const popupHeight = popup.getElement()?.offsetHeight || 200;
const mapHeight = self.map.getContainer().offsetHeight;
// If popup would be cut off at bottom, pan the map
const containerPoint = self.map.latLngToContainerPoint(popup.getLatLng());
if (containerPoint.y + popupHeight > mapHeight - 50) {
px.y -= (popupHeight / 2);
self.map.panTo(self.map.unproject(px), { animate: true });
}
});
},
};
// Helper to validate and fallback symbol code per aprs.fi logic

View file

@ -40,4 +40,15 @@ export interface LeafletZoomAnimEvent extends LeafletEvent {
noUpdate: boolean;
}
export interface LeafletTouchEvent extends LeafletEvent {
latlng: LatLng;
layerPoint: Point;
containerPoint: Point;
originalEvent: TouchEvent;
}
export interface LeafletPopupEvent extends LeafletEvent {
popup: L.Popup;
}
export type LeafletEventHandlerFn = (event: LeafletEvent) => void;

View file

@ -9,12 +9,26 @@ declare namespace L {
invalidateSize(options?: { animate?: boolean; pan?: boolean }): this;
whenReady(callback: () => void): this;
on(event: string, handler: (e: import('./leaflet-events').LeafletEvent) => void): this;
getContainer(): HTMLElement;
project(latlng: LatLngExpression, zoom?: number): Point;
unproject(point: PointExpression, zoom?: number): LatLng;
latLngToContainerPoint(latlng: LatLngExpression): Point;
panTo(latlng: LatLngExpression, options?: PanOptions): this;
}
interface MapOptions {
zoomControl?: boolean;
attributionControl?: boolean;
closePopupOnClick?: boolean;
tap?: boolean;
tapTolerance?: number;
touchZoom?: boolean;
bounceAtZoomLimits?: boolean;
worldCopyJump?: boolean;
preferCanvas?: boolean;
zoomAnimation?: boolean;
fadeAnimation?: boolean;
markerZoomAnimation?: boolean;
}
interface ZoomOptions {
@ -22,6 +36,13 @@ declare namespace L {
duration?: number;
}
interface PanOptions {
animate?: boolean;
duration?: number;
easeLinearity?: number;
noMoveStart?: boolean;
}
class LatLng {
lat: number;
lng: number;
@ -29,6 +50,18 @@ declare namespace L {
}
type LatLngExpression = LatLng | [number, number];
type PointExpression = Point | [number, number];
class Point {
x: number;
y: number;
constructor(x: number, y: number);
}
class Popup {
getLatLng(): LatLng;
getElement(): HTMLElement | undefined;
}
class LatLngBounds {
constructor(southWest: LatLngExpression, northEast: LatLngExpression);

View file

@ -94,7 +94,7 @@
padding: 10px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
@ -111,10 +111,10 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Hide toggle on desktop when slideover is open */
/* Adjust toggle position on desktop when slideover is open */
@media (min-width: 1024px) {
.slideover-toggle.slideover-open {
display: none;
right: 362px; /* 352px slideover width + 10px gap */
}
}

View file

@ -2,8 +2,16 @@
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover"
/>
<meta name="csrf-token" content={get_csrf_token()} />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#6366f1" />
<meta name="format-detection" content="telephone=no" />
<script>
(function() {
try {

View file

@ -803,6 +803,20 @@ defmodule AprsmeWeb.MapLive.Index do
#aprs-map {
right: 0 !important;
}
.slideover-panel {
width: 90vw !important;
max-width: 400px;
}
.locate-button {
top: 65px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
.locate-button {