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