---
type: trz
work_item_id: ET-008
title: "ТЗ: GPS-треки с публичных платформ на карте"
version: 2
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
changelog:
  - "v2 (2026-06-01): полная переработка под BRD v2 — серверная агрегация по региону, дедупликация, MVT-тайлы публичных треков, фильтры по активности/источнику. Предыдущая v1 описывала URL-импорт + OSM live-поиск (не соответствовало бизнес-цели)."
authors:
  - "agent:analyst"
---

# ТЗ — ET-008: GPS-треки с публичных платформ на карте

## 1. Функциональные требования

### REQ-F-01: Конфигурация источников

Файл `config/gps_sources.yaml` в репозитории:

```yaml
sources:
  - id: osm
    name: "OSM Public GPS Traces"
    enabled: true
    license_adr: "06-adr/osm-licensing.md"
    base_url: "https://api.openstreetmap.org/api/0.6"
    rate_limit_sec: 1
    user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
    attribution: "© OpenStreetMap contributors (ODbL)"
    parser_module: "src.api.gps_tracks.sources.osm"

  - id: enduro_russia
    name: "EnduroRussia.ru"
    enabled: true
    license_adr: "06-adr/enduro-russia-licensing.md"
    base_url: "https://enduro-russia.ru"
    rate_limit_sec: 5
    user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
    attribution: "EnduroRussia.ru"
    parser_module: "src.api.gps_tracks.sources.enduro_russia"

  # ...
```

Поле `enabled: false` исключает источник из pipeline (но БД сохраняет
ранее собранные треки).

### REQ-F-02: Конфигурация регионов

Файл `config/gps_regions.yaml`:

```yaml
regions:
  - id: tsfo_plus_chuvashia
    name: "ЦФО + Чувашия"
    bbox: [29.0, 49.5, 47.5, 60.0]   # [west, south, east, north]
    enabled: true
    sources: [osm, enduro_russia, ttrails]   # ID из gps_sources.yaml

  - id: north_caucasus
    name: "Северный Кавказ"
    bbox: [37.0, 41.5, 49.0, 47.0]
    enabled: false
    sources: [osm, enduro_russia]
```

Добавление региона = новая запись (≤ 30 строк) — REQ-F-04 BRD.

### REQ-F-03: Pipeline сбора `scripts/gps_collect.py`

CLI:
```
python scripts/gps_collect.py [--region <id>] [--source <id>] [--dry-run]
```

Без `--region` — обрабатываются все `enabled: true` регионы. Без
`--source` — все `enabled: true` источники, перечисленные в регионе.

Логика прогона:
```
1. Загрузить config/gps_sources.yaml и config/gps_regions.yaml.
2. Для каждого (region, source) в декартовом произведении:
   2.1. Вызвать parser_module.collect(region.bbox, db) →
        yield {external_id, geom, metadata}
   2.2. Для каждого трека:
        - dedup_key = compute_dedup_key(geom, metadata)
        - Если запись с тем же dedup_key уже есть — обновить
          metadata (sources += [source_id]).
        - Иначе — INSERT.
   2.3. Логировать статус: tracks_new, tracks_updated, errors.
3. Записать в БД таблицу `pipeline_runs`:
   (run_id, started_at, finished_at, region, source, status,
    tracks_new, tracks_updated, errors_json)
4. Exit code: 0 если все source ≥ 1 трек или dry-run; 1 иначе.
```

Все per-source модули обязаны:
- Уважать `rate_limit_sec`: `await asyncio.sleep(rate_limit_sec)` между
  HTTP-запросами.
- Использовать `User-Agent` из конфига.
- Делать `backoff` (3 повтора, exponential) на 5xx/429.
- На необработанную ошибку — `raise` → pipeline ловит, логирует, идёт
  дальше к следующему источнику.

### REQ-F-04: Парсер OSM Public GPS Traces

Модуль `src/api/gps_tracks/sources/osm.py`.

