38 KiB
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 |
|
|
ТЗ — 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 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-tilesMapLibre 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 —vectorsource. - Добавить line layer
gps-tracks-layerповерхtrails-*слоёв, нижеroute-line(если есть). - На z ≥ 12 — добавить GeoJSON source
gps-tracks-geo(загружается поmoveend-событию карты) и line layergps-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—#e6194bmoto—#f58231offroad—#ffe119bicycle—#3cb44bhike—#4363d8ski—#42d4f4other—#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():
- Читать
gps-tracks-enabledиз localStorage. - Если включено — пересоздать vector source / layer / halo.
- Применить фильтры из localStorage.
- Синхронизировать UI (чекбокс, состояние «Фильтры…»).
REQ-F-20: Поведение на low-zoom (защита от шторма запросов)
- На z < 8 — слой публичных треков скрывается автоматически (даже если включён). Подсказка в попапе слоёв (рядом с чекбоксом, по аналогии с «Тени рельефа Зум 10+»): «Зум 8+».
- На z 8–11 — данные из MVT-тайлов (нет GeoJSON запросов).
- На z ≥ 12 — переключение на GeoJSON через
moveenddebounced 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}.mvtp95 ≤ 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? Нет — отдельный endpointPOST /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+».