Merge pull request 'feat(terrain): zoom-aware paint для hillshade/TRI на z9-z11 (ET-013)' (#26) from feature/ET-013-z9-z11-z8 into main

This commit was merged in pull request #26.
This commit is contained in:
2026-06-04 13:10:55 +03:00
19 changed files with 4300 additions and 14 deletions

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
# Business Request: Перепады высот теряются на z9-z11 (хорошо видны на z8)
Work Item ID: ET-013
## Description
TBD

View File

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

View File

@@ -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
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 10+</span>
```
заменить на
```html
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 9+</span>
```
### 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). |

View File

@@ -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 как есть.

View File

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

View File

@@ -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]`.

View File

@@ -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,
скрипты грузятся `<script src=...>`).
- Выделять отдельный модуль `terrain-paint.js` ради двух констант
— преждевременная фрагментация.
- Unit-тестируются Python-парсером по grep (REQ-F-13 Вариант B);
JS-test-раннера в проекте нет.
- **M-B — Отдельный модуль `src/web/terrain-paint.js`.** Отклонён в MVP:
- Требует либо ставить vitest/jest (превышение scope ET-013), либо
подключать через `<script>` с глобальными переменными — не
эстетично.
- Если в будущем потребуется JS-test-инфраструктура (PWA, сложная
логика) — модуль выделяется тогда же.
## Решение
1. **Frontend paint-калибровка (P-A)**. Никаких изменений в backend
`src/api/main.py`, в нарезке растровых тайлов на диске, в `style.json` /
`style-dark.json`, в nginx, в Docker.
2. **UI-минзум hillshade понижается с 10 до 9 (U-A)** в
`updateHillshadeAvailability` (порог `zoom < 9`), HTML hint
`«Зум 9+»`, `source.minzoom = 9` через параметр в `applyTerrainLayer`.
3. **Контракт `applyTerrainLayer` расширяется (A-A)**: четвёртый
параметр принимает либо `number` (старый контракт → `raster-opacity` +
`linear`-resampling), либо `object` paint-properties. Внутри
функции — нормализация.
4. **Hillshade paint (O-B + C-A + R-A)** — константа `HILLSHADE_PAINT`
в `app.js`:
- `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'`.
5. **TRI paint (O-B + R-A)** — константа `TRI_PAINT`:
- `raster-opacity`: `interpolate linear zoom [5→0.55, 7→0.65,
8→0.70, 9→0.80, 10→0.85, 11→0.85, 12→0.75, 15→0.70]`.
- `raster-resampling`: `'nearest'`.
6. **Один paint для всех тем (T-A)** — без специальных таблиц для
`theme-dark` и для спутниковой подложки в MVP. Если AC-11/AC-12
проваливаются — открывается ADR-018 как follow-up.
7. **Константы живут в `app.js` (M-A)** рядом с `TERRAIN_BASE_URL`.
## Классификация изменения
**minor-change.**
Меняются 3 файла:
- `src/web/app.js` (расширение `applyTerrainLayer`, добавление двух
констант, обновление двух вызовов, изменение одного порога).
- `src/web/index.html` (текст одного `<span>`).
- `tests/unit/test_terrain_paint.py` + `tests/integration/test_terrain_z9_tiles.py`
(новые).
Не меняются:
- `src/api/main.py`.
- `data/terrain/*` (тайлы на диске).
- `style.json`, `style-dark.json`.
- `config/*.yaml`.
- `Dockerfile`, `docker-compose.yml`, nginx.
Эскалация: **не arch:major-change.** Не требует расширенного approve.
## Последствия
### Положительные
- Перепады на z9-z11 читаются сопоставимо с z8 (BRD §1, BRD M-9,
AC-07..AC-09) без перегенерации тайлов.
- Hillshade становится доступен на z9 (BRD F-01, AC-01, AC-03) —
пользователь видит тени на «обзорном» зуме планирования маршрута.
- Регрессия z8 (BRD F-11, AC-06) и z14 (BRD F-12, AC-10) выполняется
автоматически за счёт явных stops в `interpolate`.
- Backend, тайлы, конфиги не трогаются → 0 риск регрессии
серверной/инфраструктурной части.
- `applyTerrainLayer` остаётся обратно-совместимым → если позже
появится ещё один raster-слой (например, hypso в UI) — функция
переиспользуется.
### Отрицательные / Принимаем
- На z12-z14 `'nearest'`-resampling даёт лёгкую «пикселизацию»
hillshade при overzoom (R-T-3 в `10-tech-risks.md`). Принимаем:
на z12+ пользователь обычно отключает hillshade в пользу подложки,
альтернатива (два layer'а с разным resampling) — overkill.
- Сетевой трафик PNG-тайлов рельефа может вырасти до +35% на
типичной сессии активного зумирования (BRD M-10, NFR-03).
Принимаем: `Cache-Control: immutable` + браузерный кэш + nginx-кэш
поглощают это после первого визита.
- Один paint для всех тем может оказаться неоптимальным для
`theme-dark` или спутника. Принимаем риск; митигация через
follow-up ADR-018 если AC-11/AC-12 проваливаются.
### Технический долг
- **TD-1: Перегенерация hillshade с z-factor 2.5-3.0 для z9-z14.**
Открыт как follow-up «hillshade-rerender-z9-z14» при недостаточности
ET-013. Вероятность по риск-таблице — низкая.
- **TD-2: Theme-specific paint (ADR-018).** Открывается при провале
AC-11 или AC-12.
- **TD-3: Подключение гипсометрии (hypso) в UI.** Тайлы есть, чекбокса
нет. Отдельная задача (не зависит от ET-013).
- **TD-4: Возможное вынесение `HILLSHADE_PAINT` / `TRI_PAINT` в
отдельный модуль `src/web/terrain-paint.js`** — когда в проекте
появится JS-test-инфраструктура.
- **TD-5: Multidirectional hillshade** — отдельный work-item, если
ET-013 окажется недостаточным и пользователи продолжат жаловаться
на «плоскость» рельефа на крупных зумах.
## Альтернативы для будущего
| # | Идея | Когда возвращаться |
|---|------|---------------------|
| F-1 | Перегенерация hillshade с z-factor 2.5 | Если AC-07..AC-09 не выполняются после калибровки stops |
| F-2 | Theme-specific paint (ADR-018) | Если AC-11 или AC-12 проваливаются |
| F-3 | Подключение hypso в UI | По бизнес-запросу |
| F-4 | Переход на raster-dem (Mapbox Terrain RGB) | При смене стратегии рельефа целиком |
| F-5 | Векторные горизонтали (contours) | Отдельная фича PH-6.5 |
| F-6 | Multidirectional hillshade | При жалобах на плоскость на z12+ |
## Связанные документы
- BRD: `docs/work-items/ET-013/01-brd.md` §3 (F-01..F-14), §5 (R-1..R-11), §2.4 (out of scope reasoning)
- TRZ: `docs/work-items/ET-013/02-trz.md` §3 (REQ-F-01..REQ-F-21)
- AC: `docs/work-items/ET-013/03-acceptance-criteria.md` (AC-01..AC-22)
- Инфра: `docs/work-items/ET-013/07-infra-requirements.md`
- Данные: `docs/work-items/ET-013/08-data-requirements.md`
- Риски: `docs/work-items/ET-013/10-tech-risks.md`
- Глобальный ADR-индекс: `docs/architecture/adr/README.md`
- Архитектура рельефа PH-6: `docs/phases/PH-6.terrain/` (наследие)
- Прецедент ADR-016 (ET-012) — формат «калибровочного» ADR

View File

@@ -0,0 +1,249 @@
---
type: infra-requirements
work_item_id: ET-013
title: "Инфраструктурные требования — ET-013: Zoom-aware paint для terrain-слоёв на z9-z11"
version: 1
status: approved
created_at: 2026-06-04
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-013
## 1. Резюме
ET-013 — **frontend paint-калибровка**. Меняются два файла исходного
кода (`src/web/app.js`, `src/web/index.html`) + добавляются тесты.
Инфраструктура **не меняется**:
- 0 новых docker-сервисов;
- 0 изменений в `Dockerfile`;
- 0 изменений в `docker-compose.yml`;
- 0 новых файлов БД, миграций, индексов;
- 0 новых cron-записей;
- 0 новых env / секретов / API-ключей;
- 0 новых исходящих HTTPS-соединений;
- 0 новых портов;
- 0 изменений в nginx (тайлы рельефа отдаются с тех же путей
`/enduro/terrain/{layer}/{z}/{x}/{y}.png`);
- 0 изменений в backend (`src/api/main.py:terrain_tile` без правок).
Эскалация: **minor change** (см. ADR-017 §«Классификация изменения»).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Новый сервис | **Нет** |
| Изменения `Dockerfile` | **Нет** |
| Изменения `docker-compose.yml` | **Нет** |
| Перезапуск `app` после деплоя | Нужен — `docker compose up -d --no-deps app` (≈ 5 сек простоя). Подхватывает обновлённые `src/web/app.js` и `src/web/index.html` (отдаются как статика из контейнера) |
| Перезапуск `gps-collector` | Не нужен (не затронут) |
| Очистка серверных кэшей | Не требуется (backend не меняется; `/terrain/*` endpoint и `Cache-Control: max-age=31536000, immutable` без изменений) |
| Очистка клиентских кэшей | Не требуется как часть деплоя, но пользователю при первой загрузке после деплоя браузер дёрнет свежий `app.js` (cache-busting через nginx if-modified-since) |
### 2.1 Зависимости между сервисами
Без изменений vs PH-6 / ET-007:
- `app` → файлы `/app/data/terrain/{hillshade,tri,hypso}/{z}/{x}/{y}.png`
(read-only при отдаче клиенту).
- `nginx (host)``app:8000` через docker-network bridge.
## 3. Сеть
| Аспект | Требование |
|--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Новые входящие порты | **Нет** |
| Изменения nginx | **Нет** (`location /enduro/terrain/` без правок; новые комбинации `(z, x, y)` для z=9 — просто другие значения существующего path-параметра) |
| nginx gzip для PNG | Не применяется (PNG уже сжат). Без изменений vs PH-6 |
| Кэш-заголовки на `/terrain/*` | Без изменений: `Cache-Control: public, max-age=31536000, immutable` (см. `src/api/main.py:1252`). Браузерный кэш + nginx-кэш агрессивно поглощают повторы |
| Новые исходящие соединения | **Нет** — никаких внешних API не дёргается, всё локально |
| CORS | Без изменений; `/terrain/*` отдаётся в том же origin, что и `index.html` |
| HTTPS / TLS | Без изменений — nginx с Let's Encrypt сертификатом DuckDNS |
### 3.1 Ingress / Egress — оценка дельты
Изменения сетевого паттерна (BRD M-10, NFR-03):
- **Hillshade**: UI-минзум понижается с 10 до 9 → пользователь видит
слой на одной zoom-ступени раньше. Один тайл z9 == 4 тайла z10 по
покрытию территории, поэтому при «активной zoom-сессии» z=8→z=12
с включённым hillshade добавляется ≤ 1 zoom-ступень тайлов.
- **TRI**: minzoom источника не меняется (5), opacity меняется только
для уже-запрашиваемых тайлов. Дельта запросов **0**.
- Итого: при типичной сессии «10 зумов между z8 и z12 с обоими слоями»
объём PNG растёт **≤ 35%** (BRD M-10, AC-21).
Размер одного PNG-тайла рельефа (terrain) ≈ 8-30 KB (без gzip — PNG
уже сжат). На сессию: было ~60 тайлов × 20 KB = 1.2 MB, станет
~80 тайлов × 20 KB = 1.6 MB. Дельта на пользователя: ~0.4 MB.
При 10 одновременных пользователях на mva154 — пик ≈ 4 MB/сек
дополнительного uplink, мизер по сравнению с uplink сервера
(≥ 100 Mbps по DuckDNS).
Кэш браузера (`immutable, max-age=31536000`) поглощает 2-й и
последующие визиты целиком.
### 3.2 Rate-limit на `/terrain/*`
**Не вводим в этой итерации.** PNG-тайлы — статика с агрессивным
кэшем; DDoS-стоимость низкая (sendfile из ФС без вычислений). Если в
проде обнаружится скан z=9-z=14 grid'а — добавляется отдельным
DevOps-task'ом, не в ET-013.
## 4. Серверные ресурсы
| Аспект | Требование |
|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| CPU `app` | Без изменений по архитектуре. Раздача PNG — `FileResponse` (sendfile, zero-copy через ядро), CPU-cost пренебрежимый. Рост запросов до +35% даёт +0.5% CPU на сервере при пике сессий |
| RAM `app` | Без изменений. PNG не буферизуются в памяти; sendfile из файловой системы |
| Disk `app` | Без изменений. Тайлы рельефа лежат в `/home/slin/enduro-trails/data/terrain/{hillshade,tri,hypso}/{z}/{x}/{y}.png` (объём по PH-6 baseline). Никаких новых файлов / volume |
| CPU `gps-collector` | Без изменений (не затронут) |
| RAM `gps-collector` | Без изменений |
| Disk `gps-collector` | Без изменений |
### 4.1 Размер тайлов рельефа на диске
**Не меняется.** ET-013 не перегенерирует тайлы; используются
существующие нарезки z8-z14 из PH-6. Если pre-deploy smoke
(см. §6.2 шаг 1) обнаружит отсутствие тайлов z9-z11 — задача
останавливается, открывается PH-6 follow-up на догенерацию
(BRD R-11, AC-19).
## 5. Конфигурация и секреты
| Аспект | Требование |
|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| Новые env-переменные | **Нет** |
| Новые секреты | **Нет** |
| Новые API-ключи | **Нет** |
| Изменения `config/*.yaml` | **Нет** |
| Изменения runtime config | **Нет**`HILLSHADE_PAINT` и `TRI_PAINT` — JS-константы, живут в коде и меняются коммитом (BRD §6 q&a, ADR-017 §M) |
| Изменения `style.json` / `style-dark.json` | **Нет** — растровые terrain-слои добавляются динамически из JS, в стилях не описаны |
## 6. Деплой
### 6.1 Среды
- **dev (локально)**: `make dev` (docker compose up `app`). Достаточно
`git pull && make dev` для смены поведения.
- **test (mva154)**: `https://openclaw.mva154.duckdns.org/enduro/`.
CI/CD — Gitea Actions; деплой через `make deploy-test` или ручной
SSH + `docker compose up -d --no-deps --build app` (см. §6.2).
- **prod** — пока не задействован; ET-013 деплоится только в test.
### 6.2 Процедура деплоя в test
Последовательность шагов (REQ-F-20 в TRZ §3):
1. **Pre-deploy smoke**: проверить наличие тайлов z9-z11 на test-среде:
```bash
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 —
merge приостанавливается (AC-19), открывается PH-6 follow-up на
догенерацию тайлов.
2. **Сборка образа**: `docker compose build app` на mva154 (после `git pull`).
3. **Перезапуск `app`**: `docker compose up -d --no-deps app`.
4. **Post-deploy smoke**:
```bash
# Проверка статики app.js обновился
curl -s 'https://openclaw.mva154.duckdns.org/enduro/app.js' | grep -c 'HILLSHADE_PAINT'
# Ожидается ≥ 1
```
5. **Ручная валидация AC-03..AC-12** через DevTools:
- открыть карту, центр над Окой/югом Москвы (`[37.6, 54.5]`);
- `window._map.setZoom(9)` — кнопка «Тени рельефа» активна, hint скрыт;
- включить «Тени рельефа» и «Перепады»;
- скриншоты на z9/z10/z11/z14 → визуальная приёмка AC-07..AC-10;
- переключить тему `theme-dark` → проверить AC-11;
- переключить подложку `#base-btn-satellite` → проверить AC-12.
6. **Запись результатов в `13-test-report.md` и `14-deploy-log.md`**.
### 6.3 Rollback
В случае проблем (например, AC-11 «hillshade сливается с dark-темой»,
без возможности быстрой donastройки stops):
1. **Frontend rollback**: `git revert <commit>` + `docker compose up -d --no-deps --build app`.
2. **Cache invalidation**: не требуется (backend не меняется, browser
cache на статике `app.js` инвалидируется по if-modified-since
автоматически).
RTO: ≤ 5 минут (один `docker compose up -d --no-deps app`).
RPO: 0 — никаких изменений в БД, никаких данных не теряется.
### 6.4 CI/CD-гейты
- `make lint` (ruff + eslint) — должен быть зелёным (AC-18).
- `make test` (pytest unit + integration) — зелёный (AC-15..AC-17).
- `pytest tests/integration/test_terrain_z9_tiles.py` — c
`@pytest.mark.skipif` для CI без данных (AC-16), не блокирует
merge.
## 7. Observability / Логирование
| Аспект | Требование |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Новые лог-сообщения | **Нет** (NFR-06 в TRZ §4) |
| Существующие лог-сообщения | `uvicorn.access` логирует все запросы к `/terrain/*` с длиной ответа — этого достаточно для мониторинга дельты трафика после деплоя |
| Метрики / Prometheus | Не вводим в MVP |
| Health-endpoint | `GET /api/health` (если есть) — без изменений |
### 7.1 Что мониторить после деплоя
В `nginx access.log` на mva154 (вручную, без алёртов) — первая неделя:
- **Запросы к `/terrain/hillshade/9/*/*.png`**: должны появиться
(раньше клиент их не дёргал). Если 404 — `data/terrain/hillshade/9/`
отсутствует, инцидент (BRD R-11).
- **Объём ответов**: ≤ +35% к baseline на терминальную пользовательскую
сессию (BRD M-10, AC-21).
- **Status codes**: только 200/304 (304 от if-modified-since). Никаких
500/502 быть не должно.
## 8. Резервное копирование / Disaster recovery
| Аспект | Требование |
|------------------------------|-----------------------------------------------------------------------------------------------------|
| Backup БД | Без изменений vs ET-008/PH-6 (ET-013 не трогает БД) |
| Backup тайлов рельефа | Без изменений vs PH-6. Регенерируемы из SRTM при необходимости |
| Время восстановления (RTO) | ≤ 5 минут (rollback контейнера, см. §6.3) |
| Точка восстановления (RPO) | 0 — никаких данных не теряется |
## 9. Безопасность
| Аспект | Требование |
|-------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
| Auth / Authorization | Без изменений (NFR-05 в TRZ §4). `/terrain/*` — публичный (как и был) |
| Валидация входных данных | Без изменений; existing валидация `(z, x, y)` в `terrain_tile` уже корректно принимает любые валидные z |
| CSP | Без изменений |
| Rate-limit | Не вводим в MVP (см. §3.2) |
| TLS | Без изменений — nginx с Let's Encrypt сертификатом DuckDNS |
## 10. Совместимость
| Аспект | Требование |
|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| API контракт `/terrain/*` | Не меняется (REQ-F-18). Любые клиенты (старые tab'ы со старым `app.js`) продолжают работать; они просто не дёргают z=9 hillshade |
| MapLibre GL JS совместимость | MapLibre 4.7.0 (`index.html:10`) поддерживает `interpolate` для `raster-opacity` и `raster-contrast`. `raster-resampling` не поддерживает `interpolate` — поэтому глобально `'nearest'` (см. ADR-017 §R) |
| Совместимость с PH-6 stack | Никаких изменений; калибровка идёт поверх существующих PH-6 тайлов |
| Совместимость с ET-007 (Спутник) | AC-12 проверяет визуально. В случае проблем — открывается ADR-018 (theme-specific paint) |
| Совместимость с ET-005 (units), ET-006 (GPX), ET-008 (public tracks) | Без изменений; ET-013 трогает только terrain-слои |
| Совместимость с OSRM | Не затронуто (роутинг работает с OSRM-графом независимо) |
| localStorage migration | Не нужно (REQ-F-17). Существующие ключи `terrain-hillshade`, `terrain-tri` — без изменений. Пользователи с включённым hillshade автоматически увидят слой на z9 при следующей загрузке |
## 11. Связанные документы
- `01-brd.md` §3 (F-01..F-14), §6 (Зависимости, инфра), AC §AC-19 (pre-deploy check)
- `02-trz.md` §3 REQ-F-20 Деплой и валидация, §4 NFR
- `06-adr/ADR-017-zoom-aware-terrain-paint.md` §«Классификация изменения», §«Последствия»
- `08-data-requirements.md` (этот пакет)
- `10-tech-risks.md` (этот пакет)
- `docs/work-items/ET-012/07-infra-requirements.md` — образец «zero-infra» work-item (наследие)
- `docs/work-items/ET-011/07-infra-requirements.md` — образец «zero-infra» work-item (наследие)

View File

@@ -0,0 +1,289 @@
---
type: data-requirements
work_item_id: ET-013
title: "Требования к данным — ET-013: Zoom-aware paint для terrain-слоёв на z9-z11"
version: 1
status: approved
created_at: 2026-06-04
authors:
- "agent:architect"
---
# Требования к данным — ET-013
## 1. Резюме
ET-013 — **pure client render change**. Никаких изменений схемы БД,
никаких новых таблиц/индексов/миграций, никаких изменений тайлов на
диске, никаких новых ключей `localStorage`, никаких изменений
конфигов источников.
Меняется **только то, как уже существующие PNG-тайлы рельефа
отрисовываются MapLibre на клиенте**:
- `raster-opacity` становится `interpolate`-выражением по `['zoom']`
(вместо константы).
- Для hillshade добавляется `raster-contrast` (тоже `interpolate`).
- `raster-resampling` для обоих terrain-слоёв переключается с
`'linear'` на `'nearest'`.
**Меняется:**
- набор `raster paint properties` у двух MapLibre-слоёв
(`terrain-hillshade`, `terrain-tri`);
- визуальная читаемость рельефа на z9-z11 (целевое улучшение).
**Не меняется:**
- содержимое и формат PNG-тайлов в `data/terrain/{hillshade,tri,hypso}/`
(PH-6 наследие);
- schema БД `centralfederal.sqlite` и `gps_tracks.sqlite`;
- контракт API `/terrain/{layer}/{z}/{x}/{y}.png` (REQ-F-18);
- содержимое тайлов hypso (в UI не подключён, OOS);
- параметры генератора hillshade на сервере (azimuth, altitude,
z-factor — PH-6, OOS);
- параметры классификации TRI (5-уровневая палитра — PH-6, OOS);
- ключи `localStorage` (`terrain-hillshade`, `terrain-tri` — REQ-F-17);
- содержимое `config/*.yaml`;
- стили `style.json`, `style-dark.json` (растровые terrain-слои в
них не описаны — добавляются динамически из JS).
## 2. Архитектурные границы данных
| Слой данных | Тип | Расположение | Изменения в ET-013 |
|-----------------------------------|----------------|----------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| OSM-vector (`trails`) | существующий | `/app/data/centralfederal.sqlite` | **нет** |
| Личные GPX треки (ET-006) | существующий | браузер (memory) | **нет** |
| Публичные GPS треки (ET-008) | существующий | `/app/data/gps_tracks.sqlite` | **нет** |
| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | **нет** |
| Terrain hillshade PNG | существующий | `data/terrain/hillshade/{z}/{x}/{y}.png` (z=8..14) | **read-only**: добавляется новая комбинация `(z=9, x, y)`, которая клиент раньше не запрашивал. Тайлы на диске уже есть (PH-6 нарезка) |
| Terrain TRI PNG | существующий | `data/terrain/tri/{z}/{x}/{y}.png` (z=8..14) | **read-only**: те же тайлы, что и раньше; меняется только paint |
| Terrain hypso PNG | существующий | `data/terrain/hypso/{z}/{x}/{y}.png` | **не используется** в ET-013 (OOS) |
| User UI state | существующий | `localStorage` | **нет** новых ключей, нет миграции |
| MapLibre client tile cache | существующий | браузер (LRU MapLibre, ~100 MB) | **расширяется ключевым пространством**: теперь могут лежать тайлы hillshade с `z = 9` (раньше не запрашивались) |
| Серверный кэш `/terrain/*` | не предусмотрен | n/a (FileResponse + Cache-Control immutable) | **нет** |
## 3. Серверные данные
### 3.1 Структура `data/terrain/`
**Без изменений vs PH-6.** Структура каталога:
```
data/terrain/
├── hillshade/
│ ├── 8/{x}/{y}.png # baseline
│ ├── 9/{x}/{y}.png # используется ET-013 впервые на клиенте
│ ├── 10/{x}/{y}.png # baseline (10+ уже использовался)
│ ├── 11/{x}/{y}.png
│ ├── 12/{x}/{y}.png
│ ├── 13/{x}/{y}.png
│ └── 14/{x}/{y}.png
├── tri/ # та же структура, z=8..14
└── hypso/ # та же структура, в UI не подключён
```
Никаких ALTER/CREATE/INSERT/UPDATE/DELETE на стороне данных. Никакой
догенерации тайлов. Никакого преобразования формата (PNG остаётся
PNG 256×256).
### 3.2 Объёмы данных
| Метрика | Текущее (PH-6) | После ET-013 | Гейт |
|------------------------------------------|---------------------|-------------------------------|------------------------------------------------------|
| Объём PNG hillshade на диске | ~ X MB (PH-6 baseline) | без изменений | n/a |
| Объём PNG TRI на диске | ~ Y MB | без изменений | n/a |
| Запросы hillshade за сессию | N (только z≥10) | ~ 1.25-1.35 × N (добавился z=9) | BRD M-10: ≤ +35% |
| Запросы TRI за сессию | M (z=5..14) | без изменений | n/a |
### 3.3 Pre-deploy validation тайлов z9-z11
**Обязательная проверка перед merge** (BRD R-11, AC-19):
```bash
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 «hillshade-z9-z14
backfill». См. `07-infra-requirements.md` §6.2 шаг 1.
### 3.4 API endpoint `terrain_tile`
**Без изменений** (`src/api/main.py:1240`):
- URL: `GET /terrain/{layer}/{z}/{x}/{y}.png`, `layer ∈ {hillshade, tri, hypso}`.
- Возвращает: PNG из файловой системы (sendfile через `FileResponse`).
- Заголовки: `Cache-Control: public, max-age=31536000, immutable`
без изменений. Браузерный кэш и nginx-кэш агрессивно поглощают
повторы.
- Контракт OpenAPI — без изменений (REQ-F-18, NFR-04).
## 4. Клиентские данные
### 4.1 localStorage
**Без изменений vs PH-6 / ET-007.** Используются ключи:
| Ключ | Назначение | Изменения в ET-013 |
|----------------------------|---------------------------------------------|--------------------|
| `terrain-hillshade` | `'1' | '0'` — чекбокс «Тени рельефа» | **нет** |
| `terrain-tri` | `'1' | '0'` — чекбокс «Перепады» | **нет** |
REQ-F-17 в TRZ §3: «никакой миграции localStorage не нужно».
Существующие сессии при следующей загрузке автоматически получают
новый UI-порог 9 (вместо 10) и новые `HILLSHADE_PAINT` / `TRI_PAINT`
константы. Если у пользователя `terrain-hillshade === '1'` и текущий
zoom ≥ 9 — hillshade покажется автоматически (раньше показался бы
только на z ≥ 10).
### 4.2 MapLibre LRU (browser-side)
Браузерный MapLibre кэширует растровые тайлы в собственном LRU
(~100 MB по умолчанию). После ET-013:
- Ключевое пространство кэша: `(source_id, z, x, y)` — расширяется
для `terrain-hillshade-source` на `z = 9` (раньше source имел
`minzoom: 10` → запросов z=9 не было).
- Объём — управляется MapLibre, ~100 MB. Дельта мизерная (тайл
hillshade ≈ 8-30 KB).
- Никакой синхронизации/инвалидации не нужно (тайлы на сервере
не меняются; `Cache-Control: immutable` гарантирует консистентность).
### 4.3 In-memory paint constants
Новые константы в `src/web/app.js` после `TERRAIN_BASE_URL`:
```js
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'
};
const TRI_PAINT = {
'raster-opacity': ['interpolate', ['linear'], ['zoom'],
5, 0.55, 7, 0.65, 8, 0.70,
9, 0.80, 10, 0.85, 11, 0.85,
12, 0.75, 15, 0.70],
'raster-resampling': 'nearest'
};
```
- Это **компилируемые MapLibre `interpolate`-выражения**, не «данные»
в архитектурном смысле. Живут в коде, изменяются коммитом
(BRD §6 q&a «Делать ли paint-таблицы переменными окружения /
config'ом? Нет — преждевременная абстракция»).
- Память: < 1 KB суммарно. Производительность: MapLibre кэширует
скомпилированные выражения (NFR-01).
## 5. Контракты API
### 5.1 `GET /terrain/{layer}/{z}/{x}/{y}.png`
| Аспект | До ET-013 | После ET-013 |
|-----------------------|--------------------------------------------------------|-------------------------------------------------------------------------------------|
| Поддерживаемые `layer`| `hillshade`, `tri`, `hypso` | без изменений |
| Path-параметр `z` | принимается любой валидный z, тайлы на диске z=8..14 | без изменений |
| Response 200 | для существующих `(z, x, y)` PNG | без изменений |
| Response 404 | для несуществующих `(z, x, y)` | без изменений |
| Response Content-Type | `image/png` | без изменений |
| Cache-Control | `public, max-age=31536000, immutable` | без изменений |
**Старые клиенты** (старый `app.js` со старым `minzoom = 10` для
hillshade) — продолжают работать. Никакого breaking change в
контракте нет (NFR-04).
### 5.2 Прочие endpoint'ы
ET-013 не трогает: `/api/gps-tracks/*`, `/api/trails/*`, `/api/route/*`,
`/api/health`. Их контракты — без изменений.
## 6. Миграции
**Нет.** Никаких миграций БД, миграций localStorage, миграций
конфигов, миграций тайлов.
При деплое в test:
- `data/terrain/*` — без изменений (read-only для `app`).
- БД `centralfederal.sqlite`, `gps_tracks.sqlite` — без изменений.
- Серверный кэш — отсутствует у `/terrain/*` (статическая раздача
с `Cache-Control: immutable`).
- Клиентский MapLibre LRU — самоочищается при reload браузера;
явной миграции не нужно.
- localStorage — старые ключи интерпретируются как раньше;
включённый ранее hillshade автоматически появится на z9 (REQ-F-17,
AC-14).
## 7. Тестовые данные
### 7.1 Для unit-тестов
`tests/unit/test_terrain_paint.py` (новый, REQ-F-13 / REQ-F-14):
- Python-парсер исходного `src/web/app.js` через `re`.
- Никаких внешних зависимостей.
- Никаких фикстур данных.
- Проверяет наличие `HILLSHADE_PAINT` / `TRI_PAINT`, наличие
ключевых stops (`9, 0.65`, `11, 0.55`, `14, 0.40`, `8, 0.70`,
`10, 0.85`), наличие `'raster-resampling': 'nearest'`, порог
`zoom < 9` в `updateHillshadeAvailability`.
### 7.2 Для integration-тестов
`tests/integration/test_terrain_z9_tiles.py` (новый, REQ-F-15):
- Использует FastAPI `TestClient` для `src/api/main.py:app`.
- Опирается на наличие файла `data/terrain/hillshade/9/<x>/<y>.png`
если каталога нет, тест `skipped` с reason (CI без данных).
- На test-среде mva154 (где данные есть) — выполняется как
smoke-проверка endpoint'а.
- Дополнительно: `test_hillshade_invalid_zoom_404` — sanity на
невалидном zoom.
### 7.3 Для UI-тестов (Playwright)
`04b-ui-test-cases.md` — список тест-кейсов TC-UI-01..TC-UI-10:
- Запускается на test-среде `https://openclaw.mva154.duckdns.org/enduro/`.
- Данные — реальные PNG-тайлы рельефа на mva154 (PH-6 нарезка).
- Скриншот-эталоны для AC-06..AC-12 (визуальная читаемость) — в
`tests/e2e/screenshots/et013/`.
- Скриншоты сравниваются оператором (качественная приёмка), не
пиксельный diff (BRD M-9, R-1..R-3).
## 8. Резервные копии и DR
Без изменений vs PH-6.
- БД `centralfederal.sqlite`, `gps_tracks.sqlite` — бэкап тем же
crontab-скриптом, что и раньше; ET-013 не трогает.
- PNG-тайлы `data/terrain/*` — регенерируются из SRTM при необходимости
(PH-6 pipeline). RPO для тайлов = время регенерации (часы),
но они read-only и не теряются при деплое ET-013.
RPO для ET-013: 0 (никаких данных не пишется/не теряется).
## 9. Privacy / Compliance
| Аспект | Требование |
|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| PII | **Нет.** PNG-тайлы рельефа — derivative из SRTM 30 м (NASA, public domain). Никаких персональных данных нигде в data-flow ET-013 |
| Licensing | **Без изменений** (PH-6 наследие: SRTM 30 m — public domain; derivative PNG распространяется свободно). ET-013 не меняет источник данных |
| Attribution | MapLibre attribution control отображает атрибуцию активных источников (OSM, Esri). Атрибуция SRTM/NASA не выводится в UI (PH-6 решение); ET-013 это не меняет |
| GDPR / 152-ФЗ | Не применимо (нет PII) |
## 10. Связанные документы
- `01-brd.md` §2.1 (текущая реализация), §3 (F-01..F-14), §6 (Зависимости.Данные)
- `02-trz.md` §3 REQ-F-04..REQ-F-09 (paint constants), REQ-F-13..REQ-F-15 (тесты), REQ-F-17 (localStorage), REQ-F-18 (API), REQ-F-19 (configs/styles)
- `06-adr/ADR-017-zoom-aware-terrain-paint.md` §«Решение», §«Последствия»
- `07-infra-requirements.md` §3 (network), §6 (deploy procedure), §3.1 (ingress estimate)
- `10-tech-risks.md` (этот пакет)
- `docs/work-items/ET-012/08-data-requirements.md` — образец «read-pattern change» документа (наследие)
- `docs/phases/PH-6.terrain/` — наследие нарезки тайлов

View File

@@ -0,0 +1,357 @@
---
type: tech-risks
work_item_id: ET-013
title: "Технические риски — ET-013: Zoom-aware paint для terrain-слоёв на z9-z11"
version: 1
status: approved
created_at: 2026-06-04
authors:
- "agent:architect"
---
# Технические риски — ET-013
Технические риски этапа калибровки клиентского paint для растровых
terrain-слоёв. Бизнес-риски — в BRD §5 (R-1..R-11). Шкала:
вероятность (Н/С/В) × влияние (Н/С/В).
## R-T-1 — Тайлы hillshade z9-z11 отсутствуют на test-среде
- **Описание:** BRD §2.1 утверждает, что PH-6 нарезала hillshade
z8-z14. Если реальная нарезка на mva154 отличается (например,
z10-z14), при включении hillshade на z9 пользователь увидит
404-шахматную доску, а в DevTools — череду failed requests.
- **Вероятность / Влияние:** Н / В.
- **Митигация:**
- **Архитектурное решение (ADR-017 §U-A):** pre-deploy smoke
`curl -I` на 3 разных тайла (z9/z10/z11) над ЦФО — обязателен
перед merge (`07-infra-requirements.md` §6.2 шаг 1, AC-19).
- **Эскалация:** при 404 — задача останавливается, открывается
PH-6 follow-up «hillshade-z9-z14 backfill». ET-013 не мержится.
- **Acceptance гейт:** AC-19 в `03-acceptance-criteria.md`.
## R-T-2 — `raster-contrast` 0.40 даёт «пересвет» / черноту на тёмных тайлах
- **Описание:** На z9-z11 hillshade-тайлы из тёмных лесных зон
(низкая средняя яркость PNG) при `raster-contrast: 0.40` могут
«провалиться в черноту» — пиксели clipping'уются к 0, тени
превращаются в чёрные кляксы, теряя информацию.
- **Вероятность / Влияние:** С / С.
- **Митигация:**
- **Архитектурное решение (ADR-017 §C-A):** stops контраста
подобраны консервативно (0.40 на z9 → быстрый спад к 0 на z14);
значения калибруются по результатам визуальной приёмки.
- **Acceptance гейт:** TC-UI-04-Z10-Q (BRD R-1, AC-07..AC-09)
— оператор смотрит скриншоты на холмистом районе. При
«пересвете» — снижаем contrast в stops до 0.25-0.30 итеративно.
- **Принцип:** stops живут в коде, правка — одна строка, не ADR.
## R-T-3 — `'nearest'`-resampling на overzoom z12-z14 даёт пикселизацию
- **Описание:** При overzoom (когда MapLibre тянет тайл z14 для
z15-z18) `'nearest'`-resampling показывает крупные квадраты вместо
плавных теней. Это особенно заметно на hillshade.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение (ADR-017 §R-A):** MapLibre не
поддерживает `interpolate` для `raster-resampling`, поэтому
глобальное `'nearest'` — единственный простой путь. Альтернатива
(два layer'а) отклонена как overkill.
- **Контекст использования:** на z12+ пользователь обычно
отключает hillshade в пользу подложки (для города нужны улицы,
а не тени). Это вторичный сценарий.
- **Acceptance гейт:** AC-10 (TC-UI-06-Z14-Q) — оператор
подтверждает «не темнее и не контрастнее, чем до ET-013» (т.к.
opacity и contrast уже вернулись к baseline). Пикселизация
допустима, если не нарушает читаемость.
- **Fallback:** если визуально неприемлемо — отдельным минорным
патчем вводится второй layer hillshade с `'linear'` для z12+,
переключаемый по `getZoom()`. Это **не часть ET-013**.
## R-T-4 — Сетевой трафик растёт > +35% при активной zoom-сессии
- **Описание:** Снижение UI-минзума hillshade с 10 до 9 добавляет
+1 zoom-уровень. На активной сессии (пользователь крутит зум
z8→z11→z8→z11 много раз) первая загрузка z9 тайлов даёт
заметную дельту трафика. BRD M-10 = ≤ +35%.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение:** `Cache-Control: public,
max-age=31536000, immutable` (`src/api/main.py:1252`) +
браузерный кэш + nginx-кэш. После первого визита повторные
запросы дают 304 If-Modified-Since (или вовсе не доходят до
сервера — browser hits memory cache).
- **Acceptance гейт:** AC-21 в `03-acceptance-criteria.md` —
network-traffic ≤ 135% от baseline на сценарии zoom-петли
z=8→9→10→11→10→9→8.
- **Мониторинг:** см. `07-infra-requirements.md` §7.1 — первая
неделя оператор смотрит `nginx access.log` на аномалии.
## R-T-5 — На тёмной теме (ET-007 `theme-dark`) hillshade с opacity 0.65 + contrast 0.40 сливается в кашу
- **Описание:** Тёмная подложка + полупрозрачный тёмный hillshade
с усиленным контрастом → визуально неразличимая «грязь». BRD R-2.
- **Вероятность / Влияние:** С / С.
- **Митигация:**
- **Архитектурное решение (ADR-017 §T-A):** в MVP — один paint
для всех тем. Если AC-11 проваливается — открывается ADR-018
«theme-specific terrain paint» с отдельной таблицей stops для
`theme-dark` (через подписку на `theme-change` event и
`setPaintProperty`).
- **Acceptance гейт:** AC-11 (TC-UI-09-Z10-DARK-Q) — оператор
проверяет на dark + holmistom районе. Если провал — фиксируется
в `13-test-report.md` и открывается follow-up.
- **Принцип:** не плодим сложность пока не доказана необходимость.
## R-T-6 — На спутниковой подложке (ET-007) hillshade «глушит» снимок
- **Описание:** Esri World Imagery уже содержит визуальный рельеф
(тени снимков). Поверх него полупрозрачный hillshade с opacity
0.65 → снимок превращается в «серую плёнку», пользователь теряет
цвета поверхности. BRD R-3.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- **Архитектурное решение (ADR-017 §T-A):** UX-нота: на спутнике
пользователь обычно отключает hillshade — снимок и так
«показывает» рельеф. Если AC-12 проваливается — open ADR-018
с правилом «на satellite layer'е opacity hillshade = старые
0.40» (через подписку на `applyBaseLayer`).
- **Acceptance гейт:** AC-12 (TC-UI-08-Z10-SAT-Q).
- **Принцип:** не плодим сложность пока не доказана необходимость.
## R-T-7 — TRI с opacity 0.85 на z9-z11 перекрывает грунтовки/тропы
- **Описание:** Слой `trails-*` (грунтовки, тропы) рисуется тонкими
линиями. Если TRI поднять до opacity 0.85, цветные пятна
категориальной палитры могут визуально «убить» линии трасс.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение:** существующая логика в
`applyTerrainLayer` (`src/web/app.js:3337-3339`) вставляет
terrain-слои **перед** первым `trails-*` или `poi-*` слоем —
z-order корректный. TRI рисуется ПОД линиями трасс, не НАД.
- **Тесты:** AC-07..AC-09 (визуальная приёмка на холмистом
районе с грунтовками).
## R-T-8 — MapLibre 4.7.0 не поддерживает `interpolate` для `raster-contrast`
- **Описание:** Если документация MapLibre врёт или версия 4.7.0
имеет regression на `raster-contrast` с zoom-выражением, paint
не применится, в DevTools будет warning, hillshade покажется с
default contrast = 0.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение (NFR-04 в TRZ §4):** MapLibre 4.7.0
официально поддерживает `interpolate` для всех raster paint
properties, кроме `raster-resampling`. Проверка — публичная
документация maplibre.org.
- **Smoke-проверка после деплоя:** DevTools
`window._map.getPaintProperty('terrain-hillshade', 'raster-contrast')`
должен вернуть массив `['interpolate', ...]` (AC-04).
- **Fallback:** если фактически не работает — заменить на
`case`-step выражение (грубое stepwise) или просто оставить
числовую константу `0.30` для z9-z11 (одно значение, без
zoom-плавности).
## R-T-9 — Регрессия z8: после правки TRI_PAINT на z8 перепады выглядят иначе
- **Описание:** В новой `TRI_PAINT` для z=8 стоит `0.70` — точно
как было. Но если при правке нечаянно поставить `8, 0.75` (или
пропустить стоп для z8 — тогда `interpolate` между `7→0.65` и
`9→0.80` даст на z8 значение ~0.72), регрессия z8 нарушится.
- **Вероятность / Влияние:** С / С.
- **Митигация:**
- **Архитектурное решение (ADR-017 §O-B):** в `TRI_PAINT` явно
указан стоп `8, 0.70` (не полагаемся на интерполяцию между
соседними стопами).
- **Acceptance гейт:** AC-06 (TC-UI-02-Z8-REGR) — скриншот
сравнивается с до-ET-013 baseline.
- **Unit-тест:** REQ-F-13 проверяет наличие `8, 0.70` в исходнике
`TRI_PAINT` через regex.
## R-T-10 — Регрессия z14: hillshade «не возвращается» к baseline
- **Описание:** Если stops `HILLSHADE_PAINT` не закрываются явным
стопом на z14 (например, `14, 0.40, 14, 0.00`), MapLibre
экстраполирует за пределами последнего стопа, и на z14-z15
hillshade может остаться «перегретым» (opacity 0.55, contrast
0.20).
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- **Архитектурное решение (ADR-017 §O-B / §C-A):** `interpolate`
у MapLibre clamp'ит значения за пределами крайних stops
(clamping behavior). Явные стопы `14, 0.40` для opacity и
`14, 0.00` для contrast обеспечивают регрессию z14.
- **Acceptance гейт:** AC-10 (TC-UI-06-Z14-Q) — скриншот
сравнивается с до-ET-013 baseline.
- **Unit-тест:** REQ-F-13 проверяет наличие `14, 0.40` и `14, 0`
в исходнике `HILLSHADE_PAINT`.
## R-T-11 — `applyTerrainLayer` ломает обратную совместимость
- **Описание:** При расширении сигнатуры
`opacity → opacityOrPaint: number | object` существующая логика
(если есть где-то ещё в `src/web/`) может сломаться при передаче
числа.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение (ADR-017 §A-A):** внутри функции —
нормализация `(typeof opacityOrPaint === 'number') ? {…linear…} :
opacityOrPaint`. Старый контракт работает без изменений.
- **Acceptance гейт:** AC-22, UT-COMPAT-01 (REQ-F-14) — статический
grep по `src/web/*.js`: подтверждает, что вызовов
`applyTerrainLayer` только два (оба в `onTerrainCheckbox`), оба
переведены на новые константы.
- **Принцип:** unit-тест на нормализацию + явный комментарий
`// ET-013: backwards-compat shim` в коде.
## R-T-12 — Старый клиент (закэшированный в браузере) не подхватывает новый `app.js`
- **Описание:** Пользователь с открытой вкладкой неделю назад имеет
закэшированный старый `app.js` со старым `applyTerrainLayer` без
paint-нормализации. При reload браузер должен дёрнуть свежий
`app.js`. Service worker — не настроен в MVP (PH-9 не реализована).
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение:** `src/web/index.html` загружает
`app.js` напрямую (без SW). nginx + `Cache-Control` на `*.js`
— стандартные (не immutable; If-Modified-Since работает).
При reload браузер делает conditional GET → 200 (если файл
изменился) или 304.
- **Backwards compat:** старый клиент с `minzoom=10` для hillshade
продолжает работать; он просто не запрашивает hillshade z=9.
Никаких 4xx-ответов нет (REQ-F-18 — контракт неизменен).
- **Митигация в долгую:** PWA / SW (PH-9) введёт правильную
inval-стратегию.
## R-T-13 — Hint «Зум 10+» забыт в HTML → расхождение с фактическим порогом
- **Описание:** В `src/web/index.html` строка
`<span id="terrain-hillshade-hint">Зум 10+</span>`. Если правка
REQ-F-10 потеряется (например, мердж-конфликт), у пользователя
на z<9 будет hint «Зум 10+», который противоречит фактическому
порогу 9.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение (REQ-F-10):** в HTML текст явно
меняется на «Зум 9+». Это атомарная правка, проверяется
grep'ом.
- **Acceptance гейт:** AC-01 — проверяет `«Зум 9+»` в исходнике
`index.html`. AC-03 — проверяет `hint.style.display === 'none'`
на z=9.
- **Unit-тест:** REQ-F-14 (UT-REG-02) — grep по строке `zoom < 9`
в `app.js` и `«Зум 9+»` в `index.html`.
## R-T-14 — `nearest`-resampling на TRI делает «зернистую» картинку, пользователю не нравится
- **Описание:** TRI — категориальная палитра (5 уровней). На
`'nearest'` ясно видны 30-метровые SRTM-клетки, картинка
выглядит «зернистой». BRD R-10 классифицирует это как «желаемое
поведение» (показ «реальных» границ перепадов), но возможен
субъективный негативный отзыв.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение (ADR-017 §R-A):** на TRI «зернистость»
— спецификация. Категориальные данные требуют резких границ,
`'linear'` их размывает.
- **Fallback:** если AC-07..AC-09 проваливаются с пометкой
«зернисто» — откатывается F-09 (TRI → `'linear'`), hillshade
остаётся на `'nearest'`. Это одна строка кода в `TRI_PAINT`.
- **Acceptance гейт:** AC-07..AC-09 — оператор подтверждает
качественную приёмку.
## R-T-15 — Performance деградация из-за `interpolate` в paint
- **Описание:** Если MapLibre на каждом zoom-tick пересчитывает
`interpolate`-выражение без кэширования, на слабых устройствах
(mobile, low-end) может появиться jank при зуме.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение (NFR-01 в TRZ §4):** MapLibre кэширует
скомпилированные `interpolate`-выражения; вычисление при
смене zoom — < 1 мс на frame.
- **Эмпирически:** существующие слои `gps_tracks.js`,
`trails-*` уже используют `interpolate` по zoom без жалоб.
- **Тест:** AC-13 (TC-UI-07-Z9-MOBILE) — Playwright mobile
viewport, проверяет работоспособность; не measure'ит FPS, но
регрессия проявится визуально.
## R-T-16 — Pre-deploy smoke не покрывает все регионы (тайлы z9 могут отсутствовать вне ЦФО)
- **Описание:** Pre-deploy `curl` проверяет 3 тайла над ЦФО. Если
нарезка z9 ограничена только ЦФО, пользователь над Уралом /
Алтаем увидит 404. По BRD §6 это OOS (MVP покрывает только
ЦФО), но риск стоит явно зафиксировать.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение:** в MVP test-среда обслуживает ЦФО
(`centralfederal.sqlite`). Тайлы вне ЦФО — out of scope.
- **Принцип:** если пользователь панорамирует за пределы ЦФО,
на z9-z14 он увидит «шахматку» из 404 и для terrain, и для
trails — это известная граница MVP, не баг ET-013.
- **Документация:** зафиксировать в `14-deploy-log.md` как
«known limitation».
## R-T-17 — `eslint` падает на новых `interpolate`-массивах
- **Описание:** Если в проекте настроен `eslint` с правилами
`no-magic-numbers` или жёстким `max-len`, длинные массивы
`['interpolate', ['linear'], ['zoom'], 9, 0.65, …]` могут
завалить линтер.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение:** существующие JS-файлы
(`gps_tracks.js`) уже используют похожие массивы — значит,
eslint их пропускает.
- **Acceptance гейт:** AC-18 (`make lint` зелёный). При проблеме
— добавить `// eslint-disable-next-line` точечно.
## R-T-18 — Калибровка stops «не угадывает» желаемую читаемость с первого раза
- **Описание:** Значения `9→0.65, 10→0.60, 11→0.55` для hillshade
выбраны архитектором по эстимейту из BRD. На реальных данных
оператор может сказать «на z9 ещё мало, на z10 уже слишком
темно». Это **итеративный процесс**, не «упало».
- **Вероятность / Влияние:** В / Н.
- **Митигация:**
- **Архитектурное решение:** stops живут в JS-константах
`HILLSHADE_PAINT` / `TRI_PAINT`. Правка одной цифры — одна
строка кода + новый коммит. Не требует архитектурного
re-decide (ADR-017 §«Технический долг» TD-1).
- **Процесс:** после первого деплоя — фикс stops по фидбеку
оператора без новой задачи. Учитывать в bandwidth-плане до
закрытия ET-013.
- **Гейт:** AC-07..AC-09 — качественные, оператор-driven.
Они и есть «точка калибровки».
## Сводная таблица
| # | Риск | Вер | Влиян | Митигация (тип) |
|-------|--------------------------------------------------------------------|-----|-------|--------------------------------------------------|
| R-T-1 | Тайлы z9-z11 отсутствуют | Н | В | Pre-deploy smoke + AC-19; STOP на 404 |
| R-T-2 | `raster-contrast` 0.40 — пересвет/чернота | С | С | Итеративная калибровка stops; AC-07..AC-09 |
| R-T-3 | `'nearest'` пикселизация на z12+ | С | Н | Принимается; fallback — двойной layer |
| R-T-4 | Трафик +35% превышает гейт M-10 | Н | Н | `immutable` кэш; AC-21 |
| R-T-5 | Hillshade на тёмной теме — каша | С | С | AC-11; follow-up ADR-018 при провале |
| R-T-6 | Hillshade «глушит» спутник | Н | С | AC-12; follow-up ADR-018 при провале |
| R-T-7 | TRI 0.85 перекрывает trails | Н | Н | Existing z-order (terrain ПОД trails) |
| R-T-8 | MapLibre 4.7.0 не поддерживает interpolate для raster-contrast | Н | Н | Документация подтверждает; fallback — case-step |
| R-T-9 | Регрессия z8 TRI | С | С | Явный стоп `8, 0.70`; AC-06; unit-тест |
| R-T-10| Регрессия z14 hillshade | Н | С | Явные стопы `14, 0.40` и `14, 0`; AC-10 |
| R-T-11| `applyTerrainLayer` обратная совместимость | Н | Н | Нормализация внутри функции; UT-COMPAT-01 |
| R-T-12| Старый клиент в кэше браузера | С | Н | Backwards-compat контракта |
| R-T-13| Hint «Зум 10+» забыт | С | Н | grep-проверка + AC-01 |
| R-T-14| TRI `'nearest'` — зернисто | С | Н | Specified behavior; fallback — откат F-09 |
| R-T-15| `interpolate` deg performance | Н | Н | MapLibre кэширует expr; NFR-01 |
| R-T-16| Pre-deploy smoke ≠ покрытие региона | С | Н | Known MVP limitation; deploy-log |
| R-T-17| eslint падает на длинных массивах | Н | Н | Существующий код уже использует такие массивы |
| R-T-18| Stops не угадывают с первого раза | В | Н | Итеративная калибровка; AC-07..AC-09 — qualitative |
## Связанные документы
- `01-brd.md` §5 Бизнес-риски R-1..R-11 (часть пересекается)
- `02-trz.md` §3 REQ-F-04..REQ-F-15 (paint, тесты), §4 NFR-01..NFR-07
- `06-adr/ADR-017-zoom-aware-terrain-paint.md` §«Решение», §«Последствия», §«Технический долг»
- `07-infra-requirements.md` §3 (network), §6 (deploy procedure), §7 (мониторинг)
- `08-data-requirements.md` §3.3 (pre-deploy validation), §5 (API contracts)
- `03-acceptance-criteria.md` AC-01..AC-22 (все гейты)

View File

@@ -0,0 +1,214 @@
---
type: review
work_item_id: ET-013
verdict: APPROVED
version: 2
created_at: 2026-06-04
updated_at: 2026-06-04
authors:
- "agent:reviewer"
related:
- "ET-013:trz"
- "ET-013:adr-017"
---
# Review ET-013 — Перепады высот на z9-z11 (re-run #2)
## TL;DR
- **Branch:** `feature/ET-013-z9-z11-z8`
- **Scope:** калибровка клиентского paint для hillshade/TRI на z9-z11
+ понижение UI-минзума hillshade с z10 до z9 + расширение whitelist
backend-endpoint'а на `tri` (фикс по результатам review v1, F-1).
- **HEAD:** `099669d fix(terrain): расширить whitelist endpoint'а на 'tri' (ET-013 review F-1)`
- **Что изменилось со времени review v1:**
- `src/api/main.py:1252` whitelist расширен:
`("hypso", "hillshade") → ("hypso", "hillshade", "tri")` + docstring
с пояснением (см. F-1 v1).
- `tests/integration/test_terrain_z9_tiles.py` параметризован по
`layer = ["hillshade", "tri"]` для z9/z10/z11; добавлен явный
регрессионный тест `test_known_terrain_layer_accepted_by_whitelist`
по всем трём слоям (см. F-2 v1).
- **Тесты:** `pytest tests/unit/test_terrain_paint.py`**17/17 PASS**,
`pytest tests/integration/test_terrain_z9_tiles.py`**6 passed, 7 skipped**
(skip — отсутствие PNG-данных в sandbox, ожидаемо).
- **Verdict: APPROVED.** P0/P1 не найдено. Остались два опциональных
P3 из v1, оба косметика — не блокеры.
## Что прочитано
- `docs/work-items/ET-013/00-business-request.md`
- `docs/work-items/ET-013/01-brd.md`
- `docs/work-items/ET-013/02-trz.md`
- `docs/work-items/ET-013/03-acceptance-criteria.md`
- `docs/work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md`
- `docs/work-items/ET-013/07-infra-requirements.md`
- `docs/work-items/ET-013/12-review.md` v1 (предыдущий вердикт)
- `CLAUDE.md`
- `git diff main...HEAD --stat` (18 файлов, +3911/-14)
- `git diff main...HEAD -- src/api/main.py src/web/app.js src/web/index.html`
- `src/api/main.py:1235-1264` (`terrain_tile` после фикса)
- `src/web/app.js` (диапазоны 2725-2835 и 3356-3430)
- `src/web/index.html:57-65`
- `tests/unit/test_terrain_paint.py`
- `tests/integration/test_terrain_z9_tiles.py`
## Соответствие ТЗ
| Требование | Реализация | Файл / строка | OK |
|---|---|---|---|
| REQ-F-01 — `updateHillshadeAvailability`: порог `zoom < 9` | `if (zoom < 9)` с комментарием ET-013 | `src/web/app.js:3425` | ✅ |
| REQ-F-02 — `source.minzoom = 9` для hillshade | `applyTerrainLayer('terrain-hillshade', …, HILLSHADE_PAINT, 9, 15)` | `src/web/app.js:2825` | ✅ |
| REQ-F-03 — TRI minzoom = 5 без изменений | `applyTerrainLayer('terrain-tri', …, TRI_PAINT, 5, 15)` | `src/web/app.js:2826` | ✅ |
| REQ-F-04 — обратно-совместимое расширение `applyTerrainLayer(opacityOrPaint)` | нормализация `(typeof opacityOrPaint === 'number') ? legacyPaint : opacityOrPaint` | `src/web/app.js:3376-3380` | ✅ |
| REQ-F-05 — HILLSHADE_PAINT `raster-opacity` interpolate по zoom (stops 9/10/11/12/14 → 0.65/0.60/0.55/0.50/0.40) | константа `HILLSHADE_PAINT`, точные stops | `src/web/app.js:2734-2742` | ✅ |
| REQ-F-06 — HILLSHADE_PAINT `raster-contrast` interpolate (stops 9/10/11/12/14 → 0.40/0.35/0.30/0.15/0.00) | присутствует | `src/web/app.js:2743-2750` | ✅ |
| REQ-F-07 — HILLSHADE_PAINT `raster-resampling: 'nearest'` | присутствует | `src/web/app.js:2751` | ✅ |
| REQ-F-08 — TRI_PAINT `raster-opacity` interpolate (z8→0.70, пик z9-z11 = 0.80-0.85) | точное совпадение со spec | `src/web/app.js:2755-2766` | ✅ |
| REQ-F-09 — TRI_PAINT `raster-resampling: 'nearest'` | присутствует | `src/web/app.js:2767` | ✅ |
| REQ-F-10 — hint «Зум 9+» | `<span … id="terrain-hillshade-hint" …>Зум 9+</span>` | `src/web/index.html:60` | ✅ |
| REQ-F-11 — единый порог в `updateHillshadeAvailability` | тот же `< 9` | — | ✅ |
| REQ-F-12 — контракт `onTerrainCheckbox` (localStorage `terrain-hillshade`, `terrain-tri`, `#terrain-toggle.active`) | без изменений | `src/web/app.js:2816-2821` | ✅ |
| REQ-F-13 — unit-тесты paint (Вариант B: Python-парсер) | 17 тестов, все PASS | `tests/unit/test_terrain_paint.py` | ✅ |
| REQ-F-14 — регрессионные тесты (порог 9, hint, callers) | `test_minzoom_threshold_lowered_to_9`, `test_hint_text_updated_to_z9`, `test_apply_terrain_layer_caller_count` | `tests/unit/test_terrain_paint.py` | ✅ |
| REQ-F-15 — integration smoke: `/terrain/{layer}/9/.../….png` → 200 + 404 на невалидный layer + Cache-Control immutable | параметризован по `["hillshade", "tri"]` × `[9, 10, 11]`, регрессии 404, whitelist-тест по 3 слоям | `tests/integration/test_terrain_z9_tiles.py` | ✅ |
| REQ-F-16 — Playwright UI-тесты | в test-плане, исполняет Тестер | — | n/a (review) |
| REQ-F-17 — localStorage без миграции | не тронуто | — | ✅ |
| REQ-F-18 — API-контракт без изменений | сигнатура `GET /terrain/{layer}/{z}/{x}/{y}.png` сохранена; whitelist расширен (см. §«Изменения после v1») | `src/api/main.py:1240-1264` | ✅ |
| REQ-F-19 — конфиги/стили не тронуты | `style.json`, `style-dark.json`, `app.css`, `config/*.yaml` — без правок (`git diff --stat` подтверждает) | — | ✅ |
| REQ-F-20 — pre-deploy curl + smoke | задача deployer'а | — | n/a (review) |
| REQ-F-21 — документация | `00-..-10-` + `06-adr/ADR-017-…` присутствуют | — | ✅ |
**Acceptance Criteria.**
- AC-01, AC-02, AC-04, AC-05 (структура paint), AC-15, AC-17, AC-22
(back-compat) — покрыты unit-тестами, **зелёные**.
- AC-16 — integration-тесты структурно корректны, в sandbox skip
из-за отсутствия PNG; whitelist-регрессия по `tri/hillshade/hypso`
работает без данных и зелёная.
- AC-03, AC-06..AC-13, AC-19, AC-21 — требуют test-среды и Playwright,
относятся к этапу Тестирования.
## Соответствие ADR
ADR-017 («Zoom-aware terrain paint») реализован по всем пунктам:
- **P-A** (frontend-only): backend-фикс whitelist'а `tri` — это
**корректная инфра-уточнение**, не выход за P-A. ADR-017 §«Контекст»
утверждал, что эндпоинт уже отдаёт `/terrain/{layer}/…` для TRI;
фактически до этого PR `tri` не был в whitelist'е в dev-режиме, и
фикс восстанавливает заявленное состояние (а не вводит новый
endpoint/source/слой). Документировано в docstring `terrain_tile`.
- **U-A** (UI-минзум 10→9): подтверждено `app.js:3425` и `index.html:60`.
- **A-A** (обратно-совместимое расширение `applyTerrainLayer`):
нормализация числа в legacy-paint реализована (`app.js:3376-3380`),
unit-test `test_apply_terrain_layer_normalizes_number_to_legacy_paint`
зелёный.
- **O-B + C-A + R-A** для HILLSHADE_PAINT: stops, contrast,
`nearest`-resampling — точно по ADR.
- **O-B + R-A** для TRI_PAINT: stops с явной точкой `8→0.70` для
регрессии z8 — точно по ADR.
- **T-A** (один paint на все темы): theme-specific paint не добавлен —
соответствует MVP-решению ADR.
- **M-A** (константы живут в `app.js` рядом с `TERRAIN_BASE_URL`):
подтверждено, расстояние 1 строка.
Нарушений ADR-017 не найдено.
## Изменения после review v1 (что было исправлено)
| v1 finding | Severity | Статус | Что сделано |
|---|---|---|---|
| F-1 — backend whitelist не пропускает `tri` | P1 | **RESOLVED** | `src/api/main.py:1252`: `("hypso", "hillshade") → ("hypso", "hillshade", "tri")` + docstring с обоснованием (nginx на prod/test перехватывает, но dev-режим должен поддерживать нативно) |
| F-2 — integration-тест не параметризован по layer | P2 | **RESOLVED** | `test_terrain_tile_available_z9_z10_z11` параметризован по `["hillshade", "tri"]` × `[9, 10, 11]`; добавлен явный `test_known_terrain_layer_accepted_by_whitelist[hypso/hillshade/tri]` |
| F-3 — комментарий в `HILLSHADE_PAINT` не упоминает MapLibre clamping ниже z9 | P3 | OPEN (косметика) | Не блокер; см. ниже |
| F-4 — `from __future__ import annotations` неиспользован | P3 | N/A | В текущем integration-тесте `from __future__` отсутствует; в unit-тесте остался, но это микро-косметика |
Все P0/P1 v1 закрыты.
## Тесты
- **Unit (`tests/unit/test_terrain_paint.py`).** 17 тестов, **17 PASS**
локально (Python 3.12.13, pytest 8.3.3, время 0.04s). Покрывают:
объявление констант, форму `interpolate`-выражений, ключевые stops
(z9/11/14 для hillshade, z8/10/11 для TRI), монотонность,
`nearest`-resampling, регрессию порога `< 9` и текста «Зум 9+»,
обратную совместимость `applyTerrainLayer`, корректное использование
констант в вызовах.
- **Integration (`tests/integration/test_terrain_z9_tiles.py`).**
13 тестов: **6 passed, 7 skipped** в sandbox.
- Skipped: тесты, требующие реальных PNG-тайлов
(`test_terrain_tile_available_z9_z10_z11[*]`,
`test_terrain_tile_cache_control_immutable`) — корректное поведение
через `_maybe_skip`.
- Passed: whitelist-регрессия для всех трёх слоёв
(`hypso/hillshade/tri`), 404 на `unknown_layer`, 404 на
missing tile, 404 на невалидный zoom. Эти тесты доказывают,
что фикс F-1 работает (для `tri` теперь возвращается
`"Tile not found"`, а не `"Unknown layer"`).
## Качество кода
- Стиль соответствует существующему `app.js` (vanilla JS, JSDoc,
комментарии-маркеры `// ET-NNN:`).
- Изменение функции `applyTerrainLayer` минимально-инвазивное:
новая нормализация в 4 строки + переменная `paint`, остальное —
переименование параметра. Никаких ломок других call-sites
(их всего 2, оба в `onTerrainCheckbox`).
- Backend-фикс whitelist'а — 1 строка кода + docstring; не меняет
сигнатуру endpoint'а и не вводит новых query/headers/code-path'ов
(REQ-F-18 формально сохранён).
- Все новые константы (`HILLSHADE_PAINT`, `TRI_PAINT`) UPPER_SNAKE_CASE,
как принято в `app.js`.
- Комментарии содержат ссылки на ADR-017, RTM-аргументы по stops,
ссылку на review F-1 в backend-docstring.
- Нет дублирования, нет dead code, нет `console.log`, нет
закомментированного старого кода.
## Findings (текущая ревизия)
### P3 — Комментарий в `HILLSHADE_PAINT` не упоминает MapLibre clamping ниже z9
**Где.** `src/web/app.js:2728-2733`.
**Замечание.** Stops opacity начинаются с `9, 0.65` — MapLibre сделает
clamping на нижнем стопе, поэтому при z<9 (если когда-нибудь UI-gate
уберут) opacity всё равно будет 0.65, что попадёт в render.
В текущем scope не проблема (UI-gate отрубает чекбокс при z<9), но
если в будущем порог понизят — нужно будет добавить нижний stop
`8, 0.00`.
**Действие.** Опционально. **Не блокер.**
### P3 — `from __future__ import annotations` в unit-тесте
**Где.** `tests/unit/test_terrain_paint.py:15`.
**Замечание.** Не используется (нет forward-ref в аннотациях). Не вредит.
**Действие.** Опционально. **Не блокер.**
## Вердикт
**APPROVED.**
- P0/P1 не найдено.
- P1 из review v1 (backend whitelist) и P2 (integration coverage) —
закрыты.
- Оставшиеся два P3 — косметика, не влияют на функциональность.
Реализация ET-013 точно соответствует TRZ REQ-F-01..F-21 и ADR-017.
Тестовое покрытие достаточное:
- AC-01/02/04/05/15/17/22 — закрыты unit-тестами (зелёные).
- AC-16 — закрыт integration-тестами (структурно корректно, skip без данных, whitelist-регрессия зелёная).
- Поведенческие AC (AC-03, AC-06..AC-13, AC-19, AC-21) — корректно
переданы Тестеру для исполнения в test-среде.
## Сводная таблица findings
| ID | Severity | Где | Кратко | Действие |
|---|---|---|---|---|
| F-3 | P3 | `src/web/app.js:2728-2733` | комментарий не учитывает MapLibre clamping ниже z9 | опционально добавить явный stop `8, 0.00` |
| F-5 | P3 | `tests/unit/test_terrain_paint.py:15` | `from __future__ import annotations` неиспользован | косметика |
P0/P1 отсутствуют → **APPROVED**.

View File

@@ -0,0 +1,462 @@
---
type: test-report
work_item_id: ET-013
title: "Test Report: Перепады высот на z9-z11 — zoom-aware paint"
version: 1
status: blocked
verdict: BLOCKED
created_at: 2026-06-04
updated_at: 2026-06-04
authors:
- "agent:tester"
related:
- "ET-007"
- "PH-6.terrain"
adr_refs:
- "ADR-017"
---
# Test Report — ET-013
## TL;DR
- `make lint` ✅, прицельный прогон unit/integration ET-013 ✅
(23 passed, 7 skipped — skip ожидаемы: нет PNG-fixtures в sandbox).
- Полный `make test` падает на этапе collection из-за **внешней**
проблемы (`ModuleNotFoundError: No module named 'lxml'` в тестах
`tests/api/test_gps_tracks_download.py` / `_gpx_builder.py`) — это
наследие ET-011, не имеет отношения к ET-013. После исключения
этих двух файлов: **191 passed, 46 skipped, 0 failed**, регрессий
ET-007/008/009/011/012 нет.
- Код в ветке `feature/ET-013-z9-z11-z8` 1:1 соответствует TRZ
(REQ-F-01..F-21) и ADR-017 (подтверждено Review v2, **APPROVED**).
- **❌ Pre-deploy gate AC-19 — FAIL (P1):** на test-среде отсутствуют
тайлы `hillshade/9/*` (а также `hillshade/8/*`). Проверка по
координатам `[37.6, 54.5]` (юг МО / Кашира — основная зона UI-тестов):
`hillshade/z9/309/348.png → 404`. Тайлы `hillshade/z10`,
`hillshade/z11`, `tri/z8..z11` присутствуют (200 OK). Это блокирует
основную пользовательскую ценность ET-013: после деплоя на z=9
чекбокс «Тени рельефа» станет активным, но карта 404'нется на каждом
hillshade-запросе, и пользователь увидит включённый слой **без теней**
(хуже, чем до ET-013, где чекбокс был disabled с честным hint'ом
«Зум 10+»).
- **UI Playwright (TC-UI-01..12) — NOT EXECUTED:** раннер
`/home/slin/tools/ui-test/run_tests.js` и `playwright`/`npx`
недоступны в этом контейнере. Дополнительно: test-среда сейчас
держит **до-ET-013** код (`if (zoom < 10)`, `HILLSHADE_PAINT` нет),
поэтому даже при наличии раннера большинство TC дали бы PASS «по
старому контракту» — нерелевантный сигнал. Визуальные TC должны
выполниться **после** деплоя.
**Вердикт: BLOCKED.** Реализация ET-013 в коде корректна и готова,
но деплой остановлен по TRZ REQ-F-20 §1: «При 404 — задача
останавливается, тайлы z9 нужно догенерировать в рамках PH-6
follow-up». Следующий шаг — открыть PH-6 follow-up
(«generate hillshade tiles z8-z9 для CFO») и после генерации тайлов
повторно прогнать pre-deploy probe + Playwright UI suite.
---
## 1. Окружение прогона
| Параметр | Значение |
|-------------------------|-------------------------------------------------------------------------|
| Ветка | `feature/ET-013-z9-z11-z8` |
| HEAD | `397dc60 reviewer(ET): auto-commit from reviewer run_id=84` |
| Содержательные коммиты | `5be81f9 feat(terrain): zoom-aware paint для hillshade/TRI на z9-z11 (ET-013)`<br>`099669d fix(terrain): расширить whitelist endpoint'а на `tri` (ET-013 review F-1)` |
| Python | 3.12.13 |
| pytest | 8.3.3 |
| Ruff | через `python -m ruff check src/api/` |
| Test-среда (HTTP) | https://openclaw.mva154.duckdns.org/enduro/ |
| Состояние test-среды | **до-ET-013** (фронт ещё с `if (zoom < 10)`, без `HILLSHADE_PAINT`/`TRI_PAINT`). Это ожидаемо: деплой ET-013 — следующий этап пайплайна. |
| `curl` в sandbox | отсутствует; HTTP-проверки выполнены через `urllib.request` (Python). |
Сетевая проверка `/health`:
```
GET /enduro/api/health → 200
{"status":"ok","db_path":"/app/data/centralfederal.sqlite","db_exists":true}
```
---
## 2. Шаг 1 — `make lint`
```
python -m ruff check src/api/
All checks passed!
```
**Результат:** ✅ PASS (часть AC-18).
---
## 3. Шаг 2 — `make test` (целевой gate)
### 3.1 Прицельный прогон ET-013
```
python -m pytest tests/unit/test_terrain_paint.py \
tests/integration/test_terrain_z9_tiles.py -v
collected 30 items
=================== 23 passed, 7 skipped, 1 warning in 0.46s ===================
```
| Suite | Кейсов | PASS | SKIP | Покрытие AC |
|-----------------------------------------------|--------|------|------|----------------------------|
| `tests/unit/test_terrain_paint.py` | 17 | 17 | 0 | AC-01, AC-04, AC-05, AC-15, AC-22 |
| `tests/integration/test_terrain_z9_tiles.py` | 13 | 6 | 7 | AC-16 |
Что покрывают unit-тесты (выборка):
- `test_hillshade_paint_defined`, `test_hillshade_opacity_is_interpolate_by_zoom`,
`test_hillshade_opacity_stops`, `test_hillshade_contrast_peak_z9`,
`test_hillshade_resampling_nearest` — структура `HILLSHADE_PAINT`,
stops 9/10/11/12/14 → 0.65/0.60/0.55/0.50/0.40, contrast пик z9 ≥0.30 / z14 ≤0.10.
- `test_tri_paint_defined`, `test_tri_opacity_z8_regression` («8, 0.70»
ровно, защита AC-06), `test_tri_opacity_peak_z9_z11` (z10/z11 ≥ 0.80),
`test_tri_resampling_nearest`.
- `test_apply_terrain_layer_signature_uses_opacity_or_paint`,
`test_apply_terrain_layer_normalizes_number_to_legacy_paint`,
`test_apply_terrain_layer_uses_paint_variable` — обратная
совместимость `applyTerrainLayer` (AC-22).
- `test_minzoom_threshold_lowered_to_9` (`if (zoom < 9)` найден,
`< 10` отсутствует), `test_hint_text_updated_to_z9` («Зум 9+»),
`test_apply_terrain_layer_caller_count` (ровно 2 вызова),
`test_hillshade_call_uses_paint_constant_and_minzoom_9`,
`test_tri_call_uses_paint_constant_and_minzoom_5`.
Что покрывают integration-тесты:
- **PASS:** `test_known_terrain_layer_accepted_by_whitelist[hypso|hillshade|tri]`
(доказывает фикс F-1 review v1), `test_unknown_terrain_layer_returns_404`,
`test_missing_terrain_tile_returns_404`, `test_invalid_zoom_returns_404`.
- **SKIP:** `test_terrain_tile_available_z9_z10_z11[*]` ×6,
`test_terrain_tile_cache_control_immutable` — требуют PNG-fixtures
в `data/terrain/`, которых нет в sandbox-репо. Skip — корректный
механизм через `_maybe_skip`; AC-16 говорит «при отсутствии тайлов
в CI — тесты skipped с reason», что в точности и наблюдается.
### 3.2 Полный регресс (`pytest tests/`)
Полный прогон падает на collection из-за **внешней** проблемы:
```
ERROR tests/api/test_gps_tracks_download.py
ERROR tests/api/test_gps_tracks_gpx_builder.py
from lxml import etree as lxml_et
E ModuleNotFoundError: No module named 'lxml'
!!! Interrupted: 2 errors during collection !!!
```
`lxml` не установлен в этом контейнере. Это **наследие ET-011 / GPX
download**, не связано с ET-013 (ветка не трогает `gps_tracks/`).
В CI-окружении проекта `lxml` устанавливается через
`src/api/requirements.txt`, и эти тесты зелёные.
Прогон без этих двух файлов:
```
python -m pytest tests/ \
--ignore=tests/api/test_gps_tracks_download.py \
--ignore=tests/api/test_gps_tracks_gpx_builder.py
========== 191 passed, 46 skipped, 4 deselected, 79 warnings in 3.47s ==========
```
- `4 deselected` — perf/network маркеры (стандартный exclude).
- `46 skipped` — async-тесты `gps_tracks` (нет pytest-asyncio в
sandbox) + integration без fixtures. Не относится к ET-013.
- **Регрессий ET-007 / ET-008 / ET-009 / ET-011 / ET-012 — НЕТ.**
**Результат:** ✅ PASS (AC-15, AC-16 в части автоматики, AC-17, AC-18).
---
## 4. Шаг 3 — E2E (контракт API на test-среде)
### 4.1 IT-TILE-* «вживую» против test-среды
Поскольку sandbox без data fixtures даёт SKIP, я выполнил эквивалент
IT-TILE-* напрямую HTTP-запросом к test-среде. Координата
`[37.6, 54.5]` (юг МО / Кашира) — основная для UI-тестов (см.
04b-ui-test-cases.md §«Координаты»). Тайлы под TMS-схемой (как
объявлено в `addSource(... scheme: 'tms' ...)`):
| z | hillshade (x, y_tms) | hillshade status | tri (x, y_tms) | tri status |
|----|---------------------------|------------------|---------------------------|------------|
| 8 | `8/154/174` | **❌ 404** | `8/154/174` | ✅ 200 |
| 9 | `9/309/348` | **❌ 404** | `9/309/348` | ✅ 200 |
| 10 | `10/618/697` | ✅ 200 | `10/618/697` | ✅ 200 |
| 11 | `11/1237/1395` | ✅ 200 | `11/1237/1395` | ✅ 200 |
| 14 | `14/9903/11162` | ✅ 200 | `14/9903/11162` | ❌ 404 ¹ |
¹ TRI z=14 404 — за пределами TRI-стека (TRI генерится до z11 в
PH-6, регрессия известная, в скоупе ET-013 не трогается). Чекбокс TRI
на z=14 включит источник с minzoom=5/maxzoom=15, но реально тайлы
отдадутся только до z=11; визуально на z>11 — пусто. Это **не**
новая регрессия ET-013, такое же поведение было до ET-013. Фиксирую
как P3 для PH-6 follow-up.
Дополнительная проверка покрытия hillshade z=9 — wide grid 5×5 вокруг
центра `(309, 348)`:
```
hillshade z=9 found: 0 tiles around (309,348)
hillshade z=10 found: 9 tiles around (618,697)
```
То есть на z=9 нет ни одного hillshade-тайла, не только «целевого»;
данных просто нет в pipeline.
### 4.2 Заголовок Cache-Control
```
hillshade z=10 → Cache-Control: max-age=31536000
hillshade z=11 → Cache-Control: max-age=31536000
tri z=8 → Cache-Control: max-age=31536000
```
Только `max-age=31536000`; `immutable`-флаг **отсутствует** в ответах
nginx-перед-fastapi на test-среде. Это **предсуществующая** ситуация
(не введена ET-013): backend FastAPI отдаёт `Cache-Control: max-age=…,
immutable`, но nginx-конфиг на test-среде стрипает `immutable`. На
бизнес-логику это не влияет (`max-age=1y` достаточен), но формальная
формулировка REQ-F-18 / IT-TILE-CACHE-HEADER «immutable сохраняется»
выполняется только на backend-уровне (см. integration-тест
`test_terrain_tile_cache_control_immutable`, корректно SKIPPED здесь).
**Не блокер ET-013.** Фиксирую как P3 (известная инфра-косметика,
не в скоупе).
### 4.3 `/health` стабилен
См. раздел 1. ✅
---
## 5. Шаг 4 — UI / Visual тесты
### 5.1 Состояние раннера
```
ls /home/slin/tools/ui-test/ → No such file or directory
which playwright / npx → not found
find / -name run_tests.js -type f → (нет результатов)
```
UI-test раннер, Playwright и `npx` в этом контейнере отсутствуют.
Запустить TC-UI-01..12 невозможно.
### 5.2 Состояние test-среды (до-ET-013)
```
GET https://openclaw.mva154.duckdns.org/enduro/app.js
HILLSHADE_PAINT in body: False
TRI_PAINT in body: False
'if (zoom < 9)' in body: False
'if (zoom < 10)' in body: True
```
На test-среде сейчас выкатан **до-ET-013** код. Это **ожидаемо**:
деплой ET-013 — следующий этап пайплайна (deployer → `14-deploy-log.md`).
Визуальную регрессию TC-UI-01..12 имеет смысл прогонять только
ПОСЛЕ деплоя.
### 5.3 План постдеплойного прогона (DEFERRED)
| TC | Тип | viewport | Зум | Что проверяем | Severity | Статус |
|-------------------------|--------------------|----------|-----|-------------------------------------------------------|----------|--------------|
| TC-UI-01-Z9 | functional+visual | desktop | 9 | Чекбокс активен, hint скрыт, hillshade виден | **P1** | DEFERRED ¹ |
| TC-UI-02-Z8-REGRESS | regression+visual | desktop | 8 | TRI выглядит как до ET-013 | P2 | DEFERRED |
| TC-UI-03-Z9-Q | visual (qual.) | desktop | 9 | Перепады читаются ≥ z=8 | **P1** | DEFERRED ¹ |
| TC-UI-04-Z10-Q | visual (qual.) | desktop | 10 | Перепады читаются | P2 | DEFERRED |
| TC-UI-05-Z11-Q | visual (qual.) | desktop | 11 | Перепады читаются | P2 | DEFERRED |
| TC-UI-06-Z14-REGRESS | regression+visual | desktop | 14 | Hillshade не «перегрет» (opacity 0.40, contrast 0) | P2 | DEFERRED |
| TC-UI-07-Z9-MOBILE | visual | mobile | 9 | Чекбокс/hint работают, нет H-scroll | **P1** | DEFERRED ¹ |
| TC-UI-08-Z10-SAT-Q | visual (qual.) | desktop | 10 | Hillshade поверх спутника не «глушит» | P2 | DEFERRED |
| TC-UI-09-Z10-DARK-Q | visual (qual.) | desktop | 10 | Hillshade на тёмной теме читается | P2 | DEFERRED |
| TC-UI-10-PERSIST | functional+visual | desktop | 10 | F5 не теряет состояние, оба слоя восстановлены | P2 | DEFERRED |
| TC-UI-11-NETWORK-Q | perf (network) | desktop | 8-11 | Σ traffic ≤ 135% baseline | P2 | DEFERRED |
| TC-UI-12-Z9-PAN | perf+visual | desktop | 9 | Pan без «белых дыр» в hillshade/TRI | P3 | DEFERRED |
¹ **TC-UI-01, TC-UI-03, TC-UI-07 — заблокированы pre-deploy gate
(см. §4.1):** даже после деплоя ET-013 эти три кейса дадут FAIL,
потому что `/terrain/hillshade/9/*` отдаёт 404 → MapLibre нарисует
hillshade-слой пустым (или с «белыми дырами»), что не соответствует
AC-03 «На карте видны тени рельефа».
**DEFERRED** = тест не запущен в текущем окружении и должен быть
выполнен оператором/Playwright против test-среды **после**:
(a) генерации hillshade z8-z9 тайлов (PH-6 follow-up);
(b) деплоя ET-013.
Результаты приколоть к `14-deploy-log.md`.
---
## 6. Матрица Acceptance Criteria → Test
| AC | Покрытие | Результат |
|---------|-------------------------------------------------------------------------------------------|------------------------|
| AC-01 | `test_minzoom_threshold_lowered_to_9`, `test_hint_text_updated_to_z9` | ✅ PASS |
| AC-02 | DevTools на test-среде | ⏳ DEFER → deploy log |
| AC-03 | TC-UI-01-Z9 + видимость hillshade-слоя | **❌ BLOCKED** (нет тайлов z9) |
| AC-04 | `test_hillshade_opacity_is_interpolate_by_zoom`, `…contrast_peak_z9`, `…resampling_nearest` | ✅ PASS |
| AC-05 | `test_tri_opacity_z8_regression`, `test_tri_opacity_peak_z9_z11`, `…resampling_nearest` | ✅ PASS |
| AC-06 | `test_tri_opacity_z8_regression` (z8 = 0.70 ровно) + TC-UI-02-Z8-REGRESS | ✅ PASS (код) / ⏳ DEFER (visual) |
| AC-07 | TC-UI-03-Z9-Q | **❌ BLOCKED** (нет тайлов z9) |
| AC-08 | TC-UI-04-Z10-Q | ⏳ DEFER → deploy log |
| AC-09 | TC-UI-05-Z11-Q | ⏳ DEFER → deploy log |
| AC-10 | TC-UI-06-Z14-REGRESS | ⏳ DEFER → deploy log |
| AC-11 | TC-UI-09-Z10-DARK-Q | ⏳ DEFER → deploy log |
| AC-12 | TC-UI-08-Z10-SAT-Q | ⏳ DEFER → deploy log |
| AC-13 | TC-UI-07-Z9-MOBILE | **❌ BLOCKED** (нет тайлов z9) |
| AC-14 | TC-UI-10-PERSIST | ⏳ DEFER → deploy log |
| AC-15 | `pytest tests/unit/test_terrain_paint.py` — 17/17 | ✅ PASS |
| AC-16 | `pytest tests/integration/test_terrain_z9_tiles.py` — 6 pass / 7 skip (по плану) | ✅ PASS |
| AC-17 | Полный `pytest tests/` (исключая lxml-зависимые) — 191 passed, 46 skipped | ✅ PASS |
| AC-18 | `make lint` (✅) + `make test` (✅ модуль ET-013; полный — внешняя lxml-проблема) | ✅ PASS |
| AC-19 | Pre-deploy `curl -sI .../hillshade/{9,10,11}/X/Y.png``hillshade/9` отдаёт **404** | **❌ FAIL (P1)** |
| AC-20 | Документация work item (см. §8) | ✅ PASS (12+ файлов) |
| AC-21 | TC-UI-11-NETWORK-Q (требует baseline + Playwright) | ⏳ DEFER → deploy log |
| AC-22 | `test_apply_terrain_layer_normalizes_number_to_legacy_paint` + `…uses_paint_variable` | ✅ PASS |
**Итого:** 10/22 AC закрыты автоматически зелёные · 1 AC **FAIL
(блокер P1)** · 3 AC **BLOCKED** (зависят от AC-19) · 8 AC
делегированы Deployer-агенту.
---
## 7. Findings
### P0
Нет.
### P1
#### P1-01 — Pre-deploy gate AC-19: hillshade z=9 тайлы отсутствуют
**Где.** Test-среда `https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/*.png`.
**Симптом.** Все запросы вида `GET /terrain/hillshade/9/X/Y.png`
`hillshade/8/…`) возвращают 404. Покрытие отсутствует на всю
изученную область юга МО / ЦФО (проверено grid'ом 5×5 вокруг
ожидаемой целевой плитки `(309, 348)` под TMS).
**Почему блокер.** После деплоя ET-013 фронт:
- понизит UI-минзум hillshade до 9 → чекбокс «Тени рельефа» станет
активным на z=9;
- понизит `source.minzoom` до 9 → MapLibre начнёт запрашивать
`/terrain/hillshade/9/X/Y.png`;
- получит 404 → слой нарисуется пустым.
Пользователь увидит **включённый** слой **без теней**. Это хуже, чем
до ET-013, где чекбокс был disabled с честным hint'ом «Зум 10+».
**Регрессия UX**, явно противоречащая AC-03 / AC-07 / AC-13 / BRD-цели
ET-013 («перепады читаются на z9-z11»).
**Что делать.** TRZ REQ-F-20 §1 и AC-19 однозначно говорят:
> Если 404 — задача останавливается, тайлы z9 нужно догенерировать в
> рамках PH-6 follow-up.
Действия:
1. Открыть PH-6 follow-up: «Generate hillshade tiles z8-z9 for CFO
coverage» (как минимум область, покрываемая текущим
`data/terrain/hillshade/10..14/`).
2. После генерации повторно прогнать probe из §4.1.
3. После 200 OK на z=9 — повторный запуск Tester'а + переход на
Deployer.
**Severity = P1, не P0** только потому, что: (a) код ET-013 корректен
и proven unit/integration-тестами; (b) рег-серверная UI-страница
сейчас работает (тестовая среда держит до-ET-013, чекбокс правомерно
disabled); (c) рабочий процесс PH-6 follow-up — стандартная процедура
для такого класса проблем.
### P2
Нет.
### P3
#### P3-01 — TRI z=14 отдаёт 404 (предсуществующая регрессия PH-6, не в скоупе ET-013)
`GET .../tri/14/X/Y.png → 404`. ET-013 не трогает TRI pipeline,
но при включённом TRI и z>11 пользователь видит пустой слой. Покрыть
follow-up'ом «extend TRI tiles to z14».
#### P3-02 — Cache-Control `immutable` стрипается nginx-проксей на test
Backend FastAPI отдаёт `max-age=31536000, immutable`, на проде через
nginx остаётся только `max-age=31536000`. Формально REQ-F-18 нарушен
на edge-слое, но `max-age=1y` функционально достаточен. Не в скоупе
ET-013.
#### P3-03 — `from __future__ import annotations` в unit-тесте не используется
`tests/unit/test_terrain_paint.py:15` — косметика (унаследовано из
review v2 F-5).
#### P3-04 — Комментарий в `HILLSHADE_PAINT` не учитывает MapLibre clamping ниже z9
`src/web/app.js:2728-2733` — унаследовано из review v2 F-3. Не блокер;
актуально только если UI-минзум hillshade когда-нибудь понизят до z<9.
---
## 8. Документация work item (AC-20)
```
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 ✅
06-adr/ADR-017-zoom-aware-terrain-paint.md ✅
07-infra-requirements.md ✅
08-data-requirements.md ✅
10-tech-risks.md ✅
12-review.md ✅
13-test-report.md ← этот файл
14-deploy-log.md ⏳ ожидается после устранения P1-01
```
---
## 9. Вердикт
**BLOCKED.** Реализация ET-013 в коде корректна и готова к деплою:
- `make lint` и прицельный `make test` (ET-013 модуль) — зелёные.
- 23/23 PASS unit/integration ET-013 (7 SKIP — ожидаемые без data
fixtures), 0 регрессий на 191 кейсе остальных тестов.
- Соответствие TRZ / ADR-017 — 1:1 (подтверждено Review v2).
- Контракт API на test-среде — стабилен.
Однако **pre-deploy gate AC-19 не пройден** (P1-01): на test-среде
отсутствуют `hillshade/z9/*``z8`) тайлы. Деплой остановлен
согласно TRZ REQ-F-20 §1 и BRD-приоритету «UX-regression > frontend-fix
ready».
### Что должно произойти дальше
1. **Открыть PH-6 follow-up:** «Generate hillshade tiles z8..z9 for
CFO coverage area» (≈ область, покрытая `data/terrain/hillshade/10/`,
расширенная вверх по zoom-иерархии).
2. **После генерации тайлов:**
- повторный пробинг по §4.1 — все 6 ячеек (hillshade/tri × z=9..11)
должны вернуть 200;
- повторный запуск Tester'а (изменения отчёта — в виде патча версии
v2 этого файла, без `back-to:dev` для самого ET-013);
- переход на Deployer.
3. **Deployer:**
- накатить ветку `feature/ET-013-z9-z11-z8` в test;
- выполнить ручные шаги REQ-F-20 §2: открыть карту, `setZoom(9)`,
включить hillshade, скриншот → визуальная приёмка AC-03..AC-05;
- прогнать Playwright TC-UI-01..12 (или хотя бы P1: TC-UI-01,
TC-UI-03, TC-UI-07);
- замерить network-объём (TC-UI-11/AC-21) против baseline;
- зафиксировать всё в `14-deploy-log.md`.
4. **Если визуальная приёмка AC-07..AC-09 «перепады недостаточно
выразительны»** — корректировка stops в HILLSHADE_PAINT/TRI_PAINT
(это калибровка, не баг — см. BRD §6 «известная итеративность
калибровки»).
### Что НЕ нужно делать
- **Не back-to:dev для ET-013-frontend.** Код ETM-013 правильный, тесты
зелёные, ревью пройдено. Изменения в `src/web/app.js` / `src/web/index.html`
не требуются.
- **Не закрывать ET-013 без устранения P1-01.** Деплой без z9-тайлов
даст регрессию UX (включённый, но пустой hillshade на z=9).

View File

@@ -1239,8 +1239,17 @@ TERRAIN_DIR = os.environ.get(
@app.get("/terrain/{layer}/{z}/{x}/{y}.png")
async def terrain_tile(layer: str, z: int, x: int, y: int):
"""Отдаёт растровые тайлы рельефа (hypso/hillshade)"""
if layer not in ("hypso", "hillshade"):
"""Отдаёт растровые тайлы рельефа (hypso/hillshade/tri).
ET-013: добавлен слой ``tri`` (Terrain Ruggedness Index) в whitelist.
Фронтенд (`src/web/app.js`, ``onTerrainCheckbox``) запрашивает
``/terrain/tri/{z}/{x}/{y}.png`` для слоя «Перепады высот». На
test/prod-среде эти запросы перехватывает nginx и отдаёт PNG
напрямую с диска, но в dev-режиме (``make dev`` → FastAPI на :5556
без nginx) endpoint должен поддерживать ``tri`` нативно.
См. review ET-013 F-1.
"""
if layer not in ("hypso", "hillshade", "tri"):
raise HTTPException(404, "Unknown layer")
tile_path = os.path.join(TERRAIN_DIR, layer, str(z), str(x), f"{y}.png")
if not os.path.exists(tile_path):

View File

@@ -2725,6 +2725,48 @@ function initMiniRouteInteraction() {
const TERRAIN_BASE_URL = window.location.pathname.replace(/\/[^/]*$/, '') + '/terrain';
// ET-013: zoom-aware paint для слоёв рельефа (ADR-017).
// Цель — компенсировать «потерю выразительности» перепадов на 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'
};
// ET-013: TRI остаётся 0.70 на z8 (регрессия), пик 0.80-0.85 на z9-z11.
const TRI_PAINT = {
'raster-opacity': [
'interpolate', ['linear'], ['zoom'],
5, 0.55,
7, 0.65,
8, 0.70,
9, 0.80,
10, 0.85,
11, 0.85,
12, 0.75,
15, 0.70
],
'raster-resampling': 'nearest'
};
function toggleTerrainPopup() {
const popup = document.getElementById('terrain-popup');
const btn = document.getElementById('terrain-toggle');
@@ -2779,8 +2821,9 @@ function onTerrainCheckbox() {
btn.classList.toggle('active', hillshadeChecked || triChecked);
// Apply layers
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);
// ET-013: hillshade теперь доступен с z9; paint zoom-aware (см. HILLSHADE_PAINT / TRI_PAINT).
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, HILLSHADE_PAINT, 9, 15);
applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, TRI_PAINT, 5, 15);
}
@@ -3313,12 +3356,29 @@ function onUnitChange() {
}
// <<< ET-005 unit toggle block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
/**
* ET-013: обратно-совместимое расширение для поддержки zoom-aware paint.
*
* @param {string} id - id слоя.
* @param {string} tileUrl - URL-шаблон тайлов.
* @param {boolean} enabled - показывать ли слой.
* @param {number|object} opacityOrPaint - либо число (старый контракт,
* станет 'raster-opacity' + linear-resampling), либо объект 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) {
// Add source if not exists
if (!map.getSource(sourceId)) {
@@ -3334,17 +3394,14 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
// Add layer if not exists
if (!map.getLayer(id)) {
// Insert before first road/trail layer for correct z-order
const firstTrailLayer = map.getStyle().layers.find(l =>
const firstTrailLayer = map.getStyle().layers.find(l =>
l.id.startsWith('trails-') || l.id.startsWith('poi-')
);
map.addLayer({
id: id,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity,
'raster-resampling': 'linear'
},
paint: paint,
minzoom: minzoom,
maxzoom: maxzoom
}, firstTrailLayer ? firstTrailLayer.id : undefined);
@@ -3365,7 +3422,7 @@ function updateHillshadeAvailability() {
const hint = document.getElementById('terrain-hillshade-hint');
const label = cb ? cb.closest('.terrain-checkbox') : null;
if (zoom < 10) {
if (zoom < 9) { // ET-013: на z9 hillshade уже доступен
if (cb) cb.disabled = true;
if (label) label.classList.add('disabled');
if (hint) hint.style.display = 'inline';

View File

@@ -57,7 +57,7 @@
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">
<span>Тени рельефа</span>
</label>
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 10+</span>
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 9+</span>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-tri-cb" onchange="onTerrainCheckbox()">
<span>Перепады</span>

View File

@@ -0,0 +1,159 @@
"""ET-013 — integration-тесты endpoint ``/terrain/{layer}/{z}/{x}/{y}.png``
для z9-z11 (REQ-F-15; AC-16).
Тесты используют FastAPI TestClient против ``src.api.main:app``. Реальные
тайлы рельефа в репозиторий не коммитятся (PH-6 data live in ``data/terrain/``
на test-сервере). Поэтому:
* Если директория с тайлами недоступна — тесты ``IT-TILE-*`` помечаются
``skipped`` с пояснением.
* Регрессии «404 на невалидный zoom / неизвестный layer» работают всегда —
они не требуют исходных данных.
Покрытие тест-плана (`04-test-plan.yaml`):
- IT-TILE-Z9-01, IT-TILE-Z10-01, IT-TILE-Z11-01 (по обоим слоям hillshade и tri — см. F-2)
- IT-TILE-INVALID-LAYER, IT-TILE-MISSING
- IT-TILE-CACHE-HEADER
- IT-TILE-TRI-WHITELIST: регрессия, что endpoint признаёт `tri` (см. review F-1)
"""
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from src.api.main import TERRAIN_DIR, app
REPO_ROOT = Path(__file__).resolve().parents[2]
# Опционально каталог тайлов перекрывается через env. По умолчанию — берём
# тот же путь, что использует api (см. src/api/main.py TERRAIN_DIR).
TERRAIN_ROOT = Path(os.environ.get("TERRAIN_DIR", TERRAIN_DIR))
@pytest.fixture(scope="module")
def client() -> TestClient:
return TestClient(app)
def _find_sample_tile(layer: str, z: int):
"""Найти любую существующую (x, y) пару тайла для layer/z.
Возвращает None, если данных нет — тогда вызывающий тест помечается skipped.
"""
z_dir = TERRAIN_ROOT / layer / str(z)
if not z_dir.is_dir():
return None
for x_dir in sorted(z_dir.iterdir()):
if not x_dir.is_dir():
continue
try:
x = int(x_dir.name)
except ValueError:
continue
for y_file in sorted(x_dir.iterdir()):
if y_file.suffix != ".png":
continue
try:
y = int(y_file.stem)
except ValueError:
continue
return (x, y)
return None
def _maybe_skip(layer: str, z: int):
sample = _find_sample_tile(layer, z)
if sample is None:
pytest.skip(
f"PH-6 data not present: {TERRAIN_ROOT}/{layer}/{z}/ — "
"integration smoke skipped (см. TRZ REQ-F-15)."
)
return sample
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-Z9 / Z10 / Z11 — hillshade и TRI доступны на расширенном диапазоне зумов
# (review F-2: параметризация по layer, чтобы покрыть оба слоя endpoint'а)
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("layer", ["hillshade", "tri"])
@pytest.mark.parametrize("zoom", [9, 10, 11])
def test_terrain_tile_available_z9_z10_z11(client: TestClient, layer: str, zoom: int):
"""IT-TILE-Z9/Z10/Z11-01: тайл рельефа (hillshade и tri) на z9-z11 отдаётся 200 PNG."""
x, y = _maybe_skip(layer, zoom)
resp = client.get(f"/terrain/{layer}/{zoom}/{x}/{y}.png")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert len(resp.content) > 0
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-TRI-WHITELIST (review F-1) — endpoint признаёт слой `tri`
# Этот тест не зависит от наличия тайлов: для существующего слоя без файла на
# диске мы получаем 404 "Tile not found", а для несуществующего слоя — 404
# "Unknown layer". Различие проверяется по телу ответа.
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("layer", ["hypso", "hillshade", "tri"])
def test_known_terrain_layer_accepted_by_whitelist(client: TestClient, layer: str):
"""Регрессия F-1: каждый из (hypso, hillshade, tri) проходит whitelist."""
# x, y заведомо не существуют на диске → должны получить 404 "Tile not found",
# но НЕ "Unknown layer". Эта проверка работает без локальных PNG-данных.
resp = client.get(f"/terrain/{layer}/9/999999/999999.png")
assert resp.status_code == 404
detail = resp.json().get("detail", "")
assert detail != "Unknown layer", (
f"layer={layer!r} должен проходить whitelist, факт detail={detail!r}"
)
assert detail == "Tile not found", (
f"для несуществующего файла ожидался detail='Tile not found', факт={detail!r}"
)
# ──────────────────────────────────────────────────────────────────────────────
# Регрессии 404 — работают независимо от наличия данных
# ──────────────────────────────────────────────────────────────────────────────
def test_unknown_terrain_layer_returns_404(client: TestClient):
"""IT-TILE-INVALID-LAYER: неизвестный layer → 404 "Unknown layer".
Парный к ``test_known_terrain_layer_accepted_by_whitelist`` (F-1):
подтверждает, что whitelist всё ещё отсекает посторонние слои.
"""
resp = client.get("/terrain/unknown_layer/9/0/0.png")
assert resp.status_code == 404
assert resp.json().get("detail") == "Unknown layer"
def test_missing_terrain_tile_returns_404(client: TestClient):
"""IT-TILE-MISSING: hillshade-тайл с нереальными x/y → 404."""
resp = client.get("/terrain/hillshade/9/999999/999999.png")
assert resp.status_code == 404
def test_invalid_zoom_returns_404(client: TestClient):
"""Доп. регрессия: zoom вне нарезанного диапазона → 404 (тайла нет на диске)."""
resp = client.get("/terrain/hillshade/99/0/0.png")
assert resp.status_code == 404
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-CACHE-HEADER — Cache-Control: immutable сохраняется (NFR-03, REQ-F-18)
# ──────────────────────────────────────────────────────────────────────────────
def test_terrain_tile_cache_control_immutable(client: TestClient):
"""IT-TILE-CACHE-HEADER: тайл рельефа отдаётся с Cache-Control: immutable."""
x, y = _maybe_skip("hillshade", 9)
resp = client.get(f"/terrain/hillshade/9/{x}/{y}.png")
assert resp.status_code == 200
cache_control = resp.headers.get("cache-control", "")
assert "immutable" in cache_control, f"ожидался immutable в Cache-Control, факт: {cache_control}"
assert "max-age=31536000" in cache_control, (
f"ожидался max-age=31536000 в Cache-Control, факт: {cache_control}"
)

View File

@@ -0,0 +1,300 @@
"""ET-013 — unit-тесты zoom-aware paint для hillshade и TRI.
ET-013 — фронтенд-калибровка растровых terrain-слоёв (см. ADR-017).
В CI исполняется только ``pytest tests/``, JS-тест-раннера в проекте нет,
поэтому проверки выполнены как статический парсинг ``src/web/app.js``
и ``src/web/index.html`` (см. TRZ REQ-F-13 Вариант B).
Покрытие тест-плана (`04-test-plan.yaml`):
- UT-PAINT-HS-OPACITY, UT-PAINT-HS-CONTRAST, UT-PAINT-HS-RESAMPLING
- UT-PAINT-TRI-OPACITY-Z8, UT-PAINT-TRI-OPACITY-PEAK, UT-PAINT-TRI-RESAMPLING
- UT-PAINT-COMPAT-01, UT-PAINT-COMPAT-02
- UT-REG-MINZOOM-9, UT-REG-HINT-TEXT, UT-REG-CALLERS
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"
def _app_js() -> str:
assert APP_JS.is_file(), f"не найден {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _extract_block(name: str, src: str) -> str:
"""Достать тело объявления `const NAME = { ... };` (один уровень фигурных скобок)."""
start_match = re.search(rf"const\s+{re.escape(name)}\s*=\s*\{{", src)
assert start_match, f"не найдено объявление {name}"
i = start_match.end() - 1 # позиция открывающей `{`
depth = 0
end = -1
while i < len(src):
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
i += 1
assert end > 0, f"не найден конец объявления {name}"
return src[start_match.end() - 1:end + 1]
def _parse_zoom_stops(interpolate_src: str) -> dict[int, float]:
"""Достать пары (zoom, value) из 'interpolate' блока. Толерантно к пробелам/переносам."""
# ищем секцию ['zoom'] и далее парами «целое, число»
zoom_pos = interpolate_src.find("['zoom']")
assert zoom_pos > 0, "ожидался ['zoom'] в interpolate-выражении"
tail = interpolate_src[zoom_pos + len("['zoom']"):]
# тело продолжается до закрывающей ']' уровня нашего массива; ищем все числа
# сначала отрезаем хвост по конечной `]`
bracket_close = tail.rfind("]")
assert bracket_close > 0, "не найден конец interpolate-массива"
body = tail[:bracket_close]
nums = re.findall(r"-?\d+(?:\.\d+)?", body)
assert len(nums) % 2 == 0 and nums, (
f"ожидаются чётные пары (zoom, value), получено {nums}"
)
stops: dict[int, float] = {}
for i in range(0, len(nums), 2):
z = int(float(nums[i]))
v = float(nums[i + 1])
stops[z] = v
return stops
# ──────────────────────────────────────────────────────────────────────────────
# HILLSHADE_PAINT (REQ-F-05, F-06, F-07; AC-04)
# ──────────────────────────────────────────────────────────────────────────────
def test_hillshade_paint_defined():
"""REQ-F-05: HILLSHADE_PAINT объявлен в app.js."""
js = _app_js()
assert "const HILLSHADE_PAINT" in js, "HILLSHADE_PAINT не объявлен"
def test_hillshade_opacity_is_interpolate_by_zoom():
"""UT-PAINT-HS-OPACITY: raster-opacity — interpolate linear по zoom."""
block = _extract_block("HILLSHADE_PAINT", _app_js())
# достаём массив 'raster-opacity'
m = re.search(r"'raster-opacity'\s*:\s*\[(.*?)\]\s*,\s*'raster-contrast'", block, re.DOTALL)
assert m, "не найдена секция 'raster-opacity' в HILLSHADE_PAINT"
op_src = "[" + m.group(1) + "]"
assert "'interpolate'" in op_src, "raster-opacity должен быть 'interpolate'"
assert "'linear'" in op_src, "ожидается linear-interpolate"
assert "'zoom'" in op_src, "ожидается интерполяция по zoom"
def test_hillshade_opacity_stops():
"""UT-PAINT-HS-OPACITY: stops по zoom монотонно убывают, ключевые значения совпадают."""
block = _extract_block("HILLSHADE_PAINT", _app_js())
m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-contrast'", block, re.DOTALL)
assert m
stops = _parse_zoom_stops(m.group(1))
# требования ADR-017 / TRZ §3 REQ-F-05
assert 9 in stops and stops[9] == pytest.approx(0.65, abs=0.001)
assert 11 in stops and stops[11] == pytest.approx(0.55, abs=0.001)
assert 14 in stops and stops[14] == pytest.approx(0.40, abs=0.001)
# монотонность 9 → 14
zooms = sorted(stops.keys())
values = [stops[z] for z in zooms]
assert values == sorted(values, reverse=True), (
f"raster-opacity hillshade не монотонно убывает: {stops}"
)
def test_hillshade_contrast_peak_z9():
"""UT-PAINT-HS-CONTRAST: contrast на z9 ≥ 0.30, на z14 ≤ 0.10, монотонно убывает."""
block = _extract_block("HILLSHADE_PAINT", _app_js())
m = re.search(
r"'raster-contrast'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'",
block,
re.DOTALL,
)
assert m, "не найдена секция 'raster-contrast' в HILLSHADE_PAINT"
contrast_src = m.group(1)
assert "'interpolate'" in contrast_src
stops = _parse_zoom_stops(contrast_src)
assert stops[9] >= 0.30, f"z=9 contrast должен быть ≥0.30, факт {stops[9]}"
assert stops[14] <= 0.10, f"z=14 contrast должен быть ≤0.10, факт {stops[14]}"
zooms = sorted(stops.keys())
values = [stops[z] for z in zooms]
assert values == sorted(values, reverse=True), (
f"raster-contrast hillshade не монотонно убывает: {stops}"
)
def test_hillshade_resampling_nearest():
"""UT-PAINT-HS-RESAMPLING: raster-resampling = 'nearest'."""
block = _extract_block("HILLSHADE_PAINT", _app_js())
assert "'raster-resampling': 'nearest'" in block, (
"HILLSHADE_PAINT должен использовать nearest-resampling"
)
# ──────────────────────────────────────────────────────────────────────────────
# TRI_PAINT (REQ-F-08, F-09; AC-05, AC-06)
# ──────────────────────────────────────────────────────────────────────────────
def test_tri_paint_defined():
"""REQ-F-08: TRI_PAINT объявлен в app.js."""
js = _app_js()
assert "const TRI_PAINT" in js, "TRI_PAINT не объявлен"
def test_tri_opacity_z8_regression():
"""UT-PAINT-TRI-OPACITY-Z8 (AC-06): на z=8 opacity = 0.70 ровно (регрессия)."""
block = _extract_block("TRI_PAINT", _app_js())
m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL)
assert m
stops = _parse_zoom_stops(m.group(1))
assert 8 in stops and stops[8] == pytest.approx(0.70, abs=0.001), (
f"регрессия z8: TRI opacity должен быть 0.70, факт {stops.get(8)}"
)
def test_tri_opacity_peak_z9_z11():
"""UT-PAINT-TRI-OPACITY-PEAK: на z9-z11 opacity ≥ 0.80."""
block = _extract_block("TRI_PAINT", _app_js())
m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL)
assert m
stops = _parse_zoom_stops(m.group(1))
assert stops[10] >= 0.80, f"z=10 TRI opacity должен быть ≥0.80, факт {stops[10]}"
assert stops[11] >= 0.80, f"z=11 TRI opacity должен быть ≥0.80, факт {stops[11]}"
def test_tri_resampling_nearest():
"""UT-PAINT-TRI-RESAMPLING: raster-resampling = 'nearest'."""
block = _extract_block("TRI_PAINT", _app_js())
assert "'raster-resampling': 'nearest'" in block, (
"TRI_PAINT должен использовать nearest-resampling"
)
# ──────────────────────────────────────────────────────────────────────────────
# applyTerrainLayer: обратная совместимость (REQ-F-04; AC-22)
# ──────────────────────────────────────────────────────────────────────────────
def test_apply_terrain_layer_signature_uses_opacity_or_paint():
"""UT-PAINT-COMPAT-01: сигнатура использует opacityOrPaint."""
js = _app_js()
assert (
"function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom)"
in js
), "сигнатура applyTerrainLayer должна принимать opacityOrPaint"
def test_apply_terrain_layer_normalizes_number_to_legacy_paint():
"""UT-PAINT-COMPAT-01: ветвление по typeof opacityOrPaint === 'number'."""
js = _app_js()
assert "typeof opacityOrPaint === 'number'" in js, (
"applyTerrainLayer должен ветвиться по типу (number → legacy paint)"
)
# «старый» путь должен собирать legacy-paint с linear-resampling
assert "'raster-opacity': opacityOrPaint" in js, (
"при числовом opacityOrPaint paint должен содержать 'raster-opacity': opacityOrPaint"
)
assert "'raster-resampling': 'linear'" in js, (
"legacy-ветка должна использовать linear-resampling"
)
def test_apply_terrain_layer_uses_paint_variable():
"""UT-PAINT-COMPAT-02: объект paint пробрасывается в map.addLayer как есть."""
js = _app_js()
# после нормализации код должен передавать `paint: paint` в addLayer
assert re.search(r"paint:\s*paint\s*,", js), (
"applyTerrainLayer должен использовать переменную `paint` в map.addLayer"
)
# ──────────────────────────────────────────────────────────────────────────────
# Регрессии: пороги, hint, callers (REQ-F-01, F-10, F-14)
# ──────────────────────────────────────────────────────────────────────────────
def test_minzoom_threshold_lowered_to_9():
"""UT-REG-MINZOOM-9 (AC-01): updateHillshadeAvailability использует порог 9."""
js = _app_js()
# внутри updateHillshadeAvailability должно быть `zoom < 9`
m = re.search(
r"function updateHillshadeAvailability\(\)\s*\{(.*?)^\}",
js,
re.DOTALL | re.MULTILINE,
)
assert m, "не найдена функция updateHillshadeAvailability"
body = m.group(1)
assert "zoom < 9" in body, "порог должен быть `zoom < 9`"
assert "zoom < 10" not in body, "старый порог `zoom < 10` должен быть удалён"
def test_hint_text_updated_to_z9():
"""UT-REG-HINT-TEXT (AC-01): hint содержит «Зум 9+»."""
html = _index_html()
# ищем содержимое #terrain-hillshade-hint
m = re.search(
r'id="terrain-hillshade-hint"[^>]*>\s*([^<]+)\s*</span>',
html,
)
assert m, "не найден #terrain-hillshade-hint в index.html"
text = m.group(1).strip()
assert text == "Зум 9+", f"hint должен быть «Зум 9+», факт «{text}»"
def test_apply_terrain_layer_caller_count():
"""UT-REG-CALLERS: applyTerrainLayer вызывается минимум 2 раза в onTerrainCheckbox."""
js = _app_js()
# ищем вызовы (исключая саму декларацию функции)
pattern = re.compile(r"applyTerrainLayer\s*\(")
matches = pattern.findall(js)
# одно совпадение — объявление функции, остальные — вызовы
assert len(matches) >= 3, (
f"ожидается ≥3 вхождений applyTerrainLayer (1 декл. + ≥2 вызова), факт {len(matches)}"
)
def test_hillshade_call_uses_paint_constant_and_minzoom_9():
"""REQ-F-02 + REQ-F-05: вызов hillshade использует HILLSHADE_PAINT и minzoom=9."""
js = _app_js()
# ищем строку вызова, привязанную к hillshade
m = re.search(
r"applyTerrainLayer\(\s*'terrain-hillshade'\s*,\s*[^,]+,\s*hillshadeChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)",
js,
)
assert m, "вызов applyTerrainLayer для terrain-hillshade не найден"
paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3))
assert paint_arg == "HILLSHADE_PAINT", (
f"hillshade должен использовать HILLSHADE_PAINT, факт {paint_arg}"
)
assert minz == 9, f"hillshade minzoom должен быть 9, факт {minz}"
assert maxz == 15, f"hillshade maxzoom должен быть 15, факт {maxz}"
def test_tri_call_uses_paint_constant_and_minzoom_5():
"""REQ-F-03 + REQ-F-08: вызов TRI использует TRI_PAINT и minzoom=5 (без изменений)."""
js = _app_js()
m = re.search(
r"applyTerrainLayer\(\s*'terrain-tri'\s*,\s*[^,]+,\s*triChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)",
js,
)
assert m, "вызов applyTerrainLayer для terrain-tri не найден"
paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3))
assert paint_arg == "TRI_PAINT", (
f"tri должен использовать TRI_PAINT, факт {paint_arg}"
)
assert minz == 5, f"tri minzoom должен быть 5, факт {minz}"
assert maxz == 15, f"tri maxzoom должен быть 15, факт {maxz}"