npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

osmgl

v0.31.19

Published

Самостоятельный 3D-движок карты (WebGL2): своя камера, свой конвейер тайлов, свои воркеры и рендер-проходы. Без MapLibre.

Readme

| Профиль | dpr | буфер | MSAA | воркеры | тени | детализация | тайлы | |---------|-----|-------|------|---------|------|-------------|-------| | high | ≤2 | ≤4096×4096 | да | ≤6 | да, 1024 | 1 | до 512 | | balanced | ≤1.75 | ≤2560×2160 | да | ≤4 | да, 1024 | 0.85 | до 192 | | low | ≤1.25 | ≤1920×1350 | нет | ≤2 | нет | 0.5 | до 96, крупнее | | minimal | 1 | ≤1280×900 | нет | ≤2 | нет | 0 | до 48, ещё крупнее |

osmgl

Самостоятельный 3D-движок карты. Не надстройка над MapLibre: своя камера, свой конвейер тайлов (сеть и разбор — в воркерах), свой рендер на WebGL2. Ни MapLibre, ни three.js в зависимостях нет.

Отличие от maps3d-web: тот — набор three.js-оверлеев поверх чужой карты, каждый со своим проходом рендера. Здесь карта своя целиком.

npm install && npm run build
npm run demo        # http://localhost:5180/

Демо-сервер сам выбирает источник тайлов — первый, который ответит:

| # | Источник | Примечание | |---|----------|------------| | 1 | TILES_UPSTREAM | если задан | | 2 | http://localhost:8088/tileserver/data/v3 | через шлюз, основной путь | | 3 | http://localhost:8090/data/v3 | tileserver-gl напрямую | | 4 | data/tiles/tajikistan.mbtiles | офлайн, без поднятого стека |

Тайлы проксируются на origin демо: иначе упрёмся либо в CORS, либо в гейт API-ключей.

Гоча со шлюзом. При API_KEYS_ENABLED=true шлюз пускает без ключа только по first-party allowlist (API_KEYS_FIRST_PARTY, локально localhost), а решает он по заголовку Origin/Referer. Браузер шлёт его сам, а fetch из Node — нет, поэтому прокси проставляет Referer явно. Без этого шлюз отвечает 401 invalid or missing api key, даже когда «локально проверка отключена». Настоящий ключ — через TILES_KEY.

Использование

<script src="/maps/osmgl.global.js"></script>
<script>
  const map = new OsmGL.Map({
    container: 'map',
    center: [68.7864, 38.5598],        // Душанбе
    zoom: 16.4, pitch: 62, bearing: -18,
    tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
    sourceMaxZoom: 14,
    // style не задан → встроенный styles/osm-3d.json
  })
</script>

Не пишите const { Map } = OsmGL в обычном <script>. Такое объявление попадает в глобальную лексическую область и затеняет встроенный Map для кода внутри бандла. Движок от этого защищён (core/natives.ts), но привычка обращаться через пространство имён избавляет от целого класса подобных сюрпризов. В ESM проблемы нет.

ESM:

import { Map } from 'osmgl'

API

| Метод | Описание | |-------|----------| | new Map(opts) | Создать карту (см. MapOptions). | | setCenter/setZoom/setBearing/setPitch | Камера по одному параметру. | | jumpTo(opts) / easeTo({...,duration}) | Мгновенно / плавно. | | project(lngLat, alt?) / unproject(point) | География ↔ экран (с учётом наклона). | | setStyle(spec, {keepEdits?}) | Заменить стиль целиком; тайлы пересобираются, только если изменился план воркера. Стиль — формата v2 (MapLibre v8 + osmgl-*, см. «Стиль»); v1 (version: 1) читается и дальше. Строкой — ссылка или имя встроенного: 'mlight-city', 'osm-city', … keepEdits: false — сбросить правки на лету (для редактора). | | getStyle() | Стиль целиком В ФОРМАТЕ v2, с правками на лету — годится обратно в setStyle; toMapLibreStyle(getStyle()) — копия для MapLibre. Внутренняя модель — getStyleV1(). | | setPaintProperty(id, prop, v) / getPaintProperty | Покраска на лету по имени v2 ('line-color', 'osmgl-window-color') — юниформы; тайлы пересобираются, только если правка дошла до плана воркера (значение по фиче, line-cap, высота дома…). Имя v1 ('color') — синоним ещё два выпуска, с предупреждением. | | setLayoutProperty(id, prop, v) / getLayoutProperty | По имени v2: 'text-size', 'visibility', 'osmgl-corner-radius'… — пересобирается раскладка подписей; text-field, icon-image, symbol-placement… — ещё и тайлы своего источника. | | layersAt(point) | Какие слои стиля нарисовали то, что под точкой (подписи и фичи, сверху вниз) — для редактора стилей. | | setLight({...}) | Ambient и два направленных источника: азимут, высота, цвет, сила. | | setTheme(name) | Тема оформления: day | night | mono | pale либо своя накладка (v2: paint[id]["line-color"]; v1 — синоним). | | setGlobalStateProperty(k, v) | Общее состояние стиля (["global-state", k]): доходит и до воркера — фильтры, значения по фиче и подписи пересобираются у источников, чей план на него смотрит. | | setLayerVisible(id, on) | Включить/выключить слой стиля. | | refresh() | Перечитать тайлы (после правки в редакторе). | | getStats() | Тайлы в работе/видимые, число воркеров, имя GPU. | | destroy() | Освободить всё. |

События: load, move, moveend, zoom, render, idle, error, contextlost.

На проде

Движок выпускается в npm пакетом osmgl — порядок выпуска и грабли в RELEASE.md.

Потребителей двое, и обновляются они по-разному. Карта /map/ берёт пакет обычной зависимостью. Админка отдаёт отдельный файл src/Admin/public/maps/osmgl.global.js — он лежит в git и обновляется командой npm run maps:osmgl в src/Admin, которая копирует готовый бандл из установленного пакета.

Бандл уезжает в образ админки; nginx отдаёт его двумя путями — /maps/osmgl.global.js (стабильная ссылка) и /admin/maps/…. Рядом лежит страница превью osmgl-preview.html → /maps/osmgl-preview.html. Копией, а не сборкой из админки: движок собирается своим tsup со своим воркером, вшитым в бандл строкой, и тащить это в vite-сборку значило бы держать два пути сборки одного файла. Так же лежит и maps3d.global.js.

Мелкие зумы (глобус) страница берёт ОТДЕЛЬНЫМ источником /tileserver/data/world/{z}/{x}/{y}.pbf — основные тайлы покрывают только Таджикистан. Набор собирает bash deploy/rebuild-world-tiles.sh; на сервере файл кладётся в data/tiles/world.mbtiles (в git mbtiles не хранятся) и подхватывается уже прописанным в data/tiles/config.json датасетом world — после копирования нужен рестарт tileserver.

Стиль

Формат стиля — v2: синтаксис MapLibre v8 как есть плюс свои свойства osmgl-* (porting/unified-style-format.md — правила, полные таблицы соответствия и статус). Стиль MapLibre — готовый стиль v2 без единой правки; свой стиль отличается признаком metadata["osmgl:version"]: 2 и свойствами с приставкой. Встроенные стили лежат в styles/ уже в v2 и вшиты в бандл: карта рисуется без единого запроса за конфигом.

Подписи стиля MapLibre (этап b, §9 плана) раскладываются по правилам MapLibre: значения по фиче (и «по зуму и по фиче» — точно на любом зуме, без ступеньки по зуму тайла), перебор якорей text-variable-anchor[-offset] с text-radial-offset и text-justify: auto, icon-text-fit с растяжимыми картинками спрайта (stretchX/stretchY/ content, они же в addImage), раздельные text-/icon- allow-overlap/ignore-placement/ optional/padding/opacity, symbol-sort-key, symbol-z-order, text-rotate, text-line-height, *-translate, поворот у точки по *-rotation-alignment: map и рост рисунка с перспективой. Своя блочная раскладка (osmgl-symbol-layout: block) — прежняя.

{
  "version": 8,
  "metadata": { "osmgl:version": 2 },
  "osmgl-light": { "key-azimuth": 120, "key-intensity": 0.6 },   // свой свет: ровный + два источника
  "sources": { "base": { "type": "vector" } },                   // `base` без адреса — тайлы даёт карта (`tiles`)
  "layers": [
    { "id": "background", "type": "background", "paint": { "background-color": "#dbd8d4" } },
    { "id": "bridge-casing", "type": "line", "source": "base", "source-layer": "transportation",
      "filter": ["==", "brunnel", "bridge"], "minzoom": 13,
      "layout": { "line-cap": "round", "line-join": "round", "osmgl-line-shape": "capsule", "osmgl-network": "roads" },
      "paint": { "line-color": "#8a7f6a", "osmgl-color-from": { "layer": "road-casing-major", "darken": 0.25 },
                 "line-width": { "stops": [[13, 3], [18, 14]] }, "osmgl-line-elevation": 0.5 } }
  ]
}

