--- 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`. - Под переключателем — горизонтальная линия-разделитель (`
`), как уже сделано между секциями попапа. ### 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` (сразу после `
Эндуро
` ИЛИ выше него — по выбору разработчика; рекомендуется в самом верху для большей заметности): ```html
Подложка

``` ### 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 как «оставить».