OSM API: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`.

Лимиты:
- bbox площадь ≤ 0.25 deg² (OSM API requirement).
- Регион (ЦФО ≈ 18×10 = 180 deg²) разбивается на тайл-сетку 0.25
  deg-cells (≈ 720 cells на ЦФО+Чувашию).
- Пагинация: page 0…N, до `has_more=false`.

Извлекаем:
- Группировка точек по `gpx_id` атрибуту (там, где есть) → отдельные
  треки. Анонимные точки (без `gpx_id`) — пропускаем (нет публичного
  ID, не дедуплицируется).
- Для треков с `gpx_id` — дополнительный запрос `GET /api/0.6/gpx/<id>`
  для метаданных (name, description, tags, user, timestamp).
  Этот запрос делаем **отложенно** в batch: накопить 100 id → запросить.
  Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).

Метаданные на выходе:
```python
{
  "external_id": f"osm-{gpx_id}",
  "external_url": f"https://www.openstreetmap.org/user/{user}/traces/{gpx_id}",
  "source_id": "osm",
  "name": str | None,
  "description": str | None,
  "user": str | None,
  "activity_type": str | None,   # см. REQ-F-07
  "tags": List[str],
  "created_at": ISO-date | None,
  "geom": LineString,
  "points_count": int,
  "length_m": float,
}
```

### REQ-F-05: Парсер EnduroRussia.ru

Модуль `src/api/gps_tracks/sources/enduro_russia.py`.

Стратегия (зависит от структуры сайта на момент реализации;
фиксируется в ADR `06-adr/enduro-russia-licensing.md` после ревью):

```
1. Получить список регионов: GET <base_url>/regions/
2. Для каждого региона, пересекающегося с bbox:
   2.1. Получить список треков: GET <region_url>/treki/?page=N
   2.2. Для каждого трека:
        - Открыть страницу трека
        - Найти прямую ссылку на GPX
        - Скачать GPX
        - Парсить через тот же парсер, что в gpx.js (или DOMParser
          серверный — через defusedxml)
```

Метаданные на выходе — та же структура, что REQ-F-04 (общий контракт).

`activity_type` — из категории на сайте источника.

### REQ-F-06: Парсер Тропинки.ру / ttrails.ru

Модуль `src/api/gps_tracks/sources/ttrails.py`. По аналогии с REQ-F-05,
точный алгоритм — в ADR после ревью структуры сайта.

### REQ-F-07: Унификация типа активности

Все источники маппят свою категоризацию в фиксированный enum:

```python
ACTIVITY_TYPES = [
  "enduro",       # эндуро-мотоцикл (приоритет проекта)
  "moto",         # мото (не эндуро): шоссе, dual-sport
  "offroad",      # off-road авто, квадроциклы
  "bicycle",      # любые велосипеды (mtb, road)
  "hike",         # пешком, бег
  "ski",          # лыжи
  "other",        # неопределено
]
```

Маппинг per-source — в `parser_module.MAPPING` константой. Категории
источника, не маппящиеся ни во что — в `other`.

### REQ-F-08: Дедупликация

Алгоритм (детали в ADR `06-adr/dedup-algorithm.md`):

```python
def compute_dedup_key(geom: LineString, metadata: dict) -> str:
    bbox = geom.bounds                       # (w, s, e, n)
    bbox_rounded = tuple(round(c, 2) for c in bbox)  # ≈ 1 км
    length_bucket = round(metadata["length_m"] / 1000) * 1000  # 1 км
    date_bucket = metadata.get("created_at", "")[:10]  # YYYY-MM-DD
    return f"{bbox_rounded}|{length_bucket}|{date_bucket}"
```

При коллизии — мержим записи:
- `sources` (массив) ← union.
- `external_urls` (массив) ← union.
- `metadata` ← данные источника с большим приоритетом (порядок в
  `gps_sources.yaml`).

Если у одного из треков `created_at` отсутствует — date_bucket пустой
для обоих → считаем коллизией. Это даст ложные коллизии для треков из
разных дат, но без даты мы и не отличим их.

### REQ-F-09: Схема БД

Отдельная БД: `data/gps_tracks.sqlite` (SQLite + Spatialite).

```sql
CREATE TABLE tracks (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    dedup_key       TEXT NOT NULL UNIQUE,
    name            TEXT,
    description     TEXT,
    activity_type   TEXT,                   -- ACTIVITY_TYPES
    user            TEXT,                   -- автор (опционально)
    created_at      TEXT,                   -- ISO date
    length_m        REAL NOT NULL,
    points_count    INTEGER NOT NULL,
    min_lon         REAL NOT NULL,
    min_lat         REAL NOT NULL,
    max_lon         REAL NOT NULL,
    max_lat         REAL NOT NULL,
    geom            BLOB NOT NULL,          -- WKB LineString
    sources_json    TEXT NOT NULL,          -- ["osm", "enduro_russia"]
    external_urls_json TEXT NOT NULL,       -- ["https://...", "https://..."]
    tags_json       TEXT,                   -- ["forest", "river-crossing"]
    inserted_at     TEXT NOT NULL,
    updated_at      TEXT NOT NULL
);

