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