每个零售品牌、特许经营店和服务企业最终都需要一个门店定位器。这是客户已经想要购买、只是需要知道走进哪个分店时访问的页面。搞错这个页面会真实损失转化。做对比大多数开发者预期的要简单。
标准方法一直是Google Maps,但随着规模增长,这种成本结构会很难受。一个有50,000个月度访问者加载地图页面的繁忙零售网站一夜之间就可能产生数百欧元的地图加载和地理编码费用。生产使用已经没有免费层级,账单不透明,欧盟企业使用美国地图服务的GDPR情况增加了额外的合规开销。
本教程使用MapAtlas Maps API和Geocoding API构建了一个完整的门店定位器、交互式地图、地址搜索、同步列表面板、标记聚类和点击查看详情弹出窗口。无需框架。完整的可工作示例仅需约80行HTML和JavaScript。您可以在读这篇文章的同一天下午将其放入WordPress自定义HTML块、Shopify部分或任何CMS。
最后您将拥有:
- 以您的门店网络为中心的渲染矢量地图
- 从JSON数据数组加载的标记,在低缩放级别进行聚类
- 由Geocoding API支持的地址搜索栏
- 在地图点击时突出显示的同步列表面板
- 移动响应式两列布局
门店定位器实际需要什么
在写任何代码之前,精确说明需求会有帮助。一个可工作的门店定位器有四个活动部分:
- 地图渲染瓦片、接受平移和缩放,并显示标记。
- 搜索输入将用户输入的地址地理编码为坐标,然后重新居中地图。
- 标记层绘制每个门店位置,在低缩放级别聚集附近的引脚,并在点击时打开详细弹出窗口。
- 列表面板显示从搜索位置按距离排序的门店,突出显示活跃的门店,并与地图同步滚动。
就这样。其他每个功能(方向、营业时间、库存)都是在这四个功能之上分层的增强。先构建核心。
Step 1: Load the MapAtlas SDK
The MapAtlas Maps API is compatible with the Mapbox GL JS interface, so any Mapbox GL JS tutorial or plugin works directly. Add the CDN links to your page <head>:
<link
rel="stylesheet"
href="https://unpkg.com/@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css"
/>
<script src="https://unpkg.com/@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.js"></script>
If you are using npm:
npm install @mapmetrics/mapmetrics-gl
Get your free API key at portal.mapmetrics.org/signup. One key covers map tiles, geocoding, and routing, no separate credentials to juggle.
Step 2: Define Your Store Data
Store data is just a GeoJSON FeatureCollection. Each feature carries the store's coordinates and whatever properties your popup needs: name, address, phone, opening hours.
const stores = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [4.9041, 52.3676] },
properties: {
id: 1,
name: "Amsterdam Central",
address: "Stationsplein 12, 1012 AB Amsterdam",
phone: "+31 20 123 4567",
hours: "Mon–Sat 09:00–20:00"
}
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [4.4777, 51.9244] },
properties: {
id: 2,
name: "Rotterdam Lijnbaan",
address: "Lijnbaan 10, 3012 EL Rotterdam",
phone: "+31 10 987 6543",
hours: "Mon–Sat 09:00–21:00"
}
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [5.1214, 52.0907] },
properties: {
id: 3,
name: "Utrecht Centrum",
address: "Oudegracht 45, 3511 AB Utrecht",
phone: "+31 30 555 1234",
hours: "Mon–Sun 10:00–19:00"
}
}
]
};
In production you would fetch this from an API endpoint or a CMS. The structure stays the same, the only difference is where the data originates.
Step 3: Render the Map and Add Clustering
Initialize the map, add the store data as a GeoJSON source with clustering enabled, and paint the cluster circles and individual pin layer. Mapbox GL JS clustering is built into the source definition, no plugin required.
const map = new mapmetricsgl.Map({
container: 'map',
style: 'https://tiles.mapatlas.eu/styles/basic/style.json?key=YOUR_API_KEY',
center: [5.2913, 52.1326], // Centre of the Netherlands
zoom: 7
});
map.on('load', () => {
// Add GeoJSON source with clustering
map.addSource('stores', {
type: 'geojson',
data: stores,
cluster: true,
clusterMaxZoom: 12,
clusterRadius: 50
});
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#3B82F6',
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 28, 30, 36]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'stores',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 13
},
paint: { 'text-color': '#ffffff' }
});
// Individual store pins
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'stores',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#EF4444',
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
});
Clicking a cluster zooms in to reveal individual stores. Clicking an unclustered pin opens a popup.
Step 4: Wire Up Popups and the List Panel
When a user clicks a store pin, show a popup on the map and highlight the matching card in the list panel. Both interactions should be bidirectional, clicking a list card should also fly the map to that store.
// Click unclustered store → open popup + highlight list card
map.on('click', 'unclustered-point', (e) => {
const { coordinates } = e.features[0].geometry;
const { name, address, phone, hours, id } = e.features[0].properties;
new mapmetricsgl.Popup()
.setLngLat(coordinates)
.setHTML(`
<strong>${name}</strong>
<p>${address}</p>
<p>${phone}</p>
<p>${hours}</p>
`)
.addTo(map);
highlightCard(id);
});
// Click cluster → zoom in
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 });
});
});
function highlightCard(id) {
document.querySelectorAll('.store-card').forEach(card => {
card.classList.toggle('active', card.dataset.id === String(id));
});
}
// Build list panel from store data
function buildListPanel() {
const list = document.getElementById('store-list');
stores.features.forEach(({ properties, geometry }) => {
const card = document.createElement('div');
card.className = 'store-card';
card.dataset.id = properties.id;
card.innerHTML = `
<strong>${properties.name}</strong>
<p>${properties.address}</p>
<small>${properties.hours}</small>
`;
card.addEventListener('click', () => {
map.flyTo({ center: geometry.coordinates, zoom: 14 });
highlightCard(properties.id);
});
list.appendChild(card);
});
}
Step 5: Add Address Search with the Geocoding API
The search bar takes a user's typed location, geocodes it via the Geocoding API, flies the map to that point, and re-sorts the list panel by distance.
async function searchLocation(query) {
const url = new URL('https://api.mapatlas.eu/geocoding/v1/search');
url.searchParams.set('text', query);
url.searchParams.set('key', 'YOUR_API_KEY');
const res = await fetch(url);
const data = await res.json();
if (!data.features.length) {
alert('Address not found. Try a city or postcode.');
return;
}
const [lng, lat] = data.features[0].geometry.coordinates;
// Fly map to searched location
map.flyTo({ center: [lng, lat], zoom: 10 });
// Sort list by distance from searched point
const sorted = [...stores.features].sort((a, b) => {
const distA = haversine(lat, lng, a.geometry.coordinates[1], a.geometry.coordinates[0]);
const distB = haversine(lat, lng, b.geometry.coordinates[1], b.geometry.coordinates[0]);
return distA - distB;
});
document.getElementById('store-list').innerHTML = '';
sorted.forEach(feature => {
// Re-render each card (reuse buildListPanel logic)
});
}
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
document.getElementById('search-btn').addEventListener('click', () => {
const query = document.getElementById('search-input').value.trim();
if (query) searchLocation(query);
});
Step 6: Mobile-Responsive Layout
A store locator on mobile must stack vertically, map on top, list below, rather than side by side. Twenty lines of CSS handles this with a single media query breakpoint.
#locator-wrapper {
display: flex;
height: 600px;
gap: 0;
}
#store-list {
width: 300px;
overflow-y: auto;
border-right: 1px solid #e5e7eb;
padding: 12px;
}
#map {
flex: 1;
}
.store-card {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 8px;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.store-card.active {
border-color: #3B82F6;
background: #EFF6FF;
}
@media (max-width: 640px) {
#locator-wrapper {
flex-direction: column;
height: auto;
}
#store-list {
width: 100%;
border-right: none;
border-top: 1px solid #e5e7eb;
height: 280px;
}
#map {
height: 350px;
}
}
与Google Maps的账单和GDPR对比
如果您一直在零售网站上运行Google Maps并想知道为什么这个月的账单比预期高,您不孤单。Maps JavaScript API按地图加载收费。Places API按自动完成会话和地理编码请求收费。这些成本迅速复合。一个每月有50,000次访问的网站,每次都加载一次门店定位器页面,仅在地图加载上每月花费约140欧元,还没计算单个地理编码调用。
MapAtlas使用固定月费计划。没有按加载或按请求的费用会突然飙升。您可以在2026年Google Maps API定价:真实成本分解和MapAtlas与Google Maps对比中阅读完整明细。
对于欧盟开发者,GDPR角度也很重要。Google Maps通过美国基础设施路由数据。MapAtlas是欧盟托管、ISO 27001认证,在欧盟内处理所有请求。对于已经仔细管理客户同意的零售企业,使用欧盟本地的地图提供商可以从隐私政策中去除一个第三方转移。
把所有东西放在一起
完整的门店定位器、HTML结构、CSS布局、地图初始化、聚类、弹出窗口处理、列表面板、搜索和距离排序都可以舒适地放在一个文件中。结构如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Store Locator</title>
<link rel="stylesheet"
href="https://unpkg.com/@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css" />
<script src="https://unpkg.com/@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.js"></script>
<style>
/* paste the CSS from Step 6 here */
</style>
</head>
<body>
<div id="search-bar">
<input id="search-input" type="text" placeholder="Enter your city or postcode…" />
<button id="search-btn">Search</button>
</div>
<div id="locator-wrapper">
<div id="store-list"></div>
<div id="map"></div>
</div>
<script>
// paste store data, map init, clustering, popup, list panel, and search from Steps 2–5
</script>
</body>
</html>
结果是一个生产就绪的门店定位器,除MapAtlas SDK外没有外部依赖。没有构建步骤、没有框架,也没有持续的账单惊喜。
如果您需要添加路由,「从我的位置获取到这个门店的方向」,Routing API获取用户坐标和门店坐标,返回您可以作为线层在地图上绘制的完整转向路由。如何向您的网站添加交互式地图教程详细涵盖了该下一步。
后续步骤
- 注册免费MapAtlas API密钥,无需信用卡
- 浏览Maps API文档查看聚类、自定义样式和图层选项
- 探索Geocoding API获取邮编查询、反向地理编码和地址自动完成
常见问题
我可以在没有Google Maps的情况下构建门店定位器吗?
可以。MapAtlas提供Mapbox GL JS兼容的Maps API和Geocoding API,涵盖门店定位器需要的每项功能:交互式地图、地址搜索、标记聚类和弹出窗口,无按加载计费,完全符合GDPR。
在MapAtlas与Google Maps上运行门店定位器成本对比如何?
MapAtlas的成本大约比Google Maps低75%。Google Maps按地图加载和地理编码请求收费,对于繁忙的零售网站成本会迅速增加。MapAtlas使用固定月费计划,无按请求的意外费用。
MapAtlas能在WordPress和Shopify上运行吗?
可以。因为MapAtlas是纯JavaScript,没有框架依赖,您可以将其嵌入WordPress自定义HTML块、Shopify主题部分或任何允许您添加脚本标签和div的CMS。
常见问题
我可以在没有Google Maps的情况下构建门店定位器吗?
可以。MapAtlas提供Mapbox GL JS兼容的Maps API和Geocoding API,涵盖门店定位器需要的每项功能:交互式地图、地址搜索、标记聚类和弹出窗口,无按加载计费,完全符合GDPR。
在MapAtlas与Google Maps上运行门店定位器成本对比如何?
MapAtlas的成本大约比Google Maps低75%。Google Maps按地图加载和地理编码请求收费,对于繁忙的零售网站成本会迅速增加。MapAtlas使用固定月费计划,无按请求的意外账单。
MapAtlas能在WordPress和Shopify上运行吗?
可以。因为MapAtlas是纯JavaScript,没有框架依赖,您可以将其嵌入WordPress自定义HTML块、Shopify主题部分或任何允许您添加脚本标签和div的CMS。