CREATE INDEX idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat);
CREATE INDEX idx_tracks_activity ON tracks(activity_type);
CREATE INDEX idx_tracks_created ON tracks(created_at);

CREATE TABLE pipeline_runs (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    started_at      TEXT NOT NULL,
    finished_at     TEXT,
    region_id       TEXT NOT NULL,
    source_id       TEXT NOT NULL,
    status          TEXT NOT NULL,          -- ok|partial|error
    tracks_new      INTEGER DEFAULT 0,
    tracks_updated  INTEGER DEFAULT 0,
    errors_json     TEXT                    -- {error_type: count}
);

CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at);
```

`sources_json`/`external_urls_json`/`tags_json` — JSON-strings, потому
что SQLite без JSON1 не индексирует массивы; для фильтра по source
читаем в Python после bbox-выборки.

### REQ-F-10: Endpoint `GET /api/gps-tracks`

```
GET /api/gps-tracks?bbox=W,S,E,N&activity=enduro,moto&source=osm,ttrails&limit=500
```

Параметры:
- `bbox` — обязательный, 4 float.
- `activity` — опционально, comma-separated из ACTIVITY_TYPES.
  Default: все.
- `source` — опционально, comma-separated source IDs. Default: все.
- `limit` — опционально, default 500, max 2000.

Ответ — GeoJSON FeatureCollection:
```json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": 12345,
      "geometry": {"type": "LineString", "coordinates": [[lon, lat], ...]},
      "properties": {
        "name": "Утренний эндуро",
        "activity_type": "enduro",
        "user": "Vasya",
        "created_at": "2024-05-12",
        "length_km": 47.3,
        "sources": ["osm", "enduro_russia"],
        "external_urls": ["https://...", "https://..."]
      }
    },
    ...
  ],
  "total_in_bbox": 743,
  "returned": 500,
  "truncated": true
}
```

Если `truncated: true` — клиент показывает в popup: «Показаны 500
треков из 743. Увеличьте zoom для полной выборки».

### REQ-F-11: Endpoint `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`

Для эффективной отдачи на низких зумах (z ≤ 11). Структура аналогична
существующему `/api/tiles/{z}/{x}/{y}.mvt`:

- Layer: `gps_tracks`.
- Features: LineString с properties `{activity, source, length_km,
  name, ext_url}`.
- На z ≤ 7: упрощение через `simplify_coords(coords, z)` (уже есть в
  `main.py`) + `length_m >= 1000`.
- На z 8–10: упрощение тоньше, без min-length.
- На z ≥ 11: без упрощения.
- LIMIT треков в тайле — как в `/api/tiles` (3000 на z ≤ 7, 8000 на z
  ≤ 9, 15000 на z ≤ 11).
- Серверный LRU-кэш 1024 тайла (как для основных тайлов).

Клиент выбирает источник по зуму:
- z ≤ 11: vector tiles (`gps-tracks-tiles` MapLibre source).
- z ≥ 12: GeoJSON через `/api/gps-tracks?bbox=…` (более свежие данные
  + интерактивность popup).

### REQ-F-12: Endpoint `GET /api/gps-tracks/health`

Ответ:
```json
{
  "db_path": "/data/gps_tracks.sqlite",
  "db_size_mb": 124.5,
  "tracks_total": 8421,
  "tracks_by_source": {"osm": 5234, "enduro_russia": 2102, "ttrails": 1085},
  "tracks_by_activity": {"enduro": 2104, "moto": 850, "offroad": 305, "bicycle": 3201, "hike": 1810, "other": 151},
  "last_pipeline_run": {
    "started_at": "2026-05-30T03:00:00Z",
    "finished_at": "2026-05-30T05:14:00Z",
    "regions": ["tsfo_plus_chuvashia"],
    "sources_ok": ["osm", "enduro_russia"],
    "sources_error": [{"source": "ttrails", "error": "HTTP 503"}]
  },
  "tile_cache_size": 412
}
```

### REQ-F-13: Чекбокс «Публичные треки» в `#terrain-popup`

В существующий попап (после блока «Тропы», перед «POI»):

