27 KiB
type, work_item_id, title, version, status, created_at, updated_at, authors, related
| type | work_item_id | title | version | status | created_at | updated_at | authors | related | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
| trz | ET-013 | ТЗ: Перепады высот на z9-z11 — zoom-aware paint для hillshade и TRI | 1 | draft | 2026-06-04 | 2026-06-04 |
|
|
ТЗ — 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):
if (zoom < 10) {
заменить на
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).
Заменить:
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png',
hillshadeChecked, 0.40, 10, 15);
на:
// 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 после включения слоя:
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 не трогаем:
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).
Текущая сигнатура:
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
...
paint: { 'raster-opacity': opacity, 'raster-resampling': 'linear' },
...
}
Новая сигнатура (обратно-совместимая):
/**
* @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)
добавить блок констант:
// 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.
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):
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.
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:
<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>
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)
// 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-тестов в проекте нет)
# 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/: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-typeimage/png. Если директория не существует — тест skipped с пояснением.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 и деплоя:
-
Pre-merge sanity (на test-среде до деплоя):
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. -
Smoke в test-среде:
- Открыть карту, центр над Окой/югом Москвы (
[37.6, 54.5]). window._map.setZoom(9)— кнопка «Тени рельефа» активна.- Включить «Тени рельефа» и «Перепады».
- Скриншот → визуальная приёмка по AC-03..AC-05.
- Открыть карту, центр над Окой/югом Москвы (
-
Зафиксировать в
14-deploy-log.md.
REQ-F-21 — Документация
В docs/work-items/ET-013/ после Анализа:
00-business-request.md(есть)01-brd.md02-trz.md(этот файл)03-acceptance-criteria.md04-test-plan.yaml04b-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. План работ (для разработчика)
- Pre-implementation check: проверить наличие тайлов z9-z11 на test-среде (REQ-F-20 §1). Если 404 — стоп, открыть PH-6 follow-up.
- Frontend constants: добавить
HILLSHADE_PAINTиTRI_PAINT(REQ-F-05, F-08) послеTERRAIN_BASE_URL. - Frontend
applyTerrainLayer: расширить сигнатуру (REQ-F-04). - Frontend
onTerrainCheckbox: перевести вызовы на константы (REQ-F-02, F-03). - Frontend
updateHillshadeAvailability: порог< 10→< 9(REQ-F-01, F-11). - HTML hint: «Зум 10+» → «Зум 9+» (REQ-F-10).
- Тесты:
tests/unit/test_terrain_paint.py(REQ-F-13, F-14). - Integration smoke:
tests/integration/test_terrain_z9_tiles.py(REQ-F-15) — с@pytest.mark.skipifдля CI без данных. make lint/make test— должны пройти.- Code review → merge → deploy в test.
- Ручная валидация (REQ-F-20 §2).
- Playwright UI-тесты по
04b-ui-test-cases.md. - Запись в
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). |