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

Комментарии (0)