Skip to main content
Xây dựng bản đồ bất động sản có phân cụm bằng JavaScript
Tutorials

Xây dựng bản đồ bất động sản có phân cụm bằng JavaScript

Xây dựng bản đồ danh sách bất động sản với điểm đánh dấu nhãn giá, phân cụm, tìm kiếm địa chỉ và bộ lọc khoảng giá bằng JavaScript thuần hoặc React với MapAtlas Maps API.

Brent van der Heiden10 min read
#real estate map#property map#map clustering#javascript#proptech#maps api

Tìm kiếm tài sản là một vấn đề không gian. Người mua và người thuê nghĩ theo những khu phố, thời gian đi lại và sự gần gũi với trường học, chứ không phải mã bưu chính và danh sách đường phố. Giao diện lấy bản đồ làm trung tâm phù hợp với cách mọi người tìm kiếm, vì nó cho phép người dùng neo tìm kiếm của họ về mặt không gian trước khi lọc theo giá, phòng ngủ hoặc bất kỳ thuộc tính nào khác.

Hướng dẫn này xây dựng một bản đồ danh sách tài sản sẵn sàng cho sản xuất: các điểm đánh dấu tập hợp ở mức zoom thấp, nhãn giá trên các điểm đánh dấu riêng lẻ, nhấp vào bật lên với chi tiết danh sách, tìm kiếm mã bưu chính định tâm lại bản đồ và thanh trượt phạm vi giá lọc danh sách hiển thị trong thời gian thực. Bản thân component React dài khoảng 65 dòng, còn bản đầy đủ với các layer và trình xử lý click vào khoảng 150 dòng. Logic tương tự hoạt động trong JavaScript vanilla nếu bạn thích không có khung.

Bạn cũng sẽ tìm thấy một ghi chú về các nghĩa vụ GDPR dành riêng cho các nền tảng PropTech EU, vì dữ liệu vị trí được tạo ra bởi tìm kiếm tài sản là dữ liệu cá nhân và cần được xử lý phù hợp.

Nếu bạn là người mới sử dụng các API bản đồ, hướng dẫn Cách Thêm Bản Đồ Tương Tác Vào Trang Web Của Bạn bao gồm các nguyên tắc cơ bản trước khi cái này bắt đầu.

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://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=YOUR_API_KEY',
  center: [4.9041, 52.3676],
  zoom: 13
});

Một style sáng, độ tương phản thấp đặc biệt phù hợp với bản đồ bất động sản: nền sáng hơn giúp nhãn giá và các điểm đánh dấu màu nổi bật mà không gây rối mắt.

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

Thanh trượt lọc các tin đăng đang hiển thị là một trong những bổ sung giao diện có giá trị nhất cho bản đồ bất động sản. Hệ thống expression của Mapbox GL JS cho phép bạn cập nhật filter của một layer ngay phía client mà không cần gọi mạng, việc lọc diễn ra trong trình duyệt trên GeoJSON đã tải sẵn.

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);

Lưu ý rằng các cụm được tính từ dữ liệu của source chứ không từ filter của layer, nên số đếm trong cụm vẫn bao gồm những tin đăng mà thanh trượt đã ẩn. Để giữ chúng đồng bộ, hãy lọc tin đăng bằng JavaScript và truyền kết quả vào map.getSource('properties').setData().

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://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());
});

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://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
      });
      // 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>
  );
}

Đó là khung của component, khoảng 65 dòng bao gồm thanh trượt khoảng giá và phần dọn dẹp vòng đời; hãy thêm mã layer và trình xử lý click từ các phần ở trên để hoàn thiện.

Ghi Chú GDPR Cho PropTech EU

Khi người tìm kiếm tài sản nhập địa chỉ nhà của họ hoặc nhấp vào "sử dụng vị trí của tôi" trên bản đồ của bạn, họ đang chia sẻ dữ liệu cá nhân theo Điều 4(1) GDPR, điều khoản nêu rõ dữ liệu vị trí là một yếu tố định danh. Dữ liệu vị trí đó tiết lộ nơi họ sống, có thể suy ra các thuộc tính nhạy cảm khác về họ.

Các bước thực tế để tuân thủ EU:

  • Định tuyến các yêu cầu địa mã hóa thông qua backend của bạn thay vì gọi API Địa mã hóa trực tiếp từ trình duyệt. Backend của bạn có thể loại bỏ các tiêu đề nhận dạng trước khi chuyển tiếp.
  • Không ghi nhật ký hoặc duy trì tọa độ tìm kiếm ngoài phiên trừ khi bạn có cơ sở pháp lý được ghi chép lại theo Điều 6 GDPR, chẳng hạn như sự đồng ý.
  • Nếu bạn thực hiện "thông báo cho tôi khi tài sản gần tôi phù hợp với tiêu chí của tôi", hãy coi khu vực tìm kiếm được lưu trữ là dữ liệu cá nhân có thời gian lưu giữ được xác định.

Hướng dẫn Nhà Phát Triển EU Về Các API Bản Đồ Tuân Thủ GDPR bao gồm điều này đầy đủ, bao gồm các thỏa thuận nhà xử lý và các yêu cầu về vị trí dữ liệu. MapAtlas xử lý dữ liệu trong EU và cung cấp Thỏa thuận Xử lý Dữ liệu mà Điều 28(3) GDPR yêu cầu giữa bên kiểm soát và bên xử lý.

Trang giải pháp ngành Bất động sản có ngữ cảnh bổ sung về cách các nền tảng PropTech sử dụng MapAtlas trong sản xuất.

Next Steps

Frequently Asked Questions

Can I use MapAtlas for a property listing website?

Có. MapAtlas đáp ứng những gì các trang bất động sản cần: bản đồ tìm kiếm bất động sản, dashboard đầu tư và bản đồ tin cho thuê. Maps API tương thích với Mapbox GL JS, nên mã bản đồ bất động sản hiện có dựa trên Mapbox có thể chuyển đổi với rất ít thay đổi.

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.

Thấy hữu ích? Chia sẻ nhé.

Về tác giả

Brent van der Heiden

Viết bởi

Brent van der Heiden

Co-Founder & CEO at MapAtlas

Brent built MapAtlas out of a conviction that developers deserve location APIs with fair pricing and genuine end-user privacy. Before that he co-founded MapMetrics, a community mapping and navigation app. He writes about geospatial infrastructure, map data, AI search visibility, and how location data powers the products people rely on every day.

Xem tất cả bài viết →
Quay lại blog