---
type: adr
work_item_id: ET-008
adr_id: ADR-008
title: "ADR-008: Двухрежимная отдача публичных треков — MVT-тайлы на z ≤ 11, GeoJSON по bbox на z ≥ 12; клиентское переключение по zoom; общий cache-invalidation"
status: accepted
created_at: 2026-06-01
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels: []
---

# ADR-008 — Стратегия отдачи треков клиенту: MVT vs GeoJSON

## Статус

Accepted

## Контекст

Слой публичных треков (BRD §3 F-05..F-09) должен:

- Показываться на широком диапазоне zoom — от z=8 (вся область региона видна сразу) до z=16+ (один трек крупно).
- Поддерживать **клик с popup** на трек (REQ-F-18) — то есть feature должна быть «настоящей», а не растровой.
- Поддерживать **клиентскую фильтрацию** по активности и источнику без сетевого запроса (REQ-F-14, AC-08).
- Уложиться в p95 ≤ 300 мс для GeoJSON-ответа (BRD-метрика).
- Не штормить сервер запросами при быстром pan (AC-14).

На низком zoom (z=8) в видимую область могут попасть тысячи треков. Отдавать их одним GeoJSON-ответом неприемлемо: payload в 10–100 МБ → сетевой p95 проседает; парсинг GeoJSON блокирует main thread браузера; MapLibre перерисовывает каждое pan-move.

На высоком zoom (z ≥ 12) в видимую область попадают десятки треков, и пользователь ждёт interactive popup + точную геометрию.

Архитектурно нужно выбрать стратегию отдачи и переключения между режимами.

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

### Вариант M (Mode) — единый режим отдачи

- **M-A — только GeoJSON для всех zoom**. Отклонён:
  - На z=8 payload неприемлем (см. контекст).
  - Не использует существующий MVT-кэш-паттерн `main.py` для слоя `trails` — теряем уже отлаженный механизм для аналогичной задачи.
- **M-B — только MVT для всех zoom**. Отклонён:
  - MVT не даёт удобного `popup` с богатыми метаданными: `properties` MVT-тайла ограничены (плюс через MapLibre `queryRenderedFeatures` доступ есть, но фильтр feature-level через `setFilter` требует чтобы все нужные поля сидели в MVT-фиче — а у нас `sources` массив, который в MVT нативно не представляется).
  - Клиентская фильтрация по `source` через `setFilter` работает только на одной колонке source (REQ-F-16 «первый source»); для multi-source filtering на MVT-фиче без множественной колонки — компромисс.
- **M-C — гибрид: MVT на z ≤ 11, GeoJSON на z ≥ 12** (выбран, совпадает с TRZ REQ-F-11 финальной формулировкой).
  - На z ≤ 11 — MVT, серверный LRU-кэш, ограниченное упрощение геометрии. Клиент видит «общий ландшафт» — где много треков, плотность, какие источники доминируют.
  - На z ≥ 12 — GeoJSON по bbox, полные точные координаты, полные `sources_json`/`external_urls_json` для popup.
  - Cutoff z=12 — реалистичный порог: 1 тайл z=11 ≈ 19 × 12 км (на широте 55°), z=12 ≈ 10 × 6 км. В bbox z=12 типично попадает ≤ 500 треков → GeoJSON ≤ 2 МБ → влезает в SLA 300 мс.

### Вариант T (Tile generation) — как генерировать MVT

- **T-A — реальное время по запросу + LRU-кэш** (выбран; совпадает с архитектурой текущего слоя `trails` в `main.py`):
  - На запрос `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`:
    1. Проверить LRU-кэш (1024 записи).
    2. На промахе — выполнить SELECT из `tracks` по bbox тайла, упростить геометрии по `simplify_coords(coords, z)`, отдать через `mapbox-vector-tile`.
    3. Записать результат в LRU.
  - Cache-invalidation — `POST /api/gps-tracks/cache/clear` после успешного pipeline-прогона (ADR-007 §7).
  - Cold-cache p95 ≤ 200 мс (REQ-NF-02). Hot-cache ≤ 20 мс.
