951 lines
38 KiB
Markdown
951 lines
38 KiB
Markdown
---
|
||
type: trz
|
||
work_item_id: ET-008
|
||
title: "ТЗ: GPS-треки с публичных платформ на карте"
|
||
version: 2
|
||
status: draft
|
||
created_at: 2026-06-01
|
||
updated_at: 2026-06-01
|
||
changelog:
|
||
- "v2 (2026-06-01): полная переработка под BRD v2 — серверная агрегация по региону, дедупликация, MVT-тайлы публичных треков, фильтры по активности/источнику. Предыдущая v1 описывала URL-импорт + OSM live-поиск (не соответствовало бизнес-цели)."
|
||
authors:
|
||
- "agent:analyst"
|
||
---
|
||
|
||
# ТЗ — ET-008: GPS-треки с публичных платформ на карте
|
||
|
||
## 1. Функциональные требования
|
||
|
||
### REQ-F-01: Конфигурация источников
|
||
|
||
Файл `config/gps_sources.yaml` в репозитории:
|
||
|
||
```yaml
|
||
sources:
|
||
- id: osm
|
||
name: "OSM Public GPS Traces"
|
||
enabled: true
|
||
license_adr: "06-adr/osm-licensing.md"
|
||
base_url: "https://api.openstreetmap.org/api/0.6"
|
||
rate_limit_sec: 1
|
||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||
attribution: "© OpenStreetMap contributors (ODbL)"
|
||
parser_module: "src.api.gps_tracks.sources.osm"
|
||
|
||
- id: enduro_russia
|
||
name: "EnduroRussia.ru"
|
||
enabled: true
|
||
license_adr: "06-adr/enduro-russia-licensing.md"
|
||
base_url: "https://enduro-russia.ru"
|
||
rate_limit_sec: 5
|
||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||
attribution: "EnduroRussia.ru"
|
||
parser_module: "src.api.gps_tracks.sources.enduro_russia"
|
||
|
||
# ...
|
||
```
|
||
|
||
Поле `enabled: false` исключает источник из pipeline (но БД сохраняет
|
||
ранее собранные треки).
|
||
|
||
### REQ-F-02: Конфигурация регионов
|
||
|
||
Файл `config/gps_regions.yaml`:
|
||
|
||
```yaml
|
||
regions:
|
||
- id: tsfo_plus_chuvashia
|
||
name: "ЦФО + Чувашия"
|
||
bbox: [29.0, 49.5, 47.5, 60.0] # [west, south, east, north]
|
||
enabled: true
|
||
sources: [osm, enduro_russia, ttrails] # ID из gps_sources.yaml
|
||
|
||
- id: north_caucasus
|
||
name: "Северный Кавказ"
|
||
bbox: [37.0, 41.5, 49.0, 47.0]
|
||
enabled: false
|
||
sources: [osm, enduro_russia]
|
||
```
|
||
|
||
Добавление региона = новая запись (≤ 30 строк) — REQ-F-04 BRD.
|
||
|
||
### REQ-F-03: Pipeline сбора `scripts/gps_collect.py`
|
||
|
||
CLI:
|
||
```
|
||
python scripts/gps_collect.py [--region <id>] [--source <id>] [--dry-run]
|
||
```
|
||
|
||
Без `--region` — обрабатываются все `enabled: true` регионы. Без
|
||
`--source` — все `enabled: true` источники, перечисленные в регионе.
|
||
|
||
Логика прогона:
|
||
```
|
||
1. Загрузить config/gps_sources.yaml и config/gps_regions.yaml.
|
||
2. Для каждого (region, source) в декартовом произведении:
|
||
2.1. Вызвать parser_module.collect(region.bbox, db) →
|
||
yield {external_id, geom, metadata}
|
||
2.2. Для каждого трека:
|
||
- dedup_key = compute_dedup_key(geom, metadata)
|
||
- Если запись с тем же dedup_key уже есть — обновить
|
||
metadata (sources += [source_id]).
|
||
- Иначе — INSERT.
|
||
2.3. Логировать статус: tracks_new, tracks_updated, errors.
|
||
3. Записать в БД таблицу `pipeline_runs`:
|
||
(run_id, started_at, finished_at, region, source, status,
|
||
tracks_new, tracks_updated, errors_json)
|
||
4. Exit code: 0 если все source ≥ 1 трек или dry-run; 1 иначе.
|
||
```
|
||
|
||
Все per-source модули обязаны:
|
||
- Уважать `rate_limit_sec`: `await asyncio.sleep(rate_limit_sec)` между
|
||
HTTP-запросами.
|
||
- Использовать `User-Agent` из конфига.
|
||
- Делать `backoff` (3 повтора, exponential) на 5xx/429.
|
||
- На необработанную ошибку — `raise` → pipeline ловит, логирует, идёт
|
||
дальше к следующему источнику.
|
||
|
||
### REQ-F-04: Парсер OSM Public GPS Traces
|
||
|
||
Модуль `src/api/gps_tracks/sources/osm.py`.
|
||
|
||
OSM API: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`.
|
||
|
||
Лимиты:
|
||
- bbox площадь ≤ 0.25 deg² (OSM API requirement).
|
||
- Регион (ЦФО ≈ 18×10 = 180 deg²) разбивается на тайл-сетку 0.25
|
||
deg-cells (≈ 720 cells на ЦФО+Чувашию).
|
||
- Пагинация: page 0…N, до `has_more=false`.
|
||
|
||
Извлекаем:
|
||
- Группировка точек по `gpx_id` атрибуту (там, где есть) → отдельные
|
||
треки. Анонимные точки (без `gpx_id`) — пропускаем (нет публичного
|
||
ID, не дедуплицируется).
|
||
- Для треков с `gpx_id` — дополнительный запрос `GET /api/0.6/gpx/<id>`
|
||
для метаданных (name, description, tags, user, timestamp).
|
||
Этот запрос делаем **отложенно** в batch: накопить 100 id → запросить.
|
||
Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).
|
||
|
||
Метаданные на выходе:
|
||
```python
|
||
{
|
||
"external_id": f"osm-{gpx_id}",
|
||
"external_url": f"https://www.openstreetmap.org/user/{user}/traces/{gpx_id}",
|
||
"source_id": "osm",
|
||
"name": str | None,
|
||
"description": str | None,
|
||
"user": str | None,
|
||
"activity_type": str | None, # см. REQ-F-07
|
||
"tags": List[str],
|
||
"created_at": ISO-date | None,
|
||
"geom": LineString,
|
||
"points_count": int,
|
||
"length_m": float,
|
||
}
|
||
```
|
||
|
||
### REQ-F-05: Парсер EnduroRussia.ru
|
||
|
||
Модуль `src/api/gps_tracks/sources/enduro_russia.py`.
|
||
|
||
Стратегия (зависит от структуры сайта на момент реализации;
|
||
фиксируется в ADR `06-adr/enduro-russia-licensing.md` после ревью):
|
||
|
||
```
|
||
1. Получить список регионов: GET <base_url>/regions/
|
||
2. Для каждого региона, пересекающегося с bbox:
|
||
2.1. Получить список треков: GET <region_url>/treki/?page=N
|
||
2.2. Для каждого трека:
|
||
- Открыть страницу трека
|
||
- Найти прямую ссылку на GPX
|
||
- Скачать GPX
|
||
- Парсить через тот же парсер, что в gpx.js (или DOMParser
|
||
серверный — через defusedxml)
|
||
```
|
||
|
||
Метаданные на выходе — та же структура, что REQ-F-04 (общий контракт).
|
||
|
||
`activity_type` — из категории на сайте источника.
|
||
|
||
### REQ-F-06: Парсер Тропинки.ру / ttrails.ru
|
||
|
||
Модуль `src/api/gps_tracks/sources/ttrails.py`. По аналогии с REQ-F-05,
|
||
точный алгоритм — в ADR после ревью структуры сайта.
|
||
|
||
### REQ-F-07: Унификация типа активности
|
||
|
||
Все источники маппят свою категоризацию в фиксированный enum:
|
||
|
||
```python
|
||
ACTIVITY_TYPES = [
|
||
"enduro", # эндуро-мотоцикл (приоритет проекта)
|
||
"moto", # мото (не эндуро): шоссе, dual-sport
|
||
"offroad", # off-road авто, квадроциклы
|
||
"bicycle", # любые велосипеды (mtb, road)
|
||
"hike", # пешком, бег
|
||
"ski", # лыжи
|
||
"other", # неопределено
|
||
]
|
||
```
|
||
|
||
Маппинг per-source — в `parser_module.MAPPING` константой. Категории
|
||
источника, не маппящиеся ни во что — в `other`.
|
||
|
||
### REQ-F-08: Дедупликация
|
||
|
||
Алгоритм (детали в ADR `06-adr/dedup-algorithm.md`):
|
||
|
||
```python
|
||
def compute_dedup_key(geom: LineString, metadata: dict) -> str:
|
||
bbox = geom.bounds # (w, s, e, n)
|
||
bbox_rounded = tuple(round(c, 2) for c in bbox) # ≈ 1 км
|
||
length_bucket = round(metadata["length_m"] / 1000) * 1000 # 1 км
|
||
date_bucket = metadata.get("created_at", "")[:10] # YYYY-MM-DD
|
||
return f"{bbox_rounded}|{length_bucket}|{date_bucket}"
|
||
```
|
||
|
||
При коллизии — мержим записи:
|
||
- `sources` (массив) ← union.
|
||
- `external_urls` (массив) ← union.
|
||
- `metadata` ← данные источника с большим приоритетом (порядок в
|
||
`gps_sources.yaml`).
|
||
|
||
Если у одного из треков `created_at` отсутствует — date_bucket пустой
|
||
для обоих → считаем коллизией. Это даст ложные коллизии для треков из
|
||
разных дат, но без даты мы и не отличим их.
|
||
|
||
### REQ-F-09: Схема БД
|
||
|
||
Отдельная БД: `data/gps_tracks.sqlite` (SQLite + Spatialite).
|
||
|
||
```sql
|
||
CREATE TABLE tracks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
dedup_key TEXT NOT NULL UNIQUE,
|
||
name TEXT,
|
||
description TEXT,
|
||
activity_type TEXT, -- ACTIVITY_TYPES
|
||
user TEXT, -- автор (опционально)
|
||
created_at TEXT, -- ISO date
|
||
length_m REAL NOT NULL,
|
||
points_count INTEGER NOT NULL,
|
||
min_lon REAL NOT NULL,
|
||
min_lat REAL NOT NULL,
|
||
max_lon REAL NOT NULL,
|
||
max_lat REAL NOT NULL,
|
||
geom BLOB NOT NULL, -- WKB LineString
|
||
sources_json TEXT NOT NULL, -- ["osm", "enduro_russia"]
|
||
external_urls_json TEXT NOT NULL, -- ["https://...", "https://..."]
|
||
tags_json TEXT, -- ["forest", "river-crossing"]
|
||
inserted_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat);
|
||
CREATE INDEX idx_tracks_activity ON tracks(activity_type);
|
||
CREATE INDEX idx_tracks_created ON tracks(created_at);
|
||
|
||
CREATE TABLE pipeline_runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
started_at TEXT NOT NULL,
|
||
finished_at TEXT,
|
||
region_id TEXT NOT NULL,
|
||
source_id TEXT NOT NULL,
|
||
status TEXT NOT NULL, -- ok|partial|error
|
||
tracks_new INTEGER DEFAULT 0,
|
||
tracks_updated INTEGER DEFAULT 0,
|
||
errors_json TEXT -- {error_type: count}
|
||
);
|
||
|
||
CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at);
|
||
```
|
||
|
||
`sources_json`/`external_urls_json`/`tags_json` — JSON-strings, потому
|
||
что SQLite без JSON1 не индексирует массивы; для фильтра по source
|
||
читаем в Python после bbox-выборки.
|
||
|
||
### REQ-F-10: Endpoint `GET /api/gps-tracks`
|
||
|
||
```
|
||
GET /api/gps-tracks?bbox=W,S,E,N&activity=enduro,moto&source=osm,ttrails&limit=500
|
||
```
|
||
|
||
Параметры:
|
||
- `bbox` — обязательный, 4 float.
|
||
- `activity` — опционально, comma-separated из ACTIVITY_TYPES.
|
||
Default: все.
|
||
- `source` — опционально, comma-separated source IDs. Default: все.
|
||
- `limit` — опционально, default 500, max 2000.
|
||
|
||
Ответ — GeoJSON FeatureCollection:
|
||
```json
|
||
{
|
||
"type": "FeatureCollection",
|
||
"features": [
|
||
{
|
||
"type": "Feature",
|
||
"id": 12345,
|
||
"geometry": {"type": "LineString", "coordinates": [[lon, lat], ...]},
|
||
"properties": {
|
||
"name": "Утренний эндуро",
|
||
"activity_type": "enduro",
|
||
"user": "Vasya",
|
||
"created_at": "2024-05-12",
|
||
"length_km": 47.3,
|
||
"sources": ["osm", "enduro_russia"],
|
||
"external_urls": ["https://...", "https://..."]
|
||
}
|
||
},
|
||
...
|
||
],
|
||
"total_in_bbox": 743,
|
||
"returned": 500,
|
||
"truncated": true
|
||
}
|
||
```
|
||
|
||
Если `truncated: true` — клиент показывает в popup: «Показаны 500
|
||
треков из 743. Увеличьте zoom для полной выборки».
|
||
|
||
### REQ-F-11: Endpoint `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`
|
||
|
||
Для эффективной отдачи на низких зумах (z ≤ 11). Структура аналогична
|
||
существующему `/api/tiles/{z}/{x}/{y}.mvt`:
|
||
|
||
- Layer: `gps_tracks`.
|
||
- Features: LineString с properties `{activity, source, length_km,
|
||
name, ext_url}`.
|
||
- На z ≤ 7: упрощение через `simplify_coords(coords, z)` (уже есть в
|
||
`main.py`) + `length_m >= 1000`.
|
||
- На z 8–10: упрощение тоньше, без min-length.
|
||
- На z ≥ 11: без упрощения.
|
||
- LIMIT треков в тайле — как в `/api/tiles` (3000 на z ≤ 7, 8000 на z
|
||
≤ 9, 15000 на z ≤ 11).
|
||
- Серверный LRU-кэш 1024 тайла (как для основных тайлов).
|
||
|
||
Клиент выбирает источник по зуму:
|
||
- z ≤ 11: vector tiles (`gps-tracks-tiles` MapLibre source).
|
||
- z ≥ 12: GeoJSON через `/api/gps-tracks?bbox=…` (более свежие данные
|
||
+ интерактивность popup).
|
||
|
||
### REQ-F-12: Endpoint `GET /api/gps-tracks/health`
|
||
|
||
Ответ:
|
||
```json
|
||
{
|
||
"db_path": "/data/gps_tracks.sqlite",
|
||
"db_size_mb": 124.5,
|
||
"tracks_total": 8421,
|
||
"tracks_by_source": {"osm": 5234, "enduro_russia": 2102, "ttrails": 1085},
|
||
"tracks_by_activity": {"enduro": 2104, "moto": 850, "offroad": 305, "bicycle": 3201, "hike": 1810, "other": 151},
|
||
"last_pipeline_run": {
|
||
"started_at": "2026-05-30T03:00:00Z",
|
||
"finished_at": "2026-05-30T05:14:00Z",
|
||
"regions": ["tsfo_plus_chuvashia"],
|
||
"sources_ok": ["osm", "enduro_russia"],
|
||
"sources_error": [{"source": "ttrails", "error": "HTTP 503"}]
|
||
},
|
||
"tile_cache_size": 412
|
||
}
|
||
```
|
||
|
||
### REQ-F-13: Чекбокс «Публичные треки» в `#terrain-popup`
|
||
|
||
В существующий попап (после блока «Тропы», перед «POI»):
|
||
|
||
```html
|
||
<hr ...>
|
||
<label class="terrain-checkbox">
|
||
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
||
<span>Публичные треки</span>
|
||
</label>
|
||
<button class="terrain-link-btn" id="public-tracks-filters-btn"
|
||
onclick="togglePublicTracksFilters()" style="display:none">
|
||
Фильтры…
|
||
</button>
|
||
```
|
||
|
||
При включённом чекбоксе:
|
||
- Добавить vector source `gps-tracks-tiles` и raster — нет, MVT —
|
||
`vector` source.
|
||
- Добавить line layer `gps-tracks-layer` поверх `trails-*` слоёв, ниже
|
||
`route-line` (если есть).
|
||
- На z ≥ 12 — добавить GeoJSON source `gps-tracks-geo` (загружается по
|
||
`moveend`-событию карты) и line layer `gps-tracks-layer-geo` с теми
|
||
же стилями.
|
||
- Кнопка «Фильтры…» становится видна.
|
||
|
||
При выключении:
|
||
- visibility = 'none' для обоих слоёв.
|
||
- Сохранить настройки фильтров в `window._gpsFilters`.
|
||
|
||
### REQ-F-14: Sheet `#sheet-gps-filters`
|
||
|
||
Bottom sheet (аналогично `#sheet-recon`):
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ ═══ │
|
||
│ 🌍 Фильтры публичных треков [✕] │
|
||
├─────────────────────────────────────┤
|
||
│ ТИП АКТИВНОСТИ │
|
||
│ ☑ Эндуро ☑ Мото ☑ Off-road │
|
||
│ ☑ Велосипед ☑ Пешком ☑ Лыжи │
|
||
│ ☑ Другое │
|
||
│ │
|
||
│ ИСТОЧНИК │
|
||
│ ☑ OSM ☑ EnduroRussia.ru │
|
||
│ ☑ ttrails.ru ☐ Offmaps.ru │
|
||
│ ☐ Nakarte.me │
|
||
│ │
|
||
│ ЦВЕТ ЛИНИЙ │
|
||
│ ◉ По источнику ○ По активности │
|
||
│ │
|
||
│ Всего треков в области: 743 │
|
||
│ Видны: 412 (фильтр) │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
Поведение:
|
||
- Чекбоксы multi-select.
|
||
- Изменение фильтра → клиентская фильтрация уже загруженных features
|
||
через `setFilter()` MapLibre (без нового запроса).
|
||
- При смене bbox карты — повторный запрос только видимой выборки.
|
||
- Состояние фильтров сохраняется в localStorage (REQ-F-15).
|
||
|
||
### REQ-F-15: Сохранение состояния (localStorage)
|
||
|
||
| Ключ | Значение | Default |
|
||
| ------------------------------- | ------------------------------ | ------------------------------------ |
|
||
| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` |
|
||
| `gps-tracks-activities` | JSON-array из ACTIVITY_TYPES | все |
|
||
| `gps-tracks-sources` | JSON-array source IDs | все enabled |
|
||
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` |
|
||
|
||
Чтение при старте через `restorePublicTracksState()` (по аналогии с
|
||
`restoreTerrainState()` и `restoreBaseLayerState()` из ET-007).
|
||
|
||
### REQ-F-16: Палитра цветов
|
||
|
||
**По источнику** (default):
|
||
- `osm` — `#3cb44b` (зелёный)
|
||
- `enduro_russia` — `#e6194b` (красный)
|
||
- `ttrails` — `#4363d8` (синий)
|
||
- `offmaps` — `#f58231` (оранжевый)
|
||
- `nakarte` — `#911eb4` (фиолетовый)
|
||
- остальные — циклически из палитры из 8 цветов (как в ET-006).
|
||
|
||
**По активности**:
|
||
- `enduro` — `#e6194b`
|
||
- `moto` — `#f58231`
|
||
- `offroad` — `#ffe119`
|
||
- `bicycle` — `#3cb44b`
|
||
- `hike` — `#4363d8`
|
||
- `ski` — `#42d4f4`
|
||
- `other` — `#808080`
|
||
|
||
Цвет применяется через MapLibre `match` expression в layer paint:
|
||
|
||
```js
|
||
'line-color': [
|
||
'match', ['get', 'source'],
|
||
'osm', '#3cb44b',
|
||
'enduro_russia', '#e6194b',
|
||
...
|
||
'#808080'
|
||
]
|
||
```
|
||
|
||
### REQ-F-17: Стили слоя `gps-tracks-layer`
|
||
|
||
```js
|
||
{
|
||
id: 'gps-tracks-layer',
|
||
type: 'line',
|
||
source: 'gps-tracks-tiles',
|
||
'source-layer': 'gps_tracks',
|
||
paint: {
|
||
'line-color': /* match по REQ-F-16 */,
|
||
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||
8, 1.0, 12, 2.0, 16, 3.0],
|
||
'line-opacity': 0.75
|
||
},
|
||
layout: { 'line-cap': 'round', 'line-join': 'round',
|
||
visibility: 'none' }
|
||
}
|
||
```
|
||
|
||
Halo-слой для спутника (по аналогии с ET-007):
|
||
```js
|
||
{
|
||
id: 'gps-tracks-halo-satellite',
|
||
type: 'line',
|
||
source: 'gps-tracks-tiles',
|
||
'source-layer': 'gps_tracks',
|
||
paint: {
|
||
'line-color': '#ffffff',
|
||
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||
8, 2.5, 12, 4.0, 16, 6.0],
|
||
'line-opacity': 0.6
|
||
},
|
||
layout: { visibility: 'none' }
|
||
}
|
||
```
|
||
|
||
Halo включается только если `(public-tracks ON) AND (base ===
|
||
'satellite')` — по тому же паттерну, что halo троп в ET-007 §5.7.
|
||
|
||
### REQ-F-18: Popup при клике на трек
|
||
|
||
При клике на feature слоя `gps-tracks-layer`:
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ Утренний эндуро ✕ │
|
||
│ ───────────────────────── │
|
||
│ 🏍 Эндуро │
|
||
│ 📏 47.3 км · 1240 точек │
|
||
│ 📅 12 мая 2024 │
|
||
│ 👤 Vasya │
|
||
│ │
|
||
│ Источники: │
|
||
│ • OSM ↗ • EnduroRussia.ru ↗ │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
Каждая ссылка-источник открывает оригинал в новой вкладке.
|
||
|
||
### REQ-F-19: Интеграция с `rebuildMapOverlays()`
|
||
|
||
В `app.js`, в существующей функции `rebuildMapOverlays()` добавить
|
||
вызов:
|
||
|
||
```js
|
||
function rebuildMapOverlays() {
|
||
restoreBaseLayerState(); // ET-007 (уже есть)
|
||
restoreTerrainState(); // существующее
|
||
restoreTrailsState(); // существующее
|
||
restorePublicTracksState(); // НОВОЕ ET-008
|
||
restorePoiState(); // существующее
|
||
// ... GPX (ET-006) и route — без изменений
|
||
}
|
||
```
|
||
|
||
`restorePublicTracksState()`:
|
||
1. Читать `gps-tracks-enabled` из localStorage.
|
||
2. Если включено — пересоздать vector source / layer / halo.
|
||
3. Применить фильтры из localStorage.
|
||
4. Синхронизировать UI (чекбокс, состояние «Фильтры…»).
|
||
|
||
### REQ-F-20: Поведение на low-zoom (защита от шторма запросов)
|
||
|
||
- На z < 8 — слой публичных треков скрывается автоматически (даже если
|
||
включён). Подсказка в попапе слоёв (рядом с чекбоксом, по аналогии с
|
||
«Тени рельефа Зум 10+»): «Зум 8+».
|
||
- На z 8–11 — данные из MVT-тайлов (нет GeoJSON запросов).
|
||
- На z ≥ 12 — переключение на GeoJSON через `moveend` debounced 500ms.
|
||
|
||
## 2. Нефункциональные требования
|
||
|
||
### REQ-NF-01: Безопасность
|
||
|
||
- Pipeline идёт **исходящими** запросами с mva154 — нет открытых
|
||
входных точек скрейпинга.
|
||
- Endpoints `/api/gps-tracks/*` — только GET, идемпотентны, без
|
||
пользовательского ввода в SQL (параметры — числа и ENUM).
|
||
- Парсинг XML на сервере (для скачанных GPX) — через `defusedxml`
|
||
(защита от XXE / billion laughs).
|
||
- Bbox-параметр валидируется (диапазон координат, площадь).
|
||
- CORS наследуется (`allow_origins=["*"]`).
|
||
|
||
### REQ-NF-02: Производительность
|
||
|
||
- `GET /api/gps-tracks?bbox=…` p95 ≤ 300 мс на ≤ 500 треков (zoom 10+).
|
||
- `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` p95 ≤ 200 мс на cold-cache,
|
||
≤ 20 мс на cache hit.
|
||
- Pipeline на ЦФО + Чувашию: ≤ 6 часов на полный прогон (cron-окно).
|
||
- Запрос `compute_dedup_key()`: O(1), без БД.
|
||
- INSERT с конфликтом по `dedup_key`: ON CONFLICT UPDATE — один SQL.
|
||
|
||
### REQ-NF-03: Хранение и ротация
|
||
|
||
- `data/gps_tracks.sqlite`: размер ≤ 2 ГБ для ЦФО + Чувашии.
|
||
- Ротация: треки с `updated_at < NOW() - 5 years` (default) удаляются
|
||
при запуске pipeline с `--gc` (cron 1 раз в месяц).
|
||
- Бэкап: ежедневный snapshot (см. `07-infra-requirements.md`).
|
||
|
||
### REQ-NF-04: Кэширование тайлов
|
||
|
||
- LRU-кэш в памяти процесса FastAPI: 1024 записи (как для основных
|
||
тайлов).
|
||
- При запуске pipeline — кэш сбрасывается через
|
||
`POST /api/cache/clear`? Нет — отдельный endpoint
|
||
`POST /api/gps-tracks/cache/clear`. Pipeline вызывает его в конце
|
||
прогона.
|
||
|
||
### REQ-NF-05: Совместимость
|
||
|
||
- Браузеры: Chrome 90+, Firefox 90+, Safari 15+ (как ET-006/ET-007).
|
||
- Backend: Python 3.12, FastAPI, httpx, lxml/defusedxml, shapely (есть).
|
||
- MapLibre GL JS 4.7.0 (есть).
|
||
|
||
### REQ-NF-06: UX
|
||
|
||
- Включение слоя — мгновенное (тайлы загружаются параллельно).
|
||
- Загрузка тайлов на медленной сети — без блокировки UI (асинхронно).
|
||
- Фильтры — клиентские, моментальные (≤ 200 мс).
|
||
- Все ошибки — toast-уведомления, не alert/confirm.
|
||
- Атрибуция источников — в правом нижнем углу карты (MapLibre
|
||
встроенная панель), привязывается к source attribution.
|
||
|
||
### REQ-NF-07: Наблюдаемость
|
||
|
||
- `/api/gps-tracks/health` отдаёт полную картину состояния (REQ-F-12).
|
||
- Pipeline пишет structured logs (JSON-lines) с полями run_id, region,
|
||
source, status, tracks_new, error.
|
||
- Алерт (через существующий механизм проекта) при двух неудачных
|
||
прогонах подряд для одного source.
|
||
|
||
## 3. UI-спецификация
|
||
|
||
### 3.1 Изменения в `#terrain-popup`
|
||
|
||
Добавляем между секцией «Тропы» и «POI» (после соответствующего `<hr>`):
|
||
|
||
```html
|
||
<!-- ET-008 -->
|
||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||
<label class="terrain-checkbox">
|
||
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
||
<span>Публичные треки</span>
|
||
</label>
|
||
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
|
||
<button class="terrain-link-btn" id="public-tracks-filters-btn"
|
||
onclick="togglePublicTracksFiltersSheet()" style="display:none">
|
||
Фильтры…
|
||
</button>
|
||
```
|
||
|
||
CSS:
|
||
```css
|
||
.terrain-link-btn {
|
||
display: block;
|
||
margin: 4px 0 0 24px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--accent, #ff8c1a);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
padding: 2px 0;
|
||
text-decoration: underline;
|
||
}
|
||
```
|
||
|
||
### 3.2 Bottom sheet `#sheet-gps-filters`
|
||
|
||
```html
|
||
<div class="bottom-sheet" id="sheet-gps-filters">
|
||
<div class="sheet-handle"></div>
|
||
<div class="sheet-header">
|
||
<svg>...</svg>
|
||
<h2>Фильтры публичных треков</h2>
|
||
<button class="sheet-close" onclick="toggleGpsFiltersSheet()">✕</button>
|
||
</div>
|
||
<div class="sheet-body">
|
||
<div class="section-label">ТИП АКТИВНОСТИ</div>
|
||
<div id="gps-activity-grid" class="gps-filter-grid">
|
||
<!-- генерируется JS -->
|
||
</div>
|
||
<div class="section-label">ИСТОЧНИК</div>
|
||
<div id="gps-source-grid" class="gps-filter-grid">
|
||
<!-- генерируется JS из /api/gps-tracks/health -->
|
||
</div>
|
||
<div class="section-label">ЦВЕТ ЛИНИЙ</div>
|
||
<div class="seg-control">
|
||
<button class="seg-btn active" id="gps-color-by-source" data-mode="source">По источнику</button>
|
||
<button class="seg-btn" id="gps-color-by-activity" data-mode="activity">По активности</button>
|
||
</div>
|
||
<div class="gps-stats-row" id="gps-stats-row">
|
||
<span>Всего в области: <b id="gps-stat-total">—</b></span>
|
||
<span>Видны (фильтр): <b id="gps-stat-shown">—</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
Открывается через `togglePublicTracksFiltersSheet()` из попапа.
|
||
|
||
### 3.3 Popup трека
|
||
|
||
Реализуется как MapLibre `Popup` (без bottom sheet — компактнее для
|
||
этого UX):
|
||
|
||
```js
|
||
new maplibregl.Popup({closeOnClick: true})
|
||
.setLngLat(e.lngLat)
|
||
.setHTML(renderTrackPopupHtml(feature.properties))
|
||
.addTo(map);
|
||
```
|
||
|
||
### 3.4 Адаптив для мобильных
|
||
|
||
- Sheet `#sheet-gps-filters` — full-width на мобильных (как остальные).
|
||
- Чипы фильтров — wrap в 2 колонки на мобильных через CSS Grid.
|
||
- Popup трека — width: 280px, чтобы не перекрывал тулбар.
|
||
|
||
## 4. Данные
|
||
|
||
### 4.1 Модель в SQL
|
||
|
||
См. REQ-F-09.
|
||
|
||
### 4.2 GeoJSON API контракт
|
||
|
||
См. REQ-F-10.
|
||
|
||
### 4.3 MVT layer schema
|
||
|
||
Layer name: `gps_tracks`.
|
||
|
||
Properties в каждом feature:
|
||
|
||
| Поле | Тип | Описание |
|
||
| -------------- | ------ | ------------------------------------------- |
|
||
| `id` | int | track.id из БД |
|
||
| `activity` | string | ACTIVITY_TYPE |
|
||
| `source` | string | первый source_id из sources (для цвета) |
|
||
| `sources` | string | comma-separated все sources (для popup) |
|
||
| `length_km` | float | length_m / 1000 |
|
||
| `name` | string | name (может быть пустым) |
|
||
| `ext_url` | string | первый URL из external_urls (для ↗) |
|
||
|
||
### 4.4 Клиентская модель `window.gpsTracksLayer`
|
||
|
||
```js
|
||
window.gpsTracksLayer = {
|
||
enabled: false,
|
||
filters: {
|
||
activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
|
||
sources: ['osm', 'enduro_russia', 'ttrails', /* … */],
|
||
colorMode: 'source' // 'source' | 'activity'
|
||
},
|
||
sourceId: 'gps-tracks-tiles',
|
||
sourceGeoId: 'gps-tracks-geo',
|
||
layerId: 'gps-tracks-layer',
|
||
layerHaloId: 'gps-tracks-halo-satellite',
|
||
geojsonAbortController: null,
|
||
geojsonReqDebounceTimer: null,
|
||
stats: { total: 0, shown: 0 }
|
||
};
|
||
```
|
||
|
||
## 5. Файловая структура изменений
|
||
|
||
```
|
||
src/api/
|
||
├── main.py # + регистрация роутов
|
||
├── requirements.txt # + defusedxml, lxml
|
||
├── gps_tracks/ # НОВЫЙ пакет
|
||
│ ├── __init__.py
|
||
│ ├── models.py # Pydantic, ACTIVITY_TYPES
|
||
│ ├── db.py # SQLite + Spatialite обвязка
|
||
│ ├── dedup.py # compute_dedup_key
|
||
│ ├── mvt.py # MVT-генерация
|
||
│ ├── endpoint.py # FastAPI routes
|
||
│ ├── config.py # загрузка YAML
|
||
│ └── sources/
|
||
│ ├── __init__.py
|
||
│ ├── base.py # абстрактный SourceParser
|
||
│ ├── osm.py
|
||
│ ├── enduro_russia.py
|
||
│ └── ttrails.py
|
||
|
||
src/web/
|
||
├── index.html # + чекбокс, sheet-gps-filters
|
||
├── app.css # + .terrain-link-btn, .gps-filter-grid, .gps-stats-row
|
||
├── app.js # + restorePublicTracksState, popup-handler,
|
||
# расширение rebuildMapOverlays
|
||
├── gps_tracks.js # НОВЫЙ: слой, фильтры, popup
|
||
├── style.json # + halo-layer для спутника
|
||
├── style-dark.json # + halo-layer для спутника
|
||
|
||
scripts/
|
||
├── gps_collect.py # НОВЫЙ pipeline-entry
|
||
|
||
config/
|
||
├── gps_sources.yaml # НОВЫЙ
|
||
├── gps_regions.yaml # НОВЫЙ
|
||
|
||
migrations/
|
||
├── gps_tracks_001_init.sql # CREATE TABLE
|
||
|
||
tests/
|
||
├── api/test_gps_tracks_endpoint.py # bbox/filter/limit
|
||
├── api/test_gps_tracks_mvt.py # MVT-генерация
|
||
├── api/test_gps_tracks_dedup.py # compute_dedup_key
|
||
├── api/test_gps_tracks_sources_osm.py # парсер OSM (с фикстурами)
|
||
├── web/gps_tracks.test.js # фильтры, цветовая палитра
|
||
|
||
docs/work-items/ET-008/
|
||
├── 06-adr/
|
||
│ ├── ADR-001-storage-schema.md
|
||
│ ├── ADR-002-dedup-algorithm.md
|
||
│ ├── ADR-003-osm-licensing.md
|
||
│ ├── ADR-004-enduro-russia-licensing.md # обязательно перед коммитом source
|
||
│ └── ADR-005-ttrails-licensing.md # обязательно перед коммитом source
|
||
├── 07-infra-requirements.md
|
||
├── 08-data-requirements.md
|
||
├── 10-tech-risks.md
|
||
```
|
||
|
||
## 6. Алгоритмы
|
||
|
||
### 6.1 `compute_dedup_key(geom, metadata)`
|
||
|
||
См. REQ-F-08.
|
||
|
||
### 6.2 Bbox-разбиение региона на OSM-cells
|
||
|
||
```python
|
||
def split_bbox_for_osm(region_bbox, cell_size=0.25):
|
||
w, s, e, n = region_bbox
|
||
cells = []
|
||
lat = s
|
||
while lat < n:
|
||
lon = w
|
||
while lon < e:
|
||
cells.append((lon, lat, min(lon+cell_size, e), min(lat+cell_size, n)))
|
||
lon += cell_size
|
||
lat += cell_size
|
||
return cells
|
||
```
|
||
|
||
Для ЦФО + Чувашии (≈ 670K км²) → ≈ 700 cells × ≤ 5 pages × 1 sec rate
|
||
limit ≈ 1 час.
|
||
|
||
### 6.3 Backoff для скрейпинга
|
||
|
||
```python
|
||
async def fetch_with_backoff(url, max_retries=3, base_delay=2.0):
|
||
for attempt in range(max_retries):
|
||
try:
|
||
resp = await client.get(url, timeout=30)
|
||
if resp.status_code == 429:
|
||
delay = base_delay * (2 ** attempt)
|
||
await asyncio.sleep(delay)
|
||
continue
|
||
resp.raise_for_status()
|
||
return resp
|
||
except (httpx.TimeoutException, httpx.NetworkError):
|
||
await asyncio.sleep(base_delay * (2 ** attempt))
|
||
raise RuntimeError(f"Max retries exceeded: {url}")
|
||
```
|
||
|
||
### 6.4 Клиентская сторона: debounced GeoJSON-загрузка
|
||
|
||
```js
|
||
function onMapMoveEnd() {
|
||
if (!window.gpsTracksLayer.enabled) return;
|
||
if (map.getZoom() < 12) return; // на низком zoom — только тайлы
|
||
|
||
clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
|
||
window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
|
||
fetchAndUpdateGeoJson(map.getBounds());
|
||
}, 500);
|
||
}
|
||
|
||
async function fetchAndUpdateGeoJson(bounds) {
|
||
// Отменить предыдущий запрос
|
||
if (window.gpsTracksLayer.geojsonAbortController) {
|
||
window.gpsTracksLayer.geojsonAbortController.abort();
|
||
}
|
||
const ctrl = new AbortController();
|
||
window.gpsTracksLayer.geojsonAbortController = ctrl;
|
||
|
||
const { activities, sources } = window.gpsTracksLayer.filters;
|
||
const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
|
||
const url = `/api/gps-tracks?bbox=${bbox}` +
|
||
`&activity=${activities.join(',')}` +
|
||
`&source=${sources.join(',')}` +
|
||
`&limit=500`;
|
||
try {
|
||
const resp = await fetch(url, { signal: ctrl.signal });
|
||
const json = await resp.json();
|
||
map.getSource('gps-tracks-geo').setData(json);
|
||
window.gpsTracksLayer.stats = { total: json.total_in_bbox, shown: json.returned };
|
||
syncGpsFiltersStatsUI();
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') return;
|
||
showToast('Не удалось загрузить треки');
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.5 Клиентская фильтрация по `setFilter()`
|
||
|
||
При изменении чекбокса активности/источника — без нового запроса:
|
||
|
||
```js
|
||
function applyGpsFilter() {
|
||
const { activities, sources } = window.gpsTracksLayer.filters;
|
||
const filter = [
|
||
'all',
|
||
['in', ['get', 'activity'], ['literal', activities]],
|
||
['in', ['get', 'source'], ['literal', sources]]
|
||
];
|
||
map.setFilter('gps-tracks-layer', filter);
|
||
if (map.getLayer('gps-tracks-layer-geo')) {
|
||
map.setFilter('gps-tracks-layer-geo', filter);
|
||
}
|
||
if (map.getLayer('gps-tracks-halo-satellite')) {
|
||
map.setFilter('gps-tracks-halo-satellite', filter);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 7. Взаимодействие с существующими модулями
|
||
|
||
### 7.1 ET-006 (`gpx.js`)
|
||
|
||
- Не пересекается: `window.gpxTracks` (личные треки) и
|
||
`window.gpsTracksLayer` (публичный слой) — разные модели.
|
||
- На карте оба видны параллельно; z-order:
|
||
`gps-tracks-layer` < `gpx-layer-*` (личные треки выше).
|
||
|
||
### 7.2 ET-007 (спутник)
|
||
|
||
- Halo `gps-tracks-halo-satellite` включается/выключается по тому же
|
||
паттерну, что halo троп (`trails-track-halo-satellite`).
|
||
- В `applyBaseLayer()` (ET-007) добавить шаг:
|
||
```js
|
||
// ET-008: halo публичных треков
|
||
const haloOn = (currentBase === 'satellite' && layerState.publicTracks);
|
||
setLayoutProperty('gps-tracks-halo-satellite', 'visibility',
|
||
haloOn ? 'visible' : 'none');
|
||
```
|
||
|
||
### 7.3 Поиск, маршрут, разведка, scenic, ruler
|
||
|
||
- Слой публичных треков не блокирует и не модифицирует существующие
|
||
режимы.
|
||
- Клик по карте: маршрут/разведка имеют приоритет; popup трека только
|
||
если ни один режим не активен и `map.queryRenderedFeatures` возвращает
|
||
`gps-tracks-layer`.
|
||
|
||
## 8. Открытые вопросы для ADR
|
||
|
||
- **ADR-001**: единая БД vs отдельная (`data/gps_tracks.sqlite`).
|
||
Рекомендация ТЗ — отдельная (см. BRD §6 риск «размер БД»).
|
||
- **ADR-002**: точный алгоритм дедупликации — bbox-bucket vs
|
||
Frechet-distance vs hash-of-resampled-points.
|
||
- **ADR-003, 004, 005**: licensing review для каждого внешнего источника
|
||
(OSM однозначен; для остальных — обязательно перед merge кода
|
||
per-source модуля).
|
||
- **Открытое**: показывать ли popup трека или открывать `#sheet-route`-
|
||
подобный bottom sheet (ради единого UX). По умолчанию — MapLibre
|
||
Popup как компромисс компактности.
|
||
- **Открытое**: цветовая палитра «По активности» — окончательная
|
||
валидация на тёмной/светлой теме и на спутнике.
|
||
- **Открытое**: на низких зумах прятать слой или показывать сильно
|
||
упрощённый. Рекомендация ТЗ — прятать с подсказкой «Зум 8+».
|