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:
parent
095289d6f0
commit
688fc6ba95
8 changed files with 486 additions and 5 deletions
|
|
@ -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
237
assets/css/mobile.css
Normal 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;
|
||||
}
|
||||
}
|
||||
177
assets/js/map.ts
177
assets/js/map.ts
|
|
@ -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
|
||||
|
|
|
|||
11
assets/js/types/leaflet-events.d.ts
vendored
11
assets/js/types/leaflet-events.d.ts
vendored
|
|
@ -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;
|
||||
33
assets/js/types/leaflet.d.ts
vendored
33
assets/js/types/leaflet.d.ts
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue