Files
enduro-trails/docs/work-items/ET-013/02-trz.md
claude-bot 7df1ffe75c
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 9s
CI / build (push) Successful in 2s
analyst(ET): auto-commit from analyst run_id=78
2026-06-04 09:28:51 +00:00

607 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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). |