Перейти к содержанию

Маркеры и безопасные всплывающие карточки

Оглавление серии «Карты для веба» · Урок 4 из 12.

В предыдущем уроке мы подготовили четыре записи и проверили их до отображения. Теперь добавим маркеры и всплывающие карточки. На карте «Рядом» появятся те же точки, которые находятся в GeoJSON; название и описание будут обычным текстом. Это небольшой шаг, но он задаёт важную границу: данные каталога не должны превращаться в исполняемую разметку только потому, что библиотека умеет показывать HTML.

Исходный набор остаётся прежним. Каждая запись имеет строковый идентификатор, геометрию Point, название, описание и категорию. Код Leaflet не меняет этот формат. Вместо этого функция отображения создаёт отдельные объекты маркеров и связывает их с идентификаторами. Позднее такая связь понадобится общему выбору в списке и обновлению импортированных данных.

От геометрии к объекту библиотеки

Для создания маркера Leaflet 1.9.4 принимает широту и долготу. В GeoJSON они лежат в обратном порядке. На границе используем именованные переменные:

const [longitude, latitude] = feature.geometry.coordinates;
const marker = L.marker([latitude, longitude]);

Эти две строки выглядят длиннее, чем передача массива напрямую, но объясняют преобразование читателю и будущему автору изменений. Если позднее формат данных станет другим, место преобразования будет видно. Библиотечный объект не стоит хранить в feature.properties: тогда экспорт GeoJSON захватит внутреннее состояние интерфейса или столкнётся с циклическими ссылками.

Маркеры добавляем в L.layerGroup, а группу — на карту. Группа обозначает набор наших объектов поверх подложки. Очистка этой группы убирает точки каталога, но не затрагивает тайлы и не создаёт заново саму карту. Такое разделение упрощает фильтры: приложение сможет заменить свои маркеры, сохранив текущую камеру и подключённого поставщика.

Создадим карточку через DOM

bindPopup умеет принимать элемент DOM. Используем это для карточки, которая содержит название и описание. Полная функция popup из src/map.js выглядит так:

function popup(feature) {
  const card = document.createElement('article');
  const title = document.createElement('strong');
  const text = document.createElement('p');
  title.textContent = feature.properties.name;
  text.textContent = feature.properties.description;
  card.append(title, text);
  return card;
}

В коде нет подстановки описания в строку HTML. Если в учебном файле окажется текст <img src=x onerror=...>, он должен показаться как текст, а не создать изображение и обработчик. Мы не утверждаем, что данные безопасны, потому что файл пока лежит рядом с приложением: следующая версия может получать их от редактора или через импорт. API отображения лучше выбрать сразу для недоверенного текста.

Свойство textContent не решает все возможные задачи безопасности. Если позднее добавить ссылку, нужно отдельно проверять её схему и допустимый адрес; если добавить изображение — происхождение файла и альтернативный текст. В этом уроке таких полей нет. Это позволяет разобрать одну границу без ложного обещания, что единственная функция защищает любое содержимое карточки.

Полный модуль отображения

Ниже полный src/map.js четвёртого шага. Он создаёт карту, группу и реестр маркеров, а затем экспортирует операции отображения и выбора. Параметр incremental пока оставляем со значением по умолчанию: более экономное обновление разберём в уроке о множестве точек.

export const map = L.map('map', {
  scrollWheelZoom: false, minZoom: 3, maxZoom: 19
}).setView(
  [53.9, 27.5667], 13
);
export const markerLayer = L.layerGroup().addTo(map);
const markers = new Map();

function popup(feature) {
  const card = document.createElement('article');
  const title = document.createElement('strong');
  const text = document.createElement('p');
  title.textContent = feature.properties.name;
  text.textContent = feature.properties.description;
  card.append(title, text);
  return card;
}

