Понимание принципа построения функциональности карт с Яндекс.Картами API v3

Когда первый раз открываешь документацию Яндекс.Карт, то совершенно ничего не понятно. Ты смотришь быстрый старт, введение, руководство разработчика, общие сведения, подключение карты… ну да, окей, мы взяли такие, как-то вроде всё подключили, видим карту с населенными пунктами…, а дальше-то что делать?

DISCLAIMER: Чтобы не уточнять всякий раз, что все ниже перечисленное является сугубо моим личным мнением и лично моим взглядом на процесс познания, напишу это в самом начале один раз. Всякий раз, когда вы будете со мной не согласны помните, что я не претендую на истину в последней инстанции, что осознания, произошедшие со мной во время применения YaMap API v3 для реализации бизнес-фич, могут быть неверными или вы их неверно интерпретировали. Если видите ошибку в суждениях или выводах, напишите о ней, помня, что каждый из нас видит мир и структуру абстракций по-своему.

Возможно, это лично мои тараканы, но логика познания концепции, заложенной в новое API, у меня совершенно не билась с тем, как выстроена документация. Ключевым разделом документации является раздел Система сущностей. Именно он должен, на мой взгляд (да, я помню дисклеймер, но на всякий случай в последний раз уточню, что это лично мой взгляд), находиться в самом самом начале документации.

Я не буду вдаваться в подробности размещения карты на странице. Это действие подробно описано в соответствующем разделе документации. Будем исходить из того, что мы работаем напрямую с библиотекой безо всяких оберток для Vue или React-а. Получили ключ, разместили тег script в заголовке файла (раздел документации про это), на странице есть правильно стилизованный пустой div-контейнер для карты. Настал момент, когда нужно непосредственно размещать карту на странице.

С чем стоит сразу смириться, так это с тем, что все классы, с которыми нам предстоит работать, будут доступны только после резолва промиса ymaps3.ready. Это важный момент для понимания структуры работы с модулями карты (созданными вами или импортирующимися с серверов Яндекса) и организацией размещения и подключения этих самых модулей у себя в проекте.

Начало

Первое, что мы обычно видим в примерах сразу после создания экземпляра класса YMap:

const map = new YMap(document.getElementById('ymap'), {
  location: {
    center: [39.6, 52.6],
    zoom: 10
  }
})

это конструкция типа map.addChild(new YMapDefaultSchemeLayer()). С подобной механикой работы мы будем теперь сталкиваться повсюду, т.к. вся функциональность в новых картах построена через внедрение и удаление той или иной entity.

К примеру, захотели мы увидеть на своей карте собственно карту (ведь после создания экземпляра карты мы видим лишь серый, вгоняющий во фрустрацию, прямоугольник):

map.addChild(new YMapDefaultSchemeLayer({}))

или элементы управления:

const controls = new YMapControls(
    {
      position: 'bottom right',
      orientation: 'vertical'
    },
    [new YMapZoomControl({})]
  )

map.addChild(controls)

Нам нужно всё это явно добавить на карту. На первый взгляд, это кажется излишеством и каким-то задротством. Но на деле это позволяет очень гибко использовать этот инструмент. Ведь подложек может быть великое множество и с каждой из них мы сможем работать в едином API.

Что ж, теперь мы видим нормальную человеческую карту с населенными пунктами и элементами управления масштабом:

Карта отцентрированная по переданным координатам

Карта отцентрированная по переданным координатам

Предположим, что мы работаем с неким внутренним API, которое присылает нам объекты на карте в формате GeoJSON:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [39.4220979, 52.6228689],
            [39.572434, 52.5183144],
            [39.8114908, 52.5312354],
            [39.7007866, 52.6605346],
            [39.4220979, 52.6228689]
          ]
        ]
      }
    }
  ]
}

Мы хотим их увидеть. Для этого у нас есть следующие варианты (по факту, разница лишь в организации кода).

Вариант №1. Пишем все внутри основного метода (пример из документации):

// Формируем фичу типа
const polygonFeature = new YMapFeature({
  id: 'polygon',
  source: 'featureSource',
  geometry: {
    type: 'Polygon',
    coordinates: [
      [
        [39.4220979, 52.6228689],
        [39.572434, 52.5183144],
        [39.8114908, 52.5312354],
        [39.7007866, 52.6605346],
        [39.4220979, 52.6228689]
      ]
    ]
  },
  style: {
    stroke: [{ color: 'red', opacity: 0.9, width: 1} ]
    fill: 'rgba(1,1,1,0.5)'
  }
});

// Добавляем фичу на карту
map.addChild(polygonFeature);

