mapbox-store-locator-patterns by mapbox/mapbox-agent-skills
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-store-locator-patterns使用 Mapbox GL JS 构建店铺定位器、餐厅查找器和基于位置的搜索应用程序的全面模式。涵盖标记显示、筛选、距离计算、交互式列表和路线集成。
在构建以下应用程序时使用此技能:
必需:
安装:
npm install mapbox-gl @turf/turf
典型的店铺定位器包含:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
位置的 GeoJSON 格式:
{
"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 - 用于筛选(零售、餐厅、办公室等)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
});
选项 1:HTML 标记(< 100 个位置)
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 个位置)
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 个位置)
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);
});
});
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);
});
文本搜索:
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);
});
类别筛选:
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);
});
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);
});
});
}
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>`
);
});
}
}
<!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>
/* 自定义标记样式 */
.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;
}
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);
});
// ✅ 良好:一次性加载数据,在内存中筛选
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());
}
// 地理定位错误处理
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: [] };
}
}
// 添加 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' });
}
/* 移动优先:侧边栏堆叠在顶部 */
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 40vh;
max-height: 40vh;
}
#map {
height: 60vh;
}
/* 切换侧边栏 */
.sidebar.collapsed {
height: 60px;
}
}
// 地图占据全屏,列表作为覆盖层出现
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');
});
// 无侧边栏,所有内容都在弹出窗口中
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);
}
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>
);
}
每周安装量
207
仓库
GitHub 星标数
35
首次出现
2026年2月5日
安全审计
安装于
gemini-cli192
opencode190
github-copilot188
codex187
kimi-cli180
amp180
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.
Use this skill when building applications that:
Required:
Installation:
npm install mapbox-gl @turf/turf
A typical store locator consists of:
GeoJSON format for locations:
{
"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 locationname - Display nameaddress - Full address for display and geocodingcoordinates - [longitude, latitude] formatcategory - For filtering (retail, restaurant, office, etc.)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
});
Option 1: HTML Markers ( < 100 locations)
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)
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)
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);
});
});
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);
});
Text Search:
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:
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);
});
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);
});
});
}
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>`
);
});
}
}
<!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>
/* 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;
}
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);
});
// ✅ 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());
}
// 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: [] };
}
}
// 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' });
}
/* 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;
}
}
// 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');
});
// 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);
}
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>
);
}
Weekly Installs
207
Repository
GitHub Stars
35
First Seen
Feb 5, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykFail
Installed on
gemini-cli192
opencode190
github-copilot188
codex187
kimi-cli180
amp180
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
111,800 周安装