---
type: trz
work_item_id: ET-007
title: "ТЗ: Спутниковая карта (Схема / Спутник)"
version: 2
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
changelog:
  - "v2 (2026-05-31): code-review fixes (12-review.md, attempt 2/3) — P1-1..P1-6: реальные id halo-слоёв, контраст POI labels, единый satellite-bg, контракт с layerState.basemap, синхронизация halo с чекбоксами."
authors:
  - "agent:analyst"
---

# ТЗ — ET-007: Спутниковая карта (Схема / Спутник)

## 1. Функциональные требования

### REQ-F-01: Переключатель «Схема / Спутник»

- В попап-панели слоёв (`#terrain-popup`, открывается кнопкой
  `#terrain-toggle`) добавляется новая секция в самом верху панели —
  «Подложка».
- Реализация — segmented-control (`.seg-control` / `.seg-btn`) с двумя
  кнопками:
  - «Схема» (`data-base="schematic"`, ID `base-btn-schematic`) —
    активна по умолчанию.
  - «Спутник» (`data-base="satellite"`, ID `base-btn-satellite`).
- Активная кнопка визуально выделяется (`.active` — оранжевый фон, по
  аналогии с переключателем единиц измерения, ET-005).
- Обработчик: `onBaseLayerToggle(base)` в `src/web/app.js`.
- Под переключателем — горизонтальная линия-разделитель (`<hr>`),
  как уже сделано между секциями попапа.

### REQ-F-02: Спутниковый растровый источник

- Используется растровый тайл-сервер Esri World Imagery (см. ADR в
  `docs/work-items/ET-007/06-adr/`):
  - URL-шаблон: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`.
  - `tileSize: 256`, `minzoom: 0`, `maxzoom: 19`.
  - Атрибуция: «Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community».
- Источник добавляется на карту лениво: при первом включении режима
  «Спутник», а не на старте приложения.
- ID источника: `satellite-raster`.
- ID слоя: `satellite-base`.

### REQ-F-03: Поведение в режиме «Спутник»

- При включении «Спутник»:
  - Если ещё не добавлен — добавить source `satellite-raster` и layer
    `satellite-base` сразу после слоя `background` (т.е. ниже всех
    остальных слоёв).
  - Слой `osm-base` (существующий) скрывается (`visibility: none`).
  - Слой `background` остаётся (показывает «дыры» если тайлы ещё не
    загрузились) — цвет фона на спутнике — единая константа `#2a2a2a`
    для обеих тем (тёмно-серый, чтобы не «бликовал» под медленно
    подгружающимися спутниковыми плитками; решение зафиксировано в
    ADR-004 §6). Baseline `background-color` для возврата на «Схему»:
    `#f0ede6` (light), `#1a1a2e` (dark) — см. Data §5.
- При возврате на «Схема»:
  - `osm-base` снова видим (`visibility: visible`).
  - `satellite-base` скрывается (`visibility: none`), но не удаляется
    из стиля (быстрое повторное переключение).

### REQ-F-04: Совместимость со слоями приложения

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

| Слой                          | Z-order над спутником | Доп. правила в режиме «Спутник»                                                |
| ----------------------------- | --------------------- | ------------------------------------------------------------------------------ |
| Hillshade (`terrain-hillshade`) | поверх спутника       | Включается/выключается чекбоксом как раньше; по умолчанию НЕ авто-выключается  |
| TRI (`terrain-tri`)            | поверх спутника       | Аналогично hillshade                                                           |
| Trails — грунтовки (`trails-track`) | поверх terrain    | Halo через парный underlay-слой `trails-track-halo-satellite` (единый halo на весь слой, без разбиения по grade) |
| Paths / bridleway (`trails-path-bridleway`) | поверх trails | Halo через парный underlay-слой `trails-path-bridleway-halo-satellite`         |
| Asphalt-дороги (`trails-asphalt`) | поверх trails       | Halo не вводится — слой по умолчанию скрыт (`visibility: none`, `line-opacity: 0`); если будет включён в будущем, halo добавляется тем же паттерном |
| POI circles (`poi-circles`)     | поверх trails         | Обводка `circle-stroke-color: #ffffff`, толщина 2 px                            |
| POI labels (`poi-labels`)       | поверх POI            | `text-color: #ffffff`, `text-halo-color: #000000`, `text-halo-width: 2` для читаемости на спутнике (см. REQ-F-04-POI ниже) |
| Route / Scenic / Link / Ruler   | поверх POI            | Без изменений                                                                  |
| GPX-треки и waypoints           | поверх Route          | Без изменений (ET-006 уже совместим)                                           |

