feat(web): спутниковая подложка с переключателем Схема/Спутник
All checks were successful
All checks were successful
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:
@@ -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;
|
||||
|
||||
243
src/web/app.js
243
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();
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user