diff --git a/assets/css/app.css b/assets/css/app.css index 3499432..4c78e83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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. diff --git a/assets/css/mobile.css b/assets/css/mobile.css new file mode 100644 index 0000000..ec9344f --- /dev/null +++ b/assets/css/mobile.css @@ -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; + } +} \ No newline at end of file diff --git a/assets/js/map.ts b/assets/js/map.ts index afe172b..af70312 100644 --- a/assets/js/map.ts +++ b/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 diff --git a/assets/js/types/leaflet-events.d.ts b/assets/js/types/leaflet-events.d.ts index 76d1dfb..54ba216 100644 --- a/assets/js/types/leaflet-events.d.ts +++ b/assets/js/types/leaflet-events.d.ts @@ -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; \ No newline at end of file diff --git a/assets/js/types/leaflet.d.ts b/assets/js/types/leaflet.d.ts index 1febd5a..1f124d1 100644 --- a/assets/js/types/leaflet.d.ts +++ b/assets/js/types/leaflet.d.ts @@ -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); diff --git a/lib/aprsme_web/components/layouts/map.html.heex b/lib/aprsme_web/components/layouts/map.html.heex index 9376e9a..51acc5e 100644 --- a/lib/aprsme_web/components/layouts/map.html.heex +++ b/lib/aprsme_web/components/layouts/map.html.heex @@ -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 */ } } diff --git a/lib/aprsme_web/components/layouts/root.html.heex b/lib/aprsme_web/components/layouts/root.html.heex index a8ec42d..306f775 100644 --- a/lib/aprsme_web/components/layouts/root.html.heex +++ b/lib/aprsme_web/components/layouts/root.html.heex @@ -2,8 +2,16 @@ - + + + + + +