**REQ-F-04-POI (контраст подписей POI на спутнике).** На спутнике
менять обе пары свойств `text-color` и `text-halo-*`, иначе тёмный
текст `#333333` (light-theme) останется нечитаем поверх тёмного halo.
Конкретные значения и baseline-возврат — в Data §5.

**Halo-слои в `style*.json` (подтверждено фактическим кодом
`src/web/style.json` и `style-dark.json`):** реальные id — это
`trails-track-halo-satellite` и `trails-path-bridleway-halo-satellite`.
Слоёв `trails-grade1..5-halo-satellite` или
`paths-bridleway-halo-satellite` **нет** и заводить их не нужно:
`trails-track` хранит дифференциацию по grade внутри одного `match`-
выражения по `tracktype`. На спутнике halo единого цвета/ширины
накладывается на весь `trails-track` целиком; разделять halo по grade
не требуется (визуально не различимо под линией grade-цвета).

Реализация:
- Halo для грунтовок и троп — пара underlay-слоёв
  (`trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite`),
  уже присутствующих в обоих `style*.json` с `visibility: none`.
  Включаются через `setLayoutProperty(..., 'visibility', 'visible')`
  только в режиме «Спутник».
- Стили POI (circles и labels) на спутнике задаются динамически через
  `setPaintProperty` при переключении режима; baseline-значения
  возврата на «Схему» зафиксированы в `08-data-requirements.md` §5
  и в `applyBaseLayer()` (см. §5.2 ниже).

### REQ-F-05: Сохранение состояния (localStorage)

- Ключ: `map-base-layer`.
- Значения: `"schematic"` (default) | `"satellite"`.
- При `onBaseLayerToggle()` — запись.
- При старте приложения — чтение и применение через
  `restoreBaseLayerState()` (по аналогии с `restoreTerrainState()`).

### REQ-F-06: Восстановление после смены стиля карты

- При вызове `map.setStyle()` (переключение тёмной/светлой темы, см.
  `switchMapStyle()` в `app.js`) спутниковый source/layer удаляются
  вместе со стилем.
- В функции `rebuildMapOverlays()` добавляется вызов
  `restoreBaseLayerState()` — это пересоздаёт source/layer спутника и
  выставляет видимость по сохранённому состоянию.
- Порядок вызовов в `rebuildMapOverlays()`: `restoreBaseLayerState()`
  вызывается **до** `restoreTerrainState()` — чтобы hillshade/TRI
  оказались выше спутника, но ниже trails (тот же подход, что и для
  schematic-режима).

### REQ-F-07: Атрибуция

- При создании source `satellite-raster` передаётся свойство
  `attribution: "Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"`.
- MapLibre автоматически отображает атрибуцию в правом нижнем углу
  карты, когда соответствующий source активен.
- Атрибуция OSM остаётся видимой в обоих режимах (vector-источник
  `trails-tiles` всегда активен).

### REQ-F-08: Fallback при ошибке загрузки тайлов

- Если спутниковые тайлы не загружаются (network error / 4xx / 5xx),
  MapLibre сам показывает прозрачную плитку — под ней видим `background`.
- Логика fallback на схему не предусмотрена (пользователь сам
  переключит, если нужно).

## 2. Нефункциональные требования

### REQ-NF-01: Производительность

- Время переключения «Схема → Спутник» (до первой видимой спутниковой
  плитки): ≤ 500 мс при скорости сети ≥ 5 Мбит/с.
- Переключение обратно «Спутник → Схема» — мгновенное (источник
  остаётся в стиле, меняется только visibility).
- В момент переключения не должно быть «прыжков» камеры — `center`,
  `zoom`, `bearing`, `pitch` сохраняются.

### REQ-NF-02: Совместимость

- Браузеры: Chrome 90+, Firefox 90+, Safari 15+.
- Мобильные: iOS Safari 15+, Chrome для Android.
- MapLibre GL JS 4.7.0 (уже подключен).

### REQ-NF-03: UX

- Текущая активная подложка визуально видна в UI всегда (в попапе
  слоёв).
- Переключение происходит без перезагрузки страницы и без потери
  пользовательского состояния (маршрута, GPX, точек разведки).

