Files
enduro-trails/docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md
claude-bot 1984b0bde6
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 5s
CI / build (push) Successful in 4s
CI / build (pull_request) Successful in 2s
fix(ET-007): address 6 P1 findings from review (docs + code)
12-review.md (REQUEST_CHANGES, attempt 2/3) flagged 6 must-fix items
in the analysis/architecture artefacts plus matching bugs that had
already leaked into the committed implementation. This patch lands
both: documents corrected, code aligned with corrected specs, tests
updated.

P1-1: TRZ/ADR/Data/Risks referenced fictional layer ids
(`trails-grade1..5-halo-satellite`, `paths-bridleway-halo-satellite`).
Actual style*.json has only `trails-track-halo-satellite` and
`trails-path-bridleway-halo-satellite`; grade differentiation lives
inside one `match` expression on `tracktype` within `trails-track`.
Docs rewritten to operate on real ids.

P1-2: POI labels contrast was broken — spec changed only halo-color
to black, leaving `text-color: #333333` (light theme baseline)
unreadable over the new black halo. Code+docs now switch BOTH
`text-color` (-> `#ffffff` on satellite) AND halo together, with
per-theme baselines (`#333333` light / `#e0e0e0` dark) restored on
return to Schematic.

P1-3: BRD §5 hillshade risk said «hillshade auto-disabled on
satellite», contradicting TRZ/ADR/AC. BRD wording aligned: hillshade
keeps working over satellite; visual check is AC-04.

P1-4: background-color had four divergent sources (`#1a1a1a`,
`#2a2a2a`, `#1a1a2e`, `#f0ede6`), incl. an inverted-theme typo and a
baseline `#1a1a1a` that didn't match the actual `style-dark.json:28`
value `#1a1a2e`. Settled on ADR-004's single-constant model: `#2a2a2a`
on satellite for both themes; on Schematic restore per-theme baselines
`#f0ede6` (light) / `#1a1a2e` (dark). `_applyBackgroundForSatellite`
fixed accordingly.

P1-5: app.js already had `layerState.basemap` and `toggleLayer
('basemap')` (legacy «Базовая карта» switch). Neither TRZ nor ADR
specified the interaction. Added save&restore contract: on entering
Satellite save `layerState.basemap` to `_savedBasemapState` and
force-hide `osm-base`; on returning to Schematic restore osm-base
visibility from the saved value. CSS hook `body.satellite-active
#btn-basemap { display:none }` keeps the user from trying to enable
a hybrid mode (out of scope, BRD §3). TRZ §5.6, ADR-004 §8.

P1-6: `restoreTrailsState()` and `onTrailsCheckbox()` only managed
visibility of `trails-track` / `trails-path-bridleway`, leaving
their halo-underlay siblings as «phantom» halos when the user
unchecked grunты/тропы under Satellite. Introduced
`_applyTrailHaloVisibility(map, base)` reading checkbox state from
DOM; called from `onTrailsCheckbox`, `restoreTrailsState`, and both
branches of `applyBaseLayer`. Rule: halo visible ⇔ (base ===
satellite) AND (checkbox ON). TRZ §5.7, ADR-004 §9.

Docs bumped: BRD v2, TRZ v2, AC v2, Data v2, Risks v2; ADR-004
получает «Ревизии»-секцию (status remains accepted — only editorial
fixes, no decision change).

Tests:
- tests/unit/base_layer.test.js: rewritten 2 background-color
  assertions (#1a1a1a expectation removed), added 6 new tests for
  P1-2 / P1-4 (POI text-color per-theme baselines, single satellite
  bg #2a2a2a, baseline restore on Schematic).
- All 33 JS unit tests + 22 pytest static checks green.
- Full pytest suite: 76 passed (excluding pre-existing
  shapely-import skipped collection in tests/unit/test_health.py).

Refs: ET-007
Review: docs/work-items/ET-007/12-review.md (P1-1..P1-6)
ADR: docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md (rev. 2026-05-31)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:05:49 +00:00

28 KiB
Raw Permalink Blame History

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

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

Статус

Accepted

Контекст

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

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

  • В обоих стилях карты (src/web/style.json стр. 1641, 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() стр. 100117).
  • Фронтенд плоский, без сборщика: 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 разделил фичу, потому что её объём был 600900 строк и она имела собственную модель данных (gpxTracks), собственный bottom sheet и собственный canvas. Здесь фича плоская и объём в 57 раз меньше; разделение даёт чистоту, но не покрывает стоимости новой связки 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: backgroundsatellite-baseosm-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:384391). В коде уже есть отдельный пользовательский выключатель «Базовая карта» (управляет 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:27832826). В существующих 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().

  10. 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) — «одна фича = один скрипт + глобали»