Skip to main content
Construir un mapa de propiedades inmobiliarias con agrupación en JavaScript
Tutorials

Construir un mapa de propiedades inmobiliarias con agrupación en JavaScript

Crea un mapa de anuncios inmobiliarios con marcadores de precio, agrupación, búsqueda de direcciones y filtro de rango de precios en JavaScript puro o React con la MapAtlas Maps API.

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

La búsqueda de vivienda es un problema espacial. Compradores e inquilinos piensan en barrios, tiempos de desplazamiento y cercanía a colegios, no en códigos postales y listas de calles. Una interfaz centrada en el mapa se ajusta a cómo busca la gente, porque permite al usuario situar su búsqueda en el espacio antes de filtrar por precio, dormitorios u otro atributo.

En este tutorial construyes un mapa de anuncios inmobiliarios listo para producción: marcadores agrupados con zoom bajo, etiquetas de precio en los marcadores individuales, ventana emergente con los detalles del anuncio al hacer clic, una búsqueda por código postal que recentra el mapa y un control deslizante de rango de precios que filtra los anuncios visibles en tiempo real. El componente de React en sí ocupa unas 65 líneas, y la versión completa con capas y manejadores de clic llega a unas 150. La misma lógica funciona en JavaScript puro si prefieres no usar framework.

También encontrarás una nota sobre las obligaciones del RGPD específicas de las plataformas PropTech de la UE, porque los datos de ubicación que generan las búsquedas de vivienda son datos personales y deben tratarse como tales.

Si eres nuevo en las APIs de mapas, el tutorial Cómo agregar mapas interactivos a tu sitio web cubre los fundamentos antes de empezar con este.

Estructura de los datos de las propiedades

Cada anuncio necesita coordenadas, un precio y metadatos suficientes para la ventana emergente. Guárdalo como una FeatureCollection de GeoJSON: se conecta directamente a la fuente del mapa sin transformaciones.

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"
      }
    }
  ]
};

En una plataforma real, este array procede de tu API de anuncios, mediante una llamada fetch al cargar el mapa o al cambiar un parámetro de consulta.

Configura el mapa

Inicializa el mapa con una vista centrada en tu mercado inmobiliario. La MapAtlas Maps API es compatible con Mapbox GL JS, así que la llamada de inicialización es idéntica a la que escribirías para Mapbox, con una URL de teselas de 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
});

Un estilo claro y de bajo contraste funciona especialmente bien para mapas inmobiliarios: su base más clara hace que las etiquetas de precio y los marcadores de color destaquen sin saturar la vista.

Agrupación con etiquetas de precio

El patrón de interfaz clave en un mapa inmobiliario consiste en mostrar etiquetas de precio en los marcadores individuales y burbujas con el recuento en los círculos de grupo. La opción de fuente cluster: true agrupa automáticamente los puntos cercanos. Añades capas separadas para los grupos y para los marcadores individuales.

map.on('load', () => {
  map.addSource('properties', {
    type: 'geojson',
    data: properties,
    cluster: true,
    clusterMaxZoom: 14,
    clusterRadius: 60
  });

  // Círculos de grupo, el tamaño crece con el número de anuncios
  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'
    }
  });

  // Etiquetas con el recuento de cada grupo
  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' }
  });

  // Marcadores individuales de propiedad, círculo blanco con el precio
  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
    }
  });
});

Interacciones al hacer clic

Al hacer clic en un grupo, el mapa se acerca para mostrar sus anuncios individuales. Al hacer clic en una propiedad, se abre una ventana emergente con el detalle.

// Acerca el mapa al grupo pulsado
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 });
    }
  );
});

// Ventana emergente con el detalle de cada anuncio
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);
});

// Cambia el cursor al pasar por encima
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 = ''; });

Filtro por rango de precios

Un control deslizante que filtra los anuncios visibles es una de las mejoras de interfaz más valiosas para un mapa inmobiliario. El sistema de expresiones de Mapbox GL JS te permite actualizar el filtro de una capa en el cliente sin ida y vuelta a la red: el filtrado ocurre en el navegador sobre el GeoJSON ya cargado.

function applyPriceFilter(min, max) {
  const filter = ['all',
    ['!', ['has', 'point_count']],
    ['>=', ['get', 'price'], min],
    ['<=', ['get', 'price'], max]
  ];
  map.setFilter('property-price', filter);
}

// Conecta los controles de rango
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);

Ten en cuenta que los grupos se calculan a partir de los datos de la fuente, no de los filtros de capa, así que los recuentos de los grupos siguen incluyendo anuncios que el control deslizante ha ocultado. Para mantenerlos sincronizados, filtra los anuncios en JavaScript y pasa el resultado a map.getSource('properties').setData().

Búsqueda de direcciones con la Geocoding API

Deja que los usuarios escriban un barrio o un código postal para recentrar el mapa. La Geocoding API devuelve features GeoJSON, así que las coordenadas pasan directamente a 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());
});

Para búsquedas por tiempo de desplazamiento, como "muéstrame viviendas a menos de 20 minutos de este colegio", añade una capa de isócronas con la MapAtlas Travel Time API. El artículo Mapas de isócronas explicados muestra cómo obtener y mostrar ese polígono.

