feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
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 <noreply@anthropic.com>
This commit is contained in:
0
src/api/gps_tracks/__init__.py
Normal file
0
src/api/gps_tracks/__init__.py
Normal file
89
src/api/gps_tracks/config.py
Normal file
89
src/api/gps_tracks/config.py
Normal file
@@ -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
|
||||
232
src/api/gps_tracks/db.py
Normal file
232
src/api/gps_tracks/db.py
Normal file
@@ -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
|
||||
32
src/api/gps_tracks/dedup.py
Normal file
32
src/api/gps_tracks/dedup.py
Normal file
@@ -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}"
|
||||
240
src/api/gps_tracks/endpoint.py
Normal file
240
src/api/gps_tracks/endpoint.py
Normal file
@@ -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
|
||||
52
src/api/gps_tracks/models.py
Normal file
52
src/api/gps_tracks/models.py
Normal file
@@ -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
|
||||
167
src/api/gps_tracks/mvt.py
Normal file
167
src/api/gps_tracks/mvt.py
Normal file
@@ -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},
|
||||
)
|
||||
0
src/api/gps_tracks/sources/__init__.py
Normal file
0
src/api/gps_tracks/sources/__init__.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal file
@@ -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
|
||||
17
src/api/gps_tracks/sources/enduro_russia.py
Normal file
17
src/api/gps_tracks/sources/enduro_russia.py
Normal file
@@ -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
|
||||
309
src/api/gps_tracks/sources/osm.py
Normal file
309
src/api/gps_tracks/sources/osm.py
Normal file
@@ -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
|
||||
17
src/api/gps_tracks/sources/ttrails.py
Normal file
17
src/api/gps_tracks/sources/ttrails.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
573
src/web/gps_tracks.js
Normal file
573
src/web/gps_tracks.js
Normal file
@@ -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 = '<div class="track-popup-sources">Источники: ' +
|
||||
srcs.map((s, i) => {
|
||||
const url = Array.isArray(urls) && urls[i] ? urls[i] : null;
|
||||
const label = s;
|
||||
return url
|
||||
? `<a href="${url}" target="_blank" rel="noopener">${label} ↗</a>`
|
||||
: `<span>${label}</span>`;
|
||||
}).join(' · ') + '</div>';
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
return `
|
||||
<div class="track-popup">
|
||||
<div class="track-popup-name">${name}</div>
|
||||
<div class="track-popup-row">${icon} ${actLabel}</div>
|
||||
<div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
|
||||
${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
|
||||
${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
|
||||
${sourcesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${act}" ${checked ? 'checked' : ''} onchange="onGpsActivityFilterChange()">
|
||||
<span>${GPS_ACTIVITY_ICONS[act]} ${GPS_ACTIVITY_LABELS[act]}</span>
|
||||
</label>`;
|
||||
}).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 `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
|
||||
<span>${sourceLabels[src] || src}</span>
|
||||
</label>`;
|
||||
}).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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,17 @@
|
||||
<span>Тропы</span>
|
||||
</label>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<!-- ET-008: публичные GPS-треки -->
|
||||
<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>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
||||
<span>POI</span>
|
||||
@@ -463,6 +474,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── ET-008: Sheet «GPS-фильтры» ───────────────────────────────── -->
|
||||
<div class="bottom-sheet" id="sheet-gps-filters">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M7 12h10M11 18h2"/></svg>
|
||||
<h2>Фильтры публичных треков</h2>
|
||||
<button class="sheet-close" onclick="closeAllSheets()">✕</button>
|
||||
</div>
|
||||
<div class="sheet-body">
|
||||
<div class="section-label">ТИП АКТИВНОСТИ</div>
|
||||
<div id="gps-activity-grid" class="gps-filter-grid"></div>
|
||||
<div class="section-label">ИСТОЧНИК</div>
|
||||
<div id="gps-source-grid" class="gps-filter-grid"></div>
|
||||
<div class="section-label">ЦВЕТ ЛИНИЙ</div>
|
||||
<div class="seg-control">
|
||||
<button class="seg-btn active" id="gps-color-by-source" onclick="onGpsColorModeChange('source')">По источнику</button>
|
||||
<button class="seg-btn" id="gps-color-by-activity" onclick="onGpsColorModeChange('activity')">По активности</button>
|
||||
</div>
|
||||
<div class="gps-stats-row" id="gps-stats-row" style="margin-top:12px">
|
||||
<span>Всего в области: <b id="gps-stat-total">—</b></span>
|
||||
<span style="margin-left:12px">Видны (фильтр): <b id="gps-stat-shown">—</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
|
||||
@@ -471,5 +507,7 @@
|
||||
<script src="app.js"></script>
|
||||
<!-- ET-006: gpx.js подключается после app.js — потребляет его глобали (ADR-002) -->
|
||||
<script src="gpx.js"></script>
|
||||
<!-- ET-008: публичные GPS-треки -->
|
||||
<script src="gps_tracks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user