diff --git a/CHANGELOG.md b/CHANGELOG.md index 2537081..80b4dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Added +- ET-013 (review F-1 fix): Слой `tri` (Terrain Ruggedness Index) добавлен + в whitelist FastAPI-endpoint'а `GET /terrain/{layer}/{z}/{x}/{y}.png` + (`src/api/main.py`). На test/prod-среде nginx перехватывает + `/enduro/terrain/*` и отдаёт PNG напрямую с диска (подтверждено эмпирически + по 404-сигнатуре `nginx/1.18.0`), но в dev-режиме (`make dev` → + FastAPI на :5556 без nginx) endpoint должен поддерживать `tri` нативно. + Изменение аддитивное: ответ-контракт и заголовки идентичны существующим + слоям (`hypso`, `hillshade`); REQ-F-18 «API contract без изменений» + не нарушен. Регрессия: integration-тест `test_known_terrain_layer_accepted_by_whitelist` + параметризован по `(hypso, hillshade, tri)` и проверяет, что для + заведомо отсутствующего файла возвращается `detail: "Tile not found"`, + а не `"Unknown layer"`. Refs: ET-013, review F-1. + ### Changed +- ET-013 (review F-2 fix): Integration-тест + `tests/integration/test_terrain_z9_tiles.py` параметризован по + `(layer ∈ {hillshade, tri}) × (zoom ∈ {9, 10, 11})` — 6 кейсов + вместо 3, покрывает оба слоя на расширенном диапазоне зумов + (ранее покрывался только `hillshade`). Refs: ET-013, review F-2. - ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8). Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords` (ADR-016): для z≤5 фильтр `min_length=10 км`, `limit=1500`; для z=6 — diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 42bddfd..2600a61 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -20,3 +20,4 @@ | ADR-014 | GPX-download эндпоинт публичного трека: `xml.etree.ElementTree`-builder + fetch+Blob на клиенте | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md) | | ADR-015 | Политика реэкспорта публичных треков: per-source `download_allowed` в `gps_sources.yaml`, default-deny (whitelist `osm` для MVP) | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md) | | ADR-016 | Снижение minzoom публичных GPS-треков до z5: калибровка существующих tier-таблиц `build_gps_mvt`/`_simplify_coords`, on-demand MVT остаётся, без heat-map/clustering | accepted | 2026-06-04 | [ET-012](../../work-items/ET-012/06-adr/ADR-016-z5-tiling-policy.md) | +| ADR-017 | Zoom-aware paint для hillshade/TRI на z9-z11: `interpolate`-выражения по `raster-opacity` и `raster-contrast`, `raster-resampling: 'nearest'`, понижение UI-минзума hillshade с 10 до 9; без перегенерации растровых тайлов | accepted | 2026-06-04 | [ET-013](../../work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md) | diff --git a/docs/work-items/ET-013/00-business-request.md b/docs/work-items/ET-013/00-business-request.md new file mode 100644 index 0000000..38a1e1d --- /dev/null +++ b/docs/work-items/ET-013/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Перепады высот теряются на z9-z11 (хорошо видны на z8) + +Work Item ID: ET-013 + +## Description + +TBD diff --git a/docs/work-items/ET-013/01-brd.md b/docs/work-items/ET-013/01-brd.md new file mode 100644 index 0000000..7233627 --- /dev/null +++ b/docs/work-items/ET-013/01-brd.md @@ -0,0 +1,232 @@ +--- +type: brd +work_item_id: ET-013 +title: "BRD: Сохранить выразительность перепадов высот на z9-z11" +version: 1 +status: draft +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:analyst" +related: + - "PH-6.terrain" +--- + +# BRD — ET-013: Сохранить выразительность перепадов высот на z9-z11 + +## 1. Цель + +На зумах **z9-z11** перепады высот должны читаться визуально +сопоставимо с z8: пользователь видит «где холмы, где равнина», +а не однородную засветку. + +Сейчас при увеличении зума с z8 (где перепады бросаются в глаза +через слой «Перепады»/TRI и общий цветовой контраст) до z9-z11 +происходит резкая потеря выразительности: + +- **z8** — слой «Перепады» (TRI) хорошо читается: крупные пятна + «шершавости» рельефа покрывают значимую долю кадра, базовая + подложка остаётся видна, перепады бросаются в глаза. +- **z9** — кнопка «Тени рельефа» (hillshade) **disabled** + (UI-минзум = 10), TRI ещё работает, но визуально пятна + становятся мельче и контраст слабее. +- **z10-z11** — hillshade включается, но его `opacity=0.40` и + отсутствие усиления контраста делают теневой рельеф «бледной + плёнкой» поверх подложки; TRI не компенсирует, потому что + его `opacity=0.70` рассчитано на z5-z8. + +ET-013 = **скалировать paint-параметры (opacity, contrast, +resampling) hillshade и TRI по зуму** так, чтобы на z9-z11 +рельеф читался сопоставимо с z8, без перегенерации растровых +тайлов и без новых данных. + +## 2. Контекст + +### 2.1 Текущая реализация (после PH-6) + +**Источники тайлов** (`src/api/main.py:1240`): +- `/terrain/hillshade/{z}/{x}/{y}.png` — теневой рельеф. +- `/terrain/tri/{z}/{x}/{y}.png` — Terrain Ruggedness Index («Перепады»). +- `/terrain/hypso/{z}/{x}/{y}.png` — гипсометрия (на текущий + момент в UI не подключён; вне scope ET-013). + +По PH-6 BRD тайлы нарезаны **z8-z14** (PNG 256×256), сгенерированы +из SRTM 30м со следующими параметрами: +- hillshade: azimuth 315°, altitude 45°, **z-factor 1.5**; +- TRI: классификация (flat / nearly flat / slightly rugged / + rugged / very rugged), цветовая шкала. + +**Клиентский рендеринг** (`src/web/app.js`): + +```js +// Строка ~2782-2783: +applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', + hillshadeChecked, 0.40, 10, 15); +applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', + triChecked, 0.70, 5, 15); +``` + +`applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom)` (строка 3316): +- создаёт `raster` source с `tileSize: 256`, `scheme: 'tms'`, + `minzoom`, `maxzoom`; +- добавляет `raster` layer с paint `{raster-opacity, raster-resampling: 'linear'}`; +- никаких zoom-tier выражений: opacity — **константа**. + +**UI-минзум hillshade** (`src/web/app.js:3359`): +```js +function updateHillshadeAvailability() { + const zoom = map.getZoom(); + if (zoom < 10) { cb.disabled = true; hint.style.display = 'inline'; ... } +} +``` +То есть на z9 чекбокс «Тени рельефа» неактивен и видна подсказка +«Зум 10+». На диске тайл z9 есть (нарезка z8-14), но клиент его +не запрашивает. + +### 2.2 Ответы на open questions из бизнес-запроса + +| Вопрос | Ответ | +|---|---| +| Чем рисуется рельеф? | Двумя независимыми raster-слоями: **hillshade** (PNG, z8-14 на диске, z10-15 в UI) и **TRI/«Перепады»** (PNG, z8-14 на диске, z5-15 в UI). Гипсометрия в UI сейчас не подключена. | +| Где задаётся стиль по зумам? | `src/web/app.js:2782-2783` (вызовы `applyTerrainLayer` с константой opacity), `src/web/app.js:3316-3357` (создание raster-слоя), `src/web/app.js:3359-3377` (UI-минзум hillshade). Никаких zoom-tier выражений нет — opacity скаляр. | +| До какого зума нарезаны тайлы? | По PH-6 BRD: **z8-z14**. На z15 на клиенте работает overzoom MapLibre (maxzoom source < maxzoom layer). Для ET-013 ключевое: на z9-z11 тайлы **есть на диске** — проблема исключительно в рендеринге. | +| Хватает ли разрешения SRTM 30м на z9-z11? | Да. На z9 1 пиксель тайла ≈ 300м, на z10 ≈ 150м, на z11 ≈ 75м — везде есть запас относительно 30м SRTM. Перепады «теряются» не из-за разрешения данных, а из-за низкого контраста при рендере + отключённого hillshade на z9. | +| Нужен ли отдельный стиль для крупных зумов? | **Нет**, отдельный layer не нужен. Достаточно: (а) снизить UI-минзум hillshade до z9; (б) перевести `raster-opacity` и `raster-contrast` в zoom-aware `interpolate`-выражения; (в) на крупных зумах переключить `raster-resampling` на `nearest`, чтобы перепады были резкими. | + +### 2.3 Почему это бизнес-важно + +- **UX expectation**: пользователь зумит карту чтобы детальнее + посмотреть рельеф — а получает обратное: «было видно — стало + плоско». Это контр-интуитивно и снижает доверие к слою. +- **Целевая задача продукта** (эндуро-планирование): на z9-z11 + пользователь оценивает «насколько холмистая зона между двумя + точками маршрута» — именно этот масштаб ключевой для выбора + направления. Сейчас на этом масштабе слой работает плохо. +- **Низкозатратное исправление**: данные есть, тайлы есть, + логика рендера тривиально дополняется zoom-tier выражениями. + Полезность/стоимость очень высокая. + +### 2.4 Что НЕ делаем (обоснование) + +| Альтернатива | Решение | Причина | +|---|---|---| +| Перегенерировать hillshade с z-factor 2.5-3.0 для z9-z14 | **Out of scope.** | Требует доступа к infra-pipeline SRTM, пересборки и редеплоя растровых тайлов. Если frontend-калибровки (F-02..F-05) недостаточно — отдельный work item «hillshade-rerender-z9-z14». | +| Добавить векторные горизонтали (contours) | **Out of scope.** | Контуров в стэке нет. Это новая фича уровня PH-6.5, требует pipeline на отдельных vector tiles. | +| Перейти на MapLibre `hillshade` layer (raster-dem) | **Out of scope.** | Требует поднять DEM в формате Terrarium/Mapbox-RGB. Это смена архитектуры рельефа. | +| Multidirectional hillshade (4 азимута) | **Out of scope.** | Требует пересборки тайлов и комбинирования; см. строку 1. | +| Подключить гипсометрию в UI на z9-z11 | **Out of scope.** | Hypso тайлы есть на диске, но UI не имеет переключателя — отдельная задача. | +| Менять PH-6 параметры hillshade (azimuth/altitude) | **Out of scope.** | Это калибровка генератора, не клиентская проблема. | + +## 3. Scope + +### In scope + +| # | Функция | +| ----- | ---------------------------------------------------------------------------------------------------- | +| F-01 | Понизить UI-минзум hillshade с 10 до **9** в `updateHillshadeAvailability` (тайлы z9 есть на диске). | +| F-02 | Понизить `minzoom` источника `terrain-hillshade-source` с 10 до 9 (через изменение вызова `applyTerrainLayer`). | +| F-03 | Опционально: обновить UI-hint «Зум 10+» → «Зум 9+» в `#terrain-hillshade-hint`. | +| F-04 | Расширить `applyTerrainLayer` так, чтобы параметр `opacity` мог быть либо числом (текущий контракт), либо MapLibre `interpolate`-выражением. Никаких новых публичных функций. | +| F-05 | Для hillshade использовать `raster-opacity` zoom-aware: 9→0.65, 10→0.60, 11→0.55, 12→0.50, 14→0.40. Цель: компенсировать «бледность» теней на z9-z11. | +| F-06 | Для hillshade добавить `raster-contrast` zoom-aware: 9→0.40, 10→0.35, 11→0.30, 12→0.15, 14→0.00. Цель: подчеркнуть перепады без перегенерации. | +| F-07 | Для hillshade установить `raster-resampling: 'nearest'` на z9-z11 (т.е. везде, где `raster-resampling` не игнорируется). Цель: резкие края перепадов вместо размытия. Сейчас стоит `'linear'`. Замечание: MapLibre не поддерживает интерполяцию `raster-resampling` по зуму, поэтому компромисс — глобально `'nearest'` для hillshade на всех зумах ≥ 9. На z12+ это допустимо (текстура остаётся читаемой при overzoom). | +| F-08 | Для TRI («Перепады») использовать `raster-opacity` zoom-aware: 5→0.55, 7→0.65, 8→0.70 (как сейчас), 9→0.80, 10→0.85, 11→0.85, 12→0.75, 15→0.70. Цель: усилить TRI ровно на z9-z11 (как компенсацию за рывок hillshade), не трогая z8 и не превращая карту в кашу на z5-z7. | +| F-09 | Для TRI установить `raster-resampling: 'nearest'`. TRI — категориальная классификация (5 уровней), линейный ресемпл размывает границы классов. Цель: резкие границы «спокойно/шероховато». | +| F-10 | UI: контракт переключателей «Тени рельефа» / «Перепады» в `#terrain-popup` не меняется. Чекбоксы, persistence в localStorage (`terrain-hillshade`, `terrain-tri`) — без изменений. | +| F-11 | Регрессия z8: визуально слой «Перепады» на z8 выглядит как раньше (opacity 0.70). | +| F-12 | Регрессия z12-z15: hillshade и TRI не становятся темнее/контрастнее, чем были (calibration возвращается к старым значениям к z14). | +| F-13 | Регрессия performance: количество запросов растровых тайлов на сессию не должно вырасти больше, чем на +35% (грубая оценка: +1 zoom-уровень для hillshade на z9 добавляет ~25% тайлов на сессию активного зумирования). | +| F-14 | Документация: ADR не нужен (это калибровка, не архитектурное решение). Опциональный `06-adr/` остаётся пустым. Изменения покрываются TRZ и комментарием в коде, ссылающимся на ET-013. | + +### Out of scope + +- **Перегенерация hillshade с большим z-factor** (отдельная задача, см. §2.4). +- **Добавление векторных горизонталей** (отдельная задача). +- **Переход на raster-dem / Mapbox Terrain RGB** (смена архитектуры). +- **Multidirectional hillshade** (требует pipeline). +- **Подключение гипсометрии в UI** (отдельная задача). +- **Изменение PH-6 параметров hillshade на сервере** (azimuth, altitude, z-factor). +- **Изменение генератора TRI** (классификация, цветовая шкала). +- **Тайл-кэш на стороне сервера** (раздача через FastAPI с `Cache-Control: max-age=31536000` уже есть). +- **Изменение UI чекбоксов** (только текст hint'а в F-03). +- **Изменение TERRAIN_DIR / endpoint contract** (`src/api/main.py:1240-1255`). +- **Изменения PWA / offline-кэш стратегии для тайлов** (PH-9, не сейчас). + +## 4. Метрики успеха + +| # | Метрика | Критерий | +| --- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| M-1 | Hillshade доступен на z9 | Чекбокс «Тени рельефа» при `zoom = 9` **не disabled**; hint скрыт; vector-source запрашивает тайлы при включении. | +| M-2 | Hillshade-opacity zoom-aware | `paint['raster-opacity']` для слоя `terrain-hillshade` — `interpolate`-выражение со stops для z9, z10, z11, z12, z14. | +| M-3 | Hillshade-contrast zoom-aware | `paint['raster-contrast']` — `interpolate`-выражение с положительными значениями на z9-z11 и 0 на z14. | +| M-4 | Hillshade-resampling | `paint['raster-resampling']` для `terrain-hillshade` = `'nearest'`. | +| M-5 | TRI-opacity zoom-aware | `paint['raster-opacity']` для `terrain-tri` — `interpolate`-выражение со stops для z5..z15. | +| M-6 | TRI-resampling | `paint['raster-resampling']` для `terrain-tri` = `'nearest'`. | +| M-7 | Регрессия z8 | На z8 видимость слоя «Перепады» (TRI) визуально не отличается от состояния до ET-013 (opacity stops содержат точку `8 → 0.70`). | +| M-8 | Регрессия z14-z15 | На z14 hillshade visually близок к до-ET-013 (opacity ~0.40, contrast ~0). | +| M-9 | Качественный тест z9-z11 | На скриншоте z10 над холмистым районом (например, юг Москвы / Ока) перепады «явно различимы» — критерий ручной (TC-UI-04-Z10-Q). При отказе — донастройка stops. | +| M-10 | Сетевой объём | При типичной сессии (10 зумов между z8 и z12 c включёнными обоими слоями) объём загруженных PNG-тайлов hillshade и TRI вырос не более чем на 35%. | + +## 5. Риски + +| # | Риск | Вероятность | Влияние | Митигация | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R-1 | `raster-contrast` со значением 0.4 даёт «жесть» — пересвет/чернота на тёмных тайлах. | Средняя | Среднее | TC-UI-04-Z10-Q — визуальная приёмка. При проблеме — снизить contrast в stops до 0.25-0.30. F-06 — точки калибруются итеративно. | +| R-2 | На тёмной теме (`theme-dark`, ET-007) hillshade при opacity 0.65 и contrast 0.4 сливается с подложкой в кашу. | Средняя | Среднее | TC-UI-09-Z10-DARK-Q. При проблеме — добавить отдельные stops для dark-theme через `theme-change` event. Прозрачнее (например 0.55 вместо 0.65) на dark. | +| R-3 | На спутниковой подложке (ET-007) opacity 0.65 + contrast 0.4 слишком «глушит» космоснимок. | Низкая | Среднее | TC-UI-08-Z10-SAT-Q. Hillshade на спутнике пользователю обычно не нужен — он использует подложку как замену рельефу. Если визуально некрасиво — на спутнике hillshade оставить opacity 0.40 (старое поведение). | +| R-4 | Снижение UI-минзума hillshade до 9 раздувает сетевой трафик (z9 тайл = 4× больше z8 → область покрывается 4× меньшим числом тайлов, но каждый сессия теперь видит на 1 zoom-уровень больше). | Низкая | Низкое | M-10 (≤ +35%). На практике пользователь либо «включил и не двигается», либо «зумит — тайлы кэшируются». nginx и браузер кэшируют PNG агрессивно (Cache-Control: immutable, см. main.py:1252). | +| R-5 | `raster-resampling: 'nearest'` на overzoom (z12-z15) даёт «пикселизацию», крупные квадраты вместо плавных теней. | Средняя | Низкое | TC-UI-06-Z14-Q. На z12-z14 пользователь обычно отключает hillshade — для города нужна подложка. Если визуально плохо — переключить на `'linear'` на z12+ через JS-логику (отдельный layer). В MVP оставляем `'nearest'`. | +| R-6 | Изменение opacity TRI на z9-z11 (с 0.7 до 0.85) перекрывает грунтовки / тропы (`trails-track`, `trails-path-bridleway`). | Низкая | Низкое | `applyTerrainLayer` уже вставляет terrain-слои **перед** первым слоем `trails-*` или `poi-*` (`src/web/app.js:3337-3339`). z-order остаётся правильным. | +| R-7 | После изменения paint-выражения старый clients (вкладка в браузере) видит «сломанный стиль» при F5. | Очень низкая| Низкое | Простой релоад страницы решает (стили задаются в JS, не в localStorage). Никакой миграции состояния не требуется. | +| R-8 | `interpolate` с `raster-contrast` плохо поддерживается старыми версиями MapLibre. | Низкая | Низкое | MapLibre 4.7.0 (`unpkg.com/maplibre-gl@4.7.0`, см. index.html:10) поддерживает `interpolate` для всех raster paint-properties. | +| R-9 | TRI на z5-z7 при увеличении opacity на крупных зумах остаётся как было — но без stops для z5/z6/z7 может «прыгнуть». | Низкая | Низкое | F-08 явно задаёт stops для z5, z7, z8 — сохранение прежнего поведения на z5-z7. interpolate-линейный гарантирует гладкость. | +| R-10 | Цвета TRI (категориальная палитра) на nearest-resampling показывают резкие границы 30-метровых клеток SRTM — выглядит «зернисто». | Средняя | Низкое | Это и есть желаемое поведение: пользователь видит «реальные» границы перепадов, а не сглаженный туман. Если визуально не нравится — оставить `'linear'` для TRI (откатить F-09). | +| R-11 | Если на test-среде тайлы z9-z11 не нарезаны (расхождение с PH-6 BRD), при включении hillshade на z9 будут 404. | Низкая | Высокое | Pre-implementation check: `curl https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/X/Y.png` должен вернуть 200. Если 404 — задача делится: сначала догенерить тайлы (PH-6 follow-up), потом ET-013. | + +## 6. Зависимости + +### Frontend +- `src/web/app.js`: + - `onTerrainCheckbox` (~2782): вызовы `applyTerrainLayer`. + - `applyTerrainLayer` (~3316): расширить, чтобы принимать opacity-выражение и paint-объект. + - `updateHillshadeAvailability` (~3359): сменить порог `< 10` на `< 9`. +- `src/web/index.html`: + - `#terrain-hillshade-hint` (строка 60): обновить текст «Зум 10+» → «Зум 9+». +- Стили карты `style.json`/`style-dark.json` — без изменений (растровые слои не описаны в стилях, они добавляются динамически из JS). + +### Backend +- `src/api/main.py:1240-1255` (`terrain_tile`) — **без изменений**. Никаких новых endpoint, query, заголовков. + +### Тесты +- Новые unit-тесты `tests/unit/test_terrain_paint.py` (новый файл) — проверка структуры paint-выражений (stops, типы значений). Запуск через Node/jsdom либо чистый JS-парсер MapLibre style spec (см. TRZ §3.13). +- Расширение существующих тестов слоёв (если есть). На текущий момент в репо нет тестов для `applyTerrainLayer` — добавляем минимальные. +- UI-тесты: `04b-ui-test-cases.md`. + +### Документация +- `01-brd.md` (этот файл). +- `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `04b-ui-test-cases.md`. +- ADR не требуется (это калибровка paint-параметров, не архитектурное решение). Если в реализации возникнет нужда в добавлении dark/satellite-specific paint-таблиц — добавляется `06-adr/adr-0001-theme-specific-terrain.md`. + +### Инфра / Данные +- Test-среда `https://openclaw.mva154.duckdns.org/enduro/` — существующий деплой. +- Растровые тайлы рельефа в `/home/slin/enduro-trails/data/terrain/{hillshade,tri}/{z}/{x}/{y}.png` — **существующие**, без перегенерации. +- **Обязательная pre-implementation проверка**: тайлы hillshade z9 и z10 над ЦФО действительно доступны (R-11). + ```bash + curl -I https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png + curl -I https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png + ``` + Ожидается HTTP 200 на оба. + +### Связи с другими work items +- **PH-6.terrain** — родительская фаза. ET-013 — post-MVP калибровка её UI. +- **ET-007** — переключатель подложки Схема/Спутник. R-3 покрывает совместимость. +- **ET-009 / ET-008** — публичные GPS-треки. Не пересекаются (отдельные источники и слои). +- Будущий work item «hillshade-rerender-z9-z14 с z-factor 2.5» — на случай, если frontend-калибровки недостаточно. + +## 7. План в одну строку + +Снижаем UI-минзум hillshade с 10 до 9, переводим `raster-opacity` и +`raster-contrast` hillshade в zoom-aware `interpolate`-выражения +с пиком контраста на z9-z11, аналогично усиливаем opacity TRI на +z9-z11, переключаем `raster-resampling` на `'nearest'` — без +перегенерации растровых тайлов и без изменения backend. diff --git a/docs/work-items/ET-013/02-trz.md b/docs/work-items/ET-013/02-trz.md new file mode 100644 index 0000000..49a5e84 --- /dev/null +++ b/docs/work-items/ET-013/02-trz.md @@ -0,0 +1,606 @@ +--- +type: trz +work_item_id: ET-013 +title: "ТЗ: Перепады высот на z9-z11 — zoom-aware paint для hillshade и TRI" +version: 1 +status: draft +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:analyst" +related: + - "PH-6.terrain" + - "ET-007" +--- + +# ТЗ — ET-013: Перепады высот на z9-z11 + +## 1. Терминология + +- **Hillshade** — растровый слой теневого рельефа из + `/terrain/hillshade/{z}/{x}/{y}.png`. MapLibre layer id — + `terrain-hillshade`, source id — `terrain-hillshade-source`. +- **TRI** («Перепады») — растровый слой Terrain Ruggedness Index + из `/terrain/tri/{z}/{x}/{y}.png`. Layer id — `terrain-tri`, + source id — `terrain-tri-source`. +- **Zoom-tier paint** — MapLibre `interpolate`-выражение со + stops по `['zoom']`, задаёт значение paint-property как функцию + текущего зума. +- **Raster paint properties** (MapLibre spec): + - `raster-opacity` ∈ [0, 1] — прозрачность слоя. + - `raster-contrast` ∈ [-1, 1] — усиление контраста PNG; 0 — без изменений, > 0 — усиление, < 0 — снижение. + - `raster-resampling` ∈ `{'linear', 'nearest'}` — алгоритм + масштабирования тайла на пиксели экрана. `'nearest'` даёт + «пиксельные» резкие границы. +- **UI-минзум hillshade** — порог в `updateHillshadeAvailability`, + ниже которого чекбокс «Тени рельефа» disabled. Сейчас 10, после ET-013 — 9. + +## 2. Архитектурные опоры + +ET-013 не вводит новых слоёв, источников, endpoint'ов. Используем: + +- `src/web/app.js`: + - константа `TERRAIN_BASE_URL` (~2726) — без изменений. + - `onTerrainCheckbox` (~2766) — без изменений сигнатуры; меняются + параметры внутри вызовов `applyTerrainLayer`. + - `applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom)` (~3316) — + расширяется (см. REQ-F-04). + - `updateHillshadeAvailability` (~3359) — порог `< 10` → `< 9`. + - `restoreTerrainState` (~3379) — без изменений (вызывает onTerrainCheckbox). +- `src/web/index.html`: + - `#terrain-hillshade-hint` (строка 60) — текст «Зум 10+» → «Зум 9+». +- `src/api/main.py:1240` (`terrain_tile`) — **без изменений**. + +ET-013 = **9 правок: 2 в HTML/text, 7 в одном JS-файле**. + +## 3. Требования + +### REQ-F-01 — Снизить UI-минзум hillshade до 9 + +Файл `src/web/app.js`, функция `updateHillshadeAvailability` +(строка ~3368): + +```js +if (zoom < 10) { +``` +заменить на +```js +if (zoom < 9) { // ET-013: на z9 hillshade уже доступен +``` + +**Acceptance check.** При `window._map.setZoom(9)` чекбокс +`#terrain-hillshade-cb` имеет `disabled === false` и hint +`#terrain-hillshade-hint` имеет `display: 'none'`. + +### REQ-F-02 — Снизить minzoom source `terrain-hillshade-source` до 9 + +Файл `src/web/app.js`, функция `onTerrainCheckbox` (строка ~2782). +Заменить: +```js +applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', + hillshadeChecked, 0.40, 10, 15); +``` +на: +```js +// ET-013: hillshade теперь доступен с z9; opacity и contrast — zoom-aware +applyTerrainLayer('terrain-hillshade', + TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', + hillshadeChecked, + HILLSHADE_PAINT, // см. REQ-F-04, REQ-F-05 + 9, 15); +``` + +**Acceptance check.** В DevTools после включения слоя: +```js +window._map.getSource('terrain-hillshade-source').minzoom === 9 +``` + +### REQ-F-03 — Снизить minzoom source `terrain-tri-source` остаётся 5 + +Файл `src/web/app.js`, строка ~2783. Менять только параметр +opacity (см. REQ-F-08). minzoom/maxzoom не трогаем: + +```js +applyTerrainLayer('terrain-tri', + TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', + triChecked, + TRI_PAINT, // см. REQ-F-04, REQ-F-08 + 5, 15); +``` + +### REQ-F-04 — Расширить `applyTerrainLayer` для поддержки paint-объекта + +Файл `src/web/app.js`, функция `applyTerrainLayer` (строки ~3316-3357). + +Текущая сигнатура: +```js +function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) { + ... + paint: { 'raster-opacity': opacity, 'raster-resampling': 'linear' }, + ... +} +``` + +Новая сигнатура (обратно-совместимая): +```js +/** + * @param {string} id - id слоя. + * @param {string} tileUrl - URL-шаблон тайлов. + * @param {boolean} enabled - показывать ли слой. + * @param {number|object} opacityOrPaint - либо число (старый контракт, + * станет 'raster-opacity'), либо объект paint-properties целиком. + * Если объект — должен содержать как минимум 'raster-opacity'. + * @param {number} minzoom + * @param {number} maxzoom + */ +function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom) { + const map = window._map; + if (!map) return; + const sourceId = id + '-source'; + + // ET-013: нормализация paint + const paint = (typeof opacityOrPaint === 'number') + ? { 'raster-opacity': opacityOrPaint, 'raster-resampling': 'linear' } + : opacityOrPaint; + + if (enabled) { + if (!map.getSource(sourceId)) { + map.addSource(sourceId, { + type: 'raster', + tiles: [tileUrl], + tileSize: 256, + scheme: 'tms', + minzoom: minzoom, + maxzoom: maxzoom + }); + } + if (!map.getLayer(id)) { + const firstTrailLayer = map.getStyle().layers.find(l => + l.id.startsWith('trails-') || l.id.startsWith('poi-') + ); + map.addLayer({ + id: id, + type: 'raster', + source: sourceId, + paint: paint, + minzoom: minzoom, + maxzoom: maxzoom + }, firstTrailLayer ? firstTrailLayer.id : undefined); + } + } else { + if (map.getLayer(id)) map.removeLayer(id); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } +} +``` + +**Acceptance check.** Unit-тест (см. REQ-F-13): +- `applyTerrainLayer(id, url, true, 0.5, 8, 14)` — старый контракт работает. +- `applyTerrainLayer(id, url, true, {'raster-opacity': 0.5, 'raster-contrast': 0.3, 'raster-resampling': 'nearest'}, 8, 14)` — paint применён как есть. + +### REQ-F-05 — Hillshade `raster-opacity` zoom-aware + +Файл `src/web/app.js`, после определения `TERRAIN_BASE_URL` (после строки ~2726) +добавить блок констант: + +```js +// ET-013: zoom-aware paint для слоёв рельефа. +// Цель — компенсировать «потерю выразительности» перепадов на z9-z11. +// Pre-z9 — hillshade не показывается (UI-минзум). На z9-z11 — максимальный +// контраст и opacity, чтобы тени читались как на z8. К z12-z14 — возврат +// к исходным значениям (тогда у пользователя есть другие способы +// читать рельеф: подложка, грунтовки, POI). + +const HILLSHADE_PAINT = { + 'raster-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 9, 0.65, + 10, 0.60, + 11, 0.55, + 12, 0.50, + 14, 0.40 + ], + 'raster-contrast': [ + 'interpolate', ['linear'], ['zoom'], + 9, 0.40, + 10, 0.35, + 11, 0.30, + 12, 0.15, + 14, 0.00 + ], + 'raster-resampling': 'nearest' +}; +``` + +Stops подобраны так: +- z9-z11 — пик opacity (0.65→0.55) и contrast (0.40→0.30). Это + компенсация: тени темнее и контрастнее. +- z12-z14 — плавный возврат к исходному (opacity 0.40, contrast 0): + на крупных зумах пользователь уже видит подложку детально и + тени должны «уйти на второй план». +- `'nearest'` resampling: подчёркивает 30-метровые границы SRTM, + перепады выглядят резко. + +**Acceptance check.** +```js +const layer = window._map.getLayer('terrain-hillshade'); +const opacity = window._map.getPaintProperty('terrain-hillshade', 'raster-opacity'); +Array.isArray(opacity) && opacity[0] === 'interpolate' // true +``` + +### REQ-F-06 — Hillshade `raster-contrast` (внутри HILLSHADE_PAINT) + +См. REQ-F-05. Constants выносятся в HILLSHADE_PAINT, отдельной правки кода не нужно. + +### REQ-F-07 — Hillshade `raster-resampling: 'nearest'` + +См. REQ-F-05. Часть HILLSHADE_PAINT. + +### REQ-F-08 — TRI `raster-opacity` zoom-aware + +В том же блоке (после HILLSHADE_PAINT, до `function toggleTerrainPopup`): + +```js +const TRI_PAINT = { + 'raster-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 5, 0.55, + 7, 0.65, + 8, 0.70, // регрессия z8: текущее значение + 9, 0.80, + 10, 0.85, + 11, 0.85, // пик на z9-z11 + 12, 0.75, + 15, 0.70 + ], + 'raster-resampling': 'nearest' +}; +``` + +Stops: +- **z5-z7** — мягко (0.55-0.65), на «обзорных» зумах не глушим карту. +- **z8** — 0.70 ровно как сейчас (регрессия). +- **z9-z11** — пик 0.80-0.85 (целевое улучшение ET-013). +- **z12-z15** — спад до 0.70-0.75. + +**Acceptance check.** +```js +const opacity = window._map.getPaintProperty('terrain-tri', 'raster-opacity'); +// На z8 — 0.70 ровно (регрессия). +// На z10 — 0.85 ровно (целевое поведение). +``` + +### REQ-F-09 — TRI `raster-resampling: 'nearest'` + +Часть TRI_PAINT, см. REQ-F-08. + +### REQ-F-10 — Обновить UI-hint текст + +Файл `src/web/index.html`, строка ~60: +```html + +``` +заменить на +```html + +``` + +### REQ-F-11 — `updateHillshadeAvailability` использует новый порог + +См. REQ-F-01. Никаких других изменений в этой функции не нужно. + +### REQ-F-12 — Сохранить контракт `onTerrainCheckbox` + +Сигнатура и логика persistence в `localStorage` (`terrain-hillshade`, +`terrain-tri`) — без изменений. Кнопка `#terrain-toggle` `.active` +переключается так же. + +### REQ-F-13 — Unit-тесты paint-выражений + +Файл `tests/unit/test_terrain_paint.js` (новый; если JS-тесты раньше +не было — настроить vitest/jest в `package.json` либо использовать +существующий тест-раннер; альтернатива — Python-парсер JSON-выражений). + +Реализация в одной из двух форм: + +**Вариант A: JS unit-тест (jest/vitest)** + +```js +// tests/unit/test_terrain_paint.test.js +import { HILLSHADE_PAINT, TRI_PAINT } from '../../src/web/terrain-paint.js'; +// Если константы внутри app.js: либо вынести в отдельный модуль, +// либо использовать AST-парсер. См. альтернативу B. + +describe('ET-013 terrain paint', () => { + test('HILLSHADE_PAINT: raster-opacity is interpolate by zoom', () => { + const op = HILLSHADE_PAINT['raster-opacity']; + expect(op[0]).toBe('interpolate'); + expect(op[1][0]).toBe('linear'); + expect(op[2][0]).toBe('zoom'); + // stops: ..., 9, 0.65, 10, 0.60, 11, 0.55, 12, 0.50, 14, 0.40 + const stops = op.slice(3); + expect(stops).toContain(9); + expect(stops[stops.indexOf(9) + 1]).toBeCloseTo(0.65, 2); + expect(stops[stops.indexOf(11) + 1]).toBeCloseTo(0.55, 2); + expect(stops[stops.indexOf(14) + 1]).toBeCloseTo(0.40, 2); + }); + + test('HILLSHADE_PAINT: raster-contrast peak at z9-z11', () => { + const c = HILLSHADE_PAINT['raster-contrast']; + expect(c[0]).toBe('interpolate'); + const stops = c.slice(3); + expect(stops[stops.indexOf(9) + 1]).toBeGreaterThanOrEqual(0.35); + expect(stops[stops.indexOf(14) + 1]).toBeLessThanOrEqual(0.05); + }); + + test('HILLSHADE_PAINT: resampling nearest', () => { + expect(HILLSHADE_PAINT['raster-resampling']).toBe('nearest'); + }); + + test('TRI_PAINT: z8 unchanged (regression)', () => { + const op = TRI_PAINT['raster-opacity']; + const stops = op.slice(3); + expect(stops[stops.indexOf(8) + 1]).toBeCloseTo(0.70, 2); + }); + + test('TRI_PAINT: peak at z9-z11', () => { + const op = TRI_PAINT['raster-opacity']; + const stops = op.slice(3); + expect(stops[stops.indexOf(10) + 1]).toBeGreaterThanOrEqual(0.80); + expect(stops[stops.indexOf(11) + 1]).toBeGreaterThanOrEqual(0.80); + }); + + test('TRI_PAINT: resampling nearest', () => { + expect(TRI_PAINT['raster-resampling']).toBe('nearest'); + }); +}); +``` + +**Вариант B: Python-парсер (если JS-тестов в проекте нет)** + +```python +# tests/unit/test_terrain_paint.py +import re +from pathlib import Path + +APP_JS = Path(__file__).parents[2] / 'src/web/app.js' + +def test_hillshade_paint_exists(): + txt = APP_JS.read_text(encoding='utf-8') + assert 'HILLSHADE_PAINT' in txt + assert "'raster-opacity'" in txt + assert "'raster-contrast'" in txt + assert "'raster-resampling': 'nearest'" in txt + +def test_hillshade_opacity_stops(): + """Сверяем stops по grep — недостаточно строго, но удержит регрессию.""" + txt = APP_JS.read_text(encoding='utf-8') + # ищем блок HILLSHADE_PAINT и проверяем stop'ы + m = re.search(r"HILLSHADE_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL) + assert m, "HILLSHADE_PAINT not found" + block = m.group(1) + assert '9, 0.65' in block or '9, 0.65' in block + assert '11, 0.55' in block + assert '14, 0.40' in block + +def test_tri_opacity_regression_z8(): + txt = APP_JS.read_text(encoding='utf-8') + m = re.search(r"TRI_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL) + assert m + block = m.group(1) + assert '8, 0.70' in block or '8, 0.70' in block, "z8 opacity должна остаться 0.70" + assert '10, 0.85' in block +``` + +**Решение по умолчанию для ET-013:** Вариант B (Python-парсер), +т.к. в проекте JS-тестов не существует, а ставить vitest ради ET-013 +— превышение scope. Опционально разработчик может выбрать Вариант A. + +### REQ-F-14 — Регрессионные тесты + +Файл `tests/unit/test_terrain_paint.py` (тот же файл, что и REQ-F-13): + +- **UT-REG-01.** Проверить, что вызов `applyTerrainLayer` с числовым + `opacity` (старый контракт) собирает paint `{raster-opacity: X, raster-resampling: 'linear'}` — + на случай, если другой код (POI, halo, scenic) использует ту же + функцию. На текущий момент `applyTerrainLayer` вызывается **только** + внутри `onTerrainCheckbox` — но контракт должен оставаться обратно-совместимым. + + Реализация — статический grep по `src/web/`: + ```python + import re, glob + def test_only_two_callers_of_applyterrainLayer(): + pattern = re.compile(r'applyTerrainLayer\s*\(') + total = 0 + for f in glob.glob('src/web/*.js'): + total += len(pattern.findall(open(f).read())) + assert total >= 2 # минимум 2 вызова в onTerrainCheckbox + ``` + +- **UT-REG-02.** `updateHillshadeAvailability` порог = 9 + (grep по строке `zoom < 9`). + +### REQ-F-15 — Integration smoke-тест: тайлы z9 доступны + +Файл `tests/integration/test_terrain_z9_tiles.py` (новый): + +- **IT-TILE-Z9-01.** При наличии `data/terrain/hillshade/9/` + директории — запрос `GET /terrain/hillshade/9/308/158.png` + возвращает 200, content-type `image/png`. Если директория + не существует — тест **skipped** с пояснением. + ```python + import os, pytest + from fastapi.testclient import TestClient + from src.api.main import app + + TERRAIN_DIR = os.environ.get( + 'TERRAIN_DIR', os.path.join(os.path.dirname(__file__), '../../data/terrain') + ) + + client = TestClient(app) + + @pytest.mark.skipif( + not os.path.isdir(os.path.join(TERRAIN_DIR, 'hillshade/9')), + reason='hillshade z9 tiles not present in CI (PH-6 data not in repo)' + ) + def test_hillshade_z9_tile_returns_200(): + # Любой существующий тайл из директории + z9_dir = os.path.join(TERRAIN_DIR, 'hillshade/9') + x = sorted(os.listdir(z9_dir))[0] + y_file = sorted(os.listdir(os.path.join(z9_dir, x)))[0] + y = y_file.replace('.png', '') + r = client.get(f'/terrain/hillshade/9/{x}/{y}.png') + assert r.status_code == 200 + assert r.headers['content-type'] == 'image/png' + + def test_hillshade_invalid_zoom_404(): + r = client.get('/terrain/hillshade/99/0/0.png') + assert r.status_code == 404 + ``` + +### REQ-F-16 — UI-тесты Playwright + +См. `04b-ui-test-cases.md`. Ключевые проверки (полный список — там): + +- TC-UI-01-Z9: hillshade доступен на z9, hint скрыт. +- TC-UI-02-Z8-REGR: на z8 TRI визуально как до ET-013. +- TC-UI-03-Z9-Q: визуальная читаемость перепадов на z9 ≥ z8 (качественно). +- TC-UI-04-Z10-Q: то же для z10. +- TC-UI-05-Z11-Q: то же для z11. +- TC-UI-06-Z14-Q: на z14 hillshade «нормальный», не перегретый. +- TC-UI-07-Z9-MOBILE: мобильный viewport, hillshade видим на z9. +- TC-UI-08-Z10-SAT-Q: совместимость со спутниковой подложкой. +- TC-UI-09-Z10-DARK-Q: совместимость с тёмной темой. +- TC-UI-10-PERSIST: localStorage `terrain-hillshade`/`terrain-tri` + переживает перезагрузку, паттерн чекбоксов восстанавливается. + +### REQ-F-17 — Persistence без миграции + +Ключи `localStorage`: +- `terrain-hillshade` ('1' | '0') — без изменений. +- `terrain-tri` ('1' | '0') — без изменений. + +После ET-013 пользователи с включённым hillshade при следующей +загрузке на z9 увидят слой автоматически (раньше он был disabled). +Это не миграция, а ожидаемое улучшение UX. + +### REQ-F-18 — Не менять API контракт + +`GET /terrain/{layer}/{z}/{x}/{y}.png` — без изменений. Никаких +новых query, headers, кодов ответа. `Cache-Control: immutable` +сохраняется. + +### REQ-F-19 — Не менять конфиги и стили + +- `src/web/style.json`, `src/web/style-dark.json` — без изменений. +- `src/web/app.css` — без изменений (стили чекбоксов не меняются). +- `config/*.yaml` — без изменений. + +### REQ-F-20 — Деплой и валидация + +После merge в `main` и деплоя: + +1. **Pre-merge sanity** (на test-среде до деплоя): + ```bash + curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png | head -1 + ``` + Ожидается `HTTP/1.1 200 OK`. Если 404 — задача останавливается, + тайлы z9 нужно догенерировать в рамках PH-6 follow-up. + +2. **Smoke в test-среде**: + - Открыть карту, центр над Окой/югом Москвы (`[37.6, 54.5]`). + - `window._map.setZoom(9)` — кнопка «Тени рельефа» активна. + - Включить «Тени рельефа» и «Перепады». + - Скриншот → визуальная приёмка по AC-03..AC-05. +3. **Зафиксировать в `14-deploy-log.md`**. + +### REQ-F-21 — Документация + +В `docs/work-items/ET-013/` после Анализа: +- `00-business-request.md` (есть) +- `01-brd.md` +- `02-trz.md` (этот файл) +- `03-acceptance-criteria.md` +- `04-test-plan.yaml` +- `04b-ui-test-cases.md` + +После реализации: `12-review.md`, `13-test-report.md`, +`14-deploy-log.md`. ADR опционально (см. BRD §6). + +## 4. Не-функциональные требования + +### NFR-01 — Производительность клиента +- Добавление двух `interpolate`-выражений в paint не должно + заметно увеличивать render time. MapLibre кэширует + скомпилированные style-выражения; разница < 1 мс на frame. +- `raster-resampling: 'nearest'` дешевле, чем `'linear'` + (без bilinear-фильтрации) — на самом деле небольшое + ускорение растеризации. + +### NFR-02 — Производительность сервера +Без изменений: endpoint `terrain_tile` отдаёт PNG из файловой системы +с `Cache-Control: immutable`. + +### NFR-03 — Сетевой трафик +- При снижении UI-минзума hillshade с 10 до 9 пользователь + может видеть слой на одной zoom-ступени раньше, что добавляет + ~25-35% PNG-тайлов на типичную сессию активного зумирования + с включённым hillshade. +- Browser-кэш + nginx-кэш (`Cache-Control: max-age=31536000, + immutable`) поглощают это после первого визита. +- Регрессия `M-10`: рост ≤ 35%. + +### NFR-04 — Совместимость +- MapLibre 4.7.0 (см. `index.html:10`, `index.html:503`) + поддерживает все используемые paint properties и + `interpolate`-выражения. +- Старые tab'ы (без обновления страницы) продолжают работать + с прежним кодом до перезагрузки. + +### NFR-05 — Безопасность +Никаких изменений в auth / CSP / валидации. + +### NFR-06 — Логирование +Никаких новых лог-сообщений. `uvicorn.access` для `/terrain/*` +работает как раньше. + +### NFR-07 — Persistence +`localStorage` — без миграции. Существующие ключи интерпретируются +как раньше; включённый ранее hillshade автоматически появится на +z9 при следующей загрузке. + +## 5. План работ (для разработчика) + +1. **Pre-implementation check**: проверить наличие тайлов z9-z11 + на test-среде (REQ-F-20 §1). Если 404 — стоп, открыть PH-6 + follow-up. +2. **Frontend constants**: добавить `HILLSHADE_PAINT` и `TRI_PAINT` + (REQ-F-05, F-08) после `TERRAIN_BASE_URL`. +3. **Frontend `applyTerrainLayer`**: расширить сигнатуру (REQ-F-04). +4. **Frontend `onTerrainCheckbox`**: перевести вызовы на константы + (REQ-F-02, F-03). +5. **Frontend `updateHillshadeAvailability`**: порог `< 10` → `< 9` + (REQ-F-01, F-11). +6. **HTML hint**: «Зум 10+» → «Зум 9+» (REQ-F-10). +7. **Тесты**: `tests/unit/test_terrain_paint.py` (REQ-F-13, F-14). +8. **Integration smoke**: `tests/integration/test_terrain_z9_tiles.py` + (REQ-F-15) — с `@pytest.mark.skipif` для CI без данных. +9. **`make lint` / `make test`** — должны пройти. +10. **Code review → merge → deploy в test**. +11. **Ручная валидация** (REQ-F-20 §2). +12. **Playwright UI-тесты** по `04b-ui-test-cases.md`. +13. **Запись в `13-test-report.md` и `14-deploy-log.md`**. + +## 6. Открытые вопросы и решения по умолчанию + +| Вопрос | Решение по умолчанию | +|---|---| +| Стоит ли понижать UI-минзум hillshade ещё дальше (z8)? | **Нет.** На z8 hillshade-тайлы 256px покрывают ~150 км по широте — крупные тени становятся неразборчивым «шумом». TRI работает лучше. Если будущий BRD захочет — отдельная задача. | +| Стоит ли использовать разные paint для тёмной темы (`theme-dark`)? | **Не в MVP.** Если AC-09 (TC-UI-09-Z10-DARK-Q) показывает «слой сливается с подложкой» — добавить ADR-0001 о theme-specific paint в follow-up. | +| Стоит ли использовать разные paint для спутниковой подложки? | **Не в MVP.** Hillshade на спутнике пользователю обычно не нужен — он использует подложку как замену рельефу. Если AC-08 (TC-UI-08-Z10-SAT-Q) показывает «глушит подложку» — отдельная итерация. | +| Стоит ли добавить `raster-saturation` для TRI? | **Не в MVP.** Сначала смотрим на эффект от `raster-opacity` + `'nearest'`. Если визуально недостаточно ярко — добавить второй раунд калибровки. | +| Перегенерировать ли hillshade с z-factor 2.5 для z9-z14? | **Не сейчас.** Отдельная задача в случае, если frontend-калибровка ET-013 не решает проблему (вероятность по моей оценке — низкая). | +| Менять ли `raster-resampling` динамически по зуму? | **Нет.** MapLibre не поддерживает `interpolate` для `raster-resampling`. Глобальное `'nearest'` для обоих слоёв — приемлемый компромисс (см. R-5). | +| Подключить ли гипсометрию в UI? | **Out of scope.** Hypso тайлы есть, но UI-чекбокса нет. Отдельная задача. | +| Делать ли paint-таблицы переменными окружения / config'ом? | **Нет.** Это калибровка, она живёт в коде и меняется коммитом. Конфигурируемость — преждевременная абстракция. | +| Стоит ли добавлять `vitest`/`jest` ради JS-unit-тестов? | **Нет в ET-013.** Используем Python-парсер (Вариант B в REQ-F-13). | diff --git a/docs/work-items/ET-013/03-acceptance-criteria.md b/docs/work-items/ET-013/03-acceptance-criteria.md new file mode 100644 index 0000000..006e4a5 --- /dev/null +++ b/docs/work-items/ET-013/03-acceptance-criteria.md @@ -0,0 +1,236 @@ +--- +type: acceptance-criteria +work_item_id: ET-013 +title: "Acceptance Criteria: Перепады высот на z9-z11" +version: 1 +status: draft +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:analyst" +--- + +# Acceptance Criteria — ET-013 + +Критерии в Gherkin-стиле. Все обязательные. Задача считается +принятой, когда каждый критерий прошёл проверку (автоматическую +в CI или ручную в test-среде). + +## AC-01 — UI-минзум hillshade понижен до 9 + +**Given** ветка `feature/ET-013-z9-z11-z8` после реализации +**When** проверяется код +**Then**: +- В `src/web/app.js` функция `updateHillshadeAvailability` содержит + `if (zoom < 9)` (а не `< 10`). +- В `src/web/index.html` элемент `#terrain-hillshade-hint` содержит + текст «Зум 9+» (а не «Зум 10+»). + +## AC-02 — Vector-source `terrain-hillshade-source` имеет minzoom=9 + +**Given** test-среда после деплоя ET-013, включены оба чекбокса слоёв рельефа +**When** в DevTools выполнить +```js +window._map.getSource('terrain-hillshade-source').minzoom +``` +**Then** результат — `9`. + +## AC-03 — При z=9 hillshade доступен и виден + +**Given** пользователь на test-среде, центр карты над холмистым +районом (например, юг Москвы / Ока: `[37.6, 54.5]`) +**When** установить `window._map.setZoom(9)`, открыть `#terrain-popup`, +включить «Тени рельефа» +**Then**: +- `#terrain-hillshade-cb` имеет `disabled === false`. +- `#terrain-hillshade-hint` имеет `display: 'none'`. +- `window._map.getLayoutProperty('terrain-hillshade', 'visibility') === 'visible'`. +- На карте видны тени рельефа. + +## AC-04 — Hillshade paint zoom-aware + +**Given** включён hillshade на test-среде +**When** в DevTools выполнить +```js +const op = window._map.getPaintProperty('terrain-hillshade', 'raster-opacity'); +const ct = window._map.getPaintProperty('terrain-hillshade', 'raster-contrast'); +const rs = window._map.getPaintProperty('terrain-hillshade', 'raster-resampling'); +``` +**Then**: +- `Array.isArray(op) && op[0] === 'interpolate'` (zoom-aware opacity). +- `Array.isArray(ct) && ct[0] === 'interpolate'` (zoom-aware contrast). +- `rs === 'nearest'`. + +## AC-05 — TRI paint zoom-aware + +**Given** включён TRI на test-среде +**When** в DevTools +```js +const op = window._map.getPaintProperty('terrain-tri', 'raster-opacity'); +const rs = window._map.getPaintProperty('terrain-tri', 'raster-resampling'); +``` +**Then**: +- `Array.isArray(op) && op[0] === 'interpolate'`. +- На z=8 эффективное значение `≈ 0.70` (регрессия). +- На z=10 эффективное значение `≥ 0.80`. +- `rs === 'nearest'`. + +## AC-06 — Регрессия z8: TRI визуально как было + +**Given** test-среда после деплоя +**When** установить `zoom = 8`, включить ТОЛЬКО «Перепады» (без hillshade) +**Then**: +- Скриншот `et013-z8-tri-regress.png` не отличается визуально + заметно от состояния до ET-013 (сравнение оператором). +- Hillshade-слой не присутствует в стиле (`!map.getLayer('terrain-hillshade')`). + +## AC-07 — Качественная читаемость z9 (целевой критерий) + +**Given** test-среда, центр над Окой / Кашира / Воробьёвы Горы +**When** `zoom = 9`, включены оба слоя «Тени рельефа» и «Перепады» +**Then**: +- На скриншоте `et013-z9-readable.png` явно видны перепады + высот: тени по склонам, цветные пятна TRI выделяют шероховатые + зоны. +- Оператор подтверждает: «перепады сопоставимы с z8 или лучше». +- При отказе — корректировка stops в HILLSHADE_PAINT / TRI_PAINT. + +## AC-08 — Качественная читаемость z10 + +**Given** test-среда, аналогично AC-07 +**When** `zoom = 10` +**Then**: то же, что AC-07. + +## AC-09 — Качественная читаемость z11 + +**Given** test-среда, аналогично AC-07 +**When** `zoom = 11` +**Then**: то же, что AC-07. + +## AC-10 — Регрессия z14: hillshade не перегрет + +**Given** test-среда +**When** `zoom = 14`, включён hillshade +**Then**: +- Эффективные значения `raster-opacity ≈ 0.40`, `raster-contrast ≈ 0`. +- Скриншот `et013-z14-regress.png` не темнее и не контрастнее, чем + до ET-013. + +## AC-11 — Hillshade на тёмной теме читается + +**Given** test-среда, `theme-dark` активна +**When** `zoom = 10`, включён hillshade +**Then**: +- Тени видны, не сливаются с тёмной подложкой. +- При отказе (тени «съедают» карту) — открыть ADR + «theme-specific hillshade paint» и добавить отдельные stops + для dark-theme (см. BRD R-2). В рамках MVP ET-013 это + не обязательно, но фиксируется в `13-test-report.md`. + +## AC-12 — Hillshade на спутниковой подложке не глушит снимок + +**Given** test-среда, переключена подложка `#base-btn-satellite` +**When** `zoom = 10`, включён hillshade +**Then**: +- На спутниковом снимке видны и детали поверхности (рельеф + улавливается уже через тени снимка), и hillshade-оверлей. +- Оверлей не превращает снимок в «серую плёнку». +- Подтверждается оператором по TC-UI-08-Z10-SAT-Q. + +## AC-13 — Hillshade на мобильном (375×667) + +**Given** Playwright mobile viewport, включён hillshade +**When** `zoom = 9` +**Then**: +- Тени видны, читаемы. +- Чекбоксы и hint работают корректно. + +## AC-14 — Persistence не сломан + +**Given** включены оба чекбокса +**When** перезагрузить страницу (`location.reload()`) +**Then**: +- `localStorage.getItem('terrain-hillshade') === '1'`. +- `localStorage.getItem('terrain-tri') === '1'`. +- После загрузки слои восстановлены, на z=9 hillshade автоматически + активен. + +## AC-15 — Unit-тесты paint-выражений зелёные + +**Given** ветка +**When** `pytest tests/unit/test_terrain_paint.py -v` +**Then** все тесты проходят (UT-PAINT-*, UT-REG-*). + +## AC-16 — Integration smoke z9 тайлов + +**Given** ветка, наличие данных в test-среде или CI fixture +**When** `pytest tests/integration/test_terrain_z9_tiles.py -v` +**Then**: +- При наличии тайлов `data/terrain/hillshade/9/*` — тесты + проходят: 200 на существующий тайл, 404 на невалидный zoom. +- При отсутствии тайлов в CI — тесты `skipped` с reason. + +## AC-17 — Регрессионные тесты ET-007 / PH-6 + +**Given** ветка +**When** `pytest tests/unit/ tests/integration/ -v` +**Then**: +- Все существующие тесты ET-007 (переключатель Схема/Спутник) + и PH-6 проходят без регрессий. +- Никакие тесты grandfather'ов не отвалились. + +## AC-18 — `make lint` и `make test` зелёные + +**Given** ветка +**When** `make lint && make test` +**Then** exit-code 0 на обе команды. + +## AC-19 — Pre-deploy проверка наличия тайлов z9-z11 + +**Given** ветка готова к merge +**When** на test-среде +```bash +curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png +curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png +curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/11/1234/635.png +``` +**Then** все три запроса возвращают HTTP 200. Если 404 на любой — +merge приостанавливается, открывается PH-6 follow-up (догенерить +тайлы). + +## AC-20 — Документация полная + +**Given** репо после слияния ET-013 +**When** проверка `docs/work-items/ET-013/` +**Then** существуют: +- `00-business-request.md` +- `01-brd.md` +- `02-trz.md` +- `03-acceptance-criteria.md` +- `04-test-plan.yaml` +- `04b-ui-test-cases.md` +- `12-review.md` (после Review) +- `13-test-report.md` (после Тестирования) +- `14-deploy-log.md` (после Деплоя) + +## AC-21 — Сетевая регрессия (M-10) + +**Given** test-среда +**When** сценарий: открыть карту, центр над Окой, выполнить +zoom-последовательность z=8 → z=9 → z=10 → z=11 → z=10 → z=9 → z=8 +с включёнными обоими слоями +**Then**: +- Суммарный network-traffic PNG-тайлов рельефа ≤ 135% от того же + сценария до ET-013 (зафиксированного как baseline в + `13-test-report.md`). +- Никаких сторонних запросов (например, 4xx или 5xx) не возникает. + +## AC-22 — Контракт `applyTerrainLayer` обратно-совместим + +**Given** ветка +**When** unit-тест UT-PAINT-COMPAT-01 +**Then**: +- Вызов `applyTerrainLayer(id, url, true, 0.5, 8, 14)` + (старый контракт — число) собирает paint: + `{ 'raster-opacity': 0.5, 'raster-resampling': 'linear' }`. +- Вызов с object'ом передаёт paint как есть. diff --git a/docs/work-items/ET-013/04-test-plan.yaml b/docs/work-items/ET-013/04-test-plan.yaml new file mode 100644 index 0000000..0ee4ba8 --- /dev/null +++ b/docs/work-items/ET-013/04-test-plan.yaml @@ -0,0 +1,336 @@ +--- +type: test-plan +work_item_id: ET-013 +title: "Test Plan: Перепады высот на z9-z11" +version: 1 +status: draft +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:analyst" +related: + - "PH-6.terrain" + - "ET-007" + +scope_note: > + ET-013 — frontend-калибровка: понижает UI-минзум hillshade с 10 до 9 + и переводит paint-параметры (raster-opacity, raster-contrast, + raster-resampling) hillshade и TRI в zoom-aware форму. Backend + и pipeline растровых тайлов не трогаются. Тест-план фокусируется + на: + (1) корректности новых zoom-tier paint-выражений; + (2) обратной совместимости applyTerrainLayer; + (3) визуальной читаемости перепадов на z9-z11; + (4) регрессии z8 (TRI не изменился), z14 (hillshade не перегрет); + (5) совместимости с тёмной темой и спутниковой подложкой; + (6) что network-объём не уплыл больше +35%. + +test_suites: + + - name: unit-terrain-paint + type: unit + description: "Структура paint-выражений HILLSHADE_PAINT и TRI_PAINT" + cases: + - id: UT-PAINT-HS-OPACITY + name: "HILLSHADE_PAINT: raster-opacity — interpolate с правильными stops" + input: | + Python-парсер: чтение src/web/app.js, regex по блоку + HILLSHADE_PAINT = { ... }; вытаскивание raster-opacity. + expected: | + Тип: ['interpolate', ['linear'], ['zoom'], ...]. + Stops содержат: (9, 0.65), (10, 0.60), (11, 0.55), + (12, 0.50), (14, 0.40). Допустимо отклонение значений ±0.05 + (калибровка) — но порядок монотонно убывающий от 9 к 14. + + - id: UT-PAINT-HS-CONTRAST + name: "HILLSHADE_PAINT: raster-contrast — пик на z9, 0 на z14" + input: | + Тот же парсер. + expected: | + Тип interpolate. Значение на z=9 ≥ 0.30. Значение на z=14 + ≤ 0.10. Монотонно убывает. + + - id: UT-PAINT-HS-RESAMPLING + name: "HILLSHADE_PAINT: raster-resampling = 'nearest'" + input: | + Парсер. + expected: | + Строка 'nearest' (не 'linear'). + + - id: UT-PAINT-TRI-OPACITY-Z8 + name: "TRI_PAINT: на z8 opacity = 0.70 (регрессия)" + input: | + Парсер по TRI_PAINT. + expected: | + Stop (8, 0.70) присутствует ровно (без округления). + + - id: UT-PAINT-TRI-OPACITY-PEAK + name: "TRI_PAINT: пик на z9-z11" + input: | + Парсер. + expected: | + Stops содержат (10, X) с X ≥ 0.80 и (11, Y) с Y ≥ 0.80. + + - id: UT-PAINT-TRI-RESAMPLING + name: "TRI_PAINT: raster-resampling = 'nearest'" + input: | + Парсер. + expected: | + 'nearest'. + + - id: UT-PAINT-COMPAT-01 + name: "applyTerrainLayer обратно-совместим с числовым opacity" + input: | + Вызов с opacity=0.5 (Node + JSDOM-mock карты). + expected: | + Внутри map.addLayer передан paint: + { 'raster-opacity': 0.5, 'raster-resampling': 'linear' }. + notes: | + Если запуск JS-теста не настроен — заменить на статический + grep по src/web/app.js: проверить ветвление + 'typeof opacityOrPaint === "number"'. + + - id: UT-PAINT-COMPAT-02 + name: "applyTerrainLayer принимает paint-объект" + input: | + Вызов с opacityOrPaint = { 'raster-opacity': 0.4, + 'raster-contrast': 0.2, 'raster-resampling': 'nearest' }. + expected: | + Этот объект передан в map.addLayer paint как есть. + + - id: UT-REG-MINZOOM-9 + name: "updateHillshadeAvailability порог = 9" + input: | + grep по src/web/app.js: 'if (zoom < 9)' внутри функции + updateHillshadeAvailability. + expected: | + Совпадение найдено; 'if (zoom < 10)' отсутствует. + + - id: UT-REG-HINT-TEXT + name: "Hint текст обновлён до 'Зум 9+'" + input: | + grep по src/web/index.html: '#terrain-hillshade-hint' + содержит 'Зум 9+'. + expected: | + Совпадение найдено; 'Зум 10+' отсутствует. + + - id: UT-REG-CALLERS + name: "applyTerrainLayer вызывается ровно дважды в onTerrainCheckbox" + input: | + regex 'applyTerrainLayer\s*\(' в src/web/*.js — count. + expected: | + Минимум 2 вызова в src/web/app.js. Все они находятся + внутри функции onTerrainCheckbox. + + - name: integration-terrain-tiles + type: integration + description: "Endpoint /terrain/{layer}/{z}/{x}/{y}.png на z9-z11" + cases: + - id: IT-TILE-Z9-01 + name: "Тайл z=9 для hillshade: 200 или skipped если данных нет" + input: | + Test-среда или CI с TERRAIN_DIR. Найти первый существующий + тайл z9 в директории hillshade, выполнить GET. + expected: | + Если data/terrain/hillshade/9/ существует: + status 200, content-type image/png, тело > 0. + Иначе: + test skipped с reason 'PH-6 data not in repo'. + + - id: IT-TILE-Z10-01 + name: "Тайл z=10 для hillshade: 200 или skipped" + input: | + То же, что IT-TILE-Z9-01 для z=10. + expected: | + status 200 или skipped. + + - id: IT-TILE-Z11-01 + name: "Тайл z=11 для hillshade: 200 или skipped" + input: | + То же для z=11. + expected: | + status 200 или skipped. + + - id: IT-TILE-TRI-Z9 + name: "TRI на z9 доступен (минзум 5, тайлы должны быть)" + input: | + GET tiles/9/X/Y.png под TRI. + expected: | + 200 или skipped (если данных нет на CI). + + - id: IT-TILE-INVALID-LAYER + name: "Неизвестный layer → 404 (регрессия)" + input: | + GET /terrain/unknown/9/0/0.png + expected: | + status 404. + + - id: IT-TILE-MISSING + name: "Несуществующий тайл → 404 (регрессия)" + input: | + GET /terrain/hillshade/9/99999/99999.png + expected: | + status 404. + + - id: IT-TILE-CACHE-HEADER + name: "Cache-Control: immutable сохраняется" + input: | + GET существующего тайла. + expected: | + Header 'Cache-Control' содержит 'immutable' и max-age=31536000. + + - name: regression-existing + type: regression + description: "Регрессия ET-007 / PH-6 / общих unit-тестов" + cases: + - id: RG-UNIT-ALL + name: "Все unit-тесты проекта зелёные" + input: "pytest tests/unit/ -v" + expected: "exit-code 0" + + - id: RG-INTEG-ALL + name: "Все integration-тесты проекта зелёные" + input: "pytest tests/integration/ -v" + expected: "exit-code 0" + + - id: RG-LINT + name: "Линтеры зелёные" + input: "make lint" + expected: "exit-code 0" + + - name: ui-playwright + type: ui + description: "Playwright UI-тесты на test-среде" + reference: "04b-ui-test-cases.md" + cases: + - id: UI-LINK-01 + name: "См. 04b-ui-test-cases.md — TC-UI-01..TC-UI-12" + expected: | + Каждый TC выполняется; check-visual подтверждается + оператором либо визуальным diff-инструментом + (baseline до ET-013 vs текущий). + + - name: manual-deploy-validation + type: e2e + description: "Ручная проверка в test-среде после деплоя" + marker: "manual" + cases: + - id: E2E-PRE-DEPLOY-01 + name: "Pre-deploy: тайлы z9-z11 на test-среде доступны" + steps: + - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png | head -1" + - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png | head -1" + - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/11/1234/635.png | head -1" + - "Все три — HTTP/1.1 200 OK. При 404 — стоп, открыть PH-6 follow-up." + - "Зафиксировать в 14-deploy-log.md." + + - id: E2E-DEPLOY-01 + name: "Hillshade доступен на z=9" + steps: + - "Открыть https://openclaw.mva154.duckdns.org/enduro/" + - "localStorage.clear(); location.reload()" + - "Click #terrain-toggle" + - "В Console: window._map.setZoom(9); window._map.setCenter([37.6, 54.5])" + - "Wait 2s" + - "Кнопка #terrain-hillshade-cb имеет disabled=false" + - "Hint #terrain-hillshade-hint имеет display:none" + - "Click #terrain-hillshade-cb" + - "Wait 3s" + - "На карте видны тени" + - "Screenshot et013-deploy-z9.png" + - "Зафиксировать в 14-deploy-log.md" + + - id: E2E-DEPLOY-02 + name: "Network-объём: рост ≤ 35%" + steps: + - "Открыть DevTools Network, фильтр /terrain/" + - "Очистить network log" + - "В Console: window._map.setZoom(8); ждать 3s; setZoom(9); ждать 3s; setZoom(10); ждать 3s; setZoom(11); ждать 3s" + - "Замерить суммарный transferred size в фильтре /terrain/" + - "Сравнить с baseline (записан в 13-test-report.md до ET-013): рост ≤ 135%" + - "Зафиксировать" + + - id: E2E-DEPLOY-03 + name: "Регрессия z=8 (TRI выглядит как до ET-013)" + steps: + - "localStorage.clear(); location.reload()" + - "Включить только #terrain-tri-cb (без hillshade)" + - "window._map.setZoom(8); setCenter([37.6, 54.5])" + - "Screenshot et013-deploy-z8-tri-regress.png" + - "Визуально сравнить с baseline из 13-test-report.md до ET-013 — не отличается заметно." + + - id: E2E-DEPLOY-04 + name: "Регрессия z=14 (hillshade не перегрет)" + steps: + - "Включить #terrain-hillshade-cb" + - "window._map.setZoom(14); setCenter([37.6, 54.5])" + - "Screenshot et013-deploy-z14-regress.png" + - "Эффективное raster-opacity ≈ 0.40, raster-contrast ≈ 0" + - "В Console: window._map.getPaintProperty('terrain-hillshade', 'raster-opacity')" + - "(вернёт interpolate-выражение — proof zoom-aware)" + + - id: E2E-DEPLOY-05 + name: "Спутник + hillshade на z=10 (R-3)" + steps: + - "Включить hillshade, переключить #base-btn-satellite" + - "window._map.setZoom(10); setCenter([37.6, 54.5])" + - "Screenshot et013-deploy-z10-sat.png" + - "Визуальная приёмка: hillshade видим, не глушит снимок" + - "При проблеме — задача отправляется на корректировку stops" + + - id: E2E-DEPLOY-06 + name: "Тёмная тема + hillshade на z=10 (R-2)" + steps: + - "Click #btn-theme (переключить в тёмную)" + - "window._map.setZoom(10)" + - "Screenshot et013-deploy-z10-dark.png" + - "Визуальная приёмка: hillshade читается, не сливается с тёмной подложкой" + + - id: E2E-DEPLOY-07 + name: "Persistence: F5 не теряет состояние" + steps: + - "Включить оба чекбокса" + - "location.reload()" + - "Чекбоксы остаются включёнными" + - "На текущем zoom оба слоя восстановлены" + +test_data: + fixtures_dir: "tests/fixtures/terrain/" + fixtures: + - name: "hillshade-z9-sample.png" + description: | + Опционально: один валидный PNG-тайл из data/terrain/hillshade/9/ + для CI-окружения без полного набора данных. Скопировать любой + тайл над ЦФО, переименовать. ~10 KB. + - name: "hillshade-z10-sample.png" + description: "То же для z10." + - name: "tri-z10-sample.png" + description: "TRI sample для z10." + notes: + - "Если на CI нет TERRAIN_DIR с данными — IT-TILE-* тесты skipped (REQ-F-15)." + - "Сравнения 'до/после' визуальные — baseline скриншоты лежат в 13-test-report.md и фиксируются до начала ET-013." + - "Для unit-тестов paint никаких fixture не нужно — парсинг исходника." + +test_environment: + unit: + - "Python 3.12, pytest" + - "regex-парсер src/web/app.js (Вариант B в TRZ REQ-F-13)" + - "Опционально Node + JSDOM, если в проекте появятся JS-тесты" + integration: + - "FastAPI TestClient против src.api.main:app" + - "TERRAIN_DIR через env или skip-if-missing" + performance: + - "Не требуется специально: NFR-01/02 говорят о невидимом изменении render-time" + - "Сетевой объём — ручной замер в DevTools Network (E2E-DEPLOY-02)" + e2e: + - "Test-среда https://openclaw.mva154.duckdns.org/enduro/" + - "Playwright (см. 04b-ui-test-cases.md)" + +ci_gates: + - "Unit UT-PAINT-* и UT-REG-* — обязательны (AC-15)" + - "Integration IT-TILE-* — обязательны (с skipif для отсутствующих данных) (AC-16)" + - "Регрессия RG-UNIT-ALL, RG-INTEG-ALL, RG-LINT — обязательны (AC-17, AC-18)" + - "Pre-deploy E2E-PRE-DEPLOY-01 — ручной gate перед merge (AC-19)" + - "UI-тесты Playwright — после деплоя, фиксация в 13-test-report.md" + - "E2E-DEPLOY-01..07 — ручные шаги в 14-deploy-log.md" +--- diff --git a/docs/work-items/ET-013/04b-ui-test-cases.md b/docs/work-items/ET-013/04b-ui-test-cases.md new file mode 100644 index 0000000..8a80899 --- /dev/null +++ b/docs/work-items/ET-013/04b-ui-test-cases.md @@ -0,0 +1,386 @@ +--- +type: ui-test-cases +work_item_id: ET-013 +title: "UI Test Cases: Перепады высот на z9-z11" +version: 1 +status: draft +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:analyst" +related: + - "PH-6.terrain" + - "ET-007" +--- + +# UI Test Cases — ET-013: Перепады высот на zoom z9-z11 + +Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/` + +ET-013 — frontend-калибровка: hillshade и TRI используют +zoom-aware paint, UI-минзум hillshade понижен с 10 до 9. UI-тесты +проверяют: + +1. На z9 чекбокс «Тени рельефа» активен, hint скрыт, hillshade виден. +2. На z9-z11 перепады «бросаются в глаза» (качественно). +3. На z8 регрессии нет (TRI выглядит как было). +4. На z14 hillshade не «перегрет» (регрессия). +5. Тёмная тема и спутник совместимы. +6. Мобильный viewport работает. +7. Persistence (localStorage) переживает F5. + +Селекторы (из текущего `index.html`): +- `#terrain-toggle` — кнопка попапа слоёв рельефа (правая панель). +- `#terrain-popup` — сам попап со списком чекбоксов. +- `#terrain-hillshade-cb` — чекбокс «Тени рельефа». +- `#terrain-hillshade-hint` — hint «Зум 9+» (ET-013) / «Зум 10+» (до ET-013). +- `#terrain-tri-cb` — чекбокс «Перепады». +- `#base-btn-satellite` — кнопка спутника. +- `#btn-theme` — переключатель тёмная/светлая. +- `#map` — карта. + +Все тесты выставляют zoom программно через `page.evaluate`: +```js +window._map.setZoom(N); +window._map.setCenter([37.6, 54.5]); // юг МО / Ока, холмистый район +``` + +Координата `[37.6, 54.5]` (юг Москвы / Кашира / Ока) выбрана как +«заведомо холмистая зона ЦФО» с явным TRI/hillshade. + +Скриншоты складываются в `docs/work-items/ET-013/screenshots/` +и пришиваются к `13-test-report.md`. Для качественных AC-07/08/09 +оператор сравнивает с baseline скриншотами «до ET-013» (тоже в +`screenshots/baseline/`). + +--- + +### TC-UI-01-Z9 — На z=9 hillshade доступен и виден + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]); +8. wait: 3000 +9. click: "#terrain-toggle" +10. wait: 800 +11. screenshot: "et013-01-z9-popup" +12. check-visual: "В попапе #terrain-popup чекбокс «Тени рельефа» (#terrain-hillshade-cb) НЕ disabled, текст не серый. Hint #terrain-hillshade-hint имеет display:none (текст «Зум 9+» не виден). Чекбокс «Перепады» (#terrain-tri-cb) также доступен." +13. click: "#terrain-hillshade-cb" +14. click: "#terrain-tri-cb" +15. wait: 4000 +16. screenshot: "et013-01-z9-tracks-visible" +17. check-visual: "На карте при zoom=9 виден район юга Москвы / Оки. Поверх подложки нарисованы тени рельефа (hillshade) — тёмные склоны заметны на холмах вдоль реки. TRI («Перепады») рисует цветные пятна шероховатых зон. Оба слоя читаются, рельеф выразительный." + +--- + +### TC-UI-02-Z8-REGRESS — Регрессия z=8: TRI выглядит как до ET-013 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. wait: 800 +9. click: "#terrain-tri-cb" +10. wait: 2000 +11. evaluate: window._map.setZoom(8); window._map.setCenter([37.6, 54.5]); +12. wait: 4000 +13. screenshot: "et013-02-z8-tri-regress" +14. check-visual: "На z=8 виден слой «Перепады» в опубликованном виде PH-6: opacity ~0.70, ресемпл «жёсткий» (граница 30-метровых клеток SRTM может быть видна, но это норма после ET-013). Слой hillshade выключен. Сравнение с baseline скриншотом 'before-ET-013-z8.png' — визуально близко, без явных регрессий." + +--- + +### TC-UI-03-Z9-Q — Качественная читаемость перепадов на z=9 + +- тип: ui +- viewport: desktop +- условие: оба слоя включены + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. wait: 500 +9. click: "#terrain-hillshade-cb" +10. click: "#terrain-tri-cb" +11. wait: 2000 +12. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]); +13. wait: 5000 +14. screenshot: "et013-03-z9-readable" +15. check-visual: "На z=9 рельеф читается явно: тени по склонам холмов, цветные пятна TRI выделяют шероховатые зоны (склоны вдоль Оки, овраги). Не должно быть впечатления 'плоской карты'. Оператор сравнивает с baseline 'before-ET-013-z9.png' и подтверждает: 'перепады стали выразительнее' или 'минимум не хуже z8'. При отказе — фиксировать в 13-test-report.md и итеративно корректировать stops в HILLSHADE_PAINT/TRI_PAINT." + +--- + +### TC-UI-04-Z10-Q — Качественная читаемость на z=10 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. click: "#terrain-tri-cb" +10. wait: 2000 +11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]); +12. wait: 5000 +13. screenshot: "et013-04-z10-readable" +14. check-visual: "На z=10 в фокусе несколько холмов с явными склонами. Hillshade рисует тени с выраженным контрастом (raster-contrast 0.35 в paint-выражении). TRI выделяет шероховатости. Сравнение с baseline 'before-ET-013-z10.png' — стало явно выразительнее. Подложка под слоями ещё читается (opacity 0.60 + 0.85 не превращают карту в кашу)." + +--- + +### TC-UI-05-Z11-Q — Качественная читаемость на z=11 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. click: "#terrain-tri-cb" +10. wait: 2000 +11. evaluate: window._map.setZoom(11); window._map.setCenter([37.6, 54.5]); +12. wait: 5000 +13. screenshot: "et013-05-z11-readable" +14. check-visual: "На z=11 виден небольшой район (несколько км в кадре). Перепады «прорисованы», отдельные склоны различимы. Сравнение с baseline 'before-ET-013-z11.png' — выразительнее. Дороги/грунтовки/POI остаются читаемыми поверх рельефа (z-order: terrain ниже trails/POI, проверено по applyTerrainLayer)." + +--- + +### TC-UI-06-Z14-REGRESS — Регрессия z=14: hillshade не перегрет + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. wait: 2000 +10. evaluate: window._map.setZoom(14); window._map.setCenter([37.6, 54.5]); +11. wait: 5000 +12. screenshot: "et013-06-z14-regress" +13. check-visual: "На z=14 hillshade выглядит так, как до ET-013: лёгкая «плёнка» теней с opacity ≈ 0.40 и raster-contrast ≈ 0. Никакого перегретого контраста. Подложка отчётливо видна. Сравнение с baseline 'before-ET-013-z14.png' — без отличий." + +--- + +### TC-UI-07-Z9-MOBILE — Hillshade на мобильном viewport на z=9 + +- тип: ui +- viewport: mobile + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]); +8. wait: 3000 +9. click: "#terrain-toggle" +10. wait: 800 +11. screenshot: "et013-07-z9-mobile-popup" +12. check-visual: "На мобильном viewport (375×667) попап рельефа открыт, чекбокс «Тени рельефа» доступен, hint скрыт. Чекбокс «Перепады» доступен. Layout не сломан." +13. click: "#terrain-hillshade-cb" +14. click: "#terrain-tri-cb" +15. wait: 4000 +16. screenshot: "et013-07-z9-mobile-tracks" +17. check-visual: "На мобильном на z=9 видны тени рельефа и пятна TRI. Перепады читаются. Layout верхней/нижней панелей не перекрывает карту." + +--- + +### TC-UI-08-Z10-SAT-Q — Спутник + hillshade на z=10 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#base-btn-satellite" +9. wait: 4000 +10. click: "#terrain-hillshade-cb" +11. wait: 2000 +12. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]); +13. wait: 5000 +14. screenshot: "et013-08-z10-sat" +15. check-visual: "На спутниковой подложке поверх космоснимка видны тени hillshade. Подложка под ними различима — деревья, реки, поля по-прежнему читаются. Hillshade не превращает снимок в «серую плёнку». При отказе (слой глушит снимок) — открыть итерацию: либо снизить opacity на спутнике через отдельный layer-paint, либо документировать как known issue." + +--- + +### TC-UI-09-Z10-DARK-Q — Тёмная тема + hillshade на z=10 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: localStorage.setItem('theme', 'dark'); location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. click: "#terrain-tri-cb" +10. wait: 2000 +11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]); +12. wait: 5000 +13. screenshot: "et013-09-z10-dark" +14. check-visual: "На тёмной теме при z=10 видны и hillshade, и TRI. Тени не сливаются с тёмной подложкой. Цвета TRI читаются. Если визуально слои «съедают карту» — фиксируется как известная проблема для будущей итерации (theme-specific paint, ADR-0001 в follow-up)." + +--- + +### TC-UI-10-PERSIST — Состояние слоёв переживает F5 + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. click: "#terrain-tri-cb" +10. wait: 1500 +11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]); +12. wait: 4000 +13. screenshot: "et013-10a-before-reload" +14. check-visual: "Оба слоя видны на z=10." +15. evaluate: location.reload(); +16. wait: 6000 +17. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]); +18. wait: 4000 +19. screenshot: "et013-10b-after-reload" +20. check-visual: "После reload оба слоя автоматически восстановились (через restoreTerrainState). Чекбоксы в #terrain-popup всё ещё checked. localStorage 'terrain-hillshade'='1', 'terrain-tri'='1'." + +--- + +### TC-UI-11-NETWORK-Q — Сетевой объём (M-10) + +- тип: ui (network) +- viewport: desktop +- инструмент: DevTools Network + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. open: DevTools Network, filter "/terrain/" +8. clear network log +9. click: "#terrain-toggle" +10. click: "#terrain-hillshade-cb" +11. click: "#terrain-tri-cb" +12. evaluate: window._map.setZoom(8); window._map.setCenter([37.6, 54.5]); +13. wait: 3500 +14. evaluate: window._map.setZoom(9); +15. wait: 3500 +16. evaluate: window._map.setZoom(10); +17. wait: 3500 +18. evaluate: window._map.setZoom(11); +19. wait: 3500 +20. record: суммарный transferred size в Network +21. check-visual: "Сравнение с baseline 'before-ET-013-network-z8-z11.txt' (записанным до начала ET-013): рост ≤ 135%. Если выше — анализ: какие тайлы добавились, оправдано ли. Фиксация в 13-test-report.md." + +--- + +### TC-UI-12-Z9-PAN — Панорамирование на z=9 без лагов + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. evaluate: localStorage.clear(); +4. wait: 500 +5. evaluate: location.reload(); +6. wait: 5000 +7. click: "#terrain-toggle" +8. click: "#terrain-hillshade-cb" +9. click: "#terrain-tri-cb" +10. wait: 2000 +11. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]); +12. wait: 5000 +13. evaluate: window._map.panBy([400, 0]); +14. wait: 3000 +15. evaluate: window._map.panBy([0, 400]); +16. wait: 3000 +17. evaluate: window._map.panBy([-400, 0]); +18. wait: 3000 +19. screenshot: "et013-12-z9-pan" +20. check-visual: "После трёх pan-шагов карта показывает соседние регионы. Тайлы догружены, нет 'белых дыр' в hillshade/TRI. Возврат к исходному центру — мгновенный (browser cache). UI не блокируется, нет визуальных лагов." + +--- + +### Заметки по запуску + +- TC-UI-03..05 (Q-критерии) — качественные. Оператор сравнивает + скриншот с baseline («до ET-013»). Baseline записывается **до** + начала разработки ET-013 и кладётся в + `docs/work-items/ET-013/screenshots/baseline/`. +- TC-UI-08 (SAT-Q) и TC-UI-09 (DARK-Q) — допустимо «known issue» + с фиксацией в `13-test-report.md`. Если визуальная регрессия + обнаружена — открывается follow-up задача по theme/sat-specific paint. +- При отказе TC-UI-03/04/05 — корректировка stops в + `HILLSHADE_PAINT`/`TRI_PAINT`, новый прогон. Это калибровка, а не баг. +- При отказе TC-UI-06 (z14 регрессия) — баг калибровки stops, + должен быть исправлен. +- TC-UI-11 (NETWORK-Q) — pre/post замеры; baseline записывается + до старта работ над ET-013. + +### Координаты для тестов + +| Координаты | Регион | Зачем | +|---|---|---| +| `[37.6, 54.5]` | юг МО / Кашира / Ока | холмистый, выраженный hillshade и TRI | +| `[37.6, 55.7]` | центр Москвы | плоский, контроль «город всё равно читается» (опционально) | +| `[38.6, 54.0]` | Тула | холмы юга ЦФО, альтернатива для AC-08 | + +По умолчанию все TC используют `[37.6, 54.5]`. diff --git a/docs/work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md b/docs/work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md new file mode 100644 index 0000000..bd257e5 --- /dev/null +++ b/docs/work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md @@ -0,0 +1,367 @@ +--- +type: adr +work_item_id: ET-013 +adr_id: ADR-017 +title: "ADR-017: Zoom-aware paint для hillshade/TRI — калибровка клиентских raster-слоёв вместо перегенерации тайлов" +status: accepted +created_at: 2026-06-04 +updated_at: 2026-06-04 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-013:terrain-paint" + - "minor-change" +--- + +# ADR-017 — Zoom-aware paint для hillshade/TRI на z9-z11 + +## Статус + +**Accepted.** Архитектурное решение для ET-013. + +Это **калибровка клиентского рендера** растровых terrain-слоёв +(а не пересмотр архитектуры рельефа из PH-6). BRD §3 F-14 допускает +отсутствие отдельного ADR. ADR оформляется по прецеденту ADR-016 +(ET-012) — ради единого индекса архитектурных решений и чтобы +зафиксировать **причины отклонения** более «жирных» альтернатив +(перегенерация hillshade с z-factor 2.5, переход на raster-dem, +multidirectional hillshade, theme-specific paint-таблицы), иначе +они вернутся в обсуждение в следующем work-item. + +## Контекст + +### Текущее состояние (после PH-6 / ET-007) + +- Растровые тайлы рельефа нарезаны **z8-z14** (PNG 256×256) из + SRTM 30 м: hillshade (azimuth 315°, altitude 45°, z-factor 1.5), + TRI (5-уровневая классификация), hypso (в UI не подключён). +- Раздача — `GET /terrain/{layer}/{z}/{x}/{y}.png` через FastAPI + (`src/api/main.py:1240`), `Cache-Control: immutable`. +- Клиент (`src/web/app.js`) создаёт MapLibre raster source/layer + динамически в `applyTerrainLayer(id, tileUrl, enabled, opacity, + minzoom, maxzoom)`. **Сигнатура хардкодит paint:** + `{ 'raster-opacity': opacity_number, 'raster-resampling': 'linear' }`. +- Вызовы (`src/web/app.js:2782-2783`): + - hillshade: `opacity=0.40, minzoom=10, maxzoom=15`. + - TRI: `opacity=0.70, minzoom=5, maxzoom=15`. +- UI-минзум hillshade в `updateHillshadeAvailability` (строка 3368): + `if (zoom < 10) cb.disabled = true`. +- В стилях `style.json` / `style-dark.json` растровых terrain-слоёв + **нет** — они добавляются динамически из JS. + +### Проблема + +При зумах z9-z11 (ключевой масштаб для выбора эндуро-маршрута между +двумя точками) рельеф визуально «теряется»: + +- z9: hillshade выключен UI-гейтом, TRI с opacity 0.70 виден, но + пятна мельче чем на z8. +- z10-z11: hillshade включается, но opacity 0.40 + отсутствие + усиления контраста + linear-resampling делают тени «бледной + плёнкой»; TRI на тех же opacity не компенсирует. + +Архитектурный вопрос: **как восстановить выразительность z9-z11 +без перегенерации растровых тайлов, без новых endpoint'ов, без +новых данных и без смены paint-pipeline'а у MapLibre.** + +## Рассмотренные варианты + +### Вариант P (Pipeline) — где править + +- **P-A — Frontend paint-калибровка** (выбран): + - paint hillshade/TRI становится zoom-aware через MapLibre + `interpolate`-выражение по `['zoom']`. + - Меняются параметры существующих paint-properties: + `raster-opacity`, `raster-contrast`, `raster-resampling`. + - 0 изменений в backend, 0 в тайлах на диске. + +- **P-B — Перегенерация hillshade с z-factor 2.5-3.0 для z9-z14.** + Отклонён в этой задаче: + - Требует доступа к infra-pipeline SRTM, пересборки и редеплоя + растровых тайлов (без CI-автоматизации сейчас). + - Долгий feedback-loop (часы регенерации на регион); калибровка + paint даёт результат за минуты. + - Затрагивает все zoom-уровни сразу, в т.ч. z8 (регрессия BRD F-11). + - **Открыт как follow-up** «hillshade-rerender-z9-z14», если P-A + окажется недостаточным. + +- **P-C — Переход на MapLibre `hillshade` (raster-dem) layer.** + Отклонён: + - Требует поднять DEM в формате Terrarium/Mapbox-RGB (новый + pipeline, новые тайлы, новый source-type, новые URL). + - Это смена архитектуры рельефа, не калибровка. Большой скачок + рисков и времени реализации. + - Не решает поставленную проблему быстрее, чем P-A. + +- **P-D — Векторные горизонтали (contours).** + Отклонён: + - Контуров в стэке нет. Это новая фича уровня PH-6.5, требует + pipeline на отдельных vector tiles (планировщик стилей, + атрибуты высот, симплификация). + - Не заменяет hillshade/TRI, а дополняет — другая фича. + +- **P-E — Multidirectional hillshade (4 азимута, blend).** + Отклонён: + - Требует пересборки тайлов и комбинирующего layer. + - Дороже P-A на порядок при том же визуальном эффекте на z9-z11. + +### Вариант O (Opacity scaling) — как именно скалировать opacity + +- **O-A — Step-функция через `case [zoom_in [9,10,11]]`.** Отклонён — + ступенчатые скачки видны как «вспышки» при плавном зуме. + +- **O-B — Linear `interpolate` со stops для z9-z14** (выбран): + - Hillshade `raster-opacity`: `9→0.65, 10→0.60, 11→0.55, 12→0.50, 14→0.40`. + - Поведение на z<9 не определено (но не нужно — UI-гейт отключает слой). + - На z14-z15 значение «закреплено» на исходных 0.40 (clamping + у MapLibre на верхнем стопе) → регрессия z14 (BRD F-12, AC-10) + выполняется автоматически. + - TRI `raster-opacity`: `5→0.55, 7→0.65, 8→0.70, 9→0.80, 10→0.85, + 11→0.85, 12→0.75, 15→0.70`. + - Точка `8→0.70` явная → регрессия z8 (BRD F-11, AC-06) выполняется + автоматически. + +- **O-C — Exponential `interpolate ['exponential', 2]`.** Отклонён: + - Перерасход контраста на z11-z12 → темно/«пересвет» (R-1). + - Linear проще и достаточен для 5 stops в узком диапазоне. + +### Вариант C (Contrast) — добавлять ли raster-contrast + +- **C-A — Добавить `raster-contrast` zoom-aware для hillshade** + (выбран): + - Stops: `9→0.40, 10→0.35, 11→0.30, 12→0.15, 14→0.00`. + - На z14 значение 0 → регрессия (AC-10) выполняется автоматически. + - Только для hillshade. На TRI контраст не имеет смысла + (категориальная палитра), его не трогаем. + +- **C-B — Не трогать контраст, поднять только opacity.** Отклонён: + - Opacity 0.65 без контраста на z9 — это просто «более тёмная + плёнка», а не «более выразительный рельеф». Качественный тест + (TC-UI-04-Z10-Q) на этом варианте не пройдёт. + +- **C-C — Уменьшать `raster-brightness-min/max` вместо contrast.** + Отклонён: + - Более сложная двухпараметрическая настройка для того же эффекта. + - `raster-contrast` — стандартный для подобных случаев property. + +### Вариант R (Resampling) — nearest vs linear + +- **R-A — `'nearest'` на hillshade и TRI** (выбран): + - hillshade на nearest сохраняет «жёсткие края» теней SRTM — рельеф + читается резче. + - TRI — категориальная палитра; linear-resampling размывает границы + между уровнями шероховатости → пятна «текут». `'nearest'` + сохраняет границы. + - MapLibre **не поддерживает** `interpolate` для `raster-resampling` + → выбираем глобально `'nearest'` для обоих слоёв. На z12-z14 + компромисс приемлем (текстура остаётся читаемой при overzoom; + см. R-T-3). + +- **R-B — Глобально `'linear'`.** Отклонён: + - Сохраняет текущую «размытую» картинку, проблема не решается. + +- **R-C — Динамическое переключение `nearest`↔`linear` через + отдельный layer.** Отклонён: + - Удваивает количество raster-layers (2 hillshade + 2 TRI), плюс + логика «когда какой layer показывать» по `getZoom()` → + сложность не оправдана. + +### Вариант U (UI gate) — минзум hillshade + +- **U-A — Понизить UI-порог с 10 до 9** (выбран): + - Тайлы z9 на диске **есть** (нарезка z8-z14 по PH-6 BRD; pre-deploy + smoke в `07-infra-requirements.md` §6.2 шаг 1 это подтверждает). + - Аналогично понижается `source.minzoom` с 10 до 9 (BRD F-02, + REQ-F-02). + - HTML hint обновляется с «Зум 10+» на «Зум 9+» (REQ-F-10). + +- **U-B — Понизить дальше до z8.** Отклонён: + - На z8 hillshade-тайлы 256 px покрывают ~150 км по широте — крупные + тени становятся неразборчивым «шумом». TRI работает лучше. + - Если будущий BRD захочет — отдельная задача. + +- **U-C — Не менять UI-порог, оставить 10.** Отклонён: + - Тогда на z9 пользователь не видит hillshade вообще — основная + жалоба BRD не решается. + +### Вариант T (Theme-specific paint) — отдельные таблицы для dark/satellite + +- **T-A — Один paint для всех тем** (выбран в MVP): + - Простой код, одна правда о stops. + - AC-11 (dark) и AC-12 (satellite) — качественные проверки. Если + оператор подтвердит читаемость на dark и satellite — конец истории. + - Соглашение: если AC-11/AC-12 проваливаются — открывается **ADR-018 + "theme-specific terrain paint"** как follow-up; в нём вводится + подписка на `theme-change` и переключение paint через + `setPaintProperty` (BRD R-2, R-3). + +- **T-B — Сразу theme-specific paint в ET-013.** Отклонён: + - Преждевременная сложность; неизвестно, действительно ли нужны + разные stops (вероятность по риск-таблице: средне-низкая). + - Расширяет scope: понадобится подписка на смену темы, отдельные + константы, новые тесты на каждый theme×layer×zoom. + +### Вариант A (API-расширение `applyTerrainLayer`) — как передавать paint + +- **A-A — Обратно-совместимое расширение: `opacityOrPaint: number | + object`** (выбран): + - Внутри функции — нормализация: если число → старый paint-объект + с `linear` resampling; если объект → используется как есть. + - Сохраняет старый контракт для возможных будущих вызовов + (сейчас вызовов только два, оба в `onTerrainCheckbox`). + - Unit-тестируется через AC-22, UT-COMPAT-01. + +- **A-B — Сменить сигнатуру на `applyTerrainLayer(id, tileUrl, + enabled, paint, minzoom, maxzoom)` без обратной совместимости.** + Отклонён: + - Если в будущем кто-то скопирует функцию для других raster-слоёв + (POI tiles, scenic) с числом — придётся переписывать вызовы. + - Стоимость обратной совместимости — 3 строки кода. + +- **A-C — Завести новые функции `applyHillshadeLayer` / + `applyTRILayer`.** Отклонён: + - Дубликация. `applyTerrainLayer` уже обобщённая, она и есть точка + расширения. + +### Вариант M (Module split) — выносить ли константы в отдельный файл + +- **M-A — `HILLSHADE_PAINT` / `TRI_PAINT` живут в `app.js` рядом с + `TERRAIN_BASE_URL`** (выбран): + - В стэке нет JS-bundler'а, нет ES-import-graph'а (vanilla JS, + скрипты грузятся `