--- name: mapbox-store-locator-patterns description: Common patterns for building store locators, restaurant finders, and location-based search applications with Mapbox. Covers marker display, filtering, distance calculation, and interactive lists. --- # Store Locator Patterns Skill Comprehensive patterns for building store locators, restaurant finders, and location-based search applications with Mapbox GL JS. Covers marker display, filtering, distance calculation, interactive lists, and directions integration. ## When to Use This Skill Use this skill when building applications that: - Display multiple locations on a map (stores, restaurants, offices, etc.) - Allow users to filter or search locations - Calculate distances from user location - Provide interactive lists synced with map markers - Show location details in popups or side panels - Integrate directions to selected locations ## Dependencies **Required:** - Mapbox GL JS v3.x - [@turf/turf](https://turfjs.org/) - For spatial calculations (distance, area, etc.) **Installation:** ```bash npm install mapbox-gl @turf/turf ``` ## Core Architecture ### Pattern Overview A typical store locator consists of: 1. **Map Display** - Shows all locations as markers 2. **Location Data** - GeoJSON with store/location information 3. **Interactive List** - Side panel listing all locations 4. **Filtering** - Search, category filters, distance filters 5. **Detail View** - Popup or panel with location details 6. **User Location** - Geolocation for distance calculation 7. **Directions** - Route to selected location (optional) ### Data Structure **GeoJSON format for locations:** ```json { "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [-77.034084, 38.909671] }, "properties": { "id": "store-001", "name": "Downtown Store", "address": "123 Main St, Washington, DC 20001", "phone": "(202) 555-0123", "hours": "Mon-Sat: 9am-9pm, Sun: 10am-6pm", "category": "retail", "website": "https://example.com/downtown" } } ] } ``` **Key properties:** - `id` - Unique identifier for each location - `name` - Display name - `address` - Full address for display and geocoding - `coordinates` - `[longitude, latitude]` format - `category` - For filtering (retail, restaurant, office, etc.) - Custom properties as needed (hours, phone, website, etc.) ## Basic Store Locator Implementation ### Step 1: Initialize Map and Data ```javascript import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN'; // Store locations data const stores = { type: 'FeatureCollection', features: [ { type: 'Feature', geometry: { type: 'Point', coordinates: [-77.034084, 38.909671] }, properties: { id: 'store-001', name: 'Downtown Store', address: '123 Main St, Washington, DC 20001', phone: '(202) 555-0123', category: 'retail' } } // ... more stores ] }; const map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/standard', center: [-77.034084, 38.909671], zoom: 11 }); ``` ### Step 2: Add Markers to Map **Option 1: HTML Markers (< 100 locations)** ```javascript const markers = {}; stores.features.forEach((store) => { // Create marker element const el = document.createElement('div'); el.className = 'marker'; el.style.backgroundImage = 'url(/marker-icon.png)'; el.style.width = '30px'; el.style.height = '40px'; el.style.backgroundSize = 'cover'; el.style.cursor = 'pointer'; // Create marker const marker = new mapboxgl.Marker(el) .setLngLat(store.geometry.coordinates) .setPopup( new mapboxgl.Popup({ offset: 25 }).setHTML( `

${store.properties.name}

${store.properties.address}

${store.properties.phone}

