From 475d42187d864b0aaf04854d220b778bb028e83d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 31 May 2026 20:09:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D0=BF=D1=83=D1=82=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D0=B0=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0/=D0=A1=D0=BF=D1=83=D1=82=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ET-007: добавлен сегментированный переключатель «Подложка» в попап слоёв; ленивое создание Esri World Imagery raster-source при первом включении «Спутник»; восстановление выбора из localStorage и переживание смены темы через rebuildMapOverlays(). - src/web/index.html: блок .terrain-base-row в #terrain-popup - src/web/app.css: стили .terrain-base-row / .terrain-base-label / .base-seg - src/web/app.js: блок ET-007 с onBaseLayerToggle, applyBaseLayer, restoreBaseLayerState, syncBaseLayerUI; хук в rebuildMapOverlays() первым, чтобы terrain/trails/POI лежали поверх спутника - src/web/style.json, style-dark.json: halo-underlay-слои trails-track-halo-satellite и trails-path-bridleway-halo-satellite (visibility:none по умолчанию, включаются на спутнике для контраста) - tests/unit/base_layer.test.js: 28 behavioural JS-тестов (U-01..U-05, U-10..U-11, I-01..I-07, halo, z-order, private mode, тёмная тема) - tests/unit/test_base_layer.py: 22 pytest-проверки (HTML/CSS/app.js/ style.json структурные + node --test runner) Refs: ET-007 ADR: docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/app.css | 21 ++ src/web/app.js | 243 ++++++++++++++++++++ src/web/index.html | 11 + src/web/style-dark.json | 30 +++ src/web/style.json | 30 +++ tests/unit/base_layer.test.js | 411 ++++++++++++++++++++++++++++++++++ tests/unit/test_base_layer.py | 301 +++++++++++++++++++++++++ 7 files changed, 1047 insertions(+) create mode 100644 tests/unit/base_layer.test.js create mode 100644 tests/unit/test_base_layer.py diff --git a/src/web/app.css b/src/web/app.css index ae57fc7..11d3390 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -866,6 +866,27 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; } cursor: not-allowed; } +/* ── ET-007: переключатель подложки (Схема/Спутник) в попапе рельефа ── */ +.terrain-base-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0 2px; +} +.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; + height: 34px; +} + /* ── ET-005: переключатель единиц измерения (км/мили) в попапе рельефа ── */ .terrain-unit-row { padding: 8px 4px 2px; diff --git a/src/web/app.js b/src/web/app.js index b491794..4aecb93 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -125,6 +125,11 @@ function onMapStyleLoad() { } function rebuildMapOverlays() { + // ET-007: восстановить выбранную подложку первой — чтобы terrain/trails/POI + // оказались поверх неё (см. ADR-004, TRZ §5.5). + if (typeof restoreBaseLayerState === 'function') { + restoreBaseLayerState(); + } // Re-apply terrain and trails after style change restoreTerrainState(); restoreTrailsState(); @@ -2876,6 +2881,242 @@ function restorePoiState() { } // <<< ET-002 POI visibility block <<< +// >>> ET-007 base layer toggle block (do not remove markers — used by unit tests) >>> +// Переключатель базовой подложки карты «Схема» / «Спутник» в попапе слоёв. +// Реализация: ленивое создание спутникового raster-source/layer при первом +// включении «Спутника»; восстановление выбора из localStorage и +// rebuildMapOverlays() после смены темы. POI / trails halo переключаются +// через visibility у декларативных underlay-слоёв (`*-halo-satellite`) и +// setPaintProperty у POI labels/circles. См. +// docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md. + +/** + * Параметры спутникового источника и слоя (ADR-004 §4.1, TRZ §4.1). + * URL без API-ключа, HTTPS обязателен, атрибуция Esri. + */ +const SATELLITE_SOURCE_ID = 'satellite-raster'; +const SATELLITE_LAYER_ID = 'satellite-base'; +const SATELLITE_TILE_URL = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; +const SATELLITE_ATTRIBUTION = + 'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'; + +/** + * Halo-underlay-слои, видимые только в режиме «Спутник» (ADR-004 §5, + * вариант H-B). Объявлены в style.json / style-dark.json с + * visibility: none; здесь только переключаем видимость. + */ +const SATELLITE_HALO_LAYER_IDS = [ + 'trails-track-halo-satellite', + 'trails-path-bridleway-halo-satellite', +]; + +/** + * Возвращает выбранную пользователем подложку из localStorage. + * + * Любое значение, кроме известных (`'schematic'` / `'satellite'`), + * трактуется как дефолт `'schematic'` (TRZ §4.3, U-04). Безопасно к + * приватному режиму браузера: при ошибке доступа к localStorage + * возвращает дефолт. + * @returns {('schematic'|'satellite')} + */ +function getStoredBaseLayer() { + try { + const v = window.localStorage.getItem('map-base-layer'); + return v === 'satellite' ? 'satellite' : 'schematic'; + } catch (_) { + return 'schematic'; + } +} + +/** + * Обработчик сегментированного переключателя «Подложка» (атрибут + * onclick кнопок «Схема» / «Спутник»). + * + * Идемпотентен: повторный вызов с уже активным значением — no-op + * (U-05): не пишет в localStorage и не трогает стиль карты. + * @param {('schematic'|'satellite')} base - выбранная подложка. + */ +function onBaseLayerToggle(base) { + if (base !== 'schematic' && base !== 'satellite') return; + const current = getStoredBaseLayer(); + if (current === base) return; + try { + window.localStorage.setItem('map-base-layer', base); + } catch (_) { /* private mode — фича остаётся per-session */ } + applyBaseLayer(base); + syncBaseLayerUI(base); +} + +/** + * Применяет выбранную подложку к карте (TRZ §5.2, ADR-004 §3, §5). + * + * Для `'satellite'`: лениво создаёт source/layer (если их ещё нет), + * вставляет слой ниже первого terrain/trails/POI-слоя, скрывает + * `osm-base`, включает halo-underlay-слои у trails, выставляет + * тёмный halo у POI и тёмный background, чтобы белый фон не + * «бликовал» под медленно подгружающимися плитками. + * + * Для `'schematic'`: возвращает все динамически изменённые свойства + * к значениям, объявленным в текущем `style.json` / `style-dark.json`. + * @param {('schematic'|'satellite')} base + */ +function applyBaseLayer(base) { + const map = window._map; + if (!map) return; + if (base === 'satellite') { + if (!map.getSource(SATELLITE_SOURCE_ID)) { + map.addSource(SATELLITE_SOURCE_ID, { + type: 'raster', + tiles: [SATELLITE_TILE_URL], + tileSize: 256, + minzoom: 0, + maxzoom: 19, + attribution: SATELLITE_ATTRIBUTION, + }); + } + if (!map.getLayer(SATELLITE_LAYER_ID)) { + const before = _firstOverlayLayerId(map); + map.addLayer({ + id: SATELLITE_LAYER_ID, + type: 'raster', + source: SATELLITE_SOURCE_ID, + paint: { 'raster-opacity': 1.0, 'raster-resampling': 'linear' }, + layout: { visibility: 'none' }, + }, before); + } + if (map.getLayer(SATELLITE_LAYER_ID)) { + map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'visible'); + } + if (map.getLayer('osm-base')) { + map.setLayoutProperty('osm-base', 'visibility', 'none'); + } + _toggleSatelliteHalo(map, true); + _applyPoiSatellitePaint(map, true); + _applyBackgroundForSatellite(map, true); + } else { + if (map.getLayer(SATELLITE_LAYER_ID)) { + map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'none'); + } + if (map.getLayer('osm-base')) { + map.setLayoutProperty('osm-base', 'visibility', 'visible'); + } + _toggleSatelliteHalo(map, false); + _applyPoiSatellitePaint(map, false); + _applyBackgroundForSatellite(map, false); + } +} + +/** + * Восстанавливает выбор подложки из localStorage и применяет его к + * карте (TRZ §5.3). + * + * Вызывается: + * - в `rebuildMapOverlays()` (первым — TRZ §5.5) после смены темы; + * - в IIFE-инициализаторе ниже на старте приложения. + * + * Идемпотентна: дублирующий вызов с тем же сохранённым значением — no-op. + */ +function restoreBaseLayerState() { + const base = getStoredBaseLayer(); + syncBaseLayerUI(base); + applyBaseLayer(base); +} + +/** + * Синхронизирует визуальное состояние кнопок переключателя подложки + * с переданным значением (TRZ §5.4). + * @param {('schematic'|'satellite')} base + */ +function syncBaseLayerUI(base) { + const schBtn = document.getElementById('base-btn-schematic'); + const satBtn = document.getElementById('base-btn-satellite'); + if (schBtn) schBtn.classList.toggle('active', base === 'schematic'); + if (satBtn) satBtn.classList.toggle('active', base === 'satellite'); +} + +// ── Приватные хелперы (ADR-004 §5) ───────────────────────────────── + +/** + * Возвращает id первого «верхнего» слоя (terrain/trails/POI), + * чтобы спутник был добавлен ПОД ним и terrain/trails/POI/маршрут + * остались видны поверх спутника без вычисления beforeId для каждого + * слоя в отдельности (ADR-004 §O-A). + */ +function _firstOverlayLayerId(map) { + const style = map.getStyle && map.getStyle(); + if (!style || !style.layers) return undefined; + const first = style.layers.find((l) => + l.id.startsWith('terrain-') || + l.id.startsWith('trails-') || + l.id.startsWith('poi-') + ); + return first ? first.id : undefined; +} + +/** + * Переключает видимость halo-underlay-слоёв у trails (TRZ §1 REQ-F-04, + * ADR-004 §5, вариант H-B). + */ +function _toggleSatelliteHalo(map, enabled) { + const visibility = enabled ? 'visible' : 'none'; + SATELLITE_HALO_LAYER_IDS.forEach((id) => { + if (map.getLayer(id)) { + map.setLayoutProperty(id, 'visibility', visibility); + } + }); +} + +/** + * Применяет правки paint к POI labels/circles в зависимости от + * активной подложки (ADR-004 §5). + * + * На «Спутнике» — чёрный halo у подписей и белая обводка у кружков, + * чтобы POI оставались читаемыми поверх тёмных снимков. На «Схеме» — + * возврат к значениям из style.json соответствующей темы. + */ +function _applyPoiSatellitePaint(map, satellite) { + const dark = (typeof document !== 'undefined') && + document.body && document.body.classList && + document.body.classList.contains('theme-dark'); + if (map.getLayer('poi-labels')) { + if (satellite) { + map.setPaintProperty('poi-labels', 'text-halo-color', '#000000'); + map.setPaintProperty('poi-labels', 'text-halo-width', 2); + } else { + map.setPaintProperty('poi-labels', 'text-halo-color', dark ? '#1a1a2e' : '#ffffff'); + map.setPaintProperty('poi-labels', 'text-halo-width', dark ? 2 : 1.5); + } + } + if (map.getLayer('poi-circles')) { + if (satellite) { + map.setPaintProperty('poi-circles', 'circle-stroke-color', '#ffffff'); + map.setPaintProperty('poi-circles', 'circle-stroke-width', 2); + } else { + map.setPaintProperty('poi-circles', 'circle-stroke-color', dark ? '#333333' : '#ffffff'); + map.setPaintProperty('poi-circles', 'circle-stroke-width', 1.5); + } + } +} + +/** + * Меняет цвет background-слоя под спутником на тёмно-серый + * (TRZ §1 REQ-F-03, ADR-004 §6). На «Схеме» — возврат к цвету из + * style.json / style-dark.json. + */ +function _applyBackgroundForSatellite(map, satellite) { + if (!map.getLayer('background')) return; + const dark = (typeof document !== 'undefined') && + document.body && document.body.classList && + document.body.classList.contains('theme-dark'); + if (satellite) { + map.setPaintProperty('background', 'background-color', dark ? '#2a2a2a' : '#1a1a1a'); + } else { + map.setPaintProperty('background', 'background-color', dark ? '#1a1a2e' : '#f0ede6'); + } +} +// <<< ET-007 base layer toggle block <<< + // >>> ET-005 unit toggle block >>> // Переключатель единиц измерения расстояний (км/мили) в попапе рельефа. // Выбор единицы, его персистентность и форматирование вынесены в @@ -3041,6 +3282,7 @@ function restoreTerrainState() { setTimeout(restoreTerrainState, 100); }); // Initial state + restoreBaseLayerState(); restoreTerrainState(); restoreTrailsState(); restorePoiState(); @@ -3054,6 +3296,7 @@ function restoreTerrainState() { setTimeout(restoreTerrainState, 100); }); updateHillshadeAvailability(); + restoreBaseLayerState(); restoreTerrainState(); restoreTrailsState(); restorePoiState(); diff --git a/src/web/index.html b/src/web/index.html index 93dfc76..bc81afb 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -41,6 +41,17 @@