El componente de React completo

Así queda todo lo anterior dentro de un componente de React con una gestión correcta del ciclo de vida:

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
      });
      // Añade las capas cluster, cluster-label y property-price (ver arriba)
      // Añade los manejadores de clic (ver arriba)
    });

    return () => map.remove();
  }, [apiKey]);

  // Actualiza el filtro cuando cambia el control de rango de precios
  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>
  );
}

Esa es la estructura base del componente, unas 65 líneas incluidos el control de rango de precios y la limpieza del ciclo de vida; añade el código de capas y manejadores de clic de las secciones anteriores para completarlo.

Notas sobre el RGPD para PropTech en la UE

Cuando alguien que busca vivienda escribe su dirección o pulsa "usar mi ubicación" en tu mapa, está compartiendo datos personales según el artículo 4, apartado 1, del RGPD, que menciona los datos de localización como identificador. Esos datos de ubicación revelan dónde vive, y de ellos se pueden deducir otros atributos sensibles sobre esa persona.

Pasos prácticos para cumplir en la UE:

  • Envía las peticiones de geocodificación a través de tu backend en lugar de llamar a la Geocoding API directamente desde el navegador. Tu backend puede eliminar las cabeceras identificativas antes de reenviarlas.
  • No registres ni conserves las coordenadas de búsqueda más allá de la sesión, salvo que tengas una base jurídica documentada conforme al artículo 6 del RGPD, como el consentimiento.
  • Si implementas "avísame cuando una vivienda cerca de mí cumpla mis criterios", trata el área de búsqueda guardada como un dato personal con un plazo de conservación definido.

La guía para desarrolladores europeos sobre APIs de mapas conformes con el RGPD lo cubre en detalle, incluidos los contratos de encargado del tratamiento y los requisitos de residencia de datos. MapAtlas procesa los datos dentro de la UE y ofrece el contrato de encargado del tratamiento que el artículo 28, apartado 3, del RGPD exige entre responsable y encargado.

La página de soluciones para el sector inmobiliario aporta más contexto sobre cómo usan MapAtlas en producción las plataformas PropTech.

Próximos pasos

Preguntas frecuentes

¿Puedo usar MapAtlas en un portal de anuncios inmobiliarios?

Sí. MapAtlas cubre lo que necesitan los portales inmobiliarios: mapas de búsqueda de viviendas, paneles de inversión y mapas de anuncios de alquiler. La Maps API es compatible con Mapbox GL JS, así que el código existente de mapas inmobiliarios basado en Mapbox se migra con cambios mínimos.

¿Cómo funciona la agrupación de marcadores en los mapas inmobiliarios?

La agrupación reúne los marcadores de propiedades cercanas en un único círculo que muestra el recuento cuando el mapa está alejado. Cuando el usuario acerca el zoom, los grupos se dividen en marcadores individuales. MapAtlas admite de forma nativa la agrupación en fuentes GeoJSON: define cluster: true en la fuente y el SDK se encarga del resto.

¿Los datos de ubicación de quienes buscan vivienda están sujetos al RGPD?

Sí. Cuando un usuario busca por su dirección o por su ubicación actual en un portal inmobiliario, eso constituye un dato personal según el artículo 4 del RGPD. Las plataformas PropTech de la UE deberían enviar las peticiones de geocodificación a través de un proxy en el backend y aplicar políticas adecuadas de minimización y conservación de datos, en lugar de hacer llamadas a la API desde el cliente que registren la ubicación de los usuarios en servidores de terceros.

Preguntas frecuentes

¿Puedo usar MapAtlas en un portal de anuncios inmobiliarios?

Sí. MapAtlas cubre lo que necesitan los portales inmobiliarios: mapas de búsqueda de viviendas, paneles de inversión y mapas de anuncios de alquiler. La Maps API es compatible con Mapbox GL JS, así que el código existente de mapas inmobiliarios basado en Mapbox se migra con cambios mínimos.

¿Cómo funciona la agrupación de marcadores en los mapas inmobiliarios?

La agrupación reúne los marcadores de propiedades cercanas en un único círculo que muestra el recuento cuando el mapa está alejado. Cuando el usuario acerca el zoom, los grupos se dividen en marcadores individuales. MapAtlas admite de forma nativa la agrupación en fuentes GeoJSON: define cluster: true en la fuente y el SDK se encarga del resto.

¿Los datos de ubicación de quienes buscan vivienda están sujetos al RGPD?

Sí. Cuando un usuario busca por su dirección o por su ubicación actual en un portal inmobiliario, eso constituye un dato personal según el artículo 4 del RGPD. Las plataformas PropTech de la UE deberían enviar las peticiones de geocodificación a través de un proxy en el backend y aplicar políticas adecuadas de minimización y conservación de datos, en lugar de hacer llamadas a la API desde el cliente que registren la ubicación de los usuarios en servidores de terceros.

¿Le resultó útil? Compártalo.

Sobre el autor

Brent van der Heiden

Escrito por

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.

Ver todos los artículos →
Volver al blog