- **T-B — pre-generated tile cache** (после pipeline сразу генерируется весь z=8..z=11 grid на диск). Отклонён:
  - 4ˡ tiles на каждом zoom — z=8 = 16 tiles, z=9 = 64, z=10 = 256, z=11 = 1024 → ≈ 1.4k тайлов. Несложно, но: при росте региона до РФ — десятки тысяч; диск растёт без необходимости.
  - Cold-cache при первой загрузке после прогона всё равно нужен (LRU прогревается естественно).
  - Усложняет cache-invalidation: нужно удалять файлы вместо `_tile_cache.clear()`.
- **T-C — внешний tile server** (tilelive/tilemaker/Tegola). Отклонён: новый сервис, новая инфра-зависимость; mapbox-vector-tile в Python уже умеет всё, что нужно.

### Вариант G (GeoJSON limit) — как обрезать GeoJSON

- **G-A — фиксированный limit=500, truncated=true в payload** (выбран; совпадает с TRZ REQ-F-10).
  - На z ≥ 12 типично ≤ 500 треков в bbox → truncated:false.
  - На редких плотных bbox (10+ треков/км²) сервер возвращает первые 500 (LIMIT в SQL), `truncated:true`, клиент показывает в UI «показано 500 из 743, увеличьте zoom».
  - Простая семантика, нет surprise для разработчика API.
- **G-B — server-side pagination cursor**. Отклонён: над-инжиниринг; для visualisation-слоя пагинация не интуитивна; пользователю удобнее zoom, а не next-page.
- **G-C — server-side clustering для overflow**. Отклонён: track — это LineString, кластеризация по линейным сущностям нетривиальна; out of scope.

### Вариант F (Filter location) — где фильтровать по activity/source

- **F-A — серверный фильтр в SQL** (по `activity_type`) + Python-постфильтр (по `sources_json`); итоговое FeatureCollection уже отфильтровано (выбран для GeoJSON, совпадает с TRZ REQ-F-10).
  - Сервер сразу возвращает только нужное → меньше трафика.
  - Но: смена фильтра в UI → новый запрос. Это ОК для GeoJSON (z ≥ 12, < 500 треков) — REQ-NF-06 «≤ 200 мс» выполнимо при cache miss.
- **F-B — клиентский фильтр через `setFilter`** на уже загруженной выборке (выбран **дополнительно**, для MVT-режима).
  - На z ≤ 11 — MVT уже содержит всё; смена фильтра — мгновенный `setFilter` без сетевого запроса. AC-08 «фильтрация мгновенная (≤ 200 мс)».
  - На z ≥ 12 — клиентский setFilter работает поверх загруженного GeoJSON; для повторного fetch при следующем `moveend` уже учитываются новые фильтры.

### Вариант D (Debounce) — защита от шторма запросов

- **D-A — клиентский debounce 500 мс + AbortController** (выбран; совпадает с TRZ §6.4):
  - На `moveend` карта запускает 500-мс таймер; новые `moveend` сбрасывают его.
  - Старые in-flight запросы отменяются `AbortController.abort()`.
  - Server-side rate-limit не нужен — фронтенд сам себя ограничивает.
- **D-B — server-side rate-limit middleware**. Отклонён: усложняет API, не нужно при D-A.

### Вариант H (Halo on satellite) — гибридный слой через MVT/GeoJSON

- **H-A — две `'source'`-привязки в MapLibre**: одна на `gps-tracks-tiles` (vector source MVT), вторая на `gps-tracks-geo` (GeoJSON source). Один и тот же слой `gps-tracks-layer` нельзя привязать к двум sources одновременно. Поэтому **два параллельных слоя**: `gps-tracks-layer-mvt` (visible на z ≤ 11) и `gps-tracks-layer-geo` (visible на z ≥ 12). Переключение через `setLayoutProperty('visibility')` по `zoomend`. (Выбран — единственный нормально работающий способ.)
- **H-B — переключать `setData` на одном слое**. Отклонён: GeoJSON-source и vector-source — разные типы в MapLibre; нельзя «переключить» source у layer'а без `removeLayer` + `addLayer`.

