// ИНТЕРАКТИВНАЯ КАРТА НЕДВИЖИМОСТИ — Tilda + Leaflet.js // Версия 1.0 | Все комментарии на русском // ═══════════════════════════════════════════════════════════ (function () { 'use strict'; // ───────────────────────────────────────────────────── // НАСТРОЙКИ — измените под ваш город и сайт // ───────────────────────────────────────────────────── var CONFIG = { // ID контейнера карты в Zero Block mapId: 'realty-map', // Центр карты при загрузке — координаты вашего города // Минск: [53.9045, 27.5615] // Москва: [55.7558, 37.6173] // Киев: [50.4501, 30.5234] center: [53.9045, 27.5615], // Начальный зум (12 = видно весь город, 15 = видно район) zoom: 12, // CSS-селектор карточек каталога Tilda Store cardSelector: '.t-store__col', // CSS-класс нашего невидимого span с геоданными geoSelector: '.apt-geo-data', // CSS-класс для подсветки активной карточки activeClass: 'apt-card--active', }; // ───────────────────────────────────────────────────── // ПЕРЕМЕННЫЕ — хранят состояние карты // ───────────────────────────────────────────────────── var map; // объект карты Leaflet var apartments = []; // массив всех квартир var markerMap = {}; // словарь: id → маркер var clusterGroup; // группа кластеров // ───────────────────────────────────────────────────── // ЗАПУСК — ждём загрузки DOM и библиотек // ───────────────────────────────────────────────────── function start() { // Если Leaflet ещё не загрузился — повторим через 200мс if (typeof L === 'undefined') { setTimeout(start, 200); return; } // Если на этой странице нет контейнера карты — выходим var container = document.getElementById(CONFIG.mapId); if (!container) return; initMap(container); } // ───────────────────────────────────────────────────── // ИНИЦИАЛИЗАЦИЯ КАРТЫ // ───────────────────────────────────────────────────── function initMap(container) { // Создаём карту Leaflet map = L.map(container, { center: CONFIG.center, zoom: CONFIG.zoom, // Отключаем прокрутку колёсиком — иначе страница // перестаёт скроллиться когда курсор над картой scrollWheelZoom: false, zoomControl: true, }); // Загружаем тайлы OpenStreetMap (бесплатно) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(map); // Читаем квартиры из DOM-каталога Tilda apartments = readApartmentsFromCatalog(); if (apartments.length === 0) { console.warn('Карта: не найдено квартир с геоданными. ' + 'Убедитесь что в описании товаров есть span.apt-geo-data'); return; } // Добавляем маркеры с кластеризацией addMarkersToMap(); // Подключаем синхронизацию карточек и маркеров wireCardToMarkerSync(); // Подключаем фильтры initFilters(); // Масштабируем карту так чтобы были видны все квартиры fitMapToAllMarkers(); // Убираем текст 'Загрузка...' var loader = document.getElementById('map-loading'); if (loader) loader.style.display = 'none'; } // ───────────────────────────────────────────────────── // ЧТЕНИЕ КВАРТИР ИЗ КАТАЛОГА // Ищем все карточки товаров в DOM и извлекаем геоданные // ───────────────────────────────────────────────────── function readApartmentsFromCatalog() { var result = []; var cards = document.querySelectorAll(CONFIG.cardSelector); cards.forEach(function (card, index) { // Ищем невидимый span с геоданными внутри карточки var geo = card.querySelector(CONFIG.geoSelector); if (!geo) return; // эта карточка без геоданных — пропускаем var lat = parseFloat(geo.dataset.lat); var lng = parseFloat(geo.dataset.lng); // Если координаты некорректные — пропускаем if (isNaN(lat) || isNaN(lng)) return; // Читаем название из карточки Tilda var titleEl = card.querySelector('.t-store__card-title, .js-product-title'); // Читаем ссылку на страницу квартиры var linkEl = card.querySelector('a[href]'); // Читаем фото квартиры var imageEl = card.querySelector('img'); result.push({ id: index, lat: lat, lng: lng, // Цена из геоданных (предпочтительно) или из Tilda price: parseInt(geo.dataset.price) || 0, rooms: parseInt(geo.dataset.rooms) || 0, area: parseFloat(geo.dataset.area) || 0, floor: parseInt(geo.dataset.floor) || 0, address: geo.dataset.address || '', title: titleEl ? titleEl.textContent.trim() : 'Квартира', url: linkEl ? linkEl.href : '#', image: imageEl ? imageEl.src : '', cardEl: card, // сохраняем ссылку на DOM-элемент }); }); return result; } // ───────────────────────────────────────────────────── // МАРКЕР — иконка с ценой (как на ЦИАН / Zillow) // ───────────────────────────────────────────────────── function createPriceIcon(price, isActive) { // Форматируем цену: 95000 → $95k, 1500 → $1 500 var label = price >= 1000 ? '$' + Math.round(price / 1000) + 'k' : '$' + price; // Активный маркер (выбранная квартира) — синий // Обычный маркер — белый var bg = isActive ? '#1A56C4' : '#FFFFFF'; var color = isActive ? '#FFFFFF' : '#1A1A2E'; var border = isActive ? '#0D3A8C' : '#CCCCCC'; var shadow = isActive ? '0 4px 14px rgba(26,86,196,0.5)' : '0 2px 8px rgba(0,0,0,0.18)'; return L.divIcon({ className: 'apt-price-marker', html: '' + label + '', iconSize: [60, 28], iconAnchor: [30, 28], popupAnchor: [0, -32], }); } // ───────────────────────────────────────────────────── // ПОПАП — всплывающая карточка при клике на маркер // ───────────────────────────────────────────────────── function buildPopupHTML(apt) { // Блок с фото (если есть) var imgHtml = apt.image ? '' : ''; // Форматируем цену с пробелами: 95000 → 95 000 var priceFormatted = apt.price ? '$' + apt.price.toLocaleString('ru-RU') : 'Цена по запросу'; return ( '' + imgHtml + '' + '' + apt.title + '' + '' + priceFormatted + '' + '' + apt.rooms + ' комн. · ' + apt.area + ' м² · ' + apt.floor + ' эт.' + '' + '' + apt.address + '' + '' + 'Подробнее →' + '' + '' + '' ); } // ───────────────────────────────────────────────────── // ДОБАВЛЯЕМ МАРКЕРЫ НА КАРТУ (с кластеризацией) // ───────────────────────────────────────────────────── function addMarkersToMap() { // Создаём группу кластеров clusterGroup = L.markerClusterGroup({ // При каком радиусе (в пикселях) маркеры объединяются в кластер maxClusterRadius: 60, // Иконка кластера — синий круг с количеством iconCreateFunction: function (cluster) { var count = cluster.getChildCount(); var size = count < 10 ? 38 : count < 50 ? 46 : 54; return L.divIcon({ html: '' + count + '', className: 'apt-cluster', iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); }, animate: true, spiderfyOnMaxZoom: true, // при максимальном зуме раскрывает кластер паучком showCoverageOnHover: false, // не показывать область охвата кластера zoomToBoundsOnClick: true, // при клике на кластер — приближаемся }); // Добавляем каждую квартиру как маркер apartments.forEach(function (apt) { var marker = L.marker([apt.lat, apt.lng], { icon: createPriceIcon(apt.price, false), }); // Привязываем попап к маркеру marker.bindPopup(buildPopupHTML(apt), { maxWidth: 250, className: 'apt-popup', }); // Сохраняем ссылку на маркер по id квартиры markerMap[apt.id] = marker; // По клику на маркер — подсвечиваем карточку в каталоге marker.on('click', function () { highlightCard(apt); }); // Добавляем маркер в группу кластеров clusterGroup.addLayer(marker); }); // Добавляем всю группу на карту map.addLayer(clusterGroup); } // ───────────────────────────────────────────────────── // СИНХРОНИЗАЦИЯ: карточка → маркер // Клик по карточке каталога → карта летит к квартире // ───────────────────────────────────────────────────── function wireCardToMarkerSync() { apartments.forEach(function (apt) { apt.cardEl.addEventListener('click', function (e) { // Если кликнули по ссылке внутри карточки — не перехватываем if (e.target.tagName === 'A') return; // Летим к квартире на карте и открываем попап map.setView([apt.lat, apt.lng], 15, { animate: true }); var marker = markerMap[apt.id]; if (marker) marker.openPopup(); // Обновляем иконки: выбранная — синяя, остальные — белые setActiveMarker(apt.id); // Прокручиваем страницу к карте (удобно на мобильных) var mapEl = document.getElementById(CONFIG.mapId); if (mapEl) mapEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }); } // ───────────────────────────────────────────────────── // ПОДСВЕТКА КАРТОЧКИ при клике на маркер карты // ───────────────────────────────────────────────────── function highlightCard(apt) { // Снимаем подсветку со всех карточек document.querySelectorAll('.' + CONFIG.activeClass) .forEach(function (el) { el.classList.remove(CONFIG.activeClass); }); // Подсвечиваем нужную карточку apt.cardEl.classList.add(CONFIG.activeClass); // Прокручиваем список к этой карточке apt.cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ───────────────────────────────────────────────────── // СМЕНА АКТИВНОГО МАРКЕРА // ───────────────────────────────────────────────────── function setActiveMarker(activeId) { apartments.forEach(function (apt) { var marker = markerMap[apt.id]; if (marker) { // Активный маркер — синий, остальные — белые marker.setIcon(createPriceIcon(apt.price, apt.id === activeId)); } }); } // ───────────────────────────────────────────────────── // ФИЛЬТРАЦИЯ квартир по комнатам и цене // ───────────────────────────────────────────────────── function initFilters() { // Вешаем обработчики на селекторы фильтров var roomsEl = document.getElementById('filter-rooms'); var priceMaxEl = document.getElementById('filter-price-max'); var resetBtn = document.getElementById('filter-reset'); if (roomsEl) roomsEl.addEventListener('change', applyFilters); if (priceMaxEl) priceMaxEl.addEventListener('change', applyFilters); if (resetBtn) resetBtn.addEventListener('click', function () { if (roomsEl) roomsEl.value = ''; if (priceMaxEl) priceMaxEl.value = ''; applyFilters(); }); } function applyFilters() { var rooms = document.getElementById('filter-rooms')?.value || ''; var priceMax = document.getElementById('filter-price-max')?.value || ''; apartments.forEach(function (apt) { var show = true; // Проверяем фильтр по комнатам if (rooms && String(apt.rooms) !== rooms) show = false; // Проверяем фильтр по максимальной цене if (priceMax && apt.price > parseInt(priceMax)) show = false; var marker = markerMap[apt.id]; if (marker) { // Показываем или скрываем маркер на карте if (show) { clusterGroup.addLayer(marker); } else { clusterGroup.removeLayer(marker); } } // Показываем или скрываем карточку в каталоге apt.cardEl.style.display = show ? '' : 'none'; }); } // ───────────────────────────────────────────────────── // АВТОМАСШТАБ — показываем все квартиры на карте // ───────────────────────────────────────────────────── function fitMapToAllMarkers() { if (apartments.length === 0) return; if (apartments.length === 1) { // Одна квартира — просто центрируемся на ней map.setView([apartments[0].lat, apartments[0].lng], 15); return; } // Несколько квартир — вписываем все в экран var group = L.featureGroup( apartments.map(function (a) { return L.marker([a.lat, a.lng]); }) ); // pad(0.15) добавляет 15% отступ по краям map.fitBounds(group.getBounds().pad(0.15)); } // ───────────────────────────────────────────────────── // ЗАПУСКАЕМ ПОСЛЕ ЗАГРУЗКИ СТРАНИЦЫ // ───────────────────────────────────────────────────── if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { // DOM уже загружен (Tilda иногда вызывает скрипты поздно) start(); } })(); // конец IIFE — изолированная область видимости