Вариант №2. Выносим в отдельный модуль:

/*
./YMapObjects.ts
Модуль с публичным методом $addChildren
*/
export class YMapObjects extends ymaps3.YMapComplexEntity<{}> {
  private $_polygonsCollection: YMapCollection | null = null

  /*
  Соглашение (с самим собой) об именовании методов:
    - публичный метод сущности, который придумал и реализовал
      я сам начинается со знака $
    - такой же, но приватный $_
    - то же справедливо и для полей класса
    - методы/поля, начинающиеся просто с нижнего
      подчеркивания -- private или protected методы ymaps.API
    - методы/поля, начинающиеся сразу с символов -- публичные методы/поля ymaps.API
  */
  $addChildren(geoJson?: FeatureCollection) {
    if (!geoJson?.features?.length) return

    const { YMapCollection } = ymaps3

    /*
    Очищаем карту от других элементов
    В соответствие с бищнес-требованиями. нам нужен одна коллекция
    объектов в один момент времени
    */
    this.$_clearMapFromExistingObjects()
    
    /*
    Устанавливаем карту в масштабе и координатах всего набора коллекции
    Используем метод bbox библиотеки @turf
    Сами Яндекс.Карты v3 как-будто бы не умеют этого делать
    */
    setActiveBounds(this.root, geoJson)

    /*
    Создаем новую entity-коллекцию для наших объектов на карте  
    */
    this.$_polygonsCollection = new YMapCollection(
      {},
      /*
      Метод getGeoObjects маппит из GeoJSON в массив YMapFeature[]
      (см. Пример №1)
      */
      { children: getGeoObjects(ymaps3, geoJson.features) }
    )

    /*
    Размещаем всю коллекцию внутри entity YMapObjects
    */
    this.addChild(this.$polygonsCollection)
  }

  private $_clearMapFromExistingObjects() {
    this.$_polygonsCollection?.children?.slice().forEach((child) => {
      this.$_polygonsCollection?.removeChild(child)
    })
  }
}

Теперь для его подключения нам нужно его заимпортить и добавить на карту:

await ymaps3.ready
// Динамический импорт после резолва ymaps3.ready
const { YMapObjects } = await import('./YMapObjects')
const mapObjects = new YMapObjects({})

map
  // не забываем разместить слой для фичей, иначе ничего работать не будет
  .addChild(new YMapDefaultFeaturesLayer({}))
  .addChild(entitiesSwitcher)

Теперь через публичный метод нашего нового entity-модуля мы можем разместить наш geoJSON на карте:

geoJson = await loadJson()
mapObjects?.$addChildren(geoJson)

В итоге наша карта приобретает вид:

62420663ea89e695d7bd32ccfec0a376.png

Что важно здесь уточнить. Когда приложение небольшое и объектов у нас немного, вариант №1 вполне себе вариант. Но как только приложение растет, количество бизнес-логики, которая накручивается на карту, возрастает, вопрос о разделении кода на модули становится в полный рост. Так что, какой из этих вариантов вы будете использовать, надо смотреть по ситуации в моменте и в перспективе.

Система сущностей

Итак, мы подошли вплотную к очень важному моменту в понимании механик работы с Яндекс.Картами API v3.

В примере №2 мы создаем свою сущность, которая наследуется от YMapComplexEntity. Надо сказать, что типов этих энтитей есть несколько (раздел документации):

  • Entity — базовая сущность;

  • Complex Entity — сущность, которая может иметь собственное поддерево сущностей, но не имеет публичного интерфейса взаимодействия с ним;

  • Group Entity — аналогична Complex Entity, но имеет публичный интерфейс для взаимодействия с поддеревом сущностей;

  • Root Entity — корневая сущность, которая не может быть частью поддерева другой сущности. Аналогично Group Entity имеет публичный интерфейс управления собственным поддеревом.

В рассмотренном нами примере (с начала до этого момента):

  • созданная нами карта имеет тип YMap, это Root Entity (или точнее YMap extends GenericRootEntity)

  • YMapObjects — это Complex Entity (или точнее YMapComplexEntity extends GenericComplexEntity implements YMapEntity)

  • Полигон на карте это Entity (или точнее YMapFeature extends YMapEntity)

Почему все это важно? Дело в том, что чтобы мы не пытались реализовать на нашей карте в последствие, мы будем вынуждены делать это через одну из приведенных выше типов entity. Прелесть ситуации в том, что сущностью может быть и обработчик события, как например YMapListener, который под капотом оказывается тоже Entity (или точнее YMapListener extends YMapEntity). Теперь мы можем складывать кубики функциональности в любом порядке и вложенности.

