mapbox-store-locator-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.
使用Mapbox GL JS构建门店定位器、餐厅查找器及基于位置的搜索应用的全面模式,涵盖标记展示、筛选、距离计算、交互式列表和路线规划集成。

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 - For spatial calculations (distance, area, etc.)
Installation:
bash
npm install mapbox-gl @turf/turf
必需项:
  • Mapbox GL JS v3.x
  • @turf/turf - 用于空间计算(距离、面积等)
安装方式:
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)
典型的门店定位器包含以下部分:
  1. 地图展示 - 将所有地点以标记形式展示
  2. 位置数据 - 包含门店/地点信息的GeoJSON
  3. 交互式列表 - 侧边栏列出所有地点
  4. 筛选功能 - 搜索、分类筛选、距离筛选
  5. 详情视图 - 弹窗或面板展示地点详情
  6. 用户位置 - 用于距离计算的地理定位
  7. 路线规划 - 到选定地点的路线(可选)

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.)
地点的GeoJSON格式:
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"
      }
    }
  ]
}
关键属性:
  • id
    - 每个地点的唯一标识符
  • name
    - 展示名称
  • address
    - 用于展示和地理编码的完整地址
  • coordinates
    -
    [经度, 纬度]
    格式
  • category
    - 用于筛选(零售、餐厅、办公室等)
  • 自定义属性(营业时间、电话、网站等)

Basic Store Locator Implementation

基础门店定位器实现

Step 1: Initialize Map and Data

步骤1:初始化地图与数据

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
});
javascript
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';

mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';

// 门店位置数据
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'
      }
    }
    // ... 更多门店
  ]
};

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

步骤2:向地图添加标记

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(
        `<h3>${store.properties.name}</h3>
         <p>${store.properties.address}</p>
         <p>${store.properties.phone}</p>`
      )
    )
    .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(
        `<h3>${props.name}</h3>
         <p>${props.address}</p>`
      )
      .addTo(map);
  });
});
选项1:HTML标记(少于100个地点)
javascript
const markers = {};

stores.features.forEach((store) => {
  // 创建标记元素
  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';

  // 创建标记
  const marker = new mapboxgl.Marker(el)
    .setLngLat(store.geometry.coordinates)
    .setPopup(
      new mapboxgl.Popup({ offset: 25 }).setHTML(
        `<h3>${store.properties.name}</h3>
         <p>${store.properties.address}</p>
         <p>${store.properties.phone}</p>`
      )
    )
    .addTo(map);

  // 存储引用以便后续访问
  markers[store.properties.id] = marker;

  // 处理标记点击事件
  el.addEventListener('click', () => {
    flyToStore(store);
    createPopup(store);
    highlightListing(store.properties.id);
  });
});
选项2:符号图层(100-1000个地点)
javascript
map.on('load', () => {
  // 添加门店数据作为数据源
  map.addSource('stores', {
    type: 'geojson',
    data: stores
  });

  // 添加自定义标记图片
  map.loadImage('/marker-icon.png', (error, image) => {
    if (error) throw error;
    map.addImage('custom-marker', image);

    // 添加符号图层
    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
      }
    });
  });

  // 使用交互API处理标记点击(推荐)
  map.addInteraction('store-click', {
    type: 'click',
    target: { layerId: 'stores-layer' },
    handler: (e) => {
      const store = e.feature;
      flyToStore(store);
      createPopup(store);
    }
  });

  // 或使用传统事件监听器:
  // map.on('click', 'stores-layer', (e) => {
  //   const store = e.features[0];
  //   flyToStore(store);
  //   createPopup(store);
  // });

  // 悬停时改变光标
  map.on('mouseenter', 'stores-layer', () => {
    map.getCanvas().style.cursor = 'pointer';
  });

  map.on('mouseleave', 'stores-layer', () => {
    map.getCanvas().style.cursor = '';
  });
});
选项3:聚合图层(超过1000个地点)
javascript
map.on('load', () => {
  map.addSource('stores', {
    type: 'geojson',
    data: stores,
    cluster: true,
    clusterMaxZoom: 14,
    clusterRadius: 50
  });

  // 聚合圆圈
  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]
    }
  });

  // 聚合数量标签
  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
    }
  });

  未聚合点
  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'
    }
  });

  // 点击聚合点时缩放
  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
      });
    });
  });

  // 点击未聚合点时显示弹窗
  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(
        `<h3>${props.name}</h3>
         <p>${props.address}</p>`
      )
      .addTo(map);
  });
});

Step 3: Build Interactive Location List