` ) ) .addTo(map); // Store reference for later access markers[store.properties.id] = marker; // Handle marker click el.addEventListener('click', () => { flyToStore(store); createPopup(store); highlightListing(store.properties.id); }); }); ``` **Option 2: Symbol Layer (100-1000 locations)** ```javascript map.on('load', () => { // Add store data as source map.addSource('stores', { type: 'geojson', data: stores }); // Add custom marker image map.loadImage('/marker-icon.png', (error, image) => { if (error) throw error; map.addImage('custom-marker', image); // Add symbol layer map.addLayer({ id: 'stores-layer', type: 'symbol', source: 'stores', layout: { 'icon-image': 'custom-marker', 'icon-size': 0.8, 'icon-allow-overlap': true, 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-offset': [0, 1.5], 'text-anchor': 'top', 'text-size': 12 } }); }); // Handle marker clicks using Interactions API (recommended) map.addInteraction('store-click', { type: 'click', target: { layerId: 'stores-layer' }, handler: (e) => { const store = e.feature; flyToStore(store); createPopup(store); } }); // Or using traditional event listener: // map.on('click', 'stores-layer', (e) => { // const store = e.features[0]; // flyToStore(store); // createPopup(store); // }); // Change cursor on hover map.on('mouseenter', 'stores-layer', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'stores-layer', () => { map.getCanvas().style.cursor = ''; }); }); ``` **Option 3: Clustering (> 1000 locations)** ```javascript map.on('load', () => { map.addSource('stores', { type: 'geojson', data: stores, cluster: true, clusterMaxZoom: 14, clusterRadius: 50 }); // Cluster circles map.addLayer({ id: 'clusters', type: 'circle', source: 'stores', filter: ['has', 'point_count'], paint: { 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'], 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40] } }); // Cluster count labels map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'stores', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 12 } }); // Unclustered points map.addLayer({ id: 'unclustered-point', type: 'circle', source: 'stores', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#11b4da', 'circle-radius': 8, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' } }); // Zoom on cluster click map.on('click', 'clusters', (e) => { const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] }); const clusterId = features[0].properties.cluster_id; map.getSource('stores').getClusterExpansionZoom(clusterId, (err, zoom) => { if (err) return; map.easeTo({ center: features[0].geometry.coordinates, zoom: zoom }); }); }); // Show popup on unclustered point click map.on('click', 'unclustered-point', (e) => { const coordinates = e.features[0].geometry.coordinates.slice(); const props = e.features[0].properties; new mapboxgl.Popup() .setLngLat(coordinates) .setHTML( `

${props.name}

${props.address}

` ) .addTo(map); }); }); ``` ### Step 3: Build Interactive Location List ```javascript function buildLocationList(stores) { const listingContainer = document.getElementById('listings'); stores.features.forEach((store, index) => { const listing = listingContainer.appendChild(document.createElement('div')); listing.id = `listing-${store.properties.id}`; listing.className = 'listing'; const link = listing.appendChild(document.createElement('a')); link.href = '#'; link.className = 'title'; link.id = `link-${store.properties.id}`; link.innerHTML = store.properties.name; const details = listing.appendChild(document.createElement('div')); details.innerHTML = `

${store.properties.address}

${store.properties.phone || ''}

`; // Handle listing click link.addEventListener('click', (e) => { e.preventDefault(); flyToStore(store); createPopup(store); highlightListing(store.properties.id); }); }); } function flyToStore(store) { map.flyTo({ center: store.geometry.coordinates, zoom: 15, duration: 1000 }); } function createPopup(store) { const popups = document.getElementsByClassName('mapboxgl-popup'); // Remove existing popups if (popups[0]) popups[0].remove(); new mapboxgl.Popup({ closeOnClick: true }) .setLngLat(store.geometry.coordinates) .setHTML( `

${store.properties.name}

${store.properties.address}

${store.properties.phone}

${store.properties.website ? `Visit Website` : ''}` ) .addTo(map); } function highlightListing(id) { // Remove existing highlights const activeItem = document.getElementsByClassName('active'); if (activeItem[0]) { activeItem[0].classList.remove('active'); } // Add highlight to selected listing const listing = document.getElementById(`listing-${id}`); listing.classList.add('active'); // Scroll to listing listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // Build the list on load map.on('load', () => { buildLocationList(stores); }); ``` ### Step 4: Add Search/Filter Functionality **Text Search:** ```javascript function filterStores(searchTerm) { const filtered = { type: 'FeatureCollection', features: stores.features.filter((store) => { const name = store.properties.name.toLowerCase(); const address = store.properties.address.toLowerCase(); const search = searchTerm.toLowerCase(); return name.includes(search) || address.includes(search); }) }; // Update map source if (map.getSource('stores')) { map.getSource('stores').setData(filtered); } // Rebuild listing document.getElementById('listings').innerHTML = ''; buildLocationList(filtered); // Fit map to filtered results if (filtered.features.length > 0) { const bounds = new mapboxgl.LngLatBounds(); filtered.features.forEach((feature) => { bounds.extend(feature.geometry.coordinates); }); map.fitBounds(bounds, { padding: 50 }); } } // Add search input handler document.getElementById('search-input').addEventListener('input', (e) => { filterStores(e.target.value); }); ``` **Category Filter:** ```javascript function filterByCategory(category) { const filtered = category === 'all' ? stores : { type: 'FeatureCollection', features: stores.features.filter((store) => store.properties.category === category) }; // Update map and list if (map.getSource('stores')) { map.getSource('stores').setData(filtered); } document.getElementById('listings').innerHTML = ''; buildLocationList(filtered); } // Category dropdown document.getElementById('category-select').addEventListener('change', (e) => { filterByCategory(e.target.value); }); ``` ### Step 5: Add Geolocation and Distance Calculation ```javascript let userLocation = null; // Add geolocation control map.addControl( new mapboxgl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showUserHeading: true }) ); // Get user location navigator.geolocation.getCurrentPosition( (position) => { userLocation = [position.coords.longitude, position.coords.latitude]; // Calculate distances and sort const storesWithDistance = stores.features.map((store) => { const distance = calculateDistance(userLocation, store.geometry.coordinates); return { ...store, properties: { ...store.properties, distance: distance } }; }); // Sort by distance storesWithDistance.sort((a, b) => a.properties.distance - b.properties.distance); // Update data stores.features = storesWithDistance; // Rebuild list with distances document.getElementById('listings').innerHTML = ''; buildLocationList(stores); }, (error) => { console.error('Error getting location:', error); } ); // Calculate distance using Turf.js (recommended) import * as turf from '@turf/turf'; function calculateDistance(from, to) { const fromPoint = turf.point(from); const toPoint = turf.point(to); const distance = turf.distance(fromPoint, toPoint, { units: 'miles' }); return distance.toFixed(1); // Distance in miles } // Update listing to show distance function buildLocationList(stores) { const listingContainer = document.getElementById('listings'); stores.features.forEach((store) => { const listing = listingContainer.appendChild(document.createElement('div')); listing.id = `listing-${store.properties.id}`; listing.className = 'listing'; const link = listing.appendChild(document.createElement('a')); link.href = '#'; link.className = 'title'; link.innerHTML = store.properties.name; const details = listing.appendChild(document.createElement('div')); details.innerHTML = ` ${store.properties.distance ? `

