---
type: adr
work_item_id: ET-012
adr_id: ADR-016
title: "ADR-016: Снижение minzoom публичных GPS-треков до z5 — калибровка существующих tier-таблиц, on-demand MVT остаётся, без heat-map/clustering"
status: accepted
created_at: 2026-06-04
updated_at: 2026-06-04
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels:
  - "ET-012:tiling"
  - "minor-change"
---

# ADR-016 — Политика отдачи треков на z5-z7

## Статус

**Accepted.** Архитектурное решение для ET-012.

Это **калибровка** (а не пересмотр) стратегии, заложенной в ADR-008.
BRD §6 «Документация» допускает отсутствие отдельного ADR для этой
задачи, поскольку tier-структура `build_gps_mvt`/`_simplify_coords`
изначально расширяема. ADR оформляется ради единого индекса
архитектурных решений и чтобы зафиксировать **причины отклонения
альтернатив** (heat-map, pre-rendering, snap-to-h3) — иначе они
вернутся в обсуждение в следующем work-item.

## Контекст

### Текущее состояние (после ET-008 / ADR-008 / ET-009)

- ADR-008 §4-5 закрепил **двухрежимную отдачу**:
  - z ∈ [`GPS_TRACKS_MIN_ZOOM`, `GPS_TRACKS_ZOOM_CUTOFF`) — MVT через
    `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + серверный LRU(1024);
  - z ≥ `GPS_TRACKS_ZOOM_CUTOFF` (= 12) — GeoJSON через
    `GET /api/gps-tracks?bbox=…`;
  - z < `GPS_TRACKS_MIN_ZOOM` — слой полностью скрыт (защита от
    шторма запросов).
- `GPS_TRACKS_MIN_ZOOM = 8` (хардкод в `src/web/gps_tracks.js:8` и
  `gps-tracks-tiles.minzoom`).
- `build_gps_mvt` (`src/api/gps_tracks/mvt.py`) уже содержит
  zoom-aware tier-таблицу `min_length_m`/`limit` (z≤7, z≤9, z≤11, z≥12).
- `_simplify_coords` уже содержит tier по Douglas-Peucker tolerance
  (z≥12: без упрощения; z≥10: 0.0005°; z≥8: 0.002°; иначе 0.008°).
- БД `data/gps_tracks.sqlite` — порядка сотен треков сейчас, прогноз
  до 5000 за горизонт года, индексы по `min_lon/max_lon/min_lat/max_lat`
  (BRD §2.1, TRZ §2).

### Что хочет ET-012

Снизить нижний порог видимости слоя с z8 до z5, чтобы при первом
открытии карты (которая по умолчанию на обзорном зуме) пользователь
сразу видел общее покрытие сети треков (BRD §2.2).

Архитектурный вопрос: **как заставить on-demand MVT работать
приемлемо на z5-z7 без введения новых сервисов и без потери
читаемости.** «Просто понизить константу» — недостаточно: на z5 один
тайл накрывает ~1250×1250 км, и без агрессивной фильтрации/упрощения:

- размер MVT может перевалить 1 MB (R-1);
- DP-tolerance 0.008° (≈800 м) превратит трек 30 км в зигзаг из 30
  точек, что бессмысленно при пиксельном размере карты ~5 км/px (R-2);
- линия `0.5 px` на z5 будет невидима (R-3);
- bbox-запрос рискует прочитать треки всей страны без LIMIT (R-4);
- LRU из 1024 тайлов теоретически может вытесняться при walk-through
  world (R-6).

Все эти риски — в BRD §5; нужно **архитектурно их закрыть** до
реализации, а не разруливать в коде ad-hoc.

## Рассмотренные варианты

### Вариант P (Pipeline) — как готовить тайлы z5-z7

- **P-A — on-demand build с тем же LRU 1024** (выбран):
  - Тайлы z=5/6/7 строятся в `build_gps_mvt(rows, z, x, y)` так же,
    как z=8..z=11. Кэш общий.
  - Никаких новых сервисов / cron / volume. Никакой инвалидации
    поверх существующей `POST /api/gps-tracks/cache/clear` (ADR-008
    §7) не нужно.
  - Cold-time дешёвый: один SELECT по R-tree-индексу + Python-loop
    с генерализацией. На БД ≤ 5000 треков по ЦФО — < 200 мс (PERF-Z5-01).
- **P-B — pre-generate всю сетку z=5..z=7 на диск** (Tilelive-стиль).
  Отклонён:
  - z5: 32×32 = 1024 тайла; z6: 4096; z7: 16384 — суммарно ~21k.
    После gzip ~1.5 MB / 6 MB / 24 MB соответственно. Не критично
    по диску, но: ломает существующий cache-invalidation (нужно
    удалять файлы, а не `_tile_cache.clear()`), вводит новый
    pre-warm step после каждого `gps-collector` run.
  - Усложняет deployment (volume mount, fs perms).
  - Не даёт ничего сверх LRU при текущей нагрузке (пара пользователей
    в test). При росте нагрузки — возврат к рассмотрению как
    отдельный work-item.
- **P-C — внешний tile server (Tegola/Martin/tilemaker)**. Отклонён
  как и в ADR-008 §T-C: новый сервис, новый артефакт деплоя; не
  оправдано размером данных.

### Вариант T (Tier values) — на каком уровне обрезать на z5-z6

Цели:
- M-6 (p95 build ≤ 500 мс на z5);
- M-8 (размер MVT z5 ≤ 200 KB);
- M-9 (читаемость z5 — ≥ 3 различимых линий в кадре по ЦФО).

Кандидаты, рассмотренные на берегу:

| Tier   | z5 min_len | z5 limit | z6 min_len | z6 limit | Заключение |
|--------|-----------:|---------:|-----------:|---------:|------------|
| T-1    |    20000 m |      500 |    10000 m |     1000 | Слишком жёстко: при ЦФО получаем ~10-15 треков в кадре, M-9 проходит, но «обзор сети» теряется — большая часть треков невидима. |
| T-2 (**выбран**) |    10000 m |     1500 |     5000 m |     2000 | Соответствует BRD/TRZ REQ-F-03. На ЦФО (БД ~500 длинных треков) даёт ~50-80 фич в тайле z5, ~150 в z6. Размер до gzip ~80-100 KB; после nginx-gzip ~30 KB. M-6, M-8, M-9 проходят с запасом. |
| T-3    |     5000 m |     3000 |     2000 m |     3000 | Не оставляет запаса по M-8: при 5000 треков размер MVT z5 может вылезти за 200 KB при «густой» области. Резерва на рост БД нет. |

**Tier T-2 — компромисс «обзор сети» × «гарантированный лимит»**.

`tolerance` для DP подобрана так, чтобы trace ≤ 5 км на z5
схлопывалось в прямую (tolerance ~4 км / 0.04° долготы на 55° с.ш.).
Для z6 tolerance = 0.018° (~2 км) — позволяет видеть крупные изгибы
длинных треков (TRZ §3.4 REQ-F-04).

### Вариант L (Layer style) — как делать линию читаемой на z5

- **L-A — статичный `line-width: 1px`** (как было для z≥8). Отклонён:
  на retina-дисплеях 1 CSS-pixel = 2-3 physical pixels, на z5 это
  выглядит как «жирная нить»; на 1×-дисплеях 1px после anti-aliasing
  частично «съедается».
- **L-B — интерполяция `interpolate linear zoom 5 0.8 8 1.0 ...`**
  (выбран, REQ-F-05):
  - z=5: 0.8 CSS-px → 1 физ.px на 1×, 1.6 на 2×, 2.4 на 3×. Видно
    везде.
  - z=8: 1.0 CSS-px (= как было).
  - Halo (REQ-F-06): z=5: 1.8 px; соотношение ~2.25× к основной
    линии → ореол не «съедает» линию.
- **L-C — Switch на pattern/dash на z5** (тонкая прерывистая линия,
  как «маршрут на карте мира»). Отклонён: визуально несовместимо с
  z6+; пользователь будет видеть «прыжок стиля» при zoom-in.

### Вариант B (Buffer) — bbox-padding на z5

В endpoint `gps_tile` сейчас bbox расширяется на 10% при запросе к БД
(ADR-008 §8) — это страховка от «обрезанных» треков на границе тайла.
На z5 10% bbox-расширение = ~125 км в каждую сторону, что:

- **избыточно** для z5 — соседний тайл всё равно нарисует пограничный
  трек как часть собственного MVT;
- **не вредит** существенно — Spatialite-R-tree всё равно фильтрует
  по min/max lon/lat быстро.

Решение: **buffer не меняем в MVP**. Если PERF-Z5-01 покажет
деградацию — снизим до 5% точечно для z≤6 в отдельном минорном
изменении (TRZ §6, R-5).

### Вариант C (Cache size) — нужно ли увеличивать LRU

Сейчас `_GPS_TILE_CACHE_MAX = 1024`.

- На z=5 в мире 32×32=1024 уникальных тайлов; пользователь на практике
  видит 4-8 одновременно. Walk-through-world попросит ~50 уникальных.
- На z=5..z=11 совместно при «обычном» использовании в кадре
  одновременно держится ~10-20 тайлов.
- **Решение: не трогаем 1024 в MVP** (TRZ §6, R-6). Поднимем до 2048
  отдельным минорным изменением, если PERF-метрика M-11 даст cache
  hit < 80%.

### Вариант H (Heat-map for z3-z4) — что показывать ниже z5

- **H-A — heat-map / clustering на z3-z4** (Wikiloc/Komoot-стиль).
  **Отклонён из ET-012** (BRD §3 Out of scope):
  - Требует серверную агрегацию (например, h3-cell counts или
    grid-density-precompute).
  - Требует новый UI-слой (raster heatmap-source или CircleLayer с
    weight-based radius).
  - Делается отдельным work-item.
- **H-B — оставить «слой пуст, но hint показывает «Зум 5+»** (выбран,
  REQ-F-07):
  - Существующая логика `_syncGpsLayersVisibility` уже показывает
    hint при `zoom < GPS_TRACKS_MIN_ZOOM`. После понижения константы
    hint появляется при z<5, что и желательно: на z3-z4 у пользователя
    есть явное объяснение, почему «пусто».

## Решение

Принимается **P-A + T-2 + L-B + B(no-change) + C(no-change) + H-B**:

1. **On-demand MVT** на всех зумах [5..11]; LRU и
   cache-invalidation — без изменений (ADR-008 §6-7 наследуется).

2. **Tier-таблица в `build_gps_mvt`**:

   ```python
   if z <= 5:    min_length_m = 10000;  limit = 1500
   elif z == 6:  min_length_m =  5000;  limit = 2000
   elif z == 7:  min_length_m =  2000;  limit = 3000
   elif z <= 9:  min_length_m =     0;  limit = 8000
   elif z <= 11: min_length_m =     0;  limit = 15000
   else:         min_length_m =     0;  limit = 25000
   ```

   Цифры выводятся из M-6/M-8/M-9: предполагаемый максимум
   1500 фич × 200 байт ≈ 300 KB до gzip → ≈ 80 KB после nginx-gzip.

3. **Tier-таблица в `_simplify_coords`**:

   ```python
   z>=12: return coords          # без упрощения
   z>=10: tolerance = 0.0005     # ~50 м
   z>=8:  tolerance = 0.002      # ~200 м
   z==7:  tolerance = 0.008      # ~800 м (как раньше)
   z==6:  tolerance = 0.018      # ~2 км
   else:  tolerance = 0.04       # ~4 км (z5 и ниже)
   ```

   На 55° с.ш. 0.04° долготы ≈ 2.6 км — оптимум «одна точка на
   пиксель» при размере пикселя z5 ≈ 5 км/px по экватору.

4. **Клиент**:
   - `GPS_TRACKS_MIN_ZOOM = 5` в `src/web/gps_tracks.js:8`.
     `gps-tracks-tiles.minzoom` подхватит автоматически (REQ-F-01..F-02).
   - `_gpsLayerDef.paint['line-width']` — расширить интерполяцию
     стопом z=5 → 0.8 (REQ-F-05).
   - `_gpsHaloDef.paint['line-width']` — стопом z=5 → 1.8 (REQ-F-06,
     R-8/R-10).
   - `#public-tracks-zoom-hint` — текст «Зум 5+» (REQ-F-07).
     Логика показа `(enabled && zoom < GPS_TRACKS_MIN_ZOOM)` не
     меняется — порог переехал автоматически.

