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

в путях 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 дальше порога, худшийrestaurant3.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 на их странице):
- кровля не освещается вовсе — берёт цвет палитры как есть;
- свет НЕ УМНОЖАЕТ, а ДОБАВЛЯЕТ, поэтому ничто не бывает темнее собственного цвета: дом читается светящимся изнутри, а не освещённым снаружи. Обычное освещение уводит отвёрнутую стену в тень — отсюда «матовый картон».
Ручка не только для ледяной: светлая и тёмная темы включают её тоже, каждая со
своей палитрой и своим светом (withIceLight в style/style.ts — ставится
кодом, потому что светлая ГЕНЕРИРУЕТСЯ из стилей админки и правка её файла живёт
до следующей сборки). Считается она ДО свечения окон, иначе на тёмной теме
горящие окна затирались бы вместе с освещением.
Коэффициенты вдвое меньше их 0.1/0.06, и боковой свет считается по
ГОРИЗОНТАЛЬНОЙ проекции нормали. Причина — наша геометрия: низ стены у нас со
скруглением и фаской, её нормаль завалена вниз, и у земли появлялась тёмная
кайма (замер по стене: ровные 215,230,241 и полоса 203,217,227 у основания).
Проекция уравнивает фаску со стеной над ней. Затенение у основания остаётся
одной ручкой — документированной aoStrength.
Дороги у Baidu кодом не рисуются вовсе: их клиент компилирует около дюжины
шейдеров (здания, плоская проекционная тень, горизонт и наклейка текстуры), а
базовая карта приезжает готовыми растровыми PNG. Поэтому дороги здесь
воспроизведены по ИЗМЕРЕНИЯМ растра: белое полотно, магистраль #ffd86b,
второстепенная