步骤3:构建交互式地点列表

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 = `
      <p>${store.properties.address}</p>
      <p>${store.properties.phone || ''}</p>
    `;

    // 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(
      `<h3>${store.properties.name}</h3>
       <p>${store.properties.address}</p>
       <p>${store.properties.phone}</p>
       ${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Visit Website</a>` : ''}`
    )
    .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);
});
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 = `
      <p>${store.properties.address}</p>
      <p>${store.properties.phone || ''}</p>
    `;

    // 处理列表项点击事件
    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');
  // 移除现有弹窗
  if (popups[0]) popups[0].remove();

  new mapboxgl.Popup({ closeOnClick: true })
    .setLngLat(store.geometry.coordinates)
    .setHTML(
      `<h3>${store.properties.name}</h3>
       <p>${store.properties.address}</p>
       <p>${store.properties.phone}</p>
       ${store.properties.website ? `<a href="${store.properties.website}" target="_blank">访问官网</a>` : ''}`
    )
    .addTo(map);
}

function highlightListing(id) {
  // 移除现有高亮
  const activeItem = document.getElementsByClassName('active');
  if (activeItem[0]) {
    activeItem[0].classList.remove('active');
  }

  // 为选中的列表项添加高亮
  const listing = document.getElementById(`listing-${id}`);
  listing.classList.add('active');

  // 滚动到该列表项
  listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

// 加载时构建列表
map.on('load', () => {
  buildLocationList(stores);
});

Step 4: Add Search/Filter Functionality

步骤4:添加搜索/筛选功能

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);
});
文本搜索:
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);
    })
  };

  // 更新地图数据源
  if (map.getSource('stores')) {
    map.getSource('stores').setData(filtered);
  }

  // 重建列表
  document.getElementById('listings').innerHTML = '';
  buildLocationList(filtered);

  // 调整地图视野以适配筛选结果
  if (filtered.features.length > 0) {
    const bounds = new mapboxgl.LngLatBounds();
    filtered.features.forEach((feature) => {
      bounds.extend(feature.geometry.coordinates);
    });
    map.fitBounds(bounds, { padding: 50 });
  }
}

// 添加搜索输入框事件处理
document.getElementById('search-input').addEventListener('input', (e) => {
  filterStores(e.target.value);
});
分类筛选:
javascript
function filterByCategory(category) {
  const filtered =
    category === 'all'
      ? stores
      : {
          type: 'FeatureCollection',
          features: stores.features.filter((store) => store.properties.category === category)
        };

  // 更新地图和列表
  if (map.getSource('stores')) {
    map.getSource('stores').setData(filtered);
  }

  document.getElementById('listings').innerHTML = '';
  buildLocationList(filtered);
}

// 分类下拉菜单
document.getElementById('category-select').addEventListener('change', (e) => {
  filterByCategory(e.target.value);
});

Step 5: Add Geolocation and Distance Calculation

步骤5:添加地理定位与距离计算

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 ? `<p class="distance">${store.properties.distance} mi</p>` : ''}
      <p>${store.properties.address}</p>
      <p>${store.properties.phone || ''}</p>
    `;

    link.addEventListener('click', (e) => {
      e.preventDefault();
      flyToStore(store);
      createPopup(store);
      highlightListing(store.properties.id);
    });
  });
}
javascript
let userLocation = null;

// 添加地理定位控件
map.addControl(
  new mapboxgl.GeolocateControl({
    positionOptions: {
      enableHighAccuracy: true
    },
    trackUserLocation: true,
    showUserHeading: true
  })
);

// 获取用户位置
navigator.geolocation.getCurrentPosition(
  (position) => {
    userLocation = [position.coords.longitude, position.coords.latitude];

    // 计算距离并排序
    const storesWithDistance = stores.features.map((store) => {
      const distance = calculateDistance(userLocation, store.geometry.coordinates);
      return {
        ...store,
        properties: {
          ...store.properties,
          distance: distance
        }
      };
    });

    // 按距离排序
    storesWithDistance.sort((a, b) => a.properties.distance - b.properties.distance);

    // 更新数据
    stores.features = storesWithDistance;

    // 重建带距离的列表
    document.getElementById('listings').innerHTML = '';
    buildLocationList(stores);
  },
  (error) => {
    console.error('获取位置出错:', error);
  }
);

// 使用Turf.js计算距离(推荐)
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); // 英里为单位的距离
}