### REQ-NF-04: Хранение

- localStorage ключ `map-base-layer`, размер ≤ 16 байт.
- Никаких других данных приложение для этой фичи не хранит.

### REQ-NF-05: Безопасность

- Запросы к Esri World Imagery идут по HTTPS.
- Никаких персональных данных пользователя в URL запросов не
  передаётся.
- Атрибуция выводится в соответствии с лицензией провайдера (см. ADR).

## 3. UI-спецификация

### 3.1 Изменения в `#terrain-popup`

Сейчас:
```
┌────────────────────────────┐
│ Эндуро                     │
│ ☐ Тени рельефа             │
│ ☐ Перепады                 │
│ ───────                    │
│ ☑ Грунтовки                │
│ ☑ Тропы                    │
│ ───────                    │
│ ☑ POI                      │
│ ───────                    │
│ Единицы   [км][мили]       │
└────────────────────────────┘
```

После:
```
┌────────────────────────────┐
│ Подложка  [Схема][Спутник] │   ← новая секция
│ ───────                    │
│ Эндуро                     │
│ ☐ Тени рельефа             │
│ ☐ Перепады                 │
│ ───────                    │
│ ☑ Грунтовки                │
│ ☑ Тропы                    │
│ ───────                    │
│ ☑ POI                      │
│ ───────                    │
│ Единицы   [км][мили]       │
└────────────────────────────┘
```

### 3.2 Разметка HTML

В `src/web/index.html`, в начале `#terrain-popup` (сразу после
`<div class="terrain-popup-title">Эндуро</div>` ИЛИ выше него — по
выбору разработчика; рекомендуется в самом верху для большей
заметности):

```html
<!-- ET-007: переключатель подложки (Схема / Спутник) -->
<div class="terrain-base-row">
  <span class="terrain-base-label">Подложка</span>
  <div class="seg-control base-seg" id="base-seg">
    <button type="button" class="seg-btn active" id="base-btn-schematic"
            data-base="schematic" onclick="onBaseLayerToggle('schematic')">Схема</button>
    <button type="button" class="seg-btn" id="base-btn-satellite"
            data-base="satellite" onclick="onBaseLayerToggle('satellite')">Спутник</button>
  </div>
</div>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
```

### 3.3 CSS

В `src/web/app.css` — добавить стили (по аналогии с `.terrain-unit-row`):

```css
.terrain-base-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
}
.terrain-base-label {
  font-size: 12px;
  color: var(--text2);
  flex-shrink: 0;
}
.terrain-base-row .seg-control {
  flex: 1;
  margin-bottom: 0;
}
.base-seg .seg-btn {
  font-size: 12px;
}
```

### 3.4 Поведение на мобильных устройствах

- Попап `#terrain-popup` уже адаптирован под мобильные (ET-005). Новая
  строка не должна нарушать ширину попапа.
- Высота кнопок `.seg-btn` остаётся 34px (как у переключателя единиц).

## 4. Данные

### 4.1 Спутниковый источник (MapLibre source spec)

```js
{
  type: 'raster',
  tiles: [
    'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
  ],
  tileSize: 256,
  minzoom: 0,
  maxzoom: 19,
  attribution: 'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'
}
```

### 4.2 Спутниковый слой (MapLibre layer spec)

```js
{
  id: 'satellite-base',
  type: 'raster',
  source: 'satellite-raster',
  paint: {
    'raster-opacity': 1.0,
    'raster-resampling': 'linear'
  },
  layout: { visibility: 'none' }   // включается при переключении
}
```

Вставляется в стиль сразу после слоя `background`.

### 4.3 localStorage

| Ключ              | Значения                       | Default       |
| ----------------- | ------------------------------ | ------------- |
| `map-base-layer`  | `"schematic"` \| `"satellite"` | `"schematic"` |

## 5. Алгоритмы

### 5.1 `onBaseLayerToggle(base)`

```
1. Если base === текущий — return.
2. Сохранить в localStorage('map-base-layer', base).
3. Применить applyBaseLayer(base).
4. syncBaseLayerUI(base).
```

### 5.2 `applyBaseLayer(base)`