## Решение

Принимается комбинация: **M-C + T-A + G-A + F-A + F-B + D-A + H-A**.

1. **Двухрежимная отдача:**
   - `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` — векторные тайлы, **только для клиента**, который добавил vector-source `gps-tracks-tiles`. Клиент использует на z ≤ 11.
   - `GET /api/gps-tracks?bbox=&activity=&source=&limit=` — GeoJSON FeatureCollection, для z ≥ 12.

2. **Cutoff z=12** — выбран как баланс между «MVT даёт обзор + кэш» и «GeoJSON даёт полный popup-data». Cutoff фиксирован в клиенте константой `GPS_TRACKS_ZOOM_CUTOFF = 12`.

3. **MVT-слой клиента:**
   - Source: `vector` type, `tiles: ['/api/gps-tracks/tiles/{z}/{x}/{y}.mvt']`, `minzoom: 8`, `maxzoom: 11`. На z < 8 слой полностью скрыт (TRZ REQ-F-20).
   - Layer: `gps-tracks-layer-mvt`, `source-layer: 'gps_tracks'`, paint по REQ-F-17.
   - Properties фичи: `id, activity, source (первый), sources (comma-separated), length_km, name, ext_url` (TRZ §4.3). `sources` как comma-string, потому что MVT не поддерживает массивы.

4. **GeoJSON-слой клиента:**
   - Source: `geojson`, `data: { type: 'FeatureCollection', features: [] }` (пустой при инициализации).
   - Layer: `gps-tracks-layer-geo`, `source: 'gps-tracks-geo'`, paint по REQ-F-17.
   - На `moveend` (debounced 500 мс через AbortController) — `fetch('/api/gps-tracks?bbox=...&activity=...&source=...&limit=500')` → `getSource().setData(json)`.

5. **Переключение по zoom:**
   - `zoomend` listener: `if (z < 12) hide(geo); show(mvt); else show(geo); hide(mvt);`.
   - `visibility` управляется `setLayoutProperty`.
   - Кратко: оба source и layer всегда **существуют** при включённом чекбоксе; меняется только видимость.
   - На z < 8 — оба невидимы (REQ-F-20); статус-баннер «Зум 8+».

6. **Серверный MVT-кэш:**
   - LRU-словарь в памяти процесса FastAPI, ёмкость **1024** записи (как для слоя `trails`).
   - Ключ — `(z, x, y)`. Значение — байты `.mvt`.
   - На промахе SELECT идёт через R-tree (Spatialite `idx_tracks_geom`) с bbox тайла + 5% padding.
   - Упрощение геометрии — `simplify_coords(coords, z)` (Douglas-Peucker tolerance зависит от zoom).
   - LIMIT тайла — как у `trails` (3000/8000/15000 на z ≤ 7/9/11).

7. **Cache-invalidation:**
   - `POST /api/gps-tracks/cache/clear` — единственный POST в этом семействе эндпоинтов, авторизуется по сетевому пути (только из docker-compose internal network; через `/enduro/` proxy не маршрутизируется — см. `07-infra-requirements.md` §3).
   - Pipeline вызывает его при успешном завершении (ADR-007 §7).

8. **Сервер GeoJSON (`GET /api/gps-tracks`):**
   - SQL: `SELECT * FROM tracks WHERE ROWID IN (SELECT pkid FROM idx_tracks_geom WHERE ... bbox ...) [AND activity_type IN (...)] ORDER BY length_m DESC LIMIT N` — длинные треки первыми (полезнее для overview).
   - `source` фильтр — постфильтр на Python после получения < 500 строк (`'osm' in json.loads(sources_json)`).
   - Total — отдельный `COUNT(*)` запрос с теми же WHERE-условиями (без LIMIT) для `total_in_bbox`.
   - Response — GeoJSON по REQ-F-10 со всеми properties.
   - p95 ≤ 300 мс — выполнимо на bbox с ≤ 500 треков (запросы R-tree + N парсингов WKB по 1.5 КБ).

