Files
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

27 KiB
Raw Permalink Blame History

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
agent:analyst
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):

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-type image/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 и деплоя:

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

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