skills/mapbox-store-locator-patterns/AGENTS.md

8.2 KiB

Store Locator Patterns

Quick reference for building store locators and location finders with Mapbox.

Architecture

Core Components:

  1. Map with markers
  2. Location data (GeoJSON)
  3. Interactive list
  4. Search/filter
  5. User location + distance
  6. Directions (optional)

Data Structure

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [-77.034084, 38.909671]
      },
      "properties": {
        "id": "store-001",
        "name": "Downtown Store",
        "address": "123 Main St, DC 20001",
        "phone": "(202) 555-0123",
        "category": "retail",
        "hours": "Mon-Sat: 9am-9pm"
      }
    }
  ]
}

Marker Strategies

Locations Strategy Implementation
< 100 HTML Markers new mapboxgl.Marker()
100-1000 Symbol Layer addLayer({ type: 'symbol' })
> 1000 Clustering cluster: true in source

HTML Markers Pattern

stores.features.forEach((store) => {
  const marker = new mapboxgl.Marker()
    .setLngLat(store.geometry.coordinates)
    .setPopup(
      new mapboxgl.Popup().setHTML(`
        <h3>${store.properties.name}</h3>
        <p>${store.properties.address}</p>
      `)
    )
    .addTo(map);
});

Symbol Layer Pattern

map.on('load', () => {
  map.addSource('stores', {
    type: 'geojson',
    data: stores
  });

  map.addLayer({
    id: 'stores',
    type: 'symbol',
    source: 'stores',
    layout: {
      'icon-image': 'custom-marker',
      'icon-size': 0.8,
      'text-field': ['get', 'name'],
      'text-offset': [0, 1.5]
    }
  });

  // Using Interactions API (recommended)
  map.addInteraction('store-click', {
    type: 'click',
    target: { layerId: 'stores' },
    handler: (e) => {
      const store = e.feature;
      showStoreDetails(store);
    }
  });

  // Or using traditional event listener
  // map.on('click', 'stores', (e) => {
  //   const store = e.features[0];
  //   showStoreDetails(store);
  // });
});

Clustering Pattern

map.addSource('stores', {
  type: 'geojson',
  data: stores,
  cluster: true,
  clusterMaxZoom: 14,
  clusterRadius: 50
});

// Clusters
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]
  }
});

// Unclustered points
map.addLayer({
  id: 'unclustered-point',
  type: 'circle',
  source: 'stores',
  filter: ['!', ['has', 'point_count']],
  paint: { 'circle-color': '#11b4da', 'circle-radius': 8 }
});

Interactive List

function buildLocationList(stores) {
  const container = document.getElementById('listings');
  container.innerHTML = '';

  stores.features.forEach((store) => {
    const listing = document.createElement('div');
    listing.className = 'listing';
    listing.innerHTML = `
      <a href="#" class="title">${store.properties.name}</a>
      <p>${store.properties.address}</p>
      ${store.properties.distance ? `<p class="distance">${store.properties.distance} mi</p>` : ''}
    `;

    listing.querySelector('.title').addEventListener('click', (e) => {
      e.preventDefault();
      flyToStore(store);
      createPopup(store);
    });

    container.appendChild(listing);
  });
}

function flyToStore(store) {
  map.flyTo({
    center: store.geometry.coordinates,
    zoom: 15
  });
}

Search/Filter

Text Search:

function filterStores(query) {
  const filtered = {
    type: 'FeatureCollection',
    features: stores.features.filter((store) => {
      const name = store.properties.name.toLowerCase();
      const address = store.properties.address.toLowerCase();
      return name.includes(query.toLowerCase()) || address.includes(query.toLowerCase());
    })
  };

  map.getSource('stores').setData(filtered);
  buildLocationList(filtered);
}

document.getElementById('search').addEventListener('input', (e) => {
  filterStores(e.target.value);
});

Category Filter:

function filterByCategory(category) {
  const filtered =
    category === 'all'
      ? stores
      : {
          type: 'FeatureCollection',
          features: stores.features.filter((s) => s.properties.category === category)
        };

  map.getSource('stores').setData(filtered);
  buildLocationList(filtered);
}

Distance Calculation

Using Turf.js (recommended):

import * as turf from '@turf/turf';

// Calculate distance between two points
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);
}

// Get user location
navigator.geolocation.getCurrentPosition((position) => {
  const userLocation = [position.coords.longitude, position.coords.latitude];

  // Add distances
  stores.features = stores.features.map((store) => ({
    ...store,
    properties: {
      ...store.properties,
      distance: calculateDistance(userLocation, store.geometry.coordinates)
    }
  }));

  // Sort by distance
  stores.features.sort((a, b) => a.properties.distance - b.properties.distance);

  buildLocationList(stores);
});

Directions Integration

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]}?` +
      `geometries=geojson&access_token=${mapboxgl.accessToken}`
  );

  const route = (await query.json()).routes[0];

  // Display route
  map.getSource('route').setData({
    type: 'Feature',
    geometry: route.geometry
  });

  // Add route layer if not exists
  if (!map.getLayer('route')) {
    map.addLayer({
      id: 'route',
      type: 'line',
      source: 'route',
      paint: {
        'line-color': '#3b9ddd',
        'line-width': 5
      }
    });
  }

  return {
    duration: Math.floor(route.duration / 60),
    distance: (route.distance * 0.000621371).toFixed(1)
  };
}

Layout Patterns

Sidebar + Map:

<div style="display: flex; height: 100vh;">
  <div class="sidebar" style="width: 400px; overflow-y: scroll;">
    <input type="text" id="search" placeholder="Search..." />
    <div id="listings"></div>
  </div>
  <div id="map" style="flex: 1;"></div>
</div>

Mobile Responsive:

@media (max-width: 768px) {
  #app {
    flex-direction: column;
  }
  .sidebar {
    width: 100%;
    height: 40vh;
  }
  #map {
    height: 60vh;
  }
}

Performance Tips

// Debounce search
function debounce(func, wait) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
}

const debouncedSearch = debounce(filterStores, 300);

Geolocation Control

map.addControl(
  new mapboxgl.GeolocateControl({
    positionOptions: { enableHighAccuracy: true },
    trackUserLocation: true,
    showUserHeading: true
  })
);

Common Patterns

Restaurant Finder:

  • Category filters (cuisine type)
  • Price range filters
  • Rating display
  • Hours of operation
  • Delivery/pickup options

Office Locator:

  • Department filters
  • Floor/building numbers
  • Contact information
  • Meeting room availability

Retail Store Finder:

  • Inventory availability
  • Store hours
  • Services offered
  • Appointment booking

Quick Decisions

Need clustering? → Yes if > 1000 locations

Need search? → Always include for > 10 locations

Need directions? → Yes for physical locations users visit

Need distance sorting? → Yes if user location available

Need filters? → Yes if > 20 locations or multiple categories

Resources