diff --git a/Dockerfile b/Dockerfile index f486e70..8a0db18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,5 +4,7 @@ COPY src/api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/api/ ./src/api/ COPY src/web/ ./src/web/ +ENV STATIC_DIR=/app/src/web +ENV PORT=5556 EXPOSE 5556 CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "5556"] diff --git a/README.md b/README.md index 0ce914d..0e96dde 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,75 @@ -# enduro-trails +# Enduro Trails 🏍️ + +OSM-карта с фокусом на грунтовые дороги для построения эндуро-маршрутов. + +## Что это + +Обычные карты оптимизированы под автомобили — асфальт яркий, грунтовки не видны. Enduro Trails переворачивает логику: **грунтовки и тропы — главный слой**, асфальт — тусклый фон. + +Приложение помогает: +- Найти грунтовые дороги в любом районе +- Построить маршрут с максимумом грунта и минимумом асфальта +- Оценить сложность (grade 1-5, покрытие, уклон) +- Найти живописные места (озёра, виды, руины, броды) +- Экспортировать маршрут в GPX для навигатора + +## Демо + +https://openclaw.mva154.duckdns.org/enduro/ + +## Фичи + +- 🗺️ **Карта грунтовок** — MapLibre GL JS, кастомный стиль, тёмная/светлая тема +- 🛤️ **Маршрут** — до 5 альтернатив с разным балансом грунт/асфальт, промежуточные точки +- ✨ **Красивый маршрут** — замкнутый круг через живописные POI +- 🔗 **Связка** — соединить два трека грунтовками +- 🔍 **Разведка** — статистика грунтовок в радиусе 20/50/100 км +- 📏 **Линейка** — измерение расстояний на карте +- 📍 **Метки** — сохранение точек интереса +- 🏔️ **Рельеф** — гипсометрия + hillshade (SRTM 30м) +- 📊 **Статистика** — % грунта/асфальта, время, дистанция +- 📥 **GPX экспорт** — трек + waypoints для навигатора +- 🌙 **Тёмная тема** — авто (по закату), ручная, синхронизация карты и UI + +## Стек + +| Компонент | Технология | +|-----------|-----------| +| Frontend | MapLibre GL JS + vanilla JS | +| Backend | Python 3.12 + FastAPI + uvicorn | +| БД | SQLite + Spatialite (1.1M треков, 14K POI) | +| Роутинг | OSRM с кастомным эндуро-профилем | +| Тайлы | Self-hosted raster (terrain, hillshade, TRI) | +| Контейнеризация | Docker + Compose | +| CI | Gitea Actions | + +## Регион + +ЦФО + Чувашия (расширение по запросу) + +## Быстрый старт + +```bash +make dev # поднять локально (Docker Compose) +make test # запустить тесты +make lint # линтеры +make build # собрать Docker-образ +``` + +## Структура + +``` +src/api/ — FastAPI backend (маршруты, тайлы, поиск) +src/web/ — фронтенд (MapLibre, UI) +tests/ — тесты (unit, integration, e2e) +docs/ — документация, ADR, work-items +scripts/ — утилиты +migrations/ — миграции БД +.openclaw/ — system prompts агентов +``` + +## Лицензия + +Данные: © OpenStreetMap contributors (ODbL) +Рельеф: NASA SRTM (Public Domain) -Карта эндуро-маршрутов с рельефом и навигацией \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ccd0ca7..e9e71ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,14 @@ services: - ./src/web:/app/src/web environment: - DATABASE_URL=sqlite:///./data/enduro.db + - DATA_PATH=/app/data/centralfederal.sqlite - TILES_DIR=/app/data/terrain + - TERRAIN_DIR=/app/data/terrain + - STATIC_DIR=/app/src/web + - OSRM_URL=http://172.22.0.1:5559 + - PORT=5556 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5556/health"] + test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/phases/pilot/00-phase-brd.md b/docs/phases/pilot/00-phase-brd.md new file mode 100644 index 0000000..0034cb1 --- /dev/null +++ b/docs/phases/pilot/00-phase-brd.md @@ -0,0 +1,122 @@ +--- +type: phase-brd +phase_id: pilot +title: "Enduro Trails — пилотный проект мультиагентной разработки" +version: 1 +status: approved +created_at: 2026-05-15 +authors: + - "agent:stream" + - "human:slava" +--- + +# BRD — Enduro Trails (пилотный проект) + +## 1. Цель и метрика успеха + +**Цель:** Создать веб-приложение для планирования эндуро-маршрутов с визуализацией грунтовых дорог, рельефа и навигацией — как пилотный проект мультиагентной системы разработки. + +**Метрики успеха:** +- Рабочее приложение доступно по URL (https://openclaw.mva154.duckdns.org/enduro/) +- Покрытие региона: ЦФО + Чувашия (1.1M треков, 14K POI) +- Построение маршрута < 5 секунд +- Мобильный UI (PWA-ready) +- Агентный конвейер: фича от постановки до деплоя ≤ 4 часа + +## 2. Стейкхолдеры + +| Роль | Кто | Интерес | +|------|-----|---------| +| Заказчик / Owner | Слава | Использует для планирования поездок | +| Analyst | Стрим (OpenClaw) | BRD, ТЗ, координация | +| Разработка | Claude Code CLI агенты | Architect, Developer, Reviewer, Tester, Deployer | + +## 3. Scope + +### В скоупе +- Карта грунтовых дорог (MapLibre GL JS, кастомный стиль) +- Роутинг «Дикий путь» (OSRM, кастомный профиль enduro.lua) +- Альтернативные маршруты (до 5 вариантов) +- Промежуточные точки (до 8) +- Статистика покрытия (% грунт/асфальт по типам) +- «Красивый маршрут» (замкнутый круг через POI) +- «Связка» (соединить два трека) +- «Разведка» (грунтовки в радиусе) +- Рельеф (гипсометрия + hillshade, SRTM 30м) +- TRI (Terrain Ruggedness Index) +- Линейка, метки, GPX экспорт +- Тёмная/светлая тема (авто по SunCalc) +- Мобильный UI (bottom sheets, toolbar, touch) +- Поиск (Nominatim geocoding) + +### Вне скоупа (v1) +- PWA офлайн режим +- GPS-трекинг в реальном времени +- Народные треки (Wikiloc, Komoot) +- Профиль высот на маршруте +- Мультирегион (вся Россия) +- Нативное мобильное приложение + +## 4. Архитектура + +### Компоненты +- **Frontend** — MapLibre GL JS, vanilla JS (ES modules), CSS custom properties +- **Backend API** — FastAPI (Python 3.12), uvicorn (4 workers) +- **Tile Server** — статические raster tiles через nginx +- **Vector Tiles** — MVT из SQLite (self-hosted, FastAPI) +- **Routing Engine** — OSRM с кастомным профилем `enduro.lua` +- **Database** — SQLite + Spatialite (431 MB) +- **Reverse Proxy** — nginx (`/enduro/` → контейнер) + +### Инфраструктура +- Один сервер: mva154 (82.22.50.71) +- Docker Compose +- Gitea (git + CI) +- Plane (управление задачами) + +### Данные +- OSM PBF (Geofabrik, ЦФО + Чувашия) +- SRTM 30м (NASA, public domain) +- OSRM граф (~5.2 GB) + +## 5. Реализованные фазы + +| Фаза | Описание | Статус | Дата | +|------|----------|--------|------| +| 1 | MVP: карта + MVT тайлы | ✅ | 02.05.2026 | +| 2 | Роутинг + базовый UI | ✅ | 03.05.2026 | +| 3 | Умный маршрут (альтернативы, статистика, GPX) | ✅ | 04.05.2026 | +| 4 | Продвинутый роутинг (красивый, связка, разведка) | ✅ | 04.05.2026 | +| 5 | Редизайн (тёмная тема, mobile UI, UX) | ✅ | 05-06.05.2026 | +| 5.4 | Рельеф (hillshade + гипсометрия + TRI) | ✅ | 12-14.05.2026 | + +## 6. Бэклог + +| Фаза | Описание | Приоритет | +|------|----------|-----------| +| 3.1 | Улучшение роутинга (шлагбаумы, тротуары, слой препятствий) | Высокий | +| 6 | SRTM продвинутый (профиль высот, «Горка») | Средний | +| 7 | PWA + офлайн | Средний | +| 8 | Народные треки | Низкий | + +## 7. Ключевые решения + +| Решение | Причина | +|---------|---------| +| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles | +| Vanilla JS (не React) | Простота, нет build step, быстрый старт | +| FastAPI (не Django) | Лёгкий, async, минимум зависимостей | +| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона | +| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль | +| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией | +| Raster tiles для terrain (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику | +| Docker Compose | Один файл — весь стек | + +## 8. Риски + +| Риск | Митигация | +|------|-----------| +| OSRM граф большой (5.2 GB RAM) | Swap 6 GB настроен | +| SQLite не масштабируется | Миграция на PostGIS при необходимости | +| Один сервер — single point of failure | Бэкапы, Docker restart policy | +| SRTM 30м недостаточно для крутых склонов | Достаточно для ЦФО (равнина) | diff --git a/docs/phases/pilot/01-phase-plan.md b/docs/phases/pilot/01-phase-plan.md new file mode 100644 index 0000000..2a9e4d6 --- /dev/null +++ b/docs/phases/pilot/01-phase-plan.md @@ -0,0 +1,40 @@ +--- +type: phase-plan +phase_id: pilot +title: "План пилотной фазы" +version: 1 +status: active +--- + +# План пилотной фазы — Enduro Trails + +## Текущий статус + +Проект в стадии перехода от прототипа к управляемой мультиагентной разработке. + +### Выполнено (прототип, 02-14.05.2026) +- Фазы 1-5.4 реализованы вручную (Стрим + Dev-агент) +- Все основные фичи работают +- Приложение доступно по URL + +### В процессе (мультиагентная инфраструктура, 15.05.2026) +- ✅ Репо в Gitea с канонической структурой +- ✅ Claude Code CLI авторизован +- ✅ Service account claude-bot +- ✅ Branch protection +- ✅ CI pipeline +- ✅ System prompts агентов +- 🔄 Миграция прототипа в репо + +### Следующие шаги +1. Merge миграции в main +2. Первая задача через полный агентный конвейер (Фаза 1 мультиагентного BRD) +3. Orchestrator MVP (Фаза 2 мультиагентного BRD) + +## Приоритет фич (бэклог) + +1. **F-07 + F-08** — Исключить шлагбаумы и тротуары из OSRM (пересборка графа) +2. **F-10** — Слой препятствий на карте +3. **F-09** — Больше альтернатив (penalized re-query) +4. Профиль высот на маршруте +5. PWA офлайн diff --git a/scripts/download_srtm.sh b/scripts/download_srtm.sh new file mode 100755 index 0000000..7318370 --- /dev/null +++ b/scripts/download_srtm.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Download all SRTM tiles for Central Federal District + Chuvashia +# Using curl with retry on connection errors + +SRTM_DIR="/home/slin/enduro-trails/data/srtm" +mkdir -p "$SRTM_DIR" +cd "$SRTM_DIR" +BASE_URL="https://s3.amazonaws.com/elevation-tiles-prod/skadi" + +TILES=( + N55E037 N55E038 N55E039 N55E040 + N54E037 N54E038 N54E039 N54E040 + N53E038 N53E039 N53E040 N53E041 + N52E038 N52E039 N52E040 N52E041 + N56E037 N56E038 N56E039 N56E040 + N57E037 N57E038 N57E039 N57E040 + N58E037 N58E038 N58E039 N58E040 + N59E038 N59E039 N59E040 N59E041 + N60E040 N60E041 N60E042 + N54E042 N54E043 N54E044 N54E045 + N53E042 N53E043 N53E044 N53E045 + N52E042 N52E043 N52E044 N52E045 + N51E038 N51E039 N51E040 N51E041 + N50E038 N50E039 N50E040 N50E041 + N55E047 N55E048 N55E049 N55E050 + N54E047 N54E048 N54E049 N54E050 + N56E047 N56E048 N56E049 N56E050 +) + +for tile in "${TILES[@]}"; do + lat="${tile:1:2}" + url="${BASE_URL}/${lat}/${tile}.hgt.gz" + + if [ -f "${tile}.hgt" ]; then + echo "SKIP ${tile}" + continue + fi + + echo "DL ${tile}" + # Download with retry + HTTP_CODE=$(curl -s -o "${tile}.hgt.gz" -w "%{http_code}" --max-time 90 --retry 3 --retry-delay 2 "$url") + if [ "$HTTP_CODE" = "200" ] && [ -s "${tile}.hgt.gz" ]; then + gunzip -f "${tile}.hgt.gz" + if [ -f "${tile}.hgt" ]; then + echo "OK ${tile}" + else + echo "FAIL ${tile} (gunzip failed)" + fi + else + echo "FAIL ${tile} (HTTP ${HTTP_CODE})" + rm -f "${tile}.hgt.gz" + fi +done + +echo "" +echo "Total .hgt files: $(ls *.hgt 2>/dev/null | wc -l)" diff --git a/src/api/.gitkeep b/src/__init__.py similarity index 100% rename from src/api/.gitkeep rename to src/__init__.py diff --git a/src/web/.gitkeep b/src/api/__init__.py similarity index 100% rename from src/web/.gitkeep rename to src/api/__init__.py diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 0000000..42160ec --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,1255 @@ +#!/usr/bin/env python3 +""" +app.py — FastAPI сервер для Enduro Trails +- Раздаёт статику из static/ +- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite +- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия +- /api/health — статус БД +""" + +import os +import math +import struct +import sqlite3 +import json +import itertools +from pathlib import Path +from shapely.geometry import LineString +from typing import List + +from functools import lru_cache +from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import httpx +import uvicorn + +# ─── Tile cache ────────────────────────────────────────────────────────────── + +_tile_cache: dict = {} +_TILE_CACHE_MAX = 512 + + +def get_cached_tile(z, x, y): + return _tile_cache.get((z, x, y)) + + +def set_cached_tile(z, x, y, data: bytes): + if len(_tile_cache) >= _TILE_CACHE_MAX: + # FIFO вытеснение — удаляем самый старый ключ + _tile_cache.pop(next(iter(_tile_cache))) + _tile_cache[(z, x, y)] = data + + +# ─── Конфиг ─────────────────────────────────────────────────────────────────── + +DATA_PATH = os.environ.get( + "DATA_PATH", + os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"), +) +OSRM_URL = os.environ.get("OSRM_URL", "http://172.22.0.1:5559") +DATA_PATH = os.path.abspath(DATA_PATH) +STATIC_DIR = os.environ.get("STATIC_DIR", os.path.join(os.path.dirname(__file__), "../../src/web")) +PORT = int(os.environ.get("PORT", 5558)) + +app = FastAPI(title="Enduro Trails API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# ─── DB ─────────────────────────────────────────────────────────────────────── + +def get_db(): + conn = sqlite3.connect(DATA_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# ─── Tile math ──────────────────────────────────────────────────────────────── + +def tile_to_bbox(z: int, x: int, y: int): + 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 + + +# ─── WKB parser ─────────────────────────────────────────────────────────────── + +def wkb_to_coords(blob: bytes): + """Парсит WKB LineString с опциональным SRID, возвращает [(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 wkb_point_coords(blob: bytes): + """Парсит WKB Point, возвращает (lon, lat).""" + try: + b = bytes(blob) + if len(b) < 21: + return None + endian = "<" if b[0] == 1 else ">" + gtype = struct.unpack_from(endian + "I", b, 1)[0] + base_type = gtype & 0xFF + if base_type != 1: + return None + offset = 5 + if gtype & 0x20000000: + offset += 4 + lon, lat = struct.unpack_from(endian + "dd", b, offset) + return lon, lat + except Exception: + return None + + +# ─── MVT builder ────────────────────────────────────────────────────────────── + +def simplify_coords(coords, z): + """Упрощает геометрию трека по зуму через 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 build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: + """Собирает MVT тайл.""" + import mapbox_vector_tile + + west, south, east, north = tile_to_bbox(z, x, y) + + trails_features = [] + for row in trails_rows: + coords = wkb_to_coords(row["geom"]) + if not coords: + continue + coords = simplify_coords(coords, z) + try: + props = { + "highway": row["highway_type"] or "", + "tracktype": row["track_type"] or "", + "surface": row["surface"] or "", + "name": row["name"] or "", + "length_m": row["length_m"] or 0, + "mtb_scale": row["mtb_scale"] or "", + } + trails_features.append({ + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": props, + }) + except Exception: + continue + + poi_features = [] + for row in poi_rows: + pt = wkb_point_coords(row["geom"]) + if not pt: + continue + try: + props = { + "poi_type": row["poi_type"] or "", + "name": row["name"] or "", + } + poi_features.append({ + "geometry": {"type": "Point", "coordinates": list(pt)}, + "properties": props, + }) + except Exception: + continue + + layers = [] + if trails_features: + layers.append({"name": "trails", "features": trails_features}) + if poi_features: + layers.append({"name": "poi", "features": poi_features}) + + if not layers: + return b"" + + return mapbox_vector_tile.encode( + layers, + quantize_bounds=(west, south, east, north), + extents=4096, + default_options={'y_coord_down': False}, + ) + + +# ─── Route stats ────────────────────────────────────────────────────────────── + +def haversine_m(lon1, lat1, lon2, lat2) -> 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_route_stats(geometry: dict, conn) -> dict | None: + """ + Считает статистику покрытия маршрута по типам дорог. + Оптимизированная версия: сэмплирует каждые ~500м, один запрос на сэмпл. + geometry — GeoJSON LineString {"type":"LineString","coordinates":[[lon,lat],...]} + """ + try: + coords = geometry.get("coordinates", []) + if len(coords) < 2: + return None + + # Считаем длины сегментов и общую длину + total_len = 0.0 + seg_lengths = [] + for i in range(len(coords) - 1): + d = haversine_m(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1]) + seg_lengths.append(d) + total_len += d + + if total_len < 1: + return None + + # Сэмплируем каждые ~500м (не чаще чем каждые 5 точек) + # Для маршрута 100км → ~200 сэмплов, для 500км → ~1000 сэмплов + avg_step = total_len / max(len(seg_lengths), 1) + # Шаг в точках для ~500м интервала + pts_per_500m = max(5, int(round(500.0 / avg_step))) if avg_step > 0 else 20 + + cur = conn.cursor() + + stats = { + "track_lev12_m": 0.0, + "track_lev345_m": 0.0, + "path_m": 0.0, + "asphalt_m": 0.0, + } + + # Кэш результатов по ячейкам сетки ~0.01° (~1км) + # Чтобы не делать повторные запросы для близких точек + grid_cache: dict = {} + + i = 0 + while i < len(coords) - 1: + end_i = min(i + pts_per_500m, len(coords) - 1) + + # Средняя точка сегмента + mid_lon = (coords[i][0] + coords[end_i][0]) / 2 + mid_lat = (coords[i][1] + coords[end_i][1]) / 2 + + # Длина этого сегмента + seg_len = sum(seg_lengths[i:end_i]) + + # Ключ кэша: ячейка 0.01° (~1км) + grid_key = (round(mid_lon * 100), round(mid_lat * 100)) + + if grid_key in grid_cache: + hw, tt = grid_cache[grid_key] + else: + # Tight bbox ~150m — index on (min_lon, max_lon, min_lat, max_lat) is used + # No ORDER BY to avoid full scan; first bbox hit is good enough for stats + delta = 0.0015 + try: + cur.execute(""" + SELECT highway_type, track_type + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + LIMIT 1 + """, ( + mid_lon + delta, mid_lon - delta, + mid_lat + delta, mid_lat - delta, + )) + row = cur.fetchone() + if row: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() + else: + # Widen search to ~500m if nothing found nearby + delta2 = 0.005 + cur.execute(""" + SELECT highway_type, track_type + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + LIMIT 1 + """, ( + mid_lon + delta2, mid_lon - delta2, + mid_lat + delta2, mid_lat - delta2, + )) + row = cur.fetchone() + if row: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() + else: + hw, tt = "asphalt", "" + except Exception: + hw, tt = "asphalt", "" + grid_cache[grid_key] = (hw, tt) + + if hw == "track": + if tt in ("grade1", "grade2"): + stats["track_lev12_m"] += seg_len + else: + stats["track_lev345_m"] += seg_len + elif hw in ("path", "bridleway", "footway"): + stats["path_m"] += seg_len + else: + stats["asphalt_m"] += seg_len + + i = end_i + + computed_total = ( + stats["track_lev12_m"] + stats["track_lev345_m"] + + stats["path_m"] + stats["asphalt_m"] + ) + if computed_total < 1: + return None + + def pct(v): + return round(v / computed_total * 100) + + dirt_total = stats["track_lev12_m"] + stats["track_lev345_m"] + stats["path_m"] + + return { + "track_lev12_m": round(stats["track_lev12_m"]), + "track_lev345_m": round(stats["track_lev345_m"]), + "path_m": round(stats["path_m"]), + "asphalt_m": round(stats["asphalt_m"]), + "track_lev12_pct": pct(stats["track_lev12_m"]), + "track_lev345_pct": pct(stats["track_lev345_m"]), + "path_pct": pct(stats["path_m"]), + "asphalt_pct": pct(stats["asphalt_m"]), + "dirt_total_pct": pct(dirt_total), + } + except Exception: + return None + + +# ─── Pydantic models ────────────────────────────────────────────────────────── + +class Waypoint(BaseModel): + lon: float + lat: float + + +class RouteRequest(BaseModel): + waypoints: List[Waypoint] + alternatives: int = 5 + + +class ReconRequest(BaseModel): + lon: float + lat: float + radius_km: float = 20 + + +class ScenicRequest(BaseModel): + lon: float + lat: float + target_km: float = 100 + + +# ─── API endpoints ──────────────────────────────────────────────────────────── + +@app.get("/api/cache/clear") +async def clear_cache(): + _tile_cache.clear() + return {"status": "ok", "cleared": True} + + +@app.get("/api/tiles/{z}/{x}/{y}.mvt") +async def get_tile(z: int, x: int, y: int): + 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_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"}, + ) + + if not os.path.exists(DATA_PATH): + raise HTTPException(503, f"База данных не найдена: {DATA_PATH}") + + west, south, east, north = tile_to_bbox(z, x, y) + + buf_x = (east - west) * 0.15 + buf_y = (north - south) * 0.15 + q_west = west - buf_x + q_east = east + buf_x + q_south = south - buf_y + q_north = north + buf_y + + if z <= 7: + limit = 3000 + elif z <= 9: + limit = 8000 + elif z <= 11: + limit = 15000 + else: + limit = 25000 + + if z <= 7: + min_length = 2000 + elif z == 8: + min_length = 500 + elif z == 9: + min_length = 200 + elif z == 10: + min_length = 50 + else: + min_length = 0 + + try: + conn = get_db() + cur = conn.cursor() + + cur.execute(""" + SELECT osm_id, highway_type, track_type, surface, name, length_m, mtb_scale, geom + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + AND (length_m >= ? OR length_m IS NULL) + LIMIT ? + """, (q_east, q_west, q_north, q_south, min_length, limit)) + trails_rows = cur.fetchall() + + cur.execute(""" + SELECT osm_id, poi_type, name, geom + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + LIMIT 500 + """, (q_west, q_east, q_south, q_north)) + poi_rows = cur.fetchall() + + conn.close() + except Exception as e: + raise HTTPException(500, f"Ошибка БД: {e}") + + mvt = build_mvt(trails_rows, poi_rows, z, x, y) + + if mvt: + set_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", + }, + ) + + +@app.post("/api/recon") +async def post_recon(req: ReconRequest): + """ + Разведка: агрегация trails + POI в радиусе от точки. + """ + lon, lat, radius_km = req.lon, req.lat, req.radius_km + lat_rad = math.radians(lat) + delta_lat = radius_km / 111.0 + delta_lon = radius_km / (111.0 * math.cos(lat_rad)) + + try: + conn = get_db() + cur = conn.cursor() + + # Trails — агрегация по типам + cur.execute(""" + SELECT highway_type, track_type, SUM(length_m) as total_m, COUNT(*) as cnt + FROM trails + WHERE min_lon <= ? AND max_lon >= ? + AND min_lat <= ? AND max_lat >= ? + AND length_m >= 100 + GROUP BY highway_type, track_type + """, (lon + delta_lon, lon - delta_lon, lat + delta_lat, lat - delta_lat)) + trail_rows = cur.fetchall() + + # Агрегация по категориям + lev12_count, lev12_km = 0, 0.0 + lev345_count, lev345_km = 0, 0.0 + path_count, path_km = 0, 0.0 + + for row in trail_rows: + hw = (row["highway_type"] or "").lower() + tt = (row["track_type"] or "").lower() if row["track_type"] else "" + cnt = row["cnt"] + km = (row["total_m"] or 0) / 1000.0 + + if hw == "track": + if tt in ("grade1", "grade2"): + lev12_count += cnt + lev12_km += km + else: + lev345_count += cnt + lev345_km += km + elif hw in ("path", "bridleway"): + path_count += cnt + path_km += km + + total_count = lev12_count + lev345_count + path_count + total_km = lev12_km + lev345_km + path_km + + # POI — подсчёт по типам + cur.execute(""" + SELECT poi_type, COUNT(*) as cnt + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + GROUP BY poi_type + """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat)) + poi_rows = cur.fetchall() + + poi_counts = {} + for row in poi_rows: + poi_counts[row["poi_type"]] = row["cnt"] + + conn.close() + + # POI types to report + poi_report = {} + for key in ["natural=water", "tourism=viewpoint", "historic=ruins", + "ford=yes", "natural=peak", "natural=cave_entrance"]: + poi_report[key] = poi_counts.get(key, 0) + + return { + "center": {"lon": lon, "lat": lat}, + "radius_km": radius_km, + "trails": { + "total_count": total_count, + "total_km": round(total_km, 1), + "lev12_count": lev12_count, + "lev12_km": round(lev12_km, 1), + "lev345_count": lev345_count, + "lev345_km": round(lev345_km, 1), + "path_count": path_count, + "path_km": round(path_km, 1), + }, + "poi": poi_report, + } + except Exception as e: + raise HTTPException(500, f"Ошибка БД: {e}") + + +# ─── Scenic route helpers ───────────────────────────────────────────────────── + +SCENIC_POI_SCORES = { + "natural=water": 10, + "tourism=viewpoint": 15, + "historic=ruins": 10, + "natural=peak": 12, + "natural=cave_entrance": 8, + "ford=yes": 5, +} + +SCENIC_POI_ICONS = { + "natural=water": "💧", + "tourism=viewpoint": "👁", + "historic=ruins": "🏚", + "natural=peak": "🔺", + "natural=cave_entrance": "🕳", + "ford=yes": "🌊", +} + +DIRECTION_NAMES = {0: "Северный", 1: "Восточный", 2: "Южный", 3: "Западный"} + + +def _angle_bucket(lon, lat, center_lon, center_lat): + """Возвращает сектор 0-3 (С, В, Ю, З) от центра.""" + a = math.degrees(math.atan2(lon - center_lon, lat - center_lat)) % 360 + return int(a // 90) + + +async def _osrm_route_waypoints(waypoints_lonlat: list[tuple[float, float]]) -> dict | None: + """Запрос к OSRM со списком waypoints. Возвращает полный ответ OSRM.""" + coords_str = ";".join(f"{lon},{lat}" for lon, lat in waypoints_lonlat) + radiuses_str = ";".join(["5000"] * len(waypoints_lonlat)) + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url) + data = resp.json() + except Exception: + return None + if data.get("code") != "Ok" or not data.get("routes"): + # Retry with wider snap + radiuses_wide = ";".join(["10000"] * len(waypoints_lonlat)) + url2 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_wide}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url2) + data = resp.json() + except Exception: + return None + if data.get("code") != "Ok" or not data.get("routes"): + return None + return data + + +def _merge_geometries(geometries: list[dict]) -> dict: + """Объединяет несколько LineString GeoJSON в одну.""" + all_coords = [] + for g in geometries: + if g.get("type") == "LineString": + all_coords.extend(g.get("coordinates", [])) + return {"type": "LineString", "coordinates": all_coords} + + +async def _build_scenic_for_cluster( + start_lon, start_lat, poi_list, target_km, conn +) -> dict | None: + """ + Строит кольцевой маршрут: start → POI1 → POI2 → ... → start. + poi_list: [{lon, lat, poi_type, name, score}, ...] + """ + if not poi_list: + return None + + # Ограничиваем до 5 POI + pois = poi_list[:5] + + # Waypoints: start → POI sequence → start + wp = [(start_lon, start_lat)] + for p in pois: + wp.append((p["lon"], p["lat"])) + wp.append((start_lon, start_lat)) + + osrm_data = await _osrm_route_waypoints(wp) + if not osrm_data or not osrm_data.get("routes"): + return None + + route = osrm_data["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Если слишком длинный (> target * 1.3) — убрать последний POI и перестроить + if distance_m > target_km * 1300 and len(pois) > 1: + pois = pois[:-1] + wp = [(start_lon, start_lat)] + for p in pois: + wp.append((p["lon"], p["lat"])) + wp.append((start_lon, start_lat)) + osrm_data2 = await _osrm_route_waypoints(wp) + if osrm_data2 and osrm_data2.get("routes"): + route = osrm_data2["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Статистика покрытия + stats = None + if conn: + try: + stats = calc_route_stats(geometry, conn) + except Exception: + pass + + # Waypoint labels для ответа + waypoints_out = [{"lon": start_lon, "lat": start_lat, "label": "Старт"}] + for p in pois: + icon = SCENIC_POI_ICONS.get(p["poi_type"], "📍") + name = p.get("name") or p["poi_type"] + waypoints_out.append({"lon": p["lon"], "lat": p["lat"], "label": f"{icon} {name}"}) + waypoints_out.append({"lon": start_lon, "lat": start_lat, "label": "Финиш"}) + + scenic_score = sum(p.get("score", 0) for p in pois) + scenic_pois_out = [ + { + "type": p["poi_type"], + "name": p.get("name", ""), + "lon": p["lon"], + "lat": p["lat"], + } + for p in pois + ] + + return { + "name": "", + "waypoints": waypoints_out, + "geometry": geometry, + "distance_m": round(distance_m), + "duration_s": round(duration_s), + "stats": stats, + "scenic_score": scenic_score, + "scenic_pois": scenic_pois_out, + } + + +@app.post("/api/scenic") +async def post_scenic(req: ScenicRequest): + """ + Красивый маршрут: кольцевой маршрут через живописные POI. + """ + lon, lat, target_km = req.lon, req.lat, req.target_km + if target_km < 20 or target_km > 500: + raise HTTPException(400, "Дистанция должна быть от 20 до 500 км") + + # Радиус поиска POI + search_radius_km = target_km * 0.6 + lat_rad = math.radians(lat) + delta_lat = search_radius_km / 111.0 + delta_lon = search_radius_km / (111.0 * math.cos(lat_rad)) + + try: + conn = get_db() + cur = conn.cursor() + + # Найти POI в радиусе + cur.execute(""" + SELECT poi_type, name, lon, lat + FROM poi + WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? + ORDER BY poi_type + """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat)) + poi_rows = cur.fetchall() + + # Фильтруем POI по score и назначаем баллы + scored_pois = [] + for row in poi_rows: + pt = row["poi_type"] or "" + if pt in SCENIC_POI_SCORES: + p_lon = row["lon"] + p_lat = row["lat"] + # Не берём POI ближе 3 км от старта + d = haversine_m(lon, lat, p_lon, p_lat) + if d < 3000: + continue + scored_pois.append({ + "poi_type": pt, + "name": row["name"] or "", + "lon": p_lon, + "lat": p_lat, + "score": SCENIC_POI_SCORES[pt], + "distance_m": d, + }) + + if not scored_pois: + # Нет красивых мест — строим просто кольцо через ближайшие грунтовки + # Пробуем кольцо: старт → точка на расстоянии ~target_km/3 → старт + angle = 0 + third_dist = target_km / 3.0 + mid_lat = lat + (third_dist / 111.0) + mid_lon = lon + osrm_data = await _osrm_route_waypoints([ + (lon, lat), (mid_lon, mid_lat), (lon, lat) + ]) + if osrm_data and osrm_data.get("routes"): + route = osrm_data["routes"][0] + geometry = route["geometry"] + stats = calc_route_stats(geometry, conn) if conn else None + conn.close() + return { + "routes": [{ + "name": "Маршрут по грунтовкам", + "waypoints": [ + {"lon": lon, "lat": lat, "label": "Старт"}, + {"lon": mid_lon, "lat": mid_lat, "label": "Разворот"}, + {"lon": lon, "lat": lat, "label": "Финиш"}, + ], + "geometry": geometry, + "distance_m": round(route["distance"]), + "duration_s": round(route["duration"]), + "stats": stats, + "scenic_score": 0, + "scenic_pois": [], + }] + } + conn.close() + raise HTTPException(404, "Не удалось построить маршрут") + + # Разделить POI на кластеры по азимуту (4 сектора) + clusters: dict[int, list] = {0: [], 1: [], 2: [], 3: []} + for p in scored_pois: + bucket = _angle_bucket(p["lon"], p["lat"], lon, lat) + clusters[bucket].append(p) + + # Для каждого кластера — жадный выбор POI + remaining_km = target_km * 0.8 # 80% дистанции на POI, 20% на возврат + + cluster_poi_lists = {} + for bucket, pois in clusters.items(): + if not pois: + cluster_poi_lists[bucket] = [] + continue + # Сортируем по score/distance (жадный) + for p in pois: + p["score_per_km"] = p["score"] / max(p["distance_m"] / 1000.0, 1.0) + pois.sort(key=lambda p: p["score_per_km"], reverse=True) + + selected = [] + used_km = 0.0 + for p in pois: + d_km = p["distance_m"] / 1000.0 + if d_km > remaining_km * 0.5: + continue + if d_km < 3: + continue + if used_km + d_km > remaining_km: + continue + selected.append(p) + used_km += d_km + if len(selected) >= 5: + break + + cluster_poi_lists[bucket] = selected + + # Строим маршруты для кластеров с POI (до 3 альтернативных) + routes_out = [] + used_buckets = 0 + for bucket in range(4): + if used_buckets >= 3: + break + pois = cluster_poi_lists.get(bucket, []) + if not pois: + continue + + result = await _build_scenic_for_cluster(lon, lat, pois, target_km, conn) + if result: + # Проверяем дистанцию: не < 50% target + if result["distance_m"] < target_km * 500: + continue + result["name"] = DIRECTION_NAMES.get(bucket, f"Вариант {used_buckets + 1}") + routes_out.append(result) + used_buckets += 1 + + # Если ни одного маршрута — попробовать все POI вместе + if not routes_out and scored_pois: + # Топ-5 POI по score_per_km + all_sorted = sorted(scored_pois, key=lambda p: p["score_per_km"], reverse=True)[:5] + result = await _build_scenic_for_cluster(lon, lat, all_sorted, target_km, conn) + if result: + result["name"] = "Маршрут" + routes_out.append(result) + + conn.close() + + if not routes_out: + raise HTTPException(404, "Не удалось построить красивый маршрут") + + return {"routes": routes_out} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Ошибка: {e}") + + +async def _osrm_segment_alternatives(lon_a: float, lat_a: float, lon_b: float, lat_b: float, depth: int = 0) -> list: + """Запросить альтернативы для одного сегмента. При TooBig — разбить пополам.""" + coords_str = f"{lon_a},{lat_a};{lon_b},{lat_b}" + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives=true&overview=full&geometries=geojson&annotations=false&radiuses=5000;5000" + ) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception: + return [] + + if data.get("code") == "TooBig" and depth < 2: + # Разбить пополам — найти середину через среднее координат + mid_lon = (lon_a + lon_b) / 2 + mid_lat = (lat_a + lat_b) / 2 + # Рекурсивно получить альтернативы для каждой половины + alts_a = await _osrm_segment_alternatives(lon_a, lat_a, mid_lon, mid_lat, depth + 1) + alts_b = await _osrm_segment_alternatives(mid_lon, mid_lat, lon_b, lat_b, depth + 1) + if not alts_a or not alts_b: + return [] + # Скомбинировать: каждый вариант первой половины × каждый вариант второй + combined = [] + for r_a, r_b in itertools.product(alts_a[:3], alts_b[:3]): + coords_a = r_a["geometry"]["coordinates"] + coords_b = r_b["geometry"]["coordinates"] + merged_coords = coords_a + coords_b[1:] # убрать дублирующую точку стыка + combined.append({ + "distance": r_a["distance"] + r_b["distance"], + "duration": r_a["duration"] + r_b["duration"], + "geometry": {"type": "LineString", "coordinates": merged_coords}, + }) + return combined + + if data.get("code") != "Ok" or not data.get("routes"): + # Попробовать без alternatives + url_single = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&annotations=false&radiuses=5000;5000" + ) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url_single) + data = resp.json() + except Exception: + return [] + if data.get("code") != "Ok": + return [] + + return data.get("routes", []) + + +async def _build_segmented_route(req: RouteRequest) -> dict: + """Строит маршрут через промежуточные точки с альтернативами по сегментам.""" + waypoints = req.waypoints + segments_count = len(waypoints) - 1 + + # Получить альтернативы для каждого сегмента + segment_alternatives = [] + for i in range(segments_count): + wp_a = waypoints[i] + wp_b = waypoints[i + 1] + alts = await _osrm_segment_alternatives(wp_a.lon, wp_a.lat, wp_b.lon, wp_b.lat) + if not alts: + raise HTTPException(404, f"Маршрут не найден на сегменте {i + 1}") + segment_alternatives.append(alts) + + # Скомбинировать сегменты (до 3 вариантов на сегмент чтобы не взорвать комбинаторику) + max_per_segment = 3 + trimmed = [alts[:max_per_segment] for alts in segment_alternatives] + all_combos = list(itertools.product(*trimmed)) + + # Склеить геометрию для каждой комбинации + combined_routes = [] + for combo in all_combos: + total_distance = sum(r["distance"] for r in combo) + total_duration = sum(r["duration"] for r in combo) + all_coords: list = [] + for r in combo: + coords = r["geometry"]["coordinates"] + if all_coords: + all_coords.extend(coords[1:]) + else: + all_coords.extend(coords) + combined_routes.append({ + "distance": total_distance, + "duration": total_duration, + "geometry": {"type": "LineString", "coordinates": all_coords}, + }) + + # Дедупликация по геометрии (5 контрольных точек) + def route_sig(coords): + n = len(coords) + if n == 0: + return () + idxs = [0, n//4, n//2, 3*n//4, n-1] + return tuple((round(coords[i][0], 3), round(coords[i][1], 3)) for i in idxs if i < n) + + seen = set() + deduped = [] + for route in combined_routes: + sig = route_sig(route["geometry"]["coordinates"]) + if sig not in seen: + seen.add(sig) + deduped.append(route) + + deduped = deduped[:5] + + if not deduped: + raise HTTPException(404, "Маршрут не найден") + + # Считаем статистику через существующую calc_route_stats + try: + conn = get_db() + except Exception: + conn = None + + routes_out = [] + for idx, route in enumerate(deduped): + stats = None + if conn is not None: + try: + stats = calc_route_stats(route["geometry"], conn) + except Exception: + stats = None + routes_out.append({ + "index": idx, + "distance_m": round(route["distance"]), + "duration_s": round(route["duration"]), + "geometry": route["geometry"], + "stats": stats, + }) + + if conn is not None: + try: + conn.close() + except Exception: + pass + + if not routes_out: + raise HTTPException(404, "Маршрут не найден") + + # Сортировать по dirt_total_pct убывающий + routes_out.sort(key=lambda r: (r["stats"] or {}).get("dirt_total_pct", 0), reverse=True) + for idx, r in enumerate(routes_out): + r["index"] = idx + + # Waypoints для ответа + waypoints_out = [] + for i, wp in enumerate(req.waypoints): + label = "Старт" if i == 0 else ("Финиш" if i == len(req.waypoints) - 1 else f"Точка {i}") + waypoints_out.append({"lon": wp.lon, "lat": wp.lat, "label": label}) + + return {"routes": routes_out, "waypoints": waypoints_out} + + +@app.post("/api/route") +async def post_route(req: RouteRequest): + """ + Роутинг через OSRM с альтернативными маршрутами и статистикой покрытия. + Принимает JSON: {"waypoints": [{"lon":..,"lat":..}, ...], "alternatives": 5} + """ + if len(req.waypoints) < 2: + raise HTTPException(400, "Нужно минимум 2 точки") + + # При промежуточных точках — сегментный подход + if len(req.waypoints) > 2: + return await _build_segmented_route(req) + + # Строим строку координат для OSRM + coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints) + alternatives = max(1, min(5, req.alternatives)) + + # Увеличенный snap radius для длинных маршрутов (5 км) + radiuses_str = ";".join(["5000"] * len(req.waypoints)) + + url = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives={alternatives}&overview=full&geometries=geojson&annotations=false" + f"&radiuses={radiuses_str}" + ) + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # OSRM возвращает TooBig когда маршрут слишком длинный для N альтернатив + # Retry-цепочка: alternatives=5 → 3 → 1, ВСЕГДА с radiuses (snap radius критичен для длинных маршрутов) + if data.get("code") == "TooBig": + url_retry3 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?alternatives=3&overview=full&geometries=geojson&annotations=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_retry3) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") == "TooBig": + url_retry1 = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_str}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_retry1) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + # Если NoSegment — пробуем с увеличенным radius (10 км вместо 5) + if data.get("code") == "NoSegment": + radiuses_wide = ";".join(["10000"] * len(req.waypoints)) + url_wide = ( + f"{OSRM_URL}/route/v1/driving/{coords_str}" + f"?overview=full&geometries=geojson&alternatives=false" + f"&radiuses={radiuses_wide}" + ) + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(url_wide) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") != "Ok" or not data.get("routes"): + osrm_code = data.get("code", "Unknown") + osrm_msg = data.get("message", "") + if osrm_code == "NoRoute": + raise HTTPException(404, "Маршрут не найден: нет пути между точками") + elif osrm_code == "NoSegment": + raise HTTPException(404, "Маршрут не найден: точки слишком далеко от дорог") + elif osrm_code == "InvalidValue": + raise HTTPException(400, f"Некорректные координаты: {osrm_msg}") + else: + raise HTTPException(404, f"Маршрут не найден ({osrm_code})") + + # Открываем БД один раз для всех маршрутов + try: + conn = get_db() + except Exception as e: + conn = None + + routes_out = [] + for idx, route in enumerate(data["routes"]): + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + # Считаем статистику покрытия + stats = None + if conn is not None: + try: + stats = calc_route_stats(geometry, conn) + except Exception: + stats = None + + routes_out.append({ + "index": idx, + "geometry": geometry, + "distance_m": round(distance_m), + "duration_s": round(duration_s), + "stats": stats, + }) + + if conn is not None: + try: + conn.close() + except Exception: + pass + + return {"routes": routes_out} + + +# Обратная совместимость — старый GET endpoint (для линейки и прочего) +@app.get("/api/route") +async def get_route( + from_lon: float, from_lat: float, + to_lon: float, to_lat: float +): + """Роутинг через OSRM (legacy GET). Параметры: from_lon, from_lat, to_lon, to_lat""" + url = ( + f"{OSRM_URL}/route/v1/driving/" + f"{from_lon},{from_lat};{to_lon},{to_lat}" + f"?overview=full&geometries=geojson&annotations=false" + ) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + data = resp.json() + except Exception as e: + raise HTTPException(503, f"OSRM недоступен: {e}") + + if data.get("code") != "Ok" or not data.get("routes"): + raise HTTPException(404, "Маршрут не найден") + + route = data["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + return { + "type": "Feature", + "geometry": geometry, + "properties": { + "distance_m": round(distance_m), + "distance_km": round(distance_m / 1000, 1), + "duration_min": round(duration_s / 60), + "duration_s": round(duration_s), + } + } + + +@app.get("/api/health") +async def health(): + return { + "status": "ok", + "db_path": DATA_PATH, + "db_exists": os.path.exists(DATA_PATH), + } + + +# ─── Terrain tiles ─────────────────────────────────────────────────────────── + +TERRAIN_DIR = os.environ.get( + "TERRAIN_DIR", + os.path.join(os.path.dirname(__file__), "../data/terrain"), +) + +@app.get("/terrain/{layer}/{z}/{x}/{y}.png") +async def terrain_tile(layer: str, z: int, x: int, y: int): + """Отдаёт растровые тайлы рельефа (hypso/hillshade)""" + if layer not in ("hypso", "hillshade"): + raise HTTPException(404, "Unknown layer") + tile_path = os.path.join(TERRAIN_DIR, layer, str(z), str(x), f"{y}.png") + if not os.path.exists(tile_path): + raise HTTPException(404, "Tile not found") + return FileResponse( + tile_path, + media_type="image/png", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "Access-Control-Allow-Origin": "*", + } + ) + + +# ─── Static files ───────────────────────────────────────────────────────────── + +if os.path.exists(STATIC_DIR): + app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + + +if __name__ == "__main__": + print(f"==> Enduro Trails API DB={DATA_PATH} Port={PORT}") + uvicorn.run("app:app", host="0.0.0.0", port=PORT, workers=4) diff --git a/src/api/requirements.txt b/src/api/requirements.txt new file mode 100644 index 0000000..3ea81d2 --- /dev/null +++ b/src/api/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.111.0 +uvicorn==0.29.0 +shapely==2.0.4 +mapbox-vector-tile==2.2.0 +httpx==0.27.0 diff --git a/src/web/app.css b/src/web/app.css new file mode 100644 index 0000000..b09e447 --- /dev/null +++ b/src/web/app.css @@ -0,0 +1,771 @@ +/* ═══════════════════════════════════════════════════════════════════ + Enduro Trails — Design System v5.0 + Phase 5: Dual themes, skeleton, swipe, desktop, animations + ═══════════════════════════════════════════════════════════════════ */ + +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} + +/* ── Dark Theme (default) ───── */ +body.theme-dark { + --bg: #0D1117; + --surface: #161B22; + --surface2: #21262D; + --surface3: #2D333B; + --border: #30363D; + --border2: #444C56; + --text: #E6EDF3; + --text2: #8B949E; + --text3: #484F58; + --accent: #FF6B00; + --accent-h: #FF8C2A; + --accent-bg: rgba(255,107,0,0.12); + --gold: #FFD700; + --gold-bg: rgba(255,215,0,0.12); + --red: #FF3B1F; + --red-bg: rgba(255,59,31,0.12); + --success: #2EA043; + --shadow: 0 4px 24px rgba(0,0,0,0.6); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.4); + --overlay: rgba(0,0,0,0.6); +} + +/* ── Light Theme ────────────── */ +body.theme-light { + --bg: #F0EFE8; + --surface: #FFFFFF; + --surface2: #F5F4EE; + --surface3: #ECEAE2; + --border: #D4D0C8; + --border2: #B8B4AA; + --text: #1C1C1A; + --text2: #6B6760; + --text3: #9C9890; + --accent: #D95200; + --accent-h: #BF4800; + --accent-bg: rgba(217,82,0,0.1); + --gold: #A07800; + --gold-bg: rgba(160,120,0,0.1); + --red: #B82200; + --red-bg: rgba(184,34,0,0.1); + --success: #1A6B2A; + --shadow: 0 4px 24px rgba(0,0,0,0.15); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.1); + --overlay: rgba(0,0,0,0.3); +} + +/* ── Base ─────────────────────────────────────── */ +html, body { + height: 100%; + font-family: -apple-system, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + background: var(--bg); + color: var(--text); + overflow: hidden; + -webkit-font-smoothing: antialiased; + transition: background 0.3s, color 0.3s; +} + +#map { position: fixed; inset: 0; z-index: 0; } + +/* ── MapLibre nav controls position ──────────── */ +.maplibregl-ctrl-top-left { + top: calc(max(env(safe-area-inset-top, 0px), 12px) + 8px) !important; + left: 12px !important; +} + +/* ── Waypoint inline search ───────────────────── */ +.wl-search-btn { + background: none; + border: none; + color: var(--text3); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + flex-shrink: 0; + transition: color 0.15s; +} +.wl-search-btn:hover, .wl-search-btn:active { color: var(--accent); } + +.wl-search-panel { + padding: 6px 8px 4px 8px; + border-top: 1px solid var(--border); +} +.wl-search-input { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 13px; + padding: 7px 10px; + outline: none; + box-sizing: border-box; +} +.wl-search-input:focus { border-color: var(--accent); } +.wl-search-results { + margin-top: 4px; + max-height: 180px; + overflow-y: auto; +} +.wl-search-result-item { + padding: 8px 10px; + cursor: pointer; + border-radius: 6px; + font-size: 13px; + color: var(--text); +} +.wl-search-result-item:hover, .wl-search-result-item:active { + background: var(--surface2); +} +.wl-search-result-name { font-weight: 500; } +.wl-search-result-sub { font-size: 11px; color: var(--text2); margin-top: 1px; } + +/* ── Map Control Buttons ──────────────────────── */ +#map-controls-r { + position: fixed; right: 12px; + bottom: calc(80px + env(safe-area-inset-bottom, 0px) + 12px); + display: flex; flex-direction: column; gap: 8px; z-index: 400; + transition: bottom 0.2s ease; +} +.map-btn { + width: 48px; height: 48px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text2); + display: flex; align-items: center; justify-content: center; + cursor: pointer; box-shadow: var(--shadow-sm); + transition: all 0.15s; + -webkit-tap-highlight-color: transparent; + position: relative; +} +.map-btn svg { width: 20px; height: 20px; } +.map-btn:active { transform: scale(0.94); background: var(--surface2); } +.map-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* ── Bottom Toolbar ───────────────────────────── */ +#toolbar { + position: fixed; bottom: 0; left: 0; right: 0; + height: calc(68px + env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); + background: var(--surface); + border-top: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-around; + z-index: 300; + box-shadow: 0 -4px 20px rgba(0,0,0,0.2); + transition: background 0.3s, border-color 0.3s; +} +.tb-btn { + flex: 1; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 3px; height: 56px; + border: none; background: none; + color: var(--text3); + font-size: 9px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.06em; + border-radius: 10px; cursor: pointer; + transition: color 0.15s, background 0.15s, transform 0.1s; + -webkit-tap-highlight-color: transparent; + padding: 0 4px; +} +.tb-btn svg { width: 22px; height: 22px; margin-bottom: 1px; transition: transform 0.1s; } +.tb-btn:active { background: var(--surface2); transform: scale(0.94); } +.tb-btn.active { + color: #fff; background: var(--accent); border-radius: 10px; +} +.tb-btn.active svg { stroke: #fff; } +.tb-btn span { line-height: 1; } + +/* ── Bottom Sheet ─────────────────────────────── */ +.bottom-sheet { + position: fixed; bottom: 0; left: 0; right: 0; + background: var(--surface); + border-radius: 20px 20px 0 0; + border-top: 1px solid var(--border); + z-index: 400; max-height: 78vh; + overflow-y: auto; overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.32, 0, 0.15, 1); + padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + touch-action: pan-y; +} +.bottom-sheet.open { transform: translateY(0); } +.bottom-sheet.swiping { transition: none; } +.sheet-handle { + width: 36px; height: 4px; + background: var(--border2); + border-radius: 2px; margin: 12px auto 0; cursor: grab; +} +.sheet-header { + display: flex; align-items: center; + padding: 14px 16px 12px; gap: 10px; + border-bottom: 1px solid var(--border); +} +.sheet-header svg { width: 20px; height: 20px; stroke: var(--accent); flex-shrink: 0; } +.sheet-header h2 { flex: 1; font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: 0.02em; } +.sheet-close { + width: 32px; height: 32px; border-radius: 8px; + background: var(--surface2); border: 1px solid var(--border); + color: var(--text2); + display: flex; align-items: center; justify-content: center; + cursor: pointer; flex-shrink: 0; transition: all 0.15s; +} +.sheet-close svg { width: 16px; height: 16px; } +.sheet-close:active { background: var(--surface3); color: var(--text); } +.sheet-body { padding: 14px 16px; } +.sheet-hint { font-size: 13px; color: var(--text2); text-align: center; padding: 16px 0 8px; line-height: 1.5; } + +#sheet-backdrop { + position: fixed; inset: 0; + background: var(--overlay); + z-index: 390; opacity: 0; pointer-events: none; + transition: opacity 0.3s; +} +#sheet-backdrop.visible { opacity: 1; pointer-events: auto; } +/* Allow map clicks through backdrop when route/ruler/marker/recon/link/scenic mode is active */ +body.has-map-mode #sheet-backdrop.visible { pointer-events: none; } + +/* ── Section Label ────────────────────────────── */ +.section-label { font-size: 10px; font-weight: 800; color: var(--text3); text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 8px; margin-top: 4px; } + +/* ── Waypoints Row ────────────────────────────── */ +.waypoints-row { display: flex; align-items: center; gap: 4px; overflow-x: auto; padding: 0 0 4px; scrollbar-width: none; } +.waypoints-row::-webkit-scrollbar { display: none; } +.wp-chip { display: flex; align-items: center; gap: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 7px 10px; flex-shrink: 0; max-width: 140px; cursor: pointer; transition: border-color 0.15s; } +.wp-chip:active { border-color: var(--accent); } +.wp-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.wp-label { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wp-arrow { color: var(--text3); font-size: 18px; flex-shrink: 0; padding: 0 1px; } +.wp-add { display: flex; align-items: center; gap: 6px; background: none; border: 1.5px dashed var(--border2); border-radius: 10px; padding: 7px 12px; font-size: 12px; font-weight: 600; color: var(--text2); flex-shrink: 0; cursor: pointer; transition: border-color 0.15s, color 0.15s; } +.wp-add:active { border-color: var(--accent); color: var(--accent); } + +/* ── Waypoints List ───────────────────────────── */ +#waypoints-list { display: flex; flex-direction: column; margin-bottom: 10px; } +.wl-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + position: relative; +} +.wl-item:last-child { border-bottom: none; } +.wl-drag-handle { + width: 20px; height: 28px; + display: flex; align-items: center; justify-content: center; + color: var(--text3); cursor: grab; flex-shrink: 0; + touch-action: none; + -webkit-tap-highlight-color: transparent; +} +.wl-drag-handle svg { width: 16px; height: 16px; } +.wl-item.dragging { + opacity: 0.4; + background: var(--surface); + border-radius: 4px; +} +.wl-item.drag-over-top { border-top: 2px solid var(--accent); } +.wl-item.drag-over-bottom { border-bottom: 2px solid var(--accent); } +.wl-pin { flex-shrink: 0; display: flex; align-items: center; } +.wl-info { display: flex; flex-direction: column; flex: 1; min-width: 0; } +.wl-label { + font-size: 13px; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.wl-dist { font-size: 11px; color: var(--text3); margin-top: 1px; } +.wl-remove { + width: 28px; height: 28px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + background: none; border: none; color: var(--text3); + cursor: pointer; border-radius: 6px; + -webkit-tap-highlight-color: transparent; +} +.wl-remove:active { background: var(--red-bg); color: var(--red); } +.wl-remove svg { width: 14px; height: 14px; } + +/* Sheet icon buttons (header) */ +.sheet-icon-btn { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: none; border: none; color: var(--text3); + border-radius: 8px; cursor: pointer; padding: 0; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; + -webkit-tap-highlight-color: transparent; +} +.sheet-icon-btn svg { width: 18px; height: 18px; } +.sheet-icon-btn:active { background: var(--surface2); } +.sheet-icon-btn.danger { color: var(--red); } +.sheet-icon-btn.danger:active { background: var(--red); color: #fff; } + +/* Add waypoint row */ +.wl-add { cursor: pointer; } +.wl-add:active { background: var(--surface); } +.wl-add .wl-pin svg path { fill: var(--text3) !important; } +.wl-add .wl-label { color: var(--text3); } + +/* ── Route Status ─────────────────────────────── */ +#route-status { font-size: 13px; color: var(--text2); padding: 8px 0; display: flex; align-items: center; gap: 6px; min-height: 20px; } + +/* ── Route Cards ──────────────────────────────── */ +#route-cards, #link-cards, #scenic-cards { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } +.route-card { + background: var(--surface2); + border: 1.5px solid var(--border); + border-left: 4px solid transparent; + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 0; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + -webkit-tap-highlight-color: transparent; + animation: cardFadeIn 0.2s ease-out both; +} +.route-card:active { background: var(--surface3, var(--surface2)); } +.route-card.active { + border-color: var(--border); + border-left-color: var(--accent); + background: var(--accent-bg); +} +.rc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.rc-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.rc-title { font-size: 13px; font-weight: 700; color: var(--text); flex: 1; } +.rc-meta { font-size: 12px; color: var(--text2); white-space: nowrap; font-variant-numeric: tabular-nums; } +.rc-bar-wrap { margin-bottom: 4px; } +.rc-bar { + height: 6px; border-radius: 3px; + background: var(--border); + display: flex; overflow: hidden; +} +.rc-bar-dirt { background: var(--gold); border-radius: 3px 0 0 3px; transition: width 0.4s; } +.rc-bar-asphalt { background: var(--text3); } +.rc-bar-label { font-size: 11px; color: var(--text2); } +.rc-stats { display: flex; flex-wrap: wrap; gap: 5px; } + +/* Stat pills */ +.stat-pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 20px; padding: 3px 9px; font-size: 11px; font-weight: 700; letter-spacing: 0.02em; } +.stat-pill.dirt { background: var(--gold-bg); color: var(--gold); } +.stat-pill.asphalt { background: var(--surface3); color: var(--text2); } +.stat-pill.path { background: var(--red-bg); color: var(--red); } + +/* ── Primary Button ───────────────────────────── */ +.btn-primary { width: 100%; height: 48px; background: var(--accent); color: #fff; border: none; border-radius: 14px; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: background 0.15s, transform 0.1s; letter-spacing: 0.02em; margin-top: 12px; } +.btn-primary svg { width: 18px; height: 18px; } +.btn-primary:active { background: var(--accent-h); transform: scale(0.98); } +.btn-primary:disabled { opacity: 0.5; pointer-events: none; } + +/* ── Segment Control ──────────────────────────── */ +.seg-control { display: flex; gap: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 4px; margin-bottom: 12px; } +.seg-btn { flex: 1; height: 34px; background: none; border: none; border-radius: 9px; font-size: 13px; font-weight: 600; color: var(--text2); cursor: pointer; transition: all 0.15s; } +.seg-btn.active { background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(255,107,0,0.35); } +.seg-btn:not(.active):active { background: var(--surface3); } +.dist-custom { height: 34px; width: 70px; background: var(--surface2); border: 1px solid var(--border); border-radius: 9px; color: var(--text); font-size: 13px; font-weight: 600; text-align: center; outline: none; flex-shrink: 0; } +.dist-custom:focus { border-color: var(--accent); } + +/* ── Recon Results ────────────────────────────── */ +.recon-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; } +.recon-stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; } +.rs-value { font-size: 22px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1; margin-bottom: 3px; } +.rs-value.gold { color: var(--gold); } +.rs-value.red { color: var(--red); } +.rs-label { font-size: 11px; color: var(--text2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } +.poi-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); } +.poi-row:last-child { border-bottom: none; } +.poi-row-label { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 8px; } +.poi-row-count { font-size: 16px; font-weight: 800; color: var(--accent); font-variant-numeric: tabular-nums; } +.poi-icon { width: 28px; height: 28px; border-radius: 8px; background: var(--surface2); display: flex; align-items: center; justify-content: center; font-size: 14px; } + +/* ── Scenic POI ───────────────────────────────── */ +.scenic-poi-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); padding: 3px 0; } +.scenic-score-bar { height: 4px; border-radius: 2px; background: var(--surface3); overflow: hidden; margin: 6px 0; } +.scenic-score-fill { height: 100%; background: var(--gold); border-radius: 2px; } + +/* ── Link Points ──────────────────────────────── */ +.link-points { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.link-pt { display: flex; align-items: center; gap: 8px; background: var(--surface2); border: 1.5px solid var(--border); border-radius: 10px; padding: 10px 12px; } +.link-pt-num { width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.link-pt-label { font-size: 13px; color: var(--text); flex: 1; } +.link-pt.empty .link-pt-num { background: var(--surface3); color: var(--text3); } +.link-pt.empty .link-pt-label { color: var(--text3); } +#link-status { font-size: 13px; color: var(--text2); padding: 4px 0 10px; } + +/* ── Scenic Config ───────────────────────────── */ +#scenic-status { font-size: 13px; color: var(--text2); padding: 6px 0; display: flex; align-items: center; gap: 6px; } +.dist-row { display: flex; gap: 4px; align-items: center; margin-bottom: 4px; } + +/* ── Marker Dialog ────────────────────────────── */ +#marker-dialog { position: fixed; inset: 0; z-index: 500; display: flex; align-items: flex-end; justify-content: center; padding-bottom: env(safe-area-inset-bottom, 0px); pointer-events: none; opacity: 0; transition: opacity 0.2s; } +#marker-dialog.open { pointer-events: auto; opacity: 1; } +.marker-dialog-inner { background: var(--surface); border-radius: 20px 20px 0 0; border-top: 1px solid var(--border); padding: 0 16px 20px; width: 100%; transform: translateY(30px); transition: transform 0.25s cubic-bezier(0.32, 0, 0.15, 1); } +#marker-dialog.open .marker-dialog-inner { transform: translateY(0); } +.marker-type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 0; } +.marker-type-btn { background: var(--surface2); border: 1.5px solid var(--border); border-radius: 12px; padding: 12px 8px; cursor: pointer; transition: all 0.15s; display: flex; flex-direction: column; align-items: center; gap: 5px; -webkit-tap-highlight-color: transparent; } +.marker-type-btn:active { border-color: var(--accent); background: var(--accent-bg); } +.marker-type-btn .mt-icon { font-size: 24px; } +.marker-type-btn .mt-label { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; } + +/* ── No Data Warning ─────────────────────────── */ +#no-data-warning { display: none; position: fixed; bottom: 80px; left: 12px; right: 12px; background: var(--red-bg); border: 1px solid var(--red); border-radius: 12px; padding: 10px 14px; font-size: 13px; color: var(--red); z-index: 200; } +#no-data-warning.visible { display: block; } + +/* ── Skeleton Loading ────────────────────────── */ +.skeleton { + background: linear-gradient(90deg, var(--surface2) 0%, var(--surface3) 50%, var(--surface2) 100%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: 8px; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.skeleton-card { + background: var(--surface2); + border: 1.5px solid var(--border); + border-radius: 14px; + padding: 14px; + margin-bottom: 8px; +} +.skeleton-line { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; +} +.skeleton-line.w60 { width: 60%; } +.skeleton-line.w40 { width: 40%; } +.skeleton-line.w80 { width: 80%; } +.skeleton-line.h20 { height: 20px; } + +/* ── Ruler ───────────────────────────────────── */ +#ruler-info { + position: fixed; + top: calc(max(env(safe-area-inset-top,0px),12px) + 58px); + left: 50%; + transform: translateX(-50%); + width: fit-content; + max-width: 320px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 5px 10px; + font-size: 13px; color: var(--text); + font-weight: 600; z-index: 200; + display: none; box-shadow: var(--shadow-sm); +} +#ruler-info.visible { display: flex; align-items: center; gap: 6px; } +#ruler-info #ruler-dist { flex: 1; } +.ruler-action-btn { + flex-shrink: 0; + height: 32px; + min-width: 32px; + padding: 4px 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; +} +.ruler-action-btn--danger { + color: var(--danger, #e05252); + border-color: var(--danger, #e05252); + font-size: 16px; + padding: 4px 8px; +} + +/* ── Ruler toast hint ────────────────────────── */ +#ruler-toast { + position: fixed; + top: calc(max(env(safe-area-inset-top,0px),12px) + 100px); + left: 50%; + transform: translateX(-50%); + background: rgba(20,20,20,0.82); + color: #fff; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + border-radius: 20px; + z-index: 210; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; + white-space: nowrap; +} +#ruler-toast.visible { opacity: 1; } + +/* ── Fix: MapLibre markers must stay absolute ────── */ +.maplibregl-marker { + position: absolute !important; +} + +/* ── Waypoint Markers ─────────────────────────── */ +.route-waypoint-marker { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4)); width: 28px; height: 36px; cursor: grab; display: block; } +.route-waypoint-marker:active { cursor: grabbing; } +.named-marker-el { font-size: 22px; cursor: pointer; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); user-select: none; line-height: 1; display: block; width: 28px; height: 28px; text-align: center; } + +/* ═══════════════════════════════════════════════════ + TASK 5: Desktop Layout (≥768px) + ═══════════════════════════════════════════════════ */ +@media (min-width: 768px) { + #toolbar { + flex-direction: column; + width: 72px; height: auto; + right: auto; left: 0; + top: 0; bottom: 0; + border-right: 1px solid var(--border); + border-top: none; + padding: 80px 0 20px; + justify-content: flex-start; + gap: 4px; + } + .tb-btn { width: 64px; height: 56px; flex: none; } + .bottom-sheet { + left: 72px; right: auto; + width: 380px; max-width: 400px; + max-height: 100vh; + border-radius: 0 20px 0 0; + border-top: none; + border-right: 1px solid var(--border); + top: 0; bottom: 0; + transform: translateX(-120%); + } + .bottom-sheet.open { transform: translateX(0); } + .bottom-sheet.swiping { transition: none; } + #map-controls-r { right: 12px; bottom: 12px; } + #sheet-backdrop { display: none; } + #ruler-info { max-width: 320px; } +} + +/* ═══════════════════════════════════════════════════ + TASK 6: Micro-animations + ═══════════════════════════════════════════════════ */ +@keyframes cardFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.route-card:nth-child(1) { animation-delay: 0ms; } +.route-card:nth-child(2) { animation-delay: 60ms; } +.route-card:nth-child(3) { animation-delay: 120ms; } +.route-card:nth-child(4) { animation-delay: 180ms; } +.route-card:nth-child(5) { animation-delay: 240ms; } + +/* Marker pop-in animation */ +@keyframes markerPopIn { + from { transform: scale(0); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +/* marker-anim НЕ применять к элементам-обёрткам MapLibre — только к внутренним элементам */ +.marker-anim-inner { animation: markerPopIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28) both; } + +/* ── Onboarding (empty waypoints state) ─────────── */ +.wl-onboarding { + padding: 4px 0; +} +.wl-onboard-field { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 12px 8px 0; +} +.wl-onboard-input { + flex: 1; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 14px; + padding: 8px 12px; + outline: none; + box-sizing: border-box; +} +.wl-onboard-input:focus { border-color: var(--accent); } +.wl-onboard-hint { + text-align: center; + font-size: 12px; + color: var(--text3); + padding: 4px 0 8px; +} + +/* ── Misc ────────────────────────────────────── */ +.text-accent { color: var(--accent); } +.text-gold { color: var(--gold); } +.text-red { color: var(--red); } +.text-muted { color: var(--text2); } +.mt-8 { margin-top: 8px; } +.mt-12 { margin-top: 12px; } +.mb-8 { margin-bottom: 8px; } +.cursor-crosshair .maplibregl-canvas { cursor: crosshair !important; } + +/* ── My Location Marker ──────────────────────── */ +.my-location-marker { position: relative; width: 20px; height: 20px; } +.my-location-dot { + position: absolute; top: 50%; left: 50%; + width: 12px; height: 12px; + background: #4285f4; border: 2px solid #fff; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 2px 6px rgba(66,133,244,0.6); +} +.my-location-pulse { + position: absolute; top: 50%; left: 50%; + width: 30px; height: 30px; + background: rgba(66,133,244,0.3); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: pulse-ring 2s ease-out infinite; +} +@keyframes pulse-ring { + 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } +} + +/* ── MapLibre popup theme overrides ──────────── */ +.maplibregl-popup-content { + background: var(--surface) !important; + color: var(--text) !important; + border: 1px solid var(--border) !important; + border-radius: 12px !important; + padding: 12px !important; + font-size: 13px; + box-shadow: var(--shadow) !important; +} +.maplibregl-popup-tip { + border-top-color: var(--surface) !important; +} +.maplibregl-popup-close-button { + color: var(--text2) !important; + font-size: 18px !important; + right: 6px !important; top: 4px !important; +} +.popup-title { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; } +.popup-row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; } +.popup-key { color: var(--text2); } +.popup-val { color: var(--text); font-weight: 600; } + +/* Route card legacy styles (compat) */ +.route-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.route-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.route-card-title { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); } +.route-card-dist { font-size: 14px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; } +.route-card-time { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.route-coverage-bar { height: 5px; border-radius: 3px; background: var(--surface3); overflow: hidden; margin-bottom: 8px; display: flex; } +.route-coverage-bar > div { height: 100%; transition: width 0.4s; } +.route-card-summary { font-size: 12px; color: var(--text2); margin-bottom: 6px; } +.route-card-details { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; } +.route-stat-row { font-size: 12px; color: var(--text2); padding: 2px 0; } +.route-details-toggle { width: 100%; background: none; border: none; color: var(--accent); font-size: 12px; font-weight: 600; cursor: pointer; padding: 6px 0 0; text-align: left; } +.waypoint-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 4px; transition: border-color 0.15s; } +.waypoint-row.drag-over { border-color: var(--accent); } +.waypoint-label { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; } +.waypoint-label.start { background: var(--success); } +.waypoint-label.end { background: var(--red); } +.waypoint-label.mid { background: #0066ff; } +.waypoint-coords { flex: 1; font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.waypoint-remove { width: 24px; height: 24px; border: none; background: none; color: var(--text3); cursor: pointer; font-size: 14px; border-radius: 4px; display: flex; align-items: center; justify-content: center; } +.waypoint-remove:hover { background: var(--red-bg); color: var(--red); } +#btn-add-waypoint { width: 100%; height: 36px; background: var(--surface2); border: 1.5px dashed var(--border2); border-radius: 10px; color: var(--text2); font-size: 12px; font-weight: 600; cursor: pointer; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 6px; transition: border-color 0.15s; } +#btn-add-waypoint:hover { border-color: var(--accent); color: var(--accent); } +#btn-build-route { width: 100%; height: 42px; background: var(--accent); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 700; cursor: pointer; margin-top: 8px; transition: background 0.15s; } +#btn-build-route:active { background: var(--accent-h); } + + +/* ── Mini Route Bar ───────────────────────── */ +#sheet-route-mini { + position: fixed; + bottom: 72px; left: 0; right: 0; + height: 64px; + background: var(--surface); + border-top: 1px solid var(--border); + border-radius: 14px 14px 0 0; + z-index: 350; + display: none; + flex-direction: column; + align-items: center; + box-shadow: 0 -4px 16px var(--shadow); +} +#sheet-route-mini.visible { display: flex; } +#sheet-route-mini .mini-handle { + width: 32px; height: 4px; + background: var(--border2, var(--border)); + border-radius: 2px; + margin: 7px auto 0; + flex-shrink: 0; +} +.mini-route-info { + display: flex; align-items: center; + gap: 10px; padding: 0 16px; + flex: 1; width: 100%; +} +.mini-route-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } +.mini-route-text { flex: 1; min-width: 0; } +.mini-route-label { font-size: 13px; font-weight: 700; color: var(--text); } +.mini-route-stats { font-size: 11px; color: var(--text2); } +.mini-route-arrows { display: flex; gap: 6px; flex-shrink: 0; margin-left: 8px; } +.mini-arrow { + width: 36px; height: 36px; + display: flex; align-items: center; justify-content: center; + background: var(--surface2); border: 1px solid var(--border); + border-radius: 10px; font-size: 22px; color: var(--text2); + cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; +} +.mini-arrow:active { background: var(--accent); color: #fff; border-color: var(--accent); } +.mini-add-btn { + width: 36px; height: 36px; + display: flex; align-items: center; justify-content: center; + background: var(--accent); border: none; + border-radius: 10px; color: #fff; + cursor: pointer; flex-shrink: 0; + margin-left: 4px; + -webkit-tap-highlight-color: transparent; +} +.mini-add-btn:active { opacity: 0.8; transform: scale(0.94); } + +/* ── Route onboarding mini-bar ───────────────── */ +#mini-onboard-pin svg { + width: 22px; + height: 28px; +} + +@media (min-width: 768px) { + #sheet-route-mini { left: 72px; width: 380px; right: auto; border-radius: 0 14px 0 0; } +} + +/* ── Route Loading Spinner ───────────────────── */ +.route-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 16px; +} +.route-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* ── Moto Wheel Loading Indicator ────────────── */ +.moto-wheel { + width: 32px; height: 32px; + flex-shrink: 0; + display: none; + transform-origin: center; +} +.moto-wheel.spinning { + display: block; + animation: wheelSpin 0.8s linear infinite; +} +@keyframes wheelSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..27975da --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,2790 @@ +// ═══════════════════════════════════════════════════════════════════ +// Enduro Trails - Phase 5 Redesign +// Theme system (auto/light/dark + SunCalc), skeleton, swipe, animations +// ═══════════════════════════════════════════════════════════════════ + +// ─── Theme System ────────────────────────────────────────────────── +let themeMode = localStorage.getItem('enduro-theme-mode') || 'auto'; // 'auto' | 'light' | 'dark' +let userLat = null; +let userLon = null; +let themeAutoInterval = null; + +function isDarkTheme() { + return document.body.classList.contains('theme-dark'); +} + +function applyTheme() { + if (themeMode === 'light') { + document.body.className = 'theme-light'; + } else if (themeMode === 'dark') { + document.body.className = 'theme-dark'; + } else { + // auto: use SunCalc + applyAutoTheme(); + } + updateThemeButtonIcon(); + switchMapStyle(); +} + +function applyAutoTheme() { + if (themeMode !== 'auto') return; + const now = new Date(); + const lat = userLat || 55.75; + const lon = userLon || 37.62; + let isDay = true; + try { + if (typeof SunCalc !== 'undefined') { + const times = SunCalc.getTimes(now, lat, lon); + isDay = now >= times.sunrise && now < times.sunset; + } else { + // Fallback: assume day if 6am-8pm + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + } catch(e) { + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + document.body.className = isDay ? 'theme-light' : 'theme-dark'; + updateThemeButtonIcon(); +} + +function toggleTheme() { + // Cycle: auto → light → dark → auto + if (themeMode === 'auto') themeMode = 'light'; + else if (themeMode === 'light') themeMode = 'dark'; + else themeMode = 'auto'; + + localStorage.setItem('enduro-theme-mode', themeMode); + applyTheme(); +} + +function updateThemeButtonIcon() { + const sunIcon = document.getElementById('theme-icon-sun'); + const moonIcon = document.getElementById('theme-icon-moon'); + const label = document.getElementById('theme-label'); + if (!sunIcon || !moonIcon) return; + + const dark = isDarkTheme(); + + if (themeMode === 'auto') { + // Dynamic icon based on actual theme + sunIcon.style.display = dark ? 'none' : 'block'; + moonIcon.style.display = dark ? 'block' : 'none'; + if (label) label.textContent = 'Авто'; + } else if (themeMode === 'light') { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + if (label) label.textContent = 'День'; + } else { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + if (label) label.textContent = 'Ночь'; + } +} + +function switchMapStyle() { + const map = window._map; + if (!map) return; + const dark = isDarkTheme(); + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const styleUrl = dark ? basePath + '/style-dark.json' : basePath + '/style.json'; + + // Save current position before style change + const center = map.getCenter(); + const zoom = map.getZoom(); + const bearing = map.getBearing(); + const pitch = map.getPitch(); + + fetch(styleUrl, { method: 'HEAD' }).then(r => { + if (r.ok) { + map.setStyle(styleUrl); + // Restore position after style loads + map.once('style.load', () => { + map.jumpTo({ center, zoom, bearing, pitch }); + }); + } else { + console.log('Map style not available:', styleUrl); + } + }).catch(() => { + // Network error, don't switch + }); +} + +// Re-add layers after style change +function onMapStyleLoad() { + const map = window._map; + if (!map) return; + // Re-add any active route layers, markers, etc. + rebuildMapOverlays(); +} + +function rebuildMapOverlays() { + // Re-apply recon circle if active + if (reconMode && reconCenter) { + doRecon(reconCenter[0], reconCenter[1]); + } + // Re-draw route if active + if (routeMode && routeResults.length > 0) { + const savedResults = [...routeResults]; + const savedIdx = activeRouteIdx; + routeResults = []; + drawRouteResults(savedResults, savedIdx); + } + // Re-draw scenic routes + if (scenicMode && scenicRoutes.length > 0) { + const savedRoutes = [...scenicRoutes]; + scenicRoutes = []; + drawScenicRoutes(savedRoutes, activeScenicIdx); + } + // Re-draw link routes + if (linkMode && linkPoints.length >= 2) { + buildLinkRoute(); + } + // Re-draw ruler + if (rulerMode && rulerPoints.length > 0) { + const pts = [...rulerPoints]; + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + if (map.getSource('ruler')) map.removeSource('ruler'); + if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); + } + // Re-render named markers + renderMarkers(); +} + +// ─── Utilities ────────────────────────────────────────────────────── + +function formatDuration(seconds) { + const totalMin = Math.round(seconds / 60); + if (totalMin < 60) return totalMin + ' мин'; + const days = Math.floor(totalMin / 1440); + const hours = Math.floor((totalMin % 1440) / 60); + const mins = totalMin % 60; + if (days > 0) { + if (hours === 0 && mins === 0) return `${days} дн`; + if (mins === 0) return `${days} дн ${hours} ч`; + return `${days} дн ${hours} ч ${mins} мин`; + } + if (mins === 0) return `${hours} ч`; + return `${hours} ч ${mins} мин`; +} + +function formatDist(m) { + if (!m) return '-'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; +} + +// ─── Sheet Management ────────────────────────────────────────────── + +function openSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + // Close all other sheets first + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + if (s.id !== id) closeSheet(s.id); + }); + sheet.classList.add('open'); + const backdrop = document.getElementById('sheet-backdrop'); + backdrop.classList.add('visible'); +} + +function closeSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + sheet.classList.remove('open'); + sheet.style.transform = ''; + // Check if any sheets still open + const anyOpen = document.querySelector('.bottom-sheet.open'); + if (!anyOpen) { + document.getElementById('sheet-backdrop').classList.remove('visible'); + } +} + +// Close sheet panel but keep the mode active (route stays on map) +function minimizeSheet(id) { + closeSheet(id); + if (id === 'sheet-route' && routeResults.length > 0) { + showMiniRouteSheet(); + } +} + +function closeAllSheets() { + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + s.classList.remove('open'); + s.style.transform = ''; + }); + document.getElementById('sheet-backdrop').classList.remove('visible'); +} + +// ─── Swipe-down to close sheets ──────────────────────────────────── + +function initSheetSwipe() { + document.querySelectorAll('.bottom-sheet').forEach(sheet => { + let startY = 0; + let isDragging = false; + + sheet.addEventListener('touchstart', (e) => { + const rect = sheet.getBoundingClientRect(); + const touchY = e.touches[0].clientY; + // Only initiate swipe from the handle area (top 50px of sheet) + if (touchY < rect.top + 50 || e.target.closest('.sheet-handle')) { + isDragging = true; + startY = touchY; + sheet.classList.add('swiping'); + } + }, { passive: true }); + + sheet.addEventListener('touchmove', (e) => { + if (!isDragging) return; + const dy = e.touches[0].clientY - startY; + if (dy > 0) { + sheet.style.transform = `translateY(${dy}px)`; + } + }, { passive: true }); + + sheet.addEventListener('touchend', (e) => { + if (!isDragging) return; + isDragging = false; + sheet.classList.remove('swiping'); + const dy = e.changedTouches[0].clientY - startY; + if (dy > 80) { + const sheetId = sheet.id; + if (sheetId === 'sheet-route' && routeResults && routeResults.length > 0) { + minimizeSheet(sheetId); + } else { + closeSheet(sheetId); + // Deactivate corresponding mode + if (sheetId === 'sheet-route' && routeMode) toggleRouteMode(); + else if (sheetId === 'sheet-recon' && reconMode) toggleReconMode(); + else if (sheetId === 'sheet-scenic' && scenicMode) toggleScenicMode(); + else if (sheetId === 'sheet-link' && linkMode) toggleLinkMode(); + } + sheet.style.transform = ''; + } else { + sheet.style.transform = ''; + } + }, { passive: true }); + }); +} + +// ─── Skeleton Loading ────────────────────────────────────────────── + +function showSkeleton(containerId, count) { + const container = document.getElementById(containerId); + if (!container) return; + count = count || 2; + let html = ''; + for (let i = 0; i < count; i++) { + html += `
Тапни точку на карте — узнаешь сколько грунтовок рядом
+Соедини два трека — найду оптимальную грунтовую связку
+