@@ -110,8 +110,38 @@ function loadStationsInView() {
110110 } ) ;
111111}
112112
113- // Debounced version of loadStationsInView (500ms delay)
114- const debouncedLoadStations = debounce ( loadStationsInView , 500 ) ;
113+ // Mobile-optimized debouncing with longer delay on mobile
114+ const isMobile = window . innerWidth <= 480 ;
115+ const debounceDelay = isMobile ? 1000 : 500 ; // Longer delay on mobile for stability
116+ const debouncedLoadStations = debounce ( loadStationsInView , debounceDelay ) ;
117+
118+ // Memory cleanup for mobile
119+ function cleanupMemory ( ) {
120+ if ( isMobile ) {
121+ // Force garbage collection hint
122+ if ( window . gc && typeof window . gc === 'function' ) {
123+ window . gc ( ) ;
124+ }
125+
126+ // Clear old popup references
127+ const oldPopups = document . querySelectorAll ( '.mapboxgl-popup' ) ;
128+ if ( oldPopups . length > 1 ) {
129+ for ( let i = 0 ; i < oldPopups . length - 1 ; i ++ ) {
130+ oldPopups [ i ] . remove ( ) ;
131+ }
132+ }
133+ }
134+ }
135+
136+ // Performance monitoring
137+ let performanceIssues = 0 ;
138+ function trackPerformanceIssue ( ) {
139+ performanceIssues ++ ;
140+ if ( performanceIssues > 5 && isMobile ) {
141+ console . warn ( 'Multiple performance issues detected, reducing functionality' ) ;
142+ // Could disable hover effects, reduce cache size, etc.
143+ }
144+ }
115145
116146map . on ( 'load' , function ( ) {
117147 // Add a source for the stations - initially empty
@@ -207,129 +237,181 @@ map.on('load', function () {
207237 map . on ( 'moveend' , debouncedLoadStations ) ;
208238 map . on ( 'zoomend' , debouncedLoadStations ) ;
209239
210- // Periodically clean up expired cache entries (every 5 minutes)
211- setInterval ( ( ) => {
212- stationCache . clearExpired ( ) ;
213- } , 5 * 60 * 1000 ) ;
240+ // Mobile-specific optimizations
241+ if ( isMobile ) {
242+ // Cleanup memory more frequently on mobile
243+ setInterval ( cleanupMemory , 2 * 60 * 1000 ) ; // Every 2 minutes
244+
245+ // Reduce cache cleanup frequency on mobile
246+ setInterval ( ( ) => {
247+ stationCache . clearExpired ( ) ;
248+ } , 10 * 60 * 1000 ) ; // Every 10 minutes instead of 5
249+
250+ // Add visibility change handler to cleanup when app goes to background
251+ document . addEventListener ( 'visibilitychange' , ( ) => {
252+ if ( document . hidden ) {
253+ cleanupMemory ( ) ;
254+ // Cancel any pending requests when app goes to background
255+ if ( currentRequest ) {
256+ currentRequest . abort ( ) ;
257+ currentRequest = null ;
258+ }
259+ }
260+ } ) ;
261+ } else {
262+ // Desktop cleanup (original frequency)
263+ setInterval ( ( ) => {
264+ stationCache . clearExpired ( ) ;
265+ } , 5 * 60 * 1000 ) ;
266+ }
214267
215268 // Mobile-optimized popup for station info
216269 map . on ( 'click' , 'stations-layer' , function ( e ) {
217- const feature = e . features [ 0 ] ;
218- const props = feature . properties ;
219-
220- // Close any existing popups
221- const existingPopups = document . querySelectorAll ( '.mapboxgl-popup' ) ;
222- existingPopups . forEach ( popup => popup . remove ( ) ) ;
223-
224- // Create enhanced popup content with better styling
225- const isMobile = window . innerWidth <= 480 ;
226-
227- // Parse brand and location from title
228- const titleParts = props . title . split ( ', ' ) ;
229- const brand = titleParts [ 0 ] || 'Unknown Station' ;
230- const location = titleParts . slice ( 1 ) . join ( ', ' ) || 'Location not available' ;
231-
232- // Parse prices from description (format: "⛽ Unleaded £1.45" or "🚛 Diesel (B7) £1.52")
233- const prices = props . description . split ( '<br />' ) ;
234- const priceElements = prices . map ( price => {
235- // Extract icon, fuel type, and price value from new format
236- const iconMatch = price . match ( / ^ ( [ ⛽ 🚛 💎 ] ) \s + / ) ;
237- const fuelMatch = price . match ( / ( [ ⛽ 🚛 💎 ] ) \s + ( [ A - Z a - z \s ] + ?) (?: \s + \( [ ^ ) ] + \) ) ? \s + £ ( [ \d . ] + ) / ) ;
270+ try {
271+ const feature = e . features [ 0 ] ;
272+ if ( ! feature || ! feature . properties ) return ;
238273
239- if ( ! fuelMatch ) {
240- return '' ; // Skip invalid entries
241- }
274+ const props = feature . properties ;
242275
243- const icon = fuelMatch [ 1 ] ;
244- const fuel = fuelMatch [ 2 ] . trim ( ) ;
245- const priceInPounds = parseFloat ( fuelMatch [ 3 ] ) ;
276+ // Close any existing popups
277+ const existingPopups = document . querySelectorAll ( '.mapboxgl-popup' ) ;
278+ existingPopups . forEach ( popup => popup . remove ( ) ) ;
246279
247- let priceColor = '#333' ;
280+ // Create enhanced popup content with better styling
281+ const isMobile = window . innerWidth <= 480 ;
248282
249- // Color code prices based on pounds
250- if ( priceInPounds > 0 ) {
251- if ( priceInPounds < 1.40 ) priceColor = '#00C851' ; // Green
252- else if ( priceInPounds < 1.50 ) priceColor = '#ffbb33' ; // Amber
253- else priceColor = '#FF4444' ; // Red
254- }
283+ // Simplified parsing for mobile performance
284+ const titleParts = props . title . split ( ', ' ) ;
285+ const brand = titleParts [ 0 ] || 'Station' ;
286+ const location = titleParts . slice ( 1 ) . join ( ', ' ) || 'Location not available' ;
255287
256- const displayPrice = priceInPounds > 0 ? `£${ priceInPounds . toFixed ( 2 ) } ` : 'N/A' ;
288+ // Simple extraction without complex regex
289+ let priceContent = '' ;
290+ if ( props . description ) {
291+ const prices = props . description . split ( '<br />' ) ;
292+ const priceItems = [ ] ;
293+
294+ for ( let i = 0 ; i < Math . min ( prices . length , 4 ) ; i ++ ) { // Limit to 4 items
295+ const price = prices [ i ] ;
296+ if ( price && price . trim ( ) ) {
297+ // Simple matching for mobile
298+ const match = price . match ( / ( [ ⛽ 🚛 💎 ] ) \s + ( [ ^ £ ] + ) £ ( [ \d . ] + ) / ) ;
299+ if ( match ) {
300+ const icon = match [ 1 ] ;
301+ const fuel = match [ 2 ] . trim ( ) . replace ( / \( [ ^ ) ] * \) / g, '' ) . trim ( ) ;
302+ const priceVal = parseFloat ( match [ 3 ] ) ;
303+
304+ let color = '#333' ;
305+ if ( priceVal < 1.40 ) color = '#00C851' ;
306+ else if ( priceVal < 1.50 ) color = '#ffbb33' ;
307+ else color = '#FF4444' ;
308+
309+ priceItems . push ( `
310+ <div style="display: flex; justify-content: space-between; padding: 4px 0;">
311+ <span>${ icon } ${ fuel } </span>
312+ <span style="color: ${ color } ; font-weight: bold;">£${ priceVal . toFixed ( 2 ) } </span>
313+ </div>
314+ ` ) ;
315+ }
316+ }
317+ }
318+ priceContent = priceItems . join ( '' ) ;
319+ }
257320
258- return `
259- <div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #f0f0f0;">
260- <span style="font-weight: 500; color: #555; display: flex; align-items: center;">
261- <span style="margin-right: 8px; font-size: 16px;">${ icon } </span>
262- ${ fuel || 'Unknown Fuel' }
263- </span>
264- <span style="font-weight: bold; color: ${ priceColor } ; font-size: ${ isMobile ? '16px' : '14px' } ;">${ displayPrice } </span>
265- </div>
266- ` ;
267- } ) . filter ( element => element !== '' ) . join ( '' ) ;
268-
269- const content = `
270- <div style="max-width: ${ isMobile ? '300px' : '340px' } ; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
271- <div style="background: ${ props . is_best_price ? 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' } ; color: ${ props . is_best_price ? '#333' : 'white' } ; padding: 15px; margin: -15px -15px 15px -15px; border-radius: 8px 8px 0 0;">
272- <h3 style="margin: 0; font-size: ${ isMobile ? '18px' : '16px' } ; font-weight: 600;">
273- ${ props . is_best_price ? '🏆 ' : '' } ${ brand }
274- </h3>
275- <div style="font-size: ${ isMobile ? '14px' : '12px' } ; opacity: 0.9; margin-top: 4px;">
276- 📍 ${ location }
321+ const content = `
322+ <div style="max-width: ${ isMobile ? '280px' : '320px' } ; font-family: -apple-system, BlinkMacSystemFont, sans-serif;">
323+ <div style="background: ${ props . is_best_price ? '#FFD700' : '#667eea' } ; color: ${ props . is_best_price ? '#333' : 'white' } ; padding: 12px; margin: -15px -15px 12px -15px; border-radius: 6px 6px 0 0;">
324+ <h3 style="margin: 0; font-size: ${ isMobile ? '16px' : '15px' } ; font-weight: 600;">
325+ ${ props . is_best_price ? '🏆 ' : '' } ${ brand }
326+ </h3>
327+ <div style="font-size: ${ isMobile ? '12px' : '11px' } ; opacity: 0.9; margin-top: 4px;">
328+ 📍 ${ location }
329+ </div>
330+ </div>
331+
332+ <div style="margin-bottom: 12px;">
333+ <h4 style="margin: 0 0 8px 0; font-size: ${ isMobile ? '13px' : '12px' } ; color: #666;">
334+ ⛽ Prices
335+ </h4>
336+ ${ priceContent || '<div style="color: #999;">No price data</div>' }
337+ </div>
338+
339+ <div style="font-size: ${ isMobile ? '10px' : '9px' } ; color: #888; border-top: 1px solid #f0f0f0; padding-top: 8px;">
340+ 🕐 Updated: ${ ( ( ) => {
341+ try {
342+ if ( ! props . updated || props . updated === 'Unknown' ) return 'Unknown' ;
343+ const date = new Date ( props . updated ) ;
344+ return isNaN ( date . getTime ( ) ) ? props . updated : date . toLocaleDateString ( ) ;
345+ } catch ( e ) {
346+ return 'Unknown' ;
347+ }
348+ } ) ( ) }
277349 </div>
278- ${ props . is_best_price ? `<div style="font-size: ${ isMobile ? '13px' : '11px' } ; margin-top: 8px; font-weight: 600; opacity: 0.9;">⭐ Best price for: ${ ( props . best_fuel_types || [ ] ) . join ( ', ' ) } </div>` : '' }
279- </div>
280-
281- <div style="margin-bottom: 15px;">
282- <h4 style="margin: 0 0 10px 0; font-size: ${ isMobile ? '15px' : '13px' } ; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">
283- ⛽ Current Prices
284- </h4>
285- ${ priceElements || '<div style="color: #999; font-style: italic;">No price data available</div>' }
286350 </div>
351+ ` ;
352+
353+ new maptilersdk . Popup ( {
354+ closeButton : true ,
355+ closeOnClick : true ,
356+ closeOnMove : false ,
357+ maxWidth : isMobile ? '90vw' : '350px' ,
358+ anchor : isMobile ? 'bottom' : 'auto'
359+ } )
360+ . setLngLat ( e . lngLat )
361+ . setHTML ( content )
362+ . addTo ( map ) ;
287363
288- <div style="font-size: ${ isMobile ? '12px' : '11px' } ; color: #888; border-top: 1px solid #f0f0f0; padding-top: 10px; display: flex; align-items: center;">
289- <span style="margin-right: 5px;">🕐</span>
290- <strong>Updated:</strong> ${ ( ( ) => {
291- try {
292- if ( ! props . updated || props . updated === 'Unknown' ) return 'Unknown' ;
293- const date = new Date ( props . updated ) ;
294- return isNaN ( date . getTime ( ) ) ? props . updated : date . toLocaleString ( ) ;
295- } catch ( e ) {
296- return props . updated || 'Unknown' ;
297- }
298- } ) ( ) }
299- </div>
300- </div>
301- ` ;
302-
303- new maptilersdk . Popup ( {
304- closeButton : true ,
305- closeOnClick : true ,
306- closeOnMove : false , // Keep open when map moves slightly
307- maxWidth : isMobile ? '90vw' : '400px' ,
308- anchor : isMobile ? 'bottom' : 'auto' // Bottom anchor works better on mobile
309- } )
310- . setLngLat ( e . lngLat )
311- . setHTML ( content )
312- . addTo ( map ) ;
364+ } catch ( error ) {
365+ console . warn ( 'Popup creation failed:' , error ) ;
366+ trackPerformanceIssue ( ) ;
367+
368+ // Fallback: show simple alert on mobile if popup fails
369+ if ( window . innerWidth <= 480 ) {
370+ const props = e . features [ 0 ] ?. properties ;
371+ if ( props ?. title ) {
372+ setTimeout ( ( ) => {
373+ alert ( `${ props . title } \n${ props . description ?. replace ( / < b r \s * \/ ? > / gi, '\n' ) || 'No price data' } ` ) ;
374+ } , 100 ) ;
375+ }
376+ }
377+ }
313378 } ) ;
314379
315- // Enhanced cursor and hover effects
380+ // Simplified hover effects for mobile performance
381+ let hoverTimeout = null ;
382+
316383 map . on ( 'mouseenter' , 'stations-layer' , function ( e ) {
317384 map . getCanvas ( ) . style . cursor = 'pointer' ;
318385
319- // Highlight the hovered station
320- map . setPaintProperty ( 'stations-layer' , 'circle-stroke-width' , [
321- 'case' ,
322- [ '==' , [ 'get' , 'title' ] , e . features [ 0 ] . properties . title ] ,
323- 4 , // Thicker stroke for hovered station
324- 2 // Normal stroke for others
325- ] ) ;
386+ // Debounce hover effects on mobile to prevent performance issues
387+ if ( window . innerWidth <= 480 ) return ;
388+
389+ clearTimeout ( hoverTimeout ) ;
390+ hoverTimeout = setTimeout ( ( ) => {
391+ try {
392+ map . setPaintProperty ( 'stations-layer' , 'circle-stroke-width' , [
393+ 'case' ,
394+ [ '==' , [ 'get' , 'title' ] , e . features [ 0 ] . properties . title ] ,
395+ 4 , 2
396+ ] ) ;
397+ } catch ( error ) {
398+ console . warn ( 'Hover effect failed:' , error ) ;
399+ }
400+ } , 50 ) ;
326401 } ) ;
327402
328403 map . on ( 'mouseleave' , 'stations-layer' , function ( ) {
329404 map . getCanvas ( ) . style . cursor = '' ;
405+ clearTimeout ( hoverTimeout ) ;
330406
331- // Reset stroke width
332- map . setPaintProperty ( 'stations-layer' , 'circle-stroke-width' , 2 ) ;
407+ // Skip hover reset on mobile
408+ if ( window . innerWidth <= 480 ) return ;
409+
410+ try {
411+ map . setPaintProperty ( 'stations-layer' , 'circle-stroke-width' , 2 ) ;
412+ } catch ( error ) {
413+ console . warn ( 'Hover reset failed:' , error ) ;
414+ }
333415 } ) ;
334416
335417 // Also apply hover effects to labels
0 commit comments