物件検索は空間的な問題だ。買い手と借り手は郵便番号や通り一覧ではなく、近隣、通勤時間、学校への近接性で考える。マップファーストのインターフェースはリストファーストのインターフェースよりもコンバージョンが良い。ユーザーが価格、寝室数、その他の属性でフィルタリングする前に検索を空間的に固定できるからだ。
このチュートリアルでは、本番環境に対応した物件一覧マップを構築する。低ズームでクラスター化されたマーカー、個々のマーカーに価格ラベル、物件詳細付きクリックポップアップ、マップを再センタリングする郵便番号検索、そしてリアルタイムで表示される物件をフィルタリングする価格帯スライダー。完全なReactコンポーネントは100行以内に収まる。フレームワークを使わない場合、同じロジックがバニラJavaScriptでも動作する。
EU PropTechプラットフォームに固有のGDPR義務についても説明する。物件検索で生成される位置情報データは個人データであり、適切に処理する必要があるからだ。
Map 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互換なので、MapAtlasタイルURLを使用してMapbox向けに書くのと同じ初期化コールになる。
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
});
Brightスタイルは物件マップに特によく合う。より明るいベースにより、価格ラベルとカラーマーカーが視覚的なノイズなしに際立つ。
価格ラベル付きクラスタリング
物件マップの主要なUIパターンは、個々のマーカーに価格ラベルを表示し、クラスター円にカウントバブルを表示することだ。cluster: trueソースオプションは近くのポイントを自動的にグループ化する。クラスターと個々のマーカー用に別々のレイヤーを追加する。
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
}
});
});
クリックインタラクション
クラスターをクリックするとマップがズームして個々のリストが表示される。個々の物件をクリックすると詳細ポップアップが開く。
// 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 = ''; });
価格帯フィルター
表示される物件をフィルタリングする範囲スライダーは、物件マップにとって最も価値の高いUI追加の一つだ。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);
}
// 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);
クラスターレイヤーは基になるソースが変更されると自動的に更新される。個々のマーカーレイヤーからフィルタリングされた物件はクラスターカウントからも外れる。
ジオコーディングAPIを使った住所検索
ユーザーが近隣や郵便番号を入力してマップを再センタリングできるようにする。ジオコーディングAPIはGeoJSONフィーチャーを返すので、座標は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());
});
移動時間検索「この学校から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://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>
);
}
これが完全なコンポーネントだ。価格帯スライダーとライフサイクルクリーンアップを含めて100行未満。
EU PropTech向けGDPRメモ
物件購入者がマップで自宅の住所を入力したり「現在地を使用」をクリックしたりすると、GDPR第4条に基づいて個人データを共有している。その位置情報データは彼らの住居を明らかにし、それについての他の機密属性を推測させる可能性がある。
EUコンプライアンスのための実践的なステップ:
- ブラウザから直接ジオコーディングAPIを呼び出すのではなく、バックエンドを通じてジオコーディングリクエストをルーティングする。バックエンドは転送前に識別ヘッダーを削除できる。
- 文書化された法的根拠と特定の同意を収集していない限り、セッションを超えて検索座標をログまたは保持しない。
- 「近くの物件が条件に一致したら通知する」を実装する場合、保存された検索エリアを定義された保持期間を持つ個人データとして扱う。
EU開発者向けGDPR準拠マップAPIガイドは、プロセッサー契約とデータ残留要件を含めてこれを完全にカバーしている。MapAtlasはEU内のすべてのデータを処理し、GDPR第28条で必要とされるデータ処理契約を提供する。
不動産業界ソリューションページには、PropTechプラットフォームが本番環境でMapAtlasをどのように使用しているかについての追加コンテキストがある。
次のステップ
- MapAtlas APIキーの無料サインアップから始めて無料ティアを使用する
- Travel Time APIで移動時間オーバーレイを追加し、20分以内に到達可能な物件を買い手に表示する
- マップAPIスタイリングガイドを参照して物件プラットフォームのビジュアルアイデンティティに合わせる
よくある質問
MapAtlasを不動産物件ウェブサイトに使用できますか?
はい。MapAtlasはEU全体のPropTechプラットフォームで、物件検索マップ、投資ダッシュボード、賃貸物件サイトに使用されています。Maps APIはMapbox GL JS互換なので、既存のMapboxベースの物件マップコードは最小限の変更で移行できます。
不動産マップのマーカークラスタリングはどのように機能しますか?
クラスタリングは、マップがズームアウトされているときに近くの物件マーカーをカウントを表示する単一の円にグループ化します。ユーザーがズームインすると、クラスターは個々のマーカーに分割されます。MapAtlasはGeoJSONソースのクラスタリングをネイティブでサポートしており、ソースにcluster: trueを設定するとSDKが残りを処理します。
物件検索者の位置情報データはGDPRの対象になりますか?
はい。ユーザーが物件サイトで自宅の住所や現在地で検索する場合、それはGDPR第4条に基づく個人データを構成します。EU PropTechプラットフォームは、ユーザーの位置情報をサードパーティサーバーにログするクライアントサイドAPIコールを行うのではなく、バックエンドプロキシを通じてジオコーディングリクエストをルーティングし、適切なデータ最小化および保持ポリシーを適用する必要があります。
よくある質問
MapAtlasを不動産物件ウェブサイトに使用できますか?
はい。MapAtlasはEU全体のPropTechプラットフォームで、物件検索マップ、投資ダッシュボード、賃貸物件サイトに使用されています。Maps APIはMapbox GL JS互換なので、既存のMapboxベースの物件マップコードは最小限の変更で移行できます。
不動産マップのマーカークラスタリングはどのように機能しますか?
クラスタリングは、マップがズームアウトされているときに近くの物件マーカーをカウントを表示する単一の円にグループ化します。ユーザーがズームインすると、クラスターは個々のマーカーに分割されます。MapAtlasはGeoJSONソースのクラスタリングをネイティブでサポートしており、ソースに`cluster: true`を設定するとSDKが残りを処理します。
物件検索者の位置情報データはGDPRの対象になりますか?
はい。ユーザーが物件サイトで自宅の住所や現在地で検索する場合、それはGDPR第4条に基づく個人データを構成します。EU PropTechプラットフォームは、ユーザーの位置情報をサードパーティサーバーにログするクライアントサイドAPIコールを行うのではなく、バックエンドプロキシを通じてジオコーディングリクエストをルーティングし、適切なデータ最小化および保持ポリシーを適用する必要があります。