5. **Backend endpoint** `get_gps_tile` — без изменений; валидация
   `0 ≤ z ≤ 22` уже пропускает z=5..7 (REQ-F-08).

6. **Buffer (10% bbox) и `_GPS_TILE_CACHE_MAX = 1024`** — без
   изменений в MVP. Оба пункта остаются как hooks для отдельного
   мелкого изменения, если PERF-/M-метрики не сойдутся (TRZ §6).

7. **z3-z4** — слой остаётся скрытым, hint объясняет. Heat-map —
   отдельный work-item.

## Последствия

### Положительные

- Минимальная инвазивность: 1 константа на клиенте + 2 переписанных
  блока на сервере + 2 правки стилей + 1 правка hint. Никаких новых
  модулей, файлов, сервисов, миграций, env, секретов, портов.
- ADR-008 двухрежимная стратегия (MVT z<12, GeoJSON z≥12) не
  затрагивается — z12+ ведёт себя как прежде, регрессии нет
  (AC-07, IT-REGRESS-Z8-01/Z10-01).
- Тонкая настройка через числовые tier-параметры — изменяется в одной
  функции; будущая корректировка («z=5 → limit=1000 для роста БД»)
  делается в 5 минут без архитектурных правок.
- Существующий cache-clear-hook (`POST /api/gps-tracks/cache/clear`)
  автоматически очищает и тайлы z5-z7 после прогона pipeline'а
  (ADR-007 §7) — никакая дополнительная инвалидация не нужна.