К примеру, я хочу, чтобы по двойному клику на мой полигон, этот полигон выделялся, а заодно и что-то делал при выделении. Для этого создаем отдельную сущность:

/*
./YMapEntitiesSelector.ts
*/

type YMapEntitySelectorProps = {
  cb?: (...args: any[]) => any
}

export class YMapEntitiesSelector extends ymaps3.YMapComplexEntity {
  private $_entities: any[] = []

  constructor(props: YMapEntitySelectorProps) {
    /*
    Вот сюда мы передаем наши пропсы. В данном случае, у нас в пропсах есть
    поле cb, которой мы будем вызывать, при изменении состояния полигонов
    Поле cb теперь будет доступно из поля this._props базового класса
    */
    super(props)

    // Создаем вложенную entity, реализующую подписку на события указателя
    this._addDirectChild(
      new ymaps3.YMapListener({
        layer: 'any',
        // По двойному клику выделяем или убираем выделение с полигона
        onDblClick: (event) => {
          if (event?.entity) {
            this.$_toggleEntitySelection(event.entity)
          }
        },

        // Если щелкнули где-то по карте, снимаем выделение
        onPointerUp: (event) => {
          if (!event?.entity || !this.$_checkIsValidEntityType(event?.entity?._props?.type)) {
            this.$deselectAllEntities()
          }
        }
      })
    )
  }

  /*
  Хук, который вызывается при добавлении сущности к другой сущности
  В нашем случае, это будет карта
  */
  protected _onAttach() {
    /*
    Отключаем двойной клик на карте, иначе она будет приближаться
    при двойном клике. 
    
    !!!! Обращаю внимание на переменную this.root -- это ссылка 
    !!!! на сущность карты
    */
    this.root.setBehaviors(this.root.behaviors.filter((beh) => beh !== 'dblClick'))
  }

  /*
  Хук, который вызывается при удалении сущности из другой сущности
  В нашем случае, это будет карта
  */
  protected _onDetach() {
    // Возвращаем двойной клик на карту
    this.root.setBehaviors([...this.root.behaviors, 'dblClick'])
  }

  /*
  Метод, реализующий бизнес-логику работы с конкретной сущностью
  В данном случае, мы переключаем отображение и выполняем колбэк
  */
  private $_toggleEntitySelection(entity) {
    const props = entity._props
    if (this.$_checkIsValidEntityType(props.type)) {
      const style = {
        ...props.style
      }

      const isSelected = props.isSelected

      style.fillOpacity = isSelected ? 0.2 : 0.5
      entity.update({ style, isSelected: !isSelected })

      if (!isSelected) {
        this.$_entities.push(entity)
      }
    }

    if (typeof this._props.cb === 'function') {
      this._props.cb(entity._props)
    }
  }

  // Метод проверки сущности на совпадение типа
  private $_checkIsValidEntityType(type: string) {
    return type === 'Polygon'
  }

  /*
  Публичный метод для работы с YMapEntitiesSelector извне
  К примеру, нам может потребоваться снимать выделение при загрузке нового набора
  */
  $deselectAllEntities() {
    this.$_entities.forEach((entity) => {
      this.$_toggleEntitySelection(entity)
    })

    this.$_entities = []
  }
}

Всё, что теперь нам нужно сделать, это просто подключить нашу сущность к карте. Аналогично сущности с гео-объектами:

await ymaps3.ready
// Динамический импорт после резолва ymaps3.ready
// ........
// ........
const { YMapEntitiesSelector } = await import('./YMapEntitiesSelector')
const polygonSelector = new YMapEntitiesSelector({ cb: mapService.doOnSelect })

map.addChild(polygonSelector)

Всё. Теперь наша карта умеет выделять полигоны и выполнять какой-то код при переключении состояния! Больше того, мы можем создать сущность, которая будет оберткой для этой сущности. К примеру, переключать состояние между отображением по клику балуна или по двойному клику выделять объекты. Вдаваться в подробности не буду, т.к. суть работы этой сущности будет ясна после рассмотрения работы сущности с хинтом, который предоставляет нам пакет @yandex/ymaps3-hint@0.0.1. Да и материал уже получился мегаобъемный, а еще много о чем предстоит поговорить.

Различные фичи на карте: хинты от Яндекса

Если оставить самую суть, которая загружается нам в пакете при выполнении кода:

const {YMapHint, YMapHintContext} = await ymaps3.import('@yandex/ymaps3-hint@0.0.1');

то в сухом остатке у нас будет 2 ключевых объекта, которые требуют особого внимания:

export const YMapHintContext = new ymaps3.YMapContext('YMapHint')