```html
<hr ...>
<label class="terrain-checkbox">
  <input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
  <span>Публичные треки</span>
</label>
<button class="terrain-link-btn" id="public-tracks-filters-btn"
        onclick="togglePublicTracksFilters()" style="display:none">
  Фильтры…
</button>
```

При включённом чекбоксе:
- Добавить vector source `gps-tracks-tiles` и raster — нет, MVT —
  `vector` source.
- Добавить line layer `gps-tracks-layer` поверх `trails-*` слоёв, ниже
  `route-line` (если есть).
- На z ≥ 12 — добавить GeoJSON source `gps-tracks-geo` (загружается по
  `moveend`-событию карты) и line layer `gps-tracks-layer-geo` с теми
  же стилями.
- Кнопка «Фильтры…» становится видна.

При выключении:
- visibility = 'none' для обоих слоёв.
- Сохранить настройки фильтров в `window._gpsFilters`.

### REQ-F-14: Sheet `#sheet-gps-filters`

Bottom sheet (аналогично `#sheet-recon`):

```
┌─────────────────────────────────────┐
│ ═══                                 │
│ 🌍 Фильтры публичных треков [✕]    │
├─────────────────────────────────────┤
│ ТИП АКТИВНОСТИ                      │
│ ☑ Эндуро    ☑ Мото    ☑ Off-road   │
│ ☑ Велосипед ☑ Пешком  ☑ Лыжи       │
│ ☑ Другое                            │
│                                     │
│ ИСТОЧНИК                            │
│ ☑ OSM         ☑ EnduroRussia.ru     │
│ ☑ ttrails.ru  ☐ Offmaps.ru          │
│ ☐ Nakarte.me                        │
│                                     │
│ ЦВЕТ ЛИНИЙ                          │
│ ◉ По источнику   ○ По активности    │
│                                     │
│ Всего треков в области: 743         │
│ Видны: 412 (фильтр)                 │
└─────────────────────────────────────┘
```

Поведение:
- Чекбоксы multi-select.
- Изменение фильтра → клиентская фильтрация уже загруженных features
  через `setFilter()` MapLibre (без нового запроса).
- При смене bbox карты — повторный запрос только видимой выборки.
- Состояние фильтров сохраняется в localStorage (REQ-F-15).

### REQ-F-15: Сохранение состояния (localStorage)

| Ключ                            | Значение                       | Default                              |
| ------------------------------- | ------------------------------ | ------------------------------------ |
| `gps-tracks-enabled`            | `"true"` \| `"false"`          | `"false"`                            |
| `gps-tracks-activities`         | JSON-array из ACTIVITY_TYPES   | все                                  |
| `gps-tracks-sources`            | JSON-array source IDs          | все enabled                          |
| `gps-tracks-color-mode`         | `"source"` \| `"activity"`     | `"source"`                           |

Чтение при старте через `restorePublicTracksState()` (по аналогии с
`restoreTerrainState()` и `restoreBaseLayerState()` из ET-007).

### REQ-F-16: Палитра цветов

**По источнику** (default):
- `osm` — `#3cb44b` (зелёный)
- `enduro_russia` — `#e6194b` (красный)
- `ttrails` — `#4363d8` (синий)
- `offmaps` — `#f58231` (оранжевый)
- `nakarte` — `#911eb4` (фиолетовый)
- остальные — циклически из палитры из 8 цветов (как в ET-006).

**По активности**:
- `enduro` — `#e6194b`
- `moto` — `#f58231`
- `offroad` — `#ffe119`
- `bicycle` — `#3cb44b`
- `hike` — `#4363d8`
- `ski` — `#42d4f4`
- `other` — `#808080`

Цвет применяется через MapLibre `match` expression в layer paint:

```js
'line-color': [
  'match', ['get', 'source'],
  'osm', '#3cb44b',
  'enduro_russia', '#e6194b',
  ...
  '#808080'
]
```

### REQ-F-17: Стили слоя `gps-tracks-layer`

```js
{
  id: 'gps-tracks-layer',
  type: 'line',
  source: 'gps-tracks-tiles',
  'source-layer': 'gps_tracks',
  paint: {
    'line-color': /* match по REQ-F-16 */,
    'line-width': ['interpolate', ['linear'], ['zoom'],
       8, 1.0, 12, 2.0, 16, 3.0],
    'line-opacity': 0.75
  },
  layout: { 'line-cap': 'round', 'line-join': 'round',
            visibility: 'none' }
}
```