${store.properties.distance} mi

` : ''}

${store.properties.address}

${store.properties.phone || ''}

`; link.addEventListener('click', (e) => { e.preventDefault(); flyToStore(store); createPopup(store); highlightListing(store.properties.id); }); }); } ``` ### Step 6: Integrate Directions (Optional) ```javascript async function getDirections(from, to) { const query = await fetch( `https://api.mapbox.com/directions/v5/mapbox/driving/${from[0]},${from[1]};${to[0]},${to[1]}?` + `steps=true&geometries=geojson&access_token=${mapboxgl.accessToken}` ); const data = await query.json(); const route = data.routes[0]; // Display route on map if (map.getSource('route')) { map.getSource('route').setData({ type: 'Feature', geometry: route.geometry }); } else { map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: route.geometry } }); map.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#3b9ddd', 'line-width': 5, 'line-opacity': 0.75 } }); } // Display directions info const duration = Math.floor(route.duration / 60); const distance = (route.distance * 0.000621371).toFixed(1); // Convert to miles return { duration, distance, steps: route.legs[0].steps }; } // Add "Get Directions" button to popup function createPopup(store) { const popups = document.getElementsByClassName('mapboxgl-popup'); if (popups[0]) popups[0].remove(); const popup = new mapboxgl.Popup({ closeOnClick: true }) .setLngLat(store.geometry.coordinates) .setHTML( `

${store.properties.name}

${store.properties.address}

${store.properties.phone}

${userLocation ? '' : ''}` ) .addTo(map); // Handle directions button if (userLocation) { document.getElementById('get-directions').addEventListener('click', async () => { const directions = await getDirections(userLocation, store.geometry.coordinates); // Update popup with directions popup.setHTML( `

${store.properties.name}

${directions.distance} mi • ${directions.duration} min

${store.properties.address}

${directions.steps.map((step) => `

${step.maneuver.instruction}

`).join('')}
` ); }); } } ``` ## Styling Patterns ### Layout Structure ```html Store Locator
``` ### Custom Marker Styling ```css /* Custom marker styles */ .marker { background-size: cover; width: 30px; height: 40px; cursor: pointer; transition: transform 0.2s; } .marker:hover { transform: scale(1.1); } /* Category-specific marker colors */ .marker.retail { background-color: #2196f3; } .marker.restaurant { background-color: #f44336; } .marker.office { background-color: #4caf50; } ``` ## Performance Optimization ### Debounced Search ```javascript function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } const debouncedFilter = debounce(filterStores, 300); document.getElementById('search-input').addEventListener('input', (e) => { debouncedFilter(e.target.value); }); ``` ## Best Practices ### Data Management ```javascript // ✅ GOOD: Load data once, filter in memory const allStores = await fetch('/api/stores').then((r) => r.json()); function filterStores(criteria) { return { type: 'FeatureCollection', features: allStores.features.filter(criteria) }; } // ❌ BAD: Fetch on every filter async function filterStores(criteria) { return await fetch(`/api/stores?filter=${criteria}`).then((r) => r.json()); } ``` ### Error Handling ```javascript // Geolocation error handling navigator.geolocation.getCurrentPosition( successCallback, (error) => { let message = 'Unable to get your location.'; switch (error.code) { case error.PERMISSION_DENIED: message = 'Please enable location access to see nearby stores.'; break; case error.POSITION_UNAVAILABLE: message = 'Location information is unavailable.'; break; case error.TIMEOUT: message = 'Location request timed out.'; break; } showNotification(message); }, { enableHighAccuracy: true, timeout: 5000, maximumAge: 0 } ); // API error handling async function loadStores() { try { const response = await fetch('/api/stores'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error('Failed to load stores:', error); showNotification('Unable to load store locations. Please try again.'); return { type: 'FeatureCollection', features: [] }; } } ``` ### Accessibility ```javascript // Add ARIA labels document.getElementById('search-input').setAttribute('aria-label', 'Search stores'); // Keyboard navigation document.querySelectorAll('.listing').forEach((listing, index) => { listing.setAttribute('tabindex', '0'); listing.setAttribute('role', 'button'); listing.setAttribute('aria-label', `View ${listing.querySelector('.title').textContent}`); listing.addEventListener('keypress', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); listing.click(); } }); }); // Focus management function highlightListing(id) { const listing = document.getElementById(`listing-${id}`); listing.classList.add('active'); listing.focus(); listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } ``` ## Common Variations ### Mobile-First Layout ```css /* Mobile first: stack sidebar on top */ @media (max-width: 768px) { #app { flex-direction: column; } .sidebar { width: 100%; height: 40vh; max-height: 40vh; } #map { height: 60vh; } /* Toggle sidebar */ .sidebar.collapsed { height: 60px; } } ``` ### Fullscreen Map with Overlay ```javascript // Map takes full screen, list appears as overlay const listOverlay = document.createElement('div'); listOverlay.className = 'list-overlay'; listOverlay.innerHTML = ` `; document.getElementById('toggle-list').addEventListener('click', () => { document.getElementById('listings').classList.toggle('hidden'); }); ``` ### Map-Only View ```javascript // No sidebar, everything in popups function createDetailedPopup(store) { const popup = new mapboxgl.Popup({ maxWidth: '400px' }) .setLngLat(store.geometry.coordinates) .setHTML( `

${store.properties.name}

${store.properties.address}

${store.properties.phone}

${store.properties.hours}

${store.properties.distance ? `

${store.properties.distance} mi away

` : ''}
${store.properties.website ? `Website` : ''}
` ) .addTo(map); } ``` ## Framework Integration ### React Implementation ```jsx import { useEffect, useRef, useState } from 'react'; import mapboxgl from 'mapbox-gl'; function StoreLocator({ stores }) { const mapContainer = useRef(null); const map = useRef(null); const [selectedStore, setSelectedStore] = useState(null); const [filteredStores, setFilteredStores] = useState(stores); useEffect(() => { if (map.current) return; map.current = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/standard', center: [-77.034084, 38.909671], zoom: 11 }); map.current.on('load', () => { map.current.addSource('stores', { type: 'geojson', data: filteredStores }); map.current.addLayer({ id: 'stores', type: 'circle', source: 'stores', paint: { 'circle-color': '#2196f3', 'circle-radius': 8 } }); map.current.on('click', 'stores', (e) => { setSelectedStore(e.features[0]); }); }); return () => map.current.remove(); }, []); // Update source when filtered stores change useEffect(() => { if (map.current && map.current.getSource('stores')) { map.current.getSource('stores').setData(filteredStores); } }, [filteredStores]); return (
); } ``` ## Resources - [Turf.js](https://turfjs.org/) - Spatial analysis library (recommended for distance calculations) - [Mapbox GL JS API](https://docs.mapbox.com/mapbox-gl-js/) - [Interactions API Guide](https://docs.mapbox.com/mapbox-gl-js/guides/user-interactions/interactions/) - [GeoJSON Specification](https://geojson.org/) - [Directions API](https://docs.mapbox.com/api/navigation/directions/) - [Store Locator Tutorial](https://docs.mapbox.com/help/tutorials/building-a-store-locator/)