Правила:

  • без поля — умолчание MapLibre. Своё поведение движка включается флагами (их конвертер дописал во встроенные стили, и те рисуются пиксель в пиксель как прежде):

    | Флаг | Тип слоя | Что включает | |---|---|---| | layout.osmgl-line-shape: capsule | line | капсулу: скругление везде расстоянием в шейдере (не равна round/round: 3.7–6 % кадра разницы) | | layout.osmgl-lighting: osmgl | fill-extrusion | свой свет: два источника, тени, фасады (без поля — свет MapLibre) | | layout.osmgl-roofs: auto | fill-extrusion | скатные крыши (без поля — плоские) | | layout.osmgl-height-from-data: true | fill-extrusion | высоту и низ без fill-extrusion-height/base — из тайла | | layout.osmgl-symbol-layout: block | symbol | свою систему подписей: блочная раскладка и ЕЁ умолчания (13 px, ореол 1.2 белым, авторазрядка, точка вместо незнакомого значка) — поля переносятся одним переименованием | | layout.osmgl-line-anchors: osmgl / osmgl-text-halo-tracking: true | symbol | свои места вдоль линии / разрядку от ореола у подписи MapLibre |

  • свои свойства — в тех же paint/layout с приставкой osmgl-: покраска (юниформы) — в paint, всё, что в v1 лежало рядом с фильтром (геометрия, пересборка тайлов), — в layout. Свой тип слоя — osmgl-surface (osmgl-surface-*). Корень — osmgl-light, osmgl-level-height, osmgl-model3d-key; внутри sky и terrain — osmgl-* рядом с полями MapLibre;

  • ссылка на цвет («как у слоя X, темнее») — osmgl-color-from: { layer, darken?, property? } (у других цветов — <имя>-from: osmgl-text-halo-color-from); в самом цвете лежит уже разрешённый цвет, чтобы копия для MapLibre выглядела верно;

  • { "stops": … } — старая функция MapLibre: без base она у обоих линейная и остаётся как есть; шаблоны подписи списком (["{name:ru}", "{name}"] — «первый раскрывшийся целиком») — наше расширение значения text-field/icon-image; osmgl-icon-size — размер значка в пикселях (у блочной раскладки icon-size — множитель — не читается).

Таблица имён v1 ↔ v2 — ОДНА: поле v2 у каждой записи STYLE_REFERENCE (см. «Справочник»). По ней загрузчик (импортёр MapLibre, расширенный чтением osmgl-*) переводит v2 во внутреннюю модель движка — она осталась прежней (StyleSpec v1), и порты Kotlin/Swift читают v2 по той же таблице (V2Names.kt/.swift, scripts/emit-v2-names.mjs).

| Что | Как | |---|---| | v1 → v2 | styleV1toV2(style), themeV1toV2(theme, base) (тема — по базовому стилю: color значит fill-color, line-color или text-color смотря по слою); скрипт node scripts/style-v1-to-v2.mjs [--theme] in.json [out.json] | | v2 → MapLibre | toMapLibreStyle(v2, { tiles }, report): срезает osmgl-*, osmgl-surface → fill, ссылки на цвет и списки шаблонов — приближённо (что именно — в report) | | стиль v1 | грузится как раньше (version: 1), по ссылке — с одним предупреждением в консоли | | тема v1 | принимается: ключи разбираются по одному, имя v1 — синоним с предупреждением (два выпуска) | | скрипты стилей | make-*-style.mjs, палитры и импорт тем правят ВНУТРЕННЮЮ модель, а читают и пишут v2 через scripts/style-io.mjs |

Что «без потерь» — проверено (tests/style-v2.test.mjs, scripts/check-v2-migration.mjs против сборки до переезда, кадры — porting/unified-style-format.md, §7).

Встроенные стили (все вшиты в бандл, на Kotlin и Swift — строками из генераторов тем):

| Стиль | Как выбрать | Что это | |-------|-------------|---------| | osm-3d | по умолчанию / DEFAULT_STYLE | базовый стиль движка, основа тем | | osm-soft | SOFT_STYLE | скруглённые углы | | osm-relief | RELIEF_STYLE | рельефный: зелень приподнята, вода утоплена | | osm-city | CITY_STYLE | «объёмный город» — им работает navi.gram.tj | | osm-terrain | TERRAIN_STYLE | карта, натянутая на высоты | | mlight-city | setStyle('mlight-city') / MLIGHT_CITY_STYLE | светлый «город» — чистый MapLibre | | mnight-city | setStyle('mnight-city') / MNIGHT_CITY_STYLE | ночной «город» — чистый MapLibre |