Halo-слой для спутника (по аналогии с ET-007):
```js
{
  id: 'gps-tracks-halo-satellite',
  type: 'line',
  source: 'gps-tracks-tiles',
  'source-layer': 'gps_tracks',
  paint: {
    'line-color': '#ffffff',
    'line-width': ['interpolate', ['linear'], ['zoom'],
       8, 2.5, 12, 4.0, 16, 6.0],
    'line-opacity': 0.6
  },
  layout: { visibility: 'none' }
}
```

Halo включается только если `(public-tracks ON) AND (base ===
'satellite')` — по тому же паттерну, что halo троп в ET-007 §5.7.

### REQ-F-18: Popup при клике на трек

При клике на feature слоя `gps-tracks-layer`:

```
┌─────────────────────────────────┐
│ Утренний эндуро              ✕  │
│ ─────────────────────────       │
│ 🏍 Эндуро                       │
│ 📏 47.3 км · 1240 точек          │
│ 📅 12 мая 2024                  │
│ 👤 Vasya                        │
│                                 │
│ Источники:                      │
│ • OSM ↗  • EnduroRussia.ru ↗   │
└─────────────────────────────────┘
```

Каждая ссылка-источник открывает оригинал в новой вкладке.

### REQ-F-19: Интеграция с `rebuildMapOverlays()`

В `app.js`, в существующей функции `rebuildMapOverlays()` добавить
вызов:

```js
function rebuildMapOverlays() {
  restoreBaseLayerState();         // ET-007 (уже есть)
  restoreTerrainState();           // существующее
  restoreTrailsState();            // существующее
  restorePublicTracksState();      // НОВОЕ ET-008
  restorePoiState();               // существующее
  // ... GPX (ET-006) и route — без изменений
}
```

`restorePublicTracksState()`:
1. Читать `gps-tracks-enabled` из localStorage.
2. Если включено — пересоздать vector source / layer / halo.
3. Применить фильтры из localStorage.
4. Синхронизировать UI (чекбокс, состояние «Фильтры…»).

### REQ-F-20: Поведение на low-zoom (защита от шторма запросов)

- На z < 8 — слой публичных треков скрывается автоматически (даже если
  включён). Подсказка в попапе слоёв (рядом с чекбоксом, по аналогии с
  «Тени рельефа Зум 10+»): «Зум 8+».
- На z 8–11 — данные из MVT-тайлов (нет GeoJSON запросов).
- На z ≥ 12 — переключение на GeoJSON через `moveend` debounced 500ms.

## 2. Нефункциональные требования

### REQ-NF-01: Безопасность

- Pipeline идёт **исходящими** запросами с mva154 — нет открытых
  входных точек скрейпинга.
- Endpoints `/api/gps-tracks/*` — только GET, идемпотентны, без
  пользовательского ввода в SQL (параметры — числа и ENUM).
- Парсинг XML на сервере (для скачанных GPX) — через `defusedxml`
  (защита от XXE / billion laughs).
- Bbox-параметр валидируется (диапазон координат, площадь).
- CORS наследуется (`allow_origins=["*"]`).

### REQ-NF-02: Производительность

- `GET /api/gps-tracks?bbox=…` p95 ≤ 300 мс на ≤ 500 треков (zoom 10+).
- `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` p95 ≤ 200 мс на cold-cache,
  ≤ 20 мс на cache hit.
- Pipeline на ЦФО + Чувашию: ≤ 6 часов на полный прогон (cron-окно).
- Запрос `compute_dedup_key()`: O(1), без БД.
- INSERT с конфликтом по `dedup_key`: ON CONFLICT UPDATE — один SQL.

### REQ-NF-03: Хранение и ротация

- `data/gps_tracks.sqlite`: размер ≤ 2 ГБ для ЦФО + Чувашии.
- Ротация: треки с `updated_at < NOW() - 5 years` (default) удаляются
  при запуске pipeline с `--gc` (cron 1 раз в месяц).
- Бэкап: ежедневный snapshot (см. `07-infra-requirements.md`).

### REQ-NF-04: Кэширование тайлов

- LRU-кэш в памяти процесса FastAPI: 1024 записи (как для основных
  тайлов).
- При запуске pipeline — кэш сбрасывается через
  `POST /api/cache/clear`? Нет — отдельный endpoint
  `POST /api/gps-tracks/cache/clear`. Pipeline вызывает его в конце
  прогона.

### REQ-NF-05: Совместимость

