Skip to main content
ساخت نقشه املاک با خوشه‌بندی در JavaScript
Tutorials

ساخت نقشه املاک با خوشه‌بندی در JavaScript

با استفاده از MapAtlas Maps API، یک نقشه فهرست املاک با نشانگرهای برچسب قیمت، خوشه‌بندی، جستجوی آدرس و فیلتر بازه قیمت با JavaScript ساده یا React بسازید.

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

جستجوی ملک یک مشکل فضایی است. خریداران و مستأجران بر اساس محله‌ها، زمان رفت‌وآمد و نزدیکی به مدارس فکر می‌کنند، نه کدپستی‌ها و لیست‌های خیابانی. یک رابط نقشه‌اول با شیوه جستجوی افراد سازگار است، زیرا به کاربران امکان می‌دهد قبل از فیلتر کردن بر اساس قیمت، اتاق خواب یا هر ویژگی دیگری، جستجوی خود را از نظر فضایی لنگر بیندازند.

این آموزش یک نقشه فهرست ملک آماده تولید می‌سازد: نشانگرهای خوشه‌ای در زوم کم، برچسب‌های قیمت روی نشانگرهای فردی، کلیک-برای-پاپ‌آپ با جزئیات فهرست، یک جستجوی کدپستی که نقشه را مجدداً مرکزگرا می‌کند، و یک اسلایدر محدوده قیمت که فهرست‌های قابل مشاهده را در زمان واقعی فیلتر می‌کند. خود کامپوننت React حدود ۶۵ خط است و نسخه کامل با لایه‌ها و handlerهای کلیک به حدود ۱۵۰ خط می‌رسد. همان منطق در JavaScript ساده هم کار می‌کند اگر فریمورکی را ترجیح ندهید.

همچنین یادداشتی درباره تعهدات GDPR خاص برای پلتفرم‌های PropTech اتحادیه اروپا پیدا خواهید کرد، زیرا داده‌های مکانی تولیدشده توسط جستجوهای ملک داده‌های شخصی هستند و باید مطابق با آن مدیریت شوند.

اگر با APIهای نقشه آشنا نیستید، آموزش نحوه افزودن نقشه‌های تعاملی به وب‌سایت خود اصول اولیه را قبل از این آموزش پوشش می‌دهد.

ساختار داده ملک

هر فهرست به مختصات، قیمت و متادیتای کافی برای پاپ‌آپ نیاز دارد. این را به عنوان یک FeatureCollection GeoJSON نگه دارید، مستقیماً بدون تبدیل به منبع نقشه متصل می‌شود.

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 می‌نویسید، با یک URL کاشی MapAtlas، یکسان است.

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

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

فیلتر محدوده قیمت

یک اسلایدر که فهرست‌های قابل مشاهده را فیلتر می‌کند یکی از ارزشمندترین اضافه‌های رابط کاربری برای یک نقشه ملک است. سیستم بیان 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);

توجه داشته باشید که خوشه‌ها از داده‌های منبع محاسبه می‌شوند و فیلترهای لایه در آنها اثری ندارند، بنابراین تعداد خوشه‌ها همچنان فهرست‌هایی را که اسلایدر پنهان کرده است شامل می‌شود. برای همگام نگه داشتن آنها، فهرست‌ها را در 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());
});

برای جستجوی زمان سفر، «ملک‌هایی را در ۲۰ دقیقه از این مدرسه نشان بده»، یک پوشش ایزوخرون با استفاده از 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
      });
      // 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>
  );
}

این اسکلت کامپوننت است، حدود ۶۵ خط شامل اسلایدر محدوده قیمت و پاکسازی چرخه حیات؛ برای تکمیل آن، کد لایه‌ها و handlerهای کلیک را از بخش‌های بالا اضافه کنید.

یادداشت‌های GDPR برای PropTech اتحادیه اروپا

