Всем привет, не так давно ко мне в команду в ПРОФИ пришла задача реализации довольно комплексной (в плане верстки и интерактивности) карты, на которой бы отображались заказы, оставленные нашими клиентами. Мы решили использовать фреймворк, адаптирующий яндексовый SDK под реакт.
react-yandex-maps + доки к нему
UI маркеров почти полностью приходится настраивать по докам уже Яндекса, тк react-yandex-maps предоставляет нам только внешний интерфейс, позволяющий удобно прокинуть параметры в объект маркера как пропсы.
По докам яндекса довольно просто понять, как сделать маркер с статичной картинкой вместо дефолтного пина, но как сделать полностью кастомный маркер разобраться трудновато.
Что нам нужно было реализовать
Нужен был пин, который состоит из чёрной точки и описания-балуна сверху. При том некоторые пины в дефолтном состоянии должны рисоваться без описания, которое будет появляться только по ховеру или при переходе пина в активное состояние. Более того, у пинов есть несколько особенностей:
Анимированная реакция на ховеры
Анимированный переход в active-state
Анимированное изменение вёрстки по ховеру на другой элемент (по изменению пропсов маркера)
Способ реализации
Для реализации кастомного пина будем использовать поле iconLayout
пропа options
компонента Placemark
<Placemark
geometry={[pin.coordinates.lat, pin.coordinates.lon]}
options={{
iconLayout: template,
}}
/>
В это поле нам надо будет пробросить template нашего кастомного компонента. О способах создания темплейтов можно почитать в доках Яндекса
Пример, как будет выглядеть наш компонент
type Props = {
id: string;
onClick: (orderId: string) => void;
mapInstanceRef: YMapsApi | null;
};
const OrdersPin: React.FC<Props> = React.memo(
({id, mapInstanceRef, onClick}) => {
// Тут я достаю все данные для моего пина из редакса
const pin = useSelector(createMapPlacemarkByIdSelector(id)) as PinData; if (!mapInstanceRef) return null;
// Тут создаю template для проброса в iconLayout, о createPinTemplateFactory дальше
const template = createPinTemplateFactory(mapInstanceRef)({
onPinClick: onClick,
description: pin.description,
isActive: pin.isActive,
isViewed: pin.isViewed,
});
return (
<Placemark
// Проброс позиции пина на карте
geometry={[pin.coordinates.lat, pin.coordinates.lon]}
options={{
// Проброс темплейта
iconLayout: template,
}}
/>
);
},
);
О темплейтах
Если кратко, схема работы с темплейтами выглядит так:
const layout = ymaps.templateLayoutFactory.createClass(
'<Тут вёрстка>',
{
build:
function() {
layout.superclass.build.call(this);
<Тут JS>
}
}
);
Этот layout
и пробрасываем в iconLayout
Если вы обратили внимание на пример компонента, который я привёл выше, можете заметить, что я в своём коде вызываю createPinTemplateFactory
сначала с mapInstanceRef
, а потом результат с параметрами пина (onClick, isActive и так далее).
В целом можно сделать без фабрики, сразу получать layout
через ymaps.templateLayoutFactory.createClass
и пробрасывать в iconLayout
Про параметр createClass
Первый параметр тут - строка с вёрсткой. Да, вы не ослышались, кидаем сюда строку с HTML. Тут накидываем общий вид компонента и закидываем дефолтные стили через className
Благо, что мы можем удобно пробрасывать данные в строки, используя Интерполяцию выражений (структуру вида `Привет, я ${name}`)
Пример первого параметра, как он выглядит у меня:
`<div class="pin-container">
<div class="placemark-description">
<p class="placemark-description__title">
${description.title}
</p>
<p class="placemark-description__subtitle">
${description.subtitle.prefix}
<span class="placemark-description__price">
${description.subtitle.body}
</span>
${description.subtitle.postfix}
</p>
</div>
<div class="pin-container__pin">
<div class="placemark__background"></div>
</div>
</div>`
Тут описываю контейнер, в нём компонент "описания" и компонент самого "пина" (для фона я использую отдельный див, это нужно, чтобы, при увеличении пина по ховеру, его центр не сдвигался)
Второй параметр - JS, который вызовется при создании компонента. Тут описываем состояния, используя пропсы и навешивая listener'ы эвентов geoObject
У себя я поделил код тут на несколько частей:
Получение элементов для дальнейшего взаимодействия с ними
// GET ELEMENTS
const pinContainer = this.getParentElement().getElementsByClassName(
'pin-container',
)[0];
const backgroundElement = this.getParentElement().getElementsByClassName(
'placemark__background',
)[0];
...
Обработка ховера через mouseenter
и mouseleave
Тут по ховеру увеличивается размер пина через модификацию стилей backgroundElement
и показывается описание пина. На mouseleave
выполняется сброс состояния
(можно написать и красивее, тут так написано для максимальной читаемости)
// HOVER LAYOUT
this.getData().geoObject.events.add(
'mouseenter',
() => {
backgroundElement.style.top = `-${PIN_EXPANDED_INSET}px`;
backgroundElement.style.bottom = `-${PIN_EXPANDED_INSET}px`;
backgroundElement.style.left = `-${PIN_EXPANDED_INSET}px`;
backgroundElement.style.right = `-${PIN_EXPANDED_INSET}px`;
descriptionElement.style.transform = `translateY(-${PIN_EXPANDED_INSET}px)`;
if (isDescriptionHidden) {
descriptionElement.style.opacity = 1;
}
},
this,
);
this.getData().geoObject.events.add(
'mouseleave',
() => {
// if placemark is active leave hover layout unaffected
if (!isActive) {
backgroundElement.style.top = '0px';
backgroundElement.style.bottom = '0px';
backgroundElement.style.left = '0px';
backgroundElement.style.right = '0px';
descriptionElement.style.transform = 'translateY(0px)';
if (isDescriptionHidden) {
descriptionElement.style.opacity = 0;
}
}
},
this,
);
Выставление кликабельной зоны маркера через shape
Тут я управляю кликабельной зоной маркера.
// TOUCHABLE ZONE SHAPE
if (isDescriptionHidden) {
// При отсутствии описания у пина он представляет из себя круг и я
// использую для кликабельной зоны форму круга.
this.getData().options.set('shape', {
type: 'Circle',
coordinates: [0, 0],
radius: pinSize / 2,
});
} else {
// В случае, если описание отображено, используется форма прямоугольника
this.getData().options.set('shape', {
type: 'Rectangle',
coordinates: [
[-translateLeft, -translateTop],
[
descriptionElement.offsetWidth - translateLeft,
elementHeight - translateTop,
],
],
});
}
Привязка onClick
this.getData().geoObject.events.add('click', onPinClick, this);
Не буду указывать тут весь остальной код, тк его довольно много, и он довольно однообразен.
Общая суть:
Мы имеем доступ к пропсам нашего маркера и можем использовать их в JS части
createClass
Меняем стили элементов, полученных через
this.getParentElement().getElementsByClassName(...)[0];
в зависимости от пропсовПодвязываемся на ивенты
geoObject
для обработки ховеров и кликовНе забываем, что стилизация работает и через
className
иcss
. Вероятно, грубая стилизация через JS и не понадобится. Анимации можем довольно просто реализовывать черезcss transition
Не забываем указывать
shape
, чтобы пользователям было удобно взаимодействовать с получившимся маркером
Итоги
Мы можем делать красивые маркеры для react-yandex-maps, не ограничиваясь статичными картинками. Конечно, изменение стилей через JS выглядит не очень красиво, но по итогу работает довольно плавно и стабильно, если писать аккуратно :)