```
1. map = window._map; если нет — return.
2. Если base === 'satellite':
   2.1. Если source 'satellite-raster' отсутствует — addSource (см. 4.1).
   2.2. Если layer 'satellite-base' отсутствует — addLayer (см. 4.2)
        без beforeId. Корректный z-order гарантируется тем, что
        restoreBaseLayerState вызывается ПЕРВЫМ в rebuildMapOverlays
        (см. ADR-004 §«Вариант O», O-A; см. также R-7 в Tech-Risks).
   2.3. setLayoutProperty('satellite-base', 'visibility', 'visible').
   2.4. Запомнить layerState.basemap в _savedBasemapState (см. §5.6).
        Принудительно скрыть osm-base:
        setLayoutProperty('osm-base', 'visibility', 'none').
   2.5. Включить halo-слои (см. §5.7 — синхронизация с чекбоксами):
        для каждой пары (base, halo) ∈
          [('trails-track', 'trails-track-halo-satellite'),
           ('trails-path-bridleway', 'trails-path-bridleway-halo-satellite')]
        выставить halo.visibility = base.visibility текущего слоя.
   2.6. Применить динамические правки POI:
        - poi-circles: circle-stroke-color = '#ffffff',
                       circle-stroke-width = 2;
        - poi-labels:  text-color = '#ffffff',
                       text-halo-color = '#000000',
                       text-halo-width = 2.
   2.7. Сменить background-color на единую satellite-константу
        '#2a2a2a' (для обеих тем, см. ADR-004 §6).
3. Иначе (base === 'schematic'):
   3.1. setLayoutProperty('osm-base', 'visibility',
        _savedBasemapState === false ? 'none' : 'visible') —
        восстановить выбор пользователя по «Базовая карта»
        (см. §5.6); по умолчанию (если не сохранено) — 'visible'.
   3.2. setLayoutProperty('satellite-base', 'visibility', 'none')
        (если слой существует).
   3.3. Скрыть halo-underlay-слои:
        для обеих пар выставить halo.visibility = 'none'.
   3.4. Вернуть POI к baseline текущей темы (см. Data §5):
        - poi-circles: circle-stroke-color / circle-stroke-width
                       читаются из Data §5 baseline (поэтапно: light → dark);
        - poi-labels:  text-color, text-halo-color, text-halo-width — то же.
        Источник истины baseline'ов — Data §5; код держит две константы
        per-theme и выбирает по текущей теме.
   3.5. Background-color — установить baseline текущей темы из Data §5
        ('#f0ede6' light / '#1a1a2e' dark). Прямая запись через
        setPaintProperty (не полагаемся на setStyle, потому что
        applyBaseLayer вызывается и без смены стиля).
```

### 5.3 `restoreBaseLayerState()`

```
1. base = localStorage.getItem('map-base-layer') || 'schematic'.
2. syncBaseLayerUI(base).
3. applyBaseLayer(base).
```

### 5.4 `syncBaseLayerUI(base)`

```
1. schematicBtn.classList.toggle('active', base === 'schematic').
2. satelliteBtn.classList.toggle('active', base === 'satellite').
```

### 5.5 Интеграция с `rebuildMapOverlays()` (`app.js`)

В существующей функции (см. `app.js`, ~строка 127) добавить вызов
**первым**:

```js
function rebuildMapOverlays() {
  // ET-007/ADR-004 O-A: восстановить подложку ПЕРВОЙ — terrain/trails/POI
  // ложатся поверх неё (z-order через порядок вставки, без beforeId).
  // Функция определена в этом же файле (ADR-004 §2), глобально доступна.
  restoreBaseLayerState();
  // ── далее без изменений ──
  restoreTerrainState();
  restoreTrailsState();
  // ...
}
```

### 5.6 Взаимодействие с существующим `toggleLayer('basemap')`

В `app.js:384–391` уже определены:

```js
const layerState = { tracks: true, paths: true, poi: true, basemap: true };
const layerGroups = { …, basemap: ['osm-base'] };
function toggleLayer(group) { …setLayoutProperty('osm-base', 'visibility', …) }
```

— это существующий механизм «Базовая карта (схема)» как
самостоятельного выключателя. ET-007 уважает этот механизм по
следующему контракту:

1. **При входе в «Спутник»** (`applyBaseLayer('satellite')`, §5.2 шаг
   2.4): запомнить `layerState.basemap` в локальной переменной
   `_savedBasemapState` (init: `null`). Затем **принудительно** скрыть
   `osm-base`. `layerState.basemap` **не меняется** — UI-кнопка
   `#btn-basemap` остаётся в прежнем визуальном состоянии.
