--- 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 ] [--source ] [--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/` для метаданных (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 /regions/ 2. Для каждого региона, пересекающегося с bbox: 2.1. Получить список треков: GET /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
``` При включённом чекбоксе: - Добавить 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» (после соответствующего `
`): ```html
``` 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
...

Фильтры публичных треков

Всего в области: Видны (фильтр):
``` Открывается через `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+».