وقتی یک متقاضی ملک آدرس خانه‌اش را تایپ می‌کند یا روی «از موقعیت من استفاده کن» در نقشه شما کلیک می‌کند، داده‌های شخصی را بر اساس ماده ۴(۱) GDPR به اشتراک می‌گذارد، که داده مکانی را صراحتاً به عنوان یک شناسه نام می‌برد. این داده‌های مکانی محل زندگی آنها را نشان می‌دهد، که ممکن است سایر ویژگی‌های حساس آنها را نیز نشان دهد.

مراحل عملی برای انطباق با اتحادیه اروپا:

  • درخواست‌های geocoding را از طریق backend خود هدایت کنید نه اینکه Geocoding API را مستقیماً از مرورگر فراخوانی کنید. Backend شما می‌تواند هدرهای شناسایی‌کننده را قبل از ارسال حذف کند.
  • مختصات جستجو را فراتر از جلسه ثبت یا نگه ندارید مگر اینکه یک مبنای قانونی مستند بر اساس ماده ۶ GDPR داشته باشید، مانند رضایت.
  • اگر «وقتی ملکی نزدیک من با معیارهایم مطابقت داشت به من اطلاع بده» پیاده‌سازی می‌کنید، منطقه جستجوی ذخیره‌شده را با یک دوره نگهداری تعریف‌شده به عنوان داده شخصی تلقی کنید.

راهنمای توسعه‌دهندگان اتحادیه اروپا برای APIهای نقشه سازگار با GDPR این را به طور کامل پوشش می‌دهد، شامل قراردادهای پردازنده و الزامات اقامت داده. MapAtlas داده‌ها را در اتحادیه اروپا پردازش می‌کند و توافقنامه پردازش داده را که ماده ۲۸(۳) GDPR میان کنترل‌کننده و پردازشگر الزامی می‌کند فراهم می‌کند.

صفحه راه‌حل صنعت املاک زمینه بیشتری درباره نحوه استفاده پلتفرم‌های PropTech از MapAtlas در محیط تولید دارد.

مراحل بعدی

سوالات متداول

آیا می‌توانم از MapAtlas برای یک وب‌سایت فهرست ملک استفاده کنم؟

بله. MapAtlas نیازهای سایت‌های املاک را پوشش می‌دهد: نقشه‌های جستجوی ملک، داشبوردهای سرمایه‌گذاری و نقشه‌های فهرست اجاره. Maps API با Mapbox GL JS سازگار است، بنابراین کد نقشه ملک مبتنی بر Mapbox موجود با تغییرات حداقلی مهاجرت می‌کند.

خوشه‌بندی نشانگر برای نقشه‌های املاک چگونه کار می‌کند؟

خوشه‌بندی نشانگرهای ملک نزدیک را در یک دایره که تعداد را نشان می‌دهد هنگامی که نقشه زوم خارج است گروه‌بندی می‌کند. با زوم کردن کاربر، خوشه‌ها به نشانگرهای فردی تقسیم می‌شوند. MapAtlas به طور بومی خوشه‌بندی منبع GeoJSON را پشتیبانی می‌کند، cluster: true را روی منبع تنظیم کنید و SDK بقیه را مدیریت می‌کند.

آیا داده‌های مکانی از جستجوگران ملک مشمول GDPR هستند؟

بله. هنگامی که کاربری با آدرس خانه‌اش یا موقعیت فعلی‌اش در یک سایت ملک جستجو می‌کند، این داده‌های شخصی بر اساس ماده ۴ GDPR محسوب می‌شود. پلتفرم‌های PropTech اتحادیه اروپا باید درخواست‌های geocoding را از طریق یک پروکسی backend هدایت کنند و سیاست‌های به حداقل رساندن و نگهداری داده مناسب اعمال کنند نه اینکه فراخوانی‌های API سمت کلاینت که موقعیت‌های کاربر را در سرورهای شخص ثالث ثبت می‌کند ایجاد کنند.

این مفید بود؟ آن را به اشتراک بگذارید.

درباره نویسنده

Brent van der Heiden

نوشته

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.

مشاهده همه مقالات →
بازگشت به وبلاگ