и

class YMapHint extends ymaps3.YMapGroupEntity<{ hint: (entity?: EntityType) => unknown }> {
  private $_element: HTMLElement | null = null
  private $_hintElement: HTMLElement | null = null
  private $_detachDom?: Function

  constructor(props: { hint: (entity?: EntityType) => unknown }) {
    super(props, {
      container: true // Указываем, что это контейнер
    })

    // Добавляем слушатели событий
    this._addDirectChild(
      new ymaps3.YMapListener({
        layer: 'any',
        onMouseMove: (event, { screenCoordinates }) => {
          // Обновляем позицию подсказки при движении мыши
          if (this.$_hintElement?.classList.contains('ymaps3x0--YMapHint__hint_is-visible')) {
            this.$_positionHintElement(screenCoordinates)
          }
        },
        onMouseEnter: (event, { screenCoordinates }) => {
          const hintContent = this._props.hint(event?.entity) // Получаем подсказку
          if (hintContent) {
            this.$_positionHintElement(screenCoordinates)
            this.$_toggleHint(true) // Показываем подсказку
            this._provideContext(YMapHintContext, { hint: hintContent })
          } else {
            this.$_toggleHint(false) // Скрываем подсказку
            this._provideContext(YMapHintContext, null)
          }
        },
        onMouseLeave: () => {
          this._provideContext(YMapHintContext, null)
          this.$_toggleHint(false) // Скрываем подсказку
        },
        onUpdate: () => {
          this._provideContext(YMapHintContext, null)
          this.$_toggleHint(false) // Скрываем подсказку
        }
      })
    )
  }

  private $_positionHintElement(screenCoordinates: [number, number]) {
    const { left: containerLeft, top: containerTop } =
      this.root?.container?.getBoundingClientRect() ?? { left: 0, top: 0 }
    const positionX = (screenCoordinates[0] - containerLeft).toFixed(0)
    const positionY = (screenCoordinates[1] - containerTop).toFixed(0)
    if (this.$_hintElement) {
      this.$_hintElement.style.transform = `translate(${positionX}px, ${positionY}px)` // Устанавливаем позицию подсказки
    }
  }

  private $_toggleHint(isVisible: boolean) {
    this.$_hintElement?.classList.toggle('ymaps3x0--YMapHint__hint_is-visible', isVisible) // Показываем или скрываем подсказку
  }

  _onAttach() {
    this.$_element = document.createElement('ymaps')
    this.$_element.className = 'ymaps3x0--YMapHint__container'
    this.$_hintElement = this.$_element.appendChild(document.createElement('ymaps'))
    this.$_hintElement.className = 'ymaps3x0--YMapHint__hint'
    this.$_detachDom = ymaps3.useDomContext(this, this.$_element, this.$_hintElement)
    this._provideContext(YMapHintContext, null)
  }

  _onDetach() {
    if (this.$_detachDom) {
      this.$_detachDom()
    }
    this.$_detachDom = void 0
    this.$_element = null
  }
}

Что для нас здесь крайне важно понимать?

Во-первых, это механику работы хинта. При монтировании этой энтити создается элемент обертка с классом ymaps3x0--YMapHint__container. По факту, этот класс имеет следующие стили:

.ymaps3x0--YMapHint__container {
  display: block;
  height: 100%;
  left: 0;
  pointer-events: none;
  position: absolute;
  top: 0;
  width: 100%;
  z-index: 200;
}

То есть слой позиционируется абсолютно и занимает всё пространство на карте. Взаимодействия с мышью отключаются, но остается контекст для позиционирования вложенного элемента с классом ymaps3x0--YMapHint__hint:

.ymaps3x0--YMapHint__hint {
  display: none;
  position: absolute;
  pointer-events: all;
}

В результате у нас создается контейнер, в котором перемещается пока пустой элемент, в который потом мы отдельно поместим контент.

Во-вторых, это вызов метода ymaps3.useDomContext, который фактически размещает this.$_element на карте:

this.$_detachDom = ymaps3.useDomContext(this, this.$_element, this.$_hintElement)

