부동산 검색은 공간적 문제입니다. 구매자와 임차인은 우편번호와 거리 목록이 아닌 이웃, 통근 시간 및 학교 근처를 기준으로 생각합니다. 지도 우선 인터페이스는 목록 우선 인터페이스보다 더 잘 작동합니다. 사용자가 가격, 침실 또는 기타 특성으로 필터링하기 전에 공간적으로 검색을 고정할 수 있기 때문입니다.
이 튜토리얼은 프로덕션 준비가 된 부동산 목록 지도를 만듭니다: 낮은 줌에서 클러스터된 마커, 개별 마커의 가격 레이블, 클릭으로 팝업 열기 및 목록 세부 정보 표시, 지도를 다시 가운데에 맞추는 우편번호 검색, 실시간으로 표시되는 목록을 필터링하는 가격 범위 슬라이더입니다. 완전한 React 구성 요소는 100줄 미만으로 제공됩니다. 프레임워크 없음을 선호하면 동일한 논리가 바닐라 JavaScript에서 작동합니다.
또한 EU PropTech 플랫폼별 GDPR 의무 사항에 대한 참고를 찾을 수 있습니다. 부동산 검색으로 생성된 위치 데이터는 개인 데이터이므로 그에 따라 처리해야 하기 때문입니다.
지도 API를 처음 사용하는 경우 웹사이트에 대화형 지도를 추가하는 방법 튜토리얼에서 기본 사항을 다룬 후 이 튜토리얼을 진행합니다.
Property Data Structure
Each listing needs coordinates, a price, and enough metadata for the popup. Keep this as a GeoJSON FeatureCollection, it plugs directly into the map source without transformation.
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"
}
}
]
};
In a real platform this array comes from your listings API, a fetch call on map load or a query parameter change.
Setting Up the Map
Initialize the map with a central view over your property market. The MapAtlas Maps API is Mapbox GL JS-compatible, so the initialisation call is identical to what you would write for Mapbox, with a MapAtlas tile URL.
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const map = new mapmetricsgl.Map({
container: 'map',
style: 'https://tiles.mapatlas.eu/styles/bright/style.json?key=YOUR_API_KEY',
center: [4.9041, 52.3676],
zoom: 13
});
The Bright style works particularly well for property maps, the lighter base lets price labels and coloured markers stand out without visual clutter.
Clustering with Price Labels
The key UI pattern for a property map is showing price labels on individual markers and count bubbles on cluster circles. The cluster: true source option groups nearby points automatically. You add separate layers for clusters and individual markers.
map.on('load', () => {
map.addSource('properties', {
type: 'geojson',
data: properties,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 60
});
// Cluster circles, size scales with listing count
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'
}
});
// Cluster count labels
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' }
});
// Individual property markers, white circle with price text
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
}
});
});
Click Interactions
Clicking a cluster zooms the map to reveal its individual listings. Clicking an individual property opens a detail popup.
// Zoom into clicked cluster
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 });
}
);
});
// Detail popup for individual listing
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);
});
// Change cursor on hover
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 = ''; });
Price Range Filter
A range slider that filters visible listings is one of the highest-value UI additions for a property map. The Mapbox GL JS expression system lets you update a source's filter client-side without a network round-trip, the filtering happens in-browser on the already-loaded GeoJSON.
function applyPriceFilter(min, max) {
const filter = ['all',
['!', ['has', 'point_count']],
['>=', ['get', 'price'], min],
['<=', ['get', 'price'], max]
];
map.setFilter('property-price', filter);
}
// Wire up range inputs
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);
Note that the cluster layer updates automatically when the underlying source changes, properties filtered out of the individual marker layer also drop out of the cluster counts.
Address Search with the Geocoding API
Let users type a neighbourhood or postcode to recentre the map. The Geocoding API returns GeoJSON features, so coordinates drop straight into map.flyTo.
async function searchArea(query) {
const url = new URL('https://api.mapatlas.eu/geocoding/v1/search');
url.searchParams.set('text', query);
url.searchParams.set('key', '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());
});
For travel-time search, "show me properties within 20 minutes of this school", add an isochrone overlay using the MapAtlas Travel Time API. The Isochrone Maps Explained article shows how to fetch and display that polygon.
The Complete React Component
Wrapping everything above into a React component with proper lifecycle management:
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://tiles.mapatlas.eu/styles/bright/style.json?key=${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
});
// Add cluster, cluster-label, property-price layers (see above)
// Add click handlers (see above)
});
return () => map.remove();
}, [apiKey]);
// Update filter when price range slider changes
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>
);
}
That is the full component, under 100 lines including the price range slider and lifecycle cleanup.
GDPR Notes for EU PropTech
When a property seeker types their home address or clicks "use my location" on your map, they are sharing personal data under GDPR Article 4. That location data reveals where they live, which may infer other sensitive attributes about them.
Practical steps for EU compliance:
- Route geocoding requests through your backend rather than calling the Geocoding API directly from the browser. Your backend can strip identifying headers before forwarding.
- Do not log or persist search coordinates beyond the session unless you have a documented legal basis and have collected specific consent.
- If you implement "notify me when a property near me matches my criteria", treat the stored search area as personal data with a defined retention period.
The EU Developer's Guide to GDPR-Compliant Map APIs covers this in full, including processor agreements and data residency requirements. MapAtlas processes all data within the EU and provides the Data Processing Agreement required under GDPR Article 28.
The Real Estate industry solution page has additional context on how PropTech platforms use MapAtlas in production.
Next Steps
- Sign up for a free MapAtlas API key and start with the free tier
- Add travel-time overlays with the Travel Time API, show buyers what is reachable in 20 minutes
- Explore the Maps API styling guide to match your property platform's visual identity
Frequently Asked Questions
Can I use MapAtlas for a property listing website?
Yes. MapAtlas is used by PropTech platforms across the EU for property search maps, investment dashboards, and rental listing sites. The Maps API is Mapbox GL JS-compatible, so existing Mapbox-based property map code migrates with minimal changes.
How does marker clustering work for real estate maps?
Clustering groups nearby property markers into a single circle showing the count when the map is zoomed out. As the user zooms in, clusters split into individual markers. MapAtlas supports GeoJSON source clustering natively, set cluster: true on the source and the SDK handles the rest.
Is location data from property searchers subject to GDPR?
Yes. When a user searches by their home address or current location on a property site, that constitutes personal data under GDPR Article 4. EU PropTech platforms should route geocoding requests through a backend proxy and apply appropriate data minimisation and retention policies rather than making client-side API calls that log user locations to third-party servers.

