房产搜索本质上是一个空间问题。买家和租客考虑的是街区、通勤时间和离学校有多近,而不是邮编和街道列表。以地图为主的界面更符合用户的找房方式,因为它让用户先在空间上确定搜索范围,再按价格、卧室数量或其他属性进行筛选。
本教程将构建一个可用于生产环境的房源地图:低缩放级别下的聚类标记、单个标记上的价格标签、点击后显示房源详情的弹窗、可重新定位地图中心的邮编搜索,以及实时筛选可见房源的价格范围滑块。React 组件本身约 65 行,加上图层和点击处理程序的完整实现约 150 行。如果你不想使用框架,同样的逻辑也适用于原生 JavaScript。
文中还会说明欧盟 PropTech 平台需要履行的 GDPR 义务,因为房产搜索产生的位置数据属于个人数据,必须相应处理。
如果你刚接触地图 API,可以先阅读 如何在网站中添加交互式地图 教程,它介绍了本教程所需的基础知识。
房源数据结构
每条房源需要坐标、价格,以及足够填充弹窗的元数据。把数据保存为 GeoJSON FeatureCollection,它可以直接接入地图数据源,无需转换。
const properties = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [4.8952, 52.3702] },
properties: {
id: "prop-001",
price: 485000,
bedrooms: 3,
sqm: 112,
address: "Keizersgracht 142, Amsterdam",
type: "apartment",
status: "for-sale"
}
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [4.9123, 52.3601] },
properties: {
id: "prop-002",
price: 1250,
bedrooms: 2,
sqm: 78,
address: "Sarphatistraat 58, Amsterdam",
type: "apartment",
status: "for-rent"
}
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [4.8801, 52.3780] },
properties: {
id: "prop-003",
price: 720000,
bedrooms: 4,
sqm: 195,
address: "Herengracht 380, Amsterdam",
type: "house",
status: "for-sale"
}
}
]
};
在真实平台中,这个数组来自你的房源 API,通常在地图加载或查询参数变化时通过 fetch 调用获取。
初始化地图
以你所在房产市场的中心位置初始化地图。MapAtlas Maps API 与 Mapbox GL JS 兼容,因此初始化代码与 Mapbox 的写法相同,只需换成 MapAtlas 的瓦片 URL。
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const map = new mapmetricsgl.Map({
container: 'map',
style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=YOUR_API_KEY',
center: [4.9041, 52.3676],
zoom: 13
});
明亮、低对比度的样式特别适合房产地图,浅色底图能让价格标签和彩色标记更加醒目,又不会显得杂乱。
带价格标签的聚类
房产地图最关键的界面模式,是在单个标记上显示价格标签,在聚类圆圈上显示数量。数据源选项 cluster: true 会自动把相邻的点分组。你需要分别为聚类和单个标记添加图层。
map.on('load', () => {
map.addSource('properties', {
type: 'geojson',
data: properties,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 60
});
// 聚类圆圈,大小随房源数量变化
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'properties',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#2563EB',
'circle-radius': [
'step', ['get', 'point_count'],
22, 5, 30, 20, 38
],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
});
// 聚类数量标签
map.addLayer({
id: 'cluster-label',
type: 'symbol',
source: 'properties',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
});
// 单个房源标记,白色描边的价格文字
map.addLayer({
id: 'property-price',
type: 'symbol',
source: 'properties',
filter: ['!', ['has', 'point_count']],
layout: {
'text-field': [
'concat',
'€',
['to-string', ['round', ['/', ['get', 'price'], 1000]]],
'k'
],
'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
'text-size': 12
},
paint: {
'text-color': '#1e293b',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
});
});
点击交互
点击聚类时,地图会放大并展开其中的单个房源。点击单个房源时,会打开详情弹窗。
// 放大到被点击的聚类
map.on('click', 'clusters', (e) => {
const [feature] = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
map.getSource('properties').getClusterExpansionZoom(
feature.properties.cluster_id,
(err, zoom) => {
if (!err) map.easeTo({ center: feature.geometry.coordinates, zoom });
}
);
});
// 单个房源的详情弹窗
map.on('click', 'property-price', (e) => {
const { address, price, bedrooms, sqm, status, id } = e.features[0].properties;
const coords = e.features[0].geometry.coordinates.slice();
const formattedPrice = status === 'for-rent'
? `€${price.toLocaleString()}/mo`
: `€${price.toLocaleString()}`;
new mapmetricsgl.Popup({ offset: 10 })
.setLngLat(coords)
.setHTML(`
<div style="min-width:200px">
<strong style="font-size:15px">${formattedPrice}</strong>
<p style="margin:4px 0">${address}</p>
<p style="margin:4px 0;color:#64748b">${bedrooms} bed · ${sqm} m²</p>
<a href="/listings/${id}" style="color:#2563EB;font-weight:600">View listing →</a>
</div>
`)
.addTo(map);
});
// 悬停时切换鼠标指针
map.on('mouseenter', 'property-price', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'property-price', () => { map.getCanvas().style.cursor = ''; });
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; });
价格范围筛选
用于筛选可见房源的范围滑块,是房产地图中价值最高的界面功能之一。借助 Mapbox GL JS 的表达式系统,你可以在客户端更新图层过滤条件,无需网络往返,筛选直接在浏览器中对已加载的 GeoJSON 进行。
function applyPriceFilter(min, max) {
const filter = ['all',
['!', ['has', 'point_count']],
['>=', ['get', 'price'], min],
['<=', ['get', 'price'], max]
];
map.setFilter('property-price', filter);
}
// 绑定范围输入框
const minInput = document.getElementById('price-min');
const maxInput = document.getElementById('price-max');
function onRangeChange() {
applyPriceFilter(Number(minInput.value), Number(maxInput.value));
}
minInput.addEventListener('input', onRangeChange);
maxInput.addEventListener('input', onRangeChange);
注意,聚类是根据数据源中的数据计算的,并不参考图层过滤条件,因此聚类计数仍会包含被滑块隐藏的房源。要让两者保持一致,请在 JavaScript 中筛选房源,再把结果传给 map.getSource('properties').setData()。
使用 Geocoding API 进行地址搜索
让用户输入街区名称或邮编来重新定位地图。Geocoding API 返回 GeoJSON 要素,坐标可以直接传给 map.flyTo。
async function searchArea(query) {
const url = new URL('https://gateway.mapmetrics-atlas.net/forward-geocode/');
url.searchParams.set('text', query);
url.searchParams.set('token', 'YOUR_API_KEY');
url.searchParams.set('size', '1');
const res = await fetch(url);
const data = await res.json();
if (!data.features.length) return;
const [lng, lat] = data.features[0].geometry.coordinates;
map.flyTo({ center: [lng, lat], zoom: 13 });
}
document.getElementById('area-search').addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchArea(e.target.value.trim());
});
如果要实现按通行时间搜索,比如「显示离这所学校 20 分钟以内的房源」,可以使用 MapAtlas Travel Time API 添加等时线叠加层。等时线地图详解 一文介绍了如何获取并显示该多边形。
完整的 React 组件
把上面的所有内容封装进一个 React 组件,并正确管理生命周期:
import { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
export function PropertyMap({ listings, apiKey }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const [priceRange, setPriceRange] = useState([0, 2000000]);
useEffect(() => {
const map = new mapmetricsgl.Map({
container: containerRef.current,
style: `https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=${apiKey}`,
center: [4.9041, 52.3676],
zoom: 13
});
mapRef.current = map;
map.on('load', () => {
map.addSource('properties', {
type: 'geojson',
data: { type: 'FeatureCollection', features: listings },
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 60
});
// 添加 cluster、cluster-label、property-price 图层(见上文)
// 添加点击事件处理(见上文)
});
return () => map.remove();
}, [apiKey]);
// 价格范围滑块变化时更新过滤条件
useEffect(() => {
const map = mapRef.current;
if (!map || !map.getLayer('property-price')) return;
map.setFilter('property-price', [
'all',
['!', ['has', 'point_count']],
['>=', ['get', 'price'], priceRange[0]],
['<=', ['get', 'price'], priceRange[1]]
]);
}, [priceRange]);
return (
<div>
<div style={{ padding: '12px 0', display: 'flex', gap: 12 }}>
<label>
Min €
<input type="range" min={0} max={2000000} step={10000}
value={priceRange[0]}
onChange={e => setPriceRange([+e.target.value, priceRange[1]])} />
</label>
<label>
Max €
<input type="range" min={0} max={2000000} step={10000}
value={priceRange[1]}
onChange={e => setPriceRange([priceRange[0], +e.target.value])} />
</label>
</div>
<div ref={containerRef} style={{ width: '100%', height: '600px' }} />
</div>
);
}
这是组件的基本框架,包括价格范围滑块和生命周期清理在内约 65 行;再加上前文各节中的图层和点击处理代码,即可完成整个组件。
欧盟 PropTech 的 GDPR 注意事项
当找房用户在你的地图上输入自己的住址,或点击「使用我的位置」时,他们分享的是 GDPR 第 4 条第 (1) 项所定义的个人数据,该条款明确把位置数据列为标识符。这些位置数据会暴露用户的居住地,并可能由此推断出其他敏感属性。
满足欧盟合规要求的实操步骤:
- 通过后端转发地理编码请求,而不是在浏览器中直接调用 Geocoding API。后端可以在转发前去掉可识别身份的请求头。
- 除非具备 GDPR 第 6 条规定的、有书面记录的合法依据(例如用户同意),否则不要在会话结束后记录或保存搜索坐标。
- 如果你实现了「附近有符合条件的房源时通知我」这类功能,应把保存的搜索区域视为个人数据,并设定明确的保留期限。
欧盟开发者 GDPR 合规地图 API 指南 对此有完整介绍,包括数据处理协议和数据驻留要求。MapAtlas 在欧盟境内处理数据,并提供 GDPR 第 28 条第 3 款要求控制者与处理者之间签订的数据处理协议(Data Processing Agreement)。
房地产行业解决方案页面 进一步介绍了 PropTech 平台如何在生产环境中使用 MapAtlas。
后续步骤
- 注册免费的 MapAtlas API 密钥,从免费套餐开始
- 使用 Travel Time API 添加通行时间叠加层,向买家展示 20 分钟内可到达的范围
- 查看 Maps API 样式指南,让地图与你的房产平台视觉风格保持一致
常见问题
可以把 MapAtlas 用于房源网站吗?
可以。MapAtlas 覆盖了房产网站所需的功能:房产搜索地图、投资仪表板和租赁房源地图。Maps API 与 Mapbox GL JS 兼容,因此基于 Mapbox 的现有房产地图代码只需少量修改即可迁移。
房产地图中的标记聚类是如何工作的?
地图缩小时,聚类会把相邻的房源标记合并成一个显示数量的圆圈。用户放大时,聚类会拆分为单个标记。MapAtlas 原生支持 GeoJSON 数据源聚类,只需在数据源上设置 cluster: true,其余工作由 SDK 完成。
找房用户的位置数据受 GDPR 约束吗?
受约束。当用户在房产网站上按住址或当前位置搜索时,这些信息属于 GDPR 第 4 条所定义的个人数据。欧盟 PropTech 平台应通过后端代理转发地理编码请求,并执行相应的数据最小化和保留策略,而不是在客户端直接调用 API,导致用户位置被记录到第三方服务器上。
常见问题
可以把 MapAtlas 用于房源网站吗?
可以。MapAtlas 覆盖了房产网站所需的功能:房产搜索地图、投资仪表板和租赁房源地图。Maps API 与 Mapbox GL JS 兼容,因此基于 Mapbox 的现有房产地图代码只需少量修改即可迁移。
房产地图中的标记聚类是如何工作的?
地图缩小时,聚类会把相邻的房源标记合并成一个显示数量的圆圈。用户放大时,聚类会拆分为单个标记。MapAtlas 原生支持 GeoJSON 数据源聚类,只需在数据源上设置 cluster: true,其余工作由 SDK 完成。
找房用户的位置数据受 GDPR 约束吗?
受约束。当用户在房产网站上按住址或当前位置搜索时,这些信息属于 GDPR 第 4 条所定义的个人数据。欧盟 PropTech 平台应通过后端代理转发地理编码请求,并执行相应的数据最小化和保留策略,而不是在客户端直接调用 API,导致用户位置被记录到第三方服务器上。

