--- 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.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.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.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.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( `${directions.distance} mi • ${directions.duration} min
${store.properties.address}
${step.maneuver.instruction}
`).join('')}${store.properties.address}
${store.properties.phone}
${store.properties.hours}
${store.properties.distance ? `${store.properties.distance} mi away
` : ''}