feat(web): спутниковая подложка с переключателем Схема/Спутник
All checks were successful
CI / lint (push) Successful in 3s
CI / test (push) Successful in 5s
CI / build (push) Successful in 1s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:09:19 +00:00
parent 29d8461c0c
commit 475d42187d
7 changed files with 1047 additions and 0 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -41,6 +41,17 @@
<!-- ── Terrain popup ────────────────────── -->
<div id="terrain-popup" class="terrain-popup" style="display:none">
<!-- 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)">
<div class="terrain-popup-title">Эндуро</div>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">

View File

@@ -53,6 +53,21 @@
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 6,
"filter": ["==", "highway", "track"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 6, 1.5, 8, 2.6, 10, 4, 12, 6.5, 16, 10],
"line-opacity": 0.55,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track",
"type": "line",
@@ -75,6 +90,21 @@
},
"layout": { "line-cap": "round", "line-join": "round" }
},
{
"id": "trails-path-bridleway-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 8,
"filter": ["in", "highway", "path", "bridleway", "footway"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 7, 1.6, 10, 3.2, 12, 4.2, 16, 5.5],
"line-opacity": 0.5,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-path-bridleway",
"type": "line",

View File

@@ -53,6 +53,21 @@
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 6,
"filter": ["==", "highway", "track"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 6, 1.5, 8, 2.6, 10, 4, 12, 6.5, 16, 10],
"line-opacity": 0.55,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track",
"type": "line",
@@ -75,6 +90,21 @@
},
"layout": { "line-cap": "round", "line-join": "round" }
},
{
"id": "trails-path-bridleway-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 8,
"filter": ["in", "highway", "path", "bridleway", "footway"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 7, 1.6, 10, 3.2, 12, 4.2, 16, 5.5],
"line-opacity": 0.5,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-path-bridleway",
"type": "line",