9. **Atomic state в клиенте** через объект `window.gpsTracksLayer` (TRZ §4.4). Поля state на 100% derived из (`localStorage` + `map.getZoom()` + последний GeoJSON-ответ); восстановление в `rebuildMapOverlays() → restorePublicTracksState()` (REQ-F-19).

10. **Halo на спутнике (REQ-F-15, ET-007 §7.2 паттерн):**
    - Для **обоих** клиентских слоёв (MVT и GeoJSON) — свои halo:
      - `gps-tracks-halo-mvt-satellite` — halo поверх `gps-tracks-tiles`.
      - `gps-tracks-halo-geo-satellite` — halo поверх `gps-tracks-geo`.
    - Видимость halo управляется хелпером `applyGpsHaloVisibility()` по правилу: halo видим ⇔ `(public-tracks ON) AND (zoom band matches) AND (base === 'satellite')`.
    - Hook добавляется в `applyBaseLayer()` (ET-007) — по тому же паттерну, что halo для trails (ADR-004 §9).

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

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

- Соответствует SLA: MVT cold p95 ≤ 200 мс, GeoJSON p95 ≤ 300 мс при разумном bbox.
- Низкий зум — обзор; высокий зум — полный popup. Пользователь получает оптимум на каждом масштабе.
- Кэш-стратегия идентична существующему слою `trails` — оператор уже знаком; единый паттерн.
- AbortController + debounce защищают от шторма запросов независимо от того, насколько быстро юзер pan'ит карту.
- Cache-invalidation после прогона — пользователь видит свежие данные при следующем pan/zoom.

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

- **Два source / два layer на один логический слой** — небольшое усложнение клиентского кода (sync visibility, sync filter). Кодовое разбиение — в `src/web/gps_tracks.js`; внутренняя сложность не «протекает» наружу.
- **Жёсткий cutoff z=12.** На границе (z=11.5) пользователь может видеть мигание: MVT-тайлы упрощены до 1км, GeoJSON покажет точные кривые. Сглаживание — `transition` на opacity (UI-микро-улучшение, не блокер).
- **`source` в MVT — только первый из dedup-list.** Цвет по источнику (REQ-F-16) показывает «первый по приоритету»; реальное мульти-источникство видно только в popup на z ≥ 12. Принято: «дедупный мульти-источникный» трек редок (< 10% по оценке BRD §5); цвет по «первому источнику» интуитивен.
- **Серверный кэш сбрасывается ТОЛЬКО pipeline'ом.** Если оператор вручную `UPDATE tracks` — кэш не инвалидируется. Митигация — оператор знает про эндпоинт; в runbook (`07-infra-requirements.md` §8). На практике вручную в БД лазать не предполагается.

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

- `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` дублируются между `main.py` и `gps_tracks/mvt.py` (см. ADR-005 §8). При появлении третьего MVT-источника — вынести в shared util.
- Если в будущем понадобится фильтр по multiple `source` непосредственно в MVT (для multi-color по источникам трека) — необходимо переработать схему MVT properties (массив через JSON-string или через несколько колонок). Не блокер MVP.

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

**Minor change.** Стратегия отдачи — внутренний контракт клиент↔API, всё в пределах FastAPI и фронтенда. Новых сервисов, БД, очередей не вводит. `arch:major-change` не требуется.

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

- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-10, REQ-F-11, REQ-F-13, REQ-F-17, REQ-F-20, §6.4
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-04, AC-05, AC-13, AC-14
- `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/07-infra-requirements.md` §3 (network)
- `docs/work-items/ET-008/10-tech-risks.md` R-7, R-8
- `docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md` §5, §9 (halo-паттерн)
