Skip to content

Commit c05d58b

Browse files
committed
fix: optimize mobile popup performance to prevent crashes
- Simplify popup creation with reduced DOM complexity - Add comprehensive error handling with fallback alerts - Limit price items displayed to reduce memory usage - Implement mobile-specific performance optimizations - Add graceful degradation for failed popup creation
1 parent 1676889 commit c05d58b

8 files changed

Lines changed: 775 additions & 103 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# fuelaround.me
2+

public/js/app.js

Lines changed: 185 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -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

116146
map.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-Za-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>&nbsp;${(() => {
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(/<br\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

Comments
 (0)