2. **Пока активен «Спутник»**, кнопка «Базовая карта» скрыта из UI
   (CSS-класс `.satellite-active` на корне приложения скрывает
   `#btn-basemap`) — пользователь не должен пытаться включить схему
   поверх спутника (гибридный режим out of scope BRD §3). Альтернатива
   реализации — disabled, на усмотрение разработчика; визуальный
   эффект и AC-02/AC-03 идентичны.
3. **При возврате на «Схему»** (§5.2 шаг 3.1): `osm-base.visibility`
   восстанавливается из `_savedBasemapState` (по умолчанию `true` →
   `'visible'`, если ранее пользователь сам выключал — `false` →
   `'none'`). После восстановления `_savedBasemapState = null`.
4. **На «Схеме» (default-режим)**: `toggleLayer('basemap')` работает
   ровно как раньше — пишет в `layerState.basemap` и переключает
   `osm-base.visibility`. ET-007 этот код не трогает.

### 5.7 Синхронизация halo с чекбоксами «Грунтовки» / «Тропы» / «POI»

В `app.js:2783–2826` существуют `onTrailsCheckbox()` и
`restoreTrailsState()`, которые управляют `visibility` только
`trails-track` и `trails-path-bridleway`. Halo-underlay-слои
(`*-halo-satellite`) сейчас они не трогают — в режиме «Спутник» это
дало бы «фантом» halo без основной линии.

Правило (источник истины): **halo-слой видим ⇔ (текущая база ===
'satellite') AND (соответствующий пользовательский чекбокс ON)**.

Реализация:

1. Ввести хелпер `applyTrailHaloVisibility(trackOn, pathOn)`:
   - для пары `('trails-track-halo-satellite', trackOn)` и
     `('trails-path-bridleway-halo-satellite', pathOn)`:
     `visibility = (currentBaseLayer === 'satellite' && checked) ? 'visible' : 'none'`.
2. В `onTrailsCheckbox()` после установки `visibility` основным слоям —
   вызвать `applyTrailHaloVisibility(trackChecked, pathChecked)`.
3. В `restoreTrailsState()` после установки `visibility` основным слоям —
   вызвать `applyTrailHaloVisibility(trackOn, pathOn)`.
4. В `applyBaseLayer('satellite')` (§5.2 шаг 2.5) и
   `applyBaseLayer('schematic')` (§5.2 шаг 3.3) — читать текущее
   состояние чекбоксов из DOM (`#trails-track-cb`, `#trails-path-cb`)
   и вызвать тот же хелпер.

**POI:** для группы `poi-circles` / `poi-labels` отдельных
halo-underlay-слоёв нет — динамические правки `setPaintProperty`
(см. §5.2) уже привязаны к видимости самих слоёв. При выключении
чекбокса «POI» оба слоя становятся `visibility: none` через
существующий механизм `layerState.poi`/`restorePoiState()` — текстовые
halo-свойства просто не видны, поэтому отдельная синхронизация не
требуется.

## 6. Файловая структура изменений

```
src/web/
├── index.html           # + блок переключателя в #terrain-popup
├── app.css              # + стили .terrain-base-row, .base-seg
├── app.js               # + onBaseLayerToggle, applyBaseLayer,
                         #   restoreBaseLayerState, syncBaseLayerUI,
                         #   правка rebuildMapOverlays
```

Backend изменений нет.

## 7. Взаимодействие с существующими режимами

- Все режимы тулбара (Маршрут, Связка, Красивый, Разведка, Линейка,
  Поиск, Метка, GPX) работают независимо от выбранной подложки.
- Переключение подложки **не сбрасывает** состояние режимов: маршруты,
  GPX-треки, точки разведки, линейка, метки — остаются.
- Переключение темы (тёмная/светлая) **не сбрасывает** выбор подложки.
- При вызове `map.setStyle()` (тема, восстановление стиля)
  спутниковый слой пересоздаётся в `rebuildMapOverlays()`.

## 8. Открытые вопросы для ADR

- Выбор провайдера спутниковых тайлов (Esri / Mapbox / Bing / OpenAerialMap).
- Решение по halo для POI/trails на спутнике: статические правки в
  `style.json` через `visibility` или динамические `setPaintProperty`.
- Поведение hillshade при включении спутника: оставить как есть (по
  выбору пользователя) — зафиксировано в REQ-F-04 как «оставить».
