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

951 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 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`
Ответ:
```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 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>`):
```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+».