Здесь важно прояснить несколько моментов:

  • Последним по счету передается тот элемент, в который в последствие будет помещаться дочерний html-элемент. То есть, на данным момент у нас есть структура элементов вида:

    container > hint > [slot]

    Если мы передадим третьим аргументом this.$_element, то структура превратится в

    container > hint
    container > [slot]

    Если третьим аргументом будет null, тогда slot окажется на одном уровне с контейнером:

    container > hint
    [slot]

  • В результате вызовы метода ymaps3.useDomContext возвращается метод удаления элемента из поддерева карты (строго говоря, из поддерева родительского entity). Его важно вызывать в методе _onDetach

  • То, благодаря чему магия с передачей данных от объектов на карте к хинту во время передвижений работает, это метод this._provideContext. На вход он получает наш контекст YMapHintContext, который у нас есть с самого начала импорта модуля и, собственно данные, которые мы хотим пробросить в дочерние entity. YMapHintContext по сути мог бы быть и просто строкой, по которой бы мы получали текущий контекст. Сейчас мы работаем с ссылкой, и это должно быть оправдано в масштабах всего API, но сути это не меняет. Контекст — это не какая-то магическая структура, это чисто ссылка на конкретный провайдер данных.

  • Важно! При создании своих элементов на карте, вы можете им прописывать стили непосредственно через style-пропсы создаваемых элементов, или просто указав их класс, как это сделано у ребят с Яндекса. Если вы решили пойти по пути с классами имейте ввиду, что если у класса не будет указано position: absolute, ваша карта будет бесконечно съезжать вверх. Выглядит очень крипово и неожиданно. Так что сначала пишете стили у себя в приложении, потом размещаете их в сущности и потом на карте. Ну либо просто style-пропсы.

На логике работы остальных методов я останавливаться не буду, поскольку для понимания механик работы это не имеет концептуального значения.

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

export class HintWindow extends ymaps3.YMapComplexEntity<{}> {
  private $_element: HTMLDivElement

  private $_detachDom: () => void

  private $_unwatchSearchContext: () => void

  // Создаем html-контент окна хинта
  private $_createElement() {
    const windowElement = document.createElement('div')
    windowElement.classList.add('hint_window')

    const windowElementTitle = document.createElement('div')
    windowElementTitle.classList.add('hint_window__title')

    const windowElementText = document.createElement('div')

    windowElement.appendChild(windowElementTitle)
    windowElement.appendChild(windowElementText)

    return windowElement
  }

  /*
  Это важный метод! Здесь мы фактически связываем данные из контекста
  с конкретными html-элементами кона
  */
  private async $_searchContextListener() {
    const { YMapHintContext } = await ymaps3.import('@yandex/ymaps3-hint@0.0.1')
    const hintContext = this._consumeContext<{ hint: { title: string; text: string } }>(
      YMapHintContext
    )?.hint
    
    this.$_element.firstChild.textContent = hintContext?.title
    this.$_element.lastChild.textContent = hintContext?.text
  }

  protected async _onAttach(): Promise {
    const { YMapHintContext } = await ymaps3.import('@yandex/ymaps3-hint@0.0.1')
    this.$_element = this.$_createElement()

    /*
    Здесь фактически происходит подписки на проброшенный
    из родителя конектст YMapHintContext
    */
    this.$_unwatchSearchContext = this._watchContext(
      YMapHintContext,
      this.$_searchContextListener.bind(this)
    )
    this.$_detachDom = ymaps3.useDomContext(this, this.$_element, this.$_element)
  }

  // Method for detaching control from the map
  protected _onDetach(): void {
    this.$_unwatchSearchContext()
    this.$_detachDom()
  }
}

Итого:

  1. Когда смонтировали энтитю в родителя, создали html-содержимое хинта

  2. подписались на изменение контекста, передав ссылку на сам контекст и обработчик подписки

  3. Разместили html-содержимое в dom-дереве родителя.

Далее в коде мы должны подключить наш хинт:

await ymaps3.ready
// Динамический импорт после резолва ymaps3.ready
// ........
// ........
const { YMapHint } = await ymaps3.import('@yandex/ymaps3-hint@0.0.1')

const hint = new YMapHint({ hint: (object) => object?.properties?.hint })
// добавляем нашу юнтитю в YMapHint, теперь YMapHint родитель для HintWindow
hint.addChild(new HintWindow({}))

map.addChild(hint)

Обращаю внимание на метод hint, который мы передаем в конструктор YMapHint. Именно он будет интерпретировать данные с объекта на карте и передавать их в провайдер для самого содержимого хинта HintWindow(строка 22 в class YMapHint).

Заключение

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

Хочется выразить уважение ребятам с Яндекса за такой дизайн их системы. Остается лишь научить людей пользоваться их API, с чем я, надеюсь, немного помог.

Post scriptum

Из полностью не понятого мной могу отметить следующие моменты:

  • Как встроенными в карты методами спозиционировать карту по границам переданных объектов?

  • Зачем при создании Complex Entity нам нужно (или не очень) передавать container: true? Я так и не нашел отличий в поведении создаваемых entity.

© Habrahabr.ru