### Отрицательные / ограничения

- **Эффективный «жёсткий cutoff» по длине трека на z5-z6.** Треки
  короче 10 км невидимы на z5, короче 5 км — на z6. Пользователь не
  увидит «полные грунтовые километры» в обзоре — только магистральные
  трассы. Принято: для z5-z6 «общее покрытие сети» = «магистральная
  сеть» (BRD §2.2).
- **Hint «Зум 5+» появляется только при z<5**, что эффективно — только
  для z ∈ {0..4}. На самом верхнем зуме «обзор континента» (z3) у
  пользователя пусто. Митигация — heat-map в отдельном work-item.
- **Размер LRU 1024 теоретически переполняется при walk-through-world
  z5+z6 одновременно** (1024 + 4096 уникальных тайлов). На практике
  пользователь работает с регионом; rotate работает. Митигация
  отложена (R-6).
- **Buffer 10% bbox на z5 = 125 км запас** — формально избыточен, но
  не вредит: R-tree-фильтр быстрый, лишние треки отрезает Python-loop
  по `min_length`. Митигация отложена (R-5).
- **DP-tolerance ~4 км на z5 может «выпрямить» зигзагообразный трек
  в прямую.** Это норма для обзорного зума (BRD §5, R-2): трек 5 км
  → отрезок. Качественная проверка по TC-UI-12-Z5-Q.