// 更新列表以显示距离
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 ? `<p class="distance">${store.properties.distance} 英里</p>` : ''}
      <p>${store.properties.address}</p>
      <p>${store.properties.phone || ''}</p>
    `;

    link.addEventListener('click', (e) => {
      e.preventDefault();
      flyToStore(store);
      createPopup(store);
      highlightListing(store.properties.id);
    });
  });
}

Step 6: Integrate Directions (Optional)

步骤6:集成路线规划(可选)

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(
      `<h3>${store.properties.name}</h3>
       <p>${store.properties.address}</p>
       <p>${store.properties.phone}</p>
       ${userLocation ? '<button id="get-directions">Get Directions</button>' : ''}`
    )
    .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(
        `<h3>${store.properties.name}</h3>
         <p><strong>${directions.distance} mi • ${directions.duration} min</strong></p>
         <p>${store.properties.address}</p>
         <div class="directions-steps">
           ${directions.steps.map((step) => `<p>${step.maneuver.instruction}</p>`).join('')}
         </div>`
      );
    });
  }
}
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];

  // 在地图上展示路线
  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
      }
    });
  }

  // 展示路线信息
  const duration = Math.floor(route.duration / 60);
  const distance = (route.distance * 0.000621371).toFixed(1); // 转换为英里

  return { duration, distance, steps: route.legs[0].steps };
}

// 为弹窗添加"获取路线"按钮
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(
      `<h3>${store.properties.name}</h3>
       <p>${store.properties.address}</p>
       <p>${store.properties.phone}</p>
       ${userLocation ? '<button id="get-directions">获取路线</button>' : ''}`
    )
    .addTo(map);

  // 处理路线按钮点击
  if (userLocation) {
    document.getElementById('get-directions').addEventListener('click', async () => {
      const directions = await getDirections(userLocation, store.geometry.coordinates);

      // 更新弹窗显示路线
      popup.setHTML(
        `<h3>${store.properties.name}</h3>
         <p><strong>${directions.distance} 英里 • ${directions.duration} 分钟</strong></p>
         <p>${store.properties.address}</p>
         <div class="directions-steps">
           ${directions.steps.map((step) => `<p>${step.maneuver.instruction}</p>`).join('')}
         </div>`
      );
    });
  }
}

Styling Patterns

样式模式

Layout Structure

布局结构

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Store Locator</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
    <style>
      body {
        margin: 0;
        padding: 0;
        font-family: 'Arial', sans-serif;
      }

      #app {
        display: flex;
        height: 100vh;
      }

      /* Sidebar */
      .sidebar {
        width: 400px;
        height: 100vh;
        overflow-y: scroll;
        background-color: #fff;
        border-right: 1px solid #ddd;
      }

      .sidebar-header {
        padding: 20px;
        background-color: #f8f9fa;
        border-bottom: 1px solid #ddd;
      }

      .sidebar-header h1 {
        margin: 0 0 10px 0;
        font-size: 24px;
      }

      /* Search */
      .search-box {
        width: 100%;
        padding: 10px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
        box-sizing: border-box;
      }

      .filter-group {
        margin-top: 10px;
      }

      .filter-group select {
        width: 100%;
        padding: 8px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
      }

      /* Listings */
      #listings {
        padding: 0;
      }

      .listing {
        padding: 15px 20px;
        border-bottom: 1px solid #eee;
        cursor: pointer;
        transition: background-color 0.2s;
      }

      .listing:hover {
        background-color: #f8f9fa;
      }

      .listing.active {
        background-color: #e3f2fd;
        border-left: 3px solid #2196f3;
      }

      .listing .title {
        display: block;
        color: #333;
        font-weight: bold;
        font-size: 16px;
        text-decoration: none;
        margin-bottom: 5px;
      }

      .listing .title:hover {
        color: #2196f3;
      }

      .listing p {
        margin: 5px 0;
        font-size: 14px;
        color: #666;
      }

      .listing .distance {
        color: #2196f3;
        font-weight: bold;
      }

      /* Map */
      #map {
        flex: 1;
        height: 100vh;
      }

      /* Popups */
      .mapboxgl-popup-content {
        padding: 15px;
        font-family: 'Arial', sans-serif;
      }

      .mapboxgl-popup-content h3 {
        margin: 0 0 10px 0;
        font-size: 18px;
      }

      .mapboxgl-popup-content p {
        margin: 5px 0;
        font-size: 14px;
      }

      .mapboxgl-popup-content button {
        margin-top: 10px;
        padding: 8px 16px;
        background-color: #2196f3;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
      }

      .mapboxgl-popup-content button:hover {
        background-color: #1976d2;
      }

      /* Responsive */
      @media (max-width: 768px) {
        #app {
          flex-direction: column;
        }

        .sidebar {
          width: 100%;
          height: 50vh;
        }

        #map {
          height: 50vh;
        }
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="sidebar">
        <div class="sidebar-header">
          <h1>Store Locator</h1>
          <input type="text" id="search-input" class="search-box" placeholder="Search by name or address..." />
          <div class="filter-group">
            <select id="category-select">
              <option value="all">All Categories</option>
              <option value="retail">Retail</option>
              <option value="restaurant">Restaurant</option>
              <option value="office">Office</option>
            </select>
          </div>
        </div>
        <div id="listings"></div>
      </div>
      <div id="map"></div>
    </div>

    <script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
    <script src="app.js"></script>
  </body>
</html>
html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>门店定位器</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
    <style>
      body {
        margin: 0;
        padding: 0;
        font-family: 'Arial', sans-serif;
      }

      #app {
        display: flex;
        height: 100vh;
      }

      /* 侧边栏 */
      .sidebar {
        width: 400px;
        height: 100vh;
        overflow-y: scroll;
        background-color: #fff;
        border-right: 1px solid #ddd;
      }

      .sidebar-header {
        padding: 20px;
        background-color: #f8f9fa;
        border-bottom: 1px solid #ddd;
      }

      .sidebar-header h1 {
        margin: 0 0 10px 0;
        font-size: 24px;
      }

      /* 搜索框 */
      .search-box {
        width: 100%;
        padding: 10px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
        box-sizing: border-box;
      }

      .filter-group {
        margin-top: 10px;
      }

      .filter-group select {
        width: 100%;
        padding: 8px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
      }

      /* 列表项 */
      #listings {
        padding: 0;
      }

      .listing {
        padding: 15px 20px;
        border-bottom: 1px solid #eee;
        cursor: pointer;
        transition: background-color 0.2s;
      }

      .listing:hover {
        background-color: #f8f9fa;
      }

      .listing.active {
        background-color: #e3f2fd;
        border-left: 3px solid #2196f3;
      }

      .listing .title {
        display: block;
        color: #333;
        font-weight: bold;
        font-size: 16px;
        text-decoration: none;
        margin-bottom: 5px;
      }

      .listing .title:hover {
        color: #2196f3;
      }

      .listing p {
        margin: 5px 0;
        font-size: 14px;
        color: #666;
      }

      .listing .distance {
        color: #2196f3;
        font-weight: bold;
      }

      /* 地图 */
      #map {
        flex: 1;
        height: 100vh;
      }

      /* 弹窗 */
      .mapboxgl-popup-content {
        padding: 15px;
        font-family: 'Arial', sans-serif;
      }

      .mapboxgl-popup-content h3 {
        margin: 0 0 10px 0;
        font-size: 18px;
      }

      .mapboxgl-popup-content p {
        margin: 5px 0;
        font-size: 14px;
      }

      .mapboxgl-popup-content button {
        margin-top: 10px;
        padding: 8px 16px;
        background-color: #2196f3;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
      }

      .mapboxgl-popup-content button:hover {
        background-color: #1976d2;
      }

      /* 响应式布局 */
      @media (max-width: 768px) {
        #app {
          flex-direction: column;
        }

        .sidebar {
          width: 100%;
          height: 50vh;
        }

        #map {
          height: 50vh;
        }
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="sidebar">
        <div class="sidebar-header">
          <h1>门店定位器</h1>
          <input type="text" id="search-input" class="search-box" placeholder="按名称或地址搜索..." />
          <div class="filter-group">
            <select id="category-select">
              <option value="all">所有分类</option>
              <option value="retail">零售</option>
              <option value="restaurant">餐厅</option>
              <option value="office">办公室</option>
            </select>
          </div>
        </div>
        <div id="listings"></div>
      </div>
      <div id="map"></div>
    </div>

    <script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
    <script src="app.js"></script>
  </body>
</html>

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;
}
css
/* 自定义标记样式 */
.marker {
  background-size: cover;
  width: 30px;
  height: 40px;
  cursor: pointer;
  transition: transform 0.2s;
}

.marker:hover {
  transform: scale(1.1);
}

/* 按分类区分标记颜色 */
.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);
});
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());
}
javascript
// ✅ 推荐:仅加载一次数据,在内存中筛选
const allStores = await fetch('/api/stores').then((r) => r.json());

function filterStores(criteria) {
  return {
    type: 'FeatureCollection',
    features: allStores.features.filter(criteria)
  };
}

// ❌ 不推荐:每次筛选都重新请求
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: [] };
  }
}
javascript
// 地理定位错误处理
navigator.geolocation.getCurrentPosition(
  successCallback,
  (error) => {
    let message = '无法获取您的位置。';

    switch (error.code) {
      case error.PERMISSION_DENIED:
        message = '请启用位置权限以查看附近门店。';
        break;
      case error.POSITION_UNAVAILABLE:
        message = '位置信息不可用。';
        break;
      case error.TIMEOUT:
        message = '位置请求超时。';
        break;
    }

    showNotification(message);
  },
  {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0
  }
);

// API错误处理
async function loadStores() {
  try {
    const response = await fetch('/api/stores');

    if (!response.ok) {
      throw new Error(`HTTP错误!状态码: ${response.status}`);
    }

    const data = await response.json();
    return data;
  } catch (error) {
    console.error('加载门店失败:', error);
    showNotification('无法加载门店位置,请重试。');
    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' });
}
javascript
// 添加ARIA标签
document.getElementById('search-input').setAttribute('aria-label', '搜索门店');

// 键盘导航
document.querySelectorAll('.listing').forEach((listing, index) => {
  listing.setAttribute('tabindex', '0');
  listing.setAttribute('role', 'button');
  listing.setAttribute('aria-label', `查看${listing.querySelector('.title').textContent}`);

  listing.addEventListener('keypress', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      listing.click();
    }
  });
});

// 焦点管理
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;
  }
}
css
/* 移动端优先:侧边栏堆叠在上方 */
@media (max-width: 768px) {
  #app {
    flex-direction: column;
  }

  .sidebar {
    width: 100%;
    height: 40vh;
    max-height: 40vh;
  }

  #map {
    height: 60vh;
  }

  /* 侧边栏切换 */
  .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 = `
  <button id="toggle-list">View All Locations (${stores.features.length})</button>
  <div id="listings" class="hidden"></div>