- Браузеры: Chrome 90+, Firefox 90+, Safari 15+ (как ET-006/ET-007).
- Backend: Python 3.12, FastAPI, httpx, lxml/defusedxml, shapely (есть).
- MapLibre GL JS 4.7.0 (есть).

### REQ-NF-06: UX

- Включение слоя — мгновенное (тайлы загружаются параллельно).
- Загрузка тайлов на медленной сети — без блокировки UI (асинхронно).
- Фильтры — клиентские, моментальные (≤ 200 мс).
- Все ошибки — toast-уведомления, не alert/confirm.
- Атрибуция источников — в правом нижнем углу карты (MapLibre
  встроенная панель), привязывается к source attribution.

### REQ-NF-07: Наблюдаемость

- `/api/gps-tracks/health` отдаёт полную картину состояния (REQ-F-12).
- Pipeline пишет structured logs (JSON-lines) с полями run_id, region,
  source, status, tracks_new, error.
- Алерт (через существующий механизм проекта) при двух неудачных
  прогонах подряд для одного source.

## 3. UI-спецификация

### 3.1 Изменения в `#terrain-popup`

Добавляем между секцией «Тропы» и «POI» (после соответствующего `<hr>`):

```html
<!-- ET-008 -->
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<label class="terrain-checkbox">
  <input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
  <span>Публичные треки</span>
</label>
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
<button class="terrain-link-btn" id="public-tracks-filters-btn"
        onclick="togglePublicTracksFiltersSheet()" style="display:none">
  Фильтры…
</button>
```

CSS:
```css
.terrain-link-btn {
  display: block;
  margin: 4px 0 0 24px;
  background: none;
  border: none;
  color: var(--accent, #ff8c1a);
  font-size: 12px;
  cursor: pointer;
  padding: 2px 0;
  text-decoration: underline;
}
```

### 3.2 Bottom sheet `#sheet-gps-filters`

```html
<div class="bottom-sheet" id="sheet-gps-filters">
  <div class="sheet-handle"></div>
  <div class="sheet-header">
    <svg>...</svg>
    <h2>Фильтры публичных треков</h2>
    <button class="sheet-close" onclick="toggleGpsFiltersSheet()">✕</button>
  </div>
  <div class="sheet-body">
    <div class="section-label">ТИП АКТИВНОСТИ</div>
    <div id="gps-activity-grid" class="gps-filter-grid">
      <!-- генерируется JS -->
    </div>
    <div class="section-label">ИСТОЧНИК</div>
    <div id="gps-source-grid" class="gps-filter-grid">
      <!-- генерируется JS из /api/gps-tracks/health -->
    </div>
    <div class="section-label">ЦВЕТ ЛИНИЙ</div>
    <div class="seg-control">
      <button class="seg-btn active" id="gps-color-by-source" data-mode="source">По источнику</button>
      <button class="seg-btn" id="gps-color-by-activity" data-mode="activity">По активности</button>
    </div>
    <div class="gps-stats-row" id="gps-stats-row">
      <span>Всего в области: <b id="gps-stat-total">—</b></span>
      <span>Видны (фильтр): <b id="gps-stat-shown">—</b></span>
    </div>
  </div>
</div>
```

Открывается через `togglePublicTracksFiltersSheet()` из попапа.

### 3.3 Popup трека

Реализуется как MapLibre `Popup` (без bottom sheet — компактнее для
этого UX):

```js
new maplibregl.Popup({closeOnClick: true})
  .setLngLat(e.lngLat)
  .setHTML(renderTrackPopupHtml(feature.properties))
  .addTo(map);
```

### 3.4 Адаптив для мобильных

- Sheet `#sheet-gps-filters` — full-width на мобильных (как остальные).
- Чипы фильтров — wrap в 2 колонки на мобильных через CSS Grid.
- Popup трека — width: 280px, чтобы не перекрывал тулбар.

## 4. Данные

### 4.1 Модель в SQL

См. REQ-F-09.

### 4.2 GeoJSON API контракт

См. REQ-F-10.

### 4.3 MVT layer schema

Layer name: `gps_tracks`.

Properties в каждом feature:

| Поле           | Тип    | Описание                                    |
| -------------- | ------ | ------------------------------------------- |
| `id`           | int    | track.id из БД                              |
| `activity`     | string | ACTIVITY_TYPE                               |
| `source`       | string | первый source_id из sources (для цвета)     |
| `sources`      | string | comma-separated все sources (для popup)    |
| `length_km`    | float  | length_m / 1000                             |
| `name`         | string | name (может быть пустым)                    |
| `ext_url`      | string | первый URL из external_urls (для ↗)         |