### Технический долг

- Текущая tier-таблица в `build_gps_mvt` — копипаста if-elif. Если
  появится третий MVT-источник (например, шлагбаумы ET-PH-7) — вынести
  tier-функцию в shared util `mvt_tiers.py`. Не блокер MVP, отмечено
  как наследие ADR-005 §8 / ADR-008 §«Технический долг».
- При росте БД до десятков тысяч треков может понадобиться вторичный
  индекс на `length_m` для серверной сортировки/фильтрации (R-4); пока
  индексы по bbox + Python-фильтр справляются. Отложено.

## Классификация изменения

**Minor change.** ET-012 — калибровка существующей tier-структуры
ADR-008. Новых сервисов, БД, очередей, HTTP-эндпоинтов, env, портов,
секретов, миграций не добавляется. Контракт API не меняется
(REQ-F-15). `arch:major-change` не требуется.

## Связанные документы

- `docs/work-items/ET-012/01-brd.md` §3 Scope, §5 Риски R-1..R-10, §6 Зависимости
- `docs/work-items/ET-012/02-trz.md` REQ-F-01..F-08, §4 NFR, §6 Открытые вопросы
- `docs/work-items/ET-012/03-acceptance-criteria.md` AC-01..AC-21
- `docs/work-items/ET-012/07-infra-requirements.md` (этот пакет)
- `docs/work-items/ET-012/08-data-requirements.md` (этот пакет)
- `docs/work-items/ET-012/10-tech-risks.md` (этот пакет)
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §8 (общий tile-utility, наследие)
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §7 (cache-clear hook)
- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md` (родительская стратегия отдачи)
