From 0060003f281a3ad36250449d93d62264e07e07fe Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 12:28:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(gps-tracks):=20ET-008=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20GPS-=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs - Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt, GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config - Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита), enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы) - Licensing guard: pipeline проверяет status ADR-файла до запуска источника - scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc Frontend: - src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12), debounced fetch + AbortController, фильтры активности/источника, цветовая палитра by-source/by-activity, halo на спутнике, popup трека, restorePublicTracksState(), localStorage persistence - index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters - app.css: .terrain-link-btn, .gps-filter-grid, .track-popup - app.js: вызов restorePublicTracksState() в rebuildMapOverlays(), applyGpsHaloVisibility() в applyBaseLayer() Конфиги: - config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled) - config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled) Docker: - gps-collector service с profiles: [batch] Тесты: 48 новых тестов (unit + integration), 125/125 pass Refs: ET-008 Co-Authored-By: Claude Sonnet 4.6 --- config/gps_regions.yaml | 12 + config/gps_sources.yaml | 34 ++ docker-compose.yml | 18 + migrations/gps_tracks_001_init.sql | 40 ++ scripts/gps_collect.py | 366 +++++++++++ src/api/gps_tracks/__init__.py | 0 src/api/gps_tracks/config.py | 89 +++ src/api/gps_tracks/db.py | 232 +++++++ src/api/gps_tracks/dedup.py | 32 + src/api/gps_tracks/endpoint.py | 240 ++++++++ src/api/gps_tracks/models.py | 52 ++ src/api/gps_tracks/mvt.py | 167 +++++ src/api/gps_tracks/sources/__init__.py | 0 src/api/gps_tracks/sources/base.py | 34 ++ src/api/gps_tracks/sources/enduro_russia.py | 17 + src/api/gps_tracks/sources/osm.py | 309 ++++++++++ src/api/gps_tracks/sources/ttrails.py | 17 + src/api/main.py | 9 + src/api/requirements.txt | 3 + src/web/app.css | 73 +++ src/web/app.js | 12 + src/web/gps_tracks.js | 573 ++++++++++++++++++ src/web/index.html | 38 ++ tests/api/__init__.py | 0 tests/api/test_gps_tracks_dedup.py | 216 +++++++ tests/api/test_gps_tracks_endpoint.py | 351 +++++++++++ tests/api/test_gps_tracks_mvt.py | 171 ++++++ tests/api/test_gps_tracks_sources_osm.py | 182 ++++++ .../osm-trackpoints-bbox-moscow.gpx | 10 + tests/fixtures/gps-tracks/xxe-payload.gpx | 3 + 30 files changed, 3300 insertions(+) create mode 100644 config/gps_regions.yaml create mode 100644 config/gps_sources.yaml create mode 100644 migrations/gps_tracks_001_init.sql create mode 100644 scripts/gps_collect.py create mode 100644 src/api/gps_tracks/__init__.py create mode 100644 src/api/gps_tracks/config.py create mode 100644 src/api/gps_tracks/db.py create mode 100644 src/api/gps_tracks/dedup.py create mode 100644 src/api/gps_tracks/endpoint.py create mode 100644 src/api/gps_tracks/models.py create mode 100644 src/api/gps_tracks/mvt.py create mode 100644 src/api/gps_tracks/sources/__init__.py create mode 100644 src/api/gps_tracks/sources/base.py create mode 100644 src/api/gps_tracks/sources/enduro_russia.py create mode 100644 src/api/gps_tracks/sources/osm.py create mode 100644 src/api/gps_tracks/sources/ttrails.py create mode 100644 src/web/gps_tracks.js create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_gps_tracks_dedup.py create mode 100644 tests/api/test_gps_tracks_endpoint.py create mode 100644 tests/api/test_gps_tracks_mvt.py create mode 100644 tests/api/test_gps_tracks_sources_osm.py create mode 100644 tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx create mode 100644 tests/fixtures/gps-tracks/xxe-payload.gpx diff --git a/config/gps_regions.yaml b/config/gps_regions.yaml new file mode 100644 index 0000000..ba80554 --- /dev/null +++ b/config/gps_regions.yaml @@ -0,0 +1,12 @@ +regions: + - id: tsfo_plus_chuvashia + name: "ЦФО + Чувашия" + bbox: [29.0, 49.5, 47.5, 60.0] + enabled: true + sources: [osm, enduro_russia, ttrails] + + - id: north_caucasus + name: "Северный Кавказ" + bbox: [37.0, 41.5, 49.0, 47.0] + enabled: false + sources: [osm, enduro_russia] diff --git a/config/gps_sources.yaml b/config/gps_sources.yaml new file mode 100644 index 0000000..98dc559 --- /dev/null +++ b/config/gps_sources.yaml @@ -0,0 +1,34 @@ +sources: + - id: osm + name: "OSM Public GPS Traces" + enabled: true + license_adr: "docs/work-items/ET-008/06-adr/ADR-009-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" + save_user_field: true + external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}" + + - id: enduro_russia + name: "EnduroRussia.ru" + enabled: false + license_adr: "docs/work-items/ET-008/06-adr/ADR-010-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" + save_user_field: false + + - id: ttrails + name: "Тропинки.ру" + enabled: false + license_adr: "docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md" + base_url: "https://ttrails.ru" + rate_limit_sec: 5 + user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)" + attribution: "ttrails.ru" + parser_module: "src.api.gps_tracks.sources.ttrails" + save_user_field: false diff --git a/docker-compose.yml b/docker-compose.yml index e9e71ba..0aa7044 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: volumes: - ./data:/app/data - ./src/web:/app/src/web + - ./config:/app/config:ro environment: - DATABASE_URL=sqlite:///./data/enduro.db - DATA_PATH=/app/data/centralfederal.sqlite @@ -15,8 +16,25 @@ services: - STATIC_DIR=/app/src/web - OSRM_URL=http://172.22.0.1:5559 - PORT=5556 + - GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite + - GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml + - GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"] interval: 30s timeout: 5s retries: 3 + + gps-collector: + build: . + profiles: ["batch"] + volumes: + - ./data:/app/data + - ./config:/app/config:ro + - /var/log/enduro-trails:/var/log/enduro-trails + environment: + - GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite + - GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml + - GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml + command: ["python", "-m", "scripts.gps_collect"] + restart: "no" diff --git a/migrations/gps_tracks_001_init.sql b/migrations/gps_tracks_001_init.sql new file mode 100644 index 0000000..da2f9e4 --- /dev/null +++ b/migrations/gps_tracks_001_init.sql @@ -0,0 +1,40 @@ +PRAGMA journal_mode=WAL; +CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dedup_key TEXT NOT NULL UNIQUE, + name TEXT, + description TEXT, + activity_type TEXT, + user TEXT, + created_at TEXT, + 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, + sources_json TEXT NOT NULL, + external_urls_json TEXT NOT NULL, + tags_json TEXT, + inserted_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + source_priority INTEGER NOT NULL DEFAULT 999 +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_dedup ON tracks(dedup_key); +CREATE INDEX IF NOT EXISTS idx_tracks_activity ON tracks(activity_type); +CREATE INDEX IF NOT EXISTS idx_tracks_created ON tracks(created_at); +CREATE INDEX IF NOT EXISTS idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat); + +CREATE TABLE IF NOT EXISTS 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, + tracks_new INTEGER DEFAULT 0, + tracks_updated INTEGER DEFAULT 0, + errors_json TEXT +); +CREATE INDEX IF NOT EXISTS idx_pipeline_started ON pipeline_runs(started_at); diff --git a/scripts/gps_collect.py b/scripts/gps_collect.py new file mode 100644 index 0000000..d82ac92 --- /dev/null +++ b/scripts/gps_collect.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""CLI pipeline для сбора GPS-треков из публичных источников (ET-008). + +Usage: + python scripts/gps_collect.py [--region ] [--source ] [--dry-run] [--gc] + +Exit code: 0 (success) or 1 (any error/skip) +""" +import argparse +import asyncio +import importlib +import json +import logging +import os +import sys +from datetime import datetime, timezone + +# Добавляем корень проекта в PYTHONPATH +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.api.gps_tracks.config import load_regions_config, load_sources_config +from src.api.gps_tracks.db import init_db, open_db, upsert_track +from src.api.gps_tracks.dedup import compute_dedup_key + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger("gps_collect") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _check_license_adr(adr_path: str, project_root: str) -> str: + """Читает ADR файл и возвращает статус ('accepted', 'proposed', ...). + + Returns: + str статус или 'unknown' если файл не найден/не парсится + """ + full_path = os.path.join(project_root, adr_path) + if not os.path.exists(full_path): + logger.warning("ADR file not found: %s", full_path) + return "unknown" + + try: + import yaml + + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + + # Ищем YAML front-matter или поле status + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + front_matter = yaml.safe_load(parts[1]) + if isinstance(front_matter, dict) and "status" in front_matter: + return str(front_matter["status"]).lower() + + # Fallback: ищем строку "status: " + for line in content.splitlines(): + stripped = line.strip().lower() + if stripped.startswith("status:"): + value = stripped.split(":", 1)[1].strip() + return value + + return "unknown" + except Exception as exc: + logger.warning("Failed to parse ADR %s: %s", adr_path, exc) + return "unknown" + + +def _record_pipeline_run( + conn, + region_id: str, + source_id: str, + started_at: str, + finished_at: str, + status: str, + tracks_new: int = 0, + tracks_updated: int = 0, + errors: list = None, +) -> None: + """Записывает результат запуска pipeline в БД.""" + errors_json = json.dumps(errors) if errors else None + conn.execute( + """ + INSERT INTO pipeline_runs + (started_at, finished_at, region_id, source_id, status, + tracks_new, tracks_updated, errors_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + started_at, + finished_at, + region_id, + source_id, + status, + tracks_new, + tracks_updated, + errors_json, + ), + ) + conn.commit() + + +async def _collect_source_for_region( + region: dict, + source_cfg: dict, + conn, + dry_run: bool, +) -> dict: + """Запускает сбор треков для одного (region, source). + + Returns: + dict с ключами: status, tracks_new, tracks_updated, errors + """ + source_id = source_cfg["id"] + region_id = region["id"] + bbox = tuple(region["bbox"]) # (west, south, east, north) + + parser_module_path = source_cfg.get("parser_module", "") + if not parser_module_path: + return {"status": "error", "tracks_new": 0, "tracks_updated": 0, "errors": ["No parser_module"]} + + try: + module = importlib.import_module(parser_module_path) + # Конвенция: класс называется Parser + class_name = source_id.replace("_", " ").title().replace(" ", "") + "Parser" + parser_class = getattr(module, class_name, None) + if parser_class is None: + # Fallback: первый класс с суффиксом Parser + for name in dir(module): + if name.endswith("Parser") and name != "SourceParser": + parser_class = getattr(module, name) + break + + if parser_class is None: + return { + "status": "error", + "tracks_new": 0, + "tracks_updated": 0, + "errors": [f"Parser class not found in {parser_module_path}"], + } + + parser = parser_class(source_cfg) + except Exception as exc: + return { + "status": "error", + "tracks_new": 0, + "tracks_updated": 0, + "errors": [f"Failed to load parser: {exc}"], + } + + tracks_new = 0 + tracks_updated = 0 + errors = [] + source_priority = source_cfg.get("source_priority", 50) + + try: + async for track in parser.collect(bbox, {"dry_run": dry_run, "conn": conn}): + if dry_run: + logger.info("[dry-run] Would upsert track from %s: %s", source_id, track.external_id) + tracks_new += 1 + continue + + try: + dedup_key = compute_dedup_key( + (track.min_lon, track.min_lat, track.max_lon, track.max_lat), + {"length_m": track.length_m, "created_at": track.created_at}, + ) + result = upsert_track(conn, track, dedup_key, source_priority) + if result == "inserted": + tracks_new += 1 + else: + tracks_updated += 1 + except Exception as exc: + errors.append(f"upsert error for {track.external_id}: {exc}") + logger.error("Upsert error: %s", exc) + except NotImplementedError as exc: + return { + "status": "error", + "tracks_new": 0, + "tracks_updated": 0, + "errors": [str(exc)], + } + except Exception as exc: + errors.append(str(exc)) + logger.error("Collect error for %s/%s: %s", region_id, source_id, exc) + return { + "status": "error", + "tracks_new": tracks_new, + "tracks_updated": tracks_updated, + "errors": errors, + } + + status = "ok" if not errors else "partial" + return { + "status": status, + "tracks_new": tracks_new, + "tracks_updated": tracks_updated, + "errors": errors, + } + + +async def main() -> int: + """Главная функция pipeline сбора GPS-треков.""" + parser = argparse.ArgumentParser(description="GPS tracks collection pipeline") + parser.add_argument("--region", help="Region ID to process (all if not set)") + parser.add_argument("--source", help="Source ID to process (all if not set)") + parser.add_argument("--dry-run", action="store_true", help="Simulate without writing to DB") + parser.add_argument("--gc", action="store_true", help="Run garbage collection after each region") + args = parser.parse_args() + + project_root = os.path.join(os.path.dirname(__file__), "..") + + sources_config_path = os.environ.get( + "GPS_SOURCES_CONFIG", + os.path.join(project_root, "config/gps_sources.yaml"), + ) + regions_config_path = os.environ.get( + "GPS_REGIONS_CONFIG", + os.path.join(project_root, "config/gps_regions.yaml"), + ) + db_path = os.environ.get( + "GPS_TRACKS_DB_PATH", + os.path.join(project_root, "data/gps_tracks.sqlite"), + ) + + # Загружаем конфигурации + try: + sources = load_sources_config(sources_config_path) + regions = load_regions_config(regions_config_path) + except Exception as exc: + logger.error("Failed to load config: %s", exc) + return 1 + + # Фильтруем по параметрам CLI + if args.region: + regions = [r for r in regions if r["id"] == args.region] + if not regions: + logger.error("Region '%s' not found", args.region) + return 1 + + if args.source: + sources = [s for s in sources if s["id"] == args.source] + if not sources: + logger.error("Source '%s' not found", args.source) + return 1 + + # Открываем БД + try: + conn = open_db(db_path) + init_db(conn) + except Exception as exc: + logger.error("Failed to open DB: %s", exc) + return 1 + + # Строим индекс источников по id + sources_by_id = {s["id"]: s for s in sources} + + has_error = False + + for region in regions: + if not region.get("enabled", True): + logger.info("Skipping disabled region: %s", region["id"]) + continue + + region_sources = region.get("sources", []) + + for source_id in region_sources: + if source_id not in sources_by_id: + logger.warning("Source '%s' not found in sources config", source_id) + continue + + source_cfg = sources_by_id[source_id] + + # Фильтр по --source + if args.source and source_cfg["id"] != args.source: + continue + + if not source_cfg.get("enabled", False): + logger.info("Skipping disabled source: %s", source_id) + started_at = _now_iso() + _record_pipeline_run( + conn, + region["id"], + source_id, + started_at, + _now_iso(), + "skipped_disabled", + ) + continue + + # Проверяем лицензию + license_adr = source_cfg.get("license_adr", "") + started_at = _now_iso() + + if license_adr: + license_status = _check_license_adr(license_adr, project_root) + if license_status != "accepted": + logger.warning( + "Skipping %s/%s: license ADR status is '%s' (need 'accepted')", + region["id"], + source_id, + license_status, + ) + _record_pipeline_run( + conn, + region["id"], + source_id, + started_at, + _now_iso(), + "skipped_license", + ) + has_error = True + continue + + logger.info( + "Collecting %s for region %s (bbox=%s)", + source_id, + region["id"], + region["bbox"], + ) + + result = await _collect_source_for_region(region, source_cfg, conn, args.dry_run) + + finished_at = _now_iso() + _record_pipeline_run( + conn, + region["id"], + source_id, + started_at, + finished_at, + result["status"], + result["tracks_new"], + result["tracks_updated"], + result["errors"] or None, + ) + + logger.info( + "Done %s/%s: status=%s new=%d updated=%d errors=%d", + region["id"], + source_id, + result["status"], + result["tracks_new"], + result["tracks_updated"], + len(result["errors"]), + ) + + if result["status"] in ("error",): + has_error = True + + if args.gc: + import gc + gc.collect() + logger.info("GC collected after region %s", region["id"]) + + conn.close() + return 1 if has_error else 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/src/api/gps_tracks/__init__.py b/src/api/gps_tracks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/gps_tracks/config.py b/src/api/gps_tracks/config.py new file mode 100644 index 0000000..6fa7a5a --- /dev/null +++ b/src/api/gps_tracks/config.py @@ -0,0 +1,89 @@ +"""Загрузка и валидация конфигурации GPS-источников и регионов (ET-008).""" +import yaml + + +def load_sources_config(path: str) -> list: + """Загружает конфигурацию источников GPS-треков. + + Args: + path: путь к YAML-файлу конфигурации источников + + Returns: + list[dict] — список источников + + Raises: + ValueError: при ошибках валидации + FileNotFoundError: если файл не найден + """ + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + sources = data.get("sources", []) + if not isinstance(sources, list): + raise ValueError("sources must be a list") + + for src in sources: + if not src.get("id"): + raise ValueError(f"Source missing 'id': {src}") + if not src.get("base_url"): + raise ValueError(f"Source '{src['id']}' missing 'base_url'") + + # Enabled source must have license_adr + if src.get("enabled", False): + if not src.get("license_adr"): + raise ValueError( + f"Enabled source '{src['id']}' must have 'license_adr'" + ) + + return sources + + +def load_regions_config(path: str) -> list: + """Загружает конфигурацию регионов для сбора GPS-треков. + + Args: + path: путь к YAML-файлу конфигурации регионов + + Returns: + list[dict] — список регионов + + Raises: + ValueError: при ошибках валидации + FileNotFoundError: если файл не найден + """ + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + regions = data.get("regions", []) + if not isinstance(regions, list): + raise ValueError("regions must be a list") + + for reg in regions: + if not reg.get("id"): + raise ValueError(f"Region missing 'id': {reg}") + + bbox = reg.get("bbox") + if not bbox or len(bbox) != 4: + raise ValueError(f"Region '{reg['id']}' must have bbox with 4 values") + + west, south, east, north = bbox + + # Валидация диапазонов координат + if not (-180 <= west <= 180): + raise ValueError(f"Region '{reg['id']}' bbox west={west} out of range") + if not (-180 <= east <= 180): + raise ValueError(f"Region '{reg['id']}' bbox east={east} out of range") + if not (-90 <= south <= 90): + raise ValueError(f"Region '{reg['id']}' bbox south={south} out of range") + if not (-90 <= north <= 90): + raise ValueError(f"Region '{reg['id']}' bbox north={north} out of range") + if west >= east: + raise ValueError( + f"Region '{reg['id']}' bbox: west must be < east" + ) + if south >= north: + raise ValueError( + f"Region '{reg['id']}' bbox: south must be < north" + ) + + return regions diff --git a/src/api/gps_tracks/db.py b/src/api/gps_tracks/db.py new file mode 100644 index 0000000..d2efb17 --- /dev/null +++ b/src/api/gps_tracks/db.py @@ -0,0 +1,232 @@ +"""Функции работы с БД для GPS-треков (ET-008).""" +import json +import os +import sqlite3 +from datetime import datetime, timezone +from typing import Optional + +from src.api.gps_tracks.models import TrackInsert + + +_MIGRATION_PATH = os.path.join( + os.path.dirname(__file__), "../../../migrations/gps_tracks_001_init.sql" +) + + +def open_db(db_path: str) -> sqlite3.Connection: + """Открывает соединение с SQLite БД.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + """Применяет миграцию SQL для создания схемы.""" + migration_path = os.path.abspath(_MIGRATION_PATH) + with open(migration_path, "r", encoding="utf-8") as f: + sql = f.read() + # Выполняем каждый statement отдельно (executescript не поддерживает параметры, + # но зато не требует явного commit) + conn.executescript(sql) + conn.commit() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def upsert_track( + conn: sqlite3.Connection, + track: TrackInsert, + dedup_key: str, + source_priority: int, +) -> str: + """Вставляет или обновляет трек в БД. + + При коллизии dedup_key: + - UNION sources (без дублей) + - UNION external_urls (без дублей) + - Метаданные обновляются если новый source_priority < существующего + + Returns: + "inserted" или "updated" + """ + cur = conn.cursor() + now = _now_iso() + + # Проверяем существующую запись + cur.execute( + "SELECT id, sources_json, external_urls_json, name, description, activity_type, " + "user, created_at, source_priority FROM tracks WHERE dedup_key = ?", + (dedup_key,), + ) + existing = cur.fetchone() + + if existing is None: + # INSERT новой записи + sources = [track.source_id] + ext_urls = [track.external_url] if track.external_url else [] + + cur.execute( + """ + INSERT INTO tracks ( + dedup_key, name, description, activity_type, user, created_at, + length_m, points_count, min_lon, min_lat, max_lon, max_lat, + geom, sources_json, external_urls_json, tags_json, + inserted_at, updated_at, source_priority + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + dedup_key, + track.name, + track.description, + track.activity_type, + track.user, + track.created_at, + track.length_m, + track.points_count, + track.min_lon, + track.min_lat, + track.max_lon, + track.max_lat, + track.geom_wkb, + json.dumps(sources), + json.dumps(ext_urls), + json.dumps(track.tags) if track.tags else json.dumps([]), + now, + now, + source_priority, + ), + ) + conn.commit() + return "inserted" + else: + # UPDATE: мержим sources и external_urls + existing_sources = json.loads(existing["sources_json"] or "[]") + existing_urls = json.loads(existing["external_urls_json"] or "[]") + + # Union без дублей, сохраняя порядок + merged_sources = list(dict.fromkeys(existing_sources + [track.source_id])) + new_urls = [track.external_url] if track.external_url else [] + merged_urls = list(dict.fromkeys(existing_urls + new_urls)) + + # Получаем текущий source_priority (может отсутствовать в старых записях) + existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999 + + # Обновляем метаданные только если новый источник имеет более высокий приоритет + if source_priority < existing_priority: + cur.execute( + """ + UPDATE tracks SET + name = ?, + description = ?, + activity_type = ?, + user = ?, + created_at = ?, + sources_json = ?, + external_urls_json = ?, + updated_at = ?, + source_priority = ? + WHERE dedup_key = ? + """, + ( + track.name, + track.description, + track.activity_type, + track.user, + track.created_at, + json.dumps(merged_sources), + json.dumps(merged_urls), + now, + source_priority, + dedup_key, + ), + ) + else: + # Только обновляем sources/urls и updated_at + cur.execute( + """ + UPDATE tracks SET + sources_json = ?, + external_urls_json = ?, + updated_at = ? + WHERE dedup_key = ? + """, + ( + json.dumps(merged_sources), + json.dumps(merged_urls), + now, + dedup_key, + ), + ) + conn.commit() + return "updated" + + +def get_tracks_in_bbox( + conn: sqlite3.Connection, + west: float, + south: float, + east: float, + north: float, + activities: Optional[list] = None, + sources: Optional[list] = None, + limit: int = 500, +) -> tuple: + """Возвращает треки в указанном bbox. + + Returns: + (tracks: list[sqlite3.Row], total_count: int) + """ + cur = conn.cursor() + + # Базовое условие bbox + conditions = [ + "min_lon <= :east", + "max_lon >= :west", + "min_lat <= :north", + "max_lat >= :south", + ] + params: dict = {"west": west, "south": south, "east": east, "north": north} + + # Фильтр по activity_type + if activities: + placeholders = ",".join(f":act{i}" for i in range(len(activities))) + conditions.append(f"activity_type IN ({placeholders})") + for i, act in enumerate(activities): + params[f"act{i}"] = act + + where_clause = " AND ".join(conditions) + + # Подсчёт общего числа (без фильтра по source, он применяется постфактум) + count_sql = f"SELECT COUNT(*) as cnt FROM tracks WHERE {where_clause}" + cur.execute(count_sql, params) + total_count = cur.fetchone()["cnt"] + + # Основной запрос + select_sql = f""" + SELECT id, dedup_key, name, description, activity_type, user, + created_at, length_m, points_count, + min_lon, min_lat, max_lon, max_lat, + sources_json, external_urls_json, tags_json, + inserted_at, updated_at, geom + FROM tracks + WHERE {where_clause} + LIMIT :limit + """ + params["limit"] = limit + cur.execute(select_sql, params) + rows = cur.fetchall() + + # Постфильтрация по sources (если задан) + if sources: + filtered = [] + for row in rows: + row_sources = json.loads(row["sources_json"] or "[]") + if any(s in row_sources for s in sources): + filtered.append(row) + rows = filtered + + return rows, total_count diff --git a/src/api/gps_tracks/dedup.py b/src/api/gps_tracks/dedup.py new file mode 100644 index 0000000..5bc439d --- /dev/null +++ b/src/api/gps_tracks/dedup.py @@ -0,0 +1,32 @@ +"""Функции дедупликации GPS-треков (ET-008).""" + + +def compute_dedup_key(geom_bounds: tuple, metadata: dict) -> str: + """Вычисляет ключ дедупликации для трека. + + Args: + geom_bounds: (min_lon, min_lat, max_lon, max_lat) + metadata: dict с полями length_m и created_at + + Returns: + Строка вида "{bbox_round}|{length_bucket}|{date_bucket}" + """ + min_lon, min_lat, max_lon, max_lat = geom_bounds + + # Округление bbox до 2 знаков после запятой + bbox_round = ( + round(min_lon, 2), + round(min_lat, 2), + round(max_lon, 2), + round(max_lat, 2), + ) + + # Длина в бакетах по 1 км + length_m = metadata.get("length_m", 0) or 0 + length_bucket = round(length_m / 1000) * 1000 + + # Дата: первые 10 символов (YYYY-MM-DD) или пустая строка + created_at = metadata.get("created_at") or "" + date_bucket = created_at[:10] if created_at else "" + + return f"{bbox_round}|{length_bucket}|{date_bucket}" diff --git a/src/api/gps_tracks/endpoint.py b/src/api/gps_tracks/endpoint.py new file mode 100644 index 0000000..54a754a --- /dev/null +++ b/src/api/gps_tracks/endpoint.py @@ -0,0 +1,240 @@ +"""FastAPI router для GPS-треков (ET-008).""" +import json +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Response + +from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db +from src.api.gps_tracks.mvt import ( + build_gps_mvt, + clear_gps_tile_cache, + get_gps_cached_tile, + set_gps_cached_tile, + _tile_to_bbox, +) + + +def _parse_bbox(bbox_str: str) -> tuple: + """Парсит и валидирует bbox строку 'west,south,east,north'. + + Returns: + (west, south, east, north) + + Raises: + HTTPException 400 при невалидных значениях + """ + try: + parts = [float(v.strip()) for v in bbox_str.split(",")] + except (ValueError, AttributeError): + raise HTTPException(400, "bbox must be 4 comma-separated floats") + + if len(parts) != 4: + raise HTTPException(400, "bbox must have exactly 4 values: west,south,east,north") + + west, south, east, north = parts + + if not (-180 <= west <= 180) or not (-180 <= east <= 180): + raise HTTPException(400, "bbox longitude values must be in range -180..180") + + if not (-90 <= south <= 90) or not (-90 <= north <= 90): + raise HTTPException(400, "bbox latitude values must be in range -90..90") + + if west >= east: + raise HTTPException(400, "bbox west must be < east") + + if south >= north: + raise HTTPException(400, "bbox south must be < north") + + return west, south, east, north + + +def _row_to_geojson_feature(row) -> dict: + """Конвертирует sqlite3.Row в GeoJSON Feature.""" + from src.api.gps_tracks.mvt import _wkb_to_coords + + coords = _wkb_to_coords(row["geom"]) + + sources = json.loads(row["sources_json"] or "[]") + ext_urls = json.loads(row["external_urls_json"] or "[]") + tags = json.loads(row["tags_json"] or "[]") + + geometry = None + if coords: + geometry = {"type": "LineString", "coordinates": coords} + + return { + "type": "Feature", + "geometry": geometry, + "properties": { + "id": row["id"], + "dedup_key": row["dedup_key"], + "name": row["name"], + "description": row["description"], + "activity_type": row["activity_type"], + "user": row["user"], + "created_at": row["created_at"], + "length_m": row["length_m"], + "points_count": row["points_count"], + "sources": sources, + "external_urls": ext_urls, + "tags": tags, + "inserted_at": row["inserted_at"], + "updated_at": row["updated_at"], + }, + } + + +def create_gps_router(db_path: str) -> APIRouter: + """Создаёт FastAPI router для GPS-треков. + + Args: + db_path: путь к SQLite БД для GPS-треков + + Returns: + APIRouter с prefix="/api/gps-tracks" + """ + router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"]) + + def _get_conn(): + conn = open_db(db_path) + init_db(conn) + return conn + + @router.get("") + async def get_tracks( + bbox: str = Query(..., description="west,south,east,north"), + activity: Optional[str] = Query(None, description="Comma-separated activity types"), + source: Optional[str] = Query(None, description="Comma-separated source ids"), + limit: int = Query(500, ge=1, le=2000), + ): + """Возвращает GPS-треки в bbox как GeoJSON FeatureCollection.""" + west, south, east, north = _parse_bbox(bbox) + + activities = [a.strip() for a in activity.split(",")] if activity else None + sources = [s.strip() for s in source.split(",")] if source else None + + try: + conn = _get_conn() + rows, total_count = get_tracks_in_bbox( + conn, west, south, east, north, + activities=activities, + sources=sources, + limit=limit, + ) + conn.close() + except Exception as exc: + raise HTTPException(500, f"DB error: {exc}") + + features = [_row_to_geojson_feature(row) for row in rows] + returned = len(features) + + return { + "type": "FeatureCollection", + "features": features, + "total_in_bbox": total_count, + "returned": returned, + "truncated": total_count > returned, + } + + @router.get("/tiles/{z}/{x}/{y}.mvt") + async def get_gps_tile(z: int, x: int, y: int): + """Возвращает MVT тайл с GPS-треками.""" + if z < 0 or z > 22: + raise HTTPException(400, "Invalid z") + max_coord = 2 ** z + if x < 0 or x >= max_coord or y < 0 or y >= max_coord: + raise HTTPException(400, "Invalid x/y for zoom level") + + # Проверяем кэш + cached = get_gps_cached_tile(z, x, y) + if cached is not None: + return Response( + content=cached, + media_type="application/x-protobuf", + headers={ + "Content-Encoding": "identity", + "Access-Control-Allow-Origin": "*", + "X-Cache": "HIT", + }, + ) + + west, south, east, north = _tile_to_bbox(z, x, y) + + # Небольшой буфер для edge features + buf_x = (east - west) * 0.1 + buf_y = (north - south) * 0.1 + + try: + conn = _get_conn() + rows, _ = get_tracks_in_bbox( + conn, + west - buf_x, + south - buf_y, + east + buf_x, + north + buf_y, + limit=25000, + ) + conn.close() + except Exception as exc: + raise HTTPException(500, f"DB error: {exc}") + + mvt = build_gps_mvt(rows, z, x, y) + + if mvt: + set_gps_cached_tile(z, x, y, mvt) + + return Response( + content=mvt, + media_type="application/x-protobuf", + headers={ + "Content-Encoding": "identity", + "Access-Control-Allow-Origin": "*", + "X-Cache": "MISS", + }, + ) + + @router.get("/health") + async def gps_health(): + """Статистика GPS-треков БД.""" + try: + conn = _get_conn() + cur = conn.cursor() + + cur.execute("SELECT COUNT(*) as cnt FROM tracks") + total_tracks = cur.fetchone()["cnt"] + + cur.execute( + "SELECT activity_type, COUNT(*) as cnt FROM tracks GROUP BY activity_type" + ) + by_activity = {row["activity_type"] or "other": row["cnt"] for row in cur.fetchall()} + + cur.execute( + """ + SELECT id, started_at, finished_at, region_id, source_id, + status, tracks_new, tracks_updated + FROM pipeline_runs + ORDER BY started_at DESC + LIMIT 10 + """ + ) + recent_runs = [dict(row) for row in cur.fetchall()] + + conn.close() + except Exception as exc: + raise HTTPException(500, f"DB error: {exc}") + + return { + "status": "ok", + "db_path": db_path, + "total_tracks": total_tracks, + "by_activity": by_activity, + "recent_pipeline_runs": recent_runs, + } + + @router.post("/cache/clear") + async def clear_cache(): + """Сбрасывает LRU-кэш GPS-тайлов.""" + clear_gps_tile_cache() + return {"status": "ok", "cleared": True} + + return router diff --git a/src/api/gps_tracks/models.py b/src/api/gps_tracks/models.py new file mode 100644 index 0000000..0a1d87c --- /dev/null +++ b/src/api/gps_tracks/models.py @@ -0,0 +1,52 @@ +"""Pydantic-модели и константы для публичных GPS-треков (ET-008).""" +from pydantic import BaseModel +from typing import Optional, List + +ACTIVITY_TYPES = [ + "enduro", "moto", "offroad", "bicycle", "hike", "ski", "other" +] + + +class TrackRecord(BaseModel): + """Трек из БД, готовый к отдаче через API.""" + + id: int + dedup_key: str + name: Optional[str] = None + description: Optional[str] = None + activity_type: Optional[str] = "other" + user: Optional[str] = None + created_at: Optional[str] = None + length_m: float + points_count: int + min_lon: float + min_lat: float + max_lon: float + max_lat: float + sources: List[str] + external_urls: List[str] + tags: List[str] + inserted_at: str + updated_at: str + + +class TrackInsert(BaseModel): + """Трек для вставки в БД (из парсера).""" + + external_id: str + source_id: str + external_url: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + activity_type: str = "other" + user: Optional[str] = None + created_at: Optional[str] = None + length_m: float + points_count: int + geom_wkb: bytes # WKB bytes + min_lon: float + min_lat: float + max_lon: float + max_lat: float + tags: List[str] = [] + source_priority: int = 999 diff --git a/src/api/gps_tracks/mvt.py b/src/api/gps_tracks/mvt.py new file mode 100644 index 0000000..f2b7c00 --- /dev/null +++ b/src/api/gps_tracks/mvt.py @@ -0,0 +1,167 @@ +"""MVT-тайлы для GPS-треков (ET-008).""" +import json +import math +import struct +from typing import Optional + +from shapely.geometry import LineString + + +# ─── LRU-like tile cache ───────────────────────────────────────────────────── + +_gps_tile_cache: dict = {} +_GPS_TILE_CACHE_MAX = 1024 + + +def get_gps_cached_tile(z: int, x: int, y: int) -> Optional[bytes]: + return _gps_tile_cache.get((z, x, y)) + + +def set_gps_cached_tile(z: int, x: int, y: int, data: bytes) -> None: + if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX: + # FIFO вытеснение + _gps_tile_cache.pop(next(iter(_gps_tile_cache))) + _gps_tile_cache[(z, x, y)] = data + + +def clear_gps_tile_cache() -> None: + _gps_tile_cache.clear() + + +# ─── Geometry helpers ──────────────────────────────────────────────────────── + +def _simplify_coords(coords: list, z: int) -> list: + """Упрощает геометрию трека по зуму через Douglas-Peucker.""" + if z >= 12: + return coords + elif z >= 10: + tolerance = 0.0005 # ~50м + elif z >= 8: + tolerance = 0.002 # ~200м + else: + tolerance = 0.008 # ~800м на z7 и ниже + + if len(coords) < 3: + return coords + + line = LineString(coords) + simplified = line.simplify(tolerance, preserve_topology=False) + result = list(simplified.coords) + return result if len(result) >= 2 else coords + + +def _wkb_to_coords(blob: bytes) -> Optional[list]: + """Парсит WKB LineString, возвращает [(lon, lat), ...].""" + try: + b = bytes(blob) + if len(b) < 9: + return None + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + base_type = gtype & 0xFF + if base_type != 2: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 + npts = struct.unpack_from(endian + "I", b, offset)[0] + offset += 4 + coords = [] + for _ in range(npts): + lon, lat = struct.unpack_from(endian + "dd", b, offset) + offset += 16 + coords.append((lon, lat)) + return coords if len(coords) >= 2 else None + except Exception: + return None + + +def _tile_to_bbox(z: int, x: int, y: int) -> tuple: + n = 2 ** z + west = x / n * 360.0 - 180.0 + east = (x + 1) / n * 360.0 - 180.0 + north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) + south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) + return west, south, east, north + + +# ─── MVT builder ───────────────────────────────────────────────────────────── + +def build_gps_mvt(rows: list, z: int, x: int, y: int) -> bytes: + """Собирает MVT тайл с layer 'gps_tracks'. + + Args: + rows: список sqlite3.Row из таблицы tracks + z, x, y: координаты тайла + + Returns: + bytes — protobuf MVT или b"" если нет фич + """ + import mapbox_vector_tile + + west, south, east, north = _tile_to_bbox(z, x, y) + + # Min-length фильтр по зуму + if z <= 7: + min_length_m = 2000 + limit = 3000 + elif z <= 9: + min_length_m = 0 + limit = 8000 + elif z <= 11: + min_length_m = 0 + limit = 15000 + else: + min_length_m = 0 + limit = 25000 + + features = [] + for row in rows: + length_m = row["length_m"] or 0 + + # Min-length фильтр + if min_length_m > 0 and length_m < min_length_m: + continue + + if len(features) >= limit: + break + + coords = _wkb_to_coords(row["geom"]) + if not coords: + continue + + coords = _simplify_coords(coords, z) + + try: + sources_list = json.loads(row["sources_json"] or "[]") + sources_str = ",".join(sources_list) + first_source = sources_list[0] if sources_list else "" + + ext_urls = json.loads(row["external_urls_json"] or "[]") + ext_url = ext_urls[0] if ext_urls else "" + + props = { + "id": row["id"], + "activity": row["activity_type"] or "other", + "source": first_source, + "sources": sources_str, + "length_km": round(length_m / 1000, 2), + "name": row["name"] or "", + "ext_url": ext_url, + } + features.append({ + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": props, + }) + except Exception: + continue + + if not features: + return b"" + + return mapbox_vector_tile.encode( + [{"name": "gps_tracks", "features": features}], + quantize_bounds=(west, south, east, north), + extents=4096, + default_options={"y_coord_down": False}, + ) diff --git a/src/api/gps_tracks/sources/__init__.py b/src/api/gps_tracks/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/gps_tracks/sources/base.py b/src/api/gps_tracks/sources/base.py new file mode 100644 index 0000000..894aba5 --- /dev/null +++ b/src/api/gps_tracks/sources/base.py @@ -0,0 +1,34 @@ +"""Базовый класс для парсеров GPS-источников (ET-008).""" +from src.api.gps_tracks.models import ACTIVITY_TYPES + + +class SourceParser: + """Базовый класс для всех парсеров GPS-источников.""" + + MAPPING: dict = {} # source-category → ACTIVITY_TYPE + + def __init__(self, source_config: dict): + self.config = source_config + + def map_activity(self, raw_category: str) -> str: + """Маппит категорию источника в ACTIVITY_TYPES enum.""" + if not raw_category: + return "other" + mapped = self.MAPPING.get(raw_category.lower(), "other") + if mapped not in ACTIVITY_TYPES: + return "other" + return mapped + + async def collect(self, bbox: tuple, ctx: dict): + """Асинхронный генератор треков. Реализуется в наследниках. + + Args: + bbox: (west, south, east, north) + ctx: контекст выполнения (db conn, logger, etc.) + + Yields: + TrackInsert объекты + """ + raise NotImplementedError + return + yield # make it a generator diff --git a/src/api/gps_tracks/sources/enduro_russia.py b/src/api/gps_tracks/sources/enduro_russia.py new file mode 100644 index 0000000..3738c24 --- /dev/null +++ b/src/api/gps_tracks/sources/enduro_russia.py @@ -0,0 +1,17 @@ +"""Парсер EnduroRussia.ru — заглушка (ADR-010 status=proposed).""" +from src.api.gps_tracks.sources.base import SourceParser + + +class EnduroRussiaParser(SourceParser): + """Парсер EnduroRussia.ru. + + Заблокирован до получения лицензии. См. ADR-010. + """ + + MAPPING = {"enduro": "enduro", "мото": "moto"} + + async def collect(self, bbox, ctx): + # ADR-010: blocked, status=proposed + raise NotImplementedError("EnduroRussia parser not yet licensed (ADR-010)") + return + yield # make it a generator diff --git a/src/api/gps_tracks/sources/osm.py b/src/api/gps_tracks/sources/osm.py new file mode 100644 index 0000000..7a7da83 --- /dev/null +++ b/src/api/gps_tracks/sources/osm.py @@ -0,0 +1,309 @@ +"""Парсер OSM Public GPS Traces (ET-008).""" +import asyncio +import math +import logging +from typing import AsyncGenerator + +import defusedxml.ElementTree as ET +import httpx + +from src.api.gps_tracks.models import TrackInsert +from src.api.gps_tracks.sources.base import SourceParser + +logger = logging.getLogger(__name__) + +# Пространства имён GPX +_GPX_NS = { + "gpx0": "http://www.topografix.com/GPX/1/0", + "gpx1": "http://www.topografix.com/GPX/1/1", +} + + +class OsmParser(SourceParser): + """Парсер OSM Public GPS Traces API.""" + + MAPPING = { + "enduro": "enduro", + "moto": "moto", + "motorcycle": "moto", + "mtb": "bicycle", + "bicycle": "bicycle", + "bike": "bicycle", + "hike": "hike", + "hiking": "hike", + "running": "hike", + "ski": "ski", + "skiing": "ski", + "offroad": "offroad", + "4x4": "offroad", + } + + async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]: + """Собирает треки из OSM Public GPS Traces API. + + Args: + bbox: (west, south, east, north) + ctx: контекст (может содержать 'dry_run', 'session') + + Yields: + TrackInsert объекты + """ + west, south, east, north = bbox + rate_limit = self.config.get("rate_limit_sec", 1) + base_url = self.config.get("base_url", "https://api.openstreetmap.org/api/0.6") + user_agent = self.config.get("user_agent", "enduro-trails/1.0") + source_id = self.config.get("id", "osm") + ext_url_template = self.config.get("external_url_template", "") + + headers = {"User-Agent": user_agent} + + # Разбиваем bbox на ячейки 0.25° + cells = split_bbox_for_osm((west, south, east, north)) + + async with httpx.AsyncClient(timeout=30, headers=headers) as client: + for cell_bbox in cells: + cell_west, cell_south, cell_east, cell_north = cell_bbox + page = 0 + while True: + url = ( + f"{base_url}/trackpoints" + f"?bbox={cell_west},{cell_south},{cell_east},{cell_north}" + f"&page={page}" + ) + try: + resp = await _fetch_with_backoff(client, url) + if resp is None: + break + if resp.status_code == 204: + break + if resp.status_code != 200: + logger.warning("OSM API returned %d for %s", resp.status_code, url) + break + content = resp.content + except Exception as exc: + logger.error("Error fetching %s: %s", url, exc) + break + + # Парсим GPX ответ + tracks = _parse_gpx_trackpoints(content, source_id, ext_url_template) + + if not tracks: + break # Пустая страница — больше треков нет + + for track in tracks: + yield track + + page += 1 + await asyncio.sleep(rate_limit) + + +def split_bbox_for_osm(region_bbox: tuple, cell_size: float = 0.25) -> list: + """Разбивает регион на ячейки cell_size градусов для OSM API. + + OSM API требует bbox не более 0.25° x 0.25°. + + Args: + region_bbox: (west, south, east, north) + cell_size: размер ячейки в градусах (по умолчанию 0.25) + + Returns: + list of (west, south, east, north) tuples + """ + west, south, east, north = region_bbox + cells = [] + + # Перебираем ячейки с запада на восток, с юга на север + lat = south + while lat < north: + cell_south = lat + cell_north = min(lat + cell_size, north) + lon = west + while lon < east: + cell_west = lon + cell_east = min(lon + cell_size, east) + cells.append(( + round(cell_west, 6), + round(cell_south, 6), + round(cell_east, 6), + round(cell_north, 6), + )) + lon += cell_size + lat += cell_size + + return cells + + +def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float: + """Расстояние между двумя точками в метрах.""" + R = 6371000 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _calc_track_length(coords: list) -> float: + """Считает длину трека через Haversine.""" + total = 0.0 + for i in range(len(coords) - 1): + total += _haversine_m(coords[i][0], coords[i][1], coords[i + 1][0], coords[i + 1][1]) + return total + + +def _parse_gpx_trackpoints(content: bytes, source_id: str, ext_url_template: str) -> list: + """Парсит GPX-ответ OSM API с треками. + + Группирует trkpt по атрибуту gpx_id. + Анонимные точки (без gpx_id) пропускаются. + + Returns: + list[TrackInsert] + """ + try: + # defusedxml защищает от XXE + root = ET.fromstring(content) + except Exception as exc: + logger.error("Failed to parse GPX: %s", exc) + return [] + + # Группируем точки по gpx_id + tracks_points: dict = {} + + # Определяем namespace + ns = "" + tag = root.tag + if tag.startswith("{"): + ns = tag.split("}")[0] + "}" + + # Ищем trkpt напрямую и через trk/trkseg + trkpt_elements = [] + + # Вариант 1: OSM возвращает trkpt напрямую в корне (API 0.6 trackpoints endpoint) + for child in root: + local = child.tag.replace(ns, "") if ns else child.tag + if local == "trk": + for trkseg in child: + local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag + if local2 == "trkseg": + for trkpt in trkseg: + trkpt_elements.append(trkpt) + elif local == "trkpt": + trkpt_elements.append(child) + + for trkpt in trkpt_elements: + gpx_id = trkpt.get("gpx_id") or trkpt.get("{http://www.topografix.com/GPX/1/0}gpx_id") + if not gpx_id: + # Анонимные точки — пропускаем + continue + + try: + lat = float(trkpt.get("lat", 0)) + lon = float(trkpt.get("lon", 0)) + except (TypeError, ValueError): + continue + + if gpx_id not in tracks_points: + tracks_points[gpx_id] = [] + + # Получаем время из дочернего элемента + time_elem = None + for child in trkpt: + local = child.tag.replace(ns, "") if ns else child.tag + if local == "time": + time_elem = child + break + + time_str = time_elem.text if time_elem is not None else None + tracks_points[gpx_id].append((lon, lat, time_str)) + + results = [] + for gpx_id, points in tracks_points.items(): + if len(points) < 2: + continue + + coords = [(p[0], p[1]) for p in points] + + # Вычисляем bbox + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + min_lon, max_lon = min(lons), max(lons) + min_lat, max_lat = min(lats), max(lats) + + # Длина трека + length_m = _calc_track_length(coords) + if length_m < 10: # Слишком короткий трек — пропускаем + continue + + # Дата из первой точки с временем + created_at = None + for p in points: + if p[2]: + created_at = p[2][:19].replace("T", "T") # ISO без миллисекунд + break + + # WKB из shapely + try: + from shapely.geometry import LineString + from shapely import wkb + + geom = LineString(coords) + geom_wkb = wkb.dumps(geom) + except Exception: + continue + + # External URL + ext_url = None + if ext_url_template: + ext_url = ext_url_template.format( + user="", + external_id_numeric=gpx_id, + ) + + track = TrackInsert( + external_id=str(gpx_id), + source_id=source_id, + external_url=ext_url, + name=None, + description=None, + activity_type="other", + user=None, + created_at=created_at, + length_m=length_m, + points_count=len(coords), + geom_wkb=geom_wkb, + min_lon=min_lon, + min_lat=min_lat, + max_lon=max_lon, + max_lat=max_lat, + tags=[], + source_priority=50, + ) + results.append(track) + + return results + + +async def _fetch_with_backoff( + client: httpx.AsyncClient, + url: str, + max_retries: int = 3, +) -> httpx.Response | None: + """Выполняет HTTP-запрос с экспоненциальным backoff.""" + for attempt in range(max_retries): + try: + resp = await client.get(url) + if resp.status_code == 429: + wait = 2 ** attempt * 2 + logger.warning("Rate limited, waiting %ds", wait) + await asyncio.sleep(wait) + continue + return resp + except httpx.TimeoutException: + wait = 2 ** attempt + logger.warning("Timeout on attempt %d, waiting %ds", attempt + 1, wait) + await asyncio.sleep(wait) + except Exception as exc: + logger.error("Request failed: %s", exc) + return None + return None diff --git a/src/api/gps_tracks/sources/ttrails.py b/src/api/gps_tracks/sources/ttrails.py new file mode 100644 index 0000000..52ab833 --- /dev/null +++ b/src/api/gps_tracks/sources/ttrails.py @@ -0,0 +1,17 @@ +"""Парсер Тропинки.ру — заглушка (ADR-011 status=proposed).""" +from src.api.gps_tracks.sources.base import SourceParser + + +class TtrailsParser(SourceParser): + """Парсер Тропинки.ру. + + Заблокирован до получения лицензии. См. ADR-011. + """ + + MAPPING = {"велосипед": "bicycle", "пешком": "hike", "мото": "moto"} + + async def collect(self, bbox, ctx): + # ADR-011: blocked, status=proposed + raise NotImplementedError("Ttrails parser not yet licensed (ADR-011)") + return + yield # make it a generator diff --git a/src/api/main.py b/src/api/main.py index f2149fe..27a7640 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -14,6 +14,11 @@ import sqlite3 import itertools +GPS_TRACKS_DB_PATH = os.environ.get( + "GPS_TRACKS_DB_PATH", + os.path.join(os.path.dirname(__file__), "../../data/gps_tracks.sqlite"), +) + from shapely.geometry import LineString from typing import List @@ -1246,6 +1251,10 @@ async def terrain_tile(layer: str, z: int, x: int, y: int): # ─── Static files ───────────────────────────────────────────────────────────── +from src.api.gps_tracks.endpoint import create_gps_router +gps_router = create_gps_router(GPS_TRACKS_DB_PATH) +app.include_router(gps_router) + if os.path.exists(STATIC_DIR): app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/src/api/requirements.txt b/src/api/requirements.txt index 3ea81d2..b864884 100644 --- a/src/api/requirements.txt +++ b/src/api/requirements.txt @@ -3,3 +3,6 @@ uvicorn==0.29.0 shapely==2.0.4 mapbox-vector-tile==2.2.0 httpx==0.27.0 +defusedxml==0.7.1 +lxml==5.2.2 +pyyaml==6.0.1 diff --git a/src/web/app.css b/src/web/app.css index 3216e1f..55c6759 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -1227,3 +1227,76 @@ body.satellite-active #btn-basemap { border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.5); } + +/* ─── ET-008: GPS-треки ──────────────────────────── */ +.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; +} + +.gps-filter-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-bottom: 12px; +} + +.gps-filter-chip { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; + color: var(--text); +} + +.gps-filter-chip input[type=checkbox] { + accent-color: var(--accent, #ff8c1a); + width: 14px; + height: 14px; +} + +.gps-stats-row { + font-size: 12px; + color: var(--text2); + margin-top: 8px; +} + +/* Track popup */ +.track-popup { + font-size: 13px; + color: var(--text, #fff); + min-width: 220px; +} + +.track-popup-name { + font-weight: 700; + font-size: 14px; + margin-bottom: 6px; +} + +.track-popup-row { + margin: 3px 0; + color: var(--text2, #ccc); +} + +.track-popup-sources { + margin-top: 8px; + font-size: 12px; +} + +.track-popup-sources a { + color: var(--accent, #ff8c1a); + text-decoration: none; +} + +.track-popup-sources a:hover { + text-decoration: underline; +} diff --git a/src/web/app.js b/src/web/app.js index 7a51033..6158a64 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -134,6 +134,10 @@ function rebuildMapOverlays() { restoreTerrainState(); restoreTrailsState(); restorePoiState(); + // ET-008: публичные GPS-треки + if (typeof restorePublicTracksState === 'function') { + restorePublicTracksState(); + } // Re-apply recon circle if active if (reconMode && reconCenter) { @@ -3041,6 +3045,10 @@ function applyBaseLayer(base) { // ET-007 P1-6: halo синхронизирован с состоянием чекбоксов // «Грунтовки» / «Тропы», а не безусловно включён. _applyTrailHaloVisibility(map, 'satellite'); + // ET-008: halo публичных треков на спутнике + if (typeof applyGpsHaloVisibility === 'function') { + applyGpsHaloVisibility(map); + } _applyPoiSatellitePaint(map, true); _applyBackgroundForSatellite(map, true); } else { @@ -3057,6 +3065,10 @@ function applyBaseLayer(base) { _setBodyClass('satellite-active', false); // На «Схеме» halo всегда скрыт независимо от чекбоксов. _applyTrailHaloVisibility(map, 'schematic'); + // ET-008: halo публичных треков выключить + if (typeof applyGpsHaloVisibility === 'function') { + applyGpsHaloVisibility(map); + } _applyPoiSatellitePaint(map, false); _applyBackgroundForSatellite(map, false); } diff --git a/src/web/gps_tracks.js b/src/web/gps_tracks.js new file mode 100644 index 0000000..7e801e3 --- /dev/null +++ b/src/web/gps_tracks.js @@ -0,0 +1,573 @@ +// ═══════════════════════════════════════════════════════════════════ +// gps_tracks.js — ET-008: Публичные GPS-треки +// ═══════════════════════════════════════════════════════════════════ + +// ─── Константы ──────────────────────────────────────────────────── + +const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON +const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт + +const GPS_SOURCE_COLORS = { + osm: '#3cb44b', + enduro_russia: '#e6194b', + ttrails: '#4363d8', + offmaps: '#f58231', + nakarte: '#911eb4', +}; +const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8']; + +const GPS_ACTIVITY_COLORS = { + enduro: '#e6194b', + moto: '#f58231', + offroad: '#ffe119', + bicycle: '#3cb44b', + hike: '#4363d8', + ski: '#42d4f4', + other: '#808080', +}; + +const GPS_ACTIVITY_ICONS = { + enduro: '🏍', + moto: '🛵', + offroad: '🚙', + bicycle: '🚵', + hike: '🥾', + ski: '⛷️', + other: '📍', +}; + +const GPS_ACTIVITY_LABELS = { + enduro: 'Эндуро', + moto: 'Мото', + offroad: 'Off-road', + bicycle: 'Велосипед', + hike: 'Пешком', + ski: 'Лыжи', + other: 'Другое', +}; + +// ─── Состояние ─────────────────────────────────────────────────── + +window.gpsTracksLayer = { + enabled: false, + filters: { + activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'], + sources: ['osm', 'enduro_russia', 'ttrails'], + colorMode: 'source' + }, + sourceId: 'gps-tracks-tiles', + sourceGeoId: 'gps-tracks-geo', + layerId: 'gps-tracks-layer-mvt', + layerGeoId: 'gps-tracks-layer-geo', + layerHaloId: 'gps-tracks-halo-mvt-satellite', + layerHaloGeoId: 'gps-tracks-halo-geo-satellite', + geojsonAbortController: null, + geojsonReqDebounceTimer: null, + stats: { total: 0, shown: 0 } +}; + +// ─── Цветовые выражения MapLibre ────────────────────────────────── + +function _buildColorExpression(mode) { + if (mode === 'activity') { + const expr = ['match', ['get', 'activity']]; + for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) { + expr.push(act, color); + } + expr.push('#808080'); // fallback + return expr; + } else { + // по источнику + const expr = ['match', ['get', 'source']]; + for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) { + expr.push(src, color); + } + expr.push('#808080'); // fallback + return expr; + } +} + +// ─── Layer definitions ──────────────────────────────────────────── + +function _gpsLayerDef(id, source, sourceLayer) { + const colorExpr = _buildColorExpression(window.gpsTracksLayer.filters.colorMode); + return { + id, + type: 'line', + source, + 'source-layer': sourceLayer || undefined, + paint: { + 'line-color': colorExpr, + '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' } + }; +} + +function _gpsHaloDef(id, source, sourceLayer) { + return { + id, + type: 'line', + source, + 'source-layer': sourceLayer || undefined, + 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' } + }; +} + +// ─── Создание/удаление sources и layers ────────────────────────── + +function _ensureGpsSources(map) { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + + if (!map.getSource(window.gpsTracksLayer.sourceId)) { + map.addSource(window.gpsTracksLayer.sourceId, { + type: 'vector', + tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`], + minzoom: GPS_TRACKS_MIN_ZOOM, + maxzoom: 11, + attribution: '© OpenStreetMap contributors (ODbL)', + }); + } + + if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) { + map.addSource(window.gpsTracksLayer.sourceGeoId, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }); + } +} + +function _ensureGpsLayers(map) { + if (!map.getLayer(window.gpsTracksLayer.layerId)) { + const def = _gpsLayerDef( + window.gpsTracksLayer.layerId, + window.gpsTracksLayer.sourceId, + 'gps_tracks' + ); + // Добавить поверх trails, ниже route (если есть) + const before = _findGpsInsertPosition(map); + map.addLayer(def, before); + } + + if (!map.getLayer(window.gpsTracksLayer.layerGeoId)) { + const def = _gpsLayerDef( + window.gpsTracksLayer.layerGeoId, + window.gpsTracksLayer.sourceGeoId, + null + ); + delete def['source-layer']; + const before = _findGpsInsertPosition(map); + map.addLayer(def, before); + } + + if (!map.getLayer(window.gpsTracksLayer.layerHaloId)) { + const def = _gpsHaloDef( + window.gpsTracksLayer.layerHaloId, + window.gpsTracksLayer.sourceId, + 'gps_tracks' + ); + const before = window.gpsTracksLayer.layerId; + map.addLayer(def, before); + } + + if (!map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { + const def = _gpsHaloDef( + window.gpsTracksLayer.layerHaloGeoId, + window.gpsTracksLayer.sourceGeoId, + null + ); + delete def['source-layer']; + const before = window.gpsTracksLayer.layerGeoId; + map.addLayer(def, before); + } +} + +function _findGpsInsertPosition(map) { + const style = map.getStyle && map.getStyle(); + if (!style || !style.layers) return undefined; + const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-')); + return routeLayer ? routeLayer.id : undefined; +} + +// ─── Управление видимостью ──────────────────────────────────────── + +function _syncGpsLayersVisibility(map) { + const enabled = window.gpsTracksLayer.enabled; + const zoom = map.getZoom ? map.getZoom() : 0; + const mvtVisible = enabled && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF; + const geoVisible = enabled && zoom >= GPS_TRACKS_ZOOM_CUTOFF; + + const setVis = (layerId, visible) => { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none'); + } + }; + + setVis(window.gpsTracksLayer.layerId, mvtVisible); + setVis(window.gpsTracksLayer.layerGeoId, geoVisible); + + // Hint «Зум 8+» + const hint = document.getElementById('public-tracks-zoom-hint'); + if (hint) { + hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none'; + } + + // Halo обновляется через applyGpsHaloVisibility + applyGpsHaloVisibility(map); +} + +// ─── Halo ────────────────────────────────────────────────────────── + +function applyGpsHaloVisibility(map) { + if (!map) return; + const zoom = map.getZoom ? map.getZoom() : 0; + const isSatellite = document.body.classList.contains('satellite-active'); + const enabled = window.gpsTracksLayer.enabled; + + const mvtHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF; + const geoHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_ZOOM_CUTOFF; + + if (map.getLayer(window.gpsTracksLayer.layerHaloId)) { + map.setLayoutProperty(window.gpsTracksLayer.layerHaloId, 'visibility', mvtHaloOn ? 'visible' : 'none'); + } + if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { + map.setLayoutProperty(window.gpsTracksLayer.layerHaloGeoId, 'visibility', geoHaloOn ? 'visible' : 'none'); + } +} + +// ─── Фильтрация ─────────────────────────────────────────────────── + +function applyGpsFilter() { + const map = window._map; + if (!map) return; + const { activities, sources } = window.gpsTracksLayer.filters; + const filter = ['all', + ['in', ['get', 'activity'], ['literal', activities]], + ['in', ['get', 'source'], ['literal', sources]] + ]; + if (map.getLayer(window.gpsTracksLayer.layerId)) { + map.setFilter(window.gpsTracksLayer.layerId, filter); + } + if (map.getLayer(window.gpsTracksLayer.layerGeoId)) { + map.setFilter(window.gpsTracksLayer.layerGeoId, filter); + } + if (map.getLayer(window.gpsTracksLayer.layerHaloId)) { + map.setFilter(window.gpsTracksLayer.layerHaloId, filter); + } + if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { + map.setFilter(window.gpsTracksLayer.layerHaloGeoId, filter); + } + _updateGpsStatsUI(); +} + +// ─── GeoJSON загрузка ───────────────────────────────────────────── + +function onGpsMapMoveEnd() { + const map = window._map; + if (!map || !window.gpsTracksLayer.enabled) return; + if (map.getZoom() < GPS_TRACKS_ZOOM_CUTOFF) return; + + clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer); + window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => { + fetchAndUpdateGpsGeoJson(map.getBounds()); + }, 500); +} + +async function fetchAndUpdateGpsGeoJson(bounds) { + const map = window._map; + if (!map) return; + + if (window.gpsTracksLayer.geojsonAbortController) { + window.gpsTracksLayer.geojsonAbortController.abort(); + } + const ctrl = new AbortController(); + window.gpsTracksLayer.geojsonAbortController = ctrl; + + const { activities, sources } = window.gpsTracksLayer.filters; + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`; + const url = `${basePath}/api/gps-tracks?bbox=${bbox}&activity=${activities.join(',')}&source=${sources.join(',')}&limit=500`; + + try { + const resp = await fetch(url, { signal: ctrl.signal }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const json = await resp.json(); + if (map.getSource(window.gpsTracksLayer.sourceGeoId)) { + map.getSource(window.gpsTracksLayer.sourceGeoId).setData(json); + } + window.gpsTracksLayer.stats = { total: json.total_in_bbox || 0, shown: json.returned || 0 }; + if (json.truncated) { + // показываем toast один раз + if (typeof showToast === 'function') { + showToast(`Показаны ${json.returned} треков из ${json.total_in_bbox}. Увеличьте zoom для полной выборки`); + } + } + _updateGpsStatsUI(); + } catch (e) { + if (e.name === 'AbortError') return; + if (typeof showToast === 'function') showToast('Не удалось загрузить треки'); + } +} + +// ─── Popup при клике ────────────────────────────────────────────── + +function _renderTrackPopupHtml(props) { + const name = props.name || 'Без названия'; + const activity = props.activity_type || props.activity || 'other'; + const icon = GPS_ACTIVITY_ICONS[activity] || '📍'; + const actLabel = GPS_ACTIVITY_LABELS[activity] || activity; + const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—'; + const points = props.points_count || '—'; + const dateStr = props.created_at ? new Date(props.created_at).toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'}) : null; + const user = props.user || null; + + let sourcesHtml = ''; + try { + let srcs = props.sources; + let urls = props.external_urls; + if (typeof srcs === 'string') srcs = srcs.split(',').filter(Boolean); + if (typeof urls === 'string') urls = urls.split(',').filter(Boolean); + if (Array.isArray(srcs) && srcs.length) { + sourcesHtml = '
Источники: ' + + srcs.map((s, i) => { + const url = Array.isArray(urls) && urls[i] ? urls[i] : null; + const label = s; + return url + ? `${label} ↗` + : `${label}`; + }).join(' · ') + '
'; + } + } catch(e) {} + + return ` +
+
${name}
+
${icon} ${actLabel}
+
📏 ${lengthKm} км · ${points} точек
+ ${dateStr ? `
📅 ${dateStr}
` : ''} + ${user ? `
👤 ${user}
` : ''} + ${sourcesHtml} +
+ `; +} + +function _setupGpsClickHandler(map) { + const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId]; + + layerIds.forEach(layerId => { + map.on('click', layerId, (e) => { + // Не открывать popup если активен другой режим + if (window._routeMode || window._reconMode || window._scenicMode || window._rulerMode) return; + + const feature = e.features && e.features[0]; + if (!feature) return; + + new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' }) + .setLngLat(e.lngLat) + .setHTML(_renderTrackPopupHtml(feature.properties)) + .addTo(map); + }); + + map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; }); + }); +} + +// ─── Включение/выключение слоя ──────────────────────────────────── + +function onPublicTracksCheckbox() { + const cb = document.getElementById('public-tracks-cb'); + const filterBtn = document.getElementById('public-tracks-filters-btn'); + if (!cb) return; + + window.gpsTracksLayer.enabled = cb.checked; + localStorage.setItem('gps-tracks-enabled', cb.checked ? 'true' : 'false'); + + const map = window._map; + if (!map) return; + + if (cb.checked) { + _ensureGpsSources(map); + _ensureGpsLayers(map); + _setupGpsClickHandler(map); + + // Убедиться, что moveend listener есть + map.off('moveend', onGpsMapMoveEnd); + map.on('moveend', onGpsMapMoveEnd); + map.off('zoomend', onGpsZoomEnd); + map.on('zoomend', onGpsZoomEnd); + } + + _syncGpsLayersVisibility(map); + applyGpsFilter(); + + // Фильтры btn + if (filterBtn) filterBtn.style.display = cb.checked ? 'block' : 'none'; + + // Если включили и zoom >= 12 — загрузить GeoJSON + if (cb.checked && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { + fetchAndUpdateGpsGeoJson(map.getBounds()); + } +} + +function onGpsZoomEnd() { + const map = window._map; + if (!map) return; + _syncGpsLayersVisibility(map); + // При переходе на z>=12 загрузить GeoJSON + if (window.gpsTracksLayer.enabled && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { + clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer); + window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => { + fetchAndUpdateGpsGeoJson(map.getBounds()); + }, 500); + } +} + +// ─── Sheet фильтров ─────────────────────────────────────────────── + +function togglePublicTracksFiltersSheet() { + const sheet = document.getElementById('sheet-gps-filters'); + if (!sheet) return; + const isOpen = sheet.classList.contains('open'); + if (!isOpen) { + _buildGpsFiltersUI(); + openSheet('sheet-gps-filters'); + } else { + closeAllSheets(); + } +} + +function _buildGpsFiltersUI() { + // Активности + const actGrid = document.getElementById('gps-activity-grid'); + if (actGrid) { + const all = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']; + actGrid.innerHTML = all.map(act => { + const checked = window.gpsTracksLayer.filters.activities.includes(act); + return ` + `; + }).join(''); + } + + // Источники (из localStorage или дефолт) + const srcGrid = document.getElementById('gps-source-grid'); + if (srcGrid) { + const allSources = ['osm', 'enduro_russia', 'ttrails']; + const sourceLabels = { osm: 'OSM', enduro_russia: 'EnduroRussia.ru', ttrails: 'Тропинки.ру' }; + srcGrid.innerHTML = allSources.map(src => { + const checked = window.gpsTracksLayer.filters.sources.includes(src); + return ` + `; + }).join(''); + } + + // Color mode + const colorMode = window.gpsTracksLayer.filters.colorMode; + const btnSrc = document.getElementById('gps-color-by-source'); + const btnAct = document.getElementById('gps-color-by-activity'); + if (btnSrc) btnSrc.classList.toggle('active', colorMode === 'source'); + if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity'); + + _updateGpsStatsUI(); +} + +function onGpsActivityFilterChange() { + const checked = []; + document.querySelectorAll('#gps-activity-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value)); + window.gpsTracksLayer.filters.activities = checked; + localStorage.setItem('gps-tracks-activities', JSON.stringify(checked)); + applyGpsFilter(); +} + +function onGpsSourceFilterChange() { + const checked = []; + document.querySelectorAll('#gps-source-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value)); + window.gpsTracksLayer.filters.sources = checked; + localStorage.setItem('gps-tracks-sources', JSON.stringify(checked)); + applyGpsFilter(); +} + +function onGpsColorModeChange(mode) { + window.gpsTracksLayer.filters.colorMode = mode; + localStorage.setItem('gps-tracks-color-mode', mode); + + const btnSrc = document.getElementById('gps-color-by-source'); + const btnAct = document.getElementById('gps-color-by-activity'); + if (btnSrc) btnSrc.classList.toggle('active', mode === 'source'); + if (btnAct) btnAct.classList.toggle('active', mode === 'activity'); + + // Перестроить color expression + const map = window._map; + if (!map) return; + const colorExpr = _buildColorExpression(mode); + [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId].forEach(layerId => { + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, 'line-color', colorExpr); + } + }); +} + +function _updateGpsStatsUI() { + const totalEl = document.getElementById('gps-stat-total'); + const shownEl = document.getElementById('gps-stat-shown'); + if (totalEl) totalEl.textContent = window.gpsTracksLayer.stats.total || '—'; + if (shownEl) shownEl.textContent = window.gpsTracksLayer.stats.shown || '—'; +} + +// ─── restorePublicTracksState ────────────────────────────────────── +/** + * Восстанавливает состояние слоя публичных треков из localStorage. + * Вызывается из rebuildMapOverlays() в app.js. + */ +function restorePublicTracksState() { + const enabled = localStorage.getItem('gps-tracks-enabled') === 'true'; + const cb = document.getElementById('public-tracks-cb'); + const filterBtn = document.getElementById('public-tracks-filters-btn'); + + const activitiesRaw = localStorage.getItem('gps-tracks-activities'); + if (activitiesRaw) { + try { window.gpsTracksLayer.filters.activities = JSON.parse(activitiesRaw); } catch(e) {} + } + + const sourcesRaw = localStorage.getItem('gps-tracks-sources'); + if (sourcesRaw) { + try { window.gpsTracksLayer.filters.sources = JSON.parse(sourcesRaw); } catch(e) {} + } + + const colorMode = localStorage.getItem('gps-tracks-color-mode') || 'source'; + window.gpsTracksLayer.filters.colorMode = colorMode; + + if (cb) cb.checked = enabled; + if (filterBtn) filterBtn.style.display = enabled ? 'block' : 'none'; + window.gpsTracksLayer.enabled = enabled; + + const map = window._map; + if (!map) return; + + if (enabled) { + _ensureGpsSources(map); + _ensureGpsLayers(map); + _setupGpsClickHandler(map); + map.off('moveend', onGpsMapMoveEnd); + map.on('moveend', onGpsMapMoveEnd); + map.off('zoomend', onGpsZoomEnd); + map.on('zoomend', onGpsZoomEnd); + _syncGpsLayersVisibility(map); + applyGpsFilter(); + if (map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { + fetchAndUpdateGpsGeoJson(map.getBounds()); + } + } +} diff --git a/src/web/index.html b/src/web/index.html index bc81afb..4f983c0 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -72,6 +72,17 @@ Тропы
+ + + + +