MapView

Векторная карта — один и тот же движок (maplibre-native) на iOS и Android — как Presentable: пушь её, открывай на весь экран и накладывай поверх свой UI виджетами. Ты приносишь стиль MapLibre (по умолчанию — бесплатный готовый); карта рисует подложку, твои именованные слои маркеров и линий и метку местоположения пользователя, которую ты питаешь из Geolocation. Опционален для хоста — проверяй через MapView.isSupported.

Плагин приложения: перечисли его в app.json — "plugins": { "map": "*" }. Список один для всех платформ (заголовок бандла // plugins:): плагина, которого он не называет, нет — и в шелле, собранном из проекта, и в веб-плеере, а компиляция предупреждает о том, который приложение использует, но не перечисляет. Приложение-просмотрщик LeCodes регистрирует каждый первопартийный плагин, что бы ни говорил список (в его iOS App Clip карты нет).

Где работает: iOS и Android, а также веб-плеер (lecodes dev --web), чья половина — близнец плагина на maplibre-gl — тот же стиль, тот же API, так что логику карты можно тестировать в браузере; внешний вид — у родственного рендерера, проверяй его на устройстве. Не на десктопе, не в headless (lecodes render) и не в Web Lite — там isSupported равен false.

Обзор

TypeScript
const places: { id: string, name: string, lngLat: LngLat }[] = [
  { id: "opera", name: "Opera", lngLat: [56.2415, 58.0105] },
  { id: "embankment", name: "Embankment", lngLat: [56.2280, 58.0150] },
]

const title = UIText("").style({ color: "white", fontSize: 18 })
const card = UIBottomSheet(title).style({ bgColor: "#1c1c1e", padding: 24 })

const map = MapView({ center: [56.24, 58.01], zoom: 12 })    // стиль "liberty" по умолчанию
  .onTap(() => card.hide())

map.markers("places", { cluster: true })
  .onTap(m => { title.text = m.properties.title; card.show() })
  .set(places.map(p => ({ id: p.id, lngLat: p.lngLat, title: p.name, color: "#DD4B40" })))

const header = UIWidget(UIButton(UIText("Back")).onClick(() => Router.pop()))
  .style({ top: "safe-top", left: 16 })
map.setContent([header, card])   // содержимое карты: показывается вместе с ней; карточка скрыта до тапа
card.hide()

Router.init(UIScreen(
  MapView.isSupported
    ? UIButton(UIText("Show the map")).onClick(() => Router.push(map))
    : UIText("No map on this device"),
))

Создание

TypeScript
MapView.isSupported: boolean          // статическое — зарегистрировал ли этот хост view "map"?

MapView(options?: {
  style?: "liberty" | "positron" | …       // готовый стиль (по умолчанию "liberty"), URL,
         | string | FetchResponse | object //   asset(), прочитанный файл или сам стиль
  center?: [lng, lat], zoom?, minZoom?, maxZoom?, bearing?, pitch?
  rotate?: boolean                    // жест поворота, по умолчанию true
  tilt?: boolean                      // жест наклона, по умолчанию false
}): MapView

Координаты везде — [longitude, latitude] (порядок GeoJSON — тот же, что у стиля, тайлов и данных, которые у тебя уже есть). MapView — полноценный NativeView: Router.push(map), map.open({ transition }) / map.close(), onOpen / onClose / onBack, а также сырой канал call / on остаются доступны.

Откуда берётся стиль

TypeScript
MapView()                                                               // карта мира, без настройки
MapView({ style: "positron" })                                          // другой готовый
MapView({ style: "https://tiles.example.com/styles/city/style.json" })  // URL, карта загружает его сама
MapView({ style: asset("./map/style.json") })                            // в комплекте с приложением
MapView({ style: fetchLocal("style.json") })                             // файл, который у приложения уже есть
fetch(url).then(style => MapView({ style }))                            // скачан один раз, закеширован в `files`
MapView({ style: { ...base, sources: { openmaptiles: { type: "vector", url: `${server}/data/v3.json` } } } })

MapView() вообще без стиля рисует карту: готовые имена — liberty (по умолчанию), bright, positron, dark, fiord — это стили на OpenFreeMap, который отдаёт данные OSM без ключа, регистрации и лимитов запросов, включая коммерческое использование. Две вещи, которые надо знать, прежде чем на нём выпускаться: укажи авторство («© OpenFreeMap © OpenMapTiles, data from OpenStreetMap» — данные OSM требуют атрибуции при любом стиле), и это публичный инстанс на пожертвования без SLA, так что продукт с реальным трафиком должен крутить собственные тайлы (весь стек open-source) и передавать URL того стиля.

URL переходит в нативную карту как URL. Любая другая форма читается обёрткой и переходит как текст стиля, один раз, при создании — так что собранному в комплект asset("./map/style.json") не нужен сервер для самого стиля (его тайлы, спрайты и глифы по-прежнему загружаются с url, которые он называет), а объект стиля — естественный способ подставить адрес тайл-сервера в рантайме. asset() даёт URL в серверной компиляции и локальный файл в компиляции шелла; тот же вызов работает в обоих случаях.

Каждый URL внутри стиля должен быть абсолютным. maplibre-native — в отличие от maplibre-gl-js — никогда не разрешает относительные sources[…].url / sprite / glyphs относительно адреса самого стиля: стиль загружается, ready срабатывает, твои слои рисуются, а подложка остаётся пустой. Инстанс tileserver-gl выдаёт относительные url, пока не задан его publicUrl (--public_url). У стиля, переданного файлом или объектом, адреса нет вовсе, так что правило то же.

Note

нативная карта существует только после показа и загрузки её стиля. Каждый вызов данных и камеры до этого ставится в очередь и воспроизводится по порядку, так что собирай карту декларативно и пушь когда угодно — onReady срабатывает после воспроизведения. Перезагрузка стиля стирает всё, что приложение положило в стиль, поэтому обёртка заново применяет свои слои, их данные, отступы и метку каждый раз, когда карта сообщает о готовности; onReady срабатывает снова вместе с ними. getCamera() — единственный вызов не из очереди: он спрашивает карту сразу и отклоняется, пока карты ещё нет — вызывай его после onReady.

Слои

TypeScript
map.markers(name, options?: { cluster?, clusterRadius?, clusterMaxZoom? }): MarkerLayer
map.line(name, options?: { color?, width?, opacity? }): LineLayer

markers.set(items: { id, lngLat, icon?, color?, title?, ...props }[]): this   // заменяет
markers.clear(): this
markers.onTap(cb: (m: { id, lngLat, properties }) => void): this

line.set(coordinates: [lng, lat][]): this
line.clear(): this

map.setData(source: string, geojson: object): this      // сырой: любой GeoJSON-источник в стиле

Слой — один именованный GeoJSON-источник; set() заменяет его содержимое. Стиль первичен: если стиль уже объявляет источник с таким именем, его слои используются как есть и пушатся только данные — так спроектированный стиль (кластеры со счётчиками по цветам, правила подписей, выражения иконок) остаётся в JSON, где ему и место. Иначе плагин создаёт источник и слои по умолчанию: точку цвета color, изображение icon, если задано, подпись title и круги кластеров со счётчиками при cluster: true. Тап по кластеру приближает; он ничего не эмитит. clusterRadius в px (по умолчанию 50); width линии в px (по умолчанию 4), её opacity 0–1 (по умолчанию 1).

icon называет изображение в спрайте стиля. Мультиспрайтовый стиль ("sprite": [{ id, url }, …], поддерживается нативно) адресует его по id спрайта: "place:tag_bear" = изображение tag_bear спрайта place. Дополнительные ключи элемента становятся свойствами фичи и возвращаются в onTap.

Камера

TypeScript
map.flyTo(center: [lng, lat], options?: { zoom?, bearing?, pitch?, duration? }): this   // анимированно, duration в мс (по умолчанию 600)
map.jumpTo(center, options?): this                                                       // мгновенно
map.fitPoints(points: [lng, lat][], options?: { padding?, maxZoom?, animate? }): this
map.setPadding({ top?, left?, bottom?, right? }): this     // px или "40%" — часть вида, которую закрывает твой UI
map.getCamera(): Promise<{ center, zoom, bearing, pitch }>
map.onMove(cb: (camera) => void): this                     // камера остановилась (конец жеста или анимации)
map.onTap(cb: (tap: { lngLat, point }) => void): this      // тап, не попавший ни в один из твоих слоёв
map.onError(cb: (e: { message }) => void): this            // стиль/источник, который карта не смогла загрузить
map.onReady(cb: () => void): this                          // стиль загружен, вызовы из очереди воспроизведены

onError никогда не фатален — карта остаётся рабочей. Без обработчика сообщение уходит в console.error, так что незагрузившийся стиль говорит об этом, а не показывает пустую карту.

Вызовы камеры — fire-and-forget и цепочечные; ждёт только getCamera. setPadding — случай нижнего листа: с { bottom: "40%" } каждый flyTo / fitPoints центрирует в видимых 60 %. Bearing — градусы по часовой от севера, pitch — градусы от вертикали.

Местоположение пользователя

TypeScript
map.setUserLocation(lngLat: [lng, lat] | null, options?: { accuracy?, heading? }): this

Карта только рисует метку (точка, ореол точности в метрах, клин направления в градусах); откуда берётся позиция — твоё дело: плагин не трогает железо геолокации, не запрашивает разрешений, а у приложения без плагина geolocation истории с местоположением нет вовсе.

TypeScript
Geolocation.watch(pos =>
  map.setUserLocation([pos.longitude, pos.latitude], { accuracy: pos.accuracy, heading: pos.heading }))
  .then(watch => map.onClose(() => { watch.stop(); map.setUserLocation(null) }))

Подводные камни

TypeScript
map.flyTo([58.01, 56.24])     // ✗ lat, lng — попадает в Индийский океан
map.flyTo([56.24, 58.01])     // ✓ lng, lat

Смотрите также

  • NativeView — канал под капотом и как достучаться до него напрямую
  • UIWidget — шапка / лист над картой (map.setContent([...]))
  • Geolocation — позиция для setUserLocation
  • Экраны и Router — пуш карты как destination