---
type: adr
work_item_id: ET-007
adr_id: ADR-004
title: "ADR-004: Спутниковая подложка — провайдер Esri World Imagery, лениво-добавляемый raster-source, гибридная стратегия halo"
status: accepted
created_at: 2026-05-31
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels: []
---

# ADR-004 — Спутниковая подложка: провайдер, размещение кода, схема восстановления

## Статус

Accepted

## Контекст

ET-007 вводит вторую базовую подложку карты — спутниковые растровые
снимки — с переключателем «Схема / Спутник» в попапе слоёв
(см. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`).

Существующее состояние, проверенное в коде:

- В обоих стилях карты (`src/web/style.json` стр. 16–41,
  `src/web/style-dark.json`) уже определён единственный raster-source
  `osm-raster` и слой `osm-base`, лежащий поверх слоя `background`.
  Тайлы OSM раздаются `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
  — то есть прецедент **внешней (кросс-оригинальной) тайл-зависимости с
  атрибуцией без API-ключа уже существует**.
- `src/web/app.js` (3 132 строки) содержит функцию `rebuildMapOverlays()`
  (стр. 127), которая последовательно вызывает `restoreTerrainState()`,
  `restoreTrailsState()`, `restorePoiState()`, перерисовку маршрутов /
  GPX / линейки. Эта функция — единственная точка восстановления
  визуальных слоёв после `map.setStyle()` (переключение тёмной/светлой
  темы, `switchMapStyle()` стр. 100–117).
- Фронтенд плоский, без сборщика: `index.html`, `app.js`, `units.js`
  (190 строк, ADR-0001), `gpx.js` (1 242 строки, ADR-002). Сложившийся
  паттерн — «одна крупная фича = один классический скрипт + глобали»
  (ADR-002). Все JS-функции глобальные, обработчики навешаны через
  инлайновые `onclick`.
- Динамические мутации слоёв через `setPaintProperty` /
  `setLayoutProperty` / `addSource` / `addLayer` в `app.js` уже широко
  используются (~30 вхождений).
- В `app.js` уже есть зрелые «restore*State()»-функции для каждой
  группы слоёв; ET-007 встраивается в этот же паттерн ещё одной такой
  функцией `restoreBaseLayerState()`.

Решения, которые предстоит зафиксировать архитектурно:

1. Какого провайдера спутниковых тайлов выбрать.
2. Где разместить код переключателя — в `app.js` или в новом модуле.
3. Как именно добавлять спутниковый source/layer (заранее в `style.json`
   или лениво из JS), и как переживать `map.setStyle()`.
4. Каким способом обеспечивать читаемость линий грунтовок/троп и POI
   на тёмной спутниковой подложке (halo).
5. Классификацию изменения и нужна ли эскалация `arch:major-change`.

## Рассмотренные варианты

### Вариант P (провайдер) — выбор провайдера спутниковых тайлов

| Провайдер | API-ключ | Лицензия / условия | Покрытие | Решение |
|---|---|---|---|---|
| **Esri World Imagery** (`server.arcgisonline.com/.../World_Imagery/MapServer/tile/{z}/{y}/{x}`) | нет | Условия Esri ArcGIS Online: бесплатное использование с атрибуцией для некоммерческой и demo-разработки; широко применяется open-source-проектами (Leaflet, OpenLayers, QGIS) | глобальное, до z19 | **выбран** |
| Mapbox Satellite | требуется | бесплатный квот-лимит, далее платно | глобальное | отклонён — BRD F-02 явно требует «без API-ключа» |
| Bing Maps | требуется | сложная лицензия, обязательная регистрация | глобальное | отклонён — то же |
| Google Maps Tiles | требуется | прямо запрещён ToS для нативного встраивания не через Google Maps JS API | глобальное | отклонён |
| OpenAerialMap | нет | open-source, CC-BY | **фрагментарное**, нет глобального бесшовного слоя | отклонён — не покрывает РФ-эндуро-сценарии |
| MapTiler Satellite | требуется | бесплатный квот-лимит | глобальное | отклонён — API-ключ |

Esri World Imagery — единственный вариант, удовлетворяющий
**одновременно** трём ограничениям BRD: без API-ключа, с глобальным
покрытием, с лицензионно допустимой формой использования через
атрибуцию.

### Вариант M (модуль) — где разместить код

- **M-A — добавить в `app.js`** (выбран). +~150 строк
  (`onBaseLayerToggle`, `applyBaseLayer`, `restoreBaseLayerState`,
  `syncBaseLayerUI`, плюс хук в `rebuildMapOverlays()` и handler
  `onclick` в `index.html`). Минимальный blast radius, никаких новых
  файлов, никаких изменений в подключении скриптов.
- **M-B — выделить `src/web/basemap.js`** (по аналогии с ADR-002 для
  GPX). Отклонён: ADR-002 разделил фичу, потому что её объём был
  600–900 строк и она имела собственную модель данных (`gpxTracks`),
  собственный bottom sheet и собственный canvas. Здесь фича плоская и
  объём в 5–7 раз меньше; разделение даёт чистоту, но не покрывает
  стоимости новой связки `app.js ↔ basemap.js` ради ~150 строк.
  Контракт интеграции с `rebuildMapOverlays()` и так глобальный —
  никакой инкапсуляции отдельный файл не добавит.

### Вариант S (source) — как добавить спутниковый source/layer

- **S-A — задекларировать source `satellite-raster` и слой
  `satellite-base` (`visibility: none`) в обоих `style.json` /
  `style-dark.json`**. Source активен всегда, тайлы не запрашиваются
  до показа слоя. Плюс: восстановление после `setStyle()`
  тривиально (`setLayoutProperty('satellite-base', 'visibility', ...)`).
  Минус: `style.json` обоих тем нужно править симметрично; дрейф
  значений между двумя стилями.
- **S-B — лениво создавать source и layer из JS при первом включении
  «Спутник»** (выбран, совпадает с TRZ §1 REQ-F-02). Плюс: `style.json`
  не трогаем; ноль внешних запросов у пользователей, которые не
  включают спутник; единая точка определения source — в `app.js`. После
  `map.setStyle()` source и layer исчезают и переcоздаются вызовом
  `restoreBaseLayerState()` из `rebuildMapOverlays()` — это та же
  логика, что уже используется для terrain/trails/POI/GPX. Минус:
  холодное переключение «Схема → Спутник» включает в себя `addSource`
  + `addLayer` + сетевой запрос — но укладывается в НФТ 500 мс.

### Вариант O (order) — порядок восстановления в `rebuildMapOverlays()`

- **O-A — `restoreBaseLayerState()` вызывается ПЕРВЫМ**, до
  `restoreTerrainState()` (выбран, совпадает с TRZ §5.5). Гарантирует
  z-order: `background` → `satellite-base` → `osm-base` → terrain →
  trails → POI → routes → GPX. terrain/trails/POI оказываются выше
  спутника, маршрут/GPX — выше terrain.
- **O-B — добавлять `satellite-base` с явным `beforeId` первого
  trails-слоя**. Идемпотентно к порядку, но в `rebuildMapOverlays()`
  моменты создания слоёв не атомарны (terrain/trails добавляются
  асинхронно); использовать `beforeId` слоёв, которых ещё нет, нельзя.
  Поэтому простой «вызвать первым» надёжнее.

### Вариант H (halo) — обеспечение читаемости поверх спутника

- **H-A — динамический `setPaintProperty` по всем затрагиваемым слоям**.
  Все правки делаем из `applyBaseLayer()`; на «Схема» возвращаем
  исходные значения. Минус: нужно где-то хранить «исходные» paint-
  значения; при `map.setStyle()` они сбрасываются, что повышает риск
  drift между двумя темами.
- **H-B — отдельные «underlay»-слои с halo, `visibility: none` по
  умолчанию, включаются на спутнике** + **`setPaintProperty` только
  для POI text-halo** (выбран, совпадает с TRZ §1 REQ-F-04). Halo-линии
  декларативны в `style.json` обеих тем — никакого «запомнить
  исходное» не нужно, восстановление по `visibility`. Для POI label
  правок одна (`text-halo-color`/`text-halo-width`) — её проще менять
  динамически, чем заводить параллельные label-слои.
- **H-C — толстая полупрозрачная белая обводка прямо в существующих
  trails-слоях через `line-gap-width`**. Отклонён: ломает «Схему»
  (там halo не нужен и портит вид светлой подложки).

## Решение

Принимается комбинация: **P-Esri + M-A + S-B + O-A + H-B**.

1. **Провайдер — Esri World Imagery.** URL-шаблон, атрибуция и параметры
   source — как в TRZ §4.1. HTTPS обязателен. Атрибуция строки —
   `"Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"`.
   Альтернативные провайдеры не закладываются в код фичи; точка
   расширения — единственный объект source-spec в `applyBaseLayer()`,
   при будущей смене провайдера правка локализуется одним местом
   (см. R-2 в `10-tech-risks.md`).

2. **Код фичи живёт в `app.js`.** Никакой новый JS-файл не вводится.
   Новые глобальные функции — `onBaseLayerToggle()`, `applyBaseLayer()`,
   `restoreBaseLayerState()`, `syncBaseLayerUI()` — добавляются по
   соседству с уже существующими `restoreTerrainState()` /
   `restoreTrailsState()`. Если в будущей фазе появится потребность
   (например, второй провайдер, гибридный режим, оффлайн-кэш) — фича
   мигрирует в `src/web/basemap.js` без изменения публичного контракта
   (имена функций глобальные и стабильные).

3. **Source и layer добавляются лениво** при первом включении
   «Спутник» через `addSource('satellite-raster', {...})` +
   `addLayer({ id: 'satellite-base', ... })`. До этого момента
   запросов к `server.arcgisonline.com` не происходит. Это важно с
   точки зрения приватности: пользователи, которые никогда не
   используют спутник, не светят свой IP на серверы Esri (см.
   `10-tech-risks.md`, R-3).

4. **Восстановление после `map.setStyle()` — через `rebuildMapOverlays()`.**
   В функцию добавляется **первым** вызов
   `if (typeof restoreBaseLayerState === 'function') restoreBaseLayerState();`
   до `restoreTerrainState()`. Это гарантирует, что terrain и trails
   окажутся выше спутника, без необходимости вычислять `beforeId`.
   `restoreBaseLayerState()` идемпотентен: читает `localStorage` ключа
   `map-base-layer` и применяет `applyBaseLayer()`.

5. **Halo — гибридный подход:**

   - Для **линий грунтовок и троп** в обоих `style.json` /
     `style-dark.json` присутствуют парные «underlay»-слои
     `trails-track-halo-satellite` и
     `trails-path-bridleway-halo-satellite` (более широкая
     полупрозрачная белая обводка, `layout.visibility = "none"`).
     При входе в «Спутник» эти слои становятся видимыми; при возврате
     на «Схему» — скрываются. Никаких runtime-правок paint не
     требуется. Слоёв на каждую grade (`trails-grade1..5-halo-satellite`)
     **не заводится**: дифференциация grade хранится внутри одного
     `match`-выражения по `tracktype` в `trails-track`, halo единого
     цвета/ширины накладывается на весь слой целиком — этого
     достаточно для читаемости (под halo всё равно ляжет цветная
     линия `trails-track`). Аналогично для троп — единый halo на весь
     `trails-path-bridleway` (фильтр `highway in path/bridleway/footway`).
     `trails-asphalt` halo не получает: он по умолчанию скрыт
     (`visibility: none`, `line-opacity: 0`); если в будущей фазе
     включится — добавится halo тем же паттерном.
   - Для **POI labels** меняются динамически три свойства:
     `text-color` (`#ffffff` на спутнике / baseline текущей темы на схеме —
     `#333333` для light, `#e0e0e0` для dark), `text-halo-color`
     (`#000000` на спутнике / baseline `#ffffff` для light,
     `#1a1a2e` для dark на схеме), `text-halo-width` (`2` на спутнике
     / baseline `1.5` для light, `2` для dark на схеме). Менять
     **обе** пары (color + halo) необходимо: иначе тёмный baseline-
     текст светлой темы (`#333333`) поверх чёрного halo не читается.
     Baseline-значения известны и зафиксированы в Data §5; всегда
     выставляем явные значения для обоих режимов.
   - **POI circles** — обводка `circle-stroke-color: #ffffff` /
     `circle-stroke-width: 2` динамически на спутнике, возврат к
     baseline текущей темы из Data §5 на схеме (`#ffffff`/`1.5` light,
     `#333333`/`1.5` dark).

6. **Цвет `background`** в режиме «Спутник» меняется через
   `setPaintProperty('background', 'background-color', '#2a2a2a')` —
   **единая константа `#2a2a2a` для обеих тем** (тёмно-серый, чтобы
   не «бликовал» под медленно подгружающимися спутниковыми плитками).
   На обеих темах используется одно и то же значение; per-theme-
   развилки нет (упрощает код и исключает рассинхрон). При возврате
   на «Схему» восстанавливаются baseline-значения текущей темы —
   `#f0ede6` (light, из `style.json`) и `#1a1a2e` (dark, из
   `style-dark.json`; **не** `#1a1a1a` — это была ошибка в более
   раннем черновике). Эти baseline-константы зафиксированы в
   `applyBaseLayer()` и в `08-data-requirements.md` §5.

7. **localStorage — ключ `map-base-layer`** (см. TRZ §4.3), значения
   `"schematic"` / `"satellite"`, default `"schematic"`. Ключ
   полностью обособлен от существующих UI-настроек
   (`enduro-theme-mode`, `distance_unit`, `terrain-*`, `trails-*`,
   `poi-visible`) — никаких миграций старых значений не требуется.

8. **Контракт с существующим `toggleLayer('basemap')`
   (`app.js:384–391`).** В коде уже есть отдельный пользовательский
   выключатель «Базовая карта» (управляет `osm-base.visibility` и
   `layerState.basemap`). ET-007 принимает паттерн **save & restore**
   (см. TRZ §5.6): при входе в «Спутник» сохраняем `layerState.basemap`
   в `_savedBasemapState` и принудительно скрываем `osm-base`; UI-кнопка
   `#btn-basemap` скрывается через CSS-класс `.satellite-active` (чтобы
   пользователь не пытался включить «гибрид»: out of scope BRD §3).
   При возврате на «Схему» восстанавливаем `osm-base.visibility` из
   сохранённого значения. На «Схеме» `toggleLayer('basemap')` работает
   как раньше — ET-007 этот код не трогает.

9. **Синхронизация halo-слоёв с пользовательскими чекбоксами
   «Грунтовки» / «Тропы» (`app.js:2783–2826`).** В существующих
   `onTrailsCheckbox()` / `restoreTrailsState()` управляется
   видимость только `trails-track` и `trails-path-bridleway`. Halo-
   underlay-слои сами по себе не отслеживаются; на спутнике это даёт
   «фантом» halo при выключенной грунтовке/тропе. Решение (TRZ §5.7):
   ввести единый хелпер `applyTrailHaloVisibility(trackOn, pathOn)`
   и вызывать его из (а) `onTrailsCheckbox`, (б) `restoreTrailsState`,
   (в) `applyBaseLayer('satellite' | 'schematic')`. Правило: halo
   видим ⇔ `currentBaseLayer === 'satellite' AND checkbox === ON`.
   POI отдельной синхронизации не требуют — paint-правки текста
   привязаны к самим `poi-circles`/`poi-labels`, которые управляются
   `layerState.poi` / `restorePoiState()`.

8. **C4 / архитектурная диаграмма.** В репозитории нет файлов
   `c4-*.mmd`; описание архитектуры — текстовое в
   `docs/architecture/README.md`. Туда добавляется отдельный раздел
   «Внешние тайл-провайдеры» с двумя строками: OSM (существующий)
   и Esri World Imagery (новый, для подложки «Спутник»). Дополнительно
   `docs/architecture/adr/README.md` пополняется записью ADR-004.

## Последствия

### Положительные

- Изменения — **только в коде фронтенда** (`src/web/index.html`,
  `src/web/app.js`, `src/web/app.css`, оба `style*.json`). Backend,
  БД, OSRM, nginx, Docker-конфигурация — без изменений (см.
  `07-infra-requirements.md`).
- Лазерная локальность точки расширения: для смены провайдера
  достаточно отредактировать один объект source-spec в `app.js`.
- НФТ 500 мс выполнима: при холодном переключении расходы — это
  единичные вызовы `addSource` + `addLayer` + первая сетевая загрузка
  плитки z=текущий; последующие переключения мгновенные (только
  `visibility`).
- Пользователи, никогда не использующие «Спутник», не отправляют ни
  одного запроса на серверы Esri — минимизация утечки данных по
  умолчанию (см. R-3).
- Существующая инфраструктура восстановления после `map.setStyle()`
  переиспользуется без изменения её формы — единый паттерн для
  terrain/trails/POI/GPX/base-layer.

### Отрицательные / ограничения

- **Зависимость от третьей стороны.** Сервис Esri может ввести
  лимит / потребовать API-ключ / изменить URL. Митигация: точка
  расширения в `applyBaseLayer()`; риск зафиксирован
  (`10-tech-risks.md`, R-2).
- **Утечка IP при использовании спутника.** При активном «Спутник»
  IP пользователя становится виден Esri (так же, как сейчас он виден
  tile.openstreetmap.org). Это **не регрессия приватности относительно
  OSM**, но — расширение перечня третьих сторон, к которым клиент
  обращается. Зафиксировано в `08-data-requirements.md` §5 и
  `10-tech-risks.md` R-3.
- **Корпоративные / анти-трекинг блокировки.** Часть пользователей
  (корпсети, NextDNS-фильтры) могут блокировать `arcgisonline.com`.
  Поведение в этом случае — MapLibre показывает прозрачные плитки
  поверх `#2a2a2a` фона; пользователь сам переключится на «Схему».
  Это нормальное поведение (TRZ §1 REQ-F-08); fallback на схему
  автоматически — **не закладываем**.
- **Halo-слои в `style.json` обоих тем.** Любые будущие правки
  trails-слоёв требуют согласованной правки соответствующих
  `*-halo-satellite` слоёв. Зафиксировано в `10-tech-risks.md` R-1.
- **Background цвет.** В коде `applyBaseLayer()` появляется маленький
  дубль констант фона по темам. При смене палитры тем — править здесь
  тоже. Зафиксировано в `10-tech-risks.md` R-5.

### Технический долг

- Если позже появится потребность во **втором** провайдере (например,
  для альтернативной геополитической юрисдикции) или в гибридном
  режиме «Спутник + подписи дорог OSM поверх», логичный путь —
  вынести фичу в `src/web/basemap.js` (ADR-002-стиль) и расширить
  локальное состояние до `{ provider, hybrid }`. Имена глобальных
  функций (`onBaseLayerToggle`, `restoreBaseLayerState`) остаются
  стабильным контрактом — `index.html` и `app.js` не меняются.
- Если в проект введут CSP-заголовок (сейчас его нет, см. ET-006
  `07-infra-requirements.md` §4), для спутника потребуется
  `img-src 'self' https://*.openstreetmap.org https://server.arcgisonline.com data:;`.

## Классификация изменения

**Minor change.** Новых контейнеров, сервисов, БД, серверных API
ET-007 не вводит. Внешний тайл-провайдер — расширение уже
существующего класса зависимостей (OSM-tile), а не новый
архитектурный класс. Лейбл `arch:major-change` **не требуется**.
Обязательного дополнительного архитектурного approve не требуется.

## Ревизии

- 2026-05-31 — editorial: code-review fixes (12-review.md attempt 2/3).
  Решения P/M/S/O/H **не пересматривались**. Правки:
  - §5 пункт 1: реальные id halo-слоёв
    (`trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite`)
    вместо несуществующих `trails-grade1..5-halo-satellite` /
    `paths-bridleway-halo-satellite` (P1-1).
  - §5 пункт 2 (POI labels): добавлена правка `text-color` на
    спутнике + явный baseline возврата per-theme — без этого тёмный
    `#333333` поверх чёрного halo был нечитаем (P1-2).
  - §6: зафиксирована единая satellite-константа `#2a2a2a` для обеих
    тем; baseline dark исправлен `#1a1a1a` → `#1a1a2e` под фактическое
    значение `style-dark.json:28` (P1-4).
  - Добавлен §8: контракт с существующим `toggleLayer('basemap')` /
    `layerState.basemap` — паттерн save&restore через
    `_savedBasemapState` (P1-5).
  - Добавлен §9: синхронизация halo-слоёв с пользовательскими
    чекбоксами «Грунтовки»/«Тропы» — хелпер
    `applyTrailHaloVisibility` (P1-6).

## Связанные документы

- `docs/work-items/ET-007/01-brd.md`
- `docs/work-items/ET-007/02-trz.md`
- `docs/work-items/ET-007/03-acceptance-criteria.md`
- `docs/work-items/ET-007/04-test-plan.yaml`
- `docs/work-items/ET-007/04b-ui-test-cases.md`
- `docs/work-items/ET-007/07-infra-requirements.md`
- `docs/work-items/ET-007/08-data-requirements.md`
- `docs/work-items/ET-007/10-tech-risks.md`
- `docs/architecture/README.md`
- `docs/architecture/adr/README.md`
- ADR-0001 (ET-005) — паттерн классических скриптов
- ADR-002 (ET-006) — «одна фича = один скрипт + глобали»
