Files
enduro-trails/docs/work-items/ET-008/02-trz.md
claude-bot 0840818c9a
All checks were successful
CI / lint (push) Successful in 5s
CI / test (push) Successful in 5s
CI / build (push) Successful in 1s
analyst(ET-008): BRD, TRZ, AC, TestPlan, UI tests v2
2026-06-01 11:44:40 +00:00

38 KiB
Raw Permalink Blame History

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

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

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

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

Файл config/gps_sources.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:

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

Метаданные на выходе:

{
  "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:

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

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

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:

{
  "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 810: упрощение тоньше, без 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

Ответ:

{
  "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»):

<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:

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

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

{
  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):

{
  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() добавить вызов:

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 811 — данные из 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>):

<!-- 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:

.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

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

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

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

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 для скрейпинга

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-загрузка

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

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

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) добавить шаг:
    // 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+».