Два последних — стили MapLibre v8 (Maputnik), опубликованные на navi.gram.tj: они идут через импортёр (style/maplibre-import.ts), а в пакете лежит СНИМОК их JSON (styles/maplibre/*.json, обновляется node scripts/fetch-maplibre-styles.mjs). По имени карта за стилем в сеть не ходит; тайлы (TileJSON), шрифты и спрайт по-прежнему с mtile.gram.tj. Любой другой стиль MapLibre подключается ссылкой, как раньше. Список для меню — OsmGL.Map.mapLibreStyleList; на Android MapStyle.MLIGHT_CITY/MNIGHT_CITY, на iOS setStyle(url: "mlight-city").

Наши стили тоже ставятся по имени и по ссылке: map.setStyle('osm-city') (имя — имя файла в styles/, список — OsmGL.Map.styleList) и map.setStyle('https://…/styles/night.json'). Формат ссылки решают поля самого JSON: version: 8 с признаком osmgl:version — наш стиль v2, без признака — стиль MapLibre (через импортёр, со своими источниками и камерой), version: 1 — прежний формат; обёртки нет, так публикует стили редактор в админке (/styles/{slug}.json, docs/map-v2/STYLE-EDITOR.md в монорепозитории). Наш стиль по ссылке становится ОСНОВОЙ для тем (как setBaseStyle) и приносит свои sources: изменившиеся пересоздаются, пропавшие снимаются; относительные адреса источников разрешаются по адресу стиля, как у MapLibre. Стиль v8/v2 приносит и то, что говорит о карте, а не о слоях: проекцию (projection.type — меркатор/глобус), умолчания общего состояния (state) и — при первой загрузке и если приложение не задало своё — камеру (center, zoom, bearing, pitch, roll, centerAltitude). То же на Kotlin (MapCore.setStyle(url) через MapOptions.styleFetcher, MapOptions.styleUrl; Android SDK — OsmglMap.setStyleUrl) и Swift (setStyle(url:)); разбор ответа — loadStyle / parseLoadedStyle.

Шрифты и спрайт — в самом стиле (glyphs, sprite), формой ровно как у MapLibre: "glyphs": "https://…/{fontstack}/{range}.pbf", "sprite": "https://…/sprite" (или [{ "id": "default", "url": … }, { "id": "x", "url": … }]). Правило одно для всех путей — стиль объектом, по ссылке, setBaseStyle, тема поверх и перевод MapLibre: есть поле — ставится оно, нет — у карты собственные (опция fonts, встроенные значки, setSprite/addSprite). Свои карты запоминаются при первом стиле со своими и возвращаются первым стилем без них; поля независимы (стиль только со спрайтом берёт шрифты карты). @2x выбирается по плотности экрана, значки дополнительного спрайта — id:имя, id: "default" — основной. Относительные адреса разрешаются по адресу стиля. Повтор того же стиля (смена темы, правка слоя) ничего не перекачивает. Наши встроенные стили этих полей не имеют — у них всё как было, включая встроенные значки и osmgl-icon-missing. Код — syncStyleAssets в src/index.ts; Kotlin — MapCore.syncStyleAssets (спрайт через MapOptions.sprites, шрифты через ожидающие шрифты до появления раскладчика), Swift — MapCore.syncStyleAssets (spriteLoader).

Гоча. Раз стиль вшит, правка styles/*.json доезжает до карты только после npm run build (или npm run dev в режиме слежения). Отредактировать JSON и перезагрузить страницу — недостаточно, и выглядит это как «настройка не работает». Крутить на лету можно setPaintProperty и setLayoutProperty. Генераторы стилей (make-*-style.mjs) сейчас дают НЕ тот файл, что лежит в styles/ (правки после генерации): их вывод не коммитить вслепую.

Ширина линии задана в пикселях на уровне центра карты, но раздувается лента в плоскости земли — как у Mapbox (extrude * u_pixels_to_tile_units, и только потом матрица). Сначала было наоборот: квад раздувался в экранных пикселях, а перспективу учитывал множитель ширины. Толщина от этого сокращалась правильно, а направление — нет: лента оставалась развёрнутой к экрану, и при наклоне 75° дорога поперёк взгляда читалась вертикальной стеной. Круглые стыки и концы при этом сохранились: расстояние до отрезка фрагмент считает там же, на земле.

ГОЧА, которую это принесло: «не тоньше пикселя» нужно ОГРАНИЧИВАТЬ. У горизонта на пиксель приходятся десятки единиц тайла, и полпикселя превращаются в десятки метров — дорога расплывается пятном во весь склон. Подробности — в porting/lines.md.

Фильтры — старые (== != < <= > >= in !in has !has all any !, $type, $id) и выражения MapLibre; сравнение в выражениях строгое, как у MapLibre (["==", 1, "1"] — ложь, порядок разных типов — ошибка и запасное значение), есть утверждения типа (string, number, boolean, object, array). Любое число или цвет можно задать остановками { "stops": [[zoom, value], ...] }.

Покрыты все слои, которые реально приезжают из наших тайлов: water, waterway, landcover (лес/трава/песок/лёд/болото), landuse (жильё/промка/соцобъекты/спорт/ кладбища/парковки), park, aeroway, transportation (обводка + полотно для магистралей, вторичных, второстепенных, ж/д и троп), boundary, building.

Порядок слоёв в стиле — порядок отрисовки. Земля рисуется методом художника с выключенным depth-тестом (всё лежит на z=0, тест дал бы только z-fighting), затем объёмные слои с честной глубиной.

Справочник стиля и проверка

Для редактора стилей (админка, этап 2) движок отдаёт МАШИНОЧИТАЕМЫЙ справочник своего формата — как v8.json у MapLibre. Записи справочника — поля внутренней модели (StyleSpec), и у каждой есть имя v2 (v2: 'paint.line-color', 'layout.osmgl-corner-radius'; у цвета-ссылки ещё v2From, у поля на два свойства — v2Also) и, если это свойство MapLibre, статус поддержки (support: ✅/🟡/❌ с примечанием). Рядом — STYLE_REFERENCE_V2_FLAGS (флаги v2 без поля v1) и MAPLIBRE_SUPPORT (все 336 позиций спецификации MapLibre со статусом; данные — из §2 porting/unified-style-format.md, node scripts/gen-maplibre-support.mjs). В файле dist/style-reference.json — те же v2Flags и mapLibreSupport.

import { STYLE_REFERENCE, STYLE_REFERENCE_EXPRESSIONS, validateStyle } from 'osmgl'
// или файлом: import ref from 'osmgl/style-reference.json'   (dist/style-reference.json)

STYLE_REFERENCE.layers.line.paint.width
// { type: 'number', doc: 'Ширина в экранных пикселях…', default: 1, minimum: 0, units: 'px',
//   zoom: true, data: true, geometryIfData: true, group: 'width', mapLibre: 'line-width' }

Разделы: root (поля StyleSpec), source (SourceSpec), layerCommon (LayerBase + type), layers[тип].fields/paint/layout, groups (разделы интерфейса). У каждого поля: тип, описание по-русски, умолчание КАК ЕГО ПРИМЕНЯЕТ ДВИЖОК (взято из renderer.ts, symbols.ts, worker/process.ts, а не из комментариев), пределы, единицы, значения перечислений и три признака:

  • zoom — можно задать остановками или выражением по зуму;
  • data — можно задать по фиче, И движок это значение на фиче считает: цвет/прозрачность у fill, line, fill-extrusion, ширина линии, высота и низ дома, шаблоны подписи и значка, поворот значка (плюс фильтр), а у подписей раскладки MapLibre (этап b) — ещё кегль, размер значка, разрядка, ширина переноса, якоря и смещения, поворот подписи, выравнивание, регистр, ключ сортировки, цвета, ореолы и прозрачности текста и значка (style/symbol-data.ts). Остальное считается раз на слой;
  • geometry — правка пересобирает тайлы (поле едет в план воркера); geometryIfData — только когда значение задано по фиче.

STYLE_REFERENCE_EXPRESSIONS — 76 операций выражений, которые понимает интерпретатор (аргументы, результат, раздел). appliesTo у общего поля говорит, каким типам оно нужно (cornerRadius — заливке, объёму и поверхности).

Справочник не может разойтись со спекой: tests/style-reference.test.mjs разбирает spec.ts компилятором TypeScript и сверяет поля в обе стороны (корень, источник, общее, свои поля, paint и layout каждого типа, свет, небо, рельеф, ColorFrom), а операции — со списком case в compile. Новое поле в spec.ts без строки в reference.ts роняет npm test.

validateStyle(spec) → { errors, warnings }, у каждой находки путь (layers[3].paint.color), слой и текст по-русски. Стиль version: 8 проверяется ПО ИМЕНАМ v2 (style/validate-v2.ts, те же записи справочника): незнакомое osmgl-* — предупреждение, свойство MapLibre со статусом ❌ — предупреждение «osmgl пока не поддерживает», свойство не в своём разделе (paint/layout) — ошибка, тип слоя heatmap/hillshade/color-relief — предупреждение. Встроенные стили и mlight-city/mnight-city проходят без ошибок. Стиль version: 1 — как прежде:

| Ошибка — карта нарисует не то | Предупреждение — движок тихо обойдётся | |---|---| | нет обязательного поля, незнакомый тип слоя, повтор id | незнакомое поле (опечатка или поле MapLibre) | | тип/перечисление/предел, цвет не разобрался (на карте — фуксин) | значение по фиче там, где движок считает раз на слой | | незнакомая операция выражения или фильтра, остановки назад | общее поле у слоя, которому оно ни к чему (appliesTo) | | шкала/выражение у постоянного поля, null у поля по зуму | from на слой без такого свойства | | источник не объявлен или не того вида | источник рельефа не объявлен в стиле | | yieldTo мимо поверхности, { from } на несуществующий слой или на ссылку | |

validateTheme(theme, style) проверяет тему: правки мимо слоёв и мимо полей их типа. Встроенные стили, их темы и переведённые стили MapLibre проходят без ошибок (это тест).

Значение не того типа не гасит карту. Строка в прозрачности ("0.5", "abc" из поля ввода редактора) раньше падала в drawFill на каждом кадре, и пустой становилась вся карта. Теперь три линии обороны:

  1. Разбор стиля (style/sanitize.ts, зовётся из Style и из setPaintProperty / setLayoutProperty): значение, которое читатель не сможет прочесть (строка вместо числа, число вместо цвета, объект без остановок, кривые остановки), выбрасывается из КОПИИ слоя — и движок берёт своё умолчание, как если бы поля не было. Один console.warn на слой + поле + значение: слой «water»: paint.opacity = "0.5" — ожидается число или остановки по зуму; берётся значение по умолчанию (1). Спецификация приложения (и JSON редактора) не меняется. Типы — из STYLE_REFERENCE; проверка нарочно мягче validateStyle: пределы, перечисления и неразобранный цвет остаются валидатору (движок с ними не падает). null — «не задано», выбрасывается молча.
  2. Чтение (evaluateNumber/evaluateColor): то, что дошло мимо справочника (свет, небо, правки), даёт запасное значение и одно предупреждение, а не исключение.
  3. Кадр: тело каждого прохода по слоям обёрнуто в try — упавший слой пропускается в этом кадре, остальная карта рисуется; сказано один раз (console.error + событие error). Подписи — одним куском (раскладка общая), а render целиком — последней линией в цикле кадров.

Цена: покадровый путь не изменился (число → вернуть как есть; try в V8 бесплатен, пока не бросают). Замер на examples/maplibre-style.html (mlight-city, z15.5, наклон 45°, непрерывный поворот, 350 кадров): медиана нашей работы на кадр 6,6–6,9 мс против 7,2–7,6 мс у сборки до правки — разница в шуме. Разбор стиля дорожает на ≈0,04 мс (new Style для osm-city + mlight-city). Встроенные стили, все темы и переведённые MapLibre не теряют ни одного значения (tests/style-robust.test.mjs). На Kotlin — то же чтение и try по слоям (Renderer.reportFailure, событие MapEvent.ERROR); на Swift — то же чтение (исключений у его отрисовки нет).

Скорость — на каждое нажатие клавиши: стиль в 300 слоёв проверяется за 1,9 мс (Node), 1,5 мс в браузере; 60 слоёв — 0,4 мс.

Правка на лету для редактора. Полный setStyle(spec, { keepEdits: false }) дешёвый: стиль разбирается заново, а тайлы пересобираются ТОЛЬКО если изменился план воркера. Замер на живой карте (Душанбе, z16, SwiftShader, 1200×800):

| | 60 слоёв | 300 слоёв | |---|---|---| | setStyle — правка покраски (план тот же) | 0,1 мс | 0,5 мс | | setStyle — правка фильтра одного слоя, синхронно | 0,2 мс | 0,7 мс | | …до готовых тайлов (локальный тайлсервер, кеш браузера) | ≈32 мс | ≈33 мс | | setPaintProperty | 1 мкс | 1 мкс | | validateStyle | 0,4 мс | 1,5 мс |

Поэтому редактору хватает одного пути — setStyle на каждую правку; setPaintProperty / setLayoutProperty / setFilter / setLayerZoomRange / moveLayer / addLayer / removeLayer есть с именами MapLibre для приложений. Покраска и раскладка пересобирают тайлы только своего источника и только если правка дошла до плана воркера (сравнение Style.layerPlan). keepEdits: false нужен потому, что правки на лету по умолчанию накладываются заново после каждой смены стиля (ради тем), и в редакторе старое setPaintProperty перебивало бы новую спецификацию.

map.layersAt(point) — «инспектор»: какие слои нарисовали то, что под точкой, сверху вниз — подписи и значки (как их разместил этот кадр) и фичи заливок, линий, объёма и поверхностей с их свойствами. Погашенные зумом и выключенные слои в ответ не попадают. Первый вызов на тайле ≈15 мс (разбор свойств тайла), дальше ≈5 мс.

Линия, уступающая подробному слою (yieldTo)

Пара «линия дороги — асфальт» живёт в разных наборах тайлов: линия приходит с основой, асфальт (полотно по графу) — своим набором с z15. Гасить линию maxzoom по зуму камеры нельзя, и это измерено, а не придумано:

  • пока тайл асфальта едет, улицы нет вовсе — ни линии, ни полотна (замер с задержкой ответов полотна на 4 с: с maxzoom проезжая часть пуста, с уступкой видна вся сеть);
  • у наклонённой карты дальние тайлы рисуются с меньшей подробностью (Transform.detailZoom), и асфальт с minzoom: 15 гаснет вдали сам. На z15.6 при наклоне 75° дорог не было в дальней трети кадра — подписи улиц висели над пустой землёй.

Поэтому линия объявляет "yieldTo": "<id поверхности>" и рисуется везде, КРОМЕ квадратов тайлов, в которых эта поверхность в кадре уже легла. Квадраты ставятся битом трафарета (PAINTED_BIT) перед проходом поднятых линий и снимаются после него. Это «грубое сразу, подробное кусками» у Baidu (useThumbData).

Уступка идёт ПО КВАДРАТУ ТАЙЛА, а не по пикселю асфальта: линия дороги шире полотна (ширина линии — приём картографии в пикселях), и маска по самому асфальту оставила бы вдоль кромки полоску старой линии. Квадрат, чья геометрия отложена бюджетом загрузки, не ставится — иначе в нём на кадр не было бы ни того, ни другого. Там, где асфальт уже загружен, кадр совпадает с прежним до пикселя.

ГОЧА: на слабом профиле поверхность рисуется ЗАЛИВКОЙ (flattened), поэтому квадраты пишет и drawFill. Без этого на телефоне линии лежали поверх асфальта, а на компьютере — нет.

Линия под настилом (depthTest)

Поднятые линии рисуются без теста глубины: краска лежит на своём асфальте в сантиметрах, и такой зазор буфер глубины не различает. Поэтому краска улицы под эстакадой проступала сквозь настил.

Слой с "depthTest": true проходит тест глубины (без записи), а его глубина сдвинута к камере на долю расстояния (DEPTH_NUDGE = 0.4 %). Своё полотно в сантиметрах ниже её не закрывает, настил в 5–8 м над ней — закрывает. Сдвиг — одна постоянная в z клипа на кадр: B·k/(1−k), где B — элемент [14] перспективы (Transform.projZOffset); точка на экране не двигается. Действует только в проходе поднятых линий, не на шаре и не в текстуре рельефа. Дальше ~2 км от камеры сдвиг больше просвета, и краска под настилом снова видна.

Разноцветная линия

Маршрут, раскрашенный по пробкам, — как у Яндекса:

new OsmGL.Polyline({
  coordinates,
  color: '#2563eb',           // цвет по умолчанию, если кусков нет
  segments: [                 // кусок идёт с `at` и до `at` следующего
    { at: 0, color: '#39b54a' },
    { at: 128, color: '#c01818' },
  ],
})

Своего шейдера это не потребовало: объект и так возвращает СПИСОК кусков отрисовки (ObjectPart[]), и цветной участок — просто ещё один кусок со своей лентой. Два правила, которые легко нарушить:

  • соседние куски ДЕЛЯТ вершину (slice(from, to + 1)) — иначе на стыке остаётся разрыв шириной в отрезок;
  • обводка рисуется ОДНА на всю линию и первой — обводка на каждый кусок положила бы белые перемычки поверх цвета соседа.

Куски приходят из ответа сервиса, поэтому мусор гасится на входе (индексы вне линии, дробные, неупорядоченные), а первый кусок всегда начинается с начала линии: неокрашенный хвост выглядел бы обрывом, а не «неизвестным участком».

Подписи вдоль линии

layout.textPlacement: 'line' — название улицы изгибается по дороге:

{ "id": "road-label", "type": "symbol", "source-layer": "transportation_name",
  "layout": { "textField": ["{name:ru}", "{name}"], "textPlacement": "line" } }

Раскладка (text/line-placement.ts) — ЧИСТАЯ функция над экранной ломаной: на входе точки и метрики глифов, на выходе позиция и угол каждой буквы. Считается в экранных координатах и на каждый кадр: форма дороги на экране зависит от наклона камеры, и изгиб, посчитанный в тайле, при наклоне разъезжается с самой дорогой. Воркер поэтому кладёт в бакет геометрию линии (lines + lineStarts) — самую длинную цепочку объекта.

Отличия от Mapbox (symbol_projection.js), где часть работы уходит на GPU:

  • подпись ОДНА на объект, а не по одной на интервал. Поэтому при неудаче она сдвигается вдоль линии и пробует снова: улица с резким поворотом посередине иначе теряла бы название целиком;
  • направление чтения решается по ТОМУ КУСКУ, который занимает строка, а не по всей линии. У извилистой улицы хорда может идти вправо, а выбранный кусок влево — и название вставало вверх ногами;
  • место в сетке коллизий занимает цепочка боксов по буквам: общий бокс изогнутой строки — почти весь экран, и он вытеснял бы всё вокруг.

Излом больше 45° на глиф — отказ (порог как у Mapbox MAX_GLYPH_ANGLE): на дуге буквы наезжают друг на друга внутренней стороной, и подпись читается хуже, чем если её не ставить. Цена раскладки — +0,1 мс на кадр (медиана 1,0 → 1,1 мс на z16 при непрерывном вращении, SwiftShader).

Растровая подложка

Карта картинками под всей векторной графикой — вторая основа карты («Схема картинкой» на публичном сайте). Спутник это не даёт (снимков у нас нет), зато даёт любую чужую подложку и снимает работу с клиента: рисует сервер, а мы только натягиваем текстуры.

// источник
{ "basemap": { "type": "raster", "url": "/tileserver/styles/gram-raster/{z}/{x}/{y}@2x.png",
               "minzoom": 0, "maxzoom": 19 } }
// слой
{ "id": "basemap-raster", "type": "raster", "source": "basemap",
  "visible": false, "paint": { "opacity": 1 } }

Источник отдельный (source/raster-source.ts), а не ветка в TileSource. У них разная работа: векторный тайл надо разобрать и стесселировать — ради этого и живёт пул воркеров с очередью; картинку разбирает сам браузер и вне главного потока (createImageBitmap), и ставить её в ту же очередь значит задерживать геометрию ради того, что и так декодируется параллельно. Общее — отбор видимых тайлов, LRU и отмена запросов уехавших из вида тайлов — переиспользуется как есть.

Слой не хранит ничего. Один единичный квад на все тайлы (тот же, что у прохода тени по земле): матрица тайла с extent = 1 растягивает его ровно на место. Текстура живёт В ТАЙЛЕ и освобождается вместе с ним — вытеснение из кеша видит источник, а не слой. Битмап после переноса в GPU закрывается: он держит несжатую картинку вне кучи JS, и на подложке это сотни мегабайт.

Пока свой тайл грузится, рисуется кусок предка (patchUV) — иначе каждый шаг зума открывает дыры до конца загрузки. Потомков не собираем: их до четырёх на тайл, целиком в кеше они бывают редко, и мозаика из части квадратов заметнее размытого предка. Так же поступают Mapbox и MapLibre.

Y не переворачивается. У тайла начало на севере, и первая строка картинки, уходящая в texImage2D, — тоже северная. Мипы обязательны (без них отдалённый тайл кипит муаром), плюс анизотропия, если устройство её умеет: при наклоне тайл виден под острым углом, и обычные мипы мылят его вдоль взгляда.

Выключенный слой не стоит ни байта. Map обновляет только те растровые источники, которые рисует хоть один включённый слой: слой подложки объявляют заранее и включают тумблером (пересборка стиля мигает всей картой), а картинки — самый тяжёлый трафик на карте.

Подписи и иконки

Текст берётся из SDF-глифов формата Mapbox (/tileserver/fonts/{fontstack}/{range}.pbf) — их отдаёт наш tileserver-gl, поэтому подписи выглядят так же, как в остальных наших картах.

Иконка красится тем же цветом и той же обводкой, что и текст. Это не совпадение, а устройство: SVG растрируется в альфа-маску, из неё строится SDF в той же кодировке, что у глифов (край фигуры на 192/255, диапазон растянут на −6..+2 пикселя), и кладётся в ОБЩИЙ с буквами атлас. Дальше одна программа рисует и то и другое — шейдер не различает, буква перед ним или пиктограмма.

Плата за это — иконки силуэтные, одноцветные.

Иконка стоит НАД подписью и по её центру (layout.iconPlacement, по умолчанию top) — как в Mapbox Standard и у Яндекса: значок оказывается ровно над точкой объекта. При боковой раскладке длинное название уводит его в сторону, и на плотной карте перестаёт быть понятно, к чему он относится. Зазор задаётся на слой (layout.iconTextGap) и берётся как есть, без скрытых множителей.

Зазор отсчитывается от настоящего верха букв, а не от строчного бокса. Бокс строки выше того, что видно: в нём живут выносные элементы и подстрочье, и у названия без «б», «р», «у» сверху остаётся пустая полоса примерно в четверть кегля. Пока подпись ставилась по боксу, «зазор 2» читался на экране как семь, и править его в стиле было бесполезно — уменьшалось не то. Поэтому подпись рисуется первой, её рамка запоминается, и блок сдвигается по факту.

Круглая подложка под значком (layout.iconFrame: "circle", диаметр — iconFrameScale) — как у Яндекса и в Mapbox Standard: тонкая пиктограмма поверх домов и зелени сама по себе теряется. Это ОТДЕЛЬНЫЙ значок под иконкой со своим потоком вершин и своей покраской, а не обводка: обводка у нас общая с текстом, и жирный контур вокруг силуэта выглядит грязью, а кружок — фигура с заливкой и рамкой. Цвета по умолчанию выводятся из темы и отдельной настройки не требуют: заливка — цвет обводки подписи (в каждой палитре это и есть «фон под текстом»: белый днём, почти чёрный ночью), рамка — цвет значка, приглушённый до трети. Переопределяются paint.frameColor, frameHaloColor, frameHaloWidth.

Заливка кружка ПОЛУПРОЗРАЧНАЯ (paint.frameOpacity): сплошная закрывает карту и на плотной застройке читается дырой. Плотность своя на каждой теме и выводится из светлоты фона — 0,3 на бумажных палитрах, 0,6 на графитовых: тёмному фону нужна БОЛЬШАЯ плотность, иначе кружок не отделяет пиктограмму от домов, а на светлом сильный кружок сам становится объектом. Рамка при этом остаётся непрозрачной — растворять её вместе с фоном значит потерять край фигуры. Место под значок при этом занимает кружок, а не пиктограмма, иначе подпись налезала бы на него.

Набор иконок сверяется с данными, а не с воображением:

node scripts/audit-icons.mjs        # какие классы приезжают и чего не хватает

Имя иконки обязано совпадать с классом схемы (шаблон {class}), и ошибка тут тихая: имя не совпало — объект молча получает нейтральную точку. Именно так полторы тысячи гостиниц ходили с точкой, пока иконка называлась hotel, а класс в данных — lodging. Первый прогон по прод-набору показал 78 классов без значка, это 45% всех POI; с набором Maki/Temaki покрыты все 94 класса, а 69 подклассов из 164 получают свой знак (13 746 объектов из 17 861 с подклассом). Близкие по смыслу имена делят значок через ICON_ALIASES в манифесте (pub → beer, clinic → doctors).

Номера трасс — слой road-shield: светлая плашка с рамкой и тёмным номером, как в Mapbox. Плашка это ЗНАЧОК ПОД подписью (iconPlacement: "behind"), и у него своя покраска (iconColor, iconHaloColor) — иначе он красился бы цветом номера, и прочитать номер было бы нельзя. Ширина плашки выбирается по длине номера (iconImage: "shield-{ref_length}"): настоящего 9-patch у нас нет, поэтому в наборе лежат готовые ширины под 2…8 знаков — этого хватает на всё, что встречается в данных. Чтобы номер не повторялся на каждом отрезке линии, у слоя свой шаг разрежения layout.collisionPadding.

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

Цвет по типу объекта. Раз подпись и значок красятся цветом СЛОЯ, то «еда оранжевым, транспорт синим» — это слой на категорию: poi-food, poi-shop, poi-transport, poi-health, poi-education, poi-culture, poi-sport, poi-lodging, poi-service и общий poi для остального. Классы разобраны по тому, что реально приезжает в наших тайлах (замер по Душанбе: shop 460, office 220, cafe 151, restaurant 128…), остаток ловится обратным фильтром — иначе объект нарисовался бы дважды. Коллизии при этом общие: кандидаты со всех слоёв сортируются по важности в одной сетке.

Цвета категорий подобраны под светлую карту, поэтому темам они раздаются скриптом:

node scripts/apply-poi-palette.mjs

Тёмная тема получает те же тона, поднятые по светлоте, и свою обводку (белая обводка дневной темы превращает подпись в светящуюся марку). Монохромная тема остаётся монохромной: цветные категории поверх графита — это уже другая тема. Разноцветную пиктограмму так не сделать; для неё понадобится отдельный растровый слой.

{
  "id": "poi",
  "type": "symbol",
  "source-layer": "poi",
  "minzoom": 15,
  "layout": {
    "textField": ["{name:ru}", "{name}"],   // список — это фолбэки
    "iconImage": "{class}",                  // имя из набора иконок
    "textSize": { "stops": [[15, 10.5], [18, 12.5]] },
    "iconSize": { "stops": [[15, 13], [18, 16]] }
  },
  "paint": { "color": "#4a4a4a", "haloColor": "#ffffff", "haloWidth": 1.3 }
}

Свои иконки — через icons в опциях карты (дополняют встроенный набор силуэтов):

new OsmGL.Map({ icons: { taxi: '<svg viewBox="0 0 24 24">…</svg>' } })

Встроенный набор значков: Maki + Temaki (04.10.2026)

Пиктограммы POI — Maki 8.2.0 и Temaki 5.13.0 (CC0, исходники и лицензии в vendor/icons/), то есть те же знаки, что видны в стилях MapLibre (спрайт OSM Liberty — это Maki в цветных кружках). Свои остались только служебные: точка, вход, дверь, лифт, подложки, светофор, перекрытие, щиты. Разбор и замеры — porting/icons-from-maplibre.md.

  • Один источник правды — icons/manifest.json (имя → maki:<имя>/temaki:<имя>, свои SVG, псевдонимы, оптические поправки). npm run icons = scripts/import-icons.mjs (нормализация → src/text/icons-data.ts и src/icons-full.ts) + генераторы Kotlin (scripts/gen-icons.mjs, обе копии Icons.kt) и Swift (ios/tools/icons-to-swift.mjs). Руками сгенерированное не правится.

  • Нормализация на сборке, одна на три платформы: сетка 24×24, ВЕКТОРНЫЙ ink-bbox (с экстремумами кривых, по всем путям) встаёт центром в (12, 12), большая сторона = 13/15 сетки — ровно 13 px при значке 15 px: края ложатся на целые пиксели при 15/30/45 px (родная пропорция Maki). Дуги переводятся в кубики до округления (у дуги с хордой почти в диаметр центр «гуляет» от сотой доли радиуса). Сущности (&#xA; в путях Maki), id, width/height, пролог — вычищаются.

  • Пиктограмма в центре круга — тремя проверками: геометрия (tests/icons-centring.test.mjs), разборщик Kotlin (IconCentringTest) и Swift (IconRasterTests), и кадр движка (npm run check:icons, лист examples/icons.html?probe=1: 132 значка × круг/булавка × 15/20/30 px × dpr 1/2/3). Порог — 0.5 px при 30 px по каждой оси. Было: 50 значков из 102 дальше порога, худший restaurant 3.55 px; стало: худший 0.43 px (beauty при 15 px), обычно меньше 0.1.

  • Имена: класс и подкласс OpenMapTiles. Наши стили берут ["{subclass}", "{class}"], и для значка список запасных решается иначе, чем для подписи: побеждает первый ИЗВЕСТНЫЙ значок (воркер отдаёт всех кандидатов, SymbolRenderer.pickIcon выбирает) — так kiosk получает значок магазина, а не точку. Плюс имена по исходнику: maki:car-repair, temaki:atm.

  • Полный набор (772 значка, 566 КБ, 168 КБ gzip) — отдельная точка входа, в ядро не входит:

    import { FULL_ICONS } from 'osmgl/icons-full'   // IIFE: dist/icons-full.global.js → OsmGLIcons
    map.addIcons(FULL_ICONS)                         // занятые имена не перезаписываются
  • Прилипание к пикселю: квад значка (с подложкой и тенью — одним сдвигом) встаёт на целый пиксель буфера (SymbolRenderer.pixelSnap, на всех трёх платформах; повёрнутые значки и плашки щитов — нет). Лист ?jitter=1&snap=0|1: крутизна края +9.5 % при 15 px (dpr 1) и +8 % (dpr 2), при 20 px без изменений, и картинка значка больше не зависит от дробной позиции.

  • iOS: встроенные значки раньше не рисовались вовсе — растеризатора SVG в порте не было. Теперь SvgDraw.swift (перенос разборщика Kotlin) + CoreGraphics на Apple и своё покрытие (SvgRaster.swift) вне Apple; растр сверяется с альфой браузера (kmp/fixtures/icon-alpha.json, scripts/export-icon-alpha.mjs).

  • Оптические поправки — механизм есть (optical в манифесте, предложения — --report), но после просмотра листа не утверждено ни одной: сдвиг в 0.3 единицы почти не виден, а съедает три четверти порога центрирования.

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

Настоящего шейпинга нет: перо двигается по advance. Кириллице, латинице и таджикскому этого достаточно, арабский и иврит потребуют отдельного прохода.

Пороги подробности по зуму

Чтобы телефон не захлёбывался на общем плане города, подробность включается ступенями (30.09.2026, одинаково в вебе, на Android и в iOS):

| Зум | Дома | Дороги | Детальные 3D-объекты | |-----|------|--------|----------------------| | до 15 | нет | линии | нет | | 15–16 | плоские (building-flat, цвет крыши) | линии | нет | | с 16 | объём, растёт до полной высоты к 16.8 | асфальт (полотно), разметка с 16.5 | нет | | с 17 | объём | асфальт | есть |

На z15 кадр захватывает вчетверо больше города, чем на z16, а стены с окнами, тени и асфальт на этом зуме не разглядеть. Плоский дом берёт цвет крыши объёмного слоя ссылкой { from: 'building', property: 'roofColor' } (поле property у ColorFrom), поэтому его красят те же темы и на пороге z16 нет шва. Тайлы полотна на z15 больше не запрашиваются (minzoom источника 16). Ниже порога моделей маска замены снимается, и на месте модели стоит обычный дом.

Тесселяцию зданий это не экономит: тайл z14 — потолок источника, и воркер собирает в нём здания на всех зумах выше. Экономится отрисовка: стены, окна, скругления и проход теней.

Иерархия подписей мест

Во всех пяти стилях места делятся по КЛАССУ, а не по рангу. Ранг в тайлах navigram — порядковый номер внутри клетки сетки, о размере места он не говорит ничего: Худжанд с рангом 12 до z10.5 не показывался вовсе. Приоритет слоя сильнее любого ранга фичи, поэтому порядок задаётся слоями:

| Слой | Что | С зума | priority | |------|-----|--------|-----------:| | place-capital | столица и центры областей: Душанбе, Худжанд, Бохтар, Хорог | 4 | 110 | | place-region | области (свой источник regions в стиле) | 4.5 | 100 | | place-label | остальные города и страна | 4 | 90 | | place-label-minor | посёлки (town) | 7.5 | 70 | | place-label-local | сёла, кварталы, острова | 10.5 | 55 |

Верхнего зума у областей нет. Центры стоят ВЫШЕ областей: крупное название области иначе накрывает на мелком зуме свой же областной центр. Признака столицы (capital) в тайлах navigram нет, поэтому центры выбираются по имени, в обоих написаниях (Хуҷанд/Худжанд, Хоруғ/Хорог). Обычные города исключают их тем же списком (!in), иначе одна подпись встала бы дважды. Точек областей в данных navigram нет совсем, поэтому они едут в стиле встроенным geojson-источником. Проверяется tests/places.test.mjs.

Планы этажей

Помещения ТЦ, вокзалов и рынков. Схема тайлов — indoorequal (слои area / area_name / transportation / poi, у каждой фичи числовой level), её же собирает наш rebuild-indoor-tiles.sh. План и обоснование схемы — в INDOOR.md.

const map = new OsmGL.Map({
  tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
  sources: { indoor: { url: '/tileserver/data/indoor/{z}/{x}/{y}.pbf', minzoom: 15, maxzoom: 18 } },
})

map.on('indoor', ({ levels, level }) => renderFloorSwitcher(levels, level))
map.setIndoorLevel(2)

Отдельный источник, а не слой основных тайлов. У основной карты потолок z14 — планов этажей на нём не видно вовсе, — и пересобирается она сорок минут на всю страну, тогда как indoor.mbtiles собирается за секунды. Слой стиля выбирает источник полем source; их может быть сколько угодно.

Переключение этажа ничего не пересобирает. Фильтры у нас применяются в воркере, на этапе тесселяции, поэтому «дописать level == 3 в фильтр» означало бы перемолоть все тайлы на каждое нажатие. Вместо этого слой объявляет splitBy: "level", и воркер строит по мешу на КАЖДЫЙ встреченный этаж; переключение — это выбор другого готового буфера. Оттуда же берётся и список этажей для переключателя: клиенту неоткуда узнать, какие этажи есть в здании, кроме как из самих тайлов.

Накладка поверх объёма (overlay: "volume", прежнее написание true). План лежит внутри здания, и в обычном порядке его закрыла бы собственная крыша. Покрывает он ровно контур своего здания, поэтому соседние дома остаются видимыми.

У накладки есть и вторая позиция — overlay: "roads", сразу после дорог и ДО объёма. Там живёт разметка на асфальте: зебры и перекрытия. В общую очередь она не встаёт (дороги рисуются позже поверхностей), но и поверх объёма ей делать нечего — зебра, висящая на стене дома, первой бросается в глаза.

Список этажей считается по ВИДИМЫМ тайлам: переключатель должен показывать этажи того здания, на которое смотрят, а не всех, куда пользователь заезжал за сеанс. При выходе из здания выбор снимается — иначе в следующем ТЦ остался бы этаж, которого там нет. Автовыбор (map.autoIndoorLevel) ставит первый этаж, а не самый нижний: подвал по умолчанию не показывают.

Чего пока нет: POI основной карты не гасятся внутри здания, поэтому наружные подписи магазинов дублируют внутренние. Нужна маска по контуру venue.

Палитра плана выводится для КАЖДОЙ темы (scripts/apply-indoor-palette.mjs). Раньше цвета считались только для тёмных палитр, а светлые оставались с базовыми бежевыми плашками — на «морской» или «земляной» теме раскрытый этаж выглядел вставкой из другой карты. Правила те же, по которым рисуют планы Mapbox Indoor и наш web-SDK:

  • пол отсчитывается от цвета ЗДАНИЙ темы, а не от фона карты: план лежит поверх дома, и сливаться ему нельзя именно с домом;
  • коридор — самый «пустой» тон, по нему глаз читает связность плана;
  • помещения нейтральны, а смысловые классы (магазин, еда, санузел, лифт) сохраняют свой ТОН и уходят от коридора по светлоте: одного тона мало, на светлой теме плашка почти белая и предельная цветность там мизерная;
  • стены — самый контрастный элемент, и светлота у них ПЕРЕВЁРНУТА под тему: на светлой карте темнее помещений, на тёмной светлее. Без переворота на графите стена сливается с полом.

Отношения проверяются тестом на всех темах: пол отличается от здания, стена отделяется от помещения (контраст ≥ 1,25 и верное направление), коридор отличим, смысловые классы различимы между собой и от обычной комнаты.

Клик по помещению. Пока план открыт, щелчок выбирает КОМНАТУ, а не дом: дом под планом скрыт, и обводить невидимое незачем. Помещение — плоская заливка, поэтому и попадание считается иначе, чем у дома: не объёмным лучом, а пересечением луча с плоскостью земли и проверкой «внутри треугольника» (pickFill). Для этого заливки теперь несут id фичи на треугольник — те же четыре байта, что и у зданий, иначе подсветить и вернуть наружу нечего. Подсветка комнаты рисуется программой заливок: у плоского меша другой формат вершины, «нарисовать зданием» его нельзя.

Подсвечивается ТОЛЬКО ТО, ПО ЧЕМУ КЛИКНУЛИ, — пол выбранной комнаты. Приписывать комнате её стены мы пробовали и отказались: в данных стена это отдельный полигон между двумя помещениями, тянется вдоль целого ряда комнат и «своей» не помечена ни для одной. Любой геометрический подбор (по прилеганию вершин, по близости треугольников) в углах и вдоль общих стен зажигал сразу несколько помещений. К тому же выводу пришёл наш web-SDK: стены комнат попадают в общий движок зданий наравне с домами, и выделение там одно на всю карту — «своей» подсветки у помещений нет.

Заливка подсветки идёт ЧЕРЕЗ ТРАФАРЕТ. Полупрозрачный полигон в перекрытии тайлов ложится дважды, и по стыку идёт полоса иной плотности — тот самый шов на выделенной комнате. Трафарет пропускает каждый пиксель ровно один раз, и склеивать половинки полигона из соседних тайлов не нужно. ГОЧА: опорное значение обязано быть НЕнулевым — REPLACE пишет в трафарет именно его, и с нулём тест «равно нулю» продолжает проходить, то есть трафарет не делает ровно ничего.

Сами слои плана вдобавок РЕЖУТСЯ по квадрату тайла (clipToTile): они полупрозрачны, и в буферной полосе тоже ложились дважды. Непрозрачной заливке буфер, наоборот, полезен — он закрывает волосяные щели на стыке, поэтому обрезка включается слоем, а не всем подряд.

Стрелки входов при этом остаются: если дом не выделен, но его план открыт, контур для showFor: "highlight" берётся у самого плана — стрелки показывают, как в это здание попасть, и выбор комнаты этого не отменяет.

Объект без здания в тайлах (indoor-venue). У части объектов помещения размечены, а контур дома не нарисован — ни в тайлах, ни в схеме редактирования: план стоял в воздухе. Решение то же, что в нашем web-SDK (Buildings3D.extra): плита этажа (class = level, base = 1) подаётся как ОБЫЧНОЕ здание с высотой из данных и той же покраской — «свой» стиль рядом с честными домами выглядит чужеродно. Живёт объём с z15 до z16 и снимается ровно там, где появляется план: дальше внутрь смотрят помещения, а не коробка.

★ Эта коробка — ВТОРАЯ отрисовка того же дома, и её тоже надо прятать под детальной 3D-моделью. Дом, который модель заменяет собой, выбрасывается из объёма основных тайлов (renderer.hiddenBuildings), но плита этажа приезжает из ДРУГОГО источника и под ту резку не попадала: рядом с детальной башней продолжала стоять её же серая коробка. Набор поэтому второй (renderer.hiddenVenues), и наполняется он геометрически — по точке размещения, потому что нумерация у двух источников своя (та же причина, по которой геометрическая и маска планов этажей).

Открытый план гасит модель ПЛАВНО. Пока план раскрыт, стоящая на нём модель не рисуется — она закрывает его ровно так же, как это делал бы сам дом. Уход именно затуханием (Model.setHidden, отдельно от «вырастания» grow): рост сопровождает ПРИЕЗД объекта на карту, а здесь объект никуда не девался — камера просто зашла внутрь, и вырастающая обратно из земли башня при выходе читалась бы как новое событие. Полупрозрачная модель целиком уходит в прозрачный подпроход (запиши она глубину — в том, что за ней, осталась бы дыра её формы), а тень перестаёт отбрасываться на середине ухода: карта теней полутонов не знает, и под выцветшим объектом сплошная тень выглядит чужой.

Дом с раскрытым этажом НЕ РИСУЕТСЯ (layers/indoor-mask.ts). План этажа — это взгляд внутрь здания, и стоящая вокруг коробка мешает: в наклоне она закрывает половину плана, а сверху спорит с ним крышей. Так же поступают Mapbox Indoor и наш web-SDK. Связать план со зданием по id нельзя — этажи нумерует редактор (с 5e12), а тайлы собирает планетайлер по своему источнику, id из разных пространств. Поэтому маска ГЕОМЕТРИЧЕСКАЯ: под неё идёт ВСЯ плоская геометрия открытого этажа (полигон class = level есть не в каждом тайле, и по нему одному маска молча оказывалась пустой), а здания, чей центр в неё попал, выбрасываются из индексов.

Три грабли, каждая из которых уже проявлялась на карте:

  • привязка индексного буфера — состояние VAO, а не глобальное. Подменив индексы в чужом VAO, мы меняем его навсегда: тайл рисуется урезанным и в кадрах без маски, а при освобождении буфера мигает. У маски поэтому СВОЙ VAO;
  • решение принимается на id фичи и по всем тайлам сразу. Дом на границе приезжает кусками, у каждого куска свой центр — решая по куску, мы прятали половину дома;
  • набор пополняется по мере загрузки тайлов: список видимых появляется раньше их данных, и «собрать один раз» оставляло часть домов стоять поверх плана. По той же причине в отпечаток маски входит НАЛИЧИЕ ДАННЫХ у каждого тайла, а не только его ключ: иначе маска, посчитанная по недогруженному плану, не пересчитывалась вовсе — после перезагрузки страницы дом стоял поверх плана и пропадал лишь от первого движения карты. И решение «нечего резать» нельзя кешировать для тайла, данных которого ещё нет.

Маска живёт РОВНО там, где рисуется план: слой, невидимый на текущем зуме, в неё не идёт. Без этого между зумом, где план уже пропал по своему minzoom, и зумом, где здания ещё не показаны, не видно ни того ни другого — дом остаётся скрытым под планом, которого нет. Проверено по зумам: до z16 дома на месте и плана нет, с z16 появляется план и дом исчезает.

Маску получает только городской слой зданий: стены плана рисуются тем же методом и стоят внутри той же маски — применив её к ним, мы стёрли бы ровно то, ради чего дом и прячем.

Стены комнат — ОБЪЁМНЫЕ (indoor-wall, тип fill-extrusion): высота приходит из данных (height, низ — base), материал и подсветка общие с городскими домами. Покрашены они СВЕТЛЕЕ, чем плоские слои плана, и у них своя кровля: стена это освещённая поверхность, а не самая тёмная линия чертежа — отделяет её свет, и тёмная плашка под настоящей тенью читается грязью. Затенение у основания (aoStrength) выключено: полоса в пару метров на тридцатиметровом доме читается контактной тенью, а на трёхметровой перегородке занимает пол-стены и выглядит чёрной каймой со ступеньками. Внутри помещения контактной тени и не бывает. Рисуются они СВОЕЙ сценой: перед ними очищается буфер глубины, потому что внутри себя план обязан быть объёмным (дальняя стена не должна ложиться на ближнюю), а общая глубина города для него не годится — план лежит внутри дома. Тот же вывод, к которому пришёл наш web-SDK: плоская экструзия рядом с настоящими домами выглядела чужеродной, и стены отданы общему движку зданий.

Двери и лифты. Дверей в тайлах больше сотни на здание — без них план читается набором комнат без входов; они рисуются отдельным слоем с z18. Лифты стали заливкой вместо пунктирной линии: шахта читается фигурой, а не обводкой.

Объекты приложения

Маршрут, зона доставки, трек машины — то, что кладёт на карту приложение, а не тайлсервер. Отдельным источником они не оформлены сознательно: таких объектов единицы, их правят покадрово, и воркер с LRU здесь ничего бы не сэкономили.

const route = new OsmGL.Polyline({
  coordinates: [[68.780, 38.563], [68.790, 38.562], [68.790, 38.557]],
  color: '#2f6df6', width: 7,
  strokeColor: '#fff', strokeWidth: 2,   // обводка снизу, как у дорог в стиле
  zIndex: 20,
  userData: { orderId: 42 },
})
map.addObject(route)

map.addObject(new OsmGL.Circle({ center: [68.786, 38.560], radius: 400 }))   // радиус в МЕТРАХ
map.addMarker(new OsmGL.Marker({ coordinates: [68.786, 38.560], html: '<b>А</b>' }))

map.on('click', ({ object, lngLat }) => {
  if (object) console.log('попали в заказ', object.userData.orderId)
})

Доступны Polyline, Polygon (с дырками), Circle и Marker. Оформление — те же поля, что у слоёв стиля: color, opacity, width, dashArray, offset, arrow. Шейдеры буквально те же самые; отличается только тип атрибута позиции.

Координаты. У объекта нет тайла, который держал бы его вершины в маленьких целых числах, поэтому у каждого объекта СВОЁ начало координат — центр его рамки, — а вершины хранятся относительно него в нормализованном меркаторе, во Float32. Int16, как у тайлов, здесь не годится: маршрут через город это 40 км, шаг сетки вышел бы около метра, а на z20 метр — это восемь пикселей дрожания.

Правки. setCoordinates/setPaint только увеличивают revision; буфер перезаливается один раз в следующем кадре. Двигать трек машины можно хоть на каждый тик — загрузка будет одна.

Маркер — это DOM, а не спрайт в канве. По маркеру кликают, наводят подсказку, ставят в него счётчик, анимируют пульсацию; браузер это уже умеет, а в канве пришлось бы заново делать попадание, курсор и доступность. Цена известна: тысячи маркеров DOM не потянет — для массовых точек есть слой symbol из стиля, он в GL и умеет коллизии.

Клик ловится не событием click браузера, а парой pointerdown/pointerup с порогом 4 px и 500 мс: на карте почти любой клик приходит после перетаскивания, и настоящий щелчок надо отличать от конца панорамирования. Попадание считается на CPU в экранных координатах — поэтому переживает наклон и поворот и позволяет задать допуск на палец.

Фасады и крыши

Стены получают окна, а невысокие дома — скатные крыши, как в 2GIS и Яндексе.

Рисунок фасада считается ПРОЦЕДУРНО, без единой текстуры: атлас фасадов (путь Mapbox Standard) даёт больше, но требует художника, загрузки и памяти, а читаемый город получается и из сетки, построенной прямо по координатам. Класс фасада выводится в основном ИЗ ВЫСОТЫ: тег class есть у двух процентов зданий (64 из 2700 в тайле Душанбе), а частный дом от девятиэтажки отличается рисунком окон сильнее, чем жилой дом от офисного.

| Класс | Кто | Рисунок | |-------|-----|---------| | дом | ниже 7 м, house, garage | редкие окна, глухой цоколь | | жилая секция | 7–40 м | регулярная сетка, витрины на первом этаже | | стекло | выше 40 м, office, retail | ленточное остекление с импостами | | промышленное | industrial, warehouse | крупные панели, окна только выше 4 м |

Скатная крыша ставится на ОПИСАННОМ прямоугольнике и только там, где контур на него похож (заполнение ≥ 0.86), не выше 15 м и не больше 600 м². Честный способ — прямой скелет многоугольника — на порядок сложнее и нужен ради редких форм; прямоугольник закрывает ровно тот случай, ради которого всё затевалось, — частный дом. Форма (двускатная, вальмовая, шатровая, односкатная) берётся из тега roof_shape, а без него — из пропорций и хеша здания. Скат добавляется ПОВЕРХ заданной высоты: render_height у дома это обычно карниз.

Раскладка окон идёт по грани, а не по общей сетке: у каждой стены свои поля по краям, целое число ячеек и остаток, размазанный по простенкам. Так сделано в maps3d-web (fw/t0 на ребро), и причина не в красоте: при общей сетке ребро дома режет её где придётся — у угла остаётся половина окна, а простенки у соседних стен разной ширины. Ширина грани приходит атрибутом a_wall, и подряд идущие сонаправленные рёбра склеиваются в одну грань — контуры из OSM сплошь и рядом дробят прямую стену на два-три звена, и иначе на ней оказалось бы несколько независимых сеток со швом на ровном месте.

Ночью часть окон горит (windowGlow — доля светящихся). Какие именно, решает хеш ячейки, поэтому при движении камеры свет не «бегает», а яркость у окон разная — одинаково светящиеся окна выглядят вывеской, а не жилым домом.

Вся детализация гасится по fwidth (метров на пиксель) задолго до того, как начнёт рябить: на общем плане фасад не меняет НИ ОДНОГО пикселя (дымовой тест меряет это с обоих концов — 125 тысяч пикселей вблизи против нуля вдали).

Настройки — в paint слоя building: facadeDetail, windowColor, windowFrameColor, windowGlow, floorHeight, roofVariation.

Темы

Тема — накладка на базовый стиль: цвета, свет, небо, видимость. Файлы тем (styles/theme-*.json, styles/standard/*.json) — в формате v2: ключи — имена v2 (paint[id]["fill-color"], paint[id]["osmgl-window-color"], paint.background["background-color"], osmgl-light, sky["sky-color"], layout[id].visibility: "none"). Внутри движка тема — прежняя накладка на имена v1 (themeToInternal); тема v1, написанная приложением, принимается — ключи разбираются по одному, имя v1 работает как синоним с одним предупреждением. Имена ниже (dashArray, facadeDetail, ice…) — поля внутренней модели; в файле v2 это osmgl-line-dasharray-px, osmgl-facade-detail, osmgl-ice (таблица — поле v2 справочника).

Двадцать семь готовых. Пять сделаны под этот движок — day, night (ночная), mono (графит), pale (бледная, под данные сверху), ice (ледяная, со своим вариантом ice-tone — окна в тон стене); остальные — палитры GramMaps Standard из админки (src/Admin/public/maps/standard-*.json), перенесённые сюда скриптом, чтобы карта на своём движке выглядела так же, как везде в продукте:

Ледяная модель света у зданий (ice: 1: свет прибавляется, тёмных сторон нет, швов нет) стоит не только у ice: её кодом получают светлая 3d, тёмная night и пара «Атлас» (atlas-day, atlas-night). У тёмного «Атласа» кровля — цветом стены: своей у палитры нет, и движок брал ей светлую по умолчанию.

ice — отдельная тема со СВОЕЙ палитрой целиком, а не правка светлой. Цвета сняты с растровых тайлов Baidu Maps (Луцзяцзуй, z17, 25 тайлов): земля #f5f3f0, дороги белые с серой обводкой #dbdbdd, магистрали #ffd86b, вода #90ddf6, зелень #c2f0c3, жилые кварталы #ecf1fb. Пешеходные дорожки там сплошные белые, а не пунктирные, — тема снимает пунктир (dashArray: null).

Дома гладкие: рисунка фасада и швов нет вовсе (facadeDetail: 0, seamStrength: 0), затенение у основания почти снято (aoStrength: 0.04), свет наполовину рассеянный (ambientIntensity: 0.72 против keyIntensity: 0.36) — грани разводятся оттенком, а не темнотой.

Сверх цвета у зданий работает ручка ice (0..1) — цветом её не задать, она меняет саму модель освещения. Приём снят с живых шейдеров Baidu Maps (перехват shaderSource на их странице):

  1. кровля не освещается вовсе — берёт цвет палитры как есть;
  2. свет НЕ УМНОЖАЕТ, а ДОБАВЛЯЕТ, поэтому ничто не бывает темнее собственного цвета: дом читается светящимся изнутри, а не освещённым снаружи. Обычное освещение уводит отвёрнутую стену в тень — отсюда «матовый картон».

Ручка не только для ледяной: светлая и тёмная темы включают её тоже, каждая со своей палитрой и своим светом (withIceLight в style/style.ts — ставится кодом, потому что светлая ГЕНЕРИРУЕТСЯ из стилей админки и правка её файла живёт до следующей сборки). Считается она ДО свечения окон, иначе на тёмной теме горящие окна затирались бы вместе с освещением.

Коэффициенты вдвое меньше их 0.1/0.06, и боковой свет считается по ГОРИЗОНТАЛЬНОЙ проекции нормали. Причина — наша геометрия: низ стены у нас со скруглением и фаской, её нормаль завалена вниз, и у земли появлялась тёмная кайма (замер по стене: ровные 215,230,241 и полоса 203,217,227 у основания). Проекция уравнивает фаску со стеной над ней. Затенение у основания остаётся одной ручкой — документированной aoStrength.

Дороги у Baidu кодом не рисуются вовсе: их клиент компилирует около дюжины шейдеров (здания, плоская проекционная тень, горизонт и наклейка текстуры), а базовая карта приезжает готовыми растровыми PNG. Поэтому дороги здесь воспроизведены по ИЗМЕРЕНИЯМ растра: белое полотно, магистраль #ffd86b, второстепенная