607 lines
27 KiB
Markdown
607 lines
27 KiB
Markdown
---
|
||
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). |
|