/** * ThrillWiki Mobile Touch Support - Enhanced Mobile and Touch Experience * * This module provides mobile-optimized interactions, touch-friendly controls, * responsive map sizing, and battery-conscious features for mobile devices */ class MobileTouchSupport { constructor(options = {}) { this.options = { enableTouchOptimizations: true, enableSwipeGestures: true, enablePinchZoom: true, enableResponsiveResize: true, enableBatteryOptimization: true, touchDebounceDelay: 150, swipeThreshold: 50, swipeVelocityThreshold: 0.3, maxTouchPoints: 2, orientationChangeDelay: 300, ...options }; this.isMobile = this.detectMobileDevice(); this.isTouch = this.detectTouchSupport(); this.orientation = this.getOrientation(); this.mapInstances = new Set(); this.touchHandlers = new Map(); this.gestureState = { isActive: false, startDistance: 0, startCenter: null, lastTouchTime: 0 }; this.init(); } /** * Initialize mobile touch support */ init() { if (!this.isTouch && !this.isMobile) { console.log('Mobile touch support not needed for this device'); return; } this.setupTouchOptimizations(); this.setupSwipeGestures(); this.setupResponsiveHandling(); this.setupBatteryOptimization(); this.setupAccessibilityEnhancements(); this.bindEventHandlers(); console.log('Mobile touch support initialized'); } /** * Detect if device is mobile */ detectMobileDevice() { const userAgent = navigator.userAgent.toLowerCase(); const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; return mobileKeywords.some(keyword => userAgent.includes(keyword)) || window.innerWidth <= 768 || (typeof window.orientation !== 'undefined'); } /** * Detect touch support */ detectTouchSupport() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; } /** * Get current orientation */ getOrientation() { if (screen.orientation) { return screen.orientation.angle; } else if (window.orientation !== undefined) { return window.orientation; } return window.innerWidth > window.innerHeight ? 90 : 0; } /** * Setup touch optimizations */ setupTouchOptimizations() { if (!this.options.enableTouchOptimizations) return; // Add touch-optimized styles this.addTouchStyles(); // Enhance touch targets this.enhanceTouchTargets(); // Optimize scroll behavior this.optimizeScrollBehavior(); // Setup touch feedback this.setupTouchFeedback(); } /** * Add touch-optimized CSS styles */ addTouchStyles() { if (document.getElementById('mobile-touch-styles')) return; const styles = ` `; document.head.insertAdjacentHTML('beforeend', styles); } /** * Enhance touch targets for better accessibility */ enhanceTouchTargets() { const smallTargets = document.querySelectorAll('button, .btn, a, input[type="checkbox"], input[type="radio"]'); smallTargets.forEach(target => { const rect = target.getBoundingClientRect(); // If target is smaller than 44px (Apple's recommended minimum), enhance it if (rect.width < 44 || rect.height < 44) { target.classList.add('touch-enhanced'); target.style.minWidth = '44px'; target.style.minHeight = '44px'; target.style.display = 'inline-flex'; target.style.alignItems = 'center'; target.style.justifyContent = 'center'; } }); } /** * Optimize scroll behavior for mobile */ optimizeScrollBehavior() { // Add momentum scrolling to scrollable elements const scrollableElements = document.querySelectorAll('.scrollable, .overflow-auto, .overflow-y-auto'); scrollableElements.forEach(element => { element.classList.add('touch-scroll'); element.style.webkitOverflowScrolling = 'touch'; }); // Prevent body scroll when interacting with maps document.addEventListener('touchstart', (e) => { if (e.target.closest('.leaflet-container')) { e.preventDefault(); } }, { passive: false }); } /** * Setup touch feedback for interactive elements */ setupTouchFeedback() { const interactiveElements = document.querySelectorAll('button, .btn, .filter-chip, .filter-pill, .park-item'); interactiveElements.forEach(element => { element.classList.add('touch-feedback'); element.addEventListener('touchstart', (e) => { element.classList.add('active'); setTimeout(() => { element.classList.remove('active'); }, 300); }, { passive: true }); }); } /** * Setup swipe gesture support */ setupSwipeGestures() { if (!this.options.enableSwipeGestures) return; let touchStartX = 0; let touchStartY = 0; let touchStartTime = 0; document.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchStartTime = Date.now(); } }, { passive: true }); document.addEventListener('touchend', (e) => { if (e.changedTouches.length === 1) { const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const touchEndTime = Date.now(); const deltaX = touchEndX - touchStartX; const deltaY = touchEndY - touchStartY; const deltaTime = touchEndTime - touchStartTime; const velocity = Math.abs(deltaX) / deltaTime; // Check if this is a swipe gesture if (Math.abs(deltaX) > this.options.swipeThreshold && Math.abs(deltaY) < Math.abs(deltaX) && velocity > this.options.swipeVelocityThreshold) { const direction = deltaX > 0 ? 'right' : 'left'; this.handleSwipeGesture(direction, e.target); } } }, { passive: true }); } /** * Handle swipe gestures */ handleSwipeGesture(direction, target) { // Handle swipe on filter panels if (target.closest('.filter-panel')) { if (direction === 'down' || direction === 'up') { this.toggleFilterPanel(); } } // Handle swipe on road trip list if (target.closest('.parks-list')) { if (direction === 'left') { this.showParkActions(target); } else if (direction === 'right') { this.hideParkActions(target); } } // Emit custom swipe event const swipeEvent = new CustomEvent('swipe', { detail: { direction, target } }); document.dispatchEvent(swipeEvent); } /** * Setup responsive handling for orientation changes */ setupResponsiveHandling() { if (!this.options.enableResponsiveResize) return; // Handle orientation changes window.addEventListener('orientationchange', () => { setTimeout(() => { this.handleOrientationChange(); }, this.options.orientationChangeDelay); }); // Handle window resize let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { this.handleWindowResize(); }, 250); }); // Handle viewport changes (for mobile browsers with dynamic toolbars) this.setupViewportHandler(); } /** * Handle orientation change */ handleOrientationChange() { const newOrientation = this.getOrientation(); if (newOrientation !== this.orientation) { this.orientation = newOrientation; // Resize all map instances this.mapInstances.forEach(mapInstance => { if (mapInstance.invalidateSize) { mapInstance.invalidateSize(); } }); // Emit orientation change event const orientationEvent = new CustomEvent('orientationChanged', { detail: { orientation: this.orientation } }); document.dispatchEvent(orientationEvent); } } /** * Handle window resize */ handleWindowResize() { // Update mobile detection this.isMobile = this.detectMobileDevice(); // Resize map instances this.mapInstances.forEach(mapInstance => { if (mapInstance.invalidateSize) { mapInstance.invalidateSize(); } }); // Update touch targets this.enhanceTouchTargets(); } /** * Setup viewport handler for dynamic mobile toolbars */ setupViewportHandler() { // Use visual viewport API if available if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { this.handleViewportChange(); }); } // Fallback for older browsers let lastHeight = window.innerHeight; const checkViewportChange = () => { if (Math.abs(window.innerHeight - lastHeight) > 100) { lastHeight = window.innerHeight; this.handleViewportChange(); } }; window.addEventListener('resize', checkViewportChange); document.addEventListener('focusin', checkViewportChange); document.addEventListener('focusout', checkViewportChange); } /** * Handle viewport changes */ handleViewportChange() { // Adjust map container heights const mapContainers = document.querySelectorAll('.map-container'); mapContainers.forEach(container => { const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; if (viewportHeight < 500) { container.style.height = '40vh'; } else { container.style.height = ''; // Reset to CSS default } }); } /** * Setup battery optimization */ setupBatteryOptimization() { if (!this.options.enableBatteryOptimization) return; // Reduce update frequency when battery is low if ('getBattery' in navigator) { navigator.getBattery().then(battery => { const optimizeBattery = () => { if (battery.level < 0.2) { // Battery below 20% this.enableBatterySaveMode(); } else { this.disableBatterySaveMode(); } }; battery.addEventListener('levelchange', optimizeBattery); optimizeBattery(); }); } // Reduce activity when page is not visible document.addEventListener('visibilitychange', () => { if (document.hidden) { this.pauseNonEssentialFeatures(); } else { this.resumeNonEssentialFeatures(); } }); } /** * Enable battery save mode */ enableBatterySaveMode() { console.log('Enabling battery save mode'); // Reduce map update frequency this.mapInstances.forEach(mapInstance => { if (mapInstance.options) { mapInstance.options.updateInterval = 5000; // Increase to 5 seconds } }); // Disable animations document.body.classList.add('battery-save-mode'); } /** * Disable battery save mode */ disableBatterySaveMode() { console.log('Disabling battery save mode'); // Restore normal update frequency this.mapInstances.forEach(mapInstance => { if (mapInstance.options) { mapInstance.options.updateInterval = 1000; // Restore to 1 second } }); // Re-enable animations document.body.classList.remove('battery-save-mode'); } /** * Pause non-essential features */ pauseNonEssentialFeatures() { // Pause location watching if (window.userLocation && window.userLocation.stopWatching) { window.userLocation.stopWatching(); } // Reduce map updates this.mapInstances.forEach(mapInstance => { if (mapInstance.pauseUpdates) { mapInstance.pauseUpdates(); } }); } /** * Resume non-essential features */ resumeNonEssentialFeatures() { // Resume location watching if it was active if (window.userLocation && window.userLocation.options.watchPosition) { window.userLocation.startWatching(); } // Resume map updates this.mapInstances.forEach(mapInstance => { if (mapInstance.resumeUpdates) { mapInstance.resumeUpdates(); } }); } /** * Setup accessibility enhancements for mobile */ setupAccessibilityEnhancements() { // Add focus indicators for touch navigation const focusableElements = document.querySelectorAll('button, a, input, select, textarea, [tabindex]'); focusableElements.forEach(element => { element.addEventListener('focus', () => { element.classList.add('touch-focused'); }); element.addEventListener('blur', () => { element.classList.remove('touch-focused'); }); }); // Enhance keyboard navigation document.addEventListener('keydown', (e) => { if (e.key === 'Tab') { document.body.classList.add('keyboard-navigation'); } }); document.addEventListener('mousedown', () => { document.body.classList.remove('keyboard-navigation'); }); } /** * Bind event handlers */ bindEventHandlers() { // Handle double-tap to zoom this.setupDoubleTapZoom(); // Handle long press this.setupLongPress(); // Handle pinch gestures if (this.options.enablePinchZoom) { this.setupPinchZoom(); } } /** * Setup double-tap to zoom */ setupDoubleTapZoom() { let lastTapTime = 0; document.addEventListener('touchend', (e) => { const currentTime = Date.now(); if (currentTime - lastTapTime < 300) { // Double tap detected const target = e.target; if (target.closest('.leaflet-container')) { this.handleDoubleTapZoom(e); } } lastTapTime = currentTime; }, { passive: true }); } /** * Handle double-tap zoom */ handleDoubleTapZoom(e) { const mapContainer = e.target.closest('.leaflet-container'); if (!mapContainer) return; // Find associated map instance this.mapInstances.forEach(mapInstance => { if (mapInstance.getContainer() === mapContainer) { const currentZoom = mapInstance.getZoom(); const newZoom = currentZoom < mapInstance.getMaxZoom() ? currentZoom + 2 : mapInstance.getMinZoom(); mapInstance.setZoom(newZoom, { animate: true, duration: 0.3 }); } }); } /** * Setup long press detection */ setupLongPress() { let pressTimer; document.addEventListener('touchstart', (e) => { pressTimer = setTimeout(() => { this.handleLongPress(e); }, 750); // 750ms for long press }, { passive: true }); document.addEventListener('touchend', () => { clearTimeout(pressTimer); }, { passive: true }); document.addEventListener('touchmove', () => { clearTimeout(pressTimer); }, { passive: true }); } /** * Handle long press */ handleLongPress(e) { const target = e.target; // Emit long press event const longPressEvent = new CustomEvent('longPress', { detail: { target, touches: e.touches } }); target.dispatchEvent(longPressEvent); // Provide haptic feedback if available if (navigator.vibrate) { navigator.vibrate(50); } } /** * Setup pinch zoom for maps */ setupPinchZoom() { document.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { this.gestureState.isActive = true; this.gestureState.startDistance = this.getDistance(e.touches[0], e.touches[1]); this.gestureState.startCenter = this.getCenter(e.touches[0], e.touches[1]); } }, { passive: true }); document.addEventListener('touchmove', (e) => { if (this.gestureState.isActive && e.touches.length === 2) { this.handlePinchZoom(e); } }, { passive: false }); document.addEventListener('touchend', () => { this.gestureState.isActive = false; }, { passive: true }); } /** * Handle pinch zoom gesture */ handlePinchZoom(e) { if (!e.target.closest('.leaflet-container')) return; const currentDistance = this.getDistance(e.touches[0], e.touches[1]); const scale = currentDistance / this.gestureState.startDistance; // Emit pinch event const pinchEvent = new CustomEvent('pinch', { detail: { scale, center: this.gestureState.startCenter } }); e.target.dispatchEvent(pinchEvent); } /** * Get distance between two touch points */ getDistance(touch1, touch2) { const dx = touch1.clientX - touch2.clientX; const dy = touch1.clientY - touch2.clientY; return Math.sqrt(dx * dx + dy * dy); } /** * Get center point between two touches */ getCenter(touch1, touch2) { return { x: (touch1.clientX + touch2.clientX) / 2, y: (touch1.clientY + touch2.clientY) / 2 }; } /** * Register map instance for mobile optimizations */ registerMapInstance(mapInstance) { this.mapInstances.add(mapInstance); // Apply mobile-specific map options if (this.isMobile && mapInstance.options) { mapInstance.options.zoomControl = false; // Use custom larger controls mapInstance.options.attributionControl = false; // Save space } } /** * Unregister map instance */ unregisterMapInstance(mapInstance) { this.mapInstances.delete(mapInstance); } /** * Toggle filter panel for mobile */ toggleFilterPanel() { const filterPanel = document.querySelector('.filter-panel'); if (filterPanel) { filterPanel.classList.toggle('mobile-expanded'); } } /** * Show park actions on swipe */ showParkActions(target) { const parkItem = target.closest('.park-item'); if (parkItem) { parkItem.classList.add('actions-visible'); } } /** * Hide park actions */ hideParkActions(target) { const parkItem = target.closest('.park-item'); if (parkItem) { parkItem.classList.remove('actions-visible'); } } /** * Check if device is mobile */ isMobileDevice() { return this.isMobile; } /** * Check if device supports touch */ isTouchDevice() { return this.isTouch; } /** * Get device info */ getDeviceInfo() { return { isMobile: this.isMobile, isTouch: this.isTouch, orientation: this.orientation, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, pixelRatio: window.devicePixelRatio || 1 }; } } // Auto-initialize mobile touch support document.addEventListener('DOMContentLoaded', function() { window.mobileTouchSupport = new MobileTouchSupport(); // Register existing map instances if (window.thrillwikiMap) { window.mobileTouchSupport.registerMapInstance(window.thrillwikiMap); } }); // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = MobileTouchSupport; } else { window.MobileTouchSupport = MobileTouchSupport; }