`;

document.getElementById('toggle-list').addEventListener('click', () => {
  document.getElementById('listings').classList.toggle('hidden');
});
javascript
// 地图占满全屏,列表作为覆盖层展示
const listOverlay = document.createElement('div');
listOverlay.className = 'list-overlay';
listOverlay.innerHTML = `
  <button id="toggle-list">查看所有地点 (${stores.features.length})</button>
  <div id="listings" class="hidden"></div>
`;

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(
      `
      <div class="store-popup">
        <h3>${store.properties.name}</h3>
        <p class="address">${store.properties.address}</p>
        <p class="phone">${store.properties.phone}</p>
        <p class="hours">${store.properties.hours}</p>
        ${store.properties.distance ? `<p class="distance">${store.properties.distance} mi away</p>` : ''}
        <div class="actions">
          <button onclick="getDirections('${store.properties.id}')">Directions</button>
          <button onclick="callStore('${store.properties.phone}')">Call</button>
          ${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Website</a>` : ''}
        </div>
      </div>
    `
    )
    .addTo(map);
}
javascript
// 无侧边栏,所有操作在弹窗中完成
function createDetailedPopup(store) {
  const popup = new mapboxgl.Popup({ maxWidth: '400px' })
    .setLngLat(store.geometry.coordinates)
    .setHTML(
      `
      <div class="store-popup">
        <h3>${store.properties.name}</h3>
        <p class="address">${store.properties.address}</p>
        <p class="phone">${store.properties.phone}</p>
        <p class="hours">${store.properties.hours}</p>
        ${store.properties.distance ? `<p class="distance">距离您${store.properties.distance}英里</p>` : ''}
        <div class="actions">
          <button onclick="getDirections('${store.properties.id}')">获取路线</button>
          <button onclick="callStore('${store.properties.phone}')">拨打电话</button>
          ${store.properties.website ? `<a href="${store.properties.website}" target="_blank">访问官网</a>` : ''}
        </div>
      </div>
    `
    )
    .addTo(map);
}

Framework Integration

框架集成

React Implementation

React实现

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 (
    <div className="store-locator">
      <Sidebar
        stores={filteredStores}
        selectedStore={selectedStore}
        onStoreClick={setSelectedStore}
        onFilter={setFilteredStores}
      />
      <div ref={mapContainer} className="map-container" />
    </div>
  );
}
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();
  }, []);

  // 筛选后的门店变化时更新数据源
  useEffect(() => {
    if (map.current && map.current.getSource('stores')) {
      map.current.getSource('stores').setData(filteredStores);
    }
  }, [filteredStores]);

  return (
    <div className="store-locator">
      <Sidebar
        stores={filteredStores}
        selectedStore={selectedStore}
        onStoreClick={setSelectedStore}
        onFilter={setFilteredStores}
      />
      <div ref={mapContainer} className="map-container" />
    </div>
  );
}

Resources

参考资源