export function renderMarkers(features, select = () => {}, incremental = false) {
  if (!incremental) {
    markerLayer.clearLayers();
    markers.clear();
  }
  const desired = new Set(features.map(feature => feature.id));
  for (const [id, marker] of markers) {
    if (!desired.has(id)) {
      markerLayer.removeLayer(marker);
      markers.delete(id);
    }
  }
  for (const feature of features) {
    let marker = markers.get(feature.id);
    const [longitude, latitude] = feature.geometry.coordinates;
    if (!marker) {
      marker = L.marker([latitude, longitude], {
        title: feature.properties.name, alt: feature.properties.name,
        keyboard: true
      });
      const id = feature.id;
      marker.on('click', () => select(id, false));
      marker.addTo(markerLayer);
      markers.set(id, marker);
    }
    marker.options.title = feature.properties.name;
    marker.options.alt = feature.properties.name;
    const element = marker.getElement();
    if (element) {
      element.title = feature.properties.name;
      element.setAttribute('alt', feature.properties.name);
    }
    marker.setLatLng([latitude, longitude]).bindPopup(popup(feature));
  }
}

export function selectMarker(id, moveCamera) {
  const marker = markers.get(id);
  if (!marker) return;
  if (moveCamera) map.panTo(marker.getLatLng(), { animate: false });
  marker.openPopup();
}

Здесь используются два разных объекта с похожими названиями. map — экземпляр Leaflet, который показывает географическую область. markers — обычный JavaScript Map, где ключом служит feature.id, а значением — библиотечный маркер. Такой реестр позволяет позднее найти точку без поиска по координатам. Две организации могут иметь один адрес; координаты не должны определять их идентичность.

При обычном вызове очищаем слой и реестр перед созданием набора. Это понятный начальный алгоритм для четырёх записей. Ниже в функции есть возможность сравнить желаемые идентификаторы с существующими: она пригодится на следующем уровне сложности, когда пересоздание сотен DOM-элементов станет заметным. Сейчас важно понимать результат простого режима, не выдавая его за оптимизацию большого каталога.

Параметры title, alt и keyboard задают подписи и возможность клавиатурного взаимодействия маркера в выбранной версии. Они помогают взаимодействовать с точкой, но не заменяют будущий доступный список. Полное понимание мест не должно требовать движения по карте или различения маленьких пиктограмм. В архиве используется стандартный маркер Leaflet; собственная декоративная иконка не должна ухудшать видимый фокус и подпись.

Подключим слой к данным

В src/main.js четвёртого шага импортируем уже проверенный каталог и модуль карты. Существенный вызов один:

renderMarkers(catalog.features);

Он получает только массив принятых записей. Важна последовательность: сначала договор данных, потом создание объектов интерфейса. Если пропустить проверку, случайная строка вместо координаты или повторный идентификатор могут привести к частично нарисованному слою и трудно объяснимому выбору. Валидатор позволяет держать функцию отображения небольшой: она работает с обещанной формой записи.

Ожидается появление четырёх маркеров в учебной области. Карточку можно открыть нажатием на маркер; внутри будут название и описание. Подложка по-прежнему подключается своей кнопкой. Маркер не обязан ждать тайлы, потому что его координаты относятся к той же карте и могут отображаться поверх нейтрального фона.

Почему выбор пока вынесен отдельно

Функция selectMarker ищет маркер по идентификатору, при необходимости перемещает камеру и открывает popup. В этом состоянии нет списка, который вызывал бы её, но сама операция уже отделена от создания маркеров. В следующем уроке общий выбор станет состоянием приложения, и библиотека останется одним из его отображений.

Обработчик клика передаёт идентификатор и признак, что дополнительное перемещение камеры не требуется. Клик по уже видимой точке не должен обязательно возвращать пользователя к другому масштабу или запускать новую анимацию. Небольшие параметры такого рода лучше назвать по смыслу, чем спрятать поведение в несколько почти одинаковых функций.

У карточки и маркера есть жизненный цикл. Если объект удалён из принятого набора, его слой должен исчезнуть, а реестр — перестать на него ссылаться. Если объект изменился при том же идентификаторе, координаты и содержимое нужно обновить. В полном модуле эти операции предусмотрены; позже используем их при импорте. Простое добавление новых маркеров без удаления прежних привело бы к дублям.

Измените описание «Строки» в своей копии данных, добавив угловые скобки как обычный текст. Ожидается видимый текст карточки, а не новая разметка. Затем измените координаты одной записи, сохранив её id: маркер должен переместиться, но оставаться тем же местом в модели. Следующий урок даст пользователю второй способ выбора — обычный список рядом с картой.

Полный снимок этого шага находится в папке lesson-04 архива серии. При подготовке материала пример не запускался; описанные эффекты ожидаются по разобранному коду и требуют самостоятельной проверки в браузере.