### 4.4 Клиентская модель `window.gpsTracksLayer`

```js
window.gpsTracksLayer = {
  enabled: false,
  filters: {
    activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
    sources: ['osm', 'enduro_russia', 'ttrails', /* … */],
    colorMode: 'source'  // 'source' | 'activity'
  },
  sourceId: 'gps-tracks-tiles',
  sourceGeoId: 'gps-tracks-geo',
  layerId: 'gps-tracks-layer',
  layerHaloId: 'gps-tracks-halo-satellite',
  geojsonAbortController: null,
  geojsonReqDebounceTimer: null,
  stats: { total: 0, shown: 0 }
};
```

## 5. Файловая структура изменений

```
src/api/
├── main.py                                    # + регистрация роутов
├── requirements.txt                           # + defusedxml, lxml
├── gps_tracks/                                # НОВЫЙ пакет
│   ├── __init__.py
│   ├── models.py                              # Pydantic, ACTIVITY_TYPES
│   ├── db.py                                  # SQLite + Spatialite обвязка
│   ├── dedup.py                               # compute_dedup_key
│   ├── mvt.py                                 # MVT-генерация
│   ├── endpoint.py                            # FastAPI routes
│   ├── config.py                              # загрузка YAML
│   └── sources/
│       ├── __init__.py
│       ├── base.py                            # абстрактный SourceParser
│       ├── osm.py
│       ├── enduro_russia.py
│       └── ttrails.py

src/web/
├── index.html                                 # + чекбокс, sheet-gps-filters
├── app.css                                    # + .terrain-link-btn, .gps-filter-grid, .gps-stats-row
├── app.js                                     # + restorePublicTracksState, popup-handler,
                                               #   расширение rebuildMapOverlays
├── gps_tracks.js                              # НОВЫЙ: слой, фильтры, popup
├── style.json                                 # + halo-layer для спутника
├── style-dark.json                            # + halo-layer для спутника

scripts/
├── gps_collect.py                             # НОВЫЙ pipeline-entry

config/
├── gps_sources.yaml                           # НОВЫЙ
├── gps_regions.yaml                           # НОВЫЙ

migrations/
├── gps_tracks_001_init.sql                    # CREATE TABLE

tests/
├── api/test_gps_tracks_endpoint.py            # bbox/filter/limit
├── api/test_gps_tracks_mvt.py                 # MVT-генерация
├── api/test_gps_tracks_dedup.py               # compute_dedup_key
├── api/test_gps_tracks_sources_osm.py         # парсер OSM (с фикстурами)
├── web/gps_tracks.test.js                     # фильтры, цветовая палитра

docs/work-items/ET-008/
├── 06-adr/
│   ├── ADR-001-storage-schema.md
│   ├── ADR-002-dedup-algorithm.md
│   ├── ADR-003-osm-licensing.md
│   ├── ADR-004-enduro-russia-licensing.md     # обязательно перед коммитом source
│   └── ADR-005-ttrails-licensing.md           # обязательно перед коммитом source
├── 07-infra-requirements.md
├── 08-data-requirements.md
├── 10-tech-risks.md
```

## 6. Алгоритмы

### 6.1 `compute_dedup_key(geom, metadata)`

См. REQ-F-08.

### 6.2 Bbox-разбиение региона на OSM-cells

```python
def split_bbox_for_osm(region_bbox, cell_size=0.25):
    w, s, e, n = region_bbox
    cells = []
    lat = s
    while lat < n:
        lon = w
        while lon < e:
            cells.append((lon, lat, min(lon+cell_size, e), min(lat+cell_size, n)))
            lon += cell_size
        lat += cell_size
    return cells
```

Для ЦФО + Чувашии (≈ 670K км²) → ≈ 700 cells × ≤ 5 pages × 1 sec rate
limit ≈ 1 час.

### 6.3 Backoff для скрейпинга

```python
async def fetch_with_backoff(url, max_retries=3, base_delay=2.0):
    for attempt in range(max_retries):
        try:
            resp = await client.get(url, timeout=30)
            if resp.status_code == 429:
                delay = base_delay * (2 ** attempt)
                await asyncio.sleep(delay)
                continue
            resp.raise_for_status()
            return resp
        except (httpx.TimeoutException, httpx.NetworkError):
            await asyncio.sleep(base_delay * (2 ** attempt))
    raise RuntimeError(f"Max retries exceeded: {url}")
```

### 6.4 Клиентская сторона: debounced GeoJSON-загрузка

```js
function onMapMoveEnd() {
  if (!window.gpsTracksLayer.enabled) return;
  if (map.getZoom() < 12) return;   // на низком zoom — только тайлы

  clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
  window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
    fetchAndUpdateGeoJson(map.getBounds());
  }, 500);
}

async function fetchAndUpdateGeoJson(bounds) {
  // Отменить предыдущий запрос
  if (window.gpsTracksLayer.geojsonAbortController) {
    window.gpsTracksLayer.geojsonAbortController.abort();
  }
  const ctrl = new AbortController();
  window.gpsTracksLayer.geojsonAbortController = ctrl;

  const { activities, sources } = window.gpsTracksLayer.filters;
  const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
  const url = `/api/gps-tracks?bbox=${bbox}` +
              `&activity=${activities.join(',')}` +
              `&source=${sources.join(',')}` +
              `&limit=500`;
  try {
    const resp = await fetch(url, { signal: ctrl.signal });
    const json = await resp.json();
    map.getSource('gps-tracks-geo').setData(json);
    window.gpsTracksLayer.stats = { total: json.total_in_bbox, shown: json.returned };
    syncGpsFiltersStatsUI();
  } catch (e) {
    if (e.name === 'AbortError') return;
    showToast('Не удалось загрузить треки');
  }
}
```

### 6.5 Клиентская фильтрация по `setFilter()`

При изменении чекбокса активности/источника — без нового запроса:

```js
function applyGpsFilter() {
  const { activities, sources } = window.gpsTracksLayer.filters;
  const filter = [
    'all',
    ['in', ['get', 'activity'], ['literal', activities]],
    ['in', ['get', 'source'], ['literal', sources]]
  ];
  map.setFilter('gps-tracks-layer', filter);
  if (map.getLayer('gps-tracks-layer-geo')) {
    map.setFilter('gps-tracks-layer-geo', filter);
  }
  if (map.getLayer('gps-tracks-halo-satellite')) {
    map.setFilter('gps-tracks-halo-satellite', filter);
  }
}
```

## 7. Взаимодействие с существующими модулями

### 7.1 ET-006 (`gpx.js`)

- Не пересекается: `window.gpxTracks` (личные треки) и
  `window.gpsTracksLayer` (публичный слой) — разные модели.
- На карте оба видны параллельно; z-order:
  `gps-tracks-layer` < `gpx-layer-*` (личные треки выше).

### 7.2 ET-007 (спутник)

- Halo `gps-tracks-halo-satellite` включается/выключается по тому же
  паттерну, что halo троп (`trails-track-halo-satellite`).
- В `applyBaseLayer()` (ET-007) добавить шаг:
  ```js
  // ET-008: halo публичных треков
  const haloOn = (currentBase === 'satellite' && layerState.publicTracks);
  setLayoutProperty('gps-tracks-halo-satellite', 'visibility',
                    haloOn ? 'visible' : 'none');
  ```

### 7.3 Поиск, маршрут, разведка, scenic, ruler

- Слой публичных треков не блокирует и не модифицирует существующие
  режимы.
- Клик по карте: маршрут/разведка имеют приоритет; popup трека только
  если ни один режим не активен и `map.queryRenderedFeatures` возвращает
  `gps-tracks-layer`.

## 8. Открытые вопросы для ADR

- **ADR-001**: единая БД vs отдельная (`data/gps_tracks.sqlite`).
  Рекомендация ТЗ — отдельная (см. BRD §6 риск «размер БД»).
- **ADR-002**: точный алгоритм дедупликации — bbox-bucket vs
  Frechet-distance vs hash-of-resampled-points.
- **ADR-003, 004, 005**: licensing review для каждого внешнего источника
  (OSM однозначен; для остальных — обязательно перед merge кода
  per-source модуля).
- **Открытое**: показывать ли popup трека или открывать `#sheet-route`-
  подобный bottom sheet (ради единого UX). По умолчанию — MapLibre
  Popup как компромисс компактности.
- **Открытое**: цветовая палитра «По активности» — окончательная
  валидация на тёмной/светлой теме и на спутнике.
- **Открытое**: на низких зумах прятать слой или показывать сильно
  упрощённый. Рекомендация ТЗ — прятать с подсказкой «Зум 8+».
