Всем привет, не так давно ко мне в команду в ПРОФИ пришла задача реализации довольно комплексной (в плане верстки и интерактивности) карты, на которой бы отображались заказы, оставленные нашими клиентами. Мы решили использовать фреймворк, адаптирующий яндексовый 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 выглядит не очень красиво, но по итогу работает довольно плавно